From dc67e22bbb9b615ae3ea8b4cadafa835d4626c2e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 8 Apr 2026 15:31:41 -0500 Subject: [PATCH] Harden SME chat and gateway flow - Simplify SME message persistence and UI wiring - Tighten OpenClaw gateway test handling and result rendering - Update v0.19.0 release notes --- .../src/persistence/Layers/SmeMessages.ts | 101 ++--- .../src/sme/Layers/SmeChatServiceLive.ts | 9 +- apps/server/src/wsServer.ts | 375 ++++++++---------- apps/web/src/components/sme/SmeChatShell.tsx | 1 - .../src/components/sme/SmeChatWorkspace.tsx | 4 - .../src/components/sme/SmeMessageBubble.tsx | 1 - apps/web/src/routes/_chat.settings.tsx | 7 +- docs/releases/v0.19.0.md | 3 + 8 files changed, 223 insertions(+), 278 deletions(-) diff --git a/apps/server/src/persistence/Layers/SmeMessages.ts b/apps/server/src/persistence/Layers/SmeMessages.ts index 67d039147..db523a992 100644 --- a/apps/server/src/persistence/Layers/SmeMessages.ts +++ b/apps/server/src/persistence/Layers/SmeMessages.ts @@ -1,37 +1,13 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema } from "effect"; - -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { Effect, Layer } from "effect"; +import { toPersistenceSqlError } from "../Errors.ts"; import { - DeleteSmeMessagesByConversationInput, - ListSmeMessagesByConversationInput, SmeMessageRepository, - SmeMessageRow, type SmeMessageRepositoryShape, + type SmeMessageRow, } from "../Services/SmeMessages.ts"; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -/** - * DB row schema: isStreaming stored as INTEGER 0/1, mapped to/from boolean. - */ -const SmeMessageDbRow = Schema.Struct({ - messageId: SmeMessageRow.fields.messageId, - conversationId: SmeMessageRow.fields.conversationId, - role: SmeMessageRow.fields.role, - text: SmeMessageRow.fields.text, - isStreaming: Schema.Number, - createdAt: SmeMessageRow.fields.createdAt, - updatedAt: SmeMessageRow.fields.updatedAt, -}); - const makeSmeMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -56,40 +32,51 @@ const makeSmeMessageRepository = Effect.gen(function* () { }).pipe(Effect.mapError(toPersistenceSqlError("SmeMessageRepository.upsert:query"))); const listByConversationId: SmeMessageRepositoryShape["listByConversationId"] = (input) => - Effect.gen(function* () { - const rows = yield* sql` - SELECT - message_id AS "messageId", - conversation_id AS "conversationId", - role, - text, - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM sme_messages - WHERE conversation_id = ${input.conversationId} - ORDER BY created_at ASC - `; - return rows.map((r: any) => ({ - messageId: r.messageId, - conversationId: r.conversationId, - role: r.role, - text: r.text, - isStreaming: r.isStreaming !== 0, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })) as any; - }).pipe( + sql` + SELECT + message_id AS "messageId", + conversation_id AS "conversationId", + role, + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM sme_messages + WHERE conversation_id = ${input.conversationId} + ORDER BY created_at ASC + `.pipe( + Effect.map((rows) => + ( + rows as ReadonlyArray<{ + messageId: string; + conversationId: string; + role: string; + text: string; + isStreaming: number; + createdAt: string; + updatedAt: string; + }> + ).map( + (r) => + ({ + messageId: r.messageId as SmeMessageRow["messageId"], + conversationId: r.conversationId as SmeMessageRow["conversationId"], + role: r.role, + text: r.text, + isStreaming: r.isStreaming !== 0, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }) as SmeMessageRow, + ), + ), Effect.mapError(toPersistenceSqlError("SmeMessageRepository.listByConversationId:query")), ); const deleteByConversationId: SmeMessageRepositoryShape["deleteByConversationId"] = (input) => - Effect.gen(function* () { - yield* sql` - DELETE FROM sme_messages - WHERE conversation_id = ${input.conversationId} - `; - }).pipe( + sql` + DELETE FROM sme_messages + WHERE conversation_id = ${input.conversationId} + `.pipe( Effect.mapError(toPersistenceSqlError("SmeMessageRepository.deleteByConversationId:query")), ); diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index 90b83d504..7ab3d7f0d 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -7,18 +7,13 @@ * @module SmeChatServiceLive */ import Anthropic from "@anthropic-ai/sdk"; -import type { - SmeConversation, - SmeKnowledgeDocument, - SmeMessage, - SmeMessageEvent, -} from "@okcode/contracts"; +import type { SmeConversation, SmeKnowledgeDocument, SmeMessage } from "@okcode/contracts"; import { SME_MAX_DOCUMENT_SIZE_BYTES, SME_MAX_DOCUMENTS_PER_PROJECT, SME_MAX_CONVERSATIONS_PER_PROJECT, } from "@okcode/contracts"; -import { DateTime, Effect, Fiber, Layer, Option, Random, Ref } from "effect"; +import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index b068f96c8..55589aff3 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -114,118 +114,108 @@ const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000; function testOpenclawGateway( input: TestOpenclawGatewayInput, -): Effect.Effect { - return Effect.gen(function* () { - const overallStart = Date.now(); - const steps: TestOpenclawGatewayStep[] = []; - let ws: NodeWebSocket | null = null; - let rpcId = 1; - let serverInfo: { version?: string; sessionId?: string } | undefined; - - const pushStep = ( - name: string, - status: "pass" | "fail" | "skip", - durationMs: number, - detail?: string, - ) => { - steps.push({ name, status, durationMs, ...(detail ? { detail } : {}) }); - }; +): Effect.Effect { + return Effect.tryPromise({ + try: () => { + const run = async (): Promise => { + const overallStart = Date.now(); + const steps: TestOpenclawGatewayStep[] = []; + let ws: NodeWebSocket | null = null; + let rpcId = 1; + const serverInfo: { version?: string; sessionId?: string } = {}; + + const pushStep = ( + name: string, + status: "pass" | "fail" | "skip", + durationMs: number, + detail?: string, + ) => { + steps.push({ name, status, durationMs, ...(detail ? { detail } : {}) }); + }; - // ── Helper: send a JSON-RPC 2.0 request and wait for a response ── - const sendRpc = ( - socket: NodeWebSocket, - method: string, - params?: Record, - ): Promise<{ result?: unknown; error?: { code: number; message: string } }> => - new Promise((resolve, reject) => { - const id = rpcId++; - const timeout = setTimeout( - () => - reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)), - OPENCLAW_TEST_RPC_TIMEOUT_MS, - ); + const fail = (error: string): TestOpenclawGatewayResult => ({ + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error, + }); - const handler = (data: NodeWebSocket.Data) => { - try { - const msg = JSON.parse(String(data)) as { - id?: number; - result?: unknown; - error?: { code: number; message: string }; + const sendRpc = ( + socket: NodeWebSocket, + method: string, + params?: Record, + ): Promise<{ result?: unknown; error?: { code: number; message: string } }> => + new Promise((resolve, reject) => { + const id = rpcId++; + const timeout = setTimeout( + () => + reject( + new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`), + ), + OPENCLAW_TEST_RPC_TIMEOUT_MS, + ); + + const handler = (data: NodeWebSocket.Data) => { + try { + const msg = JSON.parse(String(data)) as { + id?: number; + result?: unknown; + error?: { code: number; message: string }; + }; + if (msg.id === id) { + clearTimeout(timeout); + socket.off("message", handler); + resolve({ + ...(msg.result !== undefined ? { result: msg.result } : {}), + ...(msg.error !== undefined ? { error: msg.error } : {}), + }); + } + } catch { + // Ignore non-JSON messages. + } }; - if (msg.id === id) { - clearTimeout(timeout); - socket.off("message", handler); - const payload: { result?: unknown; error?: { code: number; message: string } } = {}; - if ("result" in msg) payload.result = msg.result; - if (msg.error !== undefined) payload.error = msg.error; - resolve(payload); - } - } catch { - // Ignore non-JSON messages + + socket.on("message", handler); + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + id, + }), + ); + }); + + try { + const urlStart = Date.now(); + const gatewayUrl = input.gatewayUrl.trim(); + if (!gatewayUrl) { + pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); + return fail("Gateway URL is empty."); } - }; - socket.on("message", handler); - socket.send( - JSON.stringify({ - jsonrpc: "2.0", - method, - ...(params !== undefined ? { params } : {}), - id, - }), - ); - }); + const parsedUrl = URL.canParse(gatewayUrl) ? new URL(gatewayUrl) : null; + if (!parsedUrl) { + pushStep("URL validation", "fail", Date.now() - urlStart, "Malformed URL."); + return fail("Malformed URL."); + } - try { - // ── Step 1: URL validation ────────────────────────────────────── - const urlStart = Date.now(); - const gatewayUrl = input.gatewayUrl.trim(); - if (!gatewayUrl) { - pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: "Gateway URL is empty.", - }; - } - const parsedUrl = URL.canParse(gatewayUrl) ? new URL(gatewayUrl) : null; - if (!parsedUrl) { - pushStep("URL validation", "fail", Date.now() - urlStart, "Malformed URL."); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: "Malformed URL.", - }; - } - if (!["ws:", "wss:"].includes(parsedUrl.protocol)) { - pushStep( - "URL validation", - "fail", - Date.now() - urlStart, - `Invalid protocol "${parsedUrl.protocol}". Expected ws: or wss:.`, - ); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: `Invalid protocol "${parsedUrl.protocol}".`, - }; - } - pushStep( - "URL validation", - "pass", - Date.now() - urlStart, - `${parsedUrl.protocol}//${parsedUrl.host}`, - ); + if (!["ws:", "wss:"].includes(parsedUrl.protocol)) { + const detail = `Invalid protocol "${parsedUrl.protocol}". Expected ws: or wss:.`; + pushStep("URL validation", "fail", Date.now() - urlStart, detail); + return fail(detail); + } - // ── Step 2: WebSocket connect ─────────────────────────────────── - const connectStart = Date.now(); - try { - ws = yield* Effect.tryPromise( - () => - new Promise((resolve, reject) => { + pushStep( + "URL validation", + "pass", + Date.now() - urlStart, + `${parsedUrl.protocol}//${parsedUrl.host}`, + ); + + const connectStart = Date.now(); + try { + ws = await new Promise((resolve, reject) => { const socket = new NodeWebSocket(gatewayUrl); const timeout = setTimeout(() => { socket.close(); @@ -242,114 +232,87 @@ function testOpenclawGateway( clearTimeout(timeout); reject(err); }); - }), - ); - pushStep( - "WebSocket connect", - "pass", - Date.now() - connectStart, - `Connected in ${Date.now() - connectStart}ms`, - ); - } catch (err) { - const detail = err instanceof Error ? err.message : "Connection failed."; - pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: detail, - }; - } - ws = wsResult.right; - pushStep( - "WebSocket connect", - "pass", - Date.now() - connectStart, - `Connected in ${Date.now() - connectStart}ms`, - ); + }); + pushStep( + "WebSocket connect", + "pass", + Date.now() - connectStart, + `Connected in ${Date.now() - connectStart}ms`, + ); + } catch (err) { + const detail = err instanceof Error ? err.message : "Connection failed."; + pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail); + return fail(detail); + } + + if (input.password) { + const authStart = Date.now(); + try { + const authResult = await sendRpc(ws, "auth.authenticate", { + password: input.password, + }); + if (authResult.error) { + const detail = `RPC error ${authResult.error.code}: ${authResult.error.message}`; + pushStep("Authentication", "fail", Date.now() - authStart, detail); + return fail(`Authentication failed: ${authResult.error.message}`); + } + pushStep("Authentication", "pass", Date.now() - authStart, "Authenticated."); + } catch (err) { + const detail = err instanceof Error ? err.message : "Authentication request failed."; + pushStep("Authentication", "fail", Date.now() - authStart, detail); + return fail(detail); + } + } + + const sessionStart = Date.now(); + try { + const sessionResult = await sendRpc(ws, "session.create"); + if (sessionResult.error) { + const detail = `RPC error ${sessionResult.error.code}: ${sessionResult.error.message}`; + pushStep("Session create", "fail", Date.now() - sessionStart, detail); + return fail(`Session creation failed: ${sessionResult.error.message}`); + } + + const result = (sessionResult.result ?? {}) as Record; + const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; + const version = typeof result.version === "string" ? result.version : undefined; + if (version !== undefined) { + serverInfo.version = version; + } + if (sessionId !== undefined) { + serverInfo.sessionId = sessionId; + } + pushStep( + "Session create", + "pass", + Date.now() - sessionStart, + sessionId ? `Session ID: ${sessionId}` : "Session created.", + ); + } catch (err) { + const detail = err instanceof Error ? err.message : "Session creation failed."; + pushStep("Session create", "fail", Date.now() - sessionStart, detail); + return fail(detail); + } - // ── Step 3: Authentication ────────────────────────────────────── - if (input.password) { - const authStart = Date.now(); - const authResult = yield* Effect.either( - Effect.tryPromise(() => sendRpc(ws!, "auth.authenticate", { password: input.password })), - ); - if (authResult._tag === "Left") { - const detail = - authResult.left instanceof Error - ? authResult.left.message - : "Authentication request failed."; - pushStep("Authentication", "fail", Date.now() - authStart, detail); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: detail, - }; - } - if (authResult.right.error) { - pushStep( - "Authentication", - "fail", - Date.now() - authStart, - `RPC error ${authResult.right.error.code}: ${authResult.right.error.message}`, - ); return { - success: false, + success: true, steps, totalDurationMs: Date.now() - overallStart, - error: `Authentication failed: ${authResult.right.error.message}`, + ...(Object.keys(serverInfo).length > 0 ? { serverInfo } : {}), }; + } finally { + if (ws && ws.readyState === NodeWebSocket.OPEN) { + ws.close(); + } } - const result = (response.result ?? {}) as Record; - const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; - const version = typeof result.version === "string" ? result.version : undefined; - serverInfo = { - ...(version !== undefined ? { version } : {}), - ...(sessionId !== undefined ? { sessionId } : {}), - }; - pushStep( - "Session create", - "fail", - Date.now() - sessionStart, - `RPC error ${sessionResult.right.error.code}: ${sessionResult.right.error.message}`, - ); - return { - success: false, - steps, - totalDurationMs: Date.now() - overallStart, - error: `Session creation failed: ${sessionResult.right.error.message}`, - }; - } - const result = (sessionResult.right.result ?? {}) as Record; - const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; - const version = typeof result.version === "string" ? result.version : undefined; - serverInfo = {}; - if (version !== undefined) { - serverInfo.version = version; - } - if (sessionId !== undefined) { - serverInfo.sessionId = sessionId; - } - pushStep( - "Session create", - "pass", - Date.now() - sessionStart, - sessionId ? `Session ID: ${sessionId}` : "Session created.", - ); - - return { - success: true, - steps, - totalDurationMs: Date.now() - overallStart, - ...(serverInfo ? { serverInfo } : {}), }; - } finally { - // Always close the test WebSocket. - if (ws && ws.readyState === NodeWebSocket.OPEN) { - ws.close(); - } - } + + return run(); + }, + catch: (cause) => + new RouteRequestError({ + message: `OpenClaw gateway test failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }), }); } diff --git a/apps/web/src/components/sme/SmeChatShell.tsx b/apps/web/src/components/sme/SmeChatShell.tsx index 540d1c93d..a560a6e02 100644 --- a/apps/web/src/components/sme/SmeChatShell.tsx +++ b/apps/web/src/components/sme/SmeChatShell.tsx @@ -83,7 +83,6 @@ export function SmeChatShell({ {/* Center - chat workspace */}
setKnowledgePanelOpen((v) => !v)} knowledgePanelOpen={knowledgePanelOpen} diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx index c0d108180..c24cc5c75 100644 --- a/apps/web/src/components/sme/SmeChatWorkspace.tsx +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -1,22 +1,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BookOpenIcon, SendIcon } from "lucide-react"; import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; - -import type { Project } from "~/types"; import { ensureNativeApi } from "~/nativeApi"; import { useSmeStore } from "~/smeStore"; import { SmeMessageBubble } from "./SmeMessageBubble"; interface SmeChatWorkspaceProps { - project: Project; conversationId: string | null; onToggleKnowledge: () => void; knowledgePanelOpen: boolean; } export function SmeChatWorkspace({ - project, conversationId, onToggleKnowledge, knowledgePanelOpen, diff --git a/apps/web/src/components/sme/SmeMessageBubble.tsx b/apps/web/src/components/sme/SmeMessageBubble.tsx index 7ec53fdc4..ac1ed4551 100644 --- a/apps/web/src/components/sme/SmeMessageBubble.tsx +++ b/apps/web/src/components/sme/SmeMessageBubble.tsx @@ -9,7 +9,6 @@ interface SmeMessageBubbleProps { export function SmeMessageBubble({ message }: SmeMessageBubbleProps) { const isUser = message.role === "user"; - const isAssistant = message.role === "assistant"; return (
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index bf49bb7db..6e252e112 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -2189,8 +2189,11 @@ function SettingsRouteView() { {/* Step-by-step results */} {openclawTestResult.steps.length > 0 && (
- {openclawTestResult.steps.map((step, i) => ( -
+ {openclawTestResult.steps.map((step) => ( +
{step.status === "pass" && ( )} diff --git a/docs/releases/v0.19.0.md b/docs/releases/v0.19.0.md index 695e3268d..0254ac6f0 100644 --- a/docs/releases/v0.19.0.md +++ b/docs/releases/v0.19.0.md @@ -1,6 +1,7 @@ # OK Code v0.19.0 ## Highlights + - Release workflow hardening and release-pipeline reliability fixes. - Git/PR flow fixes to stop stale branch metadata from hijacking push and PR creation. - Release-preflight fixes for server snapshot decoding and exact optional property checks. @@ -13,8 +14,10 @@ - Fixed formatting drift and release-preflight blockers discovered during rollout. ## Desktop downloads + See the attached assets on this GitHub Release. ## Notes + - See the attached assets on this GitHub Release. - This release includes release-process fixes landed during the rollout itself.