feat: progress notes UI, search/filter, chat backend relay (HQ-21)
- Add progress note input to TaskDetailPanel (textarea + Cmd+Enter submit) - Add addProgressNote API function - Add search bar and priority filter to Queue page - Include chat backend: WebSocket relay (gateway-relay.ts), chat routes (chat.ts) - Chat frontend updated to connect via backend relay (/api/chat/ws)
This commit is contained in:
244
backend/src/lib/gateway-relay.ts
Normal file
244
backend/src/lib/gateway-relay.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Gateway WebSocket Relay
|
||||
*
|
||||
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
|
||||
* Dashboard clients connect through the backend (authenticated via BetterAuth),
|
||||
* and messages are relayed bidirectionally.
|
||||
*
|
||||
* Architecture:
|
||||
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
|
||||
* (BetterAuth) (relay) (token auth)
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
|
||||
|
||||
type GatewayState = "disconnected" | "connecting" | "connected";
|
||||
type MessageHandler = (msg: any) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextReqId() {
|
||||
return `relay-${++reqCounter}`;
|
||||
}
|
||||
|
||||
class GatewayConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: GatewayState = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
private eventListeners = new Set<MessageHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.state === "connecting") return;
|
||||
this.state = "connecting";
|
||||
|
||||
if (!GATEWAY_TOKEN) {
|
||||
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
|
||||
this.state = "disconnected";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(GATEWAY_URL);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to create WebSocket:", e);
|
||||
this.state = "disconnected";
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
console.log("[gateway-relay] WebSocket open, sending handshake...");
|
||||
const connectId = nextReqId();
|
||||
this.sendRaw({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "dashboard-relay",
|
||||
displayName: "Hammer Dashboard Relay",
|
||||
version: "1.0.0",
|
||||
platform: "server",
|
||||
mode: "webchat",
|
||||
instanceId: `relay-${process.pid}-${Date.now()}`,
|
||||
},
|
||||
auth: {
|
||||
token: GATEWAY_TOKEN,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for handshake response
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
console.log("[gateway-relay] Connected to gateway");
|
||||
this.state = "connected";
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("[gateway-relay] Handshake failed:", err);
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
},
|
||||
timer: setTimeout(() => {
|
||||
if (this.pendingRequests.has(connectId)) {
|
||||
this.pendingRequests.delete(connectId);
|
||||
console.error("[gateway-relay] Handshake timeout");
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
}
|
||||
}, 15000),
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
this.handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to parse message:", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("[gateway-relay] Disconnected");
|
||||
this.state = "disconnected";
|
||||
this.ws = null;
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Connection closed"));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("error", (e) => {
|
||||
console.error("[gateway-relay] WebSocket error");
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.shouldReconnect && this.state === "disconnected") {
|
||||
this.connect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sendRaw(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok !== false) {
|
||||
pending.resolve(msg.payload ?? msg.result ?? {});
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
// Forward events to all listeners
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Event listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Gateway not connected");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextReqId();
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.sendRaw({ type: "req", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(handler: MessageHandler): () => void {
|
||||
this.eventListeners.add(handler);
|
||||
return () => this.eventListeners.delete(handler);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.state = "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton gateway connection
|
||||
export const gateway = new GatewayConnection();
|
||||
|
||||
/**
|
||||
* Send a chat message to the gateway
|
||||
*/
|
||||
export async function chatSend(sessionKey: string, message: string): Promise<any> {
|
||||
return gateway.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history from the gateway
|
||||
*/
|
||||
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
|
||||
return gateway.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress chat response
|
||||
*/
|
||||
export async function chatAbort(sessionKey: string): Promise<any> {
|
||||
return gateway.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from the gateway
|
||||
*/
|
||||
export async function sessionsList(limit = 50): Promise<any> {
|
||||
return gateway.request("sessions.list", { limit });
|
||||
}
|
||||
Reference in New Issue
Block a user