feat(frontend): adapter web HTTP+WebSocket derrière les ports (#13)
Lot F1 du chantier server/client mode : nouvel adaptateur web branché derrière les ports d'invocation et de flux live, permettant au frontend de dialoguer avec le backend via HTTP + WebSocket en mode client/serveur. Le mode desktop (Tauri IPC) reste inchangé. - frontend/src/adapters/http : invoker HTTP, client live WebSocket, gateways request/response et stream, frames, garde unsupported (7 fichiers + 2 tests). - frontend/src/app : câblage DI (di.tsx) et son test, typage vite-env.d.ts. Validé : build vert, garde no-direct-invoke verte, 724 tests verts, desktop inchangé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
124
frontend/src/adapters/http/frames.ts
Normal file
124
frontend/src/adapters/http/frames.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
}
|
||||||
126
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
126
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
@ -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<string, string> };
|
||||||
|
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<string, string> };
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
130
frontend/src/adapters/http/httpInvoker.ts
Normal file
130
frontend/src/adapters/http/httpInvoker.ts
Normal file
@ -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<string, string>;
|
||||||
|
body?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
/** Parsed JSON body (may reject/return undefined for empty bodies). */
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
text(): Promise<string>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
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<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||||
|
|
||||||
|
let res: Awaited<ReturnType<FetchLike>>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
frontend/src/adapters/http/index.ts
Normal file
110
frontend/src/adapters/http/index.ts
Normal file
@ -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";
|
||||||
400
frontend/src/adapters/http/requestResponseGateways.ts
Normal file
400
frontend/src/adapters/http/requestResponseGateways.ts
Normal file
@ -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<Project[]> {
|
||||||
|
return this.http.invoke<Project[]>("list_projects");
|
||||||
|
}
|
||||||
|
createProject(name: string, root: string): Promise<Project> {
|
||||||
|
return this.http.invoke<Project>("create_project", { request: { name, root } });
|
||||||
|
}
|
||||||
|
openProject(projectId: string): Promise<Project> {
|
||||||
|
return this.http.invoke<Project>("open_project", { projectId });
|
||||||
|
}
|
||||||
|
async closeProject(projectId: string): Promise<void> {
|
||||||
|
await this.http.invoke("close_project", { projectId });
|
||||||
|
}
|
||||||
|
readProjectContext(projectId: string): Promise<string> {
|
||||||
|
return this.http.invoke<string>("read_project_context", { projectId });
|
||||||
|
}
|
||||||
|
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||||
|
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<LayoutTree> {
|
||||||
|
return this.http.invoke<LayoutTree>("load_layout", { projectId, layoutId });
|
||||||
|
}
|
||||||
|
mutateLayout(
|
||||||
|
projectId: string,
|
||||||
|
operation: LayoutOperation,
|
||||||
|
layoutId?: string,
|
||||||
|
): Promise<LayoutTree> {
|
||||||
|
return this.http.invoke<LayoutTree>("mutate_layout", { projectId, layoutId, operation });
|
||||||
|
}
|
||||||
|
listLayouts(projectId: string): Promise<LayoutList> {
|
||||||
|
return this.http.invoke<LayoutList>("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<void> {
|
||||||
|
return this.http.invoke<void>("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<GitFileStatus[]> {
|
||||||
|
return this.http.invoke<GitFileStatus[]>("git_status", { projectId });
|
||||||
|
}
|
||||||
|
async stage(projectId: string, path: string): Promise<void> {
|
||||||
|
await this.http.invoke("git_stage", { request: { projectId, path } });
|
||||||
|
}
|
||||||
|
async unstage(projectId: string, path: string): Promise<void> {
|
||||||
|
await this.http.invoke("git_unstage", { request: { projectId, path } });
|
||||||
|
}
|
||||||
|
commit(projectId: string, message: string): Promise<GitCommit> {
|
||||||
|
return this.http.invoke<GitCommit>("git_commit", { request: { projectId, message } });
|
||||||
|
}
|
||||||
|
branches(projectId: string): Promise<GitBranches> {
|
||||||
|
return this.http.invoke<GitBranches>("git_branches", { projectId });
|
||||||
|
}
|
||||||
|
async checkout(projectId: string, branch: string): Promise<void> {
|
||||||
|
await this.http.invoke("git_checkout", { request: { projectId, branch } });
|
||||||
|
}
|
||||||
|
log(projectId: string, limit: number): Promise<GitCommit[]> {
|
||||||
|
return this.http.invoke<GitCommit[]>("git_log", { projectId, limit });
|
||||||
|
}
|
||||||
|
async init(projectId: string): Promise<void> {
|
||||||
|
await this.http.invoke("git_init", { projectId });
|
||||||
|
}
|
||||||
|
graph(projectId: string, limit: number): Promise<GraphCommit[]> {
|
||||||
|
return this.http.invoke<GraphCommit[]>("git_graph", { projectId, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpProfileGateway implements ProfileGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
firstRunState(): Promise<FirstRunState> {
|
||||||
|
return this.http.invoke<FirstRunState>("first_run_state");
|
||||||
|
}
|
||||||
|
referenceProfiles(): Promise<AgentProfile[]> {
|
||||||
|
return this.http.invoke<AgentProfile[]>("reference_profiles");
|
||||||
|
}
|
||||||
|
detectProfiles(candidates: AgentProfile[]): Promise<ProfileAvailability[]> {
|
||||||
|
return this.http.invoke<ProfileAvailability[]>("detect_profiles", { request: { candidates } });
|
||||||
|
}
|
||||||
|
listProfiles(): Promise<AgentProfile[]> {
|
||||||
|
return this.http.invoke<AgentProfile[]>("list_profiles");
|
||||||
|
}
|
||||||
|
saveProfile(profile: AgentProfile): Promise<AgentProfile> {
|
||||||
|
return this.http.invoke<AgentProfile>("save_profile", { request: { profile } });
|
||||||
|
}
|
||||||
|
async deleteProfile(profileId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_profile", { profileId });
|
||||||
|
}
|
||||||
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||||
|
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
||||||
|
}
|
||||||
|
cloneOpenCodeProfileFromSeed(
|
||||||
|
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
return this.http.invoke<AgentProfile>("clone_opencode_profile_from_seed", {
|
||||||
|
request: { name: input.name, opencode: input.opencode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpModelServerGateway implements ModelServerGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
listModelServers(): Promise<LocalModelServerConfig[]> {
|
||||||
|
return this.http.invoke<LocalModelServerConfig[]>("list_model_servers");
|
||||||
|
}
|
||||||
|
saveModelServer(config: LocalModelServerConfig): Promise<LocalModelServerConfig> {
|
||||||
|
return this.http.invoke<LocalModelServerConfig>("save_model_server", { request: { config } });
|
||||||
|
}
|
||||||
|
async deleteModelServer(serverId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_model_server", { serverId });
|
||||||
|
}
|
||||||
|
previewModelServerCommand(config: LocalModelServerConfig): Promise<ModelServerCommandPreview> {
|
||||||
|
return this.http.invoke<ModelServerCommandPreview>("preview_model_server_command", { config });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpTemplateGateway implements TemplateGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
listTemplates(): Promise<Template[]> {
|
||||||
|
return this.http.invoke<Template[]>("list_templates");
|
||||||
|
}
|
||||||
|
createTemplate(input: CreateTemplateInput): Promise<Template> {
|
||||||
|
return this.http.invoke<Template>("create_template", {
|
||||||
|
request: { name: input.name, content: input.content, defaultProfileId: input.defaultProfileId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateTemplate(templateId: string, content: string): Promise<Template> {
|
||||||
|
return this.http.invoke<Template>("update_template", { request: { templateId, content } });
|
||||||
|
}
|
||||||
|
async deleteTemplate(templateId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_template", { templateId });
|
||||||
|
}
|
||||||
|
createAgentFromTemplate(
|
||||||
|
projectId: string,
|
||||||
|
templateId: string,
|
||||||
|
opts?: { name?: string; synchronized?: boolean },
|
||||||
|
): Promise<Agent> {
|
||||||
|
return this.http.invoke<Agent>("create_agent_from_template", {
|
||||||
|
request: {
|
||||||
|
projectId,
|
||||||
|
templateId,
|
||||||
|
name: opts?.name ?? null,
|
||||||
|
synchronized: opts?.synchronized ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
detectDrift(projectId: string): Promise<AgentDrift[]> {
|
||||||
|
return this.http.invoke<AgentDrift[]>("detect_agent_drift", { projectId });
|
||||||
|
}
|
||||||
|
syncAgent(projectId: string, agentId: string): Promise<{ synced: boolean; version: number | null }> {
|
||||||
|
return this.http.invoke<{ synced: boolean; version: number | null }>("sync_agent_with_template", {
|
||||||
|
request: { projectId, agentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpSkillGateway implements SkillGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||||
|
return this.http.invoke<Skill[]>("list_skills", { projectId, scope });
|
||||||
|
}
|
||||||
|
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
|
return this.http.invoke<Skill>("create_skill", {
|
||||||
|
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
|
||||||
|
return this.http.invoke<Skill>("update_skill", { request: { projectId, scope, skillId, content } });
|
||||||
|
}
|
||||||
|
async deleteSkill(projectId: string, scope: SkillScope, skillId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_skill", { projectId, scope, skillId });
|
||||||
|
}
|
||||||
|
async assignSkill(projectId: string, agentId: string, skillId: string, scope: SkillScope): Promise<void> {
|
||||||
|
await this.http.invoke("assign_skill_to_agent", { request: { projectId, agentId, skillId, scope } });
|
||||||
|
}
|
||||||
|
async unassignSkill(projectId: string, agentId: string, skillId: string): Promise<void> {
|
||||||
|
await this.http.invoke("unassign_skill_from_agent", { request: { projectId, agentId, skillId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpMemoryGateway implements MemoryGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
listMemories(projectId: string): Promise<Memory[]> {
|
||||||
|
return this.http.invoke<Memory[]>("list_memories", { projectId });
|
||||||
|
}
|
||||||
|
getMemory(projectId: string, slug: string): Promise<Memory> {
|
||||||
|
return this.http.invoke<Memory>("get_memory", { projectId, slug });
|
||||||
|
}
|
||||||
|
createMemory(input: CreateMemoryInput): Promise<Memory> {
|
||||||
|
return this.http.invoke<Memory>("create_memory", {
|
||||||
|
request: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
content: input.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateMemory(
|
||||||
|
projectId: string,
|
||||||
|
slug: string,
|
||||||
|
description: string,
|
||||||
|
type: MemoryType,
|
||||||
|
content: string,
|
||||||
|
): Promise<Memory> {
|
||||||
|
return this.http.invoke<Memory>("update_memory", { request: { projectId, slug, description, type, content } });
|
||||||
|
}
|
||||||
|
async deleteMemory(projectId: string, slug: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_memory", { projectId, slug });
|
||||||
|
}
|
||||||
|
readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
|
||||||
|
return this.http.invoke<MemoryIndexEntry[]>("read_memory_index", { projectId });
|
||||||
|
}
|
||||||
|
resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
|
||||||
|
return this.http.invoke<MemoryLink[]>("resolve_memory_links", { projectId, slug });
|
||||||
|
}
|
||||||
|
recall(projectId: string, text: string, tokenBudget: number): Promise<MemoryIndexEntry[]> {
|
||||||
|
return this.http.invoke<MemoryIndexEntry[]>("recall_memory", { request: { projectId, text, tokenBudget } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpEmbedderGateway implements EmbedderGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||||
|
return this.http.invoke<EmbedderProfile[]>("list_embedder_profiles");
|
||||||
|
}
|
||||||
|
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
|
||||||
|
return this.http.invoke<EmbedderProfile>("save_embedder_profile", { request: { profile } });
|
||||||
|
}
|
||||||
|
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_embedder_profile", { embedderId });
|
||||||
|
}
|
||||||
|
describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||||
|
return this.http.invoke<EmbedderEngines>("describe_embedder_engines");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpPermissionGateway implements PermissionGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
|
||||||
|
return this.http.invoke<ProjectPermissions>("get_project_permissions", { projectId });
|
||||||
|
}
|
||||||
|
updateProjectPermissions(
|
||||||
|
projectId: string,
|
||||||
|
permissions: PermissionSet | null,
|
||||||
|
): Promise<ProjectPermissions> {
|
||||||
|
return this.http.invoke<ProjectPermissions>("update_project_permissions", { request: { projectId, permissions } });
|
||||||
|
}
|
||||||
|
updateAgentPermissions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
permissions: PermissionSet | null,
|
||||||
|
): Promise<ProjectPermissions> {
|
||||||
|
return this.http.invoke<ProjectPermissions>("update_agent_permissions", {
|
||||||
|
request: { projectId, agentId, permissions },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
|
||||||
|
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
||||||
|
request: { projectId, agentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpWorkStateGateway implements WorkStateGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||||
|
const state = await this.http.invoke<unknown>("get_project_work_state", { projectId });
|
||||||
|
return normalizeProjectWorkState(state);
|
||||||
|
}
|
||||||
|
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||||
|
await this.http.invoke<unknown>("cancel_background_task", { taskId });
|
||||||
|
}
|
||||||
|
async retryBackgroundTask(taskId: string): Promise<void> {
|
||||||
|
await this.http.invoke<unknown>("retry_background_task", { taskId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpConversationGateway implements ConversationGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
async readPage(
|
||||||
|
projectId: string,
|
||||||
|
conversationId: string,
|
||||||
|
request?: ConversationPageRequest,
|
||||||
|
): Promise<TurnPage> {
|
||||||
|
const page = await this.http.invoke<unknown>("read_conversation_page", {
|
||||||
|
request: {
|
||||||
|
projectId,
|
||||||
|
conversationId,
|
||||||
|
anchor: request?.anchor,
|
||||||
|
direction: request?.direction ?? "backward",
|
||||||
|
limit: request?.limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return normalizeTurnPage(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpInputGateway implements InputGateway {
|
||||||
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
|
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||||
|
await this.http.invoke("interrupt_agent", { request: { projectId, agentId } });
|
||||||
|
}
|
||||||
|
async delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void> {
|
||||||
|
await this.http.invoke("delegation_delivered", { request: { projectId, agentId, ticket } });
|
||||||
|
}
|
||||||
|
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||||
|
await this.http.invoke("set_front_attached", { request: { agentId, attached } });
|
||||||
|
}
|
||||||
|
async cancelResume(agentId: string): Promise<boolean> {
|
||||||
|
return this.http.invoke<boolean>("cancel_resume", { agentId });
|
||||||
|
}
|
||||||
|
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||||
|
await this.http.invoke("set_resume_at", { agentId, resetsAtMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
392
frontend/src/adapters/http/streamGateways.ts
Normal file
392
frontend/src/adapters/http/streamGateways.ts
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* Stream + mixed gateways for the web transport — ticket #13, lot F1.
|
||||||
|
*
|
||||||
|
* These gateways combine HTTP request/response (via {@link HttpInvoker}) with the
|
||||||
|
* WebSocket live streams (via {@link WsLiveClient}), per the B0 contract:
|
||||||
|
* - {@link HttpSystemGateway}: `health` over HTTP; `onDomainEvent` over the WS
|
||||||
|
* `event.domain` stream; `pickFolder` is desktop-only ⇒ unsupported on web.
|
||||||
|
* - {@link HttpAgentGateway}: all agent request/response over HTTP; `launchAgent`
|
||||||
|
* / `reattach` over the WS PTY stream (**skeleton** — full round-trip in
|
||||||
|
* F3/B5/B6).
|
||||||
|
* - {@link HttpTicketGateway}: all `ticket_*`/`sprint_*` over HTTP; `sendTicketChat`
|
||||||
|
* over the WS `chat.*` stream (**skeleton**).
|
||||||
|
* - {@link HttpTerminalGateway}: PTY open/reattach/close over the WS terminal
|
||||||
|
* stream (**skeleton**).
|
||||||
|
*
|
||||||
|
* "Skeleton" means the frames are wired to the B0 contract and the handles are
|
||||||
|
* structurally correct against the ports, but a live end-to-end terminal needs
|
||||||
|
* the server (B3/B4) and the F3/B5 wiring. Lives in `src/adapters/**`; no
|
||||||
|
* `@tauri-apps/api`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
DomainEvent,
|
||||||
|
HealthReport,
|
||||||
|
ReplyChunk,
|
||||||
|
ResumableAgent,
|
||||||
|
Sprint,
|
||||||
|
TerminalSession,
|
||||||
|
Ticket,
|
||||||
|
TicketCarnet,
|
||||||
|
TicketChat,
|
||||||
|
TicketLinkKind,
|
||||||
|
TicketList,
|
||||||
|
Unsubscribe,
|
||||||
|
} from "@/domain";
|
||||||
|
import type {
|
||||||
|
AgentGateway,
|
||||||
|
ConversationDetails,
|
||||||
|
CreateAgentInput,
|
||||||
|
CreateTicketInput,
|
||||||
|
LiveAgent,
|
||||||
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
|
StoppedLiveAgent,
|
||||||
|
SystemGateway,
|
||||||
|
TerminalGateway,
|
||||||
|
TerminalHandle,
|
||||||
|
TicketGateway,
|
||||||
|
TicketListQuery,
|
||||||
|
UpdateTicketInput,
|
||||||
|
} from "@/ports";
|
||||||
|
import type { HttpInvoker } from "./httpInvoker";
|
||||||
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
|
import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
|
||||||
|
import { unsupportedOnWeb } from "./unsupported";
|
||||||
|
|
||||||
|
/** Concatenates a scrollback frame list into a single byte buffer. */
|
||||||
|
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
|
||||||
|
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
|
||||||
|
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
out.set(c, offset);
|
||||||
|
offset += c.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
|
||||||
|
* output stream is delivered through the sink the gateway registered on the
|
||||||
|
* {@link WsLiveClient} for this `sessionId`.
|
||||||
|
*/
|
||||||
|
export function makeWsTerminalHandle(
|
||||||
|
sessionId: string,
|
||||||
|
ws: WsLiveClient,
|
||||||
|
assignedConversationId?: string,
|
||||||
|
): TerminalHandle {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(assignedConversationId ? { assignedConversationId } : {}),
|
||||||
|
async write(data: Uint8Array): Promise<void> {
|
||||||
|
await ws.sendFireAndForget("terminal.input", {
|
||||||
|
sessionId,
|
||||||
|
bytesBase64: WsLiveClient.encodeInput(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async resize(rows: number, cols: number): Promise<void> {
|
||||||
|
await ws.sendFireAndForget("terminal.resize", { sessionId, rows, cols });
|
||||||
|
},
|
||||||
|
detach(): void {
|
||||||
|
// Stop delivering output locally and tell the server the view is gone. The
|
||||||
|
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
|
||||||
|
ws.removeOutputSink(sessionId);
|
||||||
|
void ws.sendFireAndForget("terminal.detach", { sessionId });
|
||||||
|
},
|
||||||
|
async close(): Promise<void> {
|
||||||
|
ws.removeOutputSink(sessionId);
|
||||||
|
await ws.send("terminal.close", { sessionId });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpSystemGateway implements SystemGateway {
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpInvoker,
|
||||||
|
private readonly ws: WsLiveClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
health(note?: string): Promise<HealthReport> {
|
||||||
|
return this.http.invoke<HealthReport>("health", {
|
||||||
|
request: note === undefined ? null : { note },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe> {
|
||||||
|
// The low-frequency domain-event stream rides the same WS connection
|
||||||
|
// (`event.domain`), replacing Tauri `listen("domain://event")`.
|
||||||
|
await this.ws.ensureConnected();
|
||||||
|
this.ws.setDomainEventHandler(handler);
|
||||||
|
return () => this.ws.clearDomainEventHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
pickFolder(): Promise<string | null> {
|
||||||
|
// Desktop-only: the native OS dialog has no web equivalent. A server-side
|
||||||
|
// folder browser would be its own lot (flagged in the F1 report).
|
||||||
|
return unsupportedOnWeb("Native folder picker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpTerminalGateway implements TerminalGateway {
|
||||||
|
constructor(private readonly ws: WsLiveClient) {}
|
||||||
|
|
||||||
|
async openTerminal(
|
||||||
|
options: OpenTerminalOptions,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<TerminalHandle> {
|
||||||
|
const ack = await this.ws.send("terminal.open", {
|
||||||
|
projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract.
|
||||||
|
nodeId: options.nodeId ?? null,
|
||||||
|
cwd: options.cwd,
|
||||||
|
rows: options.rows,
|
||||||
|
cols: options.cols,
|
||||||
|
});
|
||||||
|
const payload = ack.payload as unknown as AttachedPayload;
|
||||||
|
const sessionId = payload.session.sessionId;
|
||||||
|
this.ws.setOutputSink(sessionId, onData);
|
||||||
|
const scrollback = attachedToScrollback(payload);
|
||||||
|
if (scrollback.length > 0) onData(scrollback);
|
||||||
|
return makeWsTerminalHandle(sessionId, this.ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult> {
|
||||||
|
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||||
|
const payload = ack.payload as unknown as AttachedPayload;
|
||||||
|
this.ws.setOutputSink(sessionId, onData);
|
||||||
|
return {
|
||||||
|
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||||
|
scrollback: attachedToScrollback(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTerminal(sessionId: string): Promise<void> {
|
||||||
|
this.ws.removeOutputSink(sessionId);
|
||||||
|
await this.ws.send("terminal.close", { sessionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpAgentGateway implements AgentGateway {
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpInvoker,
|
||||||
|
private readonly ws: WsLiveClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listAgents(projectId: string): Promise<Agent[]> {
|
||||||
|
return this.http.invoke<Agent[]>("list_agents", { projectId });
|
||||||
|
}
|
||||||
|
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
||||||
|
return this.http.invoke<LiveAgent[]>("list_live_agents", { projectId });
|
||||||
|
}
|
||||||
|
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
||||||
|
return this.http
|
||||||
|
.invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId })
|
||||||
|
.then((res) => res.resumable);
|
||||||
|
}
|
||||||
|
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
|
||||||
|
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
|
||||||
|
}
|
||||||
|
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||||
|
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", { request: { projectId, agentId } });
|
||||||
|
}
|
||||||
|
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||||
|
return this.http.invoke<Agent>("create_agent", {
|
||||||
|
request: {
|
||||||
|
projectId,
|
||||||
|
name: input.name,
|
||||||
|
profileId: input.profileId,
|
||||||
|
initialContent: input.initialContent ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
changeAgentProfile(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
profileId: string,
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
|
||||||
|
return this.http.invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>("change_agent_profile", {
|
||||||
|
request: { projectId, agentId, profileId, rows, cols },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
readContext(projectId: string, agentId: string): Promise<string> {
|
||||||
|
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
||||||
|
}
|
||||||
|
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
||||||
|
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
||||||
|
}
|
||||||
|
async deleteAgent(projectId: string, agentId: string): Promise<void> {
|
||||||
|
await this.http.invoke("delete_agent", { projectId, agentId });
|
||||||
|
}
|
||||||
|
inspectConversation(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<ConversationDetails> {
|
||||||
|
return this.http.invoke<ConversationDetails>("inspect_conversation", {
|
||||||
|
request: { projectId, agentId, conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchAgent(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
options: OpenTerminalOptions,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<TerminalHandle> {
|
||||||
|
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
|
||||||
|
// sessions do not use this channel — that branch is B6.
|
||||||
|
const ack = await this.ws.send("agent.launch", {
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
nodeId: options.nodeId ?? null,
|
||||||
|
rows: options.rows,
|
||||||
|
cols: options.cols,
|
||||||
|
conversationId: options.conversationId ?? null,
|
||||||
|
});
|
||||||
|
const payload = ack.payload as unknown as AttachedPayload;
|
||||||
|
const sessionId = payload.session.sessionId;
|
||||||
|
this.ws.setOutputSink(sessionId, onData);
|
||||||
|
const scrollback = attachedToScrollback(payload);
|
||||||
|
if (scrollback.length > 0) onData(scrollback);
|
||||||
|
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult> {
|
||||||
|
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||||
|
const payload = ack.payload as unknown as AttachedPayload;
|
||||||
|
this.ws.setOutputSink(sessionId, onData);
|
||||||
|
return {
|
||||||
|
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||||
|
scrollback: attachedToScrollback(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpTicketGateway implements TicketGateway {
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpInvoker,
|
||||||
|
private readonly ws: WsLiveClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_create", { request: { projectId, ...input } });
|
||||||
|
}
|
||||||
|
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } });
|
||||||
|
}
|
||||||
|
list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||||
|
const { statuses, priorities, ...rest } = query ?? {};
|
||||||
|
return this.http.invoke<TicketList>("ticket_list", {
|
||||||
|
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } });
|
||||||
|
}
|
||||||
|
async delete(projectId: string, ref: string): Promise<void> {
|
||||||
|
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
||||||
|
}
|
||||||
|
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||||
|
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
||||||
|
}
|
||||||
|
updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_update_carnet", {
|
||||||
|
request: { projectId, ref, carnet, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
link(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
kind: TicketLinkKind,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_link", {
|
||||||
|
request: { projectId, ref, targetRef, kind, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unlink(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
kind?: TicketLinkKind,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_unlink", {
|
||||||
|
request: { projectId, ref, targetRef, expectedVersion, kind },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assign(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
agentId: string,
|
||||||
|
assigned: boolean,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return this.http.invoke<Ticket>("ticket_assign", {
|
||||||
|
request: { projectId, ref, agentId, assigned, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async listSprints(projectId: string): Promise<Sprint[]> {
|
||||||
|
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } });
|
||||||
|
return list.items;
|
||||||
|
}
|
||||||
|
setTicketSprint(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
sprintId: string | null,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
if (sprintId === null) {
|
||||||
|
return this.http.invoke<Ticket>("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } });
|
||||||
|
}
|
||||||
|
return this.http.invoke<Ticket>("ticket_assign_sprint", {
|
||||||
|
request: { projectId, ref, sprintId, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
createSprint(projectId: string, name: string): Promise<Sprint> {
|
||||||
|
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });
|
||||||
|
}
|
||||||
|
renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise<Sprint> {
|
||||||
|
return this.http.invoke<Sprint>("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } });
|
||||||
|
}
|
||||||
|
async reorderSprints(projectId: string, orderedIds: string[]): Promise<Sprint[]> {
|
||||||
|
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", {
|
||||||
|
request: { projectId, orderedIds },
|
||||||
|
});
|
||||||
|
return list.items;
|
||||||
|
}
|
||||||
|
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
||||||
|
await this.http.invoke<void>("sprint_delete", { request: { projectId, sprintId } });
|
||||||
|
}
|
||||||
|
openTicketChat(projectId: string, issueRef: string, profileId: string): Promise<TicketChat> {
|
||||||
|
return this.http.invoke<TicketChat>("open_ticket_chat", { request: { projectId, issueRef, profileId } });
|
||||||
|
}
|
||||||
|
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
||||||
|
await this.http.invoke<void>("close_ticket_chat", { request: { projectId, issueRef } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTicketChat(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
// Structured assistant reply stream over WS (`chat.*`, B0). Skeleton: route
|
||||||
|
// this session's `chat.output` chunks to `onChunk` until the `final` chunk.
|
||||||
|
// TODO(F3/B5): the WsLiveClient currently routes PTY output + domain events;
|
||||||
|
// a per-session chat sink is added alongside the terminal sink in F3.
|
||||||
|
const ack = await this.ws.send("chat.send", { sessionId, prompt: message });
|
||||||
|
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
|
||||||
|
if (payload && payload.chunk) onChunk(payload.chunk as ReplyChunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
frontend/src/adapters/http/unsupported.ts
Normal file
77
frontend/src/adapters/http/unsupported.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Desktop-only surfaces that have no web equivalent in V1 (ticket #13, lot F1):
|
||||||
|
* the native folder picker, OS view-windows and the main↔detached focused-project
|
||||||
|
* channel, plus SSH/WSL remote. On the web client these must fail with a clear,
|
||||||
|
* explicit error rather than reaching for Tauri (which is absent).
|
||||||
|
*
|
||||||
|
* The B0 inventory classified these as `desktop-only`; F1 does NOT implement them
|
||||||
|
* for web. If/when the product wants an equivalent (e.g. a server-side folder
|
||||||
|
* browser to replace `pickFolder`), it gets its own lot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||||
|
import type {
|
||||||
|
FocusedProject,
|
||||||
|
FocusedProjectGateway,
|
||||||
|
RemoteGateway,
|
||||||
|
ViewWindowClosed,
|
||||||
|
ViewWindowSnapshot,
|
||||||
|
WindowGateway,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
|
/** Throws a stable, explicit "unsupported on web" {@link GatewayError}. */
|
||||||
|
export function unsupportedOnWeb(what: string): never {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "UNSUPPORTED_ON_WEB",
|
||||||
|
message: `${what} is not available in the web client (desktop-only).`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: OS view-windows are desktop-only (no `WebviewWindow` on the web). */
|
||||||
|
export class WebWindowGateway implements WindowGateway {
|
||||||
|
openViewWindow(): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Detaching a panel into an OS window");
|
||||||
|
}
|
||||||
|
closeViewWindow(): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Closing a detached OS window");
|
||||||
|
}
|
||||||
|
listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
||||||
|
// Best-effort by contract (callers treat rejection as "no reconciliation"),
|
||||||
|
// but there are simply never detached OS windows on web → empty list.
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
onViewWindowClosed(
|
||||||
|
_handler: (event: ViewWindowClosed) => void,
|
||||||
|
): Promise<Unsubscribe> {
|
||||||
|
// No OS windows ⇒ nothing ever fires; return a no-op unsubscribe.
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web stub for the focused-project channel. With no detached windows on web the
|
||||||
|
* channel has no cross-window consumer; F1 keeps it inert (no publish, no focus).
|
||||||
|
* A future multi-tab web build could back this with `BroadcastChannel`.
|
||||||
|
*/
|
||||||
|
export class WebFocusedProjectGateway implements FocusedProjectGateway {
|
||||||
|
setFocusedProject(_project: FocusedProject | null): Promise<void> {
|
||||||
|
// No-op: nothing reads the channel on web V1.
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
getFocusedProject(): Promise<FocusedProject | null> {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
onFocusedProjectChanged(
|
||||||
|
_handler: (project: FocusedProject | null) => void,
|
||||||
|
): Promise<Unsubscribe> {
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: SSH/WSL remote connection is desktop-only in V1. */
|
||||||
|
export class WebRemoteGateway implements RemoteGateway {
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Remote (SSH/WSL) connection");
|
||||||
|
}
|
||||||
|
}
|
||||||
123
frontend/src/adapters/http/wsLiveClient.test.ts
Normal file
123
frontend/src/adapters/http/wsLiveClient.test.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* F1 — the WS live client skeleton: connects lazily, correlates a command with
|
||||||
|
* its `replyTo` acknowledgement, routes per-session PTY output to the registered
|
||||||
|
* sink, and dispatches `event.domain` frames to the domain-event handler.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { WebSocketLike } from "./wsLiveClient";
|
||||||
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
|
import { bytesToBase64, base64ToBytes } from "./frames";
|
||||||
|
|
||||||
|
/** A controllable fake WebSocket that auto-completes its open handshake. */
|
||||||
|
class FakeSocket implements WebSocketLike {
|
||||||
|
sent: string[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null;
|
||||||
|
onerror: ((event: unknown) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
/** Simulate the server pushing a frame. */
|
||||||
|
receive(frame: unknown): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(frame) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a client whose socket auto-opens on the next microtask (after the
|
||||||
|
* client has assigned `onopen`), so `ensureConnected()`/`send()` resolve without
|
||||||
|
* manual driving.
|
||||||
|
*/
|
||||||
|
function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
const client = new WsLiveClient({
|
||||||
|
wsUrl: "wss://host",
|
||||||
|
socketFactory: () => {
|
||||||
|
queueMicrotask(() => socket.onopen?.());
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { client, socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("base64 helpers round-trip binary", () => {
|
||||||
|
it("encodes and decodes arbitrary bytes", () => {
|
||||||
|
const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]);
|
||||||
|
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WsLiveClient", () => {
|
||||||
|
it("connects lazily and resolves a command with its replyTo ack", async () => {
|
||||||
|
const { client, socket } = connectedClient();
|
||||||
|
|
||||||
|
const ackPromise = client.send("terminal.open", { cwd: "/root", rows: 30, cols: 120 });
|
||||||
|
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
|
||||||
|
|
||||||
|
const sent = JSON.parse(socket.sent[0]);
|
||||||
|
expect(sent.kind).toBe("terminal.open");
|
||||||
|
expect(sent.payload).toMatchObject({ cwd: "/root", rows: 30, cols: 120 });
|
||||||
|
|
||||||
|
socket.receive({
|
||||||
|
kind: "terminal.attached",
|
||||||
|
replyTo: sent.id,
|
||||||
|
payload: { session: { sessionId: "s1", rows: 30, cols: 120 }, nextSeq: 1, scrollback: [], gap: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ack = await ackPromise;
|
||||||
|
expect(ack.kind).toBe("terminal.attached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes terminal.output to the per-session sink", async () => {
|
||||||
|
const { client, socket } = connectedClient();
|
||||||
|
await client.ensureConnected();
|
||||||
|
|
||||||
|
const received: Uint8Array[] = [];
|
||||||
|
client.setOutputSink("s1", (bytes) => received.push(bytes));
|
||||||
|
|
||||||
|
socket.receive({
|
||||||
|
kind: "terminal.output",
|
||||||
|
payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0]).toEqual(new Uint8Array([104, 105]));
|
||||||
|
|
||||||
|
// After removing the sink, further output is dropped.
|
||||||
|
client.removeOutputSink("s1");
|
||||||
|
socket.receive({
|
||||||
|
kind: "terminal.output",
|
||||||
|
payload: { sessionId: "s1", seq: 2, bytesBase64: bytesToBase64(new Uint8Array([106])) },
|
||||||
|
});
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches event.domain frames to the domain-event handler", async () => {
|
||||||
|
const { client, socket } = connectedClient();
|
||||||
|
await client.ensureConnected();
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
client.setDomainEventHandler(handler);
|
||||||
|
|
||||||
|
socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", agentId: "a1", busy: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pending command when an error frame arrives", async () => {
|
||||||
|
const { client, socket } = connectedClient();
|
||||||
|
const promise = client.send("terminal.close", { sessionId: "gone" });
|
||||||
|
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
|
||||||
|
|
||||||
|
const sent = JSON.parse(socket.sent[0]);
|
||||||
|
socket.receive({ kind: "error", replyTo: sent.id, payload: { code: "NOT_FOUND", message: "no session" } });
|
||||||
|
|
||||||
|
await expect(promise).rejects.toEqual({ code: "NOT_FOUND", message: "no session" });
|
||||||
|
});
|
||||||
|
});
|
||||||
232
frontend/src/adapters/http/wsLiveClient.ts
Normal file
232
frontend/src/adapters/http/wsLiveClient.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket live client skeleton for the web transport — ticket #13, lot F1.
|
||||||
|
*
|
||||||
|
* Owns a single WS connection to `{wsUrl}/ws/live` and multiplexes over it, per
|
||||||
|
* the B0 draft: PTY output, structured chat output and the low-frequency domain
|
||||||
|
* event stream (`event.domain`, replacing Tauri `listen("domain://event")`).
|
||||||
|
*
|
||||||
|
* **F1 scope = skeleton.** The connection, frame envelope, request/reply
|
||||||
|
* correlation by `id`, and the per-session output routing are implemented and
|
||||||
|
* unit-testable (inject a fake WebSocket factory). What is deliberately deferred
|
||||||
|
* to **F3/B5** (when a server exists — B3/B4): reconnection/backpressure, exact
|
||||||
|
* `open`/`launch` reply shape, sequence-gap replay policy, and the full xterm
|
||||||
|
* round-trip. Those are marked `TODO(F3/B5)`.
|
||||||
|
*
|
||||||
|
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DomainEvent } from "@/domain";
|
||||||
|
import {
|
||||||
|
base64ToBytes,
|
||||||
|
bytesToBase64,
|
||||||
|
type ClientFrame,
|
||||||
|
type ClientFrameKind,
|
||||||
|
type OutputPayload,
|
||||||
|
type ServerFrame,
|
||||||
|
} from "./frames";
|
||||||
|
|
||||||
|
/** Minimal WebSocket surface used here; injectable so tests pass a fake. */
|
||||||
|
export interface WebSocketLike {
|
||||||
|
send(data: string): void;
|
||||||
|
close(): void;
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null;
|
||||||
|
onerror: ((event: unknown) => void) | null;
|
||||||
|
onclose: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Factory building a {@link WebSocketLike} for a URL (defaults to global WS). */
|
||||||
|
export type WebSocketFactory = (url: string) => WebSocketLike;
|
||||||
|
|
||||||
|
/** Configuration for the live client. */
|
||||||
|
export interface WsLiveClientConfig {
|
||||||
|
/** Base WS URL, e.g. `wss://host:port`. No trailing slash. */
|
||||||
|
wsUrl: string;
|
||||||
|
/** Bearer token (ticket #13 auth). B0 note: prefer header/cookie over URL. */
|
||||||
|
token?: string;
|
||||||
|
/** Injected WebSocket factory (defaults to `new WebSocket(url)`). */
|
||||||
|
socketFactory?: WebSocketFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCounter = 0;
|
||||||
|
/** Monotonic client frame id (unique per client session). */
|
||||||
|
function nextFrameId(): string {
|
||||||
|
frameCounter += 1;
|
||||||
|
return `c${frameCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the single live WebSocket. Callers subscribe an output sink per PTY
|
||||||
|
* session and a single domain-event handler; the client routes inbound frames.
|
||||||
|
*/
|
||||||
|
export class WsLiveClient {
|
||||||
|
private readonly wsUrl: string;
|
||||||
|
private readonly token?: string;
|
||||||
|
private readonly socketFactory: WebSocketFactory;
|
||||||
|
|
||||||
|
private socket: WebSocketLike | null = null;
|
||||||
|
private opening: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Per-session PTY output sinks (sessionId → onData). */
|
||||||
|
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
|
||||||
|
/** Pending command acknowledgements, keyed by client frame id. */
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (frame: ServerFrame) => void; reject: (err: unknown) => void }
|
||||||
|
>();
|
||||||
|
/** The single domain-event handler (set by the system gateway). */
|
||||||
|
private domainEventHandler: ((event: DomainEvent) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(config: WsLiveClientConfig) {
|
||||||
|
this.wsUrl = config.wsUrl.replace(/\/+$/, "");
|
||||||
|
this.token = config.token;
|
||||||
|
this.socketFactory =
|
||||||
|
config.socketFactory ??
|
||||||
|
((url: string) => new WebSocket(url) as unknown as WebSocketLike);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the domain-event handler (replaces any previous one). */
|
||||||
|
setDomainEventHandler(handler: (event: DomainEvent) => void): void {
|
||||||
|
this.domainEventHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the domain-event handler. */
|
||||||
|
clearDomainEventHandler(): void {
|
||||||
|
this.domainEventHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers a per-session PTY output sink. */
|
||||||
|
setOutputSink(sessionId: string, onData: (bytes: Uint8Array) => void): void {
|
||||||
|
this.outputSinks.set(sessionId, onData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a per-session PTY output sink (view detached). */
|
||||||
|
removeOutputSink(sessionId: string): void {
|
||||||
|
this.outputSinks.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures the socket is connected, connecting on first use. */
|
||||||
|
async ensureConnected(): Promise<void> {
|
||||||
|
if (this.socket) return;
|
||||||
|
if (this.opening) return this.opening;
|
||||||
|
|
||||||
|
this.opening = new Promise<void>((resolve, reject) => {
|
||||||
|
// B0 auth note: token should ride an `Authorization` header / secure
|
||||||
|
// cookie at upgrade, NOT a URL secret. Browsers can't set WS upgrade
|
||||||
|
// headers, so the token placement is a contract point to confirm (see the
|
||||||
|
// F1 report). The query below is only a placeholder for the skeleton.
|
||||||
|
const url = this.token
|
||||||
|
? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}`
|
||||||
|
: `${this.wsUrl}/ws/live`;
|
||||||
|
const socket = this.socketFactory(url);
|
||||||
|
socket.onopen = () => {
|
||||||
|
this.socket = socket;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
socket.onerror = (event) => {
|
||||||
|
this.opening = null;
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
// TODO(F3/B5): reconnection + re-attach of live sessions. For F1 we drop
|
||||||
|
// the socket so a later call reconnects fresh.
|
||||||
|
this.socket = null;
|
||||||
|
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
|
||||||
|
};
|
||||||
|
socket.onmessage = (event) => this.handleMessage(event.data);
|
||||||
|
});
|
||||||
|
return this.opening;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a client frame and resolves with its acknowledgement frame (the server
|
||||||
|
* frame whose `replyTo` equals this frame's `id`). Fire-and-forget frames
|
||||||
|
* (input/resize/detach) can ignore the returned promise.
|
||||||
|
*/
|
||||||
|
async send(
|
||||||
|
kind: ClientFrameKind,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<ServerFrame> {
|
||||||
|
await this.ensureConnected();
|
||||||
|
const socket = this.socket;
|
||||||
|
if (!socket) {
|
||||||
|
const err = { code: "WS_CLOSED", message: "socket not connected" };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const id = nextFrameId();
|
||||||
|
const frame: ClientFrame = { id, kind, payload };
|
||||||
|
const ack = new Promise<ServerFrame>((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
});
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
return ack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a fire-and-forget frame (no acknowledgement awaited). */
|
||||||
|
async sendFireAndForget(
|
||||||
|
kind: ClientFrameKind,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ensureConnected();
|
||||||
|
const id = nextFrameId();
|
||||||
|
const frame: ClientFrame = { id, kind, payload };
|
||||||
|
this.socket?.send(JSON.stringify(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: base64-encode bytes for an `input` frame. */
|
||||||
|
static encodeInput(bytes: Uint8Array): string {
|
||||||
|
return bytesToBase64(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the socket and rejects everything pending. */
|
||||||
|
dispose(): void {
|
||||||
|
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
|
||||||
|
this.outputSinks.clear();
|
||||||
|
this.domainEventHandler = null;
|
||||||
|
this.socket?.close();
|
||||||
|
this.socket = null;
|
||||||
|
this.opening = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAllPending(err: unknown): void {
|
||||||
|
for (const { reject } of this.pending.values()) reject(err);
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(data: string): void {
|
||||||
|
let frame: ServerFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(data) as ServerFrame;
|
||||||
|
} catch {
|
||||||
|
return; // Ignore malformed frames in the skeleton.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route unsolicited streams first (no replyTo).
|
||||||
|
switch (frame.kind) {
|
||||||
|
case "terminal.output": {
|
||||||
|
const payload = frame.payload as unknown as OutputPayload;
|
||||||
|
const sink = this.outputSinks.get(payload.sessionId);
|
||||||
|
// TODO(F3/B5): honour `seq` ordering + gap detection for precise replay.
|
||||||
|
if (sink) sink(base64ToBytes(payload.bytesBase64));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "event.domain": {
|
||||||
|
// `payload` is a DomainEventDto (kind-tagged); forward as-is.
|
||||||
|
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise it is a reply/ack correlated by `replyTo`.
|
||||||
|
if (frame.replyTo) {
|
||||||
|
const waiter = this.pending.get(frame.replyTo);
|
||||||
|
if (waiter) {
|
||||||
|
this.pending.delete(frame.replyTo);
|
||||||
|
if (frame.kind === "error") waiter.reject(frame.payload);
|
||||||
|
else waiter.resolve(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
|
|||||||
import { render, screen, renderHook } from "@testing-library/react";
|
import { render, screen, renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
import { MockSystemGateway } from "@/adapters/mock";
|
import { MockSystemGateway } from "@/adapters/mock";
|
||||||
import { DIProvider, useGateways, resolveGateways, shouldUseMock } from "./di";
|
import { HttpSystemGateway } from "@/adapters/http/streamGateways";
|
||||||
|
import { TauriSystemGateway } from "@/adapters";
|
||||||
|
import {
|
||||||
|
DIProvider,
|
||||||
|
useGateways,
|
||||||
|
resolveGateways,
|
||||||
|
resolveTransport,
|
||||||
|
shouldUseMock,
|
||||||
|
shouldUseHttp,
|
||||||
|
} from "./di";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@ -26,12 +35,37 @@ describe("shouldUseMock / resolveGateways", () => {
|
|||||||
|
|
||||||
it("returns real (non-mock) gateways otherwise", () => {
|
it("returns real (non-mock) gateways otherwise", () => {
|
||||||
vi.stubEnv("VITE_USE_MOCK", "");
|
vi.stubEnv("VITE_USE_MOCK", "");
|
||||||
|
vi.stubEnv("VITE_TRANSPORT", "");
|
||||||
expect(shouldUseMock()).toBe(false);
|
expect(shouldUseMock()).toBe(false);
|
||||||
// Real adapter is not the mock implementation.
|
// Real adapter is not the mock implementation.
|
||||||
expect(resolveGateways().system).not.toBeInstanceOf(MockSystemGateway);
|
expect(resolveGateways().system).not.toBeInstanceOf(MockSystemGateway);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => {
|
||||||
|
it("selects the HTTP transport when VITE_TRANSPORT=http", () => {
|
||||||
|
vi.stubEnv("VITE_USE_MOCK", "");
|
||||||
|
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||||
|
expect(shouldUseHttp()).toBe(true);
|
||||||
|
expect(resolveTransport()).toBe("http");
|
||||||
|
expect(resolveGateways().system).toBeInstanceOf(HttpSystemGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Tauri as the default when no transport env is set", () => {
|
||||||
|
vi.stubEnv("VITE_USE_MOCK", "");
|
||||||
|
vi.stubEnv("VITE_TRANSPORT", "");
|
||||||
|
expect(resolveTransport()).toBe("tauri");
|
||||||
|
expect(resolveGateways().system).toBeInstanceOf(TauriSystemGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets mock win over the web transport", () => {
|
||||||
|
vi.stubEnv("VITE_USE_MOCK", "1");
|
||||||
|
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||||
|
expect(resolveTransport()).toBe("mock");
|
||||||
|
expect(resolveGateways().system).toBeInstanceOf(MockSystemGateway);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DIProvider / useGateways", () => {
|
describe("DIProvider / useGateways", () => {
|
||||||
it("provides explicit gateways to consumers", () => {
|
it("provides explicit gateways to consumers", () => {
|
||||||
const gateways = resolveGatewaysMock();
|
const gateways = resolveGatewaysMock();
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Dependency-injection provider for the UI ports.
|
* Dependency-injection provider for the UI ports.
|
||||||
*
|
*
|
||||||
* Chooses real Tauri adapters vs mocks based on `VITE_USE_MOCK` (any truthy
|
* Chooses one of three transport implementations (ticket #13): the mock adapters
|
||||||
* value → mocks). Components consume gateways via {@link useGateways} and never
|
* (`VITE_USE_MOCK`), the web HTTP+WS adapters (`VITE_TRANSPORT="http"`), or the
|
||||||
* construct adapters or call `invoke()` themselves.
|
* default Tauri desktop adapters. Components consume gateways via
|
||||||
|
* {@link useGateways} and never construct adapters or call `invoke()` themselves.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -16,18 +17,45 @@ import {
|
|||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { createTauriGateways } from "@/adapters";
|
import { createTauriGateways } from "@/adapters";
|
||||||
import { createMockGateways } from "@/adapters/mock";
|
import { createMockGateways } from "@/adapters/mock";
|
||||||
|
import { createHttpWsGateways } from "@/adapters/http";
|
||||||
|
|
||||||
const GatewaysContext = createContext<Gateways | null>(null);
|
const GatewaysContext = createContext<Gateways | null>(null);
|
||||||
|
|
||||||
|
/** The selected transport backing the gateways. */
|
||||||
|
export type Transport = "mock" | "http" | "tauri";
|
||||||
|
|
||||||
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
||||||
export function shouldUseMock(): boolean {
|
export function shouldUseMock(): boolean {
|
||||||
const flag = import.meta.env.VITE_USE_MOCK;
|
const flag = import.meta.env.VITE_USE_MOCK;
|
||||||
return flag === "1" || flag === "true";
|
return flag === "1" || flag === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the web (client/server) HTTP+WS adapters should be used. Selected
|
||||||
|
* explicitly via `VITE_TRANSPORT="http"` so the desktop path stays the default
|
||||||
|
* and is never affected (ticket #13, lot F1).
|
||||||
|
*/
|
||||||
|
export function shouldUseHttp(): boolean {
|
||||||
|
return import.meta.env.VITE_TRANSPORT === "http";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */
|
||||||
|
export function resolveTransport(): Transport {
|
||||||
|
if (shouldUseMock()) return "mock";
|
||||||
|
if (shouldUseHttp()) return "http";
|
||||||
|
return "tauri";
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolves the gateway set for the current environment. */
|
/** Resolves the gateway set for the current environment. */
|
||||||
export function resolveGateways(): Gateways {
|
export function resolveGateways(): Gateways {
|
||||||
return shouldUseMock() ? createMockGateways() : createTauriGateways();
|
switch (resolveTransport()) {
|
||||||
|
case "mock":
|
||||||
|
return createMockGateways();
|
||||||
|
case "http":
|
||||||
|
return createHttpWsGateways();
|
||||||
|
case "tauri":
|
||||||
|
return createTauriGateways();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DIProviderProps {
|
interface DIProviderProps {
|
||||||
|
|||||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_USE_MOCK?: string;
|
readonly VITE_USE_MOCK?: string;
|
||||||
|
/** Selects the web (client/server) HTTP+WS transport when set to `"http"` (ticket #13). */
|
||||||
|
readonly VITE_TRANSPORT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user