| author | |
| committer | |
| log | 7a5ea7a5d8d2a779cafb6413306eed554977825c |
| tree | 598e736705c387165b9252dc03cd8d4e7342a042 |
| parent | c71e71b5acacb74e201b3f4692ef08ed1dce3a28 |
27 files changed, 1204 insertions(+), 1135 deletions(-)
apps/server/src/index.ts-3| ... | @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; | ... | @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; |
| 6 | 6 | ||
| 7 | import { fixPath } from "./fixPath"; | 7 | import { fixPath } from "./fixPath"; |
| 8 | import { createLogger } from "./logger"; | 8 | import { createLogger } from "./logger"; |
| 9 | import { ProjectRegistry } from "./projectRegistry"; | ||
| 10 | import { createServer } from "./wsServer"; | 9 | import { createServer } from "./wsServer"; |
| 11 | 10 | ||
| 12 | fixPath(); | 11 | fixPath(); |
| ... | @@ -102,7 +101,6 @@ async function main() { | ... | @@ -102,7 +101,6 @@ async function main() { |
| 102 | const port = | 101 | const port = |
| 103 | requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); | 102 | requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); |
| 104 | const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); | 103 | const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); |
| 105 | const projectRegistry = new ProjectRegistry(stateDir); | ||
| 106 | const devUrl = process.env.VITE_DEV_SERVER_URL; | 104 | const devUrl = process.env.VITE_DEV_SERVER_URL; |
| 107 | const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; | 105 | const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; |
| 108 | const authToken = process.env.T3CODE_AUTH_TOKEN; | 106 | const authToken = process.env.T3CODE_AUTH_TOKEN; |
| ... | @@ -121,7 +119,6 @@ async function main() { | ... | @@ -121,7 +119,6 @@ async function main() { |
| 121 | stateDir, | 119 | stateDir, |
| 122 | staticDir, | 120 | staticDir, |
| 123 | devUrl, | 121 | devUrl, |
| 124 | projectRegistry, | ||
| 125 | authToken, | 122 | authToken, |
| 126 | }); | 123 | }); |
| 127 | await server.start(); | 124 | await server.start(); |
apps/server/src/orchestration/engine.test.ts+53-53| ... | @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest"; | ... | @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 8 | 8 | ||
| 9 | import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | 9 | import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; |
| 10 | import { OrchestrationEngine } from "./engine"; | 10 | import { OrchestrationEngine } from "./engine"; |
| 11 | import { createOrchestrationSystem } from "./runtime"; | 11 | |
| 12 | import { Layer, ManagedRuntime } from "effect"; | ||
| 13 | |||
| 14 | import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; | ||
| 15 | |||
| 16 | import { OrchestrationLive } from "./layers"; | ||
| 17 | import { OrchestrationEngineService } from "./services"; | ||
| 12 | 18 | ||
| 13 | const tempDirs: string[] = []; | 19 | const tempDirs: string[] = []; |
| 14 | 20 | ||
| ... | @@ -24,6 +30,19 @@ afterEach(() => { | ... | @@ -24,6 +30,19 @@ afterEach(() => { |
| 24 | } | 30 | } |
| 25 | }); | 31 | }); |
| 26 | 32 | ||
| 33 | export async function createOrchestrationSystem(stateDir: string) { | ||
| 34 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 35 | const orchestrationLayer = OrchestrationLive.pipe( | ||
| 36 | Layer.provide(makeSqlitePersistenceLive(dbPath)), | ||
| 37 | ); | ||
| 38 | const runtime = ManagedRuntime.make(orchestrationLayer); | ||
| 39 | const engine = await runtime.runPromise(OrchestrationEngineService); | ||
| 40 | return { | ||
| 41 | engine, | ||
| 42 | dispose: () => runtime.dispose(), | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 27 | describe("OrchestrationEngine", () => { | 46 | describe("OrchestrationEngine", () => { |
| 28 | it("replays to the same deterministic snapshot", async () => { | 47 | it("replays to the same deterministic snapshot", async () => { |
| 29 | const stateDir = makeTempDir("t3code-orchestration-"); | 48 | const stateDir = makeTempDir("t3code-orchestration-"); |
| ... | @@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => { | ... | @@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => { |
| 33 | 52 | ||
| 34 | const firstSystem = await createOrchestrationSystem(stateDir); | 53 | const firstSystem = await createOrchestrationSystem(stateDir); |
| 35 | const engineA = firstSystem.engine; | 54 | const engineA = firstSystem.engine; |
| 36 | await engineA.dispatch({ | ||
| 37 | type: "project.create", | ||
| 38 | commandId: "cmd-1", | ||
| 39 | projectId, | ||
| 40 | name: "demo", | ||
| 41 | cwd: "/tmp/demo", | ||
| 42 | model: "gpt-5-codex", | ||
| 43 | createdAt, | ||
| 44 | }); | ||
| 45 | await engineA.dispatch({ | 55 | await engineA.dispatch({ |
| 46 | type: "thread.create", | 56 | type: "thread.create", |
| 47 | commandId: "cmd-2", | 57 | commandId: "cmd-1", |
| 48 | threadId, | 58 | threadId, |
| 49 | projectId, | 59 | projectId, |
| 50 | title: "Thread", | 60 | title: "Thread", |
| ... | @@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => { | ... | @@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => { |
| 82 | updates.push(snapshot.sequence); | 92 | updates.push(snapshot.sequence); |
| 83 | }); | 93 | }); |
| 84 | await engine.dispatch({ | 94 | await engine.dispatch({ |
| 85 | type: "project.create", | 95 | type: "thread.create", |
| 86 | commandId: "cmd-project", | 96 | commandId: "cmd-thread", |
| 97 | threadId: "thread-2", | ||
| 87 | projectId: "project-2", | 98 | projectId: "project-2", |
| 88 | name: "fanout", | 99 | title: "fanout", |
| 89 | cwd: "/tmp/fanout", | ||
| 90 | model: "gpt-5-codex", | 100 | model: "gpt-5-codex", |
| 101 | branch: null, | ||
| 102 | worktreePath: null, | ||
| 91 | createdAt: new Date().toISOString(), | 103 | createdAt: new Date().toISOString(), |
| 92 | }); | 104 | }); |
| 93 | unsubscribe(); | 105 | unsubscribe(); |
| ... | @@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => { | ... | @@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => { |
| 100 | const system = await createOrchestrationSystem(stateDir); | 112 | const system = await createOrchestrationSystem(stateDir); |
| 101 | const engine = system.engine; | 113 | const engine = system.engine; |
| 102 | await engine.dispatch({ | 114 | await engine.dispatch({ |
| 103 | type: "project.create", | 115 | type: "thread.create", |
| 104 | commandId: "cmd-a", | 116 | commandId: "cmd-a", |
| 117 | threadId: "thread-replay", | ||
| 105 | projectId: "project-replay", | 118 | projectId: "project-replay", |
| 106 | name: "replay", | 119 | title: "replay", |
| 107 | cwd: "/tmp/replay", | ||
| 108 | model: "gpt-5-codex", | 120 | model: "gpt-5-codex", |
| 121 | branch: null, | ||
| 122 | worktreePath: null, | ||
| 109 | createdAt: new Date().toISOString(), | 123 | createdAt: new Date().toISOString(), |
| 110 | }); | 124 | }); |
| 111 | await engine.dispatch({ | 125 | await engine.dispatch({ |
| 112 | type: "project.delete", | 126 | type: "thread.delete", |
| 113 | commandId: "cmd-b", | 127 | commandId: "cmd-b", |
| 114 | projectId: "project-replay", | 128 | threadId: "thread-replay", |
| 115 | createdAt: new Date().toISOString(), | 129 | createdAt: new Date().toISOString(), |
| 116 | }); | 130 | }); |
| 117 | const events = await engine.replayEvents(0); | 131 | const events = await engine.replayEvents(0); |
| 118 | expect(events.length).toBe(2); | 132 | expect(events.length).toBe(2); |
| 119 | expect(events[0]?.type).toBe("project.created"); | 133 | expect(events[0]?.type).toBe("thread.created"); |
| 120 | expect(events[1]?.type).toBe("project.deleted"); | 134 | expect(events[1]?.type).toBe("thread.deleted"); |
| 121 | await system.dispose(); | 135 | await system.dispose(); |
| 122 | }); | 136 | }); |
| 123 | 137 | ||
| ... | @@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => { | ... | @@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => { |
| 126 | const firstSystem = await createOrchestrationSystem(stateDir); | 140 | const firstSystem = await createOrchestrationSystem(stateDir); |
| 127 | const engine = firstSystem.engine; | 141 | const engine = firstSystem.engine; |
| 128 | const completedAt = new Date().toISOString(); | 142 | const completedAt = new Date().toISOString(); |
| 129 | await engine.dispatch({ | ||
| 130 | type: "project.create", | ||
| 131 | commandId: "cmd-project-turn-diff", | ||
| 132 | projectId: "project-turn-diff", | ||
| 133 | name: "turn-diff", | ||
| 134 | cwd: "/tmp/turn-diff", | ||
| 135 | model: "gpt-5-codex", | ||
| 136 | createdAt: completedAt, | ||
| 137 | }); | ||
| 138 | await engine.dispatch({ | 143 | await engine.dispatch({ |
| 139 | type: "thread.create", | 144 | type: "thread.create", |
| 140 | commandId: "cmd-thread-turn-diff", | 145 | commandId: "cmd-thread-turn-diff", |
| ... | @@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => { | ... | @@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => { |
| 158 | createdAt: completedAt, | 163 | createdAt: completedAt, |
| 159 | }); | 164 | }); |
| 160 | 165 | ||
| 161 | const firstThread = engine.getSnapshot().threads.find((thread) => thread.id === "thread-turn-diff"); | 166 | const firstThread = engine |
| 167 | .getSnapshot() | ||
| 168 | .threads.find((thread) => thread.id === "thread-turn-diff"); | ||
| 162 | expect(firstThread?.turnDiffSummaries).toEqual([ | 169 | expect(firstThread?.turnDiffSummaries).toEqual([ |
| 163 | { | 170 | { |
| 164 | turnId: "turn-1", | 171 | turnId: "turn-1", |
| ... | @@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => { | ... | @@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => { |
| 192 | const firstSystem = await createOrchestrationSystem(stateDir); | 199 | const firstSystem = await createOrchestrationSystem(stateDir); |
| 193 | const engine = firstSystem.engine; | 200 | const engine = firstSystem.engine; |
| 194 | const createdAt = new Date().toISOString(); | 201 | const createdAt = new Date().toISOString(); |
| 195 | await engine.dispatch({ | ||
| 196 | type: "project.create", | ||
| 197 | commandId: "cmd-project-revert", | ||
| 198 | projectId: "project-revert", | ||
| 199 | name: "revert", | ||
| 200 | cwd: "/tmp/revert", | ||
| 201 | model: "gpt-5-codex", | ||
| 202 | createdAt, | ||
| 203 | }); | ||
| 204 | await engine.dispatch({ | 202 | await engine.dispatch({ |
| 205 | type: "thread.create", | 203 | type: "thread.create", |
| 206 | commandId: "cmd-thread-revert", | 204 | commandId: "cmd-thread-revert", |
| ... | @@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => { |
| 334 | append(event) { | 332 | append(event) { |
| 335 | if (shouldFailFirstAppend) { | 333 | if (shouldFailFirstAppend) { |
| 336 | shouldFailFirstAppend = false; | 334 | shouldFailFirstAppend = false; |
| 337 | return Effect.sync(() => { | 335 | return Effect.die(new Error("append failed")); |
| 338 | throw new Error("append failed"); | ||
| 339 | }); | ||
| 340 | } | 336 | } |
| 341 | const savedEvent = { | 337 | const savedEvent = { |
| 342 | ...event, | 338 | ...event, |
| ... | @@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => { | ... | @@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => { |
| 360 | 356 | ||
| 361 | await expect( | 357 | await expect( |
| 362 | engine.dispatch({ | 358 | engine.dispatch({ |
| 363 | type: "project.create", | 359 | type: "thread.create", |
| 364 | commandId: "cmd-flaky-1", | 360 | commandId: "cmd-flaky-1", |
| 365 | projectId: "project-flaky-fail", | 361 | threadId: "thread-flaky-fail", |
| 366 | name: "flaky-fail", | 362 | projectId: "project-flaky", |
| 367 | cwd: "/tmp/flaky-fail", | 363 | title: "flaky-fail", |
| 368 | model: "gpt-5-codex", | 364 | model: "gpt-5-codex", |
| 365 | branch: null, | ||
| 366 | worktreePath: null, | ||
| 369 | createdAt, | 367 | createdAt, |
| 370 | }), | 368 | }), |
| 371 | ).rejects.toThrow("append failed"); | 369 | ).rejects.toThrow("append failed"); |
| 372 | 370 | ||
| 373 | const result = await engine.dispatch({ | 371 | const result = await engine.dispatch({ |
| 374 | type: "project.create", | 372 | type: "thread.create", |
| 375 | commandId: "cmd-flaky-2", | 373 | commandId: "cmd-flaky-2", |
| 376 | projectId: "project-flaky-ok", | 374 | threadId: "thread-flaky-ok", |
| 377 | name: "flaky-ok", | 375 | projectId: "project-flaky", |
| 378 | cwd: "/tmp/flaky-ok", | 376 | title: "flaky-ok", |
| 379 | model: "gpt-5-codex", | 377 | model: "gpt-5-codex", |
| 378 | branch: null, | ||
| 379 | worktreePath: null, | ||
| 380 | createdAt, | 380 | createdAt, |
| 381 | }); | 381 | }); |
| 382 | 382 | ||
| 383 | expect(result.sequence).toBe(1); | 383 | expect(result.sequence).toBe(1); |
| 384 | expect(engine.getSnapshot().projects.map((project) => project.id)).toEqual(["project-flaky-ok"]); | 384 | expect(engine.getSnapshot().sequence).toBe(1); |
| 385 | 385 | ||
| 386 | await engine.stop(); | 386 | await engine.stop(); |
| 387 | }); | 387 | }); |
apps/server/src/orchestration/engine.ts+11-48| ... | @@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error { | ... | @@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error { |
| 34 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { | 34 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { |
| 35 | const eventId = crypto.randomUUID(); | 35 | const eventId = crypto.randomUUID(); |
| 36 | switch (command.type) { | 36 | switch (command.type) { |
| 37 | case "project.create": | ||
| 38 | return { | ||
| 39 | eventId, | ||
| 40 | type: "project.created", | ||
| 41 | aggregateType: "project", | ||
| 42 | aggregateId: command.projectId, | ||
| 43 | occurredAt: command.createdAt, | ||
| 44 | commandId: command.commandId, | ||
| 45 | payload: { | ||
| 46 | id: command.projectId, | ||
| 47 | name: command.name, | ||
| 48 | cwd: command.cwd, | ||
| 49 | model: command.model, | ||
| 50 | createdAt: command.createdAt, | ||
| 51 | updatedAt: command.createdAt, | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | case "project.delete": | ||
| 55 | return { | ||
| 56 | eventId, | ||
| 57 | type: "project.deleted", | ||
| 58 | aggregateType: "project", | ||
| 59 | aggregateId: command.projectId, | ||
| 60 | occurredAt: command.createdAt, | ||
| 61 | commandId: command.commandId, | ||
| 62 | payload: { | ||
| 63 | id: command.projectId, | ||
| 64 | deletedAt: command.createdAt, | ||
| 65 | }, | ||
| 66 | }; | ||
| 67 | case "thread.create": | 37 | case "thread.create": |
| 68 | return { | 38 | return { |
| 69 | eventId, | 39 | eventId, |
| ... | @@ -226,10 +196,7 @@ export class OrchestrationEngine { | ... | @@ -226,10 +196,7 @@ export class OrchestrationEngine { |
| 226 | return Effect.gen(this, function* () { | 196 | return Effect.gen(this, function* () { |
| 227 | const eventBase = mapCommandToEvent(envelope.command); | 197 | const eventBase = mapCommandToEvent(envelope.command); |
| 228 | const savedEvent = yield* this.eventStore.append(eventBase); | 198 | const savedEvent = yield* this.eventStore.append(eventBase); |
| 229 | 199 | this.readModel = yield* reduceEvent(this.readModel, savedEvent); | |
| 230 | yield* Effect.sync(() => { | ||
| 231 | this.readModel = reduceEvent(this.readModel, savedEvent); | ||
| 232 | }); | ||
| 233 | 200 | ||
| 234 | const snapshot = this.readModel; | 201 | const snapshot = this.readModel; |
| 235 | yield* Effect.all([ | 202 | yield* Effect.all([ |
| ... | @@ -237,14 +204,12 @@ export class OrchestrationEngine { | ... | @@ -237,14 +204,12 @@ export class OrchestrationEngine { |
| 237 | PubSub.publish(this.readModelPubSub, snapshot), | 204 | PubSub.publish(this.readModelPubSub, snapshot), |
| 238 | ]); | 205 | ]); |
| 239 | 206 | ||
| 240 | yield* Effect.sync(() => { | 207 | for (const listener of this.domainEventListeners) { |
| 241 | for (const listener of this.domainEventListeners) { | 208 | listener(savedEvent); |
| 242 | listener(savedEvent); | 209 | } |
| 243 | } | 210 | for (const listener of this.readModelListeners) { |
| 244 | for (const listener of this.readModelListeners) { | 211 | listener(snapshot); |
| 245 | listener(snapshot); | 212 | } |
| 246 | } | ||
| 247 | }); | ||
| 248 | 213 | ||
| 249 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | 214 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); |
| 250 | }).pipe( | 215 | }).pipe( |
| ... | @@ -257,14 +222,12 @@ export class OrchestrationEngine { | ... | @@ -257,14 +222,12 @@ export class OrchestrationEngine { |
| 257 | ); | 222 | ); |
| 258 | } | 223 | } |
| 259 | 224 | ||
| 260 | private bootstrapReadModel(): Effect.Effect<void> { | 225 | private bootstrapReadModel(): Effect.Effect<void, unknown> { |
| 261 | return Effect.gen(this, function* () { | 226 | return Effect.gen(this, function* () { |
| 262 | const existingEvents = yield* this.eventStore.readAll(); | 227 | const existingEvents = yield* this.eventStore.readAll(); |
| 263 | yield* Effect.sync(() => { | 228 | for (const event of existingEvents) { |
| 264 | for (const event of existingEvents) { | 229 | this.readModel = yield* reduceEvent(this.readModel, event); |
| 265 | this.readModel = reduceEvent(this.readModel, event); | 230 | } |
| 266 | } | ||
| 267 | }); | ||
| 268 | }); | 231 | }); |
| 269 | } | 232 | } |
| 270 | 233 |
apps/server/src/orchestration/layers.test.ts deleted-47| ... | @@ -1,47 +0,0 @@ | ||
| 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 { createOrchestrationSystem } 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 firstSystem = await createOrchestrationSystem(stateDir); | ||
| 28 | const { engine } = firstSystem; | ||
| 29 | |||
| 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 firstSystem.dispose(); | ||
| 41 | |||
| 42 | const secondSystem = await createOrchestrationSystem(stateDir); | ||
| 43 | const restarted = secondSystem.engine; | ||
| 44 | expect(restarted.getSnapshot()).toEqual(firstSnapshot); | ||
| 45 | await secondSystem.dispose(); | ||
| 46 | }); | ||
| 47 | }); | ||
apps/server/src/orchestration/layers.ts+2-11| ... | @@ -1,18 +1,9 @@ | ... | @@ -1,18 +1,9 @@ |
| 1 | import path from "node:path"; | ||
| 2 | |||
| 3 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 4 | 2 | ||
| 5 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; | 3 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; |
| 6 | import { makeSqliteOrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; | 4 | import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; |
| 7 | import { OrchestrationEngine } from "./engine"; | 5 | import { OrchestrationEngine } from "./engine"; |
| 8 | import { OrchestrationConfig, OrchestrationEngineService } from "./services"; | 6 | import { OrchestrationEngineService } from "./services"; |
| 9 | |||
| 10 | export const OrchestrationEventRepositoryLive = Layer.unwrapEffect( | ||
| 11 | Effect.map(OrchestrationConfig, ({ stateDir }) => { | ||
| 12 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 13 | return makeSqliteOrchestrationEventRepositoryLive(dbPath); | ||
| 14 | }), | ||
| 15 | ); | ||
| 16 | 7 | ||
| 17 | export const OrchestrationEngineLive = Layer.scoped( | 8 | export const OrchestrationEngineLive = Layer.scoped( |
| 18 | OrchestrationEngineService, | 9 | OrchestrationEngineService, |
apps/server/src/orchestration/reducer.test.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { createEmptyReadModel, reduceEvent } from "./reducer"; | ||
| 6 | |||
| 7 | describe("orchestration reducer", () => { | ||
| 8 | it("applies thread.created events using runtime schema decoding", async () => { | ||
| 9 | const now = new Date().toISOString(); | ||
| 10 | const model = createEmptyReadModel(now); | ||
| 11 | const event: OrchestrationEvent = { | ||
| 12 | sequence: 1, | ||
| 13 | eventId: "event-thread-created", | ||
| 14 | type: "thread.created", | ||
| 15 | aggregateType: "thread", | ||
| 16 | aggregateId: "thread-1", | ||
| 17 | occurredAt: now, | ||
| 18 | commandId: "cmd-1", | ||
| 19 | payload: { | ||
| 20 | id: "thread-1", | ||
| 21 | projectId: "project-1", | ||
| 22 | title: "demo", | ||
| 23 | model: "gpt-5-codex", | ||
| 24 | branch: null, | ||
| 25 | worktreePath: null, | ||
| 26 | createdAt: now, | ||
| 27 | updatedAt: now, | ||
| 28 | }, | ||
| 29 | }; | ||
| 30 | |||
| 31 | const next = await Effect.runPromise(reduceEvent(model, event)); | ||
| 32 | expect(next.sequence).toBe(1); | ||
| 33 | expect(next.threads).toEqual([ | ||
| 34 | { | ||
| 35 | id: "thread-1", | ||
| 36 | projectId: "project-1", | ||
| 37 | title: "demo", | ||
| 38 | model: "gpt-5-codex", | ||
| 39 | branch: null, | ||
| 40 | worktreePath: null, | ||
| 41 | createdAt: now, | ||
| 42 | updatedAt: now, | ||
| 43 | latestTurnId: null, | ||
| 44 | latestTurnStartedAt: null, | ||
| 45 | latestTurnCompletedAt: null, | ||
| 46 | latestTurnDurationMs: null, | ||
| 47 | messages: [], | ||
| 48 | session: null, | ||
| 49 | turnDiffSummaries: [], | ||
| 50 | error: null, | ||
| 51 | }, | ||
| 52 | ]); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("fails when event payload cannot be decoded by runtime schema", async () => { | ||
| 56 | const now = new Date().toISOString(); | ||
| 57 | const model = createEmptyReadModel(now); | ||
| 58 | const event: OrchestrationEvent = { | ||
| 59 | sequence: 1, | ||
| 60 | eventId: "event-message-invalid", | ||
| 61 | type: "message.sent", | ||
| 62 | aggregateType: "thread", | ||
| 63 | aggregateId: "thread-1", | ||
| 64 | occurredAt: now, | ||
| 65 | commandId: "cmd-1", | ||
| 66 | payload: { | ||
| 67 | id: "message-1", | ||
| 68 | threadId: "thread-1", | ||
| 69 | text: "hello", | ||
| 70 | createdAt: now, | ||
| 71 | streaming: false, | ||
| 72 | }, | ||
| 73 | }; | ||
| 74 | |||
| 75 | await expect(Effect.runPromise(reduceEvent(model, event))).rejects.toBeDefined(); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("keeps reducer forward-compatible for unknown event types", async () => { | ||
| 79 | const now = new Date().toISOString(); | ||
| 80 | const model = createEmptyReadModel(now); | ||
| 81 | const event: OrchestrationEvent = { | ||
| 82 | sequence: 7, | ||
| 83 | eventId: "event-unknown", | ||
| 84 | type: "custom.event.ignored", | ||
| 85 | aggregateType: "unknown", | ||
| 86 | aggregateId: "aggregate-1", | ||
| 87 | occurredAt: "2026-01-01T00:00:00.000Z", | ||
| 88 | commandId: null, | ||
| 89 | payload: { test: true }, | ||
| 90 | }; | ||
| 91 | |||
| 92 | const next = await Effect.runPromise(reduceEvent(model, event)); | ||
| 93 | expect(next.sequence).toBe(7); | ||
| 94 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); | ||
| 95 | expect(next.threads).toEqual([]); | ||
| 96 | }); | ||
| 97 | }); | ||
apps/server/src/orchestration/reducer.ts+265-261| ... | @@ -2,11 +2,20 @@ import type { | ... | @@ -2,11 +2,20 @@ import type { |
| 2 | OrchestrationEvent, | 2 | OrchestrationEvent, |
| 3 | OrchestrationGitReadModel, | 3 | OrchestrationGitReadModel, |
| 4 | OrchestrationMessage, | 4 | OrchestrationMessage, |
| 5 | OrchestrationProject, | ||
| 6 | OrchestrationReadModel, | 5 | OrchestrationReadModel, |
| 7 | OrchestrationSession, | 6 | OrchestrationSession, |
| 8 | OrchestrationThread, | 7 | OrchestrationThread, |
| 9 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { | ||
| 10 | OrchestrationGitReadModelSchema, | ||
| 11 | OrchestrationMessageSchema, | ||
| 12 | OrchestrationReadModelSchema, | ||
| 13 | OrchestrationSessionSchema, | ||
| 14 | OrchestrationThreadSchema, | ||
| 15 | OrchestrationTurnDiffFileSchema, | ||
| 16 | OrchestrationTurnDiffSummarySchema, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import { Effect, Schema } from "effect"; | ||
| 10 | 19 | ||
| 11 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | 20 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 12 | 21 | ||
| ... | @@ -18,38 +27,84 @@ function updateThread( | ... | @@ -18,38 +27,84 @@ function updateThread( |
| 18 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); | 27 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); |
| 19 | } | 28 | } |
| 20 | 29 | ||
| 21 | function asObject(value: unknown): Record<string, unknown> | null { | 30 | const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema); |
| 22 | if (!value || typeof value !== "object") return null; | 31 | const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema); |
| 23 | return value as Record<string, unknown>; | 32 | const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema); |
| 24 | } | 33 | const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema); |
| 34 | const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema); | ||
| 35 | const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema); | ||
| 25 | 36 | ||
| 26 | function asString(value: unknown): string | null { | 37 | const ThreadCreatedPayloadSchema = Schema.Struct({ |
| 27 | return typeof value === "string" ? value : null; | 38 | id: Schema.String, |
| 28 | } | 39 | projectId: Schema.String, |
| 40 | title: Schema.String, | ||
| 41 | model: Schema.String, | ||
| 42 | branch: Schema.NullOr(Schema.String), | ||
| 43 | worktreePath: Schema.NullOr(Schema.String), | ||
| 44 | createdAt: Schema.String, | ||
| 45 | updatedAt: Schema.String, | ||
| 46 | }); | ||
| 47 | const decodeThreadCreatedPayload = Schema.decodeUnknown(ThreadCreatedPayloadSchema); | ||
| 29 | 48 | ||
| 30 | function asNumber(value: unknown): number | null { | 49 | const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ |
| 31 | return typeof value === "number" && Number.isFinite(value) ? value : null; | 50 | threadId: Schema.String, |
| 32 | } | 51 | title: Schema.optional(Schema.String), |
| 52 | model: Schema.optional(Schema.String), | ||
| 53 | branch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 54 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 55 | updatedAt: Schema.String, | ||
| 56 | }); | ||
| 57 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknown(ThreadMetaUpdatedPayloadSchema); | ||
| 33 | 58 | ||
| 34 | function asArray(value: unknown): unknown[] { | 59 | const MessageSentPayloadSchema = Schema.Struct({ |
| 35 | return Array.isArray(value) ? value : []; | 60 | id: Schema.String, |
| 36 | } | 61 | role: Schema.Literal("user", "assistant"), |
| 62 | text: Schema.String, | ||
| 63 | threadId: Schema.String, | ||
| 64 | createdAt: Schema.String, | ||
| 65 | streaming: Schema.Boolean, | ||
| 66 | }); | ||
| 67 | const decodeMessageSentPayload = Schema.decodeUnknown(MessageSentPayloadSchema); | ||
| 68 | |||
| 69 | const ThreadSessionSetPayloadSchema = Schema.Struct({ | ||
| 70 | threadId: Schema.String, | ||
| 71 | session: OrchestrationSessionSchema, | ||
| 72 | }); | ||
| 73 | const decodeThreadSessionSetPayload = Schema.decodeUnknown(ThreadSessionSetPayloadSchema); | ||
| 74 | |||
| 75 | const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ | ||
| 76 | threadId: Schema.String, | ||
| 77 | turnId: Schema.String, | ||
| 78 | completedAt: Schema.String, | ||
| 79 | status: Schema.optional(Schema.String), | ||
| 80 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | ||
| 81 | assistantMessageId: Schema.optional(Schema.String), | ||
| 82 | checkpointTurnCount: Schema.optional(Schema.Number), | ||
| 83 | }); | ||
| 84 | const decodeThreadTurnDiffCompletedPayload = Schema.decodeUnknown( | ||
| 85 | ThreadTurnDiffCompletedPayloadSchema, | ||
| 86 | ); | ||
| 87 | |||
| 88 | const ThreadRevertedPayloadSchema = Schema.Struct({ | ||
| 89 | threadId: Schema.String, | ||
| 90 | turnCount: Schema.Number, | ||
| 91 | messageCount: Schema.Number, | ||
| 92 | }); | ||
| 93 | const decodeThreadRevertedPayload = Schema.decodeUnknown(ThreadRevertedPayloadSchema); | ||
| 37 | 94 | ||
| 38 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | 95 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { |
| 39 | return { | 96 | return decodeReadModelSync({ |
| 40 | sequence: 0, | 97 | sequence: 0, |
| 41 | projects: [], | ||
| 42 | threads: [], | 98 | threads: [], |
| 43 | gitByProjectId: {}, | 99 | gitByProjectId: {}, |
| 44 | updatedAt: nowIso, | 100 | updatedAt: nowIso, |
| 45 | }; | 101 | }); |
| 46 | } | 102 | } |
| 47 | 103 | ||
| 48 | export function reduceEvent( | 104 | export function reduceEvent( |
| 49 | model: OrchestrationReadModel, | 105 | model: OrchestrationReadModel, |
| 50 | event: OrchestrationEvent, | 106 | event: OrchestrationEvent, |
| 51 | ): OrchestrationReadModel { | 107 | ): Effect.Effect<OrchestrationReadModel, unknown> { |
| 52 | const payload = asObject(event.payload); | ||
| 53 | const nextBase: OrchestrationReadModel = { | 108 | const nextBase: OrchestrationReadModel = { |
| 54 | ...model, | 109 | ...model, |
| 55 | sequence: event.sequence, | 110 | sequence: event.sequence, |
| ... | @@ -57,266 +112,215 @@ export function reduceEvent( | ... | @@ -57,266 +112,215 @@ export function reduceEvent( |
| 57 | }; | 112 | }; |
| 58 | 113 | ||
| 59 | switch (event.type) { | 114 | switch (event.type) { |
| 60 | case "project.created": { | 115 | case "thread.created": |
| 61 | if (!payload) return nextBase; | 116 | return Effect.gen(function* () { |
| 62 | const project: OrchestrationProject = { | 117 | const payload = yield* decodeThreadCreatedPayload(event.payload); |
| 63 | id: asString(payload.id) ?? event.aggregateId, | 118 | const thread: OrchestrationThread = yield* decodeThread({ |
| 64 | name: asString(payload.name) ?? "project", | 119 | id: payload.id, |
| 65 | cwd: asString(payload.cwd) ?? "", | 120 | projectId: payload.projectId, |
| 66 | model: asString(payload.model) ?? "gpt-5-codex", | 121 | title: payload.title, |
| 67 | createdAt: asString(payload.createdAt) ?? event.occurredAt, | 122 | model: payload.model, |
| 68 | updatedAt: asString(payload.updatedAt) ?? event.occurredAt, | 123 | branch: payload.branch, |
| 69 | }; | 124 | worktreePath: payload.worktreePath, |
| 70 | const existing = nextBase.projects.find((entry) => entry.id === project.id); | 125 | createdAt: payload.createdAt, |
| 71 | return { | 126 | updatedAt: payload.updatedAt, |
| 72 | ...nextBase, | 127 | latestTurnId: null, |
| 73 | projects: existing | 128 | latestTurnStartedAt: null, |
| 74 | ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry)) | 129 | latestTurnCompletedAt: null, |
| 75 | : [...nextBase.projects, project], | 130 | latestTurnDurationMs: null, |
| 76 | }; | 131 | messages: [], |
| 77 | } | 132 | session: null, |
| 78 | case "project.deleted": | 133 | turnDiffSummaries: [], |
| 79 | return { | 134 | error: null, |
| 80 | ...nextBase, | 135 | }); |
| 81 | projects: nextBase.projects.filter((project) => project.id !== event.aggregateId), | 136 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); |
| 82 | threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId), | 137 | return { |
| 83 | }; | 138 | ...nextBase, |
| 84 | case "thread.created": { | 139 | threads: existing |
| 85 | if (!payload) return nextBase; | 140 | ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) |
| 86 | const thread: OrchestrationThread = { | 141 | : [...nextBase.threads, thread], |
| 87 | id: asString(payload.id) ?? event.aggregateId, | 142 | }; |
| 88 | projectId: asString(payload.projectId) ?? "", | 143 | }); |
| 89 | title: asString(payload.title) ?? "New thread", | ||
| 90 | model: asString(payload.model) ?? "gpt-5-codex", | ||
| 91 | branch: asString(payload.branch), | ||
| 92 | worktreePath: asString(payload.worktreePath), | ||
| 93 | createdAt: asString(payload.createdAt) ?? event.occurredAt, | ||
| 94 | updatedAt: asString(payload.updatedAt) ?? event.occurredAt, | ||
| 95 | latestTurnId: null, | ||
| 96 | latestTurnStartedAt: null, | ||
| 97 | latestTurnCompletedAt: null, | ||
| 98 | latestTurnDurationMs: null, | ||
| 99 | messages: [], | ||
| 100 | session: null, | ||
| 101 | turnDiffSummaries: [], | ||
| 102 | error: null, | ||
| 103 | }; | ||
| 104 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); | ||
| 105 | return { | ||
| 106 | ...nextBase, | ||
| 107 | threads: existing | ||
| 108 | ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) | ||
| 109 | : [...nextBase.threads, thread], | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | case "thread.deleted": | 144 | case "thread.deleted": |
| 113 | return { | 145 | return Effect.succeed({ |
| 114 | ...nextBase, | 146 | ...nextBase, |
| 115 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), | 147 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), |
| 116 | }; | 148 | }); |
| 117 | case "thread.meta-updated": { | 149 | case "thread.meta-updated": |
| 118 | if (!payload) return nextBase; | 150 | return decodeThreadMetaUpdatedPayload(event.payload).pipe( |
| 119 | const threadId = asString(payload.threadId) ?? event.aggregateId; | 151 | Effect.map((payload) => { |
| 120 | const thread = nextBase.threads.find((entry) => entry.id === threadId); | 152 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 121 | if (!thread) return nextBase; | 153 | if (!thread) { |
| 122 | return { | 154 | return nextBase; |
| 123 | ...nextBase, | 155 | } |
| 124 | threads: updateThread(nextBase.threads, threadId, { | 156 | return { |
| 125 | ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}), | 157 | ...nextBase, |
| 126 | ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}), | 158 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 127 | ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}), | 159 | ...(payload.title !== undefined ? { title: payload.title } : {}), |
| 128 | ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}), | 160 | ...(payload.model !== undefined ? { model: payload.model } : {}), |
| 129 | updatedAt: asString(payload.updatedAt) ?? event.occurredAt, | 161 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), |
| 162 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | ||
| 163 | updatedAt: payload.updatedAt, | ||
| 164 | }), | ||
| 165 | }; | ||
| 130 | }), | 166 | }), |
| 131 | }; | 167 | ); |
| 132 | } | 168 | case "message.sent": |
| 133 | case "message.sent": { | 169 | return Effect.gen(function* () { |
| 134 | if (!payload) return nextBase; | 170 | const payload = yield* decodeMessageSentPayload(event.payload); |
| 135 | const threadId = asString(payload.threadId) ?? event.aggregateId; | 171 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); |
| 136 | const message: OrchestrationMessage = { | 172 | if (!targetThread) { |
| 137 | id: asString(payload.id) ?? crypto.randomUUID(), | 173 | return nextBase; |
| 138 | role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user", | 174 | } |
| 139 | text: asString(payload.text) ?? "", | 175 | |
| 140 | createdAt: asString(payload.createdAt) ?? event.occurredAt, | 176 | const message: OrchestrationMessage = yield* decodeMessage({ |
| 141 | streaming: payload.streaming === true, | 177 | id: payload.id, |
| 142 | }; | 178 | role: payload.role, |
| 143 | const targetThread = nextBase.threads.find((thread) => thread.id === threadId); | 179 | text: payload.text, |
| 144 | if (!targetThread) return nextBase; | 180 | createdAt: payload.createdAt, |
| 145 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); | 181 | streaming: payload.streaming, |
| 146 | const nextMessages = existingMessage | 182 | }); |
| 147 | ? targetThread.messages.map((entry) => | 183 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); |
| 148 | entry.id === message.id | 184 | const nextMessages = existingMessage |
| 149 | ? { | 185 | ? targetThread.messages.map((entry) => |
| 150 | ...entry, | 186 | entry.id === message.id |
| 151 | text: | 187 | ? { |
| 152 | message.streaming | 188 | ...entry, |
| 189 | text: message.streaming | ||
| 153 | ? `${entry.text}${message.text}` | 190 | ? `${entry.text}${message.text}` |
| 154 | : message.text.length > 0 | 191 | : message.text.length > 0 |
| 155 | ? message.text | 192 | ? message.text |
| 156 | : entry.text, | 193 | : entry.text, |
| 157 | streaming: message.streaming, | 194 | streaming: message.streaming, |
| 158 | createdAt: message.createdAt, | 195 | createdAt: message.createdAt, |
| 159 | } | 196 | } |
| 160 | : entry, | 197 | : entry, |
| 161 | ) | 198 | ) |
| 162 | : [...targetThread.messages, message]; | 199 | : [...targetThread.messages, message]; |
| 163 | return { | ||
| 164 | ...nextBase, | ||
| 165 | threads: updateThread(nextBase.threads, threadId, { | ||
| 166 | messages: nextMessages, | ||
| 167 | updatedAt: event.occurredAt, | ||
| 168 | }), | ||
| 169 | }; | ||
| 170 | } | ||
| 171 | case "thread.session-set": { | ||
| 172 | if (!payload) return nextBase; | ||
| 173 | const threadId = asString(payload.threadId) ?? event.aggregateId; | ||
| 174 | const thread = nextBase.threads.find((entry) => entry.id === threadId); | ||
| 175 | if (!thread) return nextBase; | ||
| 176 | const sessionPayload = asObject(payload.session); | ||
| 177 | if (!sessionPayload) return nextBase; | ||
| 178 | const session: OrchestrationSession = { | ||
| 179 | sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(), | ||
| 180 | status: | ||
| 181 | (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting", | ||
| 182 | provider: | ||
| 183 | (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex", | ||
| 184 | threadId, | ||
| 185 | activeTurnId: asString(sessionPayload.activeTurnId), | ||
| 186 | createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt, | ||
| 187 | updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt, | ||
| 188 | lastError: asString(sessionPayload.lastError), | ||
| 189 | }; | ||
| 190 | return { | ||
| 191 | ...nextBase, | ||
| 192 | threads: updateThread(nextBase.threads, threadId, { | ||
| 193 | session, | ||
| 194 | updatedAt: event.occurredAt, | ||
| 195 | error: session.lastError, | ||
| 196 | }), | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | case "thread.turn-diff-completed": { | ||
| 200 | if (!payload) return nextBase; | ||
| 201 | const threadId = asString(payload.threadId) ?? event.aggregateId; | ||
| 202 | const thread = nextBase.threads.find((entry) => entry.id === threadId); | ||
| 203 | const turnId = asString(payload.turnId); | ||
| 204 | if (!thread || !turnId) return nextBase; | ||
| 205 | 200 | ||
| 206 | const files = asArray(payload.files).flatMap((filePayload) => { | 201 | return { |
| 207 | const file = asObject(filePayload); | 202 | ...nextBase, |
| 208 | const filePath = asString(file?.path); | 203 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 209 | if (!filePath) { | 204 | messages: nextMessages, |
| 210 | return []; | 205 | updatedAt: event.occurredAt, |
| 206 | }), | ||
| 207 | }; | ||
| 208 | }); | ||
| 209 | case "thread.session-set": | ||
| 210 | return Effect.gen(function* () { | ||
| 211 | const payload = yield* decodeThreadSessionSetPayload(event.payload); | ||
| 212 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | ||
| 213 | if (!thread) { | ||
| 214 | return nextBase; | ||
| 211 | } | 215 | } |
| 212 | 216 | ||
| 213 | const nextFile: { | 217 | const session: OrchestrationSession = yield* decodeSession({ |
| 214 | path: string; | 218 | ...payload.session, |
| 215 | kind?: string; | 219 | threadId: payload.threadId, |
| 216 | additions?: number; | 220 | }); |
| 217 | deletions?: number; | 221 | |
| 218 | } = { path: filePath }; | 222 | return { |
| 219 | const kind = asString(file?.kind); | 223 | ...nextBase, |
| 220 | if (kind !== null) { | 224 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 221 | nextFile.kind = kind; | 225 | session, |
| 222 | } | 226 | updatedAt: event.occurredAt, |
| 223 | const additions = asNumber(file?.additions); | 227 | error: session.lastError, |
| 224 | if (additions !== null) { | 228 | }), |
| 225 | nextFile.additions = additions; | 229 | }; |
| 226 | } | ||
| 227 | const deletions = asNumber(file?.deletions); | ||
| 228 | if (deletions !== null) { | ||
| 229 | nextFile.deletions = deletions; | ||
| 230 | } | ||
| 231 | return [nextFile]; | ||
| 232 | }); | 230 | }); |
| 231 | case "thread.turn-diff-completed": | ||
| 232 | return Effect.gen(function* () { | ||
| 233 | const payload = yield* decodeThreadTurnDiffCompletedPayload(event.payload); | ||
| 234 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | ||
| 235 | if (!thread) { | ||
| 236 | return nextBase; | ||
| 237 | } | ||
| 233 | 238 | ||
| 234 | const nextSummary = { | 239 | const nextSummary = yield* decodeTurnDiffSummary({ |
| 235 | turnId, | 240 | turnId: payload.turnId, |
| 236 | completedAt: asString(payload.completedAt) ?? event.occurredAt, | 241 | completedAt: payload.completedAt, |
| 237 | ...(asString(payload.status) !== null ? { status: asString(payload.status) ?? undefined } : {}), | 242 | ...(payload.status !== undefined ? { status: payload.status } : {}), |
| 238 | files, | 243 | files: payload.files, |
| 239 | ...(asString(payload.assistantMessageId) !== null | 244 | ...(payload.assistantMessageId !== undefined |
| 240 | ? { assistantMessageId: asString(payload.assistantMessageId) ?? undefined } | 245 | ? { assistantMessageId: payload.assistantMessageId } |
| 241 | : {}), | 246 | : {}), |
| 242 | ...(asNumber(payload.checkpointTurnCount) !== null | 247 | ...(payload.checkpointTurnCount !== undefined |
| 243 | ? { checkpointTurnCount: asNumber(payload.checkpointTurnCount) ?? undefined } | 248 | ? { checkpointTurnCount: payload.checkpointTurnCount } |
| 244 | : {}), | 249 | : {}), |
| 245 | }; | 250 | }); |
| 246 | const turnDiffSummaries = [...thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), nextSummary] | 251 | const turnDiffSummaries = [ |
| 247 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | 252 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), |
| 253 | nextSummary, | ||
| 254 | ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | ||
| 248 | 255 | ||
| 249 | return { | 256 | return { |
| 250 | ...nextBase, | 257 | ...nextBase, |
| 251 | threads: updateThread(nextBase.threads, threadId, { | 258 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 252 | latestTurnId: turnId, | 259 | latestTurnId: payload.turnId, |
| 253 | latestTurnCompletedAt: nextSummary.completedAt, | 260 | latestTurnCompletedAt: nextSummary.completedAt, |
| 254 | turnDiffSummaries, | 261 | turnDiffSummaries, |
| 255 | updatedAt: event.occurredAt, | 262 | updatedAt: event.occurredAt, |
| 256 | }), | 263 | }), |
| 257 | }; | 264 | }; |
| 258 | } | 265 | }); |
| 259 | case "thread.reverted": { | 266 | case "thread.reverted": |
| 260 | if (!payload) return nextBase; | 267 | return decodeThreadRevertedPayload(event.payload).pipe( |
| 261 | const threadId = asString(payload.threadId) ?? event.aggregateId; | 268 | Effect.map((payload) => { |
| 262 | const thread = nextBase.threads.find((entry) => entry.id === threadId); | 269 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 263 | if (!thread) return nextBase; | 270 | if (!thread) { |
| 271 | return nextBase; | ||
| 272 | } | ||
| 264 | 273 | ||
| 265 | const targetTurnCount = Math.max(0, Math.floor(asNumber(payload.turnCount) ?? 0)); | 274 | const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); |
| 266 | const targetMessageCount = Math.max(0, Math.floor(asNumber(payload.messageCount) ?? 0)); | 275 | const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); |
| 267 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => | 276 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => |
| 268 | left.completedAt.localeCompare(right.completedAt), | 277 | left.completedAt.localeCompare(right.completedAt), |
| 269 | ); | 278 | ); |
| 270 | const inferredTurnCountByTurnId = new Map<string, number>(); | 279 | const inferredTurnCountByTurnId = new Map<string, number>(); |
| 271 | for (let index = 0; index < sortedSummaries.length; index += 1) { | 280 | for (let index = 0; index < sortedSummaries.length; index += 1) { |
| 272 | const summary = sortedSummaries[index]; | 281 | const summary = sortedSummaries[index]; |
| 273 | if (!summary) continue; | 282 | if (!summary) continue; |
| 274 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); | 283 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); |
| 275 | } | 284 | } |
| 276 | 285 | ||
| 277 | const turnDiffSummaries = thread.turnDiffSummaries | 286 | const turnDiffSummaries = thread.turnDiffSummaries |
| 278 | .filter((summary) => { | 287 | .filter((summary) => { |
| 279 | const checkpointTurnCount = | 288 | const checkpointTurnCount = |
| 280 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; | 289 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; |
| 281 | return checkpointTurnCount <= targetTurnCount; | 290 | return checkpointTurnCount <= targetTurnCount; |
| 282 | }) | 291 | }) |
| 283 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | 292 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); |
| 284 | const latestSummary = | 293 | const latestSummary = |
| 285 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; | 294 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; |
| 286 | 295 | ||
| 287 | return { | 296 | return { |
| 288 | ...nextBase, | 297 | ...nextBase, |
| 289 | threads: updateThread(nextBase.threads, threadId, { | 298 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 290 | messages: thread.messages.slice(0, Math.min(targetMessageCount, thread.messages.length)), | 299 | messages: thread.messages.slice( |
| 291 | turnDiffSummaries, | 300 | 0, |
| 292 | latestTurnId: latestSummary?.turnId ?? null, | 301 | Math.min(targetMessageCount, thread.messages.length), |
| 293 | latestTurnStartedAt: null, | 302 | ), |
| 294 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | 303 | turnDiffSummaries, |
| 295 | latestTurnDurationMs: null, | 304 | latestTurnId: latestSummary?.turnId ?? null, |
| 296 | updatedAt: event.occurredAt, | 305 | latestTurnStartedAt: null, |
| 306 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | ||
| 307 | latestTurnDurationMs: null, | ||
| 308 | updatedAt: event.occurredAt, | ||
| 309 | }), | ||
| 310 | }; | ||
| 297 | }), | 311 | }), |
| 298 | }; | 312 | ); |
| 299 | } | 313 | case "git.read-model-upsert": |
| 300 | case "git.read-model-upsert": { | 314 | return decodeGitReadModel(event.payload).pipe( |
| 301 | if (!payload) return nextBase; | 315 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ |
| 302 | const projectId = asString(payload.projectId) ?? event.aggregateId; | 316 | ...nextBase, |
| 303 | const gitReadModel: OrchestrationGitReadModel = { | 317 | gitByProjectId: { |
| 304 | projectId, | 318 | ...nextBase.gitByProjectId, |
| 305 | branch: asString(payload.branch), | 319 | [gitReadModel.projectId]: gitReadModel, |
| 306 | hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true, | 320 | }, |
| 307 | aheadCount: asNumber(payload.aheadCount) ?? 0, | 321 | })), |
| 308 | behindCount: asNumber(payload.behindCount) ?? 0, | 322 | ); |
| 309 | updatedAt: asString(payload.updatedAt) ?? event.occurredAt, | ||
| 310 | }; | ||
| 311 | return { | ||
| 312 | ...nextBase, | ||
| 313 | gitByProjectId: { | ||
| 314 | ...nextBase.gitByProjectId, | ||
| 315 | [projectId]: gitReadModel, | ||
| 316 | }, | ||
| 317 | }; | ||
| 318 | } | ||
| 319 | default: | 323 | default: |
| 320 | return nextBase; | 324 | return Effect.succeed(nextBase); |
| 321 | } | 325 | } |
| 322 | } | 326 | } |
apps/server/src/orchestration/runtime.ts deleted-22| ... | @@ -1,22 +0,0 @@ | ||
| 1 | import { Layer, ManagedRuntime } from "effect"; | ||
| 2 | |||
| 3 | import type { OrchestrationEngine } from "./engine"; | ||
| 4 | import { OrchestrationLive } from "./layers"; | ||
| 5 | import { OrchestrationConfig, OrchestrationEngineService } from "./services"; | ||
| 6 | |||
| 7 | export interface OrchestrationSystem { | ||
| 8 | readonly engine: OrchestrationEngine; | ||
| 9 | readonly dispose: () => Promise<void>; | ||
| 10 | } | ||
| 11 | |||
| 12 | export async function createOrchestrationSystem(stateDir: string): Promise<OrchestrationSystem> { | ||
| 13 | const orchestrationLayer = OrchestrationLive.pipe( | ||
| 14 | Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })), | ||
| 15 | ); | ||
| 16 | const runtime = ManagedRuntime.make(orchestrationLayer); | ||
| 17 | const engine = await runtime.runPromise(OrchestrationEngineService); | ||
| 18 | return { | ||
| 19 | engine, | ||
| 20 | dispose: () => runtime.dispose(), | ||
| 21 | }; | ||
| 22 | } | ||
apps/server/src/orchestration/services.ts-9| ... | @@ -2,15 +2,6 @@ import { Context } from "effect"; | ... | @@ -2,15 +2,6 @@ import { Context } from "effect"; |
| 2 | 2 | ||
| 3 | import { OrchestrationEngine } from "./engine"; | 3 | import { OrchestrationEngine } from "./engine"; |
| 4 | 4 | ||
| 5 | export interface OrchestrationConfigShape { | ||
| 6 | readonly stateDir: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | export class OrchestrationConfig extends Context.Tag("orchestration/Config")< | ||
| 10 | OrchestrationConfig, | ||
| 11 | OrchestrationConfigShape | ||
| 12 | >() {} | ||
| 13 | |||
| 14 | export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< | 5 | export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< |
| 15 | OrchestrationEngineService, | 6 | OrchestrationEngineService, |
| 16 | OrchestrationEngine | 7 | OrchestrationEngine |
apps/server/src/orchestration/uiContractInventory.ts+4-6| ... | @@ -7,11 +7,6 @@ export interface UiEntityContract { | ... | @@ -7,11 +7,6 @@ export interface UiEntityContract { |
| 7 | // Explicit UI inventory used to keep server read models aligned | 7 | // Explicit UI inventory used to keep server read models aligned |
| 8 | // with what the current frontend renders. | 8 | // with what the current frontend renders. |
| 9 | export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ | 9 | export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ |
| 10 | { | ||
| 11 | entity: "Project", | ||
| 12 | requiredFields: ["id", "name", "cwd", "model"], | ||
| 13 | consumedBy: ["apps/web/src/components/Sidebar.tsx"], | ||
| 14 | }, | ||
| 15 | { | 10 | { |
| 16 | entity: "Thread", | 11 | entity: "Thread", |
| 17 | requiredFields: [ | 12 | requiredFields: [ |
| ... | @@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ | ... | @@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ |
| 61 | { | 56 | { |
| 62 | entity: "GitReadModel", | 57 | entity: "GitReadModel", |
| 63 | requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], | 58 | requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], |
| 64 | consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"], | 59 | consumedBy: [ |
| 60 | "apps/web/src/components/GitActionsControl.tsx", | ||
| 61 | "apps/web/src/components/BranchToolbar.tsx", | ||
| 62 | ], | ||
| 65 | }, | 63 | }, |
| 66 | ]; | 64 | ]; |
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts+5-5| ... | @@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => { | ... | @@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => { |
| 35 | const saved = await Runtime.runPromise(runtime)( | 35 | const saved = await Runtime.runPromise(runtime)( |
| 36 | first.append({ | 36 | first.append({ |
| 37 | eventId: "event-1", | 37 | eventId: "event-1", |
| 38 | type: "project.created", | 38 | type: "thread.created", |
| 39 | aggregateType: "project", | 39 | aggregateType: "thread", |
| 40 | aggregateId: "project-1", | 40 | aggregateId: "thread-1", |
| 41 | occurredAt: createdAt, | 41 | occurredAt: createdAt, |
| 42 | commandId: "cmd-1", | 42 | commandId: "cmd-1", |
| 43 | payload: { id: "project-1", name: "demo" }, | 43 | payload: { id: "thread-1", projectId: "project-1", title: "demo" }, |
| 44 | }), | 44 | }), |
| 45 | ); | 45 | ); |
| 46 | expect(saved.sequence).toBe(1); | 46 | expect(saved.sequence).toBe(1); |
| ... | @@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => { | ... | @@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => { |
| 92 | `, | 92 | `, |
| 93 | ) | 93 | ) |
| 94 | .all() as Array<Record<string, unknown>>; | 94 | .all() as Array<Record<string, unknown>>; |
| 95 | expect(migrationRows).toHaveLength(1); | 95 | expect(migrationRows.length).toBeGreaterThanOrEqual(1); |
| 96 | expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); | 96 | expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); |
| 97 | db.close(); | 97 | db.close(); |
| 98 | }); | 98 | }); |
apps/server/src/persistence/Layers/OrchestrationEvents.ts+4-33| ... | @@ -1,15 +1,10 @@ | ... | @@ -1,15 +1,10 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | |||
| 4 | import type { OrchestrationEvent } from "@t3tools/contracts"; | 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 5 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | 2 | import { OrchestrationEventSchema } from "@t3tools/contracts"; |
| 6 | import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; | ||
| 7 | import * as SqlClient from "@effect/sql/SqlClient"; | 3 | import * as SqlClient from "@effect/sql/SqlClient"; |
| 8 | import * as SqlSchema from "@effect/sql/SqlSchema"; | 4 | import * as SqlSchema from "@effect/sql/SqlSchema"; |
| 9 | import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; | 5 | import { Effect, Layer, Schema } from "effect"; |
| 10 | import { Context, Effect, Layer, Schema } from "effect"; | ||
| 11 | 6 | ||
| 12 | import { runMigrations } from "../Migrations"; | 7 | import { makeSqlitePersistenceLive } from "./Sqlite"; |
| 13 | import { | 8 | import { |
| 14 | OrchestrationEventRepository, | 9 | OrchestrationEventRepository, |
| 15 | type OrchestrationEventRepositoryShape, | 10 | type OrchestrationEventRepositoryShape, |
| ... | @@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ | ... | @@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ |
| 45 | limit: Schema.Number, | 40 | limit: Schema.Number, |
| 46 | }); | 41 | }); |
| 47 | 42 | ||
| 48 | class OrchestrationSql extends Context.Tag("persistence/OrchestrationSql")< | ||
| 49 | OrchestrationSql, | ||
| 50 | SqlClientService | ||
| 51 | >() {} | ||
| 52 | |||
| 53 | function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { | 43 | function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { |
| 54 | return decodeEvent({ | 44 | return decodeEvent({ |
| 55 | sequence: row.sequence, | 45 | sequence: row.sequence, |
| ... | @@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { | ... | @@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { |
| 64 | } | 54 | } |
| 65 | 55 | ||
| 66 | const makeRepository = Effect.gen(function* () { | 56 | const makeRepository = Effect.gen(function* () { |
| 67 | const sql = yield* OrchestrationSql; | 57 | const sql = yield* SqlClient.SqlClient; |
| 68 | 58 | ||
| 69 | const appendEventRow = SqlSchema.single({ | 59 | const appendEventRow = SqlSchema.single({ |
| 70 | Request: AppendEventRequestSchema, | 60 | Request: AppendEventRequestSchema, |
| ... | @@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect( | ... | @@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect( |
| 155 | ); | 145 | ); |
| 156 | 146 | ||
| 157 | export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { | 147 | export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { |
| 158 | const SqliteClientLive = Effect.gen(function* () { | 148 | return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); |
| 159 | yield* Effect.try({ | ||
| 160 | try: () => fs.mkdirSync(path.dirname(dbPath), { recursive: true }), | ||
| 161 | catch: (error) => Effect.die(error), | ||
| 162 | }) | ||
| 163 | return SqliteClient.layer({ filename: dbPath }); | ||
| 164 | }).pipe(Layer.unwrapEffect) | ||
| 165 | |||
| 166 | const OrchestrationSqlLive = Layer.scoped( | ||
| 167 | OrchestrationSql, | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const sql = yield* SqlClient.SqlClient; | ||
| 170 | yield* sql`PRAGMA journal_mode = WAL;`; | ||
| 171 | yield* sql`PRAGMA foreign_keys = ON;`; | ||
| 172 | yield* runMigrations; | ||
| 173 | return sql; | ||
| 174 | }), | ||
| 175 | ).pipe(Layer.provide(SqliteClientLive)); | ||
| 176 | |||
| 177 | return OrchestrationEventRepositoryLive.pipe(Layer.provide(OrchestrationSqlLive)); | ||
| 178 | } | 149 | } |
apps/server/src/persistence/Layers/Projects.test.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { Layer, ManagedRuntime } from "effect"; | ||
| 6 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 7 | |||
| 8 | import { ProjectRepository } from "../Services/Projects"; | ||
| 9 | import { ProjectRepositoryLive } from "./Projects"; | ||
| 10 | import { makeSqlitePersistenceLive } from "./Sqlite"; | ||
| 11 | |||
| 12 | function makeProjectRepositoryTest(dbPath: string) { | ||
| 13 | return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); | ||
| 14 | } | ||
| 15 | |||
| 16 | const tempDirs: string[] = []; | ||
| 17 | |||
| 18 | function makeTempDir(prefix: string): string { | ||
| 19 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| 20 | tempDirs.push(dir); | ||
| 21 | return dir; | ||
| 22 | } | ||
| 23 | |||
| 24 | afterEach(() => { | ||
| 25 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 26 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 27 | } | ||
| 28 | }); | ||
| 29 | |||
| 30 | describe("ProjectRepository", () => { | ||
| 31 | it("persists projects and deduplicates adds by cwd", async () => { | ||
| 32 | const stateDir = makeTempDir("t3code-project-repo-state-"); | ||
| 33 | const projectDir = makeTempDir("t3code-project-repo-project-"); | ||
| 34 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 35 | |||
| 36 | const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); | ||
| 37 | const first = await firstRuntime.runPromise(ProjectRepository); | ||
| 38 | |||
| 39 | const created = await firstRuntime.runPromise(first.add({ cwd: projectDir })); | ||
| 40 | expect(created.created).toBe(true); | ||
| 41 | |||
| 42 | const duplicate = await firstRuntime.runPromise(first.add({ cwd: projectDir })); | ||
| 43 | expect(duplicate.created).toBe(false); | ||
| 44 | expect(duplicate.project.id).toBe(created.project.id); | ||
| 45 | |||
| 46 | await firstRuntime.dispose(); | ||
| 47 | |||
| 48 | const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); | ||
| 49 | const second = await secondRuntime.runPromise(ProjectRepository); | ||
| 50 | const listed = await secondRuntime.runPromise(second.list()); | ||
| 51 | |||
| 52 | expect(listed).toHaveLength(1); | ||
| 53 | expect(listed[0]?.cwd).toBe(projectDir); | ||
| 54 | |||
| 55 | await secondRuntime.dispose(); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("prunes missing project paths", async () => { | ||
| 59 | const stateDir = makeTempDir("t3code-project-repo-prune-state-"); | ||
| 60 | const existing = makeTempDir("t3code-project-repo-prune-existing-"); | ||
| 61 | const missing = makeTempDir("t3code-project-repo-prune-missing-"); | ||
| 62 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 63 | |||
| 64 | const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); | ||
| 65 | const repository = await runtime.runPromise(ProjectRepository); | ||
| 66 | |||
| 67 | const existingProject = await runtime.runPromise(repository.add({ cwd: existing })); | ||
| 68 | const missingProject = await runtime.runPromise(repository.add({ cwd: missing })); | ||
| 69 | |||
| 70 | expect(existingProject.created).toBe(true); | ||
| 71 | expect(missingProject.created).toBe(true); | ||
| 72 | |||
| 73 | fs.rmSync(missing, { recursive: true, force: true }); | ||
| 74 | |||
| 75 | await runtime.runPromise(repository.pruneMissing()); | ||
| 76 | const listed = await runtime.runPromise(repository.list()); | ||
| 77 | |||
| 78 | expect(listed).toHaveLength(1); | ||
| 79 | expect(listed[0]?.cwd).toBe(existing); | ||
| 80 | |||
| 81 | await runtime.dispose(); | ||
| 82 | }); | ||
| 83 | }); | ||
apps/server/src/persistence/Layers/Projects.ts created+339| ... | @@ -0,0 +1,339 @@ | ||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import fs from "node:fs"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import type { | ||
| 6 | ProjectAddInput, | ||
| 7 | ProjectAddResult, | ||
| 8 | ProjectListResult, | ||
| 9 | ProjectRecord, | ||
| 10 | ProjectScript, | ||
| 11 | ProjectUpdateScriptsResult, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { | ||
| 14 | normalizeProjectScripts, | ||
| 15 | projectAddInputSchema, | ||
| 16 | projectRecordSchema, | ||
| 17 | projectRemoveInputSchema, | ||
| 18 | projectScriptsSchema, | ||
| 19 | projectUpdateScriptsInputSchema, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import * as SqlClient from "@effect/sql/SqlClient"; | ||
| 22 | import * as SqlSchema from "@effect/sql/SqlSchema"; | ||
| 23 | import { Effect, Layer, Option, Schema } from "effect"; | ||
| 24 | |||
| 25 | import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects"; | ||
| 26 | |||
| 27 | const ProjectRowSchema = Schema.Struct({ | ||
| 28 | id: Schema.String, | ||
| 29 | cwd: Schema.String, | ||
| 30 | name: Schema.String, | ||
| 31 | scriptsJson: Schema.String, | ||
| 32 | createdAt: Schema.String, | ||
| 33 | updatedAt: Schema.String, | ||
| 34 | }); | ||
| 35 | |||
| 36 | type ProjectRow = Schema.Schema.Type<typeof ProjectRowSchema>; | ||
| 37 | |||
| 38 | const FindByIdRequestSchema = Schema.Struct({ | ||
| 39 | id: Schema.String, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const FindByCwdRequestSchema = Schema.Struct({ | ||
| 43 | cwd: Schema.String, | ||
| 44 | }); | ||
| 45 | |||
| 46 | const InsertProjectRequestSchema = Schema.Struct({ | ||
| 47 | id: Schema.String, | ||
| 48 | cwd: Schema.String, | ||
| 49 | name: Schema.String, | ||
| 50 | scriptsJson: Schema.String, | ||
| 51 | createdAt: Schema.String, | ||
| 52 | updatedAt: Schema.String, | ||
| 53 | }); | ||
| 54 | |||
| 55 | const UpdateScriptsRequestSchema = Schema.Struct({ | ||
| 56 | id: Schema.String, | ||
| 57 | scriptsJson: Schema.String, | ||
| 58 | updatedAt: Schema.String, | ||
| 59 | }); | ||
| 60 | |||
| 61 | const DeleteProjectRequestSchema = Schema.Struct({ | ||
| 62 | id: Schema.String, | ||
| 63 | }); | ||
| 64 | |||
| 65 | function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | ||
| 66 | const cloned: ProjectScript[] = []; | ||
| 67 | for (const script of scripts) { | ||
| 68 | cloned.push({ | ||
| 69 | id: script.id, | ||
| 70 | name: script.name, | ||
| 71 | command: script.command, | ||
| 72 | icon: script.icon, | ||
| 73 | runOnWorktreeCreate: script.runOnWorktreeCreate, | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | return cloned; | ||
| 77 | } | ||
| 78 | |||
| 79 | function normalizeCwd(rawCwd: string): string { | ||
| 80 | const resolved = path.resolve(rawCwd.trim()); | ||
| 81 | const normalized = path.normalize(resolved); | ||
| 82 | if (process.platform === "win32") { | ||
| 83 | return normalized.toLowerCase(); | ||
| 84 | } | ||
| 85 | return normalized; | ||
| 86 | } | ||
| 87 | |||
| 88 | function isDirectory(cwd: string): boolean { | ||
| 89 | try { | ||
| 90 | return fs.statSync(cwd).isDirectory(); | ||
| 91 | } catch { | ||
| 92 | return false; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | function inferProjectName(cwd: string): string { | ||
| 97 | const name = path.basename(cwd); | ||
| 98 | return name.length > 0 ? name : "project"; | ||
| 99 | } | ||
| 100 | |||
| 101 | function parseScriptsJson(raw: string): ProjectScript[] { | ||
| 102 | try { | ||
| 103 | const parsed = JSON.parse(raw) as unknown; | ||
| 104 | const scripts = projectScriptsSchema.parse(parsed); | ||
| 105 | return normalizeProjectScripts(scripts); | ||
| 106 | } catch { | ||
| 107 | return []; | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | function rowToProjectRecord(row: ProjectRow): ProjectRecord { | ||
| 112 | return projectRecordSchema.parse({ | ||
| 113 | id: row.id, | ||
| 114 | cwd: row.cwd, | ||
| 115 | name: row.name, | ||
| 116 | scripts: parseScriptsJson(row.scriptsJson), | ||
| 117 | createdAt: row.createdAt, | ||
| 118 | updatedAt: row.updatedAt, | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | const makeRepository = Effect.gen(function* () { | ||
| 123 | const sql = yield* SqlClient.SqlClient; | ||
| 124 | |||
| 125 | const listProjectRows = SqlSchema.findAll({ | ||
| 126 | Request: Schema.Void, | ||
| 127 | Result: ProjectRowSchema, | ||
| 128 | execute: () => | ||
| 129 | sql` | ||
| 130 | SELECT | ||
| 131 | id, | ||
| 132 | cwd, | ||
| 133 | name, | ||
| 134 | scripts_json AS "scriptsJson", | ||
| 135 | created_at AS "createdAt", | ||
| 136 | updated_at AS "updatedAt" | ||
| 137 | FROM projects | ||
| 138 | ORDER BY created_at DESC | ||
| 139 | `, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const findProjectById = SqlSchema.findOne({ | ||
| 143 | Request: FindByIdRequestSchema, | ||
| 144 | Result: ProjectRowSchema, | ||
| 145 | execute: ({ id }) => | ||
| 146 | sql` | ||
| 147 | SELECT | ||
| 148 | id, | ||
| 149 | cwd, | ||
| 150 | name, | ||
| 151 | scripts_json AS "scriptsJson", | ||
| 152 | created_at AS "createdAt", | ||
| 153 | updated_at AS "updatedAt" | ||
| 154 | FROM projects | ||
| 155 | WHERE id = ${id} | ||
| 156 | `, | ||
| 157 | }); | ||
| 158 | |||
| 159 | const findProjectByCwd = SqlSchema.findOne({ | ||
| 160 | Request: FindByCwdRequestSchema, | ||
| 161 | Result: ProjectRowSchema, | ||
| 162 | execute: ({ cwd }) => | ||
| 163 | sql` | ||
| 164 | SELECT | ||
| 165 | id, | ||
| 166 | cwd, | ||
| 167 | name, | ||
| 168 | scripts_json AS "scriptsJson", | ||
| 169 | created_at AS "createdAt", | ||
| 170 | updated_at AS "updatedAt" | ||
| 171 | FROM projects | ||
| 172 | WHERE cwd = ${cwd} | ||
| 173 | `, | ||
| 174 | }); | ||
| 175 | |||
| 176 | const insertProject = SqlSchema.single({ | ||
| 177 | Request: InsertProjectRequestSchema, | ||
| 178 | Result: ProjectRowSchema, | ||
| 179 | execute: (request) => | ||
| 180 | sql` | ||
| 181 | INSERT INTO projects ( | ||
| 182 | id, | ||
| 183 | cwd, | ||
| 184 | name, | ||
| 185 | scripts_json, | ||
| 186 | created_at, | ||
| 187 | updated_at | ||
| 188 | ) | ||
| 189 | VALUES ( | ||
| 190 | ${request.id}, | ||
| 191 | ${request.cwd}, | ||
| 192 | ${request.name}, | ||
| 193 | ${request.scriptsJson}, | ||
| 194 | ${request.createdAt}, | ||
| 195 | ${request.updatedAt} | ||
| 196 | ) | ||
| 197 | RETURNING | ||
| 198 | id, | ||
| 199 | cwd, | ||
| 200 | name, | ||
| 201 | scripts_json AS "scriptsJson", | ||
| 202 | created_at AS "createdAt", | ||
| 203 | updated_at AS "updatedAt" | ||
| 204 | `, | ||
| 205 | }); | ||
| 206 | |||
| 207 | const updateProjectScripts = SqlSchema.single({ | ||
| 208 | Request: UpdateScriptsRequestSchema, | ||
| 209 | Result: ProjectRowSchema, | ||
| 210 | execute: (request) => | ||
| 211 | sql` | ||
| 212 | UPDATE projects | ||
| 213 | SET | ||
| 214 | scripts_json = ${request.scriptsJson}, | ||
| 215 | updated_at = ${request.updatedAt} | ||
| 216 | WHERE id = ${request.id} | ||
| 217 | RETURNING | ||
| 218 | id, | ||
| 219 | cwd, | ||
| 220 | name, | ||
| 221 | scripts_json AS "scriptsJson", | ||
| 222 | created_at AS "createdAt", | ||
| 223 | updated_at AS "updatedAt" | ||
| 224 | `, | ||
| 225 | }); | ||
| 226 | |||
| 227 | const deleteProject = SqlSchema.void({ | ||
| 228 | Request: DeleteProjectRequestSchema, | ||
| 229 | execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`, | ||
| 230 | }); | ||
| 231 | |||
| 232 | const list: ProjectRepositoryShape["list"] = () => | ||
| 233 | listProjectRows(undefined).pipe( | ||
| 234 | Effect.map((rows): ProjectListResult => rows.map(rowToProjectRecord)), | ||
| 235 | Effect.map((projects) => | ||
| 236 | projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), | ||
| 237 | ), | ||
| 238 | Effect.orDie, | ||
| 239 | ); | ||
| 240 | |||
| 241 | const add: ProjectRepositoryShape["add"] = (rawInput) => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const input = projectAddInputSchema.parse(rawInput as ProjectAddInput); | ||
| 244 | const normalizedCwd = normalizeCwd(input.cwd); | ||
| 245 | if (!isDirectory(normalizedCwd)) { | ||
| 246 | return yield* Effect.fail(new Error(`Project path does not exist: ${normalizedCwd}`)); | ||
| 247 | } | ||
| 248 | |||
| 249 | const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(Effect.orDie); | ||
| 250 | if (Option.isSome(existing)) { | ||
| 251 | const project = rowToProjectRecord(existing.value); | ||
| 252 | const result: ProjectAddResult = { | ||
| 253 | project: { | ||
| 254 | ...project, | ||
| 255 | scripts: cloneScripts(project.scripts), | ||
| 256 | }, | ||
| 257 | created: false, | ||
| 258 | }; | ||
| 259 | return result; | ||
| 260 | } | ||
| 261 | |||
| 262 | const now = new Date().toISOString(); | ||
| 263 | const row = yield* insertProject({ | ||
| 264 | id: randomUUID(), | ||
| 265 | cwd: normalizedCwd, | ||
| 266 | name: inferProjectName(normalizedCwd), | ||
| 267 | scriptsJson: "[]", | ||
| 268 | createdAt: now, | ||
| 269 | updatedAt: now, | ||
| 270 | }).pipe(Effect.orDie); | ||
| 271 | |||
| 272 | const created = rowToProjectRecord(row); | ||
| 273 | const result: ProjectAddResult = { | ||
| 274 | project: { | ||
| 275 | ...created, | ||
| 276 | scripts: cloneScripts(created.scripts), | ||
| 277 | }, | ||
| 278 | created: true, | ||
| 279 | }; | ||
| 280 | return result; | ||
| 281 | }); | ||
| 282 | |||
| 283 | const remove: ProjectRepositoryShape["remove"] = (rawInput) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const input = projectRemoveInputSchema.parse(rawInput); | ||
| 286 | yield* deleteProject({ id: input.id }).pipe(Effect.orDie); | ||
| 287 | }); | ||
| 288 | |||
| 289 | const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => | ||
| 290 | Effect.gen(function* () { | ||
| 291 | const input = projectUpdateScriptsInputSchema.parse(rawInput); | ||
| 292 | const existing = yield* findProjectById({ id: input.id }).pipe(Effect.orDie); | ||
| 293 | if (Option.isNone(existing)) { | ||
| 294 | return yield* Effect.fail(new Error(`Project not found: ${input.id}`)); | ||
| 295 | } | ||
| 296 | |||
| 297 | const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | ||
| 298 | const nextUpdatedAt = new Date().toISOString(); | ||
| 299 | const updatedRow = yield* updateProjectScripts({ | ||
| 300 | id: input.id, | ||
| 301 | scriptsJson: JSON.stringify(nextScripts), | ||
| 302 | updatedAt: nextUpdatedAt, | ||
| 303 | }).pipe(Effect.orDie); | ||
| 304 | |||
| 305 | const updated = rowToProjectRecord(updatedRow); | ||
| 306 | const result: ProjectUpdateScriptsResult = { | ||
| 307 | project: { | ||
| 308 | ...updated, | ||
| 309 | scripts: cloneScripts(updated.scripts), | ||
| 310 | }, | ||
| 311 | }; | ||
| 312 | return result; | ||
| 313 | }); | ||
| 314 | |||
| 315 | const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => | ||
| 316 | listProjectRows(undefined).pipe( | ||
| 317 | Effect.flatMap((rows) => | ||
| 318 | Effect.forEach(rows, (row) => { | ||
| 319 | const normalizedCwd = normalizeCwd(row.cwd); | ||
| 320 | if (isDirectory(normalizedCwd)) { | ||
| 321 | return Effect.void; | ||
| 322 | } | ||
| 323 | return deleteProject({ id: row.id }).pipe(Effect.orDie); | ||
| 324 | }), | ||
| 325 | ), | ||
| 326 | Effect.asVoid, | ||
| 327 | Effect.orDie, | ||
| 328 | ); | ||
| 329 | |||
| 330 | return { | ||
| 331 | list, | ||
| 332 | add, | ||
| 333 | remove, | ||
| 334 | updateScripts, | ||
| 335 | pruneMissing, | ||
| 336 | } satisfies ProjectRepositoryShape; | ||
| 337 | }); | ||
| 338 | |||
| 339 | export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository); | ||
apps/server/src/persistence/Layers/Sqlite.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | |||
| 4 | import * as SqlClient from "@effect/sql/SqlClient"; | ||
| 5 | import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; | ||
| 6 | import { Context, Effect, Layer } from "effect"; | ||
| 7 | |||
| 8 | import { runMigrations } from "../Migrations"; | ||
| 9 | |||
| 10 | export interface PersistenceConfigShape { | ||
| 11 | readonly dbPath: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export class PersistenceConfig extends Context.Tag("persistence/Config")< | ||
| 15 | PersistenceConfig, | ||
| 16 | PersistenceConfigShape | ||
| 17 | >() {} | ||
| 18 | |||
| 19 | export function makeSqlitePersistenceLive(dbPath: string) { | ||
| 20 | const SqliteClientLive = Layer.unwrapEffect( | ||
| 21 | Effect.sync(() => { | ||
| 22 | fs.mkdirSync(path.dirname(dbPath), { recursive: true }); | ||
| 23 | return SqliteClient.layer({ filename: dbPath }); | ||
| 24 | }), | ||
| 25 | ); | ||
| 26 | |||
| 27 | return Layer.scoped( | ||
| 28 | SqlClient.SqlClient, | ||
| 29 | Effect.gen(function* () { | ||
| 30 | const sql = yield* SqliteClient.SqliteClient; | ||
| 31 | yield* sql`PRAGMA journal_mode = WAL;`; | ||
| 32 | yield* sql`PRAGMA foreign_keys = ON;`; | ||
| 33 | yield* runMigrations; | ||
| 34 | return sql; | ||
| 35 | }), | ||
| 36 | ).pipe(Layer.provide(SqliteClientLive)); | ||
| 37 | } | ||
| 38 | |||
| 39 | export const SqlitePersistenceLive = Layer.unwrapEffect( | ||
| 40 | Effect.map(PersistenceConfig, ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), | ||
| 41 | ); | ||
apps/server/src/persistence/Migrations.ts+3-2| ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; |
| 13 | 13 | ||
| 14 | // Import all migrations statically | 14 | // Import all migrations statically |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 16 | 16 | import Migration0002 from "./Migrations/002_Projects.ts"; | |
| 17 | 17 | ||
| 18 | /** | 18 | /** |
| 19 | * Migration loader with all migrations defined inline. | 19 | * Migration loader with all migrations defined inline. |
| ... | @@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | ... | @@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 27 | */ | 27 | */ |
| 28 | const loader = Migrator.fromRecord({ | 28 | const loader = Migrator.fromRecord({ |
| 29 | "1_OrchestrationEvents": Migration0001, | 29 | "1_OrchestrationEvents": Migration0001, |
| 30 | "2_Projects": Migration0002, | ||
| 30 | }); | 31 | }); |
| 31 | 32 | ||
| 32 | /** | 33 | /** |
| ... | @@ -64,4 +65,4 @@ export const runMigrations = run({ loader }); | ... | @@ -64,4 +65,4 @@ export const runMigrations = run({ loader }); |
| 64 | * ) | 65 | * ) |
| 65 | * ``` | 66 | * ``` |
| 66 | */ | 67 | */ |
| 67 | export const MigrationsLive = Layer.effectDiscard(runMigrations); | ||
| \ No newline at end of file | |||
| 68 | export const MigrationsLive = Layer.effectDiscard(runMigrations); | ||
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+1-1| ... | @@ -21,4 +21,4 @@ export default Effect.gen(function* () { | ... | @@ -21,4 +21,4 @@ export default Effect.gen(function* () { |
| 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate |
| 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) |
| 23 | `; | 23 | `; |
| 24 | }); | ||
| \ No newline at end of file | |||
| 24 | }); | ||
apps/server/src/persistence/Migrations/002_Projects.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import { SqlClient } from "@effect/sql"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | yield* sql` | ||
| 8 | CREATE TABLE IF NOT EXISTS projects ( | ||
| 9 | id TEXT PRIMARY KEY, | ||
| 10 | cwd TEXT NOT NULL UNIQUE, | ||
| 11 | name TEXT NOT NULL, | ||
| 12 | scripts_json TEXT NOT NULL, | ||
| 13 | created_at TEXT NOT NULL, | ||
| 14 | updated_at TEXT NOT NULL | ||
| 15 | ) | ||
| 16 | `; | ||
| 17 | |||
| 18 | yield* sql` | ||
| 19 | CREATE INDEX IF NOT EXISTS idx_projects_created_at | ||
| 20 | ON projects(created_at DESC) | ||
| 21 | `; | ||
| 22 | }); | ||
apps/server/src/persistence/Services/Projects.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import type { | ||
| 2 | ProjectAddInput, | ||
| 3 | ProjectAddResult, | ||
| 4 | ProjectListResult, | ||
| 5 | ProjectRemoveInput, | ||
| 6 | ProjectUpdateScriptsInput, | ||
| 7 | ProjectUpdateScriptsResult, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { Context } from "effect"; | ||
| 10 | import type { Effect } from "effect"; | ||
| 11 | |||
| 12 | export interface ProjectRepositoryShape { | ||
| 13 | readonly list: () => Effect.Effect<ProjectListResult>; | ||
| 14 | readonly add: (input: ProjectAddInput) => Effect.Effect<ProjectAddResult, Error>; | ||
| 15 | readonly remove: (input: ProjectRemoveInput) => Effect.Effect<void, Error>; | ||
| 16 | readonly updateScripts: ( | ||
| 17 | input: ProjectUpdateScriptsInput, | ||
| 18 | ) => Effect.Effect<ProjectUpdateScriptsResult, Error>; | ||
| 19 | readonly pruneMissing: () => Effect.Effect<void>; | ||
| 20 | } | ||
| 21 | |||
| 22 | export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")< | ||
| 23 | ProjectRepository, | ||
| 24 | ProjectRepositoryShape | ||
| 25 | >() {} | ||
apps/server/src/projectRegistry.test.ts deleted-113| ... | @@ -1,113 +0,0 @@ | ||
| 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 { ProjectRegistry } from "./projectRegistry"; | ||
| 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("ProjectRegistry scripts", () => { | ||
| 24 | it("stores and reloads project scripts", () => { | ||
| 25 | const stateDir = makeTempDir("t3code-project-registry-state-"); | ||
| 26 | const projectDir = makeTempDir("t3code-project-registry-project-"); | ||
| 27 | const registry = new ProjectRegistry(stateDir); | ||
| 28 | |||
| 29 | const created = registry.add({ cwd: projectDir }).project; | ||
| 30 | const updated = registry.updateScripts({ | ||
| 31 | id: created.id, | ||
| 32 | scripts: [ | ||
| 33 | { | ||
| 34 | id: "run", | ||
| 35 | name: "Run", | ||
| 36 | command: "bun run dev", | ||
| 37 | icon: "play", | ||
| 38 | runOnWorktreeCreate: false, | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | id: "setup", | ||
| 42 | name: "Setup", | ||
| 43 | command: "bun install", | ||
| 44 | icon: "configure", | ||
| 45 | runOnWorktreeCreate: true, | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | }); | ||
| 49 | |||
| 50 | expect(updated.project.scripts).toHaveLength(2); | ||
| 51 | expect(updated.project.scripts[1]?.id).toBe("setup"); | ||
| 52 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); | ||
| 53 | |||
| 54 | const reloaded = new ProjectRegistry(stateDir); | ||
| 55 | const listed = reloaded.list(); | ||
| 56 | expect(listed).toHaveLength(1); | ||
| 57 | expect(listed[0]?.scripts).toHaveLength(2); | ||
| 58 | expect(listed[0]?.scripts[0]?.id).toBe("run"); | ||
| 59 | expect(listed[0]?.scripts[1]?.id).toBe("setup"); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("deduplicates script ids and allows only one setup script", () => { | ||
| 63 | const stateDir = makeTempDir("t3code-project-registry-state-"); | ||
| 64 | const projectDir = makeTempDir("t3code-project-registry-project-"); | ||
| 65 | const registry = new ProjectRegistry(stateDir); | ||
| 66 | const created = registry.add({ cwd: projectDir }).project; | ||
| 67 | |||
| 68 | const updated = registry.updateScripts({ | ||
| 69 | id: created.id, | ||
| 70 | scripts: [ | ||
| 71 | { | ||
| 72 | id: "setup", | ||
| 73 | name: "Setup", | ||
| 74 | command: "echo setup", | ||
| 75 | icon: "configure", | ||
| 76 | runOnWorktreeCreate: true, | ||
| 77 | }, | ||
| 78 | { | ||
| 79 | id: "setup", | ||
| 80 | name: "Setup Duplicate", | ||
| 81 | command: "echo duplicate", | ||
| 82 | icon: "configure", | ||
| 83 | runOnWorktreeCreate: true, | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | id: "another", | ||
| 87 | name: "Another Setup", | ||
| 88 | command: "echo another", | ||
| 89 | icon: "play", | ||
| 90 | runOnWorktreeCreate: true, | ||
| 91 | }, | ||
| 92 | ], | ||
| 93 | }); | ||
| 94 | |||
| 95 | expect(updated.project.scripts).toHaveLength(2); | ||
| 96 | expect(updated.project.scripts[0]?.id).toBe("setup"); | ||
| 97 | expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); | ||
| 98 | expect(updated.project.scripts[1]?.id).toBe("another"); | ||
| 99 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("throws when updating scripts for an unknown project id", () => { | ||
| 103 | const stateDir = makeTempDir("t3code-project-registry-state-"); | ||
| 104 | const registry = new ProjectRegistry(stateDir); | ||
| 105 | |||
| 106 | expect(() => | ||
| 107 | registry.updateScripts({ | ||
| 108 | id: "missing-project", | ||
| 109 | scripts: [], | ||
| 110 | }), | ||
| 111 | ).toThrowError("Project not found"); | ||
| 112 | }); | ||
| 113 | }); | ||
apps/server/src/projectRegistry.ts deleted-235| ... | @@ -1,235 +0,0 @@ | ||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import fs from "node:fs"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type ProjectAddInput, | ||
| 7 | type ProjectAddResult, | ||
| 8 | type ProjectListResult, | ||
| 9 | type ProjectRecord, | ||
| 10 | type ProjectRemoveInput, | ||
| 11 | type ProjectScript, | ||
| 12 | type ProjectUpdateScriptsInput, | ||
| 13 | type ProjectUpdateScriptsResult, | ||
| 14 | normalizeProjectScripts, | ||
| 15 | projectAddInputSchema, | ||
| 16 | projectRecordSchema, | ||
| 17 | projectRemoveInputSchema, | ||
| 18 | projectScriptsSchema, | ||
| 19 | projectUpdateScriptsInputSchema, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | |||
| 22 | function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | ||
| 23 | const cloned: ProjectScript[] = []; | ||
| 24 | for (const script of scripts) { | ||
| 25 | cloned.push({ | ||
| 26 | id: script.id, | ||
| 27 | name: script.name, | ||
| 28 | command: script.command, | ||
| 29 | icon: script.icon, | ||
| 30 | runOnWorktreeCreate: script.runOnWorktreeCreate, | ||
| 31 | }); | ||
| 32 | } | ||
| 33 | return cloned; | ||
| 34 | } | ||
| 35 | |||
| 36 | function normalizeCwd(rawCwd: string): string { | ||
| 37 | const resolved = path.resolve(rawCwd.trim()); | ||
| 38 | const normalized = path.normalize(resolved); | ||
| 39 | if (process.platform === "win32") { | ||
| 40 | return normalized.toLowerCase(); | ||
| 41 | } | ||
| 42 | return normalized; | ||
| 43 | } | ||
| 44 | |||
| 45 | function isDirectory(cwd: string): boolean { | ||
| 46 | try { | ||
| 47 | return fs.statSync(cwd).isDirectory(); | ||
| 48 | } catch { | ||
| 49 | return false; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | function inferProjectName(cwd: string): string { | ||
| 54 | const name = path.basename(cwd); | ||
| 55 | return name.length > 0 ? name : "project"; | ||
| 56 | } | ||
| 57 | |||
| 58 | export class ProjectRegistry { | ||
| 59 | private readonly stateDir: string; | ||
| 60 | private readonly filePath: string; | ||
| 61 | private projects: ProjectRecord[]; | ||
| 62 | |||
| 63 | constructor(stateDir: string) { | ||
| 64 | this.stateDir = stateDir; | ||
| 65 | this.filePath = path.join(this.stateDir, "projects.json"); | ||
| 66 | fs.mkdirSync(this.stateDir, { recursive: true }); | ||
| 67 | this.projects = this.loadFromDisk(); | ||
| 68 | } | ||
| 69 | |||
| 70 | list(): ProjectListResult { | ||
| 71 | return this.projects.map((project) => ({ | ||
| 72 | ...project, | ||
| 73 | scripts: cloneScripts(project.scripts), | ||
| 74 | })); | ||
| 75 | } | ||
| 76 | |||
| 77 | add(raw: ProjectAddInput): ProjectAddResult { | ||
| 78 | const input = projectAddInputSchema.parse(raw); | ||
| 79 | const normalizedCwd = normalizeCwd(input.cwd); | ||
| 80 | if (!isDirectory(normalizedCwd)) { | ||
| 81 | throw new Error(`Project path does not exist: ${normalizedCwd}`); | ||
| 82 | } | ||
| 83 | |||
| 84 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); | ||
| 85 | if (existing) { | ||
| 86 | return { | ||
| 87 | project: { | ||
| 88 | ...existing, | ||
| 89 | scripts: cloneScripts(existing.scripts), | ||
| 90 | }, | ||
| 91 | created: false, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | const now = new Date().toISOString(); | ||
| 96 | const project: ProjectRecord = { | ||
| 97 | id: randomUUID(), | ||
| 98 | cwd: normalizedCwd, | ||
| 99 | name: inferProjectName(normalizedCwd), | ||
| 100 | scripts: [], | ||
| 101 | createdAt: now, | ||
| 102 | updatedAt: now, | ||
| 103 | }; | ||
| 104 | |||
| 105 | this.projects = [project, ...this.projects]; | ||
| 106 | this.persist(); | ||
| 107 | return { | ||
| 108 | project: { | ||
| 109 | ...project, | ||
| 110 | scripts: cloneScripts(project.scripts), | ||
| 111 | }, | ||
| 112 | created: true, | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | remove(raw: ProjectRemoveInput): void { | ||
| 117 | const input = projectRemoveInputSchema.parse(raw); | ||
| 118 | const next = this.projects.filter((project) => project.id !== input.id); | ||
| 119 | if (next.length === this.projects.length) { | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | |||
| 123 | this.projects = next; | ||
| 124 | this.persist(); | ||
| 125 | } | ||
| 126 | |||
| 127 | updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { | ||
| 128 | const input = projectUpdateScriptsInputSchema.parse(raw); | ||
| 129 | const index = this.projects.findIndex((project) => project.id === input.id); | ||
| 130 | if (index < 0) { | ||
| 131 | throw new Error(`Project not found: ${input.id}`); | ||
| 132 | } | ||
| 133 | |||
| 134 | const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | ||
| 135 | const nextUpdatedAt = new Date().toISOString(); | ||
| 136 | const nextProject: ProjectRecord = { | ||
| 137 | ...this.projects[index]!, | ||
| 138 | scripts: nextScripts, | ||
| 139 | updatedAt: nextUpdatedAt, | ||
| 140 | }; | ||
| 141 | |||
| 142 | const nextProjects = [...this.projects]; | ||
| 143 | nextProjects[index] = nextProject; | ||
| 144 | this.projects = nextProjects; | ||
| 145 | this.persist(); | ||
| 146 | |||
| 147 | return { | ||
| 148 | project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | |||
| 152 | private loadFromDisk(): ProjectRecord[] { | ||
| 153 | const loaded = this.readPersistedProjects(); | ||
| 154 | const deduped = new Map<string, ProjectRecord>(); | ||
| 155 | const normalizedProjects: ProjectRecord[] = []; | ||
| 156 | |||
| 157 | for (const project of loaded) { | ||
| 158 | const normalizedCwd = normalizeCwd(project.cwd); | ||
| 159 | if (!isDirectory(normalizedCwd)) { | ||
| 160 | continue; | ||
| 161 | } | ||
| 162 | if (deduped.has(normalizedCwd)) { | ||
| 163 | continue; | ||
| 164 | } | ||
| 165 | |||
| 166 | const normalizedProject: ProjectRecord = { | ||
| 167 | ...project, | ||
| 168 | cwd: normalizedCwd, | ||
| 169 | name: | ||
| 170 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), | ||
| 171 | scripts: normalizeProjectScripts(project.scripts), | ||
| 172 | }; | ||
| 173 | deduped.set(normalizedCwd, normalizedProject); | ||
| 174 | normalizedProjects.push(normalizedProject); | ||
| 175 | } | ||
| 176 | |||
| 177 | const changed = | ||
| 178 | normalizedProjects.length !== loaded.length || | ||
| 179 | normalizedProjects.some((project, index) => { | ||
| 180 | const source = loaded[index]; | ||
| 181 | if (!source) return true; | ||
| 182 | return ( | ||
| 183 | source.id !== project.id || | ||
| 184 | source.cwd !== project.cwd || | ||
| 185 | source.name !== project.name || | ||
| 186 | JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || | ||
| 187 | source.createdAt !== project.createdAt || | ||
| 188 | source.updatedAt !== project.updatedAt | ||
| 189 | ); | ||
| 190 | }); | ||
| 191 | |||
| 192 | if (changed) { | ||
| 193 | this.projects = normalizedProjects; | ||
| 194 | this.persist(); | ||
| 195 | } | ||
| 196 | |||
| 197 | return normalizedProjects; | ||
| 198 | } | ||
| 199 | |||
| 200 | private readPersistedProjects(): ProjectRecord[] { | ||
| 201 | try { | ||
| 202 | const raw = fs.readFileSync(this.filePath, "utf8"); | ||
| 203 | const parsed = JSON.parse(raw) as { projects?: unknown }; | ||
| 204 | if (!parsed || !Array.isArray(parsed.projects)) { | ||
| 205 | return []; | ||
| 206 | } | ||
| 207 | |||
| 208 | const projects: ProjectRecord[] = []; | ||
| 209 | for (const candidate of parsed.projects) { | ||
| 210 | const row = projectRecordSchema.safeParse(candidate); | ||
| 211 | if (!row.success) { | ||
| 212 | continue; | ||
| 213 | } | ||
| 214 | projects.push(row.data); | ||
| 215 | } | ||
| 216 | return projects; | ||
| 217 | } catch { | ||
| 218 | return []; | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | private persist(): void { | ||
| 223 | const payload = JSON.stringify( | ||
| 224 | { | ||
| 225 | version: 1, | ||
| 226 | projects: this.projects, | ||
| 227 | }, | ||
| 228 | null, | ||
| 229 | 2, | ||
| 230 | ); | ||
| 231 | const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`; | ||
| 232 | fs.writeFileSync(tempFile, payload); | ||
| 233 | fs.renameSync(tempFile, this.filePath); | ||
| 234 | } | ||
| 235 | } | ||
apps/server/src/wsServer.test.ts+21-32| ... | @@ -3,6 +3,7 @@ import os from "node:os"; | ... | @@ -3,6 +3,7 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | import { EventEmitter } from "node:events"; | 4 | import { EventEmitter } from "node:events"; |
| 5 | 5 | ||
| 6 | import { Layer, ManagedRuntime } from "effect"; | ||
| 6 | import { describe, expect, it, afterEach, vi } from "vitest"; | 7 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 7 | import { createServer } from "./wsServer"; | 8 | import { createServer } from "./wsServer"; |
| 8 | import WebSocket from "ws"; | 9 | import WebSocket from "ws"; |
| ... | @@ -16,8 +17,8 @@ import { | ... | @@ -16,8 +17,8 @@ import { |
| 16 | type WsPush, | 17 | type WsPush, |
| 17 | type WsResponse, | 18 | type WsResponse, |
| 18 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 19 | import { ProjectRegistry } from "./projectRegistry"; | ||
| 20 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | 20 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| 21 | import { ProjectRepository } from "./persistence/Services/Projects"; | ||
| 21 | import type { | 22 | import type { |
| 22 | TerminalClearInput, | 23 | TerminalClearInput, |
| 23 | TerminalCloseInput, | 24 | TerminalCloseInput, |
| ... | @@ -28,6 +29,8 @@ import type { | ... | @@ -28,6 +29,8 @@ import type { |
| 28 | TerminalWriteInput, | 29 | TerminalWriteInput, |
| 29 | } from "@t3tools/contracts"; | 30 | } from "@t3tools/contracts"; |
| 30 | import type { TerminalManager } from "./terminalManager"; | 31 | import type { TerminalManager } from "./terminalManager"; |
| 32 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | ||
| 33 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; | ||
| 31 | 34 | ||
| 32 | interface PendingMessages { | 35 | interface PendingMessages { |
| 33 | queue: unknown[]; | 36 | queue: unknown[]; |
| ... | @@ -249,9 +252,9 @@ describe("WebSocket Server", () => { | ... | @@ -249,9 +252,9 @@ describe("WebSocket Server", () => { |
| 249 | return createServer({ | 252 | return createServer({ |
| 250 | port: 0, | 253 | port: 0, |
| 251 | cwd: options.cwd ?? "/test/project", | 254 | cwd: options.cwd ?? "/test/project", |
| 255 | stateDir, | ||
| 252 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), | 256 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), |
| 253 | ...(options.authToken ? { authToken: options.authToken } : {}), | 257 | ...(options.authToken ? { authToken: options.authToken } : {}), |
| 254 | projectRegistry: new ProjectRegistry(stateDir), | ||
| 255 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | 258 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), |
| 256 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), | 259 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), |
| 257 | }); | 260 | }); |
| ... | @@ -888,7 +891,11 @@ describe("WebSocket Server", () => { | ... | @@ -888,7 +891,11 @@ describe("WebSocket Server", () => { |
| 888 | it("supports projects.searchEntries", async () => { | 891 | it("supports projects.searchEntries", async () => { |
| 889 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); | 892 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); |
| 890 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); | 893 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); |
| 891 | fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); | 894 | fs.writeFileSync( |
| 895 | path.join(workspace, "src", "components", "Composer.tsx"), | ||
| 896 | "export {};", | ||
| 897 | "utf8", | ||
| 898 | ); | ||
| 892 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); | 899 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); |
| 893 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); | 900 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); |
| 894 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); | 901 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); |
| ... | @@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => { | ... | @@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => { |
| 1025 | it("prunes missing projects on startup", async () => { | 1032 | it("prunes missing projects on startup", async () => { |
| 1026 | const stateDir = makeTempDir("t3code-ws-prune-state-"); | 1033 | const stateDir = makeTempDir("t3code-ws-prune-state-"); |
| 1027 | const existing = makeTempDir("t3code-ws-existing-project-"); | 1034 | const existing = makeTempDir("t3code-ws-existing-project-"); |
| 1028 | const missing = path.join(stateDir, "definitely-missing"); | 1035 | const missing = makeTempDir("t3code-ws-missing-project-"); |
| 1029 | const now = new Date().toISOString(); | ||
| 1030 | const projectsFile = path.join(stateDir, "projects.json"); | ||
| 1031 | 1036 | ||
| 1032 | fs.writeFileSync( | 1037 | const projectRuntime = ManagedRuntime.make( |
| 1033 | projectsFile, | 1038 | ProjectRepositoryLive.pipe( |
| 1034 | JSON.stringify( | 1039 | Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))), |
| 1035 | { | ||
| 1036 | version: 1, | ||
| 1037 | projects: [ | ||
| 1038 | { | ||
| 1039 | id: "project-existing", | ||
| 1040 | cwd: existing, | ||
| 1041 | name: "existing", | ||
| 1042 | createdAt: now, | ||
| 1043 | updatedAt: now, | ||
| 1044 | }, | ||
| 1045 | { | ||
| 1046 | id: "project-missing", | ||
| 1047 | cwd: missing, | ||
| 1048 | name: "missing", | ||
| 1049 | createdAt: now, | ||
| 1050 | updatedAt: now, | ||
| 1051 | }, | ||
| 1052 | ], | ||
| 1053 | }, | ||
| 1054 | null, | ||
| 1055 | 2, | ||
| 1056 | ), | 1040 | ), |
| 1057 | ); | 1041 | ); |
| 1042 | const projectRepository = await projectRuntime.runPromise(ProjectRepository); | ||
| 1043 | await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); | ||
| 1044 | await projectRuntime.runPromise(projectRepository.add({ cwd: missing })); | ||
| 1045 | await projectRuntime.dispose(); | ||
| 1046 | fs.rmSync(missing, { recursive: true, force: true }); | ||
| 1058 | 1047 | ||
| 1059 | server = createTestServer({ stateDir, cwd: "/test" }); | 1048 | server = createTestServer({ stateDir, cwd: "/test" }); |
| 1060 | await server.start(); | 1049 | await server.start(); |
| ... | @@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => { | ... | @@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => { |
| 1067 | 1056 | ||
| 1068 | const response = await sendRequest(ws, WS_METHODS.projectsList); | 1057 | const response = await sendRequest(ws, WS_METHODS.projectsList); |
| 1069 | expect(response.error).toBeUndefined(); | 1058 | expect(response.error).toBeUndefined(); |
| 1070 | const listed = response.result as Array<{ id: string }>; | 1059 | const listed = response.result as Array<{ cwd: string }>; |
| 1071 | expect(listed).toHaveLength(1); | 1060 | expect(listed).toHaveLength(1); |
| 1072 | expect(listed[0]?.id).toBe("project-existing"); | 1061 | expect(listed[0]?.cwd).toBe(existing); |
| 1073 | }); | 1062 | }); |
| 1074 | 1063 | ||
| 1075 | it("rejects websocket connections without a valid auth token", async () => { | 1064 | it("rejects websocket connections without a valid auth token", async () => { |
apps/server/src/wsServer.ts+150-137| ... | @@ -18,10 +18,10 @@ import { | ... | @@ -18,10 +18,10 @@ import { |
| 18 | type WsResponse, | 18 | type WsResponse, |
| 19 | wsRequestSchema, | 19 | wsRequestSchema, |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { Layer, ManagedRuntime } from "effect"; | ||
| 21 | import { WebSocketServer, type WebSocket } from "ws"; | 22 | import { WebSocketServer, type WebSocket } from "ws"; |
| 22 | 23 | ||
| 23 | import { createLogger } from "./logger"; | 24 | import { createLogger } from "./logger"; |
| 24 | import { ProjectRegistry } from "./projectRegistry"; | ||
| 25 | import { ProviderManager } from "./providerManager"; | 25 | import { ProviderManager } from "./providerManager"; |
| 26 | import { GitManager } from "./gitManager"; | 26 | import { GitManager } from "./gitManager"; |
| 27 | import { | 27 | import { |
| ... | @@ -36,7 +36,13 @@ import { | ... | @@ -36,7 +36,13 @@ 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 { createOrchestrationSystem, type OrchestrationSystem } from "./orchestration/runtime"; | 39 | import type { OrchestrationEngine } from "./orchestration/engine"; |
| 40 | import { OrchestrationLive } from "./orchestration/layers"; | ||
| 41 | import { OrchestrationEngineService } from "./orchestration/services"; | ||
| 42 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | ||
| 43 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; | ||
| 44 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; | ||
| 45 | import assert from "node:assert"; | ||
| 40 | 46 | ||
| 41 | const MIME_TYPES: Record<string, string> = { | 47 | const MIME_TYPES: Record<string, string> = { |
| 42 | ".html": "text/html; charset=utf-8", | 48 | ".html": "text/html; charset=utf-8", |
| ... | @@ -61,12 +67,31 @@ export interface ServerOptions { | ... | @@ -61,12 +67,31 @@ export interface ServerOptions { |
| 61 | staticDir?: string | undefined; | 67 | staticDir?: string | undefined; |
| 62 | devUrl?: string | undefined; | 68 | devUrl?: string | undefined; |
| 63 | logWebSocketEvents?: boolean | undefined; | 69 | logWebSocketEvents?: boolean | undefined; |
| 64 | projectRegistry?: ProjectRegistry | undefined; | ||
| 65 | gitManager?: GitManager | undefined; | 70 | gitManager?: GitManager | undefined; |
| 66 | terminalManager?: TerminalManager | undefined; | 71 | terminalManager?: TerminalManager | undefined; |
| 67 | authToken?: string | undefined; | 72 | authToken?: string | undefined; |
| 68 | } | 73 | } |
| 69 | 74 | ||
| 75 | const isServerNotRunningError = (error: unknown): boolean => { | ||
| 76 | if (!(error instanceof Error)) return false; | ||
| 77 | const maybeCode = (error as NodeJS.ErrnoException).code; | ||
| 78 | return ( | ||
| 79 | maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") | ||
| 80 | ); | ||
| 81 | }; | ||
| 82 | |||
| 83 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | ||
| 84 | socket.write( | ||
| 85 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | ||
| 86 | "Connection: close\r\n" + | ||
| 87 | "Content-Type: text/plain\r\n" + | ||
| 88 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | ||
| 89 | "\r\n" + | ||
| 90 | message, | ||
| 91 | ); | ||
| 92 | socket.destroy(); | ||
| 93 | } | ||
| 94 | |||
| 70 | function parseBooleanEnv(value: string | undefined): boolean | undefined { | 95 | function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 71 | if (value === undefined) return undefined; | 96 | if (value === undefined) return undefined; |
| 72 | const normalized = value.trim().toLowerCase(); | 97 | const normalized = value.trim().toLowerCase(); |
| ... | @@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) { | ... | @@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) { |
| 99 | staticDir, | 124 | staticDir, |
| 100 | devUrl, | 125 | devUrl, |
| 101 | logWebSocketEvents: explicitLogWsEvents, | 126 | logWebSocketEvents: explicitLogWsEvents, |
| 102 | projectRegistry: providedRegistry, | 127 | gitManager = new GitManager(), |
| 103 | gitManager: providedGitManager, | 128 | terminalManager = new TerminalManager(), |
| 104 | terminalManager: providedTerminalManager, | ||
| 105 | authToken, | 129 | authToken, |
| 106 | } = options; | 130 | } = options; |
| 107 | const providerManager = new ProviderManager(); | 131 | const providerManager = new ProviderManager(); |
| 108 | const terminalManager = providedTerminalManager ?? new TerminalManager(); | 132 | |
| 109 | const projectRegistry = | ||
| 110 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | ||
| 111 | const gitManager = providedGitManager ?? new GitManager(); | ||
| 112 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); | 133 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); |
| 113 | let orchestrationSystem: OrchestrationSystem | null = null; | 134 | let effectRuntime: ManagedRuntime.ManagedRuntime< |
| 135 | ProjectRepository | OrchestrationEngineService, | ||
| 136 | unknown | ||
| 137 | > | null = null; | ||
| 138 | let projectRepository: ProjectRepositoryShape | null = null; | ||
| 139 | let orchestrationEngine: OrchestrationEngine | null = null; | ||
| 114 | const clients = new Set<WebSocket>(); | 140 | const clients = new Set<WebSocket>(); |
| 115 | const logger = createLogger("ws"); | 141 | const logger = createLogger("ws"); |
| 116 | const logWebSocketEvents = | 142 | const logWebSocketEvents = |
| ... | @@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) { | ... | @@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) { |
| 128 | }); | 154 | }); |
| 129 | } | 155 | } |
| 130 | 156 | ||
| 131 | function requireOrchestrationEngine() { | 157 | const getOrchestrationEngine = () => { |
| 132 | if (!orchestrationSystem) { | 158 | assert(orchestrationEngine, "Orchestration engine is not started"); |
| 133 | throw new Error("Orchestration engine is not started"); | 159 | return orchestrationEngine; |
| 134 | } | 160 | }; |
| 135 | return orchestrationSystem.engine; | 161 | |
| 136 | } | 162 | const getProjectRepository = () => { |
| 163 | assert(projectRepository, "Project repository is not started"); | ||
| 164 | return projectRepository; | ||
| 165 | }; | ||
| 166 | |||
| 167 | const getEffectRuntime = () => { | ||
| 168 | assert(effectRuntime, "Effect runtime is not started"); | ||
| 169 | return effectRuntime; | ||
| 170 | }; | ||
| 137 | 171 | ||
| 138 | function attachOrchestrationSubscriptions() { | 172 | function attachOrchestrationSubscriptions() { |
| 139 | const orchestrationEngine = requireOrchestrationEngine(); | 173 | const orchestrationEngine = getOrchestrationEngine(); |
| 140 | 174 | ||
| 141 | unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { | 175 | unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { |
| 142 | const push: WsPush = { | 176 | const push: WsPush = { |
| ... | @@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) { | ... | @@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) { |
| 179 | // Forward provider events to all connected WebSocket clients | 213 | // Forward provider events to all connected WebSocket clients |
| 180 | providerManager.on("event", (event) => { | 214 | providerManager.on("event", (event) => { |
| 181 | void (async () => { | 215 | void (async () => { |
| 182 | const liveOrchestrationSystem = orchestrationSystem; | 216 | const liveOrchestrationEngine = orchestrationEngine; |
| 183 | if (!liveOrchestrationSystem) { | 217 | if (!liveOrchestrationEngine) { |
| 184 | return; | 218 | return; |
| 185 | } | 219 | } |
| 186 | const orchestrationEngine = liveOrchestrationSystem.engine; | 220 | const snapshot = liveOrchestrationEngine.getSnapshot(); |
| 187 | const snapshot = orchestrationEngine.getSnapshot(); | ||
| 188 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); | 221 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); |
| 189 | if (!thread) return; | 222 | if (!thread) return; |
| 190 | const now = event.createdAt; | 223 | const now = event.createdAt; |
| 191 | if (event.method === "turn/started" || event.method === "turn/completed") { | 224 | if (event.method === "turn/started" || event.method === "turn/completed") { |
| 192 | await orchestrationEngine.dispatch({ | 225 | await liveOrchestrationEngine.dispatch({ |
| 193 | type: "thread.session", | 226 | type: "thread.session", |
| 194 | commandId: crypto.randomUUID(), | 227 | commandId: crypto.randomUUID(), |
| 195 | threadId: thread.id, | 228 | threadId: thread.id, |
| ... | @@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) { |
| 208 | } | 241 | } |
| 209 | if (event.textDelta && event.textDelta.length > 0) { | 242 | if (event.textDelta && event.textDelta.length > 0) { |
| 210 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; | 243 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 211 | await orchestrationEngine.dispatch({ | 244 | await liveOrchestrationEngine.dispatch({ |
| 212 | type: "message.send", | 245 | type: "message.send", |
| 213 | commandId: crypto.randomUUID(), | 246 | commandId: crypto.randomUUID(), |
| 214 | threadId: thread.id, | 247 | threadId: thread.id, |
| ... | @@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) { |
| 221 | } | 254 | } |
| 222 | if (event.method === "turn/completed") { | 255 | if (event.method === "turn/completed") { |
| 223 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; | 256 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 224 | await orchestrationEngine.dispatch({ | 257 | await liveOrchestrationEngine.dispatch({ |
| 225 | type: "message.send", | 258 | type: "message.send", |
| 226 | commandId: crypto.randomUUID(), | 259 | commandId: crypto.randomUUID(), |
| 227 | threadId: thread.id, | 260 | threadId: thread.id, |
| ... | @@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) { | ... | @@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) { |
| 236 | const payload = asObject(event.payload); | 269 | const payload = asObject(event.payload); |
| 237 | const turnId = event.turnId ?? asString(payload?.turnId); | 270 | const turnId = event.turnId ?? asString(payload?.turnId); |
| 238 | if (turnId) { | 271 | if (turnId) { |
| 239 | await orchestrationEngine.dispatch({ | 272 | await liveOrchestrationEngine.dispatch({ |
| 240 | type: "thread.turnDiff.complete", | 273 | type: "thread.turnDiff.complete", |
| 241 | commandId: crypto.randomUUID(), | 274 | commandId: crypto.randomUUID(), |
| 242 | threadId: thread.id, | 275 | threadId: thread.id, |
| 243 | turnId, | 276 | turnId, |
| 244 | completedAt: now, | 277 | completedAt: now, |
| 245 | ...(asString(payload?.status) !== null ? { status: asString(payload?.status) ?? undefined } : {}), | 278 | ...(asString(payload?.status) !== null |
| 279 | ? { status: asString(payload?.status) ?? undefined } | ||
| 280 | : {}), | ||
| 246 | files: [], | 281 | files: [], |
| 247 | assistantMessageId: `assistant:${turnId}`, | 282 | assistantMessageId: `assistant:${turnId}`, |
| 248 | ...(asNumber(payload?.turnCount) !== null | 283 | ...(asNumber(payload?.turnCount) !== null |
| ... | @@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) { |
| 253 | } | 288 | } |
| 254 | } | 289 | } |
| 255 | if (event.kind === "error" && event.message) { | 290 | if (event.kind === "error" && event.message) { |
| 256 | await orchestrationEngine.dispatch({ | 291 | await liveOrchestrationEngine.dispatch({ |
| 257 | type: "thread.session", | 292 | type: "thread.session", |
| 258 | commandId: crypto.randomUUID(), | 293 | commandId: crypto.randomUUID(), |
| 259 | threadId: thread.id, | 294 | threadId: thread.id, |
| ... | @@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) { | ... | @@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) { |
| 350 | // WebSocket server — upgrades from the HTTP server | 385 | // WebSocket server — upgrades from the HTTP server |
| 351 | const wss = new WebSocketServer({ noServer: true }); | 386 | const wss = new WebSocketServer({ noServer: true }); |
| 352 | 387 | ||
| 353 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | ||
| 354 | socket.write( | ||
| 355 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | ||
| 356 | "Connection: close\r\n" + | ||
| 357 | "Content-Type: text/plain\r\n" + | ||
| 358 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | ||
| 359 | "\r\n" + | ||
| 360 | message, | ||
| 361 | ); | ||
| 362 | socket.destroy(); | ||
| 363 | } | ||
| 364 | |||
| 365 | httpServer.on("upgrade", (request, socket, head) => { | 388 | httpServer.on("upgrade", (request, socket, head) => { |
| 366 | if (authToken) { | 389 | if (authToken) { |
| 367 | let providedToken: string | null = null; | 390 | let providedToken: string | null = null; |
| ... | @@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) { |
| 386 | 409 | ||
| 387 | wss.on("connection", (ws) => { | 410 | wss.on("connection", (ws) => { |
| 388 | clients.add(ws); | 411 | clients.add(ws); |
| 389 | const orchestrationEngine = requireOrchestrationEngine(); | 412 | const orchestrationEngine = getOrchestrationEngine(); |
| 390 | 413 | ||
| 391 | // Send welcome message with project info | 414 | // Send welcome message with project info |
| 392 | const segments = cwd.split(/[/\\]/).filter(Boolean); | 415 | const segments = cwd.split(/[/\\]/).filter(Boolean); |
| ... | @@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) { | ... | @@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) { |
| 453 | } | 476 | } |
| 454 | 477 | ||
| 455 | async function routeRequest(request: WsRequest): Promise<unknown> { | 478 | async function routeRequest(request: WsRequest): Promise<unknown> { |
| 456 | const orchestrationEngine = requireOrchestrationEngine(); | 479 | const orchestrationEngine = getOrchestrationEngine(); |
| 480 | const projectRepository = getProjectRepository(); | ||
| 481 | const effectRuntime = getEffectRuntime(); | ||
| 457 | 482 | ||
| 458 | switch (request.method) { | 483 | switch (request.method) { |
| 459 | case WS_METHODS.providersStartSession: | 484 | case WS_METHODS.providersStartSession: |
| ... | @@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) { | ... | @@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) { |
| 479 | case WS_METHODS.providersGetCheckpointDiff: | 504 | case WS_METHODS.providersGetCheckpointDiff: |
| 480 | return providerManager.getCheckpointDiff(request.params as never); | 505 | return providerManager.getCheckpointDiff(request.params as never); |
| 481 | 506 | ||
| 482 | case WS_METHODS.providersRevertToCheckpoint: | 507 | case WS_METHODS.providersRevertToCheckpoint: { |
| 483 | { | 508 | const result = await providerManager.revertToCheckpoint(request.params as never); |
| 484 | const result = await providerManager.revertToCheckpoint(request.params as never); | 509 | const params = request.params as { sessionId?: string }; |
| 485 | const params = request.params as { sessionId?: string }; | 510 | const sessionId = params.sessionId; |
| 486 | const sessionId = params.sessionId; | 511 | if (typeof sessionId === "string") { |
| 487 | if (typeof sessionId === "string") { | 512 | const snapshot = orchestrationEngine.getSnapshot(); |
| 488 | const snapshot = orchestrationEngine.getSnapshot(); | 513 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); |
| 489 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); | 514 | if (thread) { |
| 490 | if (thread) { | 515 | const now = new Date().toISOString(); |
| 491 | const now = new Date().toISOString(); | 516 | await orchestrationEngine.dispatch({ |
| 517 | type: "thread.revert", | ||
| 518 | commandId: crypto.randomUUID(), | ||
| 519 | threadId: thread.id, | ||
| 520 | turnCount: result.turnCount, | ||
| 521 | messageCount: result.messageCount, | ||
| 522 | createdAt: now, | ||
| 523 | }); | ||
| 524 | if (thread.session) { | ||
| 492 | await orchestrationEngine.dispatch({ | 525 | await orchestrationEngine.dispatch({ |
| 493 | type: "thread.revert", | 526 | type: "thread.session", |
| 494 | commandId: crypto.randomUUID(), | 527 | commandId: crypto.randomUUID(), |
| 495 | threadId: thread.id, | 528 | threadId: thread.id, |
| 496 | turnCount: result.turnCount, | 529 | session: { |
| 497 | messageCount: result.messageCount, | 530 | ...thread.session, |
| 531 | status: "ready", | ||
| 532 | activeTurnId: null, | ||
| 533 | updatedAt: now, | ||
| 534 | lastError: null, | ||
| 535 | }, | ||
| 498 | createdAt: now, | 536 | createdAt: now, |
| 499 | }); | 537 | }); |
| 500 | if (thread.session) { | ||
| 501 | await orchestrationEngine.dispatch({ | ||
| 502 | type: "thread.session", | ||
| 503 | commandId: crypto.randomUUID(), | ||
| 504 | threadId: thread.id, | ||
| 505 | session: { | ||
| 506 | ...thread.session, | ||
| 507 | status: "ready", | ||
| 508 | activeTurnId: null, | ||
| 509 | updatedAt: now, | ||
| 510 | lastError: null, | ||
| 511 | }, | ||
| 512 | createdAt: now, | ||
| 513 | }); | ||
| 514 | } | ||
| 515 | } | 538 | } |
| 516 | } | 539 | } |
| 517 | return result; | ||
| 518 | } | 540 | } |
| 541 | return result; | ||
| 542 | } | ||
| 519 | 543 | ||
| 520 | case WS_METHODS.projectsList: | 544 | case WS_METHODS.projectsList: |
| 521 | return projectRegistry.list(); | 545 | return effectRuntime.runPromise(projectRepository.list()); |
| 522 | 546 | ||
| 523 | case WS_METHODS.projectsAdd: | 547 | case WS_METHODS.projectsAdd: |
| 524 | { | 548 | return effectRuntime.runPromise(projectRepository.add(request.params as never)); |
| 525 | const result = await projectRegistry.add(request.params as never); | ||
| 526 | await orchestrationEngine.dispatch({ | ||
| 527 | type: "project.create", | ||
| 528 | commandId: crypto.randomUUID(), | ||
| 529 | projectId: result.project.id, | ||
| 530 | name: result.project.name, | ||
| 531 | cwd: result.project.cwd, | ||
| 532 | model: "gpt-5-codex", | ||
| 533 | createdAt: new Date().toISOString(), | ||
| 534 | }); | ||
| 535 | return result; | ||
| 536 | } | ||
| 537 | 549 | ||
| 538 | case WS_METHODS.projectsRemove: | 550 | case WS_METHODS.projectsRemove: |
| 539 | { | 551 | return effectRuntime.runPromise(projectRepository.remove(request.params as never)); |
| 540 | const params = request.params as { id: string }; | ||
| 541 | projectRegistry.remove(request.params as never); | ||
| 542 | await orchestrationEngine.dispatch({ | ||
| 543 | type: "project.delete", | ||
| 544 | commandId: crypto.randomUUID(), | ||
| 545 | projectId: params.id, | ||
| 546 | createdAt: new Date().toISOString(), | ||
| 547 | }); | ||
| 548 | return undefined; | ||
| 549 | } | ||
| 550 | 552 | ||
| 551 | case WS_METHODS.projectsSearchEntries: | 553 | case WS_METHODS.projectsSearchEntries: |
| 552 | return searchWorkspaceEntries(request.params as never); | 554 | return searchWorkspaceEntries(request.params as never); |
| 553 | case WS_METHODS.projectsUpdateScripts: | 555 | case WS_METHODS.projectsUpdateScripts: |
| 554 | return projectRegistry.updateScripts(request.params as never); | 556 | return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never)); |
| 555 | 557 | ||
| 556 | case WS_METHODS.shellOpenInEditor: { | 558 | case WS_METHODS.shellOpenInEditor: { |
| 557 | const params = request.params as { | 559 | const params = request.params as { |
| ... | @@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) { | ... | @@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) { |
| 597 | return undefined; | 599 | return undefined; |
| 598 | } | 600 | } |
| 599 | 601 | ||
| 600 | case WS_METHODS.gitStatus: | 602 | case WS_METHODS.gitStatus: { |
| 601 | { | 603 | const params = request.params as { cwd: string }; |
| 602 | const params = request.params as { cwd: string }; | 604 | const status = await gitManager.status(request.params as never); |
| 603 | const status = await gitManager.status(request.params as never); | 605 | const projects = await effectRuntime.runPromise(projectRepository.list()); |
| 604 | const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd); | 606 | const project = projects.find((entry) => entry.cwd === params.cwd); |
| 605 | if (project) { | 607 | if (project) { |
| 606 | await orchestrationEngine.dispatch({ | 608 | await orchestrationEngine.dispatch({ |
| 607 | type: "git.readModel.upsert", | 609 | type: "git.readModel.upsert", |
| 608 | commandId: crypto.randomUUID(), | 610 | commandId: crypto.randomUUID(), |
| 609 | projectId: project.id, | 611 | projectId: project.id, |
| 610 | branch: status.branch, | 612 | branch: status.branch, |
| 611 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, | 613 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, |
| 612 | aheadCount: status.aheadCount, | 614 | aheadCount: status.aheadCount, |
| 613 | behindCount: status.behindCount, | 615 | behindCount: status.behindCount, |
| 614 | createdAt: new Date().toISOString(), | 616 | createdAt: new Date().toISOString(), |
| 615 | }); | 617 | }); |
| 616 | } | ||
| 617 | return status; | ||
| 618 | } | 618 | } |
| 619 | return status; | ||
| 620 | } | ||
| 619 | 621 | ||
| 620 | case WS_METHODS.gitPull: | 622 | case WS_METHODS.gitPull: |
| 621 | return pullGitBranch(request.params as never); | 623 | return pullGitBranch(request.params as never); |
| ... | @@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) { | ... | @@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) { |
| 698 | } | 700 | } |
| 699 | } | 701 | } |
| 700 | 702 | ||
| 701 | async function ensureOrchestrationSystemStarted() { | 703 | async function createEffectRuntime() { |
| 702 | if (orchestrationSystem) { | 704 | const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); |
| 703 | return; | 705 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 706 | const runtime = ManagedRuntime.make( | ||
| 707 | Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe( | ||
| 708 | Layer.provide(persistenceLayer), | ||
| 709 | ), | ||
| 710 | ); | ||
| 711 | |||
| 712 | try { | ||
| 713 | const [nextOrchestrationEngine, repository] = await Promise.all([ | ||
| 714 | runtime.runPromise(OrchestrationEngineService), | ||
| 715 | runtime.runPromise(ProjectRepository), | ||
| 716 | ]); | ||
| 717 | orchestrationEngine = nextOrchestrationEngine; | ||
| 718 | projectRepository = repository; | ||
| 719 | await runtime.runPromise(repository.pruneMissing()); | ||
| 720 | attachOrchestrationSubscriptions(); | ||
| 721 | } catch (error) { | ||
| 722 | await runtime.dispose().catch(() => undefined); | ||
| 723 | throw error; | ||
| 704 | } | 724 | } |
| 705 | 725 | ||
| 706 | orchestrationSystem = await createOrchestrationSystem(resolvedStateDir); | 726 | return runtime; |
| 707 | attachOrchestrationSubscriptions(); | ||
| 708 | } | 727 | } |
| 709 | 728 | ||
| 710 | async function disposeOrchestrationSystem() { | 729 | async function disposeEffectRuntime() { |
| 711 | unsubscribeReadModel(); | 730 | unsubscribeReadModel(); |
| 712 | unsubscribeDomainEvents(); | 731 | unsubscribeDomainEvents(); |
| 713 | unsubscribeReadModel = noop; | 732 | unsubscribeReadModel = noop; |
| 714 | unsubscribeDomainEvents = noop; | 733 | unsubscribeDomainEvents = noop; |
| 715 | 734 | ||
| 716 | if (!orchestrationSystem) { | 735 | const runtime = effectRuntime; |
| 736 | effectRuntime = null; | ||
| 737 | orchestrationEngine = null; | ||
| 738 | projectRepository = null; | ||
| 739 | |||
| 740 | if (!runtime) { | ||
| 717 | return; | 741 | return; |
| 718 | } | 742 | } |
| 719 | 743 | ||
| 720 | const system = orchestrationSystem; | 744 | await runtime.dispose(); |
| 721 | orchestrationSystem = null; | ||
| 722 | await system.dispose(); | ||
| 723 | } | 745 | } |
| 724 | 746 | ||
| 725 | async function start() { | 747 | async function start() { |
| 726 | await ensureOrchestrationSystemStarted(); | 748 | effectRuntime = await createEffectRuntime(); |
| 727 | 749 | ||
| 728 | return new Promise<void>((resolve, reject) => { | 750 | return new Promise<void>((resolve, reject) => { |
| 729 | const onError = (error: Error) => { | 751 | const onError = (error: Error) => { |
| 730 | httpServer.off("error", onError); | 752 | httpServer.off("error", onError); |
| 731 | void disposeOrchestrationSystem().finally(() => reject(error)); | 753 | void disposeEffectRuntime().finally(() => reject(error)); |
| 732 | }; | 754 | }; |
| 733 | httpServer.once("error", onError); | 755 | httpServer.once("error", onError); |
| 734 | const onListening = () => { | 756 | const onListening = () => { |
| ... | @@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) { |
| 744 | } | 766 | } |
| 745 | 767 | ||
| 746 | async function stop(): Promise<void> { | 768 | async function stop(): Promise<void> { |
| 747 | await disposeOrchestrationSystem(); | 769 | await disposeEffectRuntime(); |
| 748 | terminalManager.off("event", onTerminalEvent); | 770 | terminalManager.off("event", onTerminalEvent); |
| 749 | providerManager.stopAll(); | 771 | providerManager.stopAll(); |
| 750 | providerManager.dispose(); | 772 | providerManager.dispose(); |
| ... | @@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) { | ... | @@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) { |
| 755 | } | 777 | } |
| 756 | clients.clear(); | 778 | clients.clear(); |
| 757 | 779 | ||
| 758 | const isServerNotRunningError = (error: unknown): boolean => { | ||
| 759 | if (!(error instanceof Error)) return false; | ||
| 760 | const maybeCode = (error as NodeJS.ErrnoException).code; | ||
| 761 | return ( | ||
| 762 | maybeCode === "ERR_SERVER_NOT_RUNNING" || | ||
| 763 | error.message.toLowerCase().includes("not running") | ||
| 764 | ); | ||
| 765 | }; | ||
| 766 | |||
| 767 | const closeWebSocketServer = new Promise<void>((resolve, reject) => { | 780 | const closeWebSocketServer = new Promise<void>((resolve, reject) => { |
| 768 | wss.close((error) => { | 781 | wss.close((error) => { |
| 769 | if (error && !isServerNotRunningError(error)) { | 782 | if (error && !isServerNotRunningError(error)) { |
apps/web/src/components/Sidebar.tsx+23-44| ... | @@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string { | ... | @@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string { |
| 28 | return `${Math.floor(hours / 24)}d ago`; | 28 | return `${Math.floor(hours / 24)}d ago`; |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | function inferProjectName(cwd: string): string { | ||
| 32 | const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 33 | const parts = normalized.split("/"); | ||
| 34 | const last = parts[parts.length - 1]; | ||
| 35 | return last && last.length > 0 ? last : "project"; | ||
| 36 | } | ||
| 37 | |||
| 38 | interface ThreadStatusPill { | 31 | interface ThreadStatusPill { |
| 39 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | 32 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; |
| 40 | colorClass: string; | 33 | colorClass: string; |
| ... | @@ -195,23 +188,16 @@ export default function Sidebar() { | ... | @@ -195,23 +188,16 @@ export default function Sidebar() { |
| 195 | return; | 188 | return; |
| 196 | } | 189 | } |
| 197 | 190 | ||
| 198 | const projectId = crypto.randomUUID(); | 191 | const result = await api.projects.add({ cwd }); |
| 199 | const now = new Date().toISOString(); | 192 | const projects = await api.projects.list(); |
| 200 | const name = inferProjectName(cwd); | 193 | dispatch({ type: "SYNC_PROJECTS", projects }); |
| 201 | if (isElectron) { | 194 | |
| 202 | // Keep project registry in sync for desktop integrations. | 195 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); |
| 203 | await api.projects.add({ cwd }); | 196 | if (hasThread) { |
| 197 | focusMostRecentThreadForProject(result.project.id); | ||
| 198 | return; | ||
| 204 | } | 199 | } |
| 205 | await api.orchestration.dispatchCommand({ | 200 | handleNewThread(result.project.id); |
| 206 | type: "project.create", | ||
| 207 | commandId: crypto.randomUUID(), | ||
| 208 | projectId, | ||
| 209 | name, | ||
| 210 | cwd, | ||
| 211 | model: DEFAULT_MODEL, | ||
| 212 | createdAt: now, | ||
| 213 | }); | ||
| 214 | handleNewThread(projectId); | ||
| 215 | } finally { | 201 | } finally { |
| 216 | setIsAddingProject(false); | 202 | setIsAddingProject(false); |
| 217 | setNewCwd(""); | 203 | setNewCwd(""); |
| ... | @@ -225,6 +211,7 @@ export default function Sidebar() { | ... | @@ -225,6 +211,7 @@ export default function Sidebar() { |
| 225 | handleNewThread, | 211 | handleNewThread, |
| 226 | isAddingProject, | 212 | isAddingProject, |
| 227 | state.projects, | 213 | state.projects, |
| 214 | state.threads, | ||
| 228 | ], | 215 | ], |
| 229 | ); | 216 | ); |
| 230 | 217 | ||
| ... | @@ -381,28 +368,20 @@ export default function Sidebar() { | ... | @@ -381,28 +368,20 @@ export default function Sidebar() { |
| 381 | ); | 368 | ); |
| 382 | if (!confirmed) return; | 369 | if (!confirmed) return; |
| 383 | 370 | ||
| 384 | if (isElectron) { | 371 | try { |
| 385 | try { | 372 | await api.projects.remove({ id: projectId }); |
| 386 | await api.projects.remove({ id: projectId }); | 373 | const projects = await api.projects.list(); |
| 387 | } catch (error) { | 374 | dispatch({ type: "SYNC_PROJECTS", projects }); |
| 388 | const message = | 375 | } catch (error) { |
| 389 | error instanceof Error ? error.message : "Unknown error deleting project."; | 376 | const message = |
| 390 | console.error("Failed to remove project", { projectId, error }); | 377 | error instanceof Error ? error.message : "Unknown error deleting project."; |
| 391 | toastManager.add({ | 378 | console.error("Failed to remove project", { projectId, error }); |
| 392 | type: "error", | 379 | toastManager.add({ |
| 393 | title: `Failed to delete "${project.name}"`, | 380 | type: "error", |
| 394 | description: message, | 381 | title: `Failed to delete "${project.name}"`, |
| 395 | }); | 382 | description: message, |
| 396 | return; | 383 | }); |
| 397 | } | ||
| 398 | } | 384 | } |
| 399 | |||
| 400 | await api.orchestration.dispatchCommand({ | ||
| 401 | type: "project.delete", | ||
| 402 | commandId: crypto.randomUUID(), | ||
| 403 | projectId, | ||
| 404 | createdAt: new Date().toISOString(), | ||
| 405 | }); | ||
| 406 | }, | 385 | }, |
| 407 | [api, dispatch, state.projects, state.threads], | 386 | [api, dispatch, state.projects, state.threads], |
| 408 | ); | 387 | ); |
apps/web/src/routes/__root.tsx+23-25| ... | @@ -126,9 +126,9 @@ function EventRouter() { | ... | @@ -126,9 +126,9 @@ function EventRouter() { |
| 126 | 126 | ||
| 127 | useEffect(() => { | 127 | useEffect(() => { |
| 128 | if (!api) return; | 128 | if (!api) return; |
| 129 | void api.orchestration | 129 | void Promise.all([api.projects.list(), api.orchestration.getSnapshot()]) |
| 130 | .getSnapshot() | 130 | .then(([projects, snapshot]) => { |
| 131 | .then((snapshot) => { | 131 | dispatch({ type: "SYNC_PROJECTS", projects }); |
| 132 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); | 132 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); |
| 133 | }) | 133 | }) |
| 134 | .catch(() => undefined); | 134 | .catch(() => undefined); |
| ... | @@ -150,8 +150,8 @@ function AutoProjectBootstrap() { | ... | @@ -150,8 +150,8 @@ function AutoProjectBootstrap() { |
| 150 | 150 | ||
| 151 | useEffect(() => { | 151 | useEffect(() => { |
| 152 | if (!api) return; | 152 | if (!api) return; |
| 153 | // Browser mode bootstraps from server welcome. | 153 | // Browser mode auto-adds the current cwd project via server welcome. |
| 154 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | 154 | // Desktop skips this because projects are already loaded from the repository. |
| 155 | if (isElectron) return; | 155 | if (isElectron) return; |
| 156 | 156 | ||
| 157 | return onServerWelcome((payload) => { | 157 | return onServerWelcome((payload) => { |
| ... | @@ -166,40 +166,38 @@ function AutoProjectBootstrap() { | ... | @@ -166,40 +166,38 @@ function AutoProjectBootstrap() { |
| 166 | } | 166 | } |
| 167 | 167 | ||
| 168 | bootstrappedRef.current = true; | 168 | bootstrappedRef.current = true; |
| 169 | const projectId = crypto.randomUUID(); | ||
| 170 | const threadId = crypto.randomUUID(); | ||
| 171 | const now = new Date().toISOString(); | 169 | const now = new Date().toISOString(); |
| 172 | void api.orchestration | 170 | void api.projects |
| 173 | .dispatchCommand({ | 171 | .add({ cwd: payload.cwd }) |
| 174 | type: "project.create", | 172 | .then(async (result) => { |
| 175 | commandId: crypto.randomUUID(), | 173 | const projects = await api.projects.list(); |
| 176 | projectId, | 174 | dispatch({ type: "SYNC_PROJECTS", projects }); |
| 177 | name: payload.projectName, | 175 | |
| 178 | cwd: payload.cwd, | 176 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); |
| 179 | model: DEFAULT_MODEL, | 177 | if (hasThread) { |
| 180 | createdAt: now, | 178 | return; |
| 181 | }) | 179 | } |
| 182 | .then(() => | 180 | |
| 183 | api.orchestration.dispatchCommand({ | 181 | return api.orchestration.dispatchCommand({ |
| 184 | type: "thread.create", | 182 | type: "thread.create", |
| 185 | commandId: crypto.randomUUID(), | 183 | commandId: crypto.randomUUID(), |
| 186 | threadId, | 184 | threadId: crypto.randomUUID(), |
| 187 | projectId, | 185 | projectId: result.project.id, |
| 188 | title: "New thread", | 186 | title: "New thread", |
| 189 | model: DEFAULT_MODEL, | 187 | model: DEFAULT_MODEL, |
| 190 | branch: null, | 188 | branch: null, |
| 191 | worktreePath: null, | 189 | worktreePath: null, |
| 192 | createdAt: now, | 190 | createdAt: now, |
| 193 | }), | 191 | }); |
| 194 | ) | 192 | }) |
| 195 | .catch(() => undefined); | 193 | .catch(() => undefined); |
| 196 | }); | 194 | }); |
| 197 | }, [api, state.projects, dispatch]); | 195 | }, [api, state.projects, state.threads, dispatch]); |
| 198 | 196 | ||
| 199 | return null; | 197 | return null; |
| 200 | } | 198 | } |
| 201 | 199 | ||
| 202 | function DesktopProjectBootstrap() { | 200 | function DesktopProjectBootstrap() { |
| 203 | // Desktop now hydrates from orchestration read-model snapshots. | 201 | // Desktop hydration runs through EventRouter project + orchestration sync. |
| 204 | return null; | 202 | return null; |
| 205 | } | 203 | } |
apps/web/src/store.ts+32-17| ... | @@ -10,9 +10,10 @@ import { | ... | @@ -10,9 +10,10 @@ import { |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | type OrchestrationReadModel, | 12 | type OrchestrationReadModel, |
| 13 | type ProjectListResult, | ||
| 13 | normalizeProjectScripts, | 14 | normalizeProjectScripts, |
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | import { resolveModelSlug } from "./model-logic"; | 16 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 16 | import { | 17 | import { |
| 17 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 18 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 18 | DEFAULT_THREAD_TERMINAL_ID, | 19 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | @@ -29,6 +30,7 @@ import { | ... | @@ -29,6 +30,7 @@ import { |
| 29 | 30 | ||
| 30 | type Action = | 31 | type Action = |
| 31 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | 32 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 33 | | { type: "SYNC_PROJECTS"; projects: ProjectListResult } | ||
| 32 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | 34 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } |
| 33 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | 35 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } |
| 34 | | { type: "TOGGLE_PROJECT"; projectId: string } | 36 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| ... | @@ -136,6 +138,29 @@ function updateThread( | ... | @@ -136,6 +138,29 @@ function updateThread( |
| 136 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | 138 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 137 | } | 139 | } |
| 138 | 140 | ||
| 141 | function mapProjects( | ||
| 142 | incoming: ProjectListResult, | ||
| 143 | previous: Project[], | ||
| 144 | ): Project[] { | ||
| 145 | return incoming.map((project) => { | ||
| 146 | const existing = | ||
| 147 | previous.find((entry) => entry.id === project.id) ?? | ||
| 148 | previous.find((entry) => entry.cwd === project.cwd); | ||
| 149 | return { | ||
| 150 | id: project.id, | ||
| 151 | name: project.name, | ||
| 152 | cwd: project.cwd, | ||
| 153 | model: existing?.model ?? DEFAULT_MODEL, | ||
| 154 | expanded: | ||
| 155 | existing?.expanded ?? | ||
| 156 | (persistedExpandedProjectCwds.size > 0 | ||
| 157 | ? persistedExpandedProjectCwds.has(project.cwd) | ||
| 158 | : true), | ||
| 159 | scripts: normalizeProjectScripts(project.scripts), | ||
| 160 | }; | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 139 | 164 | ||
| 140 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 165 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 141 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | 166 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| ... | @@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 330 | switch (action.type) { | 355 | switch (action.type) { |
| 331 | case "SYNC_SERVER_READ_MODEL": { | 356 | case "SYNC_SERVER_READ_MODEL": { |
| 332 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); | 357 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); |
| 333 | const projects = action.readModel.projects.map((project) => { | ||
| 334 | const existing = state.projects.find((entry) => entry.id === project.id); | ||
| 335 | return { | ||
| 336 | id: project.id, | ||
| 337 | name: project.name, | ||
| 338 | cwd: project.cwd, | ||
| 339 | model: resolveModelSlug(project.model), | ||
| 340 | expanded: | ||
| 341 | existing?.expanded ?? | ||
| 342 | (persistedExpandedProjectCwds.size > 0 | ||
| 343 | ? persistedExpandedProjectCwds.has(project.cwd) | ||
| 344 | : true), | ||
| 345 | scripts: existing?.scripts ?? [], | ||
| 346 | }; | ||
| 347 | }); | ||
| 348 | const threads = action.readModel.threads.map((thread) => { | 358 | const threads = action.readModel.threads.map((thread) => { |
| 349 | const existing = existingThreadById.get(thread.id); | 359 | const existing = existingThreadById.get(thread.id); |
| 350 | return normalizeThreadTerminals({ | 360 | return normalizeThreadTerminals({ |
| ... | @@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState { |
| 392 | }); | 402 | }); |
| 393 | return { | 403 | return { |
| 394 | ...state, | 404 | ...state, |
| 395 | projects, | ||
| 396 | threads, | 405 | threads, |
| 397 | threadsHydrated: true, | 406 | threadsHydrated: true, |
| 398 | }; | 407 | }; |
| 399 | } | 408 | } |
| 400 | 409 | ||
| 410 | case "SYNC_PROJECTS": | ||
| 411 | return { | ||
| 412 | ...state, | ||
| 413 | projects: mapProjects(action.projects, state.projects), | ||
| 414 | }; | ||
| 415 | |||
| 401 | case "SET_PROJECT_SCRIPTS": | 416 | case "SET_PROJECT_SCRIPTS": |
| 402 | return { | 417 | return { |
| 403 | ...state, | 418 | ...state, |
packages/contracts/src/orchestration.ts-31| ... | @@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({ | ... | @@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({ |
| 80 | 80 | ||
| 81 | export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>; | 81 | export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>; |
| 82 | 82 | ||
| 83 | export const OrchestrationProjectSchema = Schema.Struct({ | ||
| 84 | id: IdSchema, | ||
| 85 | name: Schema.String, | ||
| 86 | cwd: Schema.String, | ||
| 87 | model: Schema.String, | ||
| 88 | createdAt: IsoDateTimeSchema, | ||
| 89 | updatedAt: IsoDateTimeSchema, | ||
| 90 | }); | ||
| 91 | |||
| 92 | export type OrchestrationProject = Schema.Schema.Type<typeof OrchestrationProjectSchema>; | ||
| 93 | |||
| 94 | export const OrchestrationGitReadModelSchema = Schema.Struct({ | 83 | export const OrchestrationGitReadModelSchema = Schema.Struct({ |
| 95 | projectId: IdSchema, | 84 | projectId: IdSchema, |
| 96 | branch: Schema.NullOr(Schema.String), | 85 | branch: Schema.NullOr(Schema.String), |
| ... | @@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationG | ... | @@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationG |
| 104 | 93 | ||
| 105 | export const OrchestrationReadModelSchema = Schema.Struct({ | 94 | export const OrchestrationReadModelSchema = Schema.Struct({ |
| 106 | sequence: Schema.Number, | 95 | sequence: Schema.Number, |
| 107 | projects: Schema.Array(OrchestrationProjectSchema), | ||
| 108 | threads: Schema.Array(OrchestrationThreadSchema), | 96 | threads: Schema.Array(OrchestrationThreadSchema), |
| 109 | gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }), | 97 | gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }), |
| 110 | updatedAt: IsoDateTimeSchema, | 98 | updatedAt: IsoDateTimeSchema, |
| ... | @@ -112,23 +100,6 @@ export const OrchestrationReadModelSchema = Schema.Struct({ | ... | @@ -112,23 +100,6 @@ export const OrchestrationReadModelSchema = Schema.Struct({ |
| 112 | 100 | ||
| 113 | export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>; | 101 | export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>; |
| 114 | 102 | ||
| 115 | export const CreateProjectCommandSchema = Schema.Struct({ | ||
| 116 | type: Schema.Literal("project.create"), | ||
| 117 | commandId: IdSchema, | ||
| 118 | projectId: IdSchema, | ||
| 119 | name: Schema.String, | ||
| 120 | cwd: Schema.String, | ||
| 121 | model: Schema.String, | ||
| 122 | createdAt: IsoDateTimeSchema, | ||
| 123 | }); | ||
| 124 | |||
| 125 | export const DeleteProjectCommandSchema = Schema.Struct({ | ||
| 126 | type: Schema.Literal("project.delete"), | ||
| 127 | commandId: IdSchema, | ||
| 128 | projectId: IdSchema, | ||
| 129 | createdAt: IsoDateTimeSchema, | ||
| 130 | }); | ||
| 131 | |||
| 132 | export const CreateThreadCommandSchema = Schema.Struct({ | 103 | export const CreateThreadCommandSchema = Schema.Struct({ |
| 133 | type: Schema.Literal("thread.create"), | 104 | type: Schema.Literal("thread.create"), |
| 134 | commandId: IdSchema, | 105 | commandId: IdSchema, |
| ... | @@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({ | ... | @@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({ |
| 212 | }); | 183 | }); |
| 213 | 184 | ||
| 214 | export const OrchestrationCommandSchema = Schema.Union( | 185 | export const OrchestrationCommandSchema = Schema.Union( |
| 215 | CreateProjectCommandSchema, | ||
| 216 | DeleteProjectCommandSchema, | ||
| 217 | CreateThreadCommandSchema, | 186 | CreateThreadCommandSchema, |
| 218 | DeleteThreadCommandSchema, | 187 | DeleteThreadCommandSchema, |
| 219 | UpdateThreadMetaCommandSchema, | 188 | UpdateThreadMetaCommandSchema, |