diff --git a/apps/server/package.json b/apps/server/package.json index 5b0ee327fc4ff0487ce73a12fd08963d53a18490..4f311dc5817e85aeb39967fc5291049458c7f972 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,7 +18,6 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.10", - "@effect/sql-sqlite-node": "4.0.0-beta.10", "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts index e1d3c6be385015b3db37e93c829d72f58117510d..4347a6006c3fc8be3575e750def6e6aeb0571871 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts @@ -1,7 +1,3 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - import type { ProviderApprovalDecision, ProviderEvent, @@ -13,7 +9,7 @@ import type { } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; import { it, assert } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { runGit as runGitProcess } from "../../git/Process.ts"; import { CheckpointServiceLive } from "./CheckpointService.ts"; @@ -34,14 +30,9 @@ import type { } from "../../provider/Services/ProviderAdapter.ts"; import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; -import { makeSqliteCheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; - -const makeTemporaryDirectoryScoped = (prefix: string) => - Effect.acquireRelease( - Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), - (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), - ); +import { CheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; const runGit = (cwd: string, args: readonly string[]) => runGitProcess({ @@ -53,23 +44,24 @@ const runGit = (cwd: string, args: readonly string[]) => Effect.provide(NodeServices.layer), ); -function createRepo() { - return Effect.gen(function* () { - const cwd = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-repo-"); - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@example.com"]); - yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* Effect.promise(() => writeFile(path.join(cwd, "README.md"), "v1\n")); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "Initial"]); - return cwd; - }); -} +const makeGitRepository = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory(); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(path.join(cwd, "README.md"), "v1\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; +}); class InMemoryProviderAdapter implements ProviderAdapterShape { readonly provider = "codex" as const; private active = true; + private readThreadFailure: ProviderAdapterError | undefined; private snapshot: ProviderThreadSnapshot; private readonly session: ProviderSession; @@ -105,6 +97,10 @@ class InMemoryProviderAdapter implements ProviderAdapterShape => @@ -157,7 +153,9 @@ class InMemoryProviderAdapter implements ProviderAdapterShape hasSession - ? Effect.succeed(this.snapshot) + ? this.readThreadFailure + ? Effect.fail(this.readThreadFailure) + : Effect.succeed(this.snapshot) : Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: this.provider, @@ -203,9 +201,7 @@ class InMemoryProviderAdapter implements ProviderAdapterShape) { return Effect.gen(function* () { - const cwd = yield* createRepo(); - const stateDir = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-state-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); + const cwd = yield* makeGitRepository; const sessionId = "sess-1"; const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns); @@ -220,9 +216,10 @@ function makeFixture(turns: ReadonlyArray) { const dependencies = Layer.mergeAll( Layer.provide(CheckpointStoreLive, NodeServices.layer), - makeSqliteCheckpointRepositoryLive(dbPath), + Layer.provide(CheckpointRepositoryLive, SqlitePersistenceMemory), Layer.succeed(ProviderAdapterRegistry, registry), ProviderSessionDirectoryLive, + NodeServices.layer, ); const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); @@ -236,87 +233,84 @@ function makeFixture(turns: ReadonlyArray) { }); } -it.effect( - "CheckpointServiceLive captures checkpoints, diffs refs, and reverts workspace + turns", - () => - Effect.gen(function* () { - const firstTurn: ProviderThreadTurnSnapshot = { - id: "turn-1", - items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], - }; - const secondTurn: ProviderThreadTurnSnapshot = { - id: "turn-2", - items: [{ type: "agentMessage", text: "second" }], - }; +it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () => + Effect.gen(function* () { + const firstTurn: ProviderThreadTurnSnapshot = { + id: "turn-1", + items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], + }; + const secondTurn: ProviderThreadTurnSnapshot = { + id: "turn-2", + items: [{ type: "agentMessage", text: "second" }], + }; - const fixture = yield* makeFixture([]); + const fixture = yield* makeFixture([]); - yield* Effect.gen(function* () { - const service = yield* CheckpointService; - const directory = yield* ProviderSessionDirectory; + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); + const service = yield* CheckpointService; + const directory = yield* ProviderSessionDirectory; - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); - fixture.adapter.setTurns([firstTurn]); + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); + fixture.adapter.setTurns([firstTurn]); - const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); - assert.equal(listed.checkpoints.length, 2); - assert.equal(listed.checkpoints[1]?.id, "turn-1"); - assert.equal(listed.checkpoints[1]?.isCurrent, true); + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); - const diff = yield* service.getCheckpointDiff({ - sessionId: fixture.sessionId, - fromTurnCount: 0, - toTurnCount: 1, - }); - assert.equal(diff.threadId, "thread-1"); - assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); - assert.equal(diff.diff.includes("-v1"), true); - assert.equal(diff.diff.includes("+v2"), true); + const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); + assert.equal(listed.checkpoints.length, 2); + assert.equal(listed.checkpoints[1]?.id, "turn-1"); + assert.equal(listed.checkpoints[1]?.isCurrent, true); - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v3\n")); - fixture.adapter.setTurns([firstTurn, secondTurn]); + const diff = yield* service.getCheckpointDiff({ + sessionId: fixture.sessionId, + fromTurnCount: 0, + toTurnCount: 1, + }); + assert.equal(diff.threadId, "thread-1"); + assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); + assert.equal(diff.diff.includes("-v1"), true); + assert.equal(diff.diff.includes("+v2"), true); - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n"); + fixture.adapter.setTurns([firstTurn, secondTurn]); - const beforeRevert = yield* Effect.promise(() => - readFile(path.join(fixture.cwd, "README.md"), "utf8"), - ); - assert.equal(beforeRevert, "v3\n"); + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); - const reverted = yield* service.revertToCheckpoint({ - sessionId: fixture.sessionId, - turnCount: 1, - }); + const beforeRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); + assert.equal(beforeRevert, "v3\n"); - const afterRevert = yield* Effect.promise(() => - readFile(path.join(fixture.cwd, "README.md"), "utf8"), - ); - assert.equal(afterRevert, "v2\n"); - assert.equal(reverted.turnCount, 1); - assert.equal(reverted.rolledBackTurns, 1); - assert.equal(reverted.checkpoints.length, 2); - assert.equal(reverted.checkpoints[1]?.id, "turn-1"); - assert.equal(reverted.checkpoints[1]?.isCurrent, true); - assert.equal(fixture.adapter.getTurns().length, 1); - }).pipe(Effect.provide(fixture.layer)); - }), + const reverted = yield* service.revertToCheckpoint({ + sessionId: fixture.sessionId, + turnCount: 1, + }); + + const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); + assert.equal(afterRevert, "v2\n"); + assert.equal(reverted.turnCount, 1); + assert.equal(reverted.rolledBackTurns, 1); + assert.equal(reverted.checkpoints.length, 2); + assert.equal(reverted.checkpoints[1]?.id, "turn-1"); + assert.equal(reverted.checkpoints[1]?.isCurrent, true); + assert.equal(fixture.adapter.getTurns().length, 1); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( @@ -338,7 +332,7 @@ it.effect( assert.equal(result.failure._tag, "CheckpointSessionNotFoundError"); } }).pipe(Effect.provide(fixture.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( @@ -353,6 +347,8 @@ it.effect( const fixture = yield* makeFixture([]); yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const service = yield* CheckpointService; const store = yield* CheckpointStore; const checkpointRepository = yield* CheckpointRepository; @@ -369,7 +365,7 @@ it.effect( cwd: fixture.cwd, }); - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); fixture.adapter.setTurns([firstTurn]); yield* service.captureCurrentTurn({ providerSessionId: fixture.sessionId, @@ -400,5 +396,44 @@ it.effect( assert.equal(result.failure._tag, "CheckpointUnavailableError"); } }).pipe(Effect.provide(fixture.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("CheckpointServiceLive initializes root checkpoints without requiring thread/read", () => + Effect.gen(function* () { + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const service = yield* CheckpointService; + const checkpointRepository = yield* CheckpointRepository; + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); + fixture.adapter.setReadThreadFailure( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "readThread", + issue: "thread/read is temporarily unavailable", + }), + ); + + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); + + const root = yield* checkpointRepository.getCheckpoint({ + providerSessionId: fixture.sessionId, + turnCount: 0, + }); + assert.equal(root._tag, "Some"); + if (root._tag === "Some") { + assert.equal(root.value.threadId, "thread-1"); + } + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.ts b/apps/server/src/checkpointing/Layers/CheckpointService.ts index 0677d7615cf03d7c1e4ad8056a68e33a6885da19..2c54cb3c547f236f1ff1db72090b9876b88d1550 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.ts @@ -441,6 +441,43 @@ const makeCheckpointService = Effect.gen(function* () { ), ); + const resolveThreadIdForSession = ( + sessionId: string, + operation: string, + ): Effect.Effect => + directory.getThreadId(sessionId).pipe( + Effect.mapError( + (cause) => + new CheckpointSessionNotFoundError({ + sessionId, + cause, + }), + ), + Effect.flatMap((threadId) => + Option.match(threadId, { + onSome: (value) => { + const normalizedThreadId = value.trim(); + if (normalizedThreadId.length > 0) { + return Effect.succeed(normalizedThreadId); + } + return Effect.fail( + invariantError( + operation, + `Resolved empty thread id for session ${sessionId}.`, + ), + ); + }, + onNone: () => + Effect.fail( + invariantError( + operation, + `Missing thread id in session directory for session ${sessionId}.`, + ), + ), + }), + ), + ); + const rollbackThreadSnapshot = ( sessionId: string, adapter: ProviderAdapterShape, @@ -633,19 +670,10 @@ const makeCheckpointService = Effect.gen(function* () { Effect.gen(function* () { const adapter = yield* resolveAdapterForSession(input.providerSessionId); yield* ensureSessionIsActive(input.providerSessionId, adapter); - const snapshot = yield* readThreadSnapshot( + const threadId = yield* resolveThreadIdForSession( input.providerSessionId, - adapter, - "CheckpointService.initializeForSession:readThread", + "CheckpointService.initializeForSession", ); - if (snapshot.turns.length !== 0) { - return yield* Effect.fail( - invariantError( - "CheckpointService.initializeForSession", - `Expected empty thread snapshot during initialization, received ${snapshot.turns.length} turn(s).`, - ), - ); - } const supportsGit = yield* store.isGitRepository(input.cwd); if (!supportsGit) { @@ -686,7 +714,7 @@ const makeCheckpointService = Effect.gen(function* () { yield* captureAndPersistCheckpoint({ providerSessionId: input.providerSessionId, cwd: input.cwd, - threadId: snapshot.threadId, + threadId, checkpoint: rootCheckpoint(), }); yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index fb383ab32253f6d9db06bb357096766d54cccfcb..27f29b8e76d4ef4400b829d92d92ed5cd9654f69 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -1,40 +1,21 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, } from "../persistence/Services/OrchestrationEvents.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { OrchestrationEngineLive } from "./Layer.ts"; import { OrchestrationEngineService } from "./Service.ts"; import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents.ts"; -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -export async function createOrchestrationSystem(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); +export async function createOrchestrationSystem() { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationEventRepositoryLive), - Layer.provide(makeSqlitePersistenceLive(dbPath)), + Layer.provide(SqlitePersistenceMemory), ); const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -46,16 +27,15 @@ export async function createOrchestrationSystem(stateDir: string) { } describe("OrchestrationEngine", () => { - it("replays to the same deterministic snapshot", async () => { - const stateDir = makeTempDir("t3code-orchestration-"); + it("returns deterministic snapshots for repeated reads", async () => { const createdAt = new Date().toISOString(); const projectId = "project-1"; const threadId = "thread-1"; - const firstSystem = await createOrchestrationSystem(stateDir); - const engineA = firstSystem.engine; - await firstSystem.run( - engineA.dispatch({ + const system = await createOrchestrationSystem(); + const engine = system.engine; + await system.run( + engine.dispatch({ type: "thread.create", commandId: "cmd-1", threadId, @@ -67,8 +47,8 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( - engineA.dispatch({ + await system.run( + engine.dispatch({ type: "message.send", commandId: "cmd-3", threadId, @@ -79,19 +59,14 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - const snapshotA = await firstSystem.run(engineA.getSnapshot()); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const engineB = secondSystem.engine; - const snapshotB = await secondSystem.run(engineB.getSnapshot()); + const snapshotA = await system.run(engine.getSnapshot()); + const snapshotB = await system.run(engine.getSnapshot()); expect(snapshotB).toEqual(snapshotA); - await secondSystem.dispose(); + await system.dispose(); }); it("fans out read-model updates to subscribers", async () => { - const stateDir = makeTempDir("t3code-orchestration-fanout-"); - const system = await createOrchestrationSystem(stateDir); + const system = await createOrchestrationSystem(); const engine = system.engine; const updates: number[] = []; const unsubscribe = await system.run( @@ -118,8 +93,7 @@ describe("OrchestrationEngine", () => { }); it("replays append-only events from sequence", async () => { - const stateDir = makeTempDir("t3code-orchestration-replay-"); - const system = await createOrchestrationSystem(stateDir); + const system = await createOrchestrationSystem(); const engine = system.engine; await system.run( engine.dispatch({ @@ -150,11 +124,10 @@ describe("OrchestrationEngine", () => { }); it("stores completed turn summaries even when no files changed", async () => { - const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; + const system = await createOrchestrationSystem(); + const engine = system.engine; const completedAt = new Date().toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.create", commandId: "cmd-thread-turn-diff", @@ -167,7 +140,7 @@ describe("OrchestrationEngine", () => { createdAt: completedAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-diff-complete", @@ -181,7 +154,7 @@ describe("OrchestrationEngine", () => { }), ); - const firstThread = (await firstSystem.run(engine.getSnapshot())).threads.find( + const firstThread = (await system.run(engine.getSnapshot())).threads.find( (thread) => thread.id === "thread-turn-diff", ); expect(firstThread?.turnDiffSummaries).toEqual([ @@ -193,31 +166,14 @@ describe("OrchestrationEngine", () => { checkpointTurnCount: 1, }, ]); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restartedEngine = secondSystem.engine; - const restartedThread = (await secondSystem.run(restartedEngine.getSnapshot())).threads.find( - (thread) => thread.id === "thread-turn-diff", - ); - expect(restartedThread?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - }, - ]); - await secondSystem.dispose(); + await system.dispose(); }); it("reverts thread messages and turn summaries to a checkpoint", async () => { - const stateDir = makeTempDir("t3code-orchestration-revert-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; + const system = await createOrchestrationSystem(); + const engine = system.engine; const createdAt = new Date().toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.create", commandId: "cmd-thread-revert", @@ -230,7 +186,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-1-user", @@ -242,7 +198,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-1-assistant", @@ -254,7 +210,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-1-complete", @@ -270,7 +226,7 @@ describe("OrchestrationEngine", () => { ); const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-2-user", @@ -282,7 +238,7 @@ describe("OrchestrationEngine", () => { createdAt: createdAtSecond, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-2-assistant", @@ -294,7 +250,7 @@ describe("OrchestrationEngine", () => { createdAt: createdAtSecond, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-2-complete", @@ -309,7 +265,7 @@ describe("OrchestrationEngine", () => { }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.revert", commandId: "cmd-thread-revert-apply", @@ -320,25 +276,13 @@ describe("OrchestrationEngine", () => { }), ); - const thread = (await firstSystem.run(engine.getSnapshot())).threads.find( + const thread = (await system.run(engine.getSnapshot())).threads.find( (entry) => entry.id === "thread-revert", ); expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); expect(thread?.latestTurnId).toBe("turn-1"); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restarted = secondSystem.engine; - const restartedThread = (await secondSystem.run(restarted.getSnapshot())).threads.find( - (entry) => entry.id === "thread-revert", - ); - expect(restartedThread?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant:turn-1", - ]); - expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - await secondSystem.dispose(); + await system.dispose(); }); it("allows stop to be called multiple times", async () => { diff --git a/apps/server/src/persistence/Layers/Checkpoints.test.ts b/apps/server/src/persistence/Layers/Checkpoints.test.ts index d13b5c14f8b4780c8e72e8fb216e5b315127d00f..437bf734733c0151ae77f399ad23f6301778c22b 100644 --- a/apps/server/src/persistence/Layers/Checkpoints.test.ts +++ b/apps/server/src/persistence/Layers/Checkpoints.test.ts @@ -1,29 +1,16 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - import { it, assert } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import { CheckpointRepository } from "../Services/Checkpoints.ts"; import { CheckpointRepositoryLive } from "./Checkpoints.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; -const makeTemporaryDirectoryScoped = (prefix: string) => - Effect.acquireRelease( - Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), - (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), - ); - -function makeCheckpointRepositoryLayer(dbPath: string) { - return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); +function makeCheckpointRepositoryLayer() { + return CheckpointRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); } it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => Effect.gen(function* () { - const stateDir = yield* makeTemporaryDirectoryScoped("t3code-checkpoint-repository-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - yield* Effect.gen(function* () { const repository = yield* CheckpointRepository; @@ -90,6 +77,6 @@ it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint yield* repository.deleteAllForSession({ providerSessionId: "sess-1" }); const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); assert.equal(empty.length, 0); - }).pipe(Effect.provide(makeCheckpointRepositoryLayer(dbPath))); + }).pipe(Effect.provide(makeCheckpointRepositoryLayer())); }), ); diff --git a/apps/server/src/persistence/Layers/Checkpoints.ts b/apps/server/src/persistence/Layers/Checkpoints.ts index b9efe5b9b0b3f46b685b0800f589b6a37d0ae317..c6228c80974b9aade0cec93f890c30ae5e9148aa 100644 --- a/apps/server/src/persistence/Layers/Checkpoints.ts +++ b/apps/server/src/persistence/Layers/Checkpoints.ts @@ -20,7 +20,6 @@ import { CheckpointRepositoryValidationError, type CheckpointRepositoryError, } from "../Errors.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; import { CheckpointRepository, type CheckpointRepositoryEntry, @@ -357,7 +356,3 @@ export const CheckpointRepositoryLive = Layer.effect( CheckpointRepository, makeCheckpointRepository, ); - -export function makeSqliteCheckpointRepositoryLive(dbPath: string) { - return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index 6b11a973e2358c0d8806bad4a350d35bae1a10ed..dddba6a727d9bc1f2a93ef7fd2b3964f0ac1fc4d 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -1,39 +1,25 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { ManagedRuntime, Effect } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import { assert, it } from "@effect/vitest"; import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts"; -import { makeSqliteOrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { OrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; +import { NodeServices } from "@effect/platform-node"; -const tempDirs: string[] = []; +const layer = it.layer( + Layer.mergeAll( + Layer.provide(OrchestrationEventRepositoryLive, SqlitePersistenceMemory), + NodeServices.layer, + ), +); -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} +layer("OrchestrationEventRepository", (it) => { + it.effect("stores and replays events", () => + Effect.gen(function* () { + const createdAt = new Date().toISOString(); -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("OrchestrationEventRepository", () => { - it("persists and replays events across restarts", async () => { - const stateDir = makeTempDir("t3code-event-store-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const createdAt = new Date().toISOString(); - - const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - const saved = await firstRuntime.runPromise( - first.append({ + const repository = yield* OrchestrationEventRepository; + const saved = yield* repository.append({ eventId: "event-1", type: "thread.created", aggregateType: "thread", @@ -41,57 +27,11 @@ describe("OrchestrationEventRepository", () => { occurredAt: createdAt, commandId: "cmd-1", payload: { id: "thread-1", projectId: "project-1", title: "demo" }, - }), - ); - expect(saved.sequence).toBe(1); - await firstRuntime.dispose(); + }); + assert.equal(saved.sequence, 1); - const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - const replayed = await secondRuntime.runPromise(second.readFromSequence(0)); - expect(replayed).toEqual([saved]); - await secondRuntime.dispose(); - }); - - it("creates and reuses the migrator tracking table", async () => { - const stateDir = makeTempDir("t3code-event-store-migrations-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - await firstRuntime.runPromise(first.readAll()); - await firstRuntime.dispose(); - - const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - await secondRuntime.runPromise(second.readAll()); - await secondRuntime.dispose(); - - const db = new DatabaseSync(dbPath, { readOnly: true }); - const tableRow = db - .prepare( - ` - SELECT name - FROM sqlite_master - WHERE type = 'table' AND name IN ('effect_sql_migrations', 'orchestration_sql_migrations') - ORDER BY name ASC - `, - ) - .get() as { name: string } | undefined; - expect(tableRow?.name).toBeDefined(); - expect(tableRow).toBeDefined(); - if (!tableRow) return; - - const migrationRows = db - .prepare( - ` - SELECT * - FROM ${tableRow.name} - `, - ) - .all() as Array>; - expect(migrationRows.length).toBeGreaterThanOrEqual(1); - expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); - db.close(); - }); + const replayed = yield* repository.readFromSequence(0); + assert.deepEqual(replayed, [saved]); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index bd9f0bd7c39af2a39ef070fae8a35aed8fa081fc..4da8c8c06323e06163844a23e83d20e53f56c293 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -11,7 +11,6 @@ import { toPersistenceSqlError, type OrchestrationEventRepositoryError, } from "../Errors.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, @@ -177,7 +176,3 @@ export const OrchestrationEventRepositoryLive = Layer.effect( OrchestrationEventRepository, makeRepository, ); - -export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { - return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} diff --git a/apps/server/src/persistence/Layers/Projects.test.ts b/apps/server/src/persistence/Layers/Projects.test.ts index c8ee6215c92f26121a623908bfe37963979f99f0..0eb5f2689fd32f73330e6f7078aa0a2c37da0e7c 100644 --- a/apps/server/src/persistence/Layers/Projects.test.ts +++ b/apps/server/src/persistence/Layers/Projects.test.ts @@ -1,83 +1,56 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { Effect, Layer, ManagedRuntime } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Effect, Layer, FileSystem } from "effect"; +import { assert, it } from "@effect/vitest"; import { ProjectRepository } from "../Services/Projects.ts"; import { ProjectRepositoryLive } from "./Projects.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { NodeServices } from "@effect/platform-node"; -function makeProjectRepositoryTest(dbPath: string) { - return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} +const layer = it.layer( + Layer.mergeAll(Layer.provide(ProjectRepositoryLive, SqlitePersistenceMemory), NodeServices.layer), +); -const tempDirs: string[] = []; +layer("ProjectRepository", (it) => { + it.effect("stores projects and deduplicates adds by cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectDir = yield* fs.makeTempDirectoryScoped(); -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} + const repository = yield* ProjectRepository; -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("ProjectRepository", () => { - it("persists projects and deduplicates adds by cwd", async () => { - const stateDir = makeTempDir("t3code-project-repo-state-"); - const projectDir = makeTempDir("t3code-project-repo-project-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(ProjectRepository)); - - const created = await firstRuntime.runPromise(first.add({ cwd: projectDir })); - expect(created.created).toBe(true); - - const duplicate = await firstRuntime.runPromise(first.add({ cwd: projectDir })); - expect(duplicate.created).toBe(false); - expect(duplicate.project.id).toBe(created.project.id); - - await firstRuntime.dispose(); - - const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(ProjectRepository)); - const listed = await secondRuntime.runPromise(second.list()); - - expect(listed).toHaveLength(1); - expect(listed[0]?.cwd).toBe(projectDir); - - await secondRuntime.dispose(); - }); + const created = yield* repository.add({ cwd: projectDir }); + assert.equal(created.created, true); - it("prunes missing project paths", async () => { - const stateDir = makeTempDir("t3code-project-repo-prune-state-"); - const existing = makeTempDir("t3code-project-repo-prune-existing-"); - const missing = makeTempDir("t3code-project-repo-prune-missing-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); + const duplicate = yield* repository.add({ cwd: projectDir }); + assert.equal(duplicate.created, false); + assert.equal(duplicate.project.id, created.project.id); - const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const repository = await runtime.runPromise(Effect.service(ProjectRepository)); + const listed = yield* repository.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.cwd, projectDir); + }), + ); - const existingProject = await runtime.runPromise(repository.add({ cwd: existing })); - const missingProject = await runtime.runPromise(repository.add({ cwd: missing })); + it.effect("prunes missing project paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const existing = yield* fs.makeTempDirectoryScoped(); + const missing = yield* fs.makeTempDirectory(); - expect(existingProject.created).toBe(true); - expect(missingProject.created).toBe(true); + const repository = yield* ProjectRepository; - fs.rmSync(missing, { recursive: true, force: true }); + const existingProject = yield* repository.add({ cwd: existing }); + const missingProject = yield* repository.add({ cwd: missing }); - await runtime.runPromise(repository.pruneMissing()); - const listed = await runtime.runPromise(repository.list()); + assert.isTrue(existingProject.created); + assert.isTrue(missingProject.created); - expect(listed).toHaveLength(1); - expect(listed[0]?.cwd).toBe(existing); + yield* fs.remove(missing, { recursive: true, force: true }); - await runtime.dispose(); - }); + yield* repository.pruneMissing(); + const listed = yield* repository.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.cwd, existing); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 53a8b7d558291e3096cc08bae3086299db2318b6..7a700c878d46039ae97433d257d55b295a6139bd 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,41 +1,24 @@ -import fs from "node:fs"; -import path from "node:path"; - -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, FileSystem, Path } from "effect"; import { runMigrations } from "../Migrations.ts"; - -export interface PersistenceConfigShape { - readonly dbPath: string; -} - -export class PersistenceConfig extends ServiceMap.Service< - PersistenceConfig, - PersistenceConfigShape ->()("persistence/Config") {} - -export function makeSqlitePersistenceLive(dbPath: string) { - const SqliteClientLive = Layer.unwrap( - Effect.sync(() => { - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - return SqliteClient.layer({ filename: dbPath }); - }), - ); - - return Layer.effect( - SqlClient.SqlClient, - Effect.gen(function* () { - const sql = yield* SqliteClient.SqliteClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; - return sql; - }), - ).pipe(Layer.provide(SqliteClientLive)); -} - -export const SqlitePersistenceLive = Layer.unwrap( - Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), +import * as SqliteClient from "../SqliteClient.ts"; + +const setup = Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqliteClient.SqliteClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; + }), ); + +export const makeSqlitePersistenceLive = (dbPath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + + return Layer.provideMerge(setup, SqliteClient.layer({ filename: dbPath })); + }).pipe(Layer.unwrap); + +export const SqlitePersistenceMemory = Layer.provideMerge(setup, SqliteClient.layerMemory()); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 46dba5ae2deb1324a971e8ab2ba65cfce7364b97..f8db8ee1be3957ad3f5a58af743b8a0938f9ad6e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,7 +59,7 @@ export const runMigrations = run({ loader }); * @example * ```typescript * import { MigrationsLive } from "@acme/db/Migrations" - * import { SqliteClient } from "@effect/sql-sqlite-node" + * import * as SqliteClient from "@acme/db/SqliteClient" * * // Migrations run automatically when SqliteClient is provided * const AppLayer = MigrationsLive.pipe( diff --git a/apps/server/src/persistence/SqliteClient.test.ts b/apps/server/src/persistence/SqliteClient.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68902626cea3e99a23bf9291db8117c041b5b752 --- /dev/null +++ b/apps/server/src/persistence/SqliteClient.test.ts @@ -0,0 +1,26 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as SqliteClient from "./SqliteClient.ts"; + +it.effect("runs prepared queries and returns positional values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; + yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; + + const rows = yield* sql<{ readonly id: number; readonly name: string }>` + SELECT id, name FROM entries ORDER BY id + `; + assert.equal(rows.length, 2); + assert.equal(rows[0]?.name, "alpha"); + assert.equal(rows[1]?.name, "beta"); + + const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; + assert.equal(values.length, 2); + assert.equal(values[0]?.[1], "alpha"); + assert.equal(values[1]?.[1], "beta"); + }).pipe(Effect.provide(SqliteClient.layerMemory())), +); diff --git a/apps/server/src/persistence/SqliteClient.ts b/apps/server/src/persistence/SqliteClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1c88e57a9d722b18e7a7c2053dab9da1507647e --- /dev/null +++ b/apps/server/src/persistence/SqliteClient.ts @@ -0,0 +1,242 @@ +/** + * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` + * bindings instead of `better-sqlite3`. + */ +import { DatabaseSync, type StatementSync } from "node:sqlite"; + +import * as Cache from "effect/Cache"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { identity } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as ServiceMap from "effect/ServiceMap"; +import * as Stream from "effect/Stream"; +import * as Reactivity from "effect/unstable/reactivity/Reactivity"; +import * as Client from "effect/unstable/sql/SqlClient"; +import type { Connection } from "effect/unstable/sql/SqlConnection"; +import { SqlError } from "effect/unstable/sql/SqlError"; +import * as Statement from "effect/unstable/sql/Statement"; + +const ATTR_DB_SYSTEM_NAME = "db.system.name"; + +export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; + +export type TypeId = "~local/sqlite-node/SqliteClient"; + +export const SqliteClient = ServiceMap.Service("@local/sqlite-node/SqliteClient"); + +export interface SqliteClientConfig { + readonly filename: string; + readonly readonly?: boolean | undefined; + readonly allowExtension?: boolean | undefined; + readonly prepareCacheSize?: number | undefined; + readonly prepareCacheTTL?: Duration.Input | undefined; + readonly disableWAL?: boolean | undefined; + readonly spanAttributes?: Record | undefined; + readonly transformResultNames?: ((str: string) => string) | undefined; + readonly transformQueryNames?: ((str: string) => string) | undefined; +} + +export interface SqliteMemoryClientConfig extends Omit< + SqliteClientConfig, + "filename" | "readonly" +> {} + +const makeWithDatabase = ( + options: SqliteClientConfig, + openDatabase: () => DatabaseSync, +): Effect.Effect => + Effect.gen(function* () { + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined; + + const makeConnection = Effect.gen(function* () { + const scope = yield* Effect.scope; + const db = openDatabase(); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => db.close()), + ); + + if (options.disableWAL !== true) { + db.exec("PRAGMA journal_mode = WAL"); + } + + const statementReaderCache = new WeakMap(); + const hasRows = (statement: StatementSync): boolean => { + const cached = statementReaderCache.get(statement); + if (cached !== undefined) { + return cached; + } + const value = statement.columns().length > 0; + statementReaderCache.set(statement, value); + return value; + }; + + const prepareCache = yield* Cache.make({ + capacity: options.prepareCacheSize ?? 200, + timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), + lookup: (sql: string) => + Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + }), + }); + + const runStatement = ( + statement: StatementSync, + params: ReadonlyArray, + raw: boolean, + ) => + Effect.withFiber, SqlError>((fiber) => { + statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); + try { + if (hasRows(statement)) { + return Effect.succeed(statement.all(...(params as any))); + } + const result = statement.run(...(params as any)); + return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); + } catch (cause) { + return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + } + }); + + const run = (sql: string, params: ReadonlyArray, raw = false) => + Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); + + const runValues = (sql: string, params: ReadonlyArray) => + Effect.acquireUseRelease( + Cache.get(prepareCache, sql), + (statement) => + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(true); + // Safe to cast to array after we've setReturnArrays(true) + return statement.all(...(params as any)) as unknown as ReadonlyArray< + ReadonlyArray + >; + } + statement.run(...(params as any)); + return []; + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + }), + (statement) => + Effect.sync(() => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }), + ); + + return identity({ + execute(sql, params, rowTransform) { + return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); + }, + executeRaw(sql, params) { + return run(sql, params, true); + }, + executeValues(sql, params) { + return runValues(sql, params); + }, + executeUnprepared(sql, params, rowTransform) { + const effect = runStatement(db.prepare(sql), params ?? [], false); + return rowTransform ? Effect.map(effect, rowTransform) : effect; + }, + executeStream(_sql, _params) { + return Stream.die("executeStream not implemented"); + }, + }); + }); + + const semaphore = yield* Semaphore.make(1); + const connection = yield* makeConnection; + + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => + Scope.addFinalizer(scope, semaphore.release(1)), + ), + connection, + ); + }); + + return yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + }); + }); + +const make = ( + options: SqliteClientConfig, +): Effect.Effect => + makeWithDatabase( + options, + () => + new DatabaseSync(options.filename, { + readOnly: options.readonly ?? false, + allowExtension: options.allowExtension ?? false, + }), + ); + +const makeMemory = ( + config: SqliteMemoryClientConfig = {}, +): Effect.Effect => + makeWithDatabase( + { + ...config, + filename: ":memory:", + readonly: false, + disableWAL: config.disableWAL ?? true, + }, + () => { + const database = new DatabaseSync(":memory:", { + allowExtension: config.allowExtension ?? false, + }); + return database; + }, + ); + +export const layerConfig = ( + config: Config.Wrap, +): Layer.Layer => + Layer.effectServices( + Config.unwrap(config) + .asEffect() + .pipe( + Effect.flatMap(make), + Effect.map((client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectServices( + Effect.map(make(config), (client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => + Layer.effectServices( + Effect.map(makeMemory(config), (client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 8384a212823f0e9e84b32cb22f61a4a4e68461c2..25d8cf07ac176b079ea8b7f81e33eb57873e0813 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,8 +6,9 @@ import { Effect, Layer } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { makeProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; +import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; +import { NodeServices } from "@effect/platform-node"; const fakeCodexAdapter: CodexAdapterShape = { provider: "codex", @@ -25,13 +26,14 @@ const fakeCodexAdapter: CodexAdapterShape = { }; const layer = it.layer( - makeProviderAdapterRegistryLive().pipe( - Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.mergeAll( + Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + NodeServices.layer, ), ); layer("ProviderAdapterRegistryLive", (it) => { - it("resolves a registered provider adapter", () => + it.effect("resolves a registered provider adapter", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const adapter = yield* registry.getByProvider("codex"); @@ -39,12 +41,14 @@ layer("ProviderAdapterRegistryLive", (it) => { const providers = yield* registry.listProviders(); assert.deepEqual(providers, ["codex"]); - })); + }), + ); - it("fails with ProviderUnsupportedError for unknown providers", () => + it.effect("fails with ProviderUnsupportedError for unknown providers", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index ace64737ca35bb22ff8117f167b7e632c0ee98d3..e6a600327cce587e1b63ace592c53c3ce136d123 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -51,7 +51,3 @@ export const ProviderAdapterRegistryLive = Layer.effect( ProviderAdapterRegistry, makeProviderAdapterRegistry(), ); - -export function makeProviderAdapterRegistryLive(options?: ProviderAdapterRegistryLiveOptions) { - return Layer.effect(ProviderAdapterRegistry, makeProviderAdapterRegistry(options)); -} diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 726c190620e11308be179313380191e898437e49..2d7ecb4e2eb36f91f6c294197eb9658b1d50bea6 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -31,6 +31,7 @@ import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts" import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderServiceLive } from "./ProviderService.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { NodeServices } from "@effect/platform-node"; function makeFakeCodexAdapter() { const sessions = new Map(); @@ -253,15 +254,17 @@ function makeProviderServiceLayer() { listProviders: () => Effect.succeed(["codex"]), }; + const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const layer = it.layer( - ProviderServiceLive.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(ProviderAdapterRegistry, registry), - ProviderSessionDirectoryLive, - Layer.succeed(CheckpointService, checkpoint.service), - ), + Layer.mergeAll( + ProviderServiceLive.pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(ProviderSessionDirectoryLive), + Layer.provide(checkpointLayer), ), + NodeServices.layer, ), ); @@ -424,4 +427,40 @@ validation.layer("ProviderServiceLive validation", (it) => { ); }), ); + + it.effect("fails startSession when adapter returns no threadId", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + return { + sessionId: "sess-missing-thread", + provider: "codex", + status: "ready", + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }), + ); + + const failure = yield* Effect.result( + provider.startSession({ + provider: "codex", + cwd: "/tmp/project", + }), + ); + + assertFailure( + failure, + new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: + "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.", + }), + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 77d1d0f9afc0e07f46a0a2bded5b13e18d419cea..f445d157d41702ae5ff0e16f53744c42076732f8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -161,10 +161,20 @@ const makeProviderService = Effect.gen(function* () { ); } + const threadId = session.threadId?.trim(); + if (!threadId) { + return yield* Effect.fail( + toValidationError( + "ProviderService.startSession", + `Provider '${adapter.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, + ), + ); + } + yield* directory.upsert({ sessionId: session.sessionId, provider: session.provider, - ...(session.threadId !== undefined ? { threadId: session.threadId } : {}), + threadId, }); const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ade1641154361524f438dbbba18d5d1e8e1f42eb..d38d004e48bbeede1850a6f00390f174862d1a36 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -30,7 +30,9 @@ import type { } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; -import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; +import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { NodeServices } from "@effect/platform-node"; +import { SqlClient } from "effect/unstable/sql"; interface PendingMessages { queue: unknown[]; @@ -237,6 +239,7 @@ describe("WebSocket Server", () => { function createTestServer( options: { + persistenceLayer?: Layer.Layer; cwd?: string; devUrl?: string; authToken?: string; @@ -253,6 +256,7 @@ describe("WebSocket Server", () => { port: 0, cwd: options.cwd ?? "/test/project", stateDir, + persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory, ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), @@ -1033,11 +1037,11 @@ describe("WebSocket Server", () => { const stateDir = makeTempDir("t3code-ws-prune-state-"); const existing = makeTempDir("t3code-ws-existing-project-"); const missing = makeTempDir("t3code-ws-missing-project-"); + const dbPath = path.join(stateDir, "test-prune.sqlite"); + const persistenceLayer = Layer.provide(makeSqlitePersistenceLive(dbPath), NodeServices.layer); const projectRuntime = ManagedRuntime.make( - ProjectRepositoryLive.pipe( - Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))), - ), + ProjectRepositoryLive.pipe(Layer.provide(persistenceLayer)), ); const projectRepository = await projectRuntime.runPromise(Effect.service(ProjectRepository)); await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); @@ -1045,7 +1049,11 @@ describe("WebSocket Server", () => { await projectRuntime.dispose(); fs.rmSync(missing, { recursive: true, force: true }); - server = createTestServer({ stateDir, cwd: "/test" }); + server = createTestServer({ + stateDir, + cwd: "/test", + persistenceLayer, + }); await server.start(); const addr = server.httpServer.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d9fde1cb735800b75e1a6985cba447c0d8c6f422..8d33223d22128f4ec36d623bf4aa58fee8670bc2 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -18,11 +18,11 @@ import { type WsResponse, wsRequestSchema, } from "@t3tools/contracts"; +import { NodeServices } from "@effect/platform-node"; import { Effect, Layer, ManagedRuntime } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; -import { ProviderManager } from "./providerManager"; import { GitManager } from "./gitManager"; import { checkoutGitBranch, @@ -43,6 +43,17 @@ import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; import assert from "node:assert"; import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; +import { CodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; +import { ProviderServiceLive } from "./provider/Layers/ProviderService"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; +import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; +import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlError from "effect/unstable/sql/SqlError"; +import * as Migrator from "effect/unstable/sql/Migrator"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -64,6 +75,9 @@ export interface ServerOptions { host?: string | undefined; cwd: string; stateDir?: string | undefined; + persistenceLayer?: + | Layer.Layer + | undefined; staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; @@ -121,6 +135,7 @@ export function createServer(options: ServerOptions) { host, cwd, stateDir, + persistenceLayer: customPersistenceLayer, staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, @@ -128,15 +143,15 @@ export function createServer(options: ServerOptions) { terminalManager = new TerminalManager(), authToken, } = options; - const providerManager = new ProviderManager(); const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); let effectRuntime: ManagedRuntime.ManagedRuntime< - ProjectRepository | OrchestrationEngineService, + ProjectRepository | OrchestrationEngineService | ProviderService, unknown > | null = null; let projectRepository: ProjectRepositoryShape | null = null; let orchestrationEngine: OrchestrationEngineShape | null = null; + let providerService: ProviderServiceShape | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -144,6 +159,7 @@ export function createServer(options: ServerOptions) { let keybindingsConfig = loadResolvedKeybindingsConfig(logger); let unsubscribeReadModel = noop; let unsubscribeDomainEvents = noop; + let unsubscribeProviderEvents = noop; function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -169,8 +185,16 @@ export function createServer(options: ServerOptions) { return effectRuntime; }; + const getProviderService = () => { + assert(providerService, "Provider service is not started"); + return providerService; + }; + async function attachOrchestrationSubscriptions( - runtime: ManagedRuntime.ManagedRuntime, + runtime: ManagedRuntime.ManagedRuntime< + ProjectRepository | OrchestrationEngineService | ProviderService, + unknown + >, orchestrationEngine: OrchestrationEngineShape, ) { unsubscribeReadModel = await runtime.runPromise( @@ -215,114 +239,121 @@ export function createServer(options: ServerOptions) { ); } - // Forward provider events to all connected WebSocket clients - providerManager.on("event", (event) => { - void (async () => { - const liveOrchestrationEngine = orchestrationEngine; - const runtime = effectRuntime; - if (!liveOrchestrationEngine || !runtime) { - return; - } - const snapshot = await runtime.runPromise(liveOrchestrationEngine.getSnapshot()); - const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); - if (!thread) return; - const now = event.createdAt; - if (event.method === "turn/started" || event.method === "turn/completed") { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: event.method === "turn/started" ? "running" : "ready", - threadId: thread.id, - activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }), - ); - } - if (event.textDelta && event.textDelta.length > 0) { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: event.textDelta, - streaming: true, - createdAt: now, - }), - ); - } - if (event.method === "turn/completed") { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: "", - streaming: false, - createdAt: now, - }), - ); - } - if (event.method === "checkpoint/captured") { - const payload = asObject(event.payload); - const turnId = event.turnId ?? asString(payload?.turnId); - if (turnId) { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.turnDiff.complete", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnId, - completedAt: now, - ...(asString(payload?.status) !== null - ? { status: asString(payload?.status) ?? undefined } - : {}), - files: [], - assistantMessageId: `assistant:${turnId}`, - ...(asNumber(payload?.turnCount) !== null - ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } - : {}), - createdAt: now, - }), + async function attachProviderSubscriptions( + runtime: ManagedRuntime.ManagedRuntime< + ProjectRepository | OrchestrationEngineService | ProviderService, + unknown + >, + providerService: ProviderServiceShape, + orchestrationEngine: OrchestrationEngineShape, + ) { + unsubscribeProviderEvents = await runtime.runPromise( + providerService.subscribeToEvents((event) => { + void (async () => { + const snapshot = await runtime.runPromise(orchestrationEngine.getSnapshot()); + const thread = snapshot.threads.find( + (entry) => entry.session?.sessionId === event.sessionId, ); - } - } - if (event.kind === "error" && event.message) { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: "error", - threadId: thread.id, - activeTurnId: event.turnId ?? null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: event.message, - }, - createdAt: now, - }), - ); - } - })().catch(() => undefined); - }); + if (!thread) return; + const now = event.createdAt; + if (event.method === "turn/started" || event.method === "turn/completed") { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.method === "turn/started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); + } + if (event.textDelta && event.textDelta.length > 0) { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.textDelta, + streaming: true, + createdAt: now, + }), + ); + } + if (event.method === "turn/completed") { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }), + ); + } + if (event.method === "checkpoint/captured") { + const payload = asObject(event.payload); + const turnId = event.turnId ?? asString(payload?.turnId); + if (turnId) { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.turnDiff.complete", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnId, + completedAt: now, + ...(asString(payload?.status) !== null + ? { status: asString(payload?.status) ?? undefined } + : {}), + files: [], + assistantMessageId: `assistant:${turnId}`, + ...(asNumber(payload?.turnCount) !== null + ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } + : {}), + createdAt: now, + }), + ); + } + } + if (event.kind === "error" && event.message) { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }), + ); + } + })().catch(() => undefined); + }), + ); + } const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { @@ -504,33 +535,36 @@ export function createServer(options: ServerOptions) { const orchestrationEngine = getOrchestrationEngine(); const projectRepository = getProjectRepository(); const effectRuntime = getEffectRuntime(); + const providerService = getProviderService(); switch (request.method) { case WS_METHODS.providersStartSession: - return providerManager.startSession(request.params as never); + return effectRuntime.runPromise(providerService.startSession(request.params as never)); case WS_METHODS.providersSendTurn: - return providerManager.sendTurn(request.params as never); + return effectRuntime.runPromise(providerService.sendTurn(request.params as never)); case WS_METHODS.providersInterruptTurn: - return providerManager.interruptTurn(request.params as never); + return effectRuntime.runPromise(providerService.interruptTurn(request.params as never)); case WS_METHODS.providersRespondToRequest: - return providerManager.respondToRequest(request.params as never); + return effectRuntime.runPromise(providerService.respondToRequest(request.params as never)); case WS_METHODS.providersStopSession: { - providerManager.stopSession(request.params as never); + await effectRuntime.runPromise(providerService.stopSession(request.params as never)); return undefined; } case WS_METHODS.providersListCheckpoints: - return providerManager.listCheckpoints(request.params as never); + return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never)); case WS_METHODS.providersGetCheckpointDiff: - return providerManager.getCheckpointDiff(request.params as never); + return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never)); case WS_METHODS.providersRevertToCheckpoint: { - const result = await providerManager.revertToCheckpoint(request.params as never); + const result = await effectRuntime.runPromise( + providerService.revertToCheckpoint(request.params as never), + ); const params = request.params as { sessionId?: string }; const sessionId = params.sessionId; if (typeof sessionId === "string") { @@ -735,25 +769,42 @@ export function createServer(options: ServerOptions) { async function createEffectRuntime() { const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); const orchestrationLayer = Layer.provide( OrchestrationEngineLive, OrchestrationEventRepositoryLive, ); - const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive).pipe( + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(CodexAdapterLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + const checkpointDependenciesLayer = Layer.mergeAll( + checkpointStoreLayer, + CheckpointRepositoryLive, + adapterRegistryLayer, + ProviderSessionDirectoryLive, + ); + const checkpointServiceLayer = CheckpointServiceLive.pipe( + Layer.provideMerge(checkpointDependenciesLayer), + ); + const providerLayer = ProviderServiceLive.pipe(Layer.provideMerge(checkpointServiceLayer)); + + const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive, providerLayer).pipe( Layer.provide(persistenceLayer), + Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); try { - const [nextOrchestrationEngine, repository] = await Promise.all([ + const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([ runtime.runPromise(Effect.service(OrchestrationEngineService)), runtime.runPromise(Effect.service(ProjectRepository)), + runtime.runPromise(Effect.service(ProviderService)), ]); orchestrationEngine = nextOrchestrationEngine; projectRepository = repository; + providerService = nextProviderService; await runtime.runPromise(repository.pruneMissing()); await attachOrchestrationSubscriptions(runtime, nextOrchestrationEngine); + await attachProviderSubscriptions(runtime, nextProviderService, nextOrchestrationEngine); } catch (error) { await runtime.dispose().catch(() => undefined); throw error; @@ -765,18 +816,26 @@ export function createServer(options: ServerOptions) { async function disposeEffectRuntime() { unsubscribeReadModel(); unsubscribeDomainEvents(); + unsubscribeProviderEvents(); unsubscribeReadModel = noop; unsubscribeDomainEvents = noop; + unsubscribeProviderEvents = noop; const runtime = effectRuntime; + const liveProviderService = providerService; effectRuntime = null; orchestrationEngine = null; projectRepository = null; + providerService = null; if (!runtime) { return; } + if (liveProviderService) { + await runtime.runPromise(liveProviderService.stopAll()).catch(() => undefined); + } + await runtime.dispose(); } @@ -804,8 +863,6 @@ export function createServer(options: ServerOptions) { async function stop(): Promise { await disposeEffectRuntime(); terminalManager.off("event", onTerminalEvent); - providerManager.stopAll(); - providerManager.dispose(); terminalManager.dispose(); for (const client of clients) { diff --git a/bun.lock b/bun.lock index 0292e127a49bf96fb40650bf8a0ee3e23daef546..2438919d85f764a7a3297234d3c6f70da5cc7316 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,6 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.10", - "@effect/sql-sqlite-node": "4.0.0-beta.10", "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -154,8 +153,6 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="], - "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0-beta.10", "", { "dependencies": { "better-sqlite3": "^12.6.2" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-oQl+gF31HaVM9R8YEpf96CIJypP1Roa5z1VPj8s7ZP+3aCtRVor1K+eMxYNAb8ph7R68Tz0LpmVkefq0mEI3CA=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -610,28 +607,18 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -662,8 +649,6 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -698,8 +683,6 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -774,8 +757,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -788,8 +769,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -798,8 +777,6 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -818,8 +795,6 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], @@ -868,14 +843,10 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1082,8 +1053,6 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], @@ -1098,10 +1067,6 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], - "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], @@ -1150,8 +1115,6 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1168,8 +1131,6 @@ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -1178,8 +1139,6 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1234,8 +1193,6 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1254,10 +1211,6 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1278,14 +1231,10 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1306,10 +1255,6 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -1346,8 +1291,6 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], @@ -1400,8 +1343,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1514,8 +1455,6 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],