586 lines · 19.9 KB · TypeScript
RawDownload
import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";import {  createOpencodeClient,  type Agent,  type FilePartInput,  type OpencodeClient,  type PermissionRuleset,  type ProviderListResponse,  type QuestionAnswer,  type QuestionRequest,} from "@opencode-ai/sdk/v2";import * as NetService from "@t3tools/shared/Net";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { resolveSpawnCommand } from "@t3tools/shared/shell";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Scope from "effect/Scope";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { isWindowsCommandNotFound } from "../processRunner.ts";import { collectStreamAsString } from "./providerSnapshot.ts"; const encoder = new TextEncoder();const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;const DEFAULT_HOSTNAME = "127.0.0.1";export interface OpenCodeServerProcess {  readonly url: string;  readonly exitCode: Effect.Effect<number, never>;} export interface OpenCodeServerConnection {  readonly url: string;  readonly exitCode: Effect.Effect<number, never> | null;  readonly external: boolean;} export class OpenCodeRuntimeError extends Schema.TaggedErrorClass<OpenCodeRuntimeError>()(  "OpenCodeRuntimeError",  {    operation: Schema.String,    detail: Schema.String,    cause: Schema.optional(Schema.Defect()),    exitCode: Schema.optionalKey(Schema.Number),    argumentCount: Schema.optionalKey(Schema.Number),    timeoutMs: Schema.optionalKey(Schema.Number),    responseStatus: Schema.optionalKey(Schema.Number),    stdoutBytes: Schema.optionalKey(Schema.Number),    stderrBytes: Schema.optionalKey(Schema.Number),  },) {  override get message(): string {    return `OpenCode runtime operation ${this.operation} failed.`;  }   static fromCause(input: {    readonly operation: string;    readonly detail: string;    readonly cause: unknown;    readonly argumentCount?: number;  }): OpenCodeRuntimeError {    if (isOpenCodeRuntimeError(input.cause)) {      return input.cause;    }    const responseStatus = openCodeResponseStatus(input.cause);    return new OpenCodeRuntimeError({      operation: input.operation,      detail: input.detail,      cause: input.cause,      ...(input.argumentCount === undefined ? {} : { argumentCount: input.argumentCount }),      ...(responseStatus === undefined ? {} : { responseStatus }),    });  }   static detailFromCause(cause: unknown): string {    return isOpenCodeRuntimeError(cause) ? cause.detail : "OpenCode request failed.";  }} export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError); function openCodeResponseStatus(cause: unknown): number | undefined {  if (isOpenCodeRuntimeError(cause)) {    return cause.responseStatus;  }  if (typeof cause !== "object" || cause === null) {    return undefined;  }  const response = (cause as Readonly<Record<string, unknown>>).response;  if (typeof response !== "object" || response === null) {    return undefined;  }  const status = (response as Readonly<Record<string, unknown>>).status;  return typeof status === "number" && Number.isInteger(status) ? status : undefined;} function utf8ByteLength(value: string): number {  return encoder.encode(value).byteLength;} export const runOpenCodeSdk = <A>(  operation: string,  fn: () => Promise<A>,): Effect.Effect<A, OpenCodeRuntimeError> =>  Effect.tryPromise({    try: fn,    catch: (cause) =>      OpenCodeRuntimeError.fromCause({        operation,        detail: "OpenCode SDK request failed.",        cause,      }),  }).pipe(Effect.withSpan(`opencode.${operation}`)); export interface OpenCodeCommandResult {  readonly stdout: string;  readonly stderr: string;  readonly code: number;} export interface OpenCodeInventory {  readonly providerList: ProviderListResponse;  readonly agents: ReadonlyArray<Agent>;} export interface ParsedOpenCodeModelSlug {  readonly providerID: string;  readonly modelID: string;} export class OpenCodeRuntime extends Context.Service<  OpenCodeRuntime,  {    /**     * Spawns a local OpenCode server process. Its lifetime is bound to the caller's     * `Scope.Scope` — the child is killed automatically when that scope closes.     * Consumers that want a long-lived server must create and hold a scope explicitly     * (see {@link Scope.make}) and close it when done.     */    readonly startOpenCodeServerProcess: (input: {      readonly binaryPath: string;      readonly environment?: NodeJS.ProcessEnv;      readonly port?: number;      readonly hostname?: string;      readonly timeoutMs?: number;    }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;    /**     * Returns a handle to either an externally-managed OpenCode server (when     * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a     * freshly spawned local server whose lifetime is bound to the caller's scope.     */    readonly connectToOpenCodeServer: (input: {      readonly binaryPath: string;      readonly serverUrl?: string | null;      readonly environment?: NodeJS.ProcessEnv;      readonly port?: number;      readonly hostname?: string;      readonly timeoutMs?: number;    }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;    readonly runOpenCodeCommand: (input: {      readonly binaryPath: string;      readonly args: ReadonlyArray<string>;      readonly environment?: NodeJS.ProcessEnv;    }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;    readonly createOpenCodeSdkClient: (input: {      readonly baseUrl: string;      readonly directory: string;      readonly serverPassword?: string;    }) => OpencodeClient;    readonly loadOpenCodeInventory: (      client: OpencodeClient,    ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;  }>()("t3/provider/opencodeRuntime") {} function parseServerUrlFromOutput(output: string): string | null {  for (const line of output.split("\n")) {    if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) {      continue;    }    const match = line.match(/on\s+(https?:\/\/[^\s]+)/);    return match?.[1] ?? null;  }  return null;} export function parseOpenCodeModelSlug(  slug: string | null | undefined,): ParsedOpenCodeModelSlug | null {  if (typeof slug !== "string") {    return null;  }   const trimmed = slug.trim();  const separator = trimmed.indexOf("/");  if (separator <= 0 || separator === trimmed.length - 1) {    return null;  }   return {    providerID: trimmed.slice(0, separator),    modelID: trimmed.slice(separator + 1),  };} export function openCodeQuestionId(  index: number,  question: QuestionRequest["questions"][number],): string {  const header = question.header    .trim()    .toLowerCase()    .replace(/[^a-z0-9_-]+/g, "-");  return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;} export function toOpenCodeFileParts(input: {  readonly attachments: ReadonlyArray<ChatAttachment> | undefined;  readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;}): Array<FilePartInput> {  const parts: Array<FilePartInput> = [];   for (const attachment of input.attachments ?? []) {    const attachmentPath = input.resolveAttachmentPath(attachment);    if (!attachmentPath) {      continue;    }     parts.push({      type: "file",      mime: attachment.mimeType,      filename: attachment.name,      url: NodeURL.pathToFileURL(attachmentPath).href,    });  }   return parts;} export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {  if (runtimeMode === "full-access") {    return [{ permission: "*", pattern: "*", action: "allow" }];  }   return [    { permission: "*", pattern: "*", action: "ask" },    { permission: "bash", pattern: "*", action: "ask" },    { permission: "edit", pattern: "*", action: "ask" },    { permission: "webfetch", pattern: "*", action: "ask" },    { permission: "websearch", pattern: "*", action: "ask" },    { permission: "codesearch", pattern: "*", action: "ask" },    { permission: "external_directory", pattern: "*", action: "ask" },    { permission: "doom_loop", pattern: "*", action: "ask" },    { permission: "question", pattern: "*", action: "allow" },  ];} export function toOpenCodePermissionReply(  decision: ProviderApprovalDecision,): "once" | "always" | "reject" {  switch (decision) {    case "accept":      return "once";    case "acceptForSession":      return "always";    case "decline":    case "cancel":    default:      return "reject";  }} export function toOpenCodeQuestionAnswers(  request: QuestionRequest,  answers: Record<string, unknown>,): Array<QuestionAnswer> {  return request.questions.map((question, index) => {    const raw =      answers[openCodeQuestionId(index, question)] ??      answers[question.header] ??      answers[question.question];    if (Array.isArray(raw)) {      return raw.filter((value): value is string => typeof value === "string");    }    if (typeof raw === "string") {      return raw.trim().length > 0 ? [raw] : [];    }    return [];  });} export const make = Effect.gen(function* () {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const netService = yield* NetService.NetService;  const hostPlatform = yield* HostProcessPlatform;  const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>    resolveSpawnCommand(command, args, env ? { env } : {});   const runOpenCodeCommand: OpenCodeRuntime["Service"]["runOpenCodeCommand"] = (input) =>    Effect.gen(function* () {      const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);      const child = yield* spawner.spawn(        ChildProcess.make(spawnCommand.command, spawnCommand.args, {          shell: spawnCommand.shell,          ...(input.environment ? { env: input.environment } : { extendEnv: true }),        }),      );      const [stdout, stderr, code] = yield* Effect.all(        [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],        { concurrency: "unbounded" },      );      const exitCode = Number(code);      if (yield* isWindowsCommandNotFound(exitCode, stderr)) {        return yield* new OpenCodeRuntimeError({          operation: "runOpenCodeCommand",          detail: "OpenCode executable was not found.",          argumentCount: input.args.length,        });      }      return {        stdout,        stderr,        code: exitCode,      } satisfies OpenCodeCommandResult;    }).pipe(      Effect.scoped,      Effect.mapError((cause) =>        OpenCodeRuntimeError.fromCause({          operation: "runOpenCodeCommand",          detail: "Failed to execute OpenCode command.",          cause,          argumentCount: input.args.length,        }),      ),    );   const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (    input,  ) =>    Effect.gen(function* () {      // Bind this server's lifetime to the caller's scope. When the caller's      // scope closes, the spawned child is killed and all associated fibers      // are interrupted automatically — no `close()` method needed.      const runtimeScope = yield* Scope.Scope;       const hostname = input.hostname ?? DEFAULT_HOSTNAME;      const port =        input.port ??        (yield* netService.findAvailablePort(0).pipe(          Effect.mapError((cause) =>            OpenCodeRuntimeError.fromCause({              operation: "startOpenCodeServerProcess",              detail: "Failed to find an available OpenCode server port.",              cause,            }),          ),        ));      const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;      const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];      const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);       const child = yield* spawner        .spawn(          ChildProcess.make(spawnCommand.command, spawnCommand.args, {            detached: hostPlatform !== "win32",            shell: spawnCommand.shell,            env: {              ...input.environment,              OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT,            },            extendEnv: input.environment === undefined,          }),        )        .pipe(          Effect.provideService(Scope.Scope, runtimeScope),          Effect.mapError((cause) =>            OpenCodeRuntimeError.fromCause({              operation: "startOpenCodeServerProcess",              detail: "Failed to spawn OpenCode server process.",              cause,              argumentCount: args.length,            }),          ),        );       const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>        hostPlatform === "win32"          ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)          : Effect.sync(() => {              try {                process.kill(-Number(child.pid), signal);              } catch {                // The direct child may already have exited after starting the                // server; the process group kill is best-effort cleanup for                // any serve process left in that group.              }            });      const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(        Effect.andThen(Effect.sleep("1 second")),        Effect.andThen(killOpenCodeProcessGroup("SIGKILL")),        Effect.ignore,      );      yield* Scope.addFinalizer(runtimeScope, terminateChild);       const stdoutRef = yield* Ref.make("");      const stderrRef = yield* Ref.make("");      const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();       const setReadyFromStdoutChunk = (chunk: string) =>        Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe(          Effect.flatMap((nextStdout) => {            const parsed = parseServerUrlFromOutput(nextStdout);            return parsed              ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore)              : Effect.void;          }),        );       const stdoutFiber = yield* child.stdout.pipe(        Stream.decodeText(),        Stream.runForEach(setReadyFromStdoutChunk),        Effect.ignore,        Effect.forkIn(runtimeScope),      );      const stderrFiber = yield* child.stderr.pipe(        Stream.decodeText(),        Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)),        Effect.ignore,        Effect.forkIn(runtimeScope),      );       const exitFiber = yield* child.exitCode.pipe(        Effect.flatMap((code) =>          Effect.gen(function* () {            const stdout = yield* Ref.get(stdoutRef);            const stderr = yield* Ref.get(stderrRef);            const exitCode = Number(code);            yield* Deferred.fail(              readyDeferred,              new OpenCodeRuntimeError({                operation: "startOpenCodeServerProcess",                detail: "OpenCode server exited before startup completed.",                exitCode,                argumentCount: args.length,                stdoutBytes: utf8ByteLength(stdout),                stderrBytes: utf8ByteLength(stderr),              }),            ).pipe(Effect.ignore);          }),        ),        Effect.ignore,        Effect.forkIn(runtimeScope),      );       const readyExit = yield* Effect.exit(        Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),      );       // Startup-time fibers are no longer needed once ready has resolved (either      // way). The exit fiber is only interrupted on failure; on success it keeps      // the caller's `exitCode` effect observable until the scope closes.      yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore);      yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore);       if (Exit.isFailure(readyExit)) {        yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);        const squashed = Cause.squash(readyExit.cause);        return yield* OpenCodeRuntimeError.fromCause({          operation: "startOpenCodeServerProcess",          detail: "Failed while waiting for OpenCode server startup.",          cause: squashed,        });      }       const readyOption = readyExit.value;      if (Option.isNone(readyOption)) {        yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);        return yield* new OpenCodeRuntimeError({          operation: "startOpenCodeServerProcess",          detail: "Timed out waiting for OpenCode server startup.",          timeoutMs,        });      }       return {

Lines 1–500 of 586.