Terminals: unify on a shared resilient-terminal core + deploy script for agent images
ci / gates (push) Failing after 13s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

- frontend/.../terminal/core.ts: `useResilientTerminal` owns the xterm lifecycle
  (init, fit, debounced resize, reconnect, refit-on-visible) with a pluggable
  transport. Two connectors: `wsConnector` (agent terminal → container PTY over WS)
  and `nodeWebrtcConnector` (node terminal → host PTY, direct WebRTC DataChannel
  with WS-relay fallback, input buffered during the race). The agent terminal now
  gets the node terminal's robust reconnect for free; both share one xterm setup.
- TerminalApp (agent) + NodeTerminalApp (node) reduced to thin wrappers over the
  core — net ~330 lines of duplicated transport/reconnect/xterm code removed.
- scripts/deploy.sh: the deploys were manual, so the locally-built agent images
  (agent-base/browser/terminal — not in any registry) were never shipped and 404'd
  on provision. The script always (re)builds + loads them onto gw-04 AND every
  fleet node, with a skip-if-identical guard so unchanged images aren't re-transferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 07:55:43 -07:00
co-authored by Claude Opus 4.8
parent a3bb16d838
commit 1072ec159a
4 changed files with 486 additions and 371 deletions
@@ -13,6 +13,7 @@ import type { Agent } from "@/lib/api/schemas";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
import { useSubHeader } from "./AppShell"; import { useSubHeader } from "./AppShell";
import { useResilientTerminal, wsConnector } from "./terminal/core";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
@@ -261,8 +262,9 @@ export default function TerminalApp({ agent }: { agent: Agent }) {
); );
} }
/** One tab: an xterm bridged to a tmux session over a WebSocket. Kept mounted /** One tab: an xterm bridged to a tmux session over a WebSocket (the shared
* (hidden when inactive) so its session + processes keep running. */ * resilient-terminal core). Kept mounted (hidden when inactive) so its session +
* processes keep running. */
function TerminalTab({ function TerminalTab({
agent, agent,
session, session,
@@ -272,157 +274,32 @@ function TerminalTab({
session: string; session: string;
active: boolean; active: boolean;
}) { }) {
const hostRef = useRef<HTMLDivElement>(null); // Latest `active` for the core's callbacks without re-running the connect.
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Latest `active` for callbacks without re-running the connect effect.
const activeRef = useRef(active); const activeRef = useRef(active);
useEffect(() => { useEffect(() => {
activeRef.current = active; activeRef.current = active;
}); });
const { hostRef, refit } = useResilientTerminal(
useEffect(() => { {
let disposed = false; connect: wsConnector(async () => {
let ro: ResizeObserver | null = null; const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
let retry: ReturnType<typeof setTimeout> | null = null; if (!res.ok) return null;
const { ticket } = (await res.json()) as { ticket: string };
(async () => { const proto = location.protocol === "https:" ? "wss:" : "ws:";
const [{ Terminal }, { FitAddon }] = await Promise.all([ return `${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(ticket)}&session=${encodeURIComponent(session)}`;
import("@xterm/xterm"), }),
import("@xterm/addon-fit"), visible: () => activeRef.current,
]); autoFocus: true,
if (disposed || !hostRef.current) return; },
[agent.id, session],
const term = new Terminal({ );
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
allowProposedApi: true,
scrollback: 5000,
theme: {
background: "#0b0b0e",
foreground: "#e6e6ea",
cursor: "#ff8a7a",
cursorAccent: "#0b0b0e",
selectionBackground: "rgba(255,138,122,.28)",
black: "#1c1c22",
red: "#ff6f61",
green: "#5fd08a",
yellow: "#e8b465",
blue: "#5ec8d8",
magenta: "#c98af0",
cyan: "#6fd0c0",
white: "#cfcfd5",
brightBlack: "#5a5a62",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
termRef.current = term;
fitRef.current = fit;
// Only fit when actually visible (a hidden tab has zero size).
if (hostRef.current.clientWidth > 0) {
try {
fit.fit();
} catch {
/* not laid out */
}
}
const encoder = new TextEncoder();
term.onData((d) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
});
const sendResize = () => {
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit.fit();
} catch {
/* ignore */
}
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ro = new ResizeObserver(() => sendResize());
ro.observe(hostRef.current);
const connect = async () => {
if (disposed) return;
try {
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
const { ticket } = (await res.json()) as { ticket: string };
if (disposed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
ticket,
)}&session=${encodeURIComponent(session)}`,
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
sendResize();
if (activeRef.current) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") term.write(ev.data);
else term.write(new Uint8Array(ev.data as ArrayBuffer));
};
ws.onerror = () => ws.close();
ws.onclose = () => {
wsRef.current = null;
if (disposed) return;
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
retry = setTimeout(connect, 1500);
};
} catch (e) {
if (disposed) return;
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
retry = setTimeout(connect, 2500);
}
};
void connect();
})();
return () => {
disposed = true;
if (retry) clearTimeout(retry);
ro?.disconnect();
wsRef.current?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
}, [agent.id, session]);
// Re-fit + focus when this tab becomes visible (it may have had zero size). // Re-fit + focus when this tab becomes visible (it may have had zero size).
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
const id = setTimeout(() => { const id = setTimeout(refit, 40);
const term = termRef.current;
const fit = fitRef.current;
const ws = wsRef.current;
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
try {
fit?.fit();
} catch {
/* ignore */
}
if (term && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
term?.focus();
}, 40);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [active]); }, [active, refit]);
return ( return (
<div <div
@@ -7,244 +7,30 @@
import { Server, Terminal as TerminalIcon, Zap } from "lucide-react"; import { Server, Terminal as TerminalIcon, Zap } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
import type { FleetNode } from "@/components/dashboard/fleet/FleetPanels"; import type { FleetNode } from "@/components/dashboard/fleet/FleetPanels";
import { nodeWebrtcConnector, useResilientTerminal } from "../terminal/core";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
const ICE_SERVERS: RTCIceServer[] = [{ urls: ["stun:stun.l.google.com:19302"] }];
type Transport = "connecting" | "direct" | "relayed"; type Transport = "connecting" | "direct" | "relayed";
/** xterm bridged to a node's host shell, preferring a direct WebRTC DataChannel. */ /** xterm bridged to a node's host shell, preferring a direct WebRTC DataChannel.
* Transport + xterm lifecycle live in the shared resilient-terminal core. */
function NodeShell({ nodeId }: { nodeId: string }) { function NodeShell({ nodeId }: { nodeId: string }) {
const hostRef = useRef<HTMLDivElement>(null);
const [transport, setTransport] = useState<Transport>("connecting"); const [transport, setTransport] = useState<Transport>("connecting");
const { hostRef } = useResilientTerminal(
useEffect(() => { {
let disposed = false; connect: nodeWebrtcConnector(nodeId, setTransport),
let term: import("@xterm/xterm").Terminal | null = null; theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
let fit: import("@xterm/addon-fit").FitAddon | null = null; autoFocus: true,
let ws: WebSocket | null = null; },
let pc: RTCPeerConnection | null = null; [nodeId],
let dc: RTCDataChannel | null = null; );
let ro: ResizeObserver | null = null;
let resizeT: ReturnType<typeof setTimeout> | undefined;
let reconnectT: ReturnType<typeof setTimeout> | undefined;
let raceT: ReturnType<typeof setTimeout> | undefined;
let mode: Transport = "connecting";
let remoteSet = false;
const pendingIce: RTCIceCandidateInit[] = [];
const inputQueue: string[] = [];
const setMode = (m: Transport) => {
mode = m;
if (!disposed) setTransport(m);
};
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN && term && fit && hostRef.current?.clientWidth) {
fit.fit();
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
// Route keystrokes to whichever transport is live; buffer while connecting.
const sendInput = (d: string) => {
const bytes = new TextEncoder().encode(d);
if (mode === "direct" && dc?.readyState === "open") dc.send(bytes);
else if (mode === "relayed" && ws?.readyState === WebSocket.OPEN) ws.send(bytes);
else inputQueue.push(d);
};
const flushInput = () => {
const q = inputQueue.splice(0);
for (const d of q) sendInput(d);
};
// Give up on the direct path → use the WS relay (today's behavior).
const goRelayed = () => {
if (disposed || mode !== "connecting") return;
clearTimeout(raceT);
setMode("relayed");
try {
dc?.close();
pc?.close();
} catch {
/* ignore */
}
dc = null;
pc = null;
ws?.send(JSON.stringify({ type: "fallback", cols: term?.cols ?? 80, rows: term?.rows ?? 24 }));
flushInput();
};
const startWebrtc = () => {
if (typeof RTCPeerConnection === "undefined") {
goRelayed();
return;
}
try {
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
dc = pc.createDataChannel("term", { ordered: true });
dc.binaryType = "arraybuffer";
dc.onopen = () => {
if (disposed || mode !== "connecting") return;
clearTimeout(raceT);
setMode("direct");
flushInput();
sendResize();
};
dc.onmessage = (e) => {
if (typeof e.data === "string") term?.write(e.data);
else term?.write(new Uint8Array(e.data as ArrayBuffer));
};
dc.onclose = () => {
if (mode === "direct" && !disposed) ws?.close(); // lost direct → reconnect
};
pc.onicecandidate = (e) => {
if (e.candidate && ws?.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "webrtc_ice",
candidate: e.candidate.candidate,
sdp_mid: e.candidate.sdpMid,
sdp_mline_index: e.candidate.sdpMLineIndex,
}),
);
}
};
pc.onconnectionstatechange = () => {
if (pc?.connectionState === "failed") goRelayed();
};
pc.createOffer()
.then((offer) => pc!.setLocalDescription(offer).then(() => {
ws?.send(JSON.stringify({ type: "webrtc_offer", sdp: offer.sdp }));
}))
.catch(() => goRelayed());
// Race: if no direct DataChannel within 2.5s, fall back.
raceT = setTimeout(goRelayed, 2500);
} catch {
goRelayed();
}
};
const onSignal = (m: { type: string; sdp?: string; candidate?: string; sdp_mid?: string | null; sdp_mline_index?: number | null }) => {
if (!pc) return;
if (m.type === "webrtc_answer" && m.sdp) {
pc.setRemoteDescription({ type: "answer", sdp: m.sdp })
.then(() => {
remoteSet = true;
for (const c of pendingIce.splice(0)) pc?.addIceCandidate(c).catch(() => {});
})
.catch(() => goRelayed());
} else if (m.type === "webrtc_ice" && m.candidate != null) {
const cand: RTCIceCandidateInit = {
candidate: m.candidate,
sdpMid: m.sdp_mid ?? undefined,
sdpMLineIndex: m.sdp_mline_index ?? undefined,
};
if (remoteSet) pc.addIceCandidate(cand).catch(() => {});
else pendingIce.push(cand);
} else if (m.type === "webrtc_failed") {
goRelayed();
}
};
const connect = async () => {
if (disposed || !term) return;
setMode("connecting");
pc = null;
dc = null;
remoteSet = false;
let ticket: string | undefined;
try {
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
const body = res.ok ? ((await res.json()) as { ticket?: string; error?: string }) : {};
ticket = body.ticket;
if (!ticket) {
term.writeln(`\r\n\x1b[2m[${body.error ?? "node offline"} — retrying…]\x1b[0m`);
reconnectT = setTimeout(connect, 2000);
return;
}
} catch {
reconnectT = setTimeout(connect, 2000);
return;
}
if (disposed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(ticket)}`);
ws.binaryType = "arraybuffer";
ws.onopen = () => startWebrtc();
ws.onmessage = (e) => {
if (typeof e.data === "string") {
try {
onSignal(JSON.parse(e.data));
} catch {
/* ignore malformed signaling */
}
} else {
// Binary = PTY output over the WS relay (fallback path).
term?.write(new Uint8Array(e.data as ArrayBuffer));
}
};
ws.onclose = () => {
if (disposed) return;
clearTimeout(raceT);
try {
dc?.close();
pc?.close();
} catch {
/* ignore */
}
term?.writeln("\r\n\x1b[2m[reconnecting…]\x1b[0m");
reconnectT = setTimeout(connect, 1500);
};
};
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
term.focus();
term.onData(sendInput);
ro = new ResizeObserver(() => {
clearTimeout(resizeT);
resizeT = setTimeout(sendResize, 150);
});
ro.observe(hostRef.current);
connect();
})();
return () => {
disposed = true;
clearTimeout(resizeT);
clearTimeout(reconnectT);
clearTimeout(raceT);
try {
dc?.close();
pc?.close();
} catch {
/* ignore */
}
ro?.disconnect();
ws?.close();
term?.dispose();
};
}, [nodeId]);
return ( return (
<div className="relative flex h-full w-full flex-col bg-[#0a0a0c]"> <div className="relative flex h-full w-full flex-col bg-[#0a0a0c]">
@@ -0,0 +1,373 @@
"use client";
// Shared resilient-terminal core used by BOTH the agent terminal (WS to a themed
// container PTY) and the node terminal (WebRTC DataChannel direct → fallback WS to
// a host PTY). The core owns the xterm lifecycle (init, fit, debounced resize,
// reconnect); each caller supplies a transport `connector`. This unifies the two
// terminals so they share one xterm setup + one robust reconnect loop.
import { useEffect, useRef } from "react";
import type { ITheme, Terminal } from "@xterm/xterm";
/** A live connection to a remote PTY. The core wires keystrokes + resize to it. */
export interface TermTransport {
sendInput: (data: Uint8Array<ArrayBuffer>) => void;
sendResize: (cols: number, rows: number) => void;
dispose: () => void;
}
/** Establishes ONE connection: writes incoming PTY bytes to `term`, calls
* `onClosed` when the link drops (the core then reconnects), and returns the live
* transport — or null on failure (the core reconnects). */
export type TermConnector = (ctx: { term: Terminal; onClosed: () => void }) => Promise<TermTransport | null>;
export interface TerminalCoreOpts {
connect: TermConnector;
theme?: ITheme;
fontSize?: number;
scrollback?: number;
reconnectMs?: number;
/** Skip fit/focus while hidden (e.g. an inactive tab has zero size). */
visible?: () => boolean;
autoFocus?: boolean;
}
export const TERMINAL_THEME: ITheme = {
background: "#0b0b0e",
foreground: "#e6e6ea",
cursor: "#ff8a7a",
cursorAccent: "#0b0b0e",
selectionBackground: "rgba(255,138,122,.28)",
black: "#1c1c22",
red: "#ff6f61",
green: "#5fd08a",
yellow: "#e8b465",
blue: "#5ec8d8",
magenta: "#c98af0",
cyan: "#6fd0c0",
white: "#cfcfd5",
brightBlack: "#5a5a62",
};
const FONT = '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace';
/** Mount an xterm into `hostRef`, drive it through the supplied connector, and
* auto-reconnect on drop. Re-runs when `deps` change. */
export function useResilientTerminal(opts: TerminalCoreOpts, deps: unknown[]): {
hostRef: React.RefObject<HTMLDivElement | null>;
/** Force a fit + resize + focus — e.g. when a hidden tab becomes visible. */
refit: () => void;
} {
const hostRef = useRef<HTMLDivElement>(null);
const refitRef = useRef<() => void>(() => {});
// Latest opts (connect/visible) without re-running the connect effect.
const optsRef = useRef(opts);
useEffect(() => {
optsRef.current = opts;
});
useEffect(() => {
let disposed = false;
let term: Terminal | null = null;
let fit: import("@xterm/addon-fit").FitAddon | null = null;
let transport: TermTransport | null = null;
let ro: ResizeObserver | null = null;
let resizeT: ReturnType<typeof setTimeout> | undefined;
let reconnectT: ReturnType<typeof setTimeout> | undefined;
const enc = new TextEncoder();
const visible = () => optsRef.current.visible?.() ?? true;
const sendResize = () => {
const host = hostRef.current;
if (!host || host.clientWidth === 0 || !term || !fit || !visible()) return;
try {
fit.fit();
} catch {
/* not laid out */
}
transport?.sendResize(term.cols, term.rows);
};
const scheduleReconnect = () => {
if (disposed) return;
term?.write("\r\n\x1b[90m[reconnecting…]\x1b[0m\r\n");
reconnectT = setTimeout(go, optsRef.current.reconnectMs ?? 1500);
};
const go = async () => {
if (disposed || !term) return;
transport = await optsRef.current.connect({
term,
onClosed: () => {
transport = null;
scheduleReconnect();
},
});
if (transport) {
sendResize();
if (optsRef.current.autoFocus && visible()) term.focus();
} else if (!disposed) {
scheduleReconnect();
}
};
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
term = new Terminal({
fontFamily: FONT,
fontSize: optsRef.current.fontSize ?? 13,
cursorBlink: true,
allowProposedApi: true,
scrollback: optsRef.current.scrollback ?? 5000,
theme: optsRef.current.theme ?? TERMINAL_THEME,
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
if (hostRef.current.clientWidth > 0) {
try {
fit.fit();
} catch {
/* not laid out */
}
}
term.onData((d) => transport?.sendInput(enc.encode(d)));
// Debounced: a pull-out / tab animates open, firing the observer per pixel.
ro = new ResizeObserver(() => {
clearTimeout(resizeT);
resizeT = setTimeout(sendResize, 120);
});
ro.observe(hostRef.current);
refitRef.current = () => {
sendResize();
if (optsRef.current.autoFocus && visible()) term?.focus();
};
void go();
})();
return () => {
disposed = true;
refitRef.current = () => {};
clearTimeout(resizeT);
clearTimeout(reconnectT);
transport?.dispose();
ro?.disconnect();
term?.dispose();
};
// The connector/visible callbacks are read via optsRef; `deps` is the caller's
// intentional re-run trigger (e.g. [agent.id, session] or [nodeId]).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { hostRef, refit: () => refitRef.current() };
}
// ── Transports ───────────────────────────────────────────────────────────────
/** A plain WebSocket transport: binary frames = keystrokes/PTY bytes, a JSON
* `{type:"resize"}` control frame for size. `getUrl` mints the ticket + builds
* the wss URL. */
export function wsConnector(getUrl: () => Promise<string | null>): TermConnector {
return ({ term, onClosed }) =>
new Promise<TermTransport | null>((resolve) => {
let opened = false;
void getUrl().then((url) => {
if (!url) {
resolve(null);
return;
}
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
opened = true;
resolve({
sendInput: (d) => {
if (ws.readyState === WebSocket.OPEN) ws.send(d);
},
sendResize: (cols, rows) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols, rows }));
},
dispose: () => ws.close(),
});
};
ws.onmessage = (e) => {
if (typeof e.data === "string") term.write(e.data);
else term.write(new Uint8Array(e.data as ArrayBuffer));
};
ws.onerror = () => ws.close();
ws.onclose = () => {
if (!opened) resolve(null);
else onClosed();
};
});
});
}
const ICE_SERVERS: RTCIceServer[] = [{ urls: ["stun:stun.l.google.com:19302"] }];
/** WebRTC transport for a fleet node's host PTY: tries a direct DataChannel
* (browser↔node, LAN speed) and falls back to the gateway WS relay if no direct
* path forms within ~2.5s. The same WS carries the WebRTC signaling. `onMode`
* reports the live transport so callers can show a direct/relayed indicator. */
export function nodeWebrtcConnector(nodeId: string, onMode?: (m: "connecting" | "direct" | "relayed") => void): TermConnector {
return ({ term, onClosed }) =>
new Promise<TermTransport | null>((resolve) => {
let settled = false;
let mode: "connecting" | "direct" | "relayed" = "connecting";
let ws: WebSocket | null = null;
let pc: RTCPeerConnection | null = null;
let dc: RTCDataChannel | null = null;
let raceT: ReturnType<typeof setTimeout> | undefined;
let remoteSet = false;
const pendingIce: RTCIceCandidateInit[] = [];
const inputQueue: Uint8Array<ArrayBuffer>[] = [];
const setMode = (m: typeof mode) => {
mode = m;
onMode?.(m);
};
// Keystrokes route to the live transport; buffered while still connecting
// (the server only PTY-relays once direct/fallback is chosen).
const flush = () => {
for (const b of inputQueue.splice(0)) {
if (mode === "direct" && dc?.readyState === "open") dc.send(b);
else if (ws?.readyState === WebSocket.OPEN) ws.send(b);
}
};
const sendResizeNow = () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
const transport: TermTransport = {
sendInput: (d) => {
if (mode === "direct" && dc?.readyState === "open") dc.send(d);
else if (mode === "relayed" && ws?.readyState === WebSocket.OPEN) ws.send(d);
else inputQueue.push(d);
},
sendResize: () => {
if (mode !== "connecting") sendResizeNow();
},
dispose: () => {
clearTimeout(raceT);
try {
dc?.close();
pc?.close();
} catch {
/* ignore */
}
ws?.close();
},
};
const ready = () => {
if (!settled) {
settled = true;
resolve(transport);
}
};
const goRelayed = () => {
if (mode !== "connecting") return;
clearTimeout(raceT);
setMode("relayed");
try {
dc?.close();
pc?.close();
} catch {
/* ignore */
}
dc = null;
pc = null;
ws?.send(JSON.stringify({ type: "fallback", cols: term.cols, rows: term.rows }));
flush();
};
const startWebrtc = () => {
if (typeof RTCPeerConnection === "undefined") {
goRelayed();
return;
}
try {
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
dc = pc.createDataChannel("term", { ordered: true });
dc.binaryType = "arraybuffer";
dc.onopen = () => {
if (mode !== "connecting") return;
clearTimeout(raceT);
setMode("direct");
flush();
sendResizeNow();
};
dc.onmessage = (e) => {
if (typeof e.data === "string") term.write(e.data);
else term.write(new Uint8Array(e.data as ArrayBuffer));
};
dc.onclose = () => {
if (mode === "direct") ws?.close();
};
pc.onicecandidate = (e) => {
if (e.candidate && ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "webrtc_ice", candidate: e.candidate.candidate, sdp_mid: e.candidate.sdpMid, sdp_mline_index: e.candidate.sdpMLineIndex }));
}
};
pc.onconnectionstatechange = () => {
if (pc?.connectionState === "failed") goRelayed();
};
pc.createOffer()
.then((offer) => pc!.setLocalDescription(offer).then(() => ws?.send(JSON.stringify({ type: "webrtc_offer", sdp: offer.sdp }))))
.catch(() => goRelayed());
raceT = setTimeout(goRelayed, 2500);
} catch {
goRelayed();
}
};
const onSignal = (m: { type: string; sdp?: string; candidate?: string; sdp_mid?: string | null; sdp_mline_index?: number | null }) => {
if (!pc) return;
if (m.type === "webrtc_answer" && m.sdp) {
pc.setRemoteDescription({ type: "answer", sdp: m.sdp })
.then(() => {
remoteSet = true;
for (const c of pendingIce.splice(0)) pc?.addIceCandidate(c).catch(() => {});
})
.catch(() => goRelayed());
} else if (m.type === "webrtc_ice" && m.candidate != null) {
const cand: RTCIceCandidateInit = { candidate: m.candidate, sdpMid: m.sdp_mid ?? undefined, sdpMLineIndex: m.sdp_mline_index ?? undefined };
if (remoteSet) pc.addIceCandidate(cand).catch(() => {});
else pendingIce.push(cand);
} else if (m.type === "webrtc_failed") {
goRelayed();
}
};
void fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" })
.then((r) => (r.ok ? r.json() : {}))
.then((body: { ticket?: string }) => {
if (!body.ticket) {
resolve(null);
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(body.ticket)}`);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
ready();
startWebrtc();
};
ws.onmessage = (e) => {
if (typeof e.data === "string") {
try {
onSignal(JSON.parse(e.data));
} catch {
/* malformed signaling */
}
} else {
term.write(new Uint8Array(e.data as ArrayBuffer)); // relayed PTY output
}
};
ws.onclose = () => {
clearTimeout(raceT);
if (!settled) resolve(null);
else onClosed();
};
})
.catch(() => resolve(null));
});
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Deploy ClawMates: build the server, frontend, node daemon, AND the agent runtime
# images on the build host (tank), then load + recreate on the gateway. The agent
# images (agent-base / agent-browser / agent-terminal) are locally-built dev images
# — not in any registry — so a `docker pull` can't fetch them. This script loads
# them onto the gateway AND every fleet node, so agent/terminal containers never
# 404 with "No such image" (the failure mode this script exists to prevent).
#
# Usage: scripts/deploy.sh # full deploy
# IMAGES_ONLY=1 scripts/deploy.sh # just (re)build + load the agent images
#
# Override hosts via env: BUILD_HOST, GW, NODES, TAG.
set -euo pipefail
BUILD_HOST=${BUILD_HOST:-osobh@tank} # builds the images + the linux daemon
GW=${GW:-gw-04} # the gateway (server + frontend + postgres)
NODES=${NODES:-"morpheus osobh@tank"} # fleet nodes that provision agent containers
TAG=${TAG:-latest}
AGENT_IMAGES=(agent-base agent-browser agent-terminal)
load() { ssh "$BUILD_HOST" "docker save $1" | ssh "$2" "docker load"; }
# Load only if the target lacks the exact image (skips re-transferring unchanged
# multi-hundred-MB agent images to every node on each code deploy).
load_if_changed() {
local lid rid
lid=$(ssh "$BUILD_HOST" "docker image inspect -f '{{.Id}}' $1 2>/dev/null" || true)
rid=$(ssh "$2" "docker image inspect -f '{{.Id}}' $1 2>/dev/null" || true)
if [ -n "$lid" ] && [ "$lid" = "$rid" ]; then
echo " (unchanged — skip)"
return 0
fi
load "$1" "$2"
}
echo "→ sync to $BUILD_HOST"
rsync -az --delete --exclude target/ --exclude node_modules/ --exclude .git/ \
--exclude '.next/' --exclude 'frontend/public/dl/' --exclude '**/.DS_Store' \
./ "$BUILD_HOST":~/clawmates/
if [ -z "${IMAGES_ONLY:-}" ]; then
echo "→ build server + frontend + daemon on $BUILD_HOST"
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
export PATH=$HOME/.cargo/bin:$PATH CARGO_NET_GIT_FETCH_WITH_CLI=true SQLX_OFFLINE=true
cargo build --release -p clawmates-node
cp target/release/clawmates-node frontend/public/dl/clawmates-node-linux-amd64
docker build -f images/server.Dockerfile -t clawmates/server:'"$TAG"' .
docker build -f images/frontend.Dockerfile -t clawmates/frontend:'"$TAG"' .'
fi
echo "→ build agent runtime images on $BUILD_HOST"
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
for i in '"${AGENT_IMAGES[*]}"'; do
docker build -f images/$i/Dockerfile -t clawmates/$i:dev images/$i/
done'
if [ -z "${IMAGES_ONLY:-}" ]; then
echo "→ load + recreate server + frontend on $GW"
ssh "$GW" "docker tag clawmates/server:$TAG clawmates/server:rollback 2>/dev/null || true
docker tag clawmates/frontend:$TAG clawmates/frontend:rollback 2>/dev/null || true"
load "clawmates/server:$TAG" "$GW"
load "clawmates/frontend:$TAG" "$GW"
fi
echo "→ load agent runtime images onto $GW + every fleet node"
for img in "${AGENT_IMAGES[@]}"; do
for host in "$GW" $NODES; do
printf ' %-26s → %s\n' "clawmates/$img:dev" "$host"
load_if_changed "clawmates/$img:dev" "$host"
done
done
if [ -z "${IMAGES_ONLY:-}" ]; then
echo "→ recreate server + frontend"
ssh "$GW" "cd /root/clawmates && docker-compose -p clawmates up -d --force-recreate server frontend"
sleep 6
curl -s -o /dev/null -w "edge HTTP %{http_code}\n" -m 10 https://clawmates.work/ || true
fi
echo "✓ deploy complete"