Files
huskies/frontend/src/api/gateway.ts
T
2026-04-28 14:01:18 +00:00

193 lines
5.1 KiB
TypeScript

/// Gateway API client — used when running in gateway mode.
///
/// The gateway mode is detected by checking `GET /gateway/mode`. If it returns
/// `{ "mode": "gateway" }` the frontend switches to the gateway UI.
export interface JoinedAgent {
id: string;
label: string;
address: string;
registered_at: number;
/// Unix timestamp of the last heartbeat from this agent.
last_seen: number;
/// Project this agent is assigned to, if any.
assigned_project?: string;
}
export interface GatewayProject {
name: string;
url: string;
}
export interface GatewayInfo {
active: string;
projects: GatewayProject[];
}
export interface PipelineItem {
story_id: string;
name: string;
stage: string;
agent?: { agent_name: string; model: string; status: string } | null;
blocked?: boolean;
retry_count?: number;
merge_failure?: string;
}
export interface ProjectPipelineStatus {
active: PipelineItem[];
backlog: { story_id: string; name: string }[];
backlog_count: number;
error?: string;
}
export interface AllProjectsPipeline {
active: string;
projects: Record<string, ProjectPipelineStatus>;
}
export interface GenerateTokenResponse {
token: string;
}
export interface ServerMode {
mode: "gateway" | "standard";
}
async function gatewayRequest<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
// DELETE /gateway/agents/:id returns 204 No Content.
if (res.status === 204) {
return undefined as unknown as T;
}
return res.json() as Promise<T>;
}
let _mcpRequestId = 1;
/// Call a gateway MCP tool via JSON-RPC and return the result.
async function gatewayMcpCall<T>(
toolName: string,
args: Record<string, unknown> = {},
): Promise<T> {
const id = _mcpRequestId++;
const body = JSON.stringify({
jsonrpc: "2.0",
id,
method: "tools/call",
params: { name: toolName, arguments: args },
});
const res = await fetch("/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `MCP request failed (${res.status})`);
}
const json = (await res.json()) as {
result?: Record<string, unknown>;
error?: { message: string };
};
if (json.error) {
throw new Error(json.error.message);
}
return json.result as T;
}
export const gatewayApi = {
/// Returns `{ mode: "gateway" }` if this server is a gateway, otherwise rejects.
getServerMode(): Promise<ServerMode> {
return gatewayRequest<ServerMode>("/gateway/mode");
},
/// Generate a one-time join token for a new build agent.
generateToken(): Promise<GenerateTokenResponse> {
return gatewayRequest<GenerateTokenResponse>("/gateway/tokens", {
method: "POST",
});
},
/// List all build agents that have registered with this gateway.
listAgents(): Promise<JoinedAgent[]> {
return gatewayMcpCall<{ agents: JoinedAgent[] }>("agents.list").then(
(result) => result.agents ?? [],
);
},
/// Remove a registered build agent by its ID.
removeAgent(id: string): Promise<void> {
return gatewayRequest<void>(`/gateway/agents/${id}`, {
method: "DELETE",
});
},
/// Assign an agent to a project, or unassign it by passing null.
assignAgent(id: string, project: string | null): Promise<JoinedAgent> {
return gatewayRequest<JoinedAgent>(`/gateway/agents/${id}/assign`, {
method: "POST",
body: JSON.stringify({ project }),
});
},
/// Get the list of registered projects from the gateway.
getGatewayInfo(): Promise<GatewayInfo> {
return gatewayRequest<GatewayInfo>("/api/gateway");
},
/// Send a heartbeat for an agent to update its last-seen timestamp.
heartbeat(id: string): Promise<void> {
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
method: "POST",
});
},
/// Fetch pipeline status from all registered projects via the pipeline.get read-RPC.
async getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
const res = await fetch("/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "pipeline.get", params: {} }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
const rpc = await res.json() as { result?: AllProjectsPipeline; error?: { message: string } };
if (rpc.error) {
throw new Error(rpc.error.message);
}
return rpc.result!;
},
/// Switch the active project via the MCP switch_project tool.
async switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch("/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "switch_project", arguments: { project } },
}),
});
const data = await res.json();
if (data.error) {
return { ok: false, error: data.error.message ?? String(data.error) };
}
return { ok: true };
},
};