Showing the changes to apps/server/src/git/Layers/OpenCodeTextGeneration.ts (1 of 52 files). Show all
Unified · Split
| @@ -0,0 +1,422 @@ | |||
| 1 | import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; | ||
| 2 | import * as Semaphore from "effect/Semaphore"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | TextGenerationError, | ||
| 6 | type ChatAttachment, | ||
| 7 | type OpenCodeModelSelection, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 10 | |||
| 11 | import { ServerConfig } from "../../config.ts"; | ||
| 12 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 13 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 14 | import { | ||
| 15 | buildBranchNamePrompt, | ||
| 16 | buildCommitMessagePrompt, | ||
| 17 | buildPrContentPrompt, | ||
| 18 | buildThreadTitlePrompt, | ||
| 19 | } from "../Prompts.ts"; | ||
| 20 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | ||
| 21 | import { | ||
| 22 | sanitizeCommitSubject, | ||
| 23 | sanitizePrTitle, | ||
| 24 | sanitizeThreadTitle, | ||
| 25 | toJsonSchemaObject, | ||
| 26 | } from "../Utils.ts"; | ||
| 27 | import { | ||
| 28 | createOpenCodeSdkClient, | ||
| 29 | type OpenCodeServerConnection, | ||
| 30 | type OpenCodeServerProcess, | ||
| 31 | parseOpenCodeModelSlug, | ||
| 32 | startOpenCodeServerProcess, | ||
| 33 | toOpenCodeFileParts, | ||
| 34 | } from "../../provider/opencodeRuntime.ts"; | ||
| 35 | |||
| 36 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | ||
| 37 | |||
| 38 | interface SharedOpenCodeTextGenerationServerState { | ||
| 39 | server: OpenCodeServerProcess | null; | ||
| 40 | binaryPath: string | null; | ||
| 41 | activeRequests: number; | ||
| 42 | idleCloseFiber: Fiber.Fiber<void, never> | null; | ||
| 43 | } | ||
| 44 | |||
| 45 | const makeOpenCodeTextGeneration = Effect.gen(function* () { | ||
| 46 | const serverConfig = yield* ServerConfig; | ||
| 47 | const serverSettingsService = yield* ServerSettingsService; | ||
| 48 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | ||
| 49 | Scope.close(scope, Exit.void), | ||
| 50 | ); | ||
| 51 | const sharedServerMutex = yield* Semaphore.make(1); | ||
| 52 | const sharedServerState: SharedOpenCodeTextGenerationServerState = { | ||
| 53 | server: null, | ||
| 54 | binaryPath: null, | ||
| 55 | activeRequests: 0, | ||
| 56 | idleCloseFiber: null, | ||
| 57 | }; | ||
| 58 | |||
| 59 | const closeSharedServer = (server: OpenCodeServerProcess) => { | ||
| 60 | if (sharedServerState.server === server) { | ||
| 61 | sharedServerState.server = null; | ||
| 62 | sharedServerState.binaryPath = null; | ||
| 63 | } | ||
| 64 | server.close(); | ||
| 65 | }; | ||
| 66 | |||
| 67 | const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { | ||
| 68 | const idleCloseFiber = sharedServerState.idleCloseFiber; | ||
| 69 | sharedServerState.idleCloseFiber = null; | ||
| 70 | if (idleCloseFiber !== null) { | ||
| 71 | yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); | ||
| 72 | } | ||
| 73 | }); | ||
| 74 | |||
| 75 | const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( | ||
| 76 | server: OpenCodeServerProcess, | ||
| 77 | ) { | ||
| 78 | yield* cancelIdleCloseFiber(); | ||
| 79 | const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( | ||
| 80 | Effect.andThen( | ||
| 81 | sharedServerMutex.withPermit( | ||
| 82 | Effect.sync(() => { | ||
| 83 | if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { | ||
| 84 | return; | ||
| 85 | } | ||
| 86 | sharedServerState.idleCloseFiber = null; | ||
| 87 | closeSharedServer(server); | ||
| 88 | }), | ||
| 89 | ), | ||
| 90 | ), | ||
| 91 | Effect.forkIn(idleFiberScope), | ||
| 92 | ); | ||
| 93 | sharedServerState.idleCloseFiber = fiber; | ||
| 94 | }); | ||
| 95 | |||
| 96 | const acquireSharedServer = (input: { | ||
| 97 | readonly binaryPath: string; | ||
| 98 | readonly operation: | ||
| 99 | | "generateCommitMessage" | ||
| 100 | | "generatePrContent" | ||
| 101 | | "generateBranchName" | ||
| 102 | | "generateThreadTitle"; | ||
| 103 | }) => | ||
| 104 | sharedServerMutex.withPermit( | ||
| 105 | Effect.gen(function* () { | ||
| 106 | yield* cancelIdleCloseFiber(); | ||
| 107 | |||
| 108 | const existingServer = sharedServerState.server; | ||
| 109 | if (existingServer !== null) { | ||
| 110 | if ( | ||
| 111 | sharedServerState.binaryPath !== input.binaryPath && | ||
| 112 | sharedServerState.activeRequests === 0 | ||
| 113 | ) { | ||
| 114 | closeSharedServer(existingServer); | ||
| 115 | } else { | ||
| 116 | if (sharedServerState.binaryPath !== input.binaryPath) { | ||
| 117 | yield* Effect.logWarning( | ||
| 118 | "OpenCode shared server binary path mismatch: requested " + | ||
| 119 | input.binaryPath + | ||
| 120 | " but active server uses " + | ||
| 121 | sharedServerState.binaryPath + | ||
| 122 | "; reusing existing server because there are active requests", | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | sharedServerState.activeRequests += 1; | ||
| 126 | return existingServer; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | const server = yield* Effect.tryPromise({ | ||
| 131 | try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), | ||
| 132 | catch: (cause) => | ||
| 133 | new TextGenerationError({ | ||
| 134 | operation: input.operation, | ||
| 135 | detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", | ||
| 136 | cause, | ||
| 137 | }), | ||
| 138 | }); | ||
| 139 | |||
| 140 | sharedServerState.server = server; | ||
| 141 | sharedServerState.binaryPath = input.binaryPath; | ||
| 142 | sharedServerState.activeRequests = 1; | ||
| 143 | return server; | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | const releaseSharedServer = (server: OpenCodeServerProcess) => | ||
| 148 | sharedServerMutex.withPermit( | ||
| 149 | Effect.gen(function* () { | ||
| 150 | if (sharedServerState.server !== server) { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); | ||
| 154 | if (sharedServerState.activeRequests === 0) { | ||
| 155 | yield* scheduleIdleClose(server); | ||
| 156 | } | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | yield* Effect.addFinalizer(() => | ||
| 161 | sharedServerMutex.withPermit( | ||
| 162 | Effect.gen(function* () { | ||
| 163 | yield* cancelIdleCloseFiber(); | ||
| 164 | const server = sharedServerState.server; | ||
| 165 | sharedServerState.server = null; | ||
| 166 | sharedServerState.binaryPath = null; | ||
| 167 | sharedServerState.activeRequests = 0; | ||
| 168 | if (server !== null) { | ||
| 169 | server.close(); | ||
| 170 | } | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | ); | ||
| 174 | |||
| 175 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | ||
| 176 | readonly operation: | ||
| 177 | | "generateCommitMessage" | ||
| 178 | | "generatePrContent" | ||
| 179 | | "generateBranchName" | ||
| 180 | | "generateThreadTitle"; | ||
| 181 | readonly cwd: string; | ||
| 182 | readonly prompt: string; | ||
| 183 | readonly outputSchemaJson: S; | ||
| 184 | readonly modelSelection: OpenCodeModelSelection; | ||
| 185 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 186 | }) { | ||
| 187 | const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | ||
| 188 | if (!parsedModel) { | ||
| 189 | return yield* new TextGenerationError({ | ||
| 190 | operation: input.operation, | ||
| 191 | detail: "OpenCode model selection must use the 'provider/model' format.", | ||
| 192 | }); | ||
| 193 | } | ||
| 194 | |||
| 195 | const settings = yield* serverSettingsService.getSettings.pipe( | ||
| 196 | Effect.map( | ||
| 197 | (value) => | ||
| 198 | value.providers?.opencode ?? { | ||
| 199 | enabled: true, | ||
| 200 | binaryPath: "opencode", | ||
| 201 | serverUrl: "", | ||
| 202 | serverPassword: "", | ||
| 203 | customModels: [], | ||
| 204 | }, | ||
| 205 | ), | ||
| 206 | Effect.orElseSucceed(() => ({ | ||
| 207 | enabled: true, | ||
| 208 | binaryPath: "opencode", | ||
| 209 | serverUrl: "", | ||
| 210 | serverPassword: "", | ||
| 211 | customModels: [], | ||
| 212 | })), | ||
| 213 | ); | ||
| 214 | |||
| 215 | const fileParts = toOpenCodeFileParts({ | ||
| 216 | attachments: input.attachments, | ||
| 217 | resolveAttachmentPath: (attachment) => | ||
| 218 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | ||
| 219 | }); | ||
| 220 | |||
| 221 | const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) => | ||
| 222 | Effect.tryPromise({ | ||
| 223 | try: async () => { | ||
| 224 | const client = createOpenCodeSdkClient({ | ||
| 225 | baseUrl: server.url, | ||
| 226 | directory: input.cwd, | ||
| 227 | ...(settings.serverUrl.length > 0 && settings.serverPassword | ||
| 228 | ? { serverPassword: settings.serverPassword } | ||
| 229 | : {}), | ||
| 230 | }); | ||
| 231 | const session = await client.session.create({ | ||
| 232 | title: `T3 Code ${input.operation}`, | ||
| 233 | permission: [{ permission: "*", pattern: "*", action: "deny" }], | ||
| 234 | }); | ||
| 235 | if (!session.data) { | ||
| 236 | throw new Error("OpenCode session.create returned no session payload."); | ||
| 237 | } | ||
| 238 | |||
| 239 | const result = await client.session.prompt({ | ||
| 240 | sessionID: session.data.id, | ||
| 241 | model: parsedModel, | ||
| 242 | ...(input.modelSelection.options?.agent | ||
| 243 | ? { agent: input.modelSelection.options.agent } | ||
| 244 | : {}), | ||
| 245 | ...(input.modelSelection.options?.variant | ||
| 246 | ? { variant: input.modelSelection.options.variant } | ||
| 247 | : {}), | ||
| 248 | format: { | ||
| 249 | type: "json_schema", | ||
| 250 | schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>, | ||
| 251 | }, | ||
| 252 | parts: [{ type: "text", text: input.prompt }, ...fileParts], | ||
| 253 | }); | ||
| 254 | const structured = result.data?.info?.structured; | ||
| 255 | if (structured === undefined) { | ||
| 256 | throw new Error("OpenCode returned no structured output."); | ||
| 257 | } | ||
| 258 | return structured; | ||
| 259 | }, | ||
| 260 | catch: (cause) => | ||
| 261 | new TextGenerationError({ | ||
| 262 | operation: input.operation, | ||
| 263 | detail: | ||
| 264 | cause instanceof Error ? cause.message : "OpenCode text generation request failed.", | ||
| 265 | cause, | ||
| 266 | }), | ||
| 267 | }); | ||
| 268 | |||
| 269 | const structuredOutput = | ||
| 270 | settings.serverUrl.length > 0 | ||
| 271 | ? yield* runAgainstServer({ url: settings.serverUrl }) | ||
| 272 | : yield* Effect.acquireUseRelease( | ||
| 273 | acquireSharedServer({ | ||
| 274 | binaryPath: settings.binaryPath, | ||
| 275 | operation: input.operation, | ||
| 276 | }), | ||
| 277 | runAgainstServer, | ||
| 278 | releaseSharedServer, | ||
| 279 | ); | ||
| 280 | |||
| 281 | return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( | ||
| 282 | Effect.catchTag("SchemaError", (cause) => | ||
| 283 | Effect.fail( | ||
| 284 | new TextGenerationError({ | ||
| 285 | operation: input.operation, | ||
| 286 | detail: "OpenCode returned invalid structured output.", | ||
| 287 | cause, | ||
| 288 | }), | ||
| 289 | ), | ||
| 290 | ), | ||
| 291 | ); | ||
| 292 | }); | ||
| 293 | |||
| 294 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 295 | "OpenCodeTextGeneration.generateCommitMessage", | ||
| 296 | )(function* (input) { | ||
| 297 | if (input.modelSelection.provider !== "opencode") { | ||
| 298 | return yield* new TextGenerationError({ | ||
| 299 | operation: "generateCommitMessage", | ||
| 300 | detail: "Invalid model selection.", | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 305 | branch: input.branch, | ||
| 306 | stagedSummary: input.stagedSummary, | ||
| 307 | stagedPatch: input.stagedPatch, | ||
| 308 | includeBranch: input.includeBranch === true, | ||
| 309 | }); | ||
| 310 | const generated = yield* runOpenCodeJson({ | ||
| 311 | operation: "generateCommitMessage", | ||
| 312 | cwd: input.cwd, | ||
| 313 | prompt, | ||
| 314 | outputSchemaJson: outputSchema, | ||
| 315 | modelSelection: input.modelSelection, | ||
| 316 | }); | ||
| 317 | |||
| 318 | return { | ||
| 319 | subject: sanitizeCommitSubject(generated.subject), | ||
| 320 | body: generated.body.trim(), | ||
| 321 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 322 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 323 | : {}), | ||
| 324 | }; | ||
| 325 | }); | ||
| 326 | |||
| 327 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 328 | "OpenCodeTextGeneration.generatePrContent", | ||
| 329 | )(function* (input) { | ||
| 330 | if (input.modelSelection.provider !== "opencode") { | ||
| 331 | return yield* new TextGenerationError({ | ||
| 332 | operation: "generatePrContent", | ||
| 333 | detail: "Invalid model selection.", | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | |||
| 337 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 338 | baseBranch: input.baseBranch, | ||
| 339 | headBranch: input.headBranch, | ||
| 340 | commitSummary: input.commitSummary, | ||
| 341 | diffSummary: input.diffSummary, | ||
| 342 | diffPatch: input.diffPatch, | ||
| 343 | }); | ||
| 344 | const generated = yield* runOpenCodeJson({ | ||
| 345 | operation: "generatePrContent", | ||
| 346 | cwd: input.cwd, | ||
| 347 | prompt, | ||
| 348 | outputSchemaJson: outputSchema, | ||
| 349 | modelSelection: input.modelSelection, | ||
| 350 | }); | ||
| 351 | |||
| 352 | return { | ||
| 353 | title: sanitizePrTitle(generated.title), | ||
| 354 | body: generated.body.trim(), | ||
| 355 | }; | ||
| 356 | }); | ||
| 357 | |||
| 358 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 359 | "OpenCodeTextGeneration.generateBranchName", | ||
| 360 | )(function* (input) { | ||
| 361 | if (input.modelSelection.provider !== "opencode") { | ||
| 362 | return yield* new TextGenerationError({ | ||
| 363 | operation: "generateBranchName", | ||
| 364 | detail: "Invalid model selection.", | ||
| 365 | }); | ||
| 366 | } | ||
| 367 | |||
| 368 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 369 | message: input.message, | ||
| 370 | attachments: input.attachments, | ||
| 371 | }); | ||
| 372 | const generated = yield* runOpenCodeJson({ | ||
| 373 | operation: "generateBranchName", | ||
| 374 | cwd: input.cwd, | ||
| 375 | prompt, | ||
| 376 | outputSchemaJson: outputSchema, | ||
| 377 | modelSelection: input.modelSelection, | ||
| 378 | attachments: input.attachments, | ||
| 379 | }); | ||
| 380 | |||
| 381 | return { | ||
| 382 | branch: sanitizeBranchFragment(generated.branch), | ||
| 383 | }; | ||
| 384 | }); | ||
| 385 | |||
| 386 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 387 | "OpenCodeTextGeneration.generateThreadTitle", | ||
| 388 | )(function* (input) { | ||
| 389 | if (input.modelSelection.provider !== "opencode") { | ||
| 390 | return yield* new TextGenerationError({ | ||
| 391 | operation: "generateThreadTitle", | ||
| 392 | detail: "Invalid model selection.", | ||
| 393 | }); | ||
| 394 | } | ||
| 395 | |||
| 396 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 397 | message: input.message, | ||
| 398 | attachments: input.attachments, | ||
| 399 | }); | ||
| 400 | const generated = yield* runOpenCodeJson({ | ||
| 401 | operation: "generateThreadTitle", | ||
| 402 | cwd: input.cwd, | ||
| 403 | prompt, | ||
| 404 | outputSchemaJson: outputSchema, | ||
| 405 | modelSelection: input.modelSelection, | ||
| 406 | attachments: input.attachments, | ||
| 407 | }); | ||
| 408 | |||
| 409 | return { | ||
| 410 | title: sanitizeThreadTitle(generated.title), | ||
| 411 | }; | ||
| 412 | }); | ||
| 413 | |||
| 414 | return { | ||
| 415 | generateCommitMessage, | ||
| 416 | generatePrContent, | ||
| 417 | generateBranchName, | ||
| 418 | generateThreadTitle, | ||
| 419 | } satisfies TextGenerationShape; | ||
| 420 | }); | ||
| 421 | |||
| 422 | export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); | ||