241 lines
6.7 KiB
TypeScript
241 lines
6.7 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[];
|
|
}
|
|
|
|
/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */
|
|
export type Pipeline =
|
|
| "backlog"
|
|
| "coding"
|
|
| "qa"
|
|
| "merge"
|
|
| "done"
|
|
| "closed"
|
|
| "archived";
|
|
|
|
/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */
|
|
export type Status =
|
|
| "active"
|
|
| "frozen"
|
|
| "review-hold"
|
|
| "blocked"
|
|
| "merge-failure"
|
|
| "merge-failure-final"
|
|
| "abandoned"
|
|
| "superseded"
|
|
| "rejected"
|
|
| "done";
|
|
|
|
export interface PipelineItem {
|
|
story_id: string;
|
|
name: string;
|
|
/** Legacy stage string (kept for back-compat); prefer `pipeline` + `status`. */
|
|
stage: string;
|
|
/** Display column (story 1085). Optional until all servers are upgraded. */
|
|
pipeline?: Pipeline;
|
|
/** Display badge (story 1085). Optional until all servers are upgraded. */
|
|
status?: Status;
|
|
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;
|
|
archived?: PipelineItem[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface AllProjectsPipeline {
|
|
active: string;
|
|
projects: Record<string, ProjectPipelineStatus>;
|
|
}
|
|
|
|
export interface GenerateTokenResponse {
|
|
token: string;
|
|
}
|
|
|
|
export interface ServerMode {
|
|
mode: "gateway" | "standard";
|
|
}
|
|
|
|
/// Type guard: verify that an unknown value has the AllProjectsPipeline shape.
|
|
/// Prevents silent "no active stories" when the backend response shape drifts.
|
|
function isAllProjectsPipeline(value: unknown): value is AllProjectsPipeline {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const v = value as Record<string, unknown>;
|
|
if (typeof v.active !== "string") return false;
|
|
if (typeof v.projects !== "object" || v.projects === null) return false;
|
|
for (const proj of Object.values(v.projects as Record<string, unknown>)) {
|
|
if (typeof proj !== "object" || proj === null) return false;
|
|
const p = proj as Record<string, unknown>;
|
|
if (!Array.isArray(p.active) && typeof p.error !== "string") return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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?: unknown; error?: { message: string } };
|
|
if (rpc.error) {
|
|
throw new Error(rpc.error.message);
|
|
}
|
|
const result = rpc.result;
|
|
if (!isAllProjectsPipeline(result)) {
|
|
throw new Error("pipeline.get returned unexpected shape");
|
|
}
|
|
return 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 };
|
|
},
|
|
};
|