diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts new file mode 100644 index 0000000..e61cef9 --- /dev/null +++ b/frontend/src/adapters/http/frames.ts @@ -0,0 +1,124 @@ +/** + * WebSocket frame contract for the web (client/server) transport — ticket #13, + * lot F1. Mirrors the first-draft frames in + * `docs/ticket13-b0-backend-transport-inventory.md` (§ "WebSocket first draft"). + * + * F1 delivers the *types + skeleton* only. The full PTY wiring runs on top of + * these in F3/B5 once a server exists (B3/B4). Keeping the frame shapes here — in + * the adapters layer — lets the terminal/agent/chat gateways be structurally + * correct against the contract today without a live backend. + * + * Envelope (B0): every frame carries an `id` (client→server) or optional + * `replyTo` (server→client, present for acknowledgements, absent for unsolicited + * output/events). PTY bytes travel base64-encoded (`bytesBase64`) so binary + * survives JSON. + */ + +// --------------------------------------------------------------------------- +// Base64 helpers (binary PTY bytes over JSON frames) +// --------------------------------------------------------------------------- + +/** Encodes raw bytes to a base64 string for a `bytesBase64` frame field. */ +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + // `btoa` exists in browsers and jsdom. + return btoa(binary); +} + +/** Decodes a base64 `bytesBase64` frame field back to raw bytes. */ +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +// --------------------------------------------------------------------------- +// Client → server frames +// --------------------------------------------------------------------------- + +/** Client→server frame kinds (B0 § PTY + structured chat + ping). */ +export type ClientFrameKind = + | "terminal.attach" + | "terminal.open" + | "agent.launch" + | "terminal.input" + | "terminal.resize" + | "terminal.detach" + | "terminal.close" + | "chat.attach" + | "chat.send" + | "chat.detach" + | "ping"; + +/** A client→server frame. `id` correlates the eventual `replyTo`. */ +export interface ClientFrame { + id: string; + kind: ClientFrameKind; + payload: Record; +} + +// --------------------------------------------------------------------------- +// Server → client frames +// --------------------------------------------------------------------------- + +/** Server→client frame kinds. */ +export type ServerFrameKind = + | "terminal.attached" + | "terminal.output" + | "terminal.status" + | "chat.attached" + | "chat.output" + | "event.domain" + | "error" + | "pong"; + +/** Suggested lifecycle statuses (B0). */ +export type TerminalStatus = + | "starting" + | "running" + | "exited" + | "closed" + | "detached" + | "reattached"; + +/** A server→client frame. `replyTo` is present only for acknowledgements. */ +export interface ServerFrame { + kind: ServerFrameKind; + replyTo?: string; + payload: Record; +} + +/** Payload of a `terminal.attached` acknowledgement. */ +export interface AttachedPayload { + session: { + sessionId: string; + projectId?: string; + nodeId?: string | null; + rows: number; + cols: number; + }; + nextSeq: number; + scrollback: { seq: number; bytesBase64: string }[]; + gap: boolean; + /** Conversation id minted by an `agent.launch` (mirrors `assignedConversationId`). */ + assignedConversationId?: string; +} + +/** Payload of a `terminal.output` frame. */ +export interface OutputPayload { + sessionId: string; + seq: number; + bytesBase64: string; +} + +/** Payload of a `chat.output` frame (structured assistant stream). */ +export interface ChatOutputPayload { + sessionId: string; + chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller. +} diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts new file mode 100644 index 0000000..141eedc --- /dev/null +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -0,0 +1,126 @@ +/** + * F1 — the HTTP invoker forwards `{command, args}` to `POST /api/invoke`, + * preserves the backend `ErrorDto` on failure, and maps empty bodies to + * `undefined`. Also verifies a couple of request/response gateways emit the + * exact command + argument envelope of their Tauri siblings. + */ +import { describe, it, expect, vi } from "vitest"; + +import type { FetchLike } from "./httpInvoker"; +import { HttpInvoker } from "./httpInvoker"; +import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways"; + +/** Builds a fake fetch that records the request and returns `body`. */ +function fakeFetch( + body: unknown, + opts: { ok?: boolean; status?: number } = {}, +): { fetchImpl: FetchLike; calls: { url: string; init: unknown }[] } { + const calls: { url: string; init: unknown }[] = []; + const fetchImpl: FetchLike = async (url, init) => { + calls.push({ url, init }); + return { + ok: opts.ok ?? true, + status: opts.status ?? 200, + json: async () => body, + text: async () => JSON.stringify(body), + }; + }; + return { fetchImpl, calls }; +} + +describe("HttpInvoker", () => { + it("POSTs {command, args} to /api/invoke and returns the parsed body", async () => { + const { fetchImpl, calls } = fakeFetch({ ok: true }); + const http = new HttpInvoker({ baseUrl: "https://host:9000/", fetchImpl }); + + const result = await http.invoke<{ ok: boolean }>("health", { + request: { note: "hi" }, + }); + + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("https://host:9000/api/invoke"); + const init = calls[0].init as { method: string; body: string; headers: Record }; + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ + command: "health", + args: { request: { note: "hi" } }, + }); + }); + + it("sends the pairing token as an Authorization header", async () => { + const { fetchImpl, calls } = fakeFetch([]); + const http = new HttpInvoker({ baseUrl: "https://host", token: "tok-123", fetchImpl }); + + await http.invoke("list_projects"); + + const init = calls[0].init as { headers: Record }; + expect(init.headers.Authorization).toBe("Bearer tok-123"); + // No args ⇒ empty object envelope. + expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ + command: "list_projects", + args: {}, + }); + }); + + it("rejects with the backend ErrorDto on a non-2xx response", async () => { + const { fetchImpl } = fakeFetch( + { code: "NOT_FOUND", message: "project x" }, + { ok: false, status: 404 }, + ); + const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl }); + + await expect(http.invoke("open_project", { projectId: "x" })).rejects.toEqual({ + code: "NOT_FOUND", + message: "project x", + }); + }); + + it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("boom"); + }; + const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl }); + + await expect(http.invoke("health")).rejects.toMatchObject({ + code: "TRANSPORT_ERROR", + }); + }); +}); + +describe("request/response gateways preserve the Tauri command contract", () => { + it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => { + const http = new HttpInvoker({ baseUrl: "https://h" }); + const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never); + const gw = new HttpProjectGateway(http); + + await gw.createProject("My proj", "/abs/root"); + + expect(spy).toHaveBeenCalledWith("create_project", { + request: { name: "My proj", root: "/abs/root" }, + }); + }); + + it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => { + const http = new HttpInvoker({ baseUrl: "https://h" }); + const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never); + const gw = new HttpGitGateway(http); + + await gw.stage("p1", "src/a.ts"); + + expect(spy).toHaveBeenCalledWith("git_stage", { + request: { projectId: "p1", path: "src/a.ts" }, + }); + }); + + it("HttpGitGateway.status maps to git_status with a flat projectId", async () => { + const http = new HttpInvoker({ baseUrl: "https://h" }); + const spy = vi.spyOn(http, "invoke").mockResolvedValue([] as never); + const gw = new HttpGitGateway(http); + + await gw.status("p1"); + + expect(spy).toHaveBeenCalledWith("git_status", { projectId: "p1" }); + }); +}); diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts new file mode 100644 index 0000000..162d869 --- /dev/null +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -0,0 +1,130 @@ +/** + * HTTP transport primitive for the web (client/server) adapter set — ticket #13, + * lot F1. This is the HTTP analogue of Tauri's `invoke`: it forwards one backend + * *command* + its camelCase argument envelope to the shared backend core over + * HTTP, and preserves the exact `ErrorDto` shape ({@link GatewayError}) on + * failure. + * + * **Contract choice (F1):** rather than a per-command REST resource tree (the + * first-draft routes in `docs/ticket13-b0-backend-transport-inventory.md`), F1 + * uses a single generic RPC endpoint `POST {baseUrl}/api/invoke` with body + * `{ command, args }`. This mirrors the Tauri `invoke(command, args)` seam + * one-for-one, so **every** request/response gateway reuses the *identical* + * command names and `{ request: { … } }` envelopes already frozen for the Tauri + * adapter — no DTO divergence. Switching to REST later (if the backend picks that + * in B3/B4) only touches this file + the gateway wiring, never a component. + * This divergence from the B0 REST sketch is flagged for DevBackend to confirm. + * + * Only `src/adapters/**` may own transport code (CI guard + * `no-direct-invoke.test.ts`); this file lives there and touches no + * `@tauri-apps/api`. + */ + +import type { GatewayError } from "@/domain"; + +/** The `fetch` surface this invoker needs; injectable so tests pass a stub. */ +export type FetchLike = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + }, +) => Promise<{ + ok: boolean; + status: number; + /** Parsed JSON body (may reject/return undefined for empty bodies). */ + json(): Promise; + text(): Promise; +}>; + +/** Configuration for the HTTP invoker. */ +export interface HttpInvokerConfig { + /** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */ + baseUrl: string; + /** Bearer token obtained from pairing (ticket #13 auth); sent as `Authorization`. */ + token?: string; + /** Injected fetch (defaults to global `fetch`). */ + fetchImpl?: FetchLike; +} + +/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */ +function toGatewayError(value: unknown, fallbackMessage: string): GatewayError { + if (value && typeof value === "object") { + const rec = value as Record; + const code = typeof rec.code === "string" ? rec.code : undefined; + const message = typeof rec.message === "string" ? rec.message : undefined; + if (code || message) { + return { code: code ?? "ERROR", message: message ?? fallbackMessage }; + } + } + return { code: "TRANSPORT_ERROR", message: fallbackMessage }; +} + +/** + * Sends backend commands over HTTP. Mirrors the Tauri `invoke` signature so the + * web gateways can reuse the exact command + argument envelopes of their Tauri + * siblings. + */ +export class HttpInvoker { + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: FetchLike; + + constructor(config: HttpInvokerConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.token = config.token; + // `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it + // to the minimal shape used here. + this.fetchImpl = + config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + } + + /** + * Invokes a backend command. `args` is the same camelCase argument object the + * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the + * parsed result, or rejects with a {@link GatewayError}. + */ + async invoke(command: string, args: Record = {}): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.token) headers.Authorization = `Bearer ${this.token}`; + + let res: Awaited>; + try { + res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { + method: "POST", + headers, + body: JSON.stringify({ command, args }), + }); + } catch (networkError) { + const err: GatewayError = { + code: "TRANSPORT_ERROR", + message: `HTTP request for '${command}' failed: ${String(networkError)}`, + }; + throw err; + } + + if (!res.ok) { + // Preserve the backend `ErrorDto` when present; fall back to the status. + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`); + } + + // A 204 / empty body maps to `undefined` (the void commands). + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + return body as T; + } +} diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts new file mode 100644 index 0000000..b3a7d9b --- /dev/null +++ b/frontend/src/adapters/http/index.ts @@ -0,0 +1,110 @@ +/** + * Web (client/server) adapter set — ticket #13, lot F1. + * + * `createHttpWsGateways()` is the third gateway implementation, alongside + * `createTauriGateways()` (desktop) and `createMockGateways()` (tests/dev). It + * wires every UI port to the shared backend core over HTTP request/response and a + * single multiplexed WebSocket, exactly behind the *unchanged* ports — no + * component is aware of it (selected in `app/di.tsx`). + * + * Scope reminder (F1): request/response gateways are fully implemented; the WS + * PTY/chat streams are a contract-conformant **skeleton** (full round-trip lands + * in F3/B5/B6 once a server exists — B3/B4). Desktop-only surfaces (pickFolder, + * OS windows, focused-project, remote) fail with an explicit "unsupported on web" + * error or an inert stub, never a Tauri call. + */ + +import type { Gateways } from "@/ports"; +import { LocalStorageUiPreferencesGateway } from "../uiPreferences"; +import { HttpInvoker } from "./httpInvoker"; +import { WsLiveClient } from "./wsLiveClient"; +import { + HttpConversationGateway, + HttpEmbedderGateway, + HttpGitGateway, + HttpInputGateway, + HttpLayoutGateway, + HttpMemoryGateway, + HttpModelServerGateway, + HttpPermissionGateway, + HttpProfileGateway, + HttpProjectGateway, + HttpSkillGateway, + HttpTemplateGateway, + HttpWorkStateGateway, +} from "./requestResponseGateways"; +import { + HttpAgentGateway, + HttpSystemGateway, + HttpTerminalGateway, + HttpTicketGateway, +} from "./streamGateways"; +import { + WebFocusedProjectGateway, + WebRemoteGateway, + WebWindowGateway, +} from "./unsupported"; + +/** Endpoint configuration for the web transport. */ +export interface HttpWsGatewaysConfig { + /** Backend HTTP base URL (no trailing slash). Defaults to the page origin. */ + baseUrl?: string; + /** WebSocket base URL. Defaults to the page origin with `http(s)`→`ws(s)`. */ + wsUrl?: string; + /** Bearer token from pairing (ticket #13 auth). */ + token?: string; +} + +/** Derives default `{baseUrl, wsUrl}` from `window.location` when available. */ +function resolveEndpoints(config: HttpWsGatewaysConfig): { + baseUrl: string; + wsUrl: string; +} { + const origin = + typeof window !== "undefined" && window.location + ? window.location.origin + : "http://localhost"; + const baseUrl = config.baseUrl ?? origin; + const wsUrl = + config.wsUrl ?? baseUrl.replace(/^http(s?):\/\//, (_m, s) => `ws${s}://`); + return { baseUrl, wsUrl }; +} + +/** + * Builds the full set of web (HTTP+WS) gateways. `config` is optional; endpoints + * default to the current page origin so a browser build talks to its own server. + * The {@link HttpInvoker} and {@link WsLiveClient} are shared across gateways. + */ +export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways { + const { baseUrl, wsUrl } = resolveEndpoints(config); + const http = new HttpInvoker({ baseUrl, token: config.token }); + const ws = new WsLiveClient({ wsUrl, token: config.token }); + + return { + system: new HttpSystemGateway(http, ws), + agent: new HttpAgentGateway(http, ws), + input: new HttpInputGateway(http), + terminal: new HttpTerminalGateway(ws), + project: new HttpProjectGateway(http), + layout: new HttpLayoutGateway(http), + git: new HttpGitGateway(http), + remote: new WebRemoteGateway(), + profile: new HttpProfileGateway(http), + modelServer: new HttpModelServerGateway(http), + template: new HttpTemplateGateway(http), + skill: new HttpSkillGateway(http), + memory: new HttpMemoryGateway(http), + embedder: new HttpEmbedderGateway(http), + permission: new HttpPermissionGateway(http), + workState: new HttpWorkStateGateway(http), + conversation: new HttpConversationGateway(http), + ticket: new HttpTicketGateway(http, ws), + window: new WebWindowGateway(), + focusedProject: new WebFocusedProjectGateway(), + // Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is. + uiPreferences: new LocalStorageUiPreferencesGateway(), + }; +} + +export { HttpInvoker } from "./httpInvoker"; +export { WsLiveClient } from "./wsLiveClient"; diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts new file mode 100644 index 0000000..3f41bf3 --- /dev/null +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -0,0 +1,400 @@ +/** + * HTTP request/response gateways for the web transport — ticket #13, lot F1. + * + * Each class implements a UI port by forwarding the **exact** backend command + * name + camelCase argument envelope its Tauri sibling uses (the shared backend + * core, so identical contracts) through the generic {@link HttpInvoker}. No DTO + * shape is re-derived here; only the transport changes (Tauri `invoke` → HTTP + * `POST /api/invoke`). Normalizers that are transport-neutral (`workState`, + * `conversation`) are reused from the sibling adapters. + * + * Lives in `src/adapters/**`; touches no `@tauri-apps/api`. + */ + +import type { + Agent, + AgentDrift, + AgentProfile, + EffectivePermissions, + EmbedderEngines, + EmbedderProfile, + FirstRunState, + GitBranches, + GitCommit, + GitFileStatus, + GraphCommit, + LayoutKind, + LayoutList, + LayoutOperation, + LayoutTree, + LocalModelServerConfig, + Memory, + MemoryIndexEntry, + MemoryLink, + MemoryType, + ModelServerCommandPreview, + PermissionSet, + Project, + ProjectPermissions, + ProjectWorkState, + ProfileAvailability, + Skill, + SkillScope, + Template, + TurnPage, +} from "@/domain"; +import type { + CloneOpenCodeProfileFromSeedInput, + ConversationGateway, + ConversationPageRequest, + CreateMemoryInput, + CreateSkillInput, + CreateTemplateInput, + EmbedderGateway, + GitGateway, + InputGateway, + LayoutGateway, + MemoryGateway, + ModelServerGateway, + PermissionGateway, + ProfileGateway, + ProjectGateway, + SkillGateway, + TemplateGateway, + WorkStateGateway, +} from "@/ports"; +import { normalizeProjectWorkState } from "../workStateNormalization"; +import { normalizeTurnPage } from "../conversationNormalization"; +import type { HttpInvoker } from "./httpInvoker"; + +export class HttpProjectGateway implements ProjectGateway { + constructor(private readonly http: HttpInvoker) {} + listProjects(): Promise { + return this.http.invoke("list_projects"); + } + createProject(name: string, root: string): Promise { + return this.http.invoke("create_project", { request: { name, root } }); + } + openProject(projectId: string): Promise { + return this.http.invoke("open_project", { projectId }); + } + async closeProject(projectId: string): Promise { + await this.http.invoke("close_project", { projectId }); + } + readProjectContext(projectId: string): Promise { + return this.http.invoke("read_project_context", { projectId }); + } + async updateProjectContext(projectId: string, content: string): Promise { + await this.http.invoke("update_project_context", { request: { projectId, content } }); + } +} + +export class HttpLayoutGateway implements LayoutGateway { + constructor(private readonly http: HttpInvoker) {} + loadLayout(projectId: string, layoutId?: string): Promise { + return this.http.invoke("load_layout", { projectId, layoutId }); + } + mutateLayout( + projectId: string, + operation: LayoutOperation, + layoutId?: string, + ): Promise { + return this.http.invoke("mutate_layout", { projectId, layoutId, operation }); + } + listLayouts(projectId: string): Promise { + return this.http.invoke("list_layouts", { projectId }); + } + createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> { + return this.http.invoke<{ layoutId: string }>("create_layout", { request: { projectId, name, kind } }); + } + renameLayout(projectId: string, layoutId: string, name: string): Promise { + return this.http.invoke("rename_layout", { request: { projectId, layoutId, name } }); + } + deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> { + return this.http.invoke<{ activeId: string }>("delete_layout", { request: { projectId, layoutId } }); + } + setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> { + return this.http.invoke<{ activeId: string }>("set_active_layout", { request: { projectId, layoutId } }); + } +} + +export class HttpGitGateway implements GitGateway { + constructor(private readonly http: HttpInvoker) {} + status(projectId: string): Promise { + return this.http.invoke("git_status", { projectId }); + } + async stage(projectId: string, path: string): Promise { + await this.http.invoke("git_stage", { request: { projectId, path } }); + } + async unstage(projectId: string, path: string): Promise { + await this.http.invoke("git_unstage", { request: { projectId, path } }); + } + commit(projectId: string, message: string): Promise { + return this.http.invoke("git_commit", { request: { projectId, message } }); + } + branches(projectId: string): Promise { + return this.http.invoke("git_branches", { projectId }); + } + async checkout(projectId: string, branch: string): Promise { + await this.http.invoke("git_checkout", { request: { projectId, branch } }); + } + log(projectId: string, limit: number): Promise { + return this.http.invoke("git_log", { projectId, limit }); + } + async init(projectId: string): Promise { + await this.http.invoke("git_init", { projectId }); + } + graph(projectId: string, limit: number): Promise { + return this.http.invoke("git_graph", { projectId, limit }); + } +} + +export class HttpProfileGateway implements ProfileGateway { + constructor(private readonly http: HttpInvoker) {} + firstRunState(): Promise { + return this.http.invoke("first_run_state"); + } + referenceProfiles(): Promise { + return this.http.invoke("reference_profiles"); + } + detectProfiles(candidates: AgentProfile[]): Promise { + return this.http.invoke("detect_profiles", { request: { candidates } }); + } + listProfiles(): Promise { + return this.http.invoke("list_profiles"); + } + saveProfile(profile: AgentProfile): Promise { + return this.http.invoke("save_profile", { request: { profile } }); + } + async deleteProfile(profileId: string): Promise { + await this.http.invoke("delete_profile", { profileId }); + } + configureProfiles(profiles: AgentProfile[]): Promise { + return this.http.invoke("configure_profiles", { request: { profiles } }); + } + cloneOpenCodeProfileFromSeed( + input: CloneOpenCodeProfileFromSeedInput = {}, + ): Promise { + return this.http.invoke("clone_opencode_profile_from_seed", { + request: { name: input.name, opencode: input.opencode }, + }); + } +} + +export class HttpModelServerGateway implements ModelServerGateway { + constructor(private readonly http: HttpInvoker) {} + listModelServers(): Promise { + return this.http.invoke("list_model_servers"); + } + saveModelServer(config: LocalModelServerConfig): Promise { + return this.http.invoke("save_model_server", { request: { config } }); + } + async deleteModelServer(serverId: string): Promise { + await this.http.invoke("delete_model_server", { serverId }); + } + previewModelServerCommand(config: LocalModelServerConfig): Promise { + return this.http.invoke("preview_model_server_command", { config }); + } +} + +export class HttpTemplateGateway implements TemplateGateway { + constructor(private readonly http: HttpInvoker) {} + listTemplates(): Promise { + return this.http.invoke("list_templates"); + } + createTemplate(input: CreateTemplateInput): Promise