huskies: merge 563_story_build_agent_join_mechanism_agents_register_with_the_gateway_via_token
This commit is contained in:
+18
-1
@@ -2,11 +2,14 @@ import * as React from "react";
|
||||
import type { OAuthStatus } from "./api/client";
|
||||
import { api } from "./api/client";
|
||||
import { Chat } from "./components/Chat";
|
||||
import { GatewayPanel } from "./components/GatewayPanel";
|
||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
||||
import { gatewayApi } from "./api/gateway";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [isGateway, setIsGateway] = React.useState<boolean | null>(null);
|
||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
|
||||
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
||||
@@ -19,6 +22,14 @@ function App() {
|
||||
null,
|
||||
);
|
||||
|
||||
// Detect gateway mode on startup — if /gateway/mode returns 200, we're a gateway.
|
||||
React.useEffect(() => {
|
||||
gatewayApi
|
||||
.getServerMode()
|
||||
.then((result) => setIsGateway(result.mode === "gateway"))
|
||||
.catch(() => setIsGateway(false));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
function fetchOAuthStatus() {
|
||||
@@ -188,10 +199,16 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingProject) {
|
||||
// Still probing server mode — wait before rendering.
|
||||
if (isGateway === null || isCheckingProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gateway mode: render the agent management UI instead of the normal chat.
|
||||
if (isGateway) {
|
||||
return <GatewayPanel />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="container"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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 gatewayRequest<JoinedAgent[]>("/gateway/agents");
|
||||
},
|
||||
|
||||
/// Remove a registered build agent by its ID.
|
||||
removeAgent(id: string): Promise<void> {
|
||||
return gatewayRequest<void>(`/gateway/agents/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
/// Gateway management panel shown when huskies runs in `--gateway` mode.
|
||||
///
|
||||
/// Provides:
|
||||
/// - An "Add Agent" button that generates a one-time join token.
|
||||
/// - Instructions for running a build agent with the token.
|
||||
/// - A list of connected agents with per-agent "Remove" buttons.
|
||||
|
||||
import * as React from "react";
|
||||
import { gatewayApi, type JoinedAgent } from "../api/gateway";
|
||||
|
||||
const { useCallback, useEffect, useState } = React;
|
||||
|
||||
function TokenDisplay({ token }: { token: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const envCmd = `HUSKIES_JOIN_TOKEN=${token} huskies agent --rendezvous <CRDT_SYNC_URL>`;
|
||||
const flagCmd = `huskies agent --rendezvous <CRDT_SYNC_URL> --join-token ${token}`;
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
padding: "12px 16px",
|
||||
background: "#161b22",
|
||||
border: "1px solid #238636",
|
||||
borderRadius: "8px",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#3fb950", fontWeight: 600, marginBottom: "8px" }}>
|
||||
Token generated — run the build agent with one of:
|
||||
</div>
|
||||
<div style={{ marginBottom: "6px" }}>
|
||||
<code
|
||||
style={{
|
||||
display: "block",
|
||||
background: "#0d1117",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "4px",
|
||||
color: "#e6edf3",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{envCmd}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<code
|
||||
style={{
|
||||
display: "block",
|
||||
background: "#0d1117",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "4px",
|
||||
color: "#e6edf3",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{flagCmd}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(flagCmd)}
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
fontSize: "0.8em",
|
||||
padding: "3px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #444",
|
||||
background: "none",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy flag command"}
|
||||
</button>
|
||||
<div style={{ marginTop: "8px", color: "#666", fontSize: "0.85em" }}>
|
||||
This token is single-use. Generate a new one for each agent.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
onRemove,
|
||||
}: {
|
||||
agent: JoinedAgent;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`agent-row-${agent.id}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "10px 14px",
|
||||
background: "#161b22",
|
||||
border: "1px solid #30363d",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: "#3fb950",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
|
||||
{agent.address}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
|
||||
Registered {registeredAt}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remove-agent-${agent.id}`}
|
||||
onClick={() => onRemove(agent.id)}
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #f85149",
|
||||
background: "none",
|
||||
color: "#f85149",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/// Gateway management panel — rendered when running in `--gateway` mode.
|
||||
export function GatewayPanel() {
|
||||
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
gatewayApi
|
||||
.listAgents()
|
||||
.then(setAgents)
|
||||
.catch(() => setAgents([]));
|
||||
}, []);
|
||||
|
||||
const handleAddAgent = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
try {
|
||||
const result = await gatewayApi.generateToken();
|
||||
setToken(result.token);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveAgent = useCallback(async (id: string) => {
|
||||
try {
|
||||
await gatewayApi.removeAgent(id);
|
||||
setAgents((prev) => prev.filter((a) => a.id !== id));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
padding: "32px",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: "720px", margin: "0 auto" }}>
|
||||
<h1 style={{ fontSize: "1.5em", fontWeight: 700, marginBottom: "4px" }}>
|
||||
Huskies Gateway
|
||||
</h1>
|
||||
<p style={{ color: "#8b949e", marginBottom: "32px" }}>
|
||||
Manage build agents connected to this gateway.
|
||||
</p>
|
||||
|
||||
{/* Add Agent */}
|
||||
<section style={{ marginBottom: "32px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
borderBottom: "1px solid #21262d",
|
||||
paddingBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Add Agent
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-agent-button"
|
||||
onClick={handleAddAgent}
|
||||
disabled={generating}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #238636",
|
||||
background: generating ? "#1a2f1a" : "#238636",
|
||||
color: "#fff",
|
||||
cursor: generating ? "not-allowed" : "pointer",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
{generating ? "Generating…" : "Add Agent"}
|
||||
</button>
|
||||
{token && <TokenDisplay token={token} />}
|
||||
</section>
|
||||
|
||||
{/* Agent list */}
|
||||
<section>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
borderBottom: "1px solid #21262d",
|
||||
paddingBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Connected Agents{" "}
|
||||
{agents.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#8b949e",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
({agents.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{agents.length === 0 ? (
|
||||
<p style={{ color: "#6e7681" }}>
|
||||
No agents connected yet. Click "Add Agent" to generate a join
|
||||
token.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{agents.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onRemove={handleRemoveAgent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "10px 14px",
|
||||
background: "#f8514911",
|
||||
border: "1px solid #f85149",
|
||||
borderRadius: "6px",
|
||||
color: "#f85149",
|
||||
fontSize: "0.875em",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user