259 lines · 9.6 KB · TypeScript
RawDownload
import {  PROJECT_SCRIPT_MCP_DEFAULT_LIST_LIMIT,  PROJECT_SCRIPT_MCP_DEFAULT_PREVIEW_CHARS,  type ProjectScript,  ProjectScriptMcpFailure,  type ProjectScriptMcpListInput,  type ProjectScriptMcpListResult,  type ProjectScriptMcpRunInput,  type ProjectScriptMcpRunResult,  type ProjectScriptMcpStopInput,  type ProjectScriptMcpStopResult,  type TerminalMcpFailure,  ThreadId,} from "@t3tools/contracts";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Result from "effect/Result"; import type { McpInvocationScope } from "./McpInvocationContext.ts";import { TerminalMcpService } from "./TerminalMcpService.ts";import * as TerminalManager from "../terminal/Manager.ts"; const MAX_SCRIPT_INPUT_CHARS = 65_535; interface ScriptRunOwnership {  readonly projectId: string;  readonly scriptId: string;  readonly handle: TerminalManager.TerminalSessionHandle;} type ScriptRuns = Map<ThreadId, Map<string, ScriptRunOwnership>>; function terminalFailure(error: TerminalMcpFailure): ProjectScriptMcpFailure {  return new ProjectScriptMcpFailure({    code: error.code,    message:      error.code === "operation_failed" ? "The saved script operation failed." : error.message,  });} function terminalInputFailureMessage(error: TerminalMcpFailure): string {  return error.code === "operation_failed"    ? "The terminal was opened, but its saved script input was not accepted."    : error.message;} export class ProjectScriptMcpService extends Context.Service<  ProjectScriptMcpService,  {    readonly list: (      scope: McpInvocationScope,      input: ProjectScriptMcpListInput,    ) => Effect.Effect<ProjectScriptMcpListResult, ProjectScriptMcpFailure>;    readonly run: (      scope: McpInvocationScope,      input: ProjectScriptMcpRunInput,    ) => Effect.Effect<ProjectScriptMcpRunResult, ProjectScriptMcpFailure>;    readonly stop: (      scope: McpInvocationScope,      input: ProjectScriptMcpStopInput,    ) => Effect.Effect<ProjectScriptMcpStopResult, ProjectScriptMcpFailure>;  }>()("t3/mcp/ProjectScriptMcpService") {} function scriptForId(scripts: ReadonlyArray<ProjectScript>, scriptId: string) {  return scripts.find((candidate) => candidate.id === scriptId);} function preview(script: ProjectScript, maxChars: number) {  const characters = Array.from(script.command);  return {    scriptId: script.id,    name: script.name,    icon: script.icon,    runOnWorktreeCreate: script.runOnWorktreeCreate,    previewUrl: script.previewUrl ?? null,    autoOpenPreview: script.autoOpenPreview ?? false,    commandPreview: characters.slice(0, maxChars).join(""),    commandCharacters: characters.length,    commandTruncated: characters.length > maxChars,  } as const;} export const make = Effect.gen(function* () {  const terminals = yield* TerminalMcpService;  const terminalManager = yield* TerminalManager.TerminalManager;  const runs: ScriptRuns = new Map();   const recordRun = (threadId: ThreadId, terminalId: string, ownership: ScriptRunOwnership) => {    const forThread = runs.get(threadId) ?? new Map();    forThread.set(terminalId, ownership);    runs.set(threadId, forThread);  };   const removeRun = (    threadId: ThreadId,    terminalId: string,    handle: TerminalManager.TerminalSessionHandle,  ) => {    const forThread = runs.get(threadId);    if (forThread?.get(terminalId)?.handle.incarnation !== handle.incarnation) return;    forThread.delete(terminalId);    if (forThread.size === 0) runs.delete(threadId);  };   const load = Effect.fn("ProjectScriptMcpService.load")(function* (    scope: McpInvocationScope,    threadId: ThreadId | undefined,  ) {    return yield* terminals.resolveTarget(scope, threadId).pipe(Effect.mapError(terminalFailure));  });   const unsubscribe = yield* terminalManager.subscribeSessionInvalidation((handle) =>    Effect.sync(() => removeRun(ThreadId.make(handle.threadId), handle.terminalId, handle)),  );  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));   return ProjectScriptMcpService.of({    list: (scope, input) =>      Effect.gen(function* () {        const resolved = yield* load(scope, input.threadId);        const cursor = input.cursor ?? 0;        const limit = input.limit ?? PROJECT_SCRIPT_MCP_DEFAULT_LIST_LIMIT;        const maxChars = input.commandPreviewChars ?? PROJECT_SCRIPT_MCP_DEFAULT_PREVIEW_CHARS;        const page = resolved.project.scripts.slice(cursor, cursor + limit);        return {          threadId: resolved.target.thread.id,          projectId: resolved.project.id,          scripts: page.map((script) => preview(script, maxChars)),          nextCursor:            cursor + page.length < resolved.project.scripts.length ? cursor + page.length : null,          total: resolved.project.scripts.length,        };      }),    run: (scope, input) =>      Effect.gen(function* () {        const resolved = yield* load(scope, input.threadId);        const script = scriptForId(resolved.project.scripts, input.scriptId);        if (script === undefined) {          return yield* new ProjectScriptMcpFailure({            code: "script_not_found",            message: `Saved script '${input.scriptId}' was not found in project '${resolved.project.id}'.`,          });        }        if (script.command.length > MAX_SCRIPT_INPUT_CHARS) {          return yield* new ProjectScriptMcpFailure({            code: "script_command_too_large",            message: `Saved script '${script.id}' exceeds the managed terminal input limit.`,          });        }         const opened = yield* Effect.uninterruptible(          Effect.gen(function* () {            const opened = yield* terminals              .openFreshOwned(scope, {                threadId: resolved.target.thread.id,                terminalId: input.terminalId,              })              .pipe(Effect.mapError(terminalFailure));            const ownership = {              projectId: resolved.project.id,              scriptId: script.id,              handle: opened.handle,            } satisfies ScriptRunOwnership;            const admitted = yield* terminalManager.admitRunningSessionHandle(              {                threadId: resolved.target.thread.id,                terminalId: input.terminalId,                handle: opened.handle,              },              () => recordRun(resolved.target.thread.id, input.terminalId, ownership),            );            if (!admitted) {              return yield* new ProjectScriptMcpFailure({                code: "terminal_not_found",                message: `Terminal '${input.terminalId}' changed before script ownership could be recorded.`,              });            }            return opened;          }),        );         const write = yield* Effect.result(          terminals.writeOwned(scope, {            threadId: resolved.target.thread.id,            terminalId: input.terminalId,            data: `${script.command}\r`,            handle: opened.handle,          }),        );        return Result.match(write, {          onFailure: (error) => ({            threadId: resolved.target.thread.id,            projectId: resolved.project.id,            scriptId: script.id,            terminalId: input.terminalId,            outcome: "terminal_opened_input_failed" as const,            terminal: opened.terminal,            inputAcceptance: null,            error: terminalInputFailureMessage(error),            previewUrl: script.previewUrl ?? null,            previewAutoOpened: false as const,          }),          onSuccess: (inputAcceptance) => ({            threadId: resolved.target.thread.id,            projectId: resolved.project.id,            scriptId: script.id,            terminalId: input.terminalId,            outcome: "input_accepted" as const,            terminal: opened.terminal,            inputAcceptance,            error: null,            previewUrl: script.previewUrl ?? null,            previewAutoOpened: false as const,          }),        });      }),    stop: (scope, input) =>      Effect.gen(function* () {        const resolved = yield* load(scope, input.threadId);        const ownership = runs.get(resolved.target.thread.id)?.get(input.terminalId);        if (          ownership === undefined ||          ownership.projectId !== resolved.project.id ||          ownership.scriptId !== input.scriptId        ) {          return yield* new ProjectScriptMcpFailure({            code: "script_run_not_found",            message: `Terminal '${input.terminalId}' is not a managed run of saved script '${input.scriptId}' for thread '${resolved.target.thread.id}'.`,          });        }        const closed = yield* terminals          .closeOwned(scope, {            threadId: resolved.target.thread.id,            terminalId: input.terminalId,            handle: ownership.handle,          })          .pipe(Effect.mapError(terminalFailure));        if (!closed) {          removeRun(resolved.target.thread.id, input.terminalId, ownership.handle);          return yield* new ProjectScriptMcpFailure({            code: "script_run_not_found",            message: `Terminal '${input.terminalId}' no longer names the managed run of saved script '${input.scriptId}'.`,          });        }        removeRun(resolved.target.thread.id, input.terminalId, ownership.handle);        return {          threadId: resolved.target.thread.id,          projectId: resolved.project.id,          scriptId: input.scriptId,          terminalId: input.terminalId,          stopped: true,        };      }),  });}); export const layer = Layer.effect(ProjectScriptMcpService, make);