From 0ae5d81debe3932f649eb9e9c55c8a5b483589de Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 17:09:21 -0600 Subject: [PATCH 01/25] fix(ui): show agent issue title as two lines on dashboard Change the issue title in agent run cards from single-line truncate to line-clamp-2 so titles always occupy two lines for consistent card height. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/ActiveAgentsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 2910b68d..5332eb8a 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -461,7 +461,7 @@ function AgentRunCard({ Date: Sat, 7 Mar 2026 08:26:49 -0600 Subject: [PATCH 02/25] feat(ui): add agent creation choice modal and full-page config Replace the direct agent config dialog with a choice modal offering two paths: "Ask the CEO to create a new agent" (opens pre-filled issue) or "I want advanced configuration myself" (navigates to /agents/new). - Extend NewIssueDefaults with title/description for pre-fill support - Add /agents/new route with full-page agent configuration form - NewAgentDialog now shows CEO recommendation modal Co-Authored-By: Claude Opus 4.6 --- ui/src/App.tsx | 3 + ui/src/components/NewAgentDialog.tsx | 348 ++++----------------------- ui/src/components/NewIssueDialog.tsx | 13 +- ui/src/context/DialogContext.tsx | 2 + ui/src/pages/NewAgent.tsx | 289 ++++++++++++++++++++++ 5 files changed, 357 insertions(+), 298 deletions(-) create mode 100644 ui/src/pages/NewAgent.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 02baefb6..18df83d8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { OrgChart } from "./pages/OrgChart"; +import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; @@ -101,6 +102,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -214,6 +216,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index a5392716..b3ab9233 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,53 +1,20 @@ -import { useState, useEffect } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; -import { AGENT_ROLES } from "@paperclipai/shared"; import { Dialog, DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Minimize2, - Maximize2, - Shield, - User, -} from "lucide-react"; -import { cn, agentUrl } from "../lib/utils"; -import { roleLabels } from "./agent-config-primitives"; -import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; -import { defaultCreateValues } from "./agent-config-defaults"; -import { getUIAdapter } from "../adapters"; -import { AgentIcon } from "./AgentIconPicker"; +import { Bot, Sparkles } from "lucide-react"; export function NewAgentDialog() { - const { newAgentOpen, closeNewAgent } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const queryClient = useQueryClient(); + const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); - const [expanded, setExpanded] = useState(true); - - // Identity - const [name, setName] = useState(""); - const [title, setTitle] = useState(""); - const [role, setRole] = useState("general"); - const [reportsTo, setReportsTo] = useState(""); - - // Config values (managed by AgentConfigForm) - const [configValues, setConfigValues] = useState(defaultCreateValues); - - // Popover states - const [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -55,287 +22,74 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { - data: adapterModels, - error: adapterModelsError, - isLoading: adapterModelsLoading, - isFetching: adapterModelsFetching, - } = useQuery({ - queryKey: - selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) - : ["agents", "none", "adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), - enabled: Boolean(selectedCompanyId) && newAgentOpen, - }); + const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); - const isFirstAgent = !agents || agents.length === 0; - const effectiveRole = isFirstAgent ? "ceo" : role; - const [formError, setFormError] = useState(null); - - // Auto-fill for CEO - useEffect(() => { - if (newAgentOpen && isFirstAgent) { - if (!name) setName("CEO"); - if (!title) setTitle("CEO"); - } - }, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps - - const createAgent = useMutation({ - mutationFn: (data: Record) => - agentsApi.hire(selectedCompanyId!, data), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); - reset(); - closeNewAgent(); - navigate(agentUrl(result.agent)); - }, - onError: (error) => { - setFormError(error instanceof Error ? error.message : "Failed to create agent"); - }, - }); - - function reset() { - setName(""); - setTitle(""); - setRole("general"); - setReportsTo(""); - setConfigValues(defaultCreateValues); - setExpanded(true); - setFormError(null); - } - - function buildAdapterConfig() { - const adapter = getUIAdapter(configValues.adapterType); - return adapter.buildAdapterConfig(configValues); - } - - function handleSubmit() { - if (!selectedCompanyId || !name.trim()) return; - setFormError(null); - if (configValues.adapterType === "opencode_local") { - const selectedModel = configValues.model.trim(); - if (!selectedModel) { - setFormError("OpenCode requires an explicit model in provider/model format."); - return; - } - if (adapterModelsError) { - setFormError( - adapterModelsError instanceof Error - ? adapterModelsError.message - : "Failed to load OpenCode models.", - ); - return; - } - if (adapterModelsLoading || adapterModelsFetching) { - setFormError("OpenCode models are still loading. Please wait and try again."); - return; - } - const discovered = adapterModels ?? []; - if (!discovered.some((entry) => entry.id === selectedModel)) { - setFormError( - discovered.length === 0 - ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModel}`, - ); - return; - } - } - createAgent.mutate({ - name: name.trim(), - role: effectiveRole, - ...(title.trim() ? { title: title.trim() } : {}), - ...(reportsTo ? { reportsTo } : {}), - adapterType: configValues.adapterType, - adapterConfig: buildAdapterConfig(), - runtimeConfig: { - heartbeat: { - enabled: configValues.heartbeatEnabled, - intervalSec: configValues.intervalSec, - wakeOnDemand: true, - cooldownSec: 10, - maxConcurrentRuns: 1, - }, - }, - budgetMonthlyCents: 0, + function handleAskCeo() { + closeNewAgent(); + openNewIssue({ + assigneeAgentId: ceoAgent?.id, + title: "Create a new agent", + description: "(type in what kind of agent you want here)", }); } - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } + function handleAdvancedConfig() { + closeNewAgent(); + navigate("/agents/new"); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); - return ( { - if (!open) { reset(); closeNewAgent(); } + if (!open) closeNewAgent(); }} > {/* Header */}
-
- {selectedCompany && ( - - {selectedCompany.name.slice(0, 3).toUpperCase()} - - )} - - New agent -
-
- - -
-
- -
- {/* Name */} -
- setName(e.target.value)} - autoFocus - /> -
- - {/* Title */} -
- setTitle(e.target.value)} - /> -
- - {/* Property chips: Role + Reports To */} -
- {/* Role */} - - - - - - {AGENT_ROLES.map((r) => ( - - ))} - - - - {/* Reports To */} - - - - - - - {(agents ?? []).map((a) => ( - - ))} - - -
- - {/* Shared config form (adapter + heartbeat) */} - setConfigValues((prev) => ({ ...prev, ...patch }))} - adapterModels={adapterModels} - /> -
- - {/* Footer */} -
- - {isFirstAgent ? "This will be the CEO" : ""} - -
- {formError && ( -
{formError}
- )} -
+ Add a new agent
+ +
+ {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
+ + + + {/* Advanced link */} +
+ +
+
); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 35fab02e..de0ea73f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -332,7 +332,18 @@ export function NewIssueDialog() { setDialogCompanyId(selectedCompanyId); const draft = loadDraft(); - if (draft && draft.title.trim()) { + if (newIssueDefaults.title) { + setTitle(newIssueDefaults.title); + setDescription(newIssueDefaults.description ?? ""); + setStatus(newIssueDefaults.status ?? "todo"); + setPriority(newIssueDefaults.priority ?? ""); + setProjectId(newIssueDefaults.projectId ?? ""); + setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeModelOverride(""); + setAssigneeThinkingEffort(""); + setAssigneeChrome(false); + setAssigneeUseProjectWorkspace(true); + } else if (draft && draft.title.trim()) { setTitle(draft.title); setDescription(draft.description); setStatus(draft.status || "todo"); diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index b21e6b8a..ef7b12b8 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,8 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + title?: string; + description?: string; } interface NewGoalDefaults { diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx new file mode 100644 index 00000000..a583ece2 --- /dev/null +++ b/ui/src/pages/NewAgent.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@/lib/router"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; +import { AGENT_ROLES } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Shield, User } from "lucide-react"; +import { cn, agentUrl } from "../lib/utils"; +import { roleLabels } from "../components/agent-config-primitives"; +import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; +import { defaultCreateValues } from "../components/agent-config-defaults"; +import { getUIAdapter } from "../adapters"; +import { AgentIcon } from "../components/AgentIconPicker"; + +export function NewAgent() { + const { selectedCompanyId, selectedCompany } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [role, setRole] = useState("general"); + const [reportsTo, setReportsTo] = useState(""); + const [configValues, setConfigValues] = useState(defaultCreateValues); + const [roleOpen, setRoleOpen] = useState(false); + const [reportsToOpen, setReportsToOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { + data: adapterModels, + error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) + : ["agents", "none", "adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + enabled: Boolean(selectedCompanyId), + }); + + const isFirstAgent = !agents || agents.length === 0; + const effectiveRole = isFirstAgent ? "ceo" : role; + + useEffect(() => { + setBreadcrumbs([ + { label: "Agents", href: "/agents" }, + { label: "New Agent" }, + ]); + }, [setBreadcrumbs]); + + useEffect(() => { + if (isFirstAgent) { + if (!name) setName("CEO"); + if (!title) setTitle("CEO"); + } + }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + + const createAgent = useMutation({ + mutationFn: (data: Record) => + agentsApi.hire(selectedCompanyId!, data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); + navigate(agentUrl(result.agent)); + }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to create agent"); + }, + }); + + function buildAdapterConfig() { + const adapter = getUIAdapter(configValues.adapterType); + return adapter.buildAdapterConfig(configValues); + } + + function handleSubmit() { + if (!selectedCompanyId || !name.trim()) return; + setFormError(null); + if (configValues.adapterType === "opencode_local") { + const selectedModel = configValues.model.trim(); + if (!selectedModel) { + setFormError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setFormError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + if (adapterModelsLoading || adapterModelsFetching) { + setFormError("OpenCode models are still loading. Please wait and try again."); + return; + } + const discovered = adapterModels ?? []; + if (!discovered.some((entry) => entry.id === selectedModel)) { + setFormError( + discovered.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModel}`, + ); + return; + } + } + createAgent.mutate({ + name: name.trim(), + role: effectiveRole, + ...(title.trim() ? { title: title.trim() } : {}), + ...(reportsTo ? { reportsTo } : {}), + adapterType: configValues.adapterType, + adapterConfig: buildAdapterConfig(), + runtimeConfig: { + heartbeat: { + enabled: configValues.heartbeatEnabled, + intervalSec: configValues.intervalSec, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }, + budgetMonthlyCents: 0, + }); + } + + const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); + + return ( +
+
+

New Agent

+

+ Advanced agent configuration +

+
+ +
+ {/* Name */} +
+ setName(e.target.value)} + autoFocus + /> +
+ + {/* Title */} +
+ setTitle(e.target.value)} + /> +
+ + {/* Property chips: Role + Reports To */} +
+ + + + + + {AGENT_ROLES.map((r) => ( + + ))} + + + + + + + + + + {(agents ?? []).map((a) => ( + + ))} + + +
+ + {/* Shared config form */} + setConfigValues((prev) => ({ ...prev, ...patch }))} + adapterModels={adapterModels} + /> + + {/* Footer */} +
+ {isFirstAgent && ( +

This will be the CEO

+ )} + {formError && ( +

{formError}

+ )} +
+ + +
+
+
+
+ ); +} From 45708a06f112e3a777516ad80366306c51279c8f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:31:59 -0600 Subject: [PATCH 03/25] ui: avoid duplicate and self comment toasts --- ui/src/context/LiveUpdatesProvider.tsx | 25 +++++++++++++++++++++---- ui/src/pages/IssueDetail.tsx | 10 +--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 6a1403af..3f3f222a 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, type ReactNode } from "react"; -import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { Agent, Issue, LiveEvent } from "@paperclipai/shared"; +import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToast } from "./ToastContext"; @@ -152,6 +153,7 @@ function buildActivityToast( queryClient: QueryClient, companyId: string, payload: Record, + currentActor: { userId: string | null; agentId: string | null }, ): ToastInput | null { const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); @@ -200,6 +202,11 @@ function buildActivityToast( } const commentId = readString(details?.commentId); + const isSelfComment = + action === "issue.comment_added" && + ((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) || + (actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId)); + if (isSelfComment) return null; const bodySnippet = readString(details?.bodySnippet); const reopened = details?.reopened === true; const reopenedFrom = readString(details?.reopenedFrom); @@ -448,6 +455,7 @@ function handleLiveEvent( event: LiveEvent, pushToast: (toast: ToastInput) => string | null, gate: ToastGate, + currentActor: { userId: string | null; agentId: string | null }, ) { if (event.companyId !== expectedCompanyId) return; @@ -485,7 +493,7 @@ function handleLiveEvent( invalidateActivityQueries(queryClient, expectedCompanyId, payload); const action = readString(payload.action); const toast = - buildActivityToast(queryClient, expectedCompanyId, payload) ?? + buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ?? buildJoinRequestToast(payload); if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast); } @@ -496,6 +504,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; useEffect(() => { if (!selectedCompanyId) return; @@ -541,7 +555,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current); + handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { + userId: currentUserId, + agentId: null, + }); } catch { // Ignore non-JSON payloads. } @@ -570,7 +587,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId, pushToast]); + }, [queryClient, selectedCompanyId, pushToast, currentUserId]); return <>{children}; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 84f07ed1..f787931e 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -419,17 +419,9 @@ export function IssueDetail() { const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), - onSuccess: (comment) => { + onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); - const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue"); - pushToast({ - dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`, - title: `Comment posted on ${issueRef}`, - body: issue?.title ? truncate(issue.title, 96) : undefined, - tone: "success", - action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined, - }); }, }); From 1fcc6900ffc83e02bbef589a8af689702a15ff10 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:42:58 -0600 Subject: [PATCH 04/25] ui: suppress self-authored issue toasts --- ui/src/context/LiveUpdatesProvider.tsx | 9 ++++----- ui/src/pages/IssueDetail.tsx | 22 ++-------------------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 3f3f222a..3363b9a5 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -168,6 +168,10 @@ function buildActivityToast( const issue = resolveIssueToastContext(queryClient, companyId, entityId, details); const actor = resolveActorLabel(queryClient, companyId, actorType, actorId); + const isSelfActivity = + (actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) || + (actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId); + if (isSelfActivity) return null; if (action === "issue.created") { return { @@ -202,11 +206,6 @@ function buildActivityToast( } const commentId = readString(details?.commentId); - const isSelfComment = - action === "issue.comment_added" && - ((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) || - (actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId)); - if (isSelfComment) return null; const bodySnippet = readString(details?.bodySnippet); const reopened = details?.reopened === true; const reopenedFrom = readString(details?.reopenedFrom); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f787931e..90c94888 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -8,7 +8,6 @@ import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -146,7 +145,6 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map(); const { selectedCompanyId } = useCompany(); - const { pushToast } = useToast(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); @@ -403,16 +401,8 @@ export function IssueDetail() { const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), - onSuccess: (updated) => { + onSuccess: () => { invalidateIssue(); - const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`; - pushToast({ - dedupeKey: `activity:issue.updated:${updated.id}`, - title: `${issueRef} updated`, - body: truncate(updated.title, 96), - tone: "success", - action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` }, - }); }, }); @@ -441,17 +431,9 @@ export function IssueDetail() { assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), }), - onSuccess: (updated) => { + onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); - const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue"); - pushToast({ - dedupeKey: `activity:issue.reassigned:${updated.id}`, - title: `${issueRef} reassigned`, - body: issue?.title ? truncate(issue.title, 96) : undefined, - tone: "success", - action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined, - }); }, }); From fa8499719a28c7b231a7bb5293aa5cbd6d1a782c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:55:19 -0600 Subject: [PATCH 05/25] ui: remove local toast on issue create --- ui/src/components/NewIssueDialog.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index de0ea73f..99106f9f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -170,7 +169,6 @@ const priorities = [ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { companies, selectedCompanyId, selectedCompany } = useCompany(); - const { pushToast } = useToast(); const queryClient = useQueryClient(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -262,19 +260,12 @@ export function NewIssueDialog() { const createIssue = useMutation({ mutationFn: ({ companyId, ...data }: { companyId: string } & Record) => issuesApi.create(companyId, data), - onSuccess: (issue) => { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) }); if (draftTimer.current) clearTimeout(draftTimer.current); clearDraft(); reset(); closeNewIssue(); - pushToast({ - dedupeKey: `activity:issue.created:${issue.id}`, - title: `${issue.identifier ?? "Issue"} created`, - body: issue.title, - tone: "success", - action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` }, - }); }, }); From a498c268c55dc4c04a5c025ce9c99ddfadbb7ce1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:59:29 -0600 Subject: [PATCH 06/25] feat: add openclaw_gateway adapter New adapter type for invoking OpenClaw agents via the gateway protocol. Registers in server, CLI, and UI adapter registries. Adds onboarding wizard support with gateway URL field and e2e smoke test script. Co-Authored-By: Claude Opus 4.6 --- cli/esbuild.config.mjs | 1 + cli/package.json | 1 + cli/src/adapters/registry.ts | 17 +- packages/adapters/openclaw-gateway/README.md | 71 ++ .../doc/ONBOARDING_AND_TEST_PLAN.md | 324 +++++ .../adapters/openclaw-gateway/package.json | 52 + .../openclaw-gateway/src/cli/format-event.ts | 23 + .../openclaw-gateway/src/cli/index.ts | 1 + .../adapters/openclaw-gateway/src/index.ts | 41 + .../openclaw-gateway/src/server/execute.ts | 1060 +++++++++++++++++ .../openclaw-gateway/src/server/index.ts | 2 + .../openclaw-gateway/src/server/test.ts | 317 +++++ .../openclaw-gateway/src/shared/stream.ts | 16 + .../openclaw-gateway/src/ui/build-config.ts | 13 + .../adapters/openclaw-gateway/src/ui/index.ts | 2 + .../openclaw-gateway/src/ui/parse-stdout.ts | 75 ++ .../adapters/openclaw-gateway/tsconfig.json | 8 + packages/shared/src/constants.ts | 1 + pnpm-lock.yaml | 976 ++++++++++++++- scripts/smoke/openclaw-gateway-e2e.sh | 752 ++++++++++++ server/package.json | 1 + .../openclaw-gateway-adapter.test.ts | 254 ++++ server/src/adapters/registry.ts | 28 +- ui/package.json | 4 +- .../openclaw-gateway/config-fields.tsx | 221 ++++ ui/src/adapters/openclaw-gateway/index.ts | 12 + ui/src/adapters/registry.ts | 12 +- ui/src/components/AgentProperties.tsx | 1 + ui/src/components/LiveRunWidget.tsx | 2 +- ui/src/components/OnboardingWizard.tsx | 15 +- ui/src/components/agent-config-primitives.tsx | 3 +- ui/src/pages/Agents.tsx | 1 + ui/src/pages/InviteLanding.tsx | 1 + ui/src/pages/OrgChart.tsx | 1 + 34 files changed, 4290 insertions(+), 19 deletions(-) create mode 100644 packages/adapters/openclaw-gateway/README.md create mode 100644 packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md create mode 100644 packages/adapters/openclaw-gateway/package.json create mode 100644 packages/adapters/openclaw-gateway/src/cli/format-event.ts create mode 100644 packages/adapters/openclaw-gateway/src/cli/index.ts create mode 100644 packages/adapters/openclaw-gateway/src/index.ts create mode 100644 packages/adapters/openclaw-gateway/src/server/execute.ts create mode 100644 packages/adapters/openclaw-gateway/src/server/index.ts create mode 100644 packages/adapters/openclaw-gateway/src/server/test.ts create mode 100644 packages/adapters/openclaw-gateway/src/shared/stream.ts create mode 100644 packages/adapters/openclaw-gateway/src/ui/build-config.ts create mode 100644 packages/adapters/openclaw-gateway/src/ui/index.ts create mode 100644 packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts create mode 100644 packages/adapters/openclaw-gateway/tsconfig.json create mode 100755 scripts/smoke/openclaw-gateway-e2e.sh create mode 100644 server/src/__tests__/openclaw-gateway-adapter.test.ts create mode 100644 ui/src/adapters/openclaw-gateway/config-fields.tsx create mode 100644 ui/src/adapters/openclaw-gateway/index.ts diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index c116047c..495fad99 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -22,6 +22,7 @@ const workspacePaths = [ "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that should NOT be bundled — they'll be published diff --git a/cli/package.json b/cli/package.json index 4126d93b..bd0ac340 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,6 +39,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 818bc6e6..82762ab3 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -4,6 +4,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; +import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -32,8 +33,22 @@ const openclawCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printOpenClawStreamEvent, }; +const openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, +}; + const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + cursorLocalCLIAdapter, + openclawCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md new file mode 100644 index 00000000..61ebfaea --- /dev/null +++ b/packages/adapters/openclaw-gateway/README.md @@ -0,0 +1,71 @@ +# OpenClaw Gateway Adapter + +This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol. + +## Transport + +This adapter always uses WebSocket gateway transport. + +- URL must be `ws://` or `wss://` +- Connect flow follows gateway protocol: +1. receive `connect.challenge` +2. send `req connect` (protocol/client/auth/device payload) +3. send `req agent` +4. wait for completion via `req agent.wait` +5. stream `event agent` frames into Paperclip logs/transcript parsing + +## Auth Modes + +Gateway credentials can be provided in any of these ways: + +- `authToken` / `token` in adapter config +- `headers.x-openclaw-token` +- `headers.x-openclaw-auth` (legacy) +- `password` (shared password mode) + +When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer `. + +## Device Auth + +By default the adapter sends a signed `device` payload in `connect` params. + +- set `disableDeviceAuth=true` to omit device signing +- set `devicePrivateKeyPem` to pin a stable signing key +- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run + +## Session Strategy + +The adapter supports the same session routing model as HTTP OpenClaw mode: + +- `sessionKeyStrategy=fixed|issue|run` +- `sessionKey` is used when strategy is `fixed` + +Resolved session key is sent as `agent.sessionKey`. + +## Payload Mapping + +The agent request is built as: + +- required fields: + - `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix) + - `idempotencyKey` (Paperclip `runId`) + - `sessionKey` (resolved strategy) +- optional additions: + - all `payloadTemplate` fields merged in + - `agentId` from config if set and not already in template + +## Timeouts + +- `timeoutSec` controls adapter-level request budget +- `waitTimeoutMs` controls `agent.wait.timeoutMs` + +If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`. + +## Log Format + +Structured gateway event logs use: + +- `[openclaw-gateway] ...` for lifecycle/system logs +- `[openclaw-gateway:event] run= stream= data=` for `event agent` frames + +UI/CLI parsers consume these lines to render transcript updates. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md new file mode 100644 index 00000000..965d8179 --- /dev/null +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -0,0 +1,324 @@ +# OpenClaw Gateway Onboarding and Test Plan + +## Objective +Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex. + +This plan covers: +- Current onboarding flow behavior and gaps. +- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol). +- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company. + +## Hard Requirements (Testing Contract) +These are mandatory for onboarding and smoke testing: + +1. **Stock/clean OpenClaw boot every run** +- Use a fresh, unmodified OpenClaw Docker image path each test cycle. +- Do not rely on persistent/manual in-UI tweaks from prior runs. +- Recreate runtime state each run so results represent first-time user experience. + +2. **One-command/prompt setup inside OpenClaw** +- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able). +- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). +- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. + +## External Protocol Constraints +OpenClaw docs to anchor behavior: +- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook +- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol +- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses + +Implication: +- `webhook` transport should target `/hooks/*` and requires hook server enablement. +- `sse` transport should target `/v1/responses`. +- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`. + +## Current Implementation Map (What Exists) + +### Invite + onboarding pipeline +- Invite create: `POST /api/companies/:companyId/invites` +- Invite onboarding manifest: `GET /api/invites/:token/onboarding` +- Agent-readable text: `GET /api/invites/:token/onboarding.txt` +- Accept join: `POST /api/invites/:token/accept` +- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve` +- Claim key: `POST /api/join-requests/:requestId/claim-api-key` + +### Adapter state +- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode. +- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`). + +### Existing smoke foundation +- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`. +- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`). + +## Deep Code Findings (Gaps) + +### 1) Onboarding content is still OpenClaw-HTTP specific +`server/src/routes/access.ts` hardcodes onboarding to: +- `recommendedAdapterType: "openclaw"` +- Required `agentDefaultsPayload.headers.x-openclaw-auth` +- HTTP callback URL guidance and `/v1/responses` examples. + +There is no adapter-specific onboarding manifest/text for `openclaw_gateway`. + +### 2) Company settings snippet is OpenClaw HTTP-first +`ui/src/pages/CompanySettings.tsx` generates one snippet that: +- Assumes OpenClaw HTTP callback setup. +- Instructs enabling `gateway.http.endpoints.responses.enabled=true`. +- Does not provide a dedicated gateway onboarding path. + +### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters +`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI. + +### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"` +`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific. +No equivalent normalization/diagnostics for gateway defaults. + +### 5) Webhook confusion is expected in current setup +For `openclaw` + `streamTransport=webhook`: +- Adapter may remap `/v1/responses -> /hooks/agent`. +- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`. + +If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected. + +### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode +- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`). +- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`. +- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes. + +### 7) Gateway adapter lacks hire-approved callback parity +`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not. +Not a blocker for core routing, but creates inconsistent onboarding feedback behavior. + +## UX Intention (Target Experience) + +### Product goal +Users should pick one clear onboarding path: +- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs. +- `Invite OpenClaw Gateway` for gateway-native installs. + +### UX design requirements +- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route). +- Mode-specific generated snippet and mode-specific onboarding text. +- Clear compatibility checks before user copies anything. + +### Proposed UX structure +1. Add invite buttons: +- `Invite OpenClaw (SSE/Webhook)` +- `Invite OpenClaw Gateway` + +2. For HTTP invite: +- Require transport choice (`sse` or `webhook`). +- Validate endpoint expectations: + - `sse` with `/v1/responses`. + - `webhook` with `/hooks/*` and hooks enablement guidance. + +3. For Gateway invite: +- Ask only for `ws://`/`wss://` and token source guidance. +- No callback URL/paperclipApiUrl complexity in onboarding. + +4. Always show: +- Preflight diagnostics. +- Copy-ready command/snippet. +- Expected next steps (join -> approve -> claim -> skill install). + +## Why Gateway Improves Onboarding +Compared to webhook/SSE onboarding: +- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls. +- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion. +- Better run observability: gateway event frames stream lifecycle/delta events in one protocol. + +Tradeoff: +- Requires stable WS endpoint and gateway token handling. + +## Codex-Executable E2E Workflow + +## Scope +Run this full flow per test cycle against company `CLA`: +1. Assign task to OpenClaw agent -> agent executes -> task closes. +2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat. +3. OpenClaw in a fresh/new session can still create a Paperclip task. +4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup. + +## 0) Cleanup Before Each Run +Use deterministic reset to avoid stale agents/runs/state. + +1. OpenClaw Docker cleanup: +```bash +# stop/remove OpenClaw compose services +OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker +if [ -d "$OPENCLAW_DOCKER_DIR" ]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true +fi + +# remove old image (as requested) +docker image rm openclaw:local || true +``` + +2. Recreate OpenClaw cleanly: +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs. + +3. Remove prior CLA OpenClaw agents: +- List `CLA` agents via API. +- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding. + +4. Reject/clear stale pending join requests for CLA (optional but recommended). + +## 1) Start Paperclip in Required Mode +```bash +pnpm dev --tailscale-auth +``` +Verify: +```bash +curl -fsS http://127.0.0.1:3100/api/health +# expect deploymentMode=authenticated, deploymentExposure=private +``` + +## 2) Acquire Board Session for Automation +Board operations (create invite, approve join, terminate agents) require board session cookie. + +Short-term practical options: +1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`. +2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script). + +Note: +- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow. + +## 3) Resolve CLA Company ID +With board cookie: +```bash +curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies +``` +Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`. + +## 4) Preflight OpenClaw Endpoint Capability +From host (using current OpenClaw token): +- For HTTP SSE mode: confirm `/v1/responses` behavior. +- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled. +- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`. + +Expected in current docker smoke config: +- `/hooks/agent` likely `404` unless hooks explicitly enabled. +- WS gateway protocol works. + +## 5) Gateway Join Flow (Primary Path) + +1. Create agent-only invite in CLA: +```bash +POST /api/companies/$CLA_COMPANY_ID/invites +{ "allowedJoinTypes": "agent" } +``` + +2. Submit join request with gateway defaults: +```json +{ + "requestType": "agent", + "agentName": "OpenClaw Gateway", + "adapterType": "openclaw_gateway", + "capabilities": "OpenClaw gateway agent", + "agentDefaultsPayload": { + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" }, + "role": "operator", + "scopes": ["operator.admin"], + "sessionKeyStrategy": "fixed", + "sessionKey": "paperclip", + "waitTimeoutMs": 120000 + } +} +``` + +3. Approve join request. +4. Claim API key with `claimSecret`. +5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. +6. Ensure Paperclip skill is installed for OpenClaw runtime. +7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only. + +## 6) E2E Validation Cases + +### Case A: Assigned task execution/closure +1. Create issue in CLA assigned to joined OpenClaw agent. +2. Poll issue + heartbeat runs until terminal. +3. Pass criteria: +- At least one run invoked for that agent/issue. +- Run status `succeeded`. +- Issue reaches `done` (or documented expected terminal state if policy differs). + +### Case B: Message tool to main chat +1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”. +2. Trigger/poll run completion. +3. Validate output: +- Automated minimum: run log/transcript confirms tool invocation success. +- UX-level validation: message visibly appears in main chat UI. + +Current recommendation: +- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification. + +### Case C: Fresh session still creates Paperclip task +1. Force fresh-session behavior for test: +- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key). +2. Create issue asking agent to create a new Paperclip task. +3. Pass criteria: +- New issue appears in CLA with expected title/body. +- Agent succeeds without re-onboarding. + +## 7) Observability and Assertions +Use these APIs for deterministic assertions: +- `GET /api/companies/:companyId/heartbeat-runs?agentId=...` +- `GET /api/heartbeat-runs/:runId/events` +- `GET /api/heartbeat-runs/:runId/log` +- `GET /api/issues/:id` +- `GET /api/companies/:companyId/issues?q=...` + +Include explicit timeout budgets per poll loop and hard failure reasons in output. + +## 8) Automation Artifact +Implemented smoke harness: +- `scripts/smoke/openclaw-gateway-e2e.sh` + +Responsibilities: +- OpenClaw docker cleanup/rebuild/start. +- Paperclip health/auth preflight. +- CLA company resolution. +- Old OpenClaw agent cleanup. +- Invite/join/approve/claim orchestration. +- E2E case execution + assertions. +- Final summary with run IDs, issue IDs, agent ID. + +## 9) Required Product/Code Changes to Support This Plan Cleanly + +### Access/onboarding backend +- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`). +- Add gateway-specific required fields and examples. +- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints). + +### Company settings UX +- Replace single generic snippet with mode-specific invite actions. +- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding. + +### Invite landing UX +- Enable OpenClaw adapter options when invite allows agent join. +- Allow `agentDefaultsPayload` entry for advanced joins where needed. + +### Adapter parity +- Consider `onHireApproved` support for `openclaw_gateway` for consistency. + +### Test coverage +- Add integration tests for adapter-aware onboarding manifest generation. +- Add route tests for gateway join/approve/claim path. +- Add smoke test target for gateway E2E flow. + +## 10) Execution Order +1. Implement onboarding manifest/text split by adapter mode. +2. Add company settings invite UX split (HTTP vs Gateway). +3. Add gateway E2E smoke script. +4. Run full CLA workflow in authenticated/private mode. +5. Iterate on message-tool verification automation. + +## Acceptance Criteria +- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal. +- Gateway onboarding is first-class and copy/pasteable from company settings. +- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup. +- All three validation cases are documented with pass/fail criteria and reproducible evidence paths. diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json new file mode 100644 index 00000000..0999b220 --- /dev/null +++ b/packages/adapters/openclaw-gateway/package.json @@ -0,0 +1,52 @@ +{ + "name": "@paperclipai/adapter-openclaw-gateway", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/ws": "^8.18.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/openclaw-gateway/src/cli/format-event.ts b/packages/adapters/openclaw-gateway/src/cli/format-event.ts new file mode 100644 index 00000000..55814317 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/format-event.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + if (line.startsWith("[openclaw-gateway:event]")) { + console.log(pc.cyan(line)); + return; + } + + if (line.startsWith("[openclaw-gateway]")) { + console.log(pc.blue(line)); + return; + } + + console.log(pc.gray(line)); +} diff --git a/packages/adapters/openclaw-gateway/src/cli/index.ts b/packages/adapters/openclaw-gateway/src/cli/index.ts new file mode 100644 index 00000000..9c621bcb --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenClawGatewayStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts new file mode 100644 index 00000000..ca16cdc9 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -0,0 +1,41 @@ +export const type = "openclaw_gateway"; +export const label = "OpenClaw Gateway"; + +export const models: { id: string; label: string }[] = []; + +export const agentConfigurationDoc = `# openclaw_gateway agent configuration + +Adapter: openclaw_gateway + +Use when: +- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol. +- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. + +Don't use when: +- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport). +- Your deployment does not permit outbound WebSocket access from the Paperclip server. + +Core fields: +- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://) +- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth +- authToken (string, optional): shared gateway token override +- password (string, optional): gateway shared password, if configured + +Gateway connect identity fields: +- clientId (string, optional): gateway client id (default gateway-client) +- clientMode (string, optional): gateway client mode (default backend) +- clientVersion (string, optional): client version string +- role (string, optional): gateway role (default operator) +- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"]) +- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false) + +Request behavior fields: +- payloadTemplate (object, optional): additional fields merged into gateway agent params +- timeoutSec (number, optional): adapter timeout in seconds (default 120) +- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text + +Session routing fields: +- sessionKeyStrategy (string, optional): fixed (default), issue, or run +- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) +`; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts new file mode 100644 index 00000000..3cc20533 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -0,0 +1,1060 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import crypto, { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +type SessionKeyStrategy = "fixed" | "issue" | "run"; + +type WakePayload = { + runId: string; + agentId: string; + companyId: string; + taskId: string | null; + issueId: string | null; + wakeReason: string | null; + wakeCommentId: string | null; + approvalId: string | null; + approvalStatus: string | null; + issueIds: string[]; +}; + +type GatewayDeviceIdentity = { + deviceId: string; + publicKeyRawBase64Url: string; + privateKeyPem: string; +}; + +type GatewayRequestFrame = { + type: "req"; + id: string; + method: string; + params?: unknown; +}; + +type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + }; +}; + +type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + expectFinal: boolean; + timer: ReturnType | null; +}; + +type GatewayClientOptions = { + url: string; + headers: Record; + onEvent: (frame: GatewayEventFrame) => Promise | void; + onLog: AdapterExecutionContext["onLog"]; +}; + +type GatewayClientRequestOptions = { + timeoutMs: number; + expectFinal?: boolean; +}; + +const PROTOCOL_VERSION = 3; +const DEFAULT_SCOPES = ["operator.admin"]; +const DEFAULT_CLIENT_ID = "gateway-client"; +const DEFAULT_CLIENT_MODE = "backend"; +const DEFAULT_CLIENT_VERSION = "paperclip"; +const DEFAULT_ROLE = "operator"; + +const SENSITIVE_LOG_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(1, Math.floor(value)); + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed)); + } + return null; +} + +function parseBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return fallback; +} + +function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { + const normalized = asString(value, "fixed").trim().toLowerCase(); + if (normalized === "issue" || normalized === "run") return normalized; + return "fixed"; +} + +function resolveSessionKey(input: { + strategy: SessionKeyStrategy; + configuredSessionKey: string | null; + runId: string; + issueId: string | null; +}): string { + const fallback = input.configuredSessionKey ?? "paperclip"; + if (input.strategy === "run") return `paperclip:run:${input.runId}`; + if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; + return fallback; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeScopes(value: unknown): string[] { + const parsed = toStringArray(value); + return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function headerMapHasIgnoreCase(headers: Record, key: string): boolean { + return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); +} + +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function isSensitiveLogKey(key: string): boolean { + return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); +} + +function sha256Prefix(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function redactSecretForLog(value: string): string { + return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; +} + +function truncateForLog(value: string, maxChars = 320): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; +} + +function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { + const currentKey = keyPath[keyPath.length - 1] ?? ""; + if (typeof value === "string") { + if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); + return truncateForLog(value); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return value; + } + if (Array.isArray(value)) { + if (depth >= 6) return "[array-truncated]"; + const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); + if (value.length > 20) out.push(`[+${value.length - 20} more items]`); + return out; + } + if (typeof value === "object") { + if (depth >= 6) return "[object-truncated]"; + const entries = Object.entries(value as Record); + const out: Record = {}; + for (const [key, entry] of entries.slice(0, 80)) { + out[key] = redactForLog(entry, [...keyPath, key], depth + 1); + } + if (entries.length > 80) { + out.__truncated__ = `+${entries.length - 80} keys`; + } + return out; + } + return String(value); +} + +function stringifyForLog(value: unknown, maxChars: number): string { + const text = JSON.stringify(value); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { + const { runId, agent, context } = ctx; + return { + runId, + agentId: agent.id, + companyId: agent.companyId, + taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), + issueId: nonEmpty(context.issueId), + wakeReason: nonEmpty(context.wakeReason), + wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), + approvalId: nonEmpty(context.approvalId), + approvalStatus: nonEmpty(context.approvalStatus), + issueIds: Array.isArray(context.issueIds) + ? context.issueIds.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : [], + }; +} + +function resolvePaperclipApiUrlOverride(value: unknown): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString(); + } catch { + return null; + } +} + +function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { + const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); + const paperclipEnv: Record = { + ...buildPaperclipEnv(ctx.agent), + PAPERCLIP_RUN_ID: ctx.runId, + }; + + if (paperclipApiUrlOverride) { + paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; + } + if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; + if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; + if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; + if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; + if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; + if (wakePayload.issueIds.length > 0) { + paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); + } + + return paperclipEnv; +} + +function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { + const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; + const orderedKeys = [ + "PAPERCLIP_RUN_ID", + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_API_URL", + "PAPERCLIP_TASK_ID", + "PAPERCLIP_WAKE_REASON", + "PAPERCLIP_WAKE_COMMENT_ID", + "PAPERCLIP_APPROVAL_ID", + "PAPERCLIP_APPROVAL_STATUS", + "PAPERCLIP_LINKED_ISSUE_IDS", + ]; + + const envLines: string[] = []; + for (const key of orderedKeys) { + const value = paperclipEnv[key]; + if (!value) continue; + envLines.push(`${key}=${value}`); + } + + const lines = [ + "Paperclip wake event for a cloud adapter.", + "", + "Set these values in your run context:", + ...envLines, + `PAPERCLIP_API_KEY=`, + "", + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, + "", + `task_id=${payload.taskId ?? ""}`, + `issue_id=${payload.issueId ?? ""}`, + `wake_reason=${payload.wakeReason ?? ""}`, + `wake_comment_id=${payload.wakeCommentId ?? ""}`, + `approval_id=${payload.approvalId ?? ""}`, + `approval_status=${payload.approvalStatus ?? ""}`, + `linked_issue_ids=${payload.issueIds.join(",")}`, + ]; + + lines.push("", "Run your Paperclip heartbeat procedure now."); + return lines.join("\n"); +} + +function appendWakeText(baseText: string, wakeText: string): string { + const trimmedBase = baseText.trim(); + return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; +} + +function normalizeUrl(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { + const key = crypto.createPublicKey(publicKeyPem); + const spki = key.export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function signDevicePayload(privateKeyPem: string, payload: string): string { + const key = crypto.createPrivateKey(privateKeyPem); + const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); + return base64UrlEncode(sig); +} + +function buildDeviceAuthPayloadV3(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce: string; + platform?: string | null; + deviceFamily?: string | null; +}): string { + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const platform = params.platform?.trim() ?? ""; + const deviceFamily = params.deviceFamily?.trim() ?? ""; + return [ + "v3", + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + params.nonce, + platform, + deviceFamily, + ].join("|"); +} + +function resolveDeviceIdentity(config: Record): GatewayDeviceIdentity { + const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem); + if (configuredPrivateKey) { + const privateKey = crypto.createPrivateKey(configuredPrivateKey); + const publicKey = crypto.createPublicKey(privateKey); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem: configuredPrivateKey, + }; + } + + const generated = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem, + }; +} + +function isResponseFrame(value: unknown): value is GatewayResponseFrame { + const record = asRecord(value); + return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean"); +} + +function isEventFrame(value: unknown): value is GatewayEventFrame { + const record = asRecord(value); + return Boolean(record && record.type === "event" && typeof record.event === "string"); +} + +class GatewayWsClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private challengePromise: Promise; + private resolveChallenge!: (nonce: string) => void; + private rejectChallenge!: (err: Error) => void; + + constructor(private readonly opts: GatewayClientOptions) { + this.challengePromise = new Promise((resolve, reject) => { + this.resolveChallenge = resolve; + this.rejectChallenge = reject; + }); + } + + async connect( + buildConnectParams: (nonce: string) => Record, + timeoutMs: number, + ): Promise | null> { + this.ws = new WebSocket(this.opts.url, { + headers: this.opts.headers, + maxPayload: 25 * 1024 * 1024, + }); + + const ws = this.ws; + + ws.on("message", (data) => { + this.handleMessage(rawDataToString(data)); + }); + + ws.on("close", (code, reason) => { + const reasonText = rawDataToString(reason); + const err = new Error(`gateway closed (${code}): ${reasonText}`); + this.failPending(err); + this.rejectChallenge(err); + }); + + ws.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`); + }); + + await withTimeout( + new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`)); + }; + const cleanup = () => { + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }), + timeoutMs, + "gateway websocket open timeout", + ); + + const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout"); + const signedConnectParams = buildConnectParams(nonce); + + const hello = await this.request | null>("connect", signedConnectParams, { + timeoutMs, + }); + + return hello; + } + + async request( + method: string, + params: unknown, + opts: GatewayClientRequestOptions, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("gateway not connected"); + } + + const id = randomUUID(); + const frame: GatewayRequestFrame = { + type: "req", + id, + method, + params, + }; + + const payload = JSON.stringify(frame); + const requestPromise = new Promise((resolve, reject) => { + const timer = + opts.timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`gateway request timeout (${method})`)); + }, opts.timeoutMs) + : null; + + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + expectFinal: opts.expectFinal === true, + timer, + }); + }); + + this.ws.send(payload); + return requestPromise; + } + + close() { + if (!this.ws) return; + this.ws.close(1000, "paperclip-complete"); + this.ws = null; + } + + private failPending(err: Error) { + for (const [, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(err); + } + this.pending.clear(); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (isEventFrame(parsed)) { + if (parsed.event === "connect.challenge") { + const payload = asRecord(parsed.payload); + const nonce = nonEmpty(payload?.nonce); + if (nonce) { + this.resolveChallenge(nonce); + return; + } + } + void Promise.resolve(this.opts.onEvent(parsed)).catch(() => { + // Ignore event callback failures and keep stream active. + }); + return; + } + + if (!isResponseFrame(parsed)) return; + + const pending = this.pending.get(parsed.id); + if (!pending) return; + + const payload = asRecord(parsed.payload); + const status = nonEmpty(payload?.status)?.toLowerCase(); + if (pending.expectFinal && status === "accepted") { + return; + } + + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(parsed.id); + + if (parsed.ok) { + pending.resolve(parsed.payload ?? null); + return; + } + + const errorRecord = asRecord(parsed.error); + const message = + nonEmpty(errorRecord?.message) ?? + nonEmpty(errorRecord?.code) ?? + "gateway request failed"; + pending.reject(new Error(message)); + } +} + +function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = asNumber(record.inputTokens ?? record.input, 0); + const outputTokens = asNumber(record.outputTokens ?? record.output, 0); + const cachedInputTokens = asNumber( + record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read, + 0, + ); + + if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) { + return undefined; + } + + return { + inputTokens, + outputTokens, + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + }; +} + +function extractResultText(value: unknown): string | null { + const record = asRecord(value); + if (!record) return null; + + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((entry) => { + const payload = asRecord(entry); + return nonEmpty(payload?.text); + }) + .filter((entry): entry is string => Boolean(entry)); + + if (texts.length > 0) return texts.join("\n\n"); + return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const urlValue = asString(ctx.config.url, "").trim(); + if (!urlValue) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "OpenClaw gateway adapter missing url", + errorCode: "openclaw_gateway_url_missing", + }; + } + + const parsedUrl = normalizeUrl(urlValue); + if (!parsedUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Invalid gateway URL: ${urlValue}`, + errorCode: "openclaw_gateway_url_invalid", + }; + } + + if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`, + errorCode: "openclaw_gateway_url_protocol", + }; + } + + const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120))); + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; + const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000; + const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000); + + const payloadTemplate = parseObject(ctx.config.payloadTemplate); + const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport); + + const headers = toStringRecord(ctx.config.headers); + const authToken = resolveAuthToken(parseObject(ctx.config), headers); + const password = nonEmpty(ctx.config.password); + const deviceToken = nonEmpty(ctx.config.deviceToken); + + if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) { + headers.authorization = toAuthorizationHeaderValue(authToken); + } + + const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID; + const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE; + const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION; + const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE; + const scopes = normalizeScopes(ctx.config.scopes); + const deviceFamily = nonEmpty(ctx.config.deviceFamily); + const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false); + + const wakePayload = buildWakePayload(ctx); + const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); + const wakeText = buildWakeText(wakePayload, paperclipEnv); + + const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); + const configuredSessionKey = nonEmpty(ctx.config.sessionKey); + const sessionKey = resolveSessionKey({ + strategy: sessionKeyStrategy, + configuredSessionKey, + runId: ctx.runId, + issueId: wakePayload.issueId, + }); + + const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text); + const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText; + + const agentParams: Record = { + ...payloadTemplate, + message, + sessionKey, + idempotencyKey: ctx.runId, + }; + delete agentParams.text; + + const configuredAgentId = nonEmpty(ctx.config.agentId); + if (configuredAgentId && !nonEmpty(agentParams.agentId)) { + agentParams.agentId = configuredAgentId; + } + + if (typeof agentParams.timeout !== "number") { + agentParams.timeout = waitTimeoutMs; + } + + const trackedRunIds = new Set([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let latestResultPayload: unknown = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; + await ctx.onLog( + "stdout", + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, + ); + + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; + } + + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } + + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, + }); + + if (ctx.onMeta) { + await ctx.onMeta({ + adapterType: "openclaw_gateway", + command: "gateway", + commandArgs: ["ws", parsedUrl.toString(), "agent"], + context: ctx.context, + }); + } + + const outboundHeaderKeys = Object.keys(headers).sort(); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, + ); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`, + ); + await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); + if (transportHint) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`, + ); + } + if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) { + await ctx.onLog( + "stdout", + "[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n", + ); + } + + try { + const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); + + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${message}\n`); + + return { + exitCode: 1, + signal: null, + timedOut, + errorMessage: message, + errorCode: timedOut ? "openclaw_gateway_timeout" : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), + }; + } finally { + client.close(); + } +} diff --git a/packages/adapters/openclaw-gateway/src/server/index.ts b/packages/adapters/openclaw-gateway/src/server/index.ts new file mode 100644 index 00000000..04036438 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/index.ts @@ -0,0 +1,2 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; diff --git a/packages/adapters/openclaw-gateway/src/server/test.ts b/packages/adapters/openclaw-gateway/src/server/test.ts new file mode 100644 index 00000000..af4c74d1 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/test.ts @@ -0,0 +1,317 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +async function probeGateway(input: { + url: string; + headers: Record; + authToken: string | null; + role: string; + scopes: string[]; + timeoutMs: number; +}): Promise<"ok" | "challenge_only" | "failed"> { + return await new Promise((resolve) => { + const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 }); + const timeout = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + resolve("failed"); + }, input.timeoutMs); + + let completed = false; + + const finish = (status: "ok" | "challenge_only" | "failed") => { + if (completed) return; + completed = true; + clearTimeout(timeout); + try { + ws.close(); + } catch { + // ignore + } + resolve(status); + }; + + ws.on("message", (raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(raw)); + } catch { + return; + } + const event = asRecord(parsed); + if (event?.type === "event" && event.event === "connect.challenge") { + const nonce = nonEmpty(asRecord(event.payload)?.nonce); + if (!nonce) { + finish("failed"); + return; + } + + const connectId = randomUUID(); + ws.send( + JSON.stringify({ + type: "req", + id: connectId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "gateway-client", + version: "paperclip-probe", + platform: process.platform, + mode: "probe", + }, + role: input.role, + scopes: input.scopes, + ...(input.authToken + ? { + auth: { + token: input.authToken, + }, + } + : {}), + }, + }), + ); + return; + } + + if (event?.type === "res") { + if (event.ok === true) { + finish("ok"); + } else { + finish("challenge_only"); + } + } + }); + + ws.on("error", () => { + finish("failed"); + }); + + ws.on("close", () => { + if (!completed) finish("failed"); + }); + }); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const urlValue = asString(config.url, "").trim(); + + if (!urlValue) { + checks.push({ + code: "openclaw_gateway_url_missing", + level: "error", + message: "OpenClaw gateway adapter requires a WebSocket URL.", + hint: "Set adapterConfig.url to ws://host:port (or wss://).", + }); + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + let url: URL | null = null; + try { + url = new URL(urlValue); + } catch { + checks.push({ + code: "openclaw_gateway_url_invalid", + level: "error", + message: `Invalid URL: ${urlValue}`, + }); + } + + if (url && url.protocol !== "ws:" && url.protocol !== "wss:") { + checks.push({ + code: "openclaw_gateway_url_protocol_invalid", + level: "error", + message: `Unsupported URL protocol: ${url.protocol}`, + hint: "Use ws:// or wss://.", + }); + } + + if (url) { + checks.push({ + code: "openclaw_gateway_url_valid", + level: "info", + message: `Configured gateway URL: ${url.toString()}`, + }); + + if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) { + checks.push({ + code: "openclaw_gateway_plaintext_remote_ws", + level: "warn", + message: "Gateway URL uses plaintext ws:// on a non-loopback host.", + hint: "Prefer wss:// for remote gateways.", + }); + } + } + + const headers = toStringRecord(config.headers); + const authToken = resolveAuthToken(config, headers); + const password = nonEmpty(config.password); + const role = nonEmpty(config.role) ?? "operator"; + const scopes = toStringArray(config.scopes); + + if (authToken || password) { + checks.push({ + code: "openclaw_gateway_auth_present", + level: "info", + message: "Gateway credentials are configured.", + }); + } else { + checks.push({ + code: "openclaw_gateway_auth_missing", + level: "warn", + message: "No gateway credentials detected in adapter config.", + hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.", + }); + } + + if (url && (url.protocol === "ws:" || url.protocol === "wss:")) { + try { + const probeResult = await probeGateway({ + url: url.toString(), + headers, + authToken, + role, + scopes: scopes.length > 0 ? scopes : ["operator.admin"], + timeoutMs: 3_000, + }); + + if (probeResult === "ok") { + checks.push({ + code: "openclaw_gateway_probe_ok", + level: "info", + message: "Gateway connect probe succeeded.", + }); + } else if (probeResult === "challenge_only") { + checks.push({ + code: "openclaw_gateway_probe_challenge_only", + level: "warn", + message: "Gateway challenge was received, but connect probe was rejected.", + hint: "Check gateway credentials, scopes, role, and device-auth requirements.", + }); + } else { + checks.push({ + code: "openclaw_gateway_probe_failed", + level: "warn", + message: "Gateway probe failed.", + hint: "Verify network reachability and gateway URL from the Paperclip server host.", + }); + } + } catch (err) { + checks.push({ + code: "openclaw_gateway_probe_error", + level: "warn", + message: err instanceof Error ? err.message : "Gateway probe failed", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/openclaw-gateway/src/shared/stream.ts b/packages/adapters/openclaw-gateway/src/shared/stream.ts new file mode 100644 index 00000000..860fc367 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/shared/stream.ts @@ -0,0 +1,16 @@ +export function normalizeOpenClawGatewayStreamLine(rawLine: string): { + stream: "stdout" | "stderr" | null; + line: string; +} { + const trimmed = rawLine.trim(); + if (!trimmed) return { stream: null, line: "" }; + + const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); + if (!prefixed) { + return { stream: null, line: trimmed }; + } + + const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; + const line = (prefixed[2] ?? "").trim(); + return { stream, line }; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts new file mode 100644 index 00000000..fcbbbf4e --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -0,0 +1,13 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.timeoutSec = 120; + ac.waitTimeoutMs = 120000; + ac.sessionKeyStrategy = "fixed"; + ac.sessionKey = "paperclip"; + ac.role = "operator"; + ac.scopes = ["operator.admin"]; + return ac; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/index.ts b/packages/adapters/openclaw-gateway/src/ui/index.ts new file mode 100644 index 00000000..c2ec0bcf --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js"; +export { buildOpenClawGatewayConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts new file mode 100644 index 00000000..c8cb48ae --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts @@ -0,0 +1,75 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] { + const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s); + if (!match) return [{ kind: "stdout", ts, text: line }]; + + const stream = asString(match[2]).toLowerCase(); + const data = asRecord(safeJsonParse(asString(match[3]).trim())); + + if (stream === "assistant") { + const delta = asString(data?.delta); + if (delta.length > 0) { + return [{ kind: "assistant", ts, text: delta, delta: true }]; + } + + const text = asString(data?.text); + if (text.length > 0) { + return [{ kind: "assistant", ts, text }]; + } + return []; + } + + if (stream === "error") { + const message = asString(data?.error) || asString(data?.message); + return message ? [{ kind: "stderr", ts, text: message }] : []; + } + + if (stream === "lifecycle") { + const phase = asString(data?.phase).toLowerCase(); + const message = asString(data?.error) || asString(data?.message); + if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) { + return [{ kind: "stderr", ts, text: message }]; + } + } + + return []; +} + +export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { + const normalized = normalizeOpenClawGatewayStreamLine(line); + if (normalized.stream === "stderr") { + return [{ kind: "stderr", ts, text: normalized.line }]; + } + + const trimmed = normalized.line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[openclaw-gateway:event]")) { + return parseAgentEventLine(trimmed, ts); + } + + if (trimmed.startsWith("[openclaw-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: normalized.line }]; +} diff --git a/packages/adapters/openclaw-gateway/tsconfig.json b/packages/adapters/openclaw-gateway/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/openclaw-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4f6b75b9..5a35a78a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,6 +29,7 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "cursor", "openclaw", + "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492cd35a..80ef3264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -146,6 +149,28 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/openclaw-gateway: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/opencode-local: dependencies: '@paperclipai/adapter-utils': @@ -156,8 +181,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -217,6 +242,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -329,6 +357,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -359,6 +390,9 @@ importers: lucide-react: specifier: ^0.574.0 version: 0.574.0(react@19.2.4) + mermaid: + specifier: ^11.12.0 + version: 11.12.3 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -411,6 +445,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -686,6 +723,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -741,6 +781,21 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@clack/core@0.4.2': resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} @@ -1401,6 +1456,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1571,6 +1632,9 @@ packages: react: '>= 18 || >= 19' react-dom: '>= 18 || >= 19' + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -2798,6 +2862,99 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2816,6 +2973,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2872,6 +3032,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3142,6 +3305,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3186,6 +3357,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3196,6 +3375,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3222,6 +3404,12 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3237,13 +3425,172 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3275,6 +3622,9 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3317,6 +3667,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3676,6 +4030,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3708,6 +4065,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3725,6 +4086,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. @@ -3834,6 +4202,13 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + katex@0.16.37: + resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -3842,6 +4217,16 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lexical@0.35.0: resolution: {integrity: sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==} @@ -3924,6 +4309,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -3955,6 +4343,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4032,6 +4425,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4182,6 +4578,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4264,6 +4663,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4271,6 +4673,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4362,6 +4767,15 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4575,6 +4989,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4583,6 +5000,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4594,6 +5014,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4755,6 +5178,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -4789,6 +5215,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4819,6 +5249,10 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4846,6 +5280,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4918,6 +5355,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -5046,6 +5487,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5100,6 +5561,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5728,6 +6194,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -5871,6 +6339,23 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + '@clack/core@0.4.2': dependencies: picocolors: 1.1.1 @@ -6487,6 +6972,14 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + '@inquirer/external-editor@1.0.3(@types/node@25.2.3)': dependencies: chardet: 2.1.1 @@ -6843,6 +7336,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -8162,7 +8659,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/chai@5.2.3': dependencies: @@ -8171,10 +8668,127 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -8189,7 +8803,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8200,6 +8814,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8246,18 +8862,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.12.0 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -8265,13 +8881,16 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@types/ws@8.18.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@ungap/structured-clone@1.3.0': {} @@ -8502,6 +9121,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8551,6 +9184,10 @@ snapshots: commander@13.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + component-emitter@1.3.1: {} compute-scroll-into-view@2.0.4: {} @@ -8562,6 +9199,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -8576,6 +9215,14 @@ snapshots: cookiejar@2.1.4: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -8588,13 +9235,199 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + d@1.0.2: dependencies: es5-ext: 0.10.64 type: 2.7.3 + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + dateformat@4.6.3: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -8616,6 +9449,10 @@ snapshots: defu@6.1.4: {} + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -8647,6 +9484,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -9053,6 +9894,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -9101,6 +9944,10 @@ snapshots: human-id@4.1.3: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -9113,6 +9960,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + intersection-observer@0.10.0: {} ipaddr.js@1.9.1: {} @@ -9189,10 +10040,28 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.37: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + kleur@4.1.5: {} kysely@0.28.11: {} + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lexical@0.35.0: {} lib0@0.2.117: @@ -9252,6 +10121,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.17.23: {} + lodash.startcase@4.4.0: {} longest-streak@3.1.0: {} @@ -9278,6 +10149,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -9480,6 +10353,29 @@ snapshots: merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.2 + katex: 0.16.37 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + methods@1.1.2: {} micromark-core-commonmark@2.0.3: @@ -9797,6 +10693,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -9868,6 +10771,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9880,6 +10785,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9982,6 +10889,19 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -10253,6 +11173,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10286,6 +11208,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -10302,6 +11231,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -10465,6 +11396,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -10505,6 +11438,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10526,6 +11461,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -10552,6 +11489,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10628,6 +11567,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 @@ -10833,6 +11774,23 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} which@2.0.2: diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh new file mode 100755 index 00000000..e20db35d --- /dev/null +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -0,0 +1,752 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[openclaw-gateway-e2e] $*" +} + +warn() { + echo "[openclaw-gateway-e2e] WARN: $*" >&2 +} + +fail() { + echo "[openclaw-gateway-e2e] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +} + +require_cmd curl +require_cmd jq +require_cmd docker +require_cmd node +require_cmd shasum + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" + +COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}" +OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}" +OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}" +OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" +OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}" +OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}" +OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}" +OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}" + +PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}" +CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" +RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}" +STRICT_CASES="${STRICT_CASES:-1}" +AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}" + +AUTH_HEADERS=() +if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then + AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" ) +fi +if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then + AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" ) + PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}" + AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" ) +fi + +RESPONSE_CODE="" +RESPONSE_BODY="" +COMPANY_ID="" +AGENT_ID="" +AGENT_API_KEY="" +JOIN_REQUEST_ID="" +INVITE_ID="" +RUN_ID="" + +CASE_A_ISSUE_ID="" +CASE_B_ISSUE_ID="" +CASE_C_ISSUE_ID="" +CASE_C_CREATED_ISSUE_ID="" + +api_request() { + local method="$1" + local path="$2" + local data="${3-}" + local tmp + tmp="$(mktemp)" + + local url + if [[ "$path" == http://* || "$path" == https://* ]]; then + url="$path" + elif [[ "$path" == /api/* ]]; then + url="${PAPERCLIP_API_URL%/}${path}" + else + url="${API_BASE}${path}" + fi + + if [[ -n "$data" ]]; then + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")" + fi + else + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")" + fi + fi + + RESPONSE_BODY="$(cat "$tmp")" + rm -f "$tmp" +} + +assert_status() { + local expected="$1" + if [[ "$RESPONSE_CODE" != "$expected" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "expected HTTP ${expected}, got ${RESPONSE_CODE}" + fi +} + +require_board_auth() { + if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then + fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER." + fi + api_request "GET" "/companies" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})" + fi +} + +maybe_cleanup_openclaw_docker() { + if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then + log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup" + return + fi + + log "cleaning OpenClaw docker state" + if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true + fi + if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true + fi + docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true +} + +start_openclaw_docker() { + log "starting clean OpenClaw docker" + OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \ + OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \ + ./scripts/smoke/openclaw-docker-ui.sh +} + +wait_http_ready() { + local url="$1" + local timeout_sec="$2" + local started_at now code + started_at="$(date +%s)" + while true; do + code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)" + if [[ "$code" == "200" ]]; then + return 0 + fi + now="$(date +%s)" + if (( now - started_at >= timeout_sec )); then + return 1 + fi + sleep 1 + done +} + +detect_openclaw_container() { + if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + echo "$OPENCLAW_CONTAINER_NAME" + return 0 + fi + + local detected + detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)" + if [[ -n "$detected" ]]; then + echo "$detected" + return 0 + fi + + return 1 +} + +detect_gateway_token() { + if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then + echo "$OPENCLAW_GATEWAY_TOKEN" + return 0 + fi + + local config_path + config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json" + if [[ -f "$config_path" ]]; then + local token + token="$(jq -r '.gateway.auth.token // empty' "$config_path")" + if [[ -n "$token" ]]; then + echo "$token" + return 0 + fi + fi + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + local token_from_container + token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)" + if [[ -n "$token_from_container" ]]; then + echo "$token_from_container" + return 0 + fi + fi + + return 1 +} + +hash_prefix() { + local value="$1" + printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12 +} + +probe_gateway_ws() { + local url="$1" + local token="$2" + + node - "$url" "$token" <<'NODE' +const WebSocket = require("ws"); +const url = process.argv[2]; +const token = process.argv[3]; + +const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }); +const timeout = setTimeout(() => { + console.error("gateway probe timed out"); + process.exit(2); +}, 8000); + +ws.on("message", (raw) => { + try { + const message = JSON.parse(String(raw)); + if (message?.type === "event" && message?.event === "connect.challenge") { + clearTimeout(timeout); + ws.close(); + process.exit(0); + } + } catch { + // ignore + } +}); + +ws.on("error", (err) => { + clearTimeout(timeout); + console.error(err?.message || String(err)); + process.exit(1); +}); +NODE +} + +resolve_company_id() { + api_request "GET" "/companies" + assert_status "200" + + local selector + selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')" + + COMPANY_ID="$(jq -r --arg sel "$selector" ' + map(select( + ((.id // "") | ascii_upcase) == $sel or + ((.name // "") | ascii_upcase) == $sel or + ((.issuePrefix // "") | ascii_upcase) == $sel + )) + | .[0].id // empty + ' <<<"$RESPONSE_BODY")" + + if [[ -z "$COMPANY_ID" ]]; then + local available + available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")" + echo "$available" >&2 + fail "could not find company for selector '${COMPANY_SELECTOR}'" + fi + + log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}" +} + +cleanup_openclaw_agents() { + api_request "GET" "/companies/${COMPANY_ID}/agents" + assert_status "200" + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + log "no prior OpenClaw agents to cleanup" + return + fi + + while IFS= read -r id; do + [[ -n "$id" ]] || continue + log "terminating prior OpenClaw agent ${id}" + api_request "POST" "/agents/${id}/terminate" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "terminate ${id} returned HTTP ${RESPONSE_CODE}" + fi + + api_request "DELETE" "/agents/${id}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "delete ${id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +cleanup_pending_join_requests() { + api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval" + if [[ "$RESPONSE_CODE" != "200" ]]; then + warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})" + return + fi + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + return + fi + + while IFS= read -r request_id; do + [[ -n "$request_id" ]] || continue + log "rejecting stale pending join request ${request_id}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then + warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +create_and_approve_gateway_join() { + local gateway_token="$1" + + local invite_payload + invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')" + api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload" + assert_status "201" + + local invite_token + invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id" + + local join_payload + join_payload="$(jq -nc \ + --arg name "$OPENCLAW_AGENT_NAME" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$gateway_token" \ + --arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \ + '{ + requestType: "agent", + agentName: $name, + adapterType: "openclaw_gateway", + capabilities: "OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + role: "operator", + scopes: ["operator.admin"], + disableDeviceAuth: true, + sessionKeyStrategy: "fixed", + sessionKey: "paperclip", + waitTimeoutMs: 120000, + paperclipApiUrl: $paperclipApiUrl + } + }')" + + api_request "POST" "/invites/${invite_token}/accept" "$join_payload" + assert_status "202" + + JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + local claim_secret + claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")" + local claim_path + claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")" + [[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata" + + log "approving join request ${JOIN_REQUEST_ID}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}" + assert_status "200" + + AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId" + + log "claiming one-time agent API key" + local claim_payload + claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')" + api_request "POST" "$claim_path" "$claim_payload" + assert_status "201" + + AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token" + + persist_claimed_key_artifacts "$RESPONSE_BODY" + inject_agent_api_key_payload_template +} + +persist_claimed_key_artifacts() { + local claim_json="$1" + local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace" + local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip" + local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json" + + mkdir -p "$workspace_dir" "$skill_dir" + printf "%s\n" "$claim_json" > "$claimed_file" + chmod 600 "$claimed_file" + + if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then + api_request "GET" "/skills/paperclip" + assert_status "200" + { + echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}" + echo + printf "%s\n" "$RESPONSE_BODY" + } > "${skill_dir}/SKILL.md" + chmod 600 "${skill_dir}/SKILL.md" + fi + + log "wrote claimed key to ${claimed_file}" +} + +inject_agent_api_key_payload_template() { + [[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID" + [[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY" + + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local patch_payload + patch_payload="$(jq -c --arg token "$AGENT_API_KEY" ' + {adapterConfig: ((.adapterConfig // {}) + { + payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + { + message: ("Set PAPERCLIP_API_KEY=" + $token + " in your run context before running Paperclip heartbeat steps.") + }) + })} + ' <<<"$RESPONSE_BODY")" + + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +trigger_wakeup() { + local reason="$1" + local issue_id="${2:-}" + + local payload + if [[ -n "$issue_id" ]]; then + payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')" + else + payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')" + fi + + api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload" + if [[ "$RESPONSE_CODE" != "202" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "wakeup failed (HTTP ${RESPONSE_CODE})" + fi + + RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + if [[ -z "$RUN_ID" ]]; then + warn "wakeup response did not include run id; body: ${RESPONSE_BODY}" + fi +} + +get_run_status() { + local run_id="$1" + api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1 +} + +wait_for_run_terminal() { + local run_id="$1" + local timeout_sec="$2" + local started now status + + [[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id" + started="$(date +%s)" + + while true; do + status="$(get_run_status "$run_id")" + if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 2 + done +} + +get_issue_status() { + local issue_id="$1" + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.status // empty' <<<"$RESPONSE_BODY" +} + +wait_for_issue_terminal() { + local issue_id="$1" + local timeout_sec="$2" + local started now status + started="$(date +%s)" + + while true; do + status="$(get_issue_status "$issue_id")" + if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 3 + done +} + +issue_comments_contain() { + local issue_id="$1" + local marker="$2" + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "false" + return 0 + fi + jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY" +} + +create_issue_for_case() { + local title="$1" + local description="$2" + local priority="${3:-high}" + + local payload + payload="$(jq -nc \ + --arg title "$title" \ + --arg description "$description" \ + --arg assignee "$AGENT_ID" \ + --arg priority "$priority" \ + '{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')" + + api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload" + assert_status "201" + + local issue_id issue_identifier + issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")" + [[ -n "$issue_id" ]] || fail "issue create missing id" + + echo "${issue_id}|${issue_identifier}" +} + +patch_agent_session_strategy_run() { + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local patch_payload + patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")" + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +find_issue_by_query() { + local query="$1" + local encoded_query + encoded_query="$(jq -rn --arg q "$query" '$q|@uri')" + api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1 +} + +run_case_a() { + local marker="OPENCLAW_CASE_A_OK_$(date +%s)" + local description + description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")" + CASE_A_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case A run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")" + log "case A issue_status=${issue_status} marker_found=${marker_found}" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case A issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case A marker not found in comments" + fi +} + +run_case_b() { + local marker="OPENCLAW_CASE_B_OK_$(date +%s)" + local message_text="${marker}" + local description + description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")" + CASE_B_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case B run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")" + log "case B issue_status=${issue_status} marker_found=${marker_found}" + + warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case B issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case B marker not found in comments" + fi +} + +run_case_c() { + patch_agent_session_strategy_run + + local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)" + local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)" + local description + description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")" + CASE_C_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID" + + local run_status issue_status marker_found created_issue + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case C run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")" + created_issue="$(find_issue_by_query "$marker")" + if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then + created_issue="" + fi + CASE_C_CREATED_ISSUE_ID="$created_issue" + log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case C issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments" + [[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue" + fi +} + +main() { + log "starting OpenClaw gateway E2E smoke" + + wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable" + api_request "GET" "/health" + assert_status "200" + log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" + + require_board_auth + resolve_company_id + cleanup_openclaw_agents + cleanup_pending_join_requests + + maybe_cleanup_openclaw_docker + start_openclaw_docker + wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable" + + local gateway_token + gateway_token="$(detect_gateway_token || true)" + [[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token" + log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))" + + log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}" + probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token" + + create_and_approve_gateway_join "$gateway_token" + log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}" + + trigger_wakeup "openclaw_gateway_smoke_connectivity" + if [[ -n "$RUN_ID" ]]; then + local connect_status + connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + [[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}" + log "connectivity wake run ${RUN_ID} succeeded" + fi + + run_case_a + run_case_b + run_case_c + + log "success" + log "companyId=${COMPANY_ID}" + log "agentId=${AGENT_ID}" + log "inviteId=${INVITE_ID}" + log "joinRequestId=${JOIN_REQUEST_ID}" + log "caseA_issueId=${CASE_A_ISSUE_ID}" + log "caseB_issueId=${CASE_B_ISSUE_ID}" + log "caseC_issueId=${CASE_C_ISSUE_ID}" + log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}" + log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..." +} + +main "$@" diff --git a/server/package.json b/server/package.json index 2e470111..9ec0c494 100644 --- a/server/package.json +++ b/server/package.json @@ -36,6 +36,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts new file mode 100644 index 00000000..df57af32 --- /dev/null +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +function buildContext( + config: Record, + overrides?: Partial, +): AdapterExecutionContext { + return { + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "OpenClaw Gateway Agent", + adapterType: "openclaw_gateway", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: { + taskId: "task-123", + issueId: "issue-123", + wakeReason: "issue_assigned", + issueIds: ["issue-123"], + }, + onLog: async () => {}, + ...overrides, + }; +} + +async function createMockGatewayServer() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "cha" }, + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { delta: "chacha" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +afterEach(() => { + // no global mocks +}); + +describe("openclaw gateway ui stdout parser", () => { + it("parses assistant deltas from gateway event lines", () => { + const ts = "2026-03-06T15:00:00.000Z"; + const line = + '[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}'; + + expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([ + { + kind: "assistant", + ts, + text: "hello", + delta: true, + }, + ]); + }); +}); + +describe("openclaw gateway adapter execute", () => { + it("runs connect -> agent -> agent.wait and forwards wake payload", async () => { + const gateway = await createMockGatewayServer(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.summary).toContain("chachacha"); + expect(result.provider).toBe("openclaw"); + + const payload = gateway.getAgentPayload(); + expect(payload).toBeTruthy(); + expect(payload?.idempotencyKey).toBe("run-123"); + expect(payload?.sessionKey).toBe("paperclip"); + expect(String(payload?.message ?? "")).toContain("wake now"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + + expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); + } finally { + await gateway.close(); + } + }); + + it("fails fast when url is missing", async () => { + const result = await execute(buildContext({})); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("openclaw_gateway_url_missing"); + }); +}); + +describe("openclaw gateway testEnvironment", () => { + it("reports missing url as failure", async () => { + const result = await testEnvironment({ + companyId: "company-123", + adapterType: "openclaw_gateway", + config: {}, + }); + + expect(result.status).toBe("fail"); + expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index cc8c040c..b58712dd 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -35,6 +35,14 @@ import { agentConfigurationDoc as openclawAgentConfigurationDoc, models as openclawModels, } from "@paperclipai/adapter-openclaw"; +import { + execute as openclawGatewayExecute, + testEnvironment as openclawGatewayTestEnvironment, +} from "@paperclipai/adapter-openclaw-gateway/server"; +import { + agentConfigurationDoc as openclawGatewayAgentConfigurationDoc, + models as openclawGatewayModels, +} from "@paperclipai/adapter-openclaw-gateway"; import { listCodexModels } from "./codex-models.js"; import { listCursorModels } from "./cursor-models.js"; import { processAdapter } from "./process/index.js"; @@ -82,6 +90,15 @@ const openclawAdapter: ServerAdapterModule = { agentConfigurationDoc: openclawAgentConfigurationDoc, }; +const openclawGatewayAdapter: ServerAdapterModule = { + type: "openclaw_gateway", + execute: openclawGatewayExecute, + testEnvironment: openclawGatewayTestEnvironment, + models: openclawGatewayModels, + supportsLocalAgentJwt: false, + agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, +}; + const openCodeLocalAdapter: ServerAdapterModule = { type: "opencode_local", execute: openCodeExecute, @@ -94,7 +111,16 @@ const openCodeLocalAdapter: ServerAdapterModule = { }; const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [ + claudeLocalAdapter, + codexLocalAdapter, + openCodeLocalAdapter, + cursorLocalAdapter, + openclawAdapter, + openclawGatewayAdapter, + processAdapter, + httpAdapter, + ].map((a) => [a.type, a]), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/ui/package.json b/ui/package.json index ccd40dd7..e265209f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,8 +17,9 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", - "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.574.0", + "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx new file mode 100644 index 00000000..5bcad80b --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, + help, +} from "../../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +function SecretField({ + label, + value, + onCommit, + placeholder, +}: { + label: string; + value: string; + onCommit: (v: string) => void; + placeholder?: string; +}) { + const [visible, setVisible] = useState(false); + return ( + +
+ + +
+
+ ); +} + +function parseScopes(value: unknown): string { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string").join(", "); + } + return typeof value === "string" ? value : ""; +} + +export function OpenClawGatewayConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const configuredHeaders = + config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) + ? (config.headers as Record) + : {}; + const effectiveHeaders = + (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; + + const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string" + ? String(effectiveHeaders["x-openclaw-token"]) + : typeof effectiveHeaders["x-openclaw-auth"] === "string" + ? String(effectiveHeaders["x-openclaw-auth"]) + : ""; + + const commitGatewayToken = (rawValue: string) => { + const nextValue = rawValue.trim(); + const nextHeaders: Record = { ...effectiveHeaders }; + if (nextValue) { + nextHeaders["x-openclaw-token"] = nextValue; + delete nextHeaders["x-openclaw-auth"]; + } else { + delete nextHeaders["x-openclaw-token"]; + delete nextHeaders["x-openclaw-auth"]; + } + mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); + }; + + const sessionStrategy = eff( + "adapterConfig", + "sessionKeyStrategy", + String(config.sessionKeyStrategy ?? "fixed"), + ); + + return ( + <> + + + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) + } + immediate + className={inputClass} + placeholder="ws://127.0.0.1:18789" + /> + + + {!isCreate && ( + <> + + mark("adapterConfig", "paperclipApiUrl", v || undefined)} + immediate + className={inputClass} + placeholder="https://paperclip.example" + /> + + + + + + + {sessionStrategy === "fixed" && ( + + mark("adapterConfig", "sessionKey", v || undefined)} + immediate + className={inputClass} + placeholder="paperclip" + /> + + )} + + + + + mark("adapterConfig", "role", v || undefined)} + immediate + className={inputClass} + placeholder="operator" + /> + + + + { + const parsed = v + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined); + }} + immediate + className={inputClass} + placeholder="operator.admin" + /> + + + + { + const parsed = Number.parseInt(v.trim(), 10); + mark( + "adapterConfig", + "waitTimeoutMs", + Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + ); + }} + immediate + className={inputClass} + placeholder="120000" + /> + + + + + + + )} + + ); +} diff --git a/ui/src/adapters/openclaw-gateway/index.ts b/ui/src/adapters/openclaw-gateway/index.ts new file mode 100644 index 00000000..812f7de0 --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { OpenClawGatewayConfigFields } from "./config-fields"; + +export const openClawGatewayUIAdapter: UIAdapterModule = { + type: "openclaw_gateway", + label: "OpenClaw Gateway", + parseStdoutLine: parseOpenClawGatewayStdoutLine, + ConfigFields: OpenClawGatewayConfigFields, + buildAdapterConfig: buildOpenClawGatewayConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 2ce643f0..b2b69052 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,11 +4,21 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { openClawUIAdapter } from "./openclaw"; +import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + openCodeLocalUIAdapter, + cursorLocalUIAdapter, + openClawUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index c7fa3647..4e1bc76e 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,6 +19,7 @@ const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 9f15a1fc..02bdf74c 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -157,7 +157,7 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { - if (run.adapterType === "openclaw") { + if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") { continue; } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 77fb4db8..5b1667ce 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,7 +56,8 @@ type AdapterType = | "cursor" | "process" | "http" - | "openclaw"; + | "openclaw" + | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) @@ -672,6 +673,12 @@ export function OnboardingWizard() { desc: "Notify OpenClaw webhook", comingSoon: true }, + { + value: "openclaw_gateway" as const, + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol" + }, { value: "cursor" as const, label: "Cursor", @@ -973,14 +980,14 @@ export function OnboardingWizard() { )} - {(adapterType === "http" || adapterType === "openclaw") && ( + {(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
setUrl(e.target.value)} /> diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 5d1a3539..9f1b3585 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -23,7 +23,7 @@ export const help: Record = { role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", - adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", @@ -54,6 +54,7 @@ export const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 51621ac3..40913804 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { opencode_local: "OpenCode", cursor: "Cursor", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", }; diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index b5487c37..81a13d80 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -20,6 +20,7 @@ const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 6b02c7fb..786b1f87 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -121,6 +121,7 @@ const adapterLabels: Record = { opencode_local: "OpenCode", cursor: "Cursor", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", }; From 10cccc07cd3db1ccd415f178827a48ddfbad89a4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:59:34 -0600 Subject: [PATCH 07/25] feat: deduplicate project shortnames on create and update Ensure unique URL-safe shortnames by appending numeric suffixes when collisions occur. Applied during project creation, update, and company import flows. Co-Authored-By: Claude Opus 4.6 --- .../project-shortname-resolution.test.ts | 45 +++++++++++++ server/src/services/company-portability.ts | 8 +++ server/src/services/projects.ts | 63 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 server/src/__tests__/project-shortname-resolution.test.ts diff --git a/server/src/__tests__/project-shortname-resolution.test.ts b/server/src/__tests__/project-shortname-resolution.test.ts new file mode 100644 index 00000000..5b0ab728 --- /dev/null +++ b/server/src/__tests__/project-shortname-resolution.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectNameForUniqueShortname } from "../services/projects.ts"; + +describe("resolveProjectNameForUniqueShortname", () => { + it("keeps name when shortname is not used", () => { + const resolved = resolveProjectNameForUniqueShortname("Platform", [ + { id: "p1", name: "Growth" }, + ]); + expect(resolved).toBe("Platform"); + }); + + it("appends numeric suffix when shortname collides", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + ]); + expect(resolved).toBe("Growth Team 2"); + }); + + it("increments suffix until unique", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "growth-team-2" }, + ]); + expect(resolved).toBe("Growth Team 3"); + }); + + it("ignores excluded project id", () => { + const resolved = resolveProjectNameForUniqueShortname( + "Growth Team", + [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "platform" }, + ], + { excludeProjectId: "p1" }, + ); + expect(resolved).toBe("Growth Team"); + }); + + it("keeps non-normalizable names unchanged", () => { + const resolved = resolveProjectNameForUniqueShortname("!!!", [ + { id: "p1", name: "growth" }, + ]); + expect(resolved).toBe("!!!"); + }); +}); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cc59063c..afe54ffc 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 3ff3b53b..54d5cd82 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow { primaryWorkspace: ProjectWorkspace | null; } +interface ProjectShortnameRow { + id: string; + name: string; +} + +interface ResolveProjectNameOptions { + excludeProjectId?: string | null; +} + /** Batch-load goal refs for a set of projects. */ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; @@ -192,6 +201,34 @@ function deriveWorkspaceName(input: { return "Workspace"; } +export function resolveProjectNameForUniqueShortname( + requestedName: string, + existingProjects: ProjectShortnameRow[], + options?: ResolveProjectNameOptions, +): string { + const requestedShortname = normalizeProjectUrlKey(requestedName); + if (!requestedShortname) return requestedName; + + const usedShortnames = new Set( + existingProjects + .filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId)) + .map((project) => normalizeProjectUrlKey(project.name)) + .filter((value): value is string => value !== null), + ); + if (!usedShortnames.has(requestedShortname)) return requestedName; + + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidateName = `${requestedName} ${suffix}`; + const candidateShortname = normalizeProjectUrlKey(candidateName); + if (candidateShortname && !usedShortnames.has(candidateShortname)) { + return candidateName; + } + } + + // Fallback guard for pathological naming collisions. + return `${requestedName} ${Date.now()}`; +} + async function ensureSinglePrimaryWorkspace( dbOrTx: any, input: { @@ -271,6 +308,12 @@ export function projectService(db: Db) { projectData.color = nextColor; } + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); + // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; @@ -295,6 +338,26 @@ export function projectService(db: Db) { ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + const existingProject = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null); + if (!existingProject) return null; + + if (projectData.name !== undefined) { + const existingShortname = normalizeProjectUrlKey(existingProject.name); + const nextShortname = normalizeProjectUrlKey(projectData.name); + if (existingShortname !== nextShortname) { + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, existingProject.companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, { + excludeProjectId: id, + }); + } + } // Keep legacy goalId column in sync const updates: Partial = { From f1ad727f8e7c501fdf7f9320390453a028a63c51 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:59:39 -0600 Subject: [PATCH 08/25] feat(ui): render mermaid diagrams in markdown content Lazy-load mermaid.js and render fenced mermaid code blocks as inline SVG diagrams with dark/light mode support. Falls back to showing the source code on parse errors. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/MarkdownBody.tsx | 88 +++++++++++++++++++++++++++++- ui/src/index.css | 34 ++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c1eb59ac..b996629a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; @@ -10,6 +10,30 @@ interface MarkdownBodyProps { className?: string; } +let mermaidLoaderPromise: Promise | null = null; + +function loadMermaid() { + if (!mermaidLoaderPromise) { + mermaidLoaderPromise = import("mermaid").then((module) => module.default); + } + return mermaidLoaderPromise; +} + +function flattenText(value: ReactNode): string { + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map((item) => flattenText(item)).join(""); + return ""; +} + +function extractMermaidSource(children: ReactNode): string | null { + if (!isValidElement(children)) return null; + const childProps = children.props as { className?: unknown; children?: ReactNode }; + if (typeof childProps.className !== "string") return null; + if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null; + return flattenText(childProps.children).replace(/\n$/, ""); +} + function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) return null; @@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined { }; } +function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { + const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setSvg(null); + setError(null); + + loadMermaid() + .then(async (mermaid) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: darkMode ? "dark" : "default", + fontFamily: "inherit", + suppressErrorRendering: true, + }); + const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source); + if (!active) return; + setSvg(rendered.svg); + }) + .catch((err) => { + if (!active) return; + const message = + err instanceof Error && err.message + ? err.message + : "Failed to render Mermaid diagram."; + setError(message); + }); + + return () => { + active = false; + }; + }, [darkMode, renderId, source]); + + return ( +
+ {svg ? ( +
+ ) : ( + <> +

+ {error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."} +

+
+            {source}
+          
+ + )} +
+ ); +} + export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); return ( @@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { { + const mermaidSource = extractMermaidSource(preChildren); + if (mermaidSource) { + return ; + } + return
{preChildren}
; + }, a: ({ href, children: linkChildren }) => { const parsed = href ? parseProjectMentionHref(href) : null; if (parsed) { diff --git a/ui/src/index.css b/ui/src/index.css index 0fa33136..63a8c3dc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -426,6 +426,40 @@ font-weight: 500; } +.paperclip-mermaid { + margin: 0.5rem 0; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + background-color: color-mix(in oklab, var(--accent) 35%, transparent); + overflow-x: auto; +} + +.paperclip-mermaid svg { + display: block; + width: max-content; + max-width: none; + min-width: 100%; + height: auto; +} + +.paperclip-mermaid-status { + margin: 0 0 0.45rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.paperclip-mermaid-status-error { + color: var(--destructive); +} + +.paperclip-mermaid-source { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + /* Project mention chips rendered inside MarkdownBody */ a.paperclip-project-mention-chip { display: inline-flex; From 654463c28f0db13ee648dd2114bb2d86f8a4a7ae Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:59:45 -0600 Subject: [PATCH 09/25] feat(cli): add agent local-cli command for skill install and env export New subcommand to install Paperclip skills for Claude/Codex agents and print the required PAPERCLIP_* environment variables for local CLI usage outside heartbeat runs. Co-Authored-By: Claude Opus 4.6 --- cli/src/commands/client/agent.ts | 197 +++++++++++++++++++++++++++++++ doc/CLI.md | 14 +++ docs/adapters/claude-local.md | 8 ++ docs/adapters/codex-local.md | 8 ++ 4 files changed, 227 insertions(+) diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2a1b4243..c98ca158 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { addCommonClientOptions, formatInlineRecord, @@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions { companyId?: string; } +interface AgentLocalCliOptions extends BaseClientOptions { + companyId?: string; + keyName?: string; + installSkills?: boolean; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface SkillsInstallSummary { + tool: "codex" | "claude"; + target: string; + linked: string[]; + skipped: string[]; + failed: Array<{ name: string; error: string }>; +} + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills + path.resolve(process.cwd(), "skills"), +]; + +function codexSkillsHome(): string { + const fromEnv = process.env.CODEX_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); + return path.join(base, "skills"); +} + +function claudeSkillsHome(): string { + const fromEnv = process.env.CLAUDE_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); + return path.join(base, "skills"); +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +async function installSkillsForTarget( + sourceSkillsDir: string, + targetSkillsDir: string, + tool: "codex" | "claude", +): Promise { + const summary: SkillsInstallSummary = { + tool, + target: targetSkillsDir, + linked: [], + skipped: [], + failed: [], + }; + + await fs.mkdir(targetSkillsDir, { recursive: true }); + const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(sourceSkillsDir, entry.name); + const target = path.join(targetSkillsDir, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + summary.skipped.push(entry.name); + continue; + } + + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + } catch (err) { + summary.failed.push({ + name: entry.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return summary; +} + +function buildAgentEnvExports(input: { + apiBase: string; + companyId: string; + agentId: string; + apiKey: string; +}): string { + const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); + return [ + `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, + `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`, + `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`, + `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`, + ].join("\n"); +} + export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); @@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void { } }), ); + + addCommonClientOptions( + agent + .command("local-cli") + .description( + "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", + ) + .argument("", "Agent ID or shortname/url-key") + .requiredOption("-C, --company-id ", "Company ID") + .option("--key-name ", "API key label", "local-cli") + .option( + "--no-install-skills", + "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", + ) + .action(async (agentRef: string, opts: AgentLocalCliOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); + const agentRow = await ctx.api.get( + `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, + ); + + const now = new Date().toISOString().replaceAll(":", "-"); + const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; + const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + + const installSummaries: SkillsInstallSummary[] = []; + if (opts.installSkills !== false) { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) { + throw new Error( + "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + ); + } + + installSummaries.push( + await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), + await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + ); + } + + const exportsText = buildAgentEnvExports({ + apiBase: ctx.api.apiBase, + companyId: agentRow.companyId, + agentId: agentRow.id, + apiKey: key.token, + }); + + if (ctx.json) { + printOutput( + { + agent: { + id: agentRow.id, + name: agentRow.name, + urlKey: agentRow.urlKey, + companyId: agentRow.companyId, + }, + key: { + id: key.id, + name: key.name, + createdAt: key.createdAt, + token: key.token, + }, + skills: installSummaries, + exports: exportsText, + }, + { json: true }, + ); + return; + } + + console.log(`Agent: ${agentRow.name} (${agentRow.id})`); + console.log(`API key created: ${key.name} (${key.id})`); + if (installSummaries.length > 0) { + for (const summary of installSummaries) { + console.log( + `${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + ); + for (const failed of summary.failed) { + console.log(` failed ${failed.name}: ${failed.error}`); + } + } + } + console.log(""); + console.log("# Run this in your shell before launching codex/claude:"); + console.log(exportsText); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); } diff --git a/doc/CLI.md b/doc/CLI.md index b56abf75..6f945656 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -116,6 +116,20 @@ pnpm paperclipai issue release ```sh pnpm paperclipai agent list --company-id pnpm paperclipai agent get +pnpm paperclipai agent local-cli --company-id +``` + +`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: + +- creates a new long-lived agent API key +- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills` +- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY` + +Example for shortname-based local setup: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +pnpm paperclipai agent local-cli claudecoder --company-id ``` ## Approval Commands diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 254689a2..3b80f288 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory. +For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: + +```sh +pnpm paperclipai agent local-cli claudecoder --company-id +``` + +This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test Use the "Test Environment" button in the UI to validate the adapter config. It checks: diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index d87172f8..60725a49 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +``` + +This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test The environment test checks: From 0315e4cdc2b3c5a1674ebec640fc0b7d744ba972 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 08:59:51 -0600 Subject: [PATCH 10/25] docs: add local-cli mode and self-test playbook to paperclip skill Document the agent local-cli command for manual CLI usage and add a step-by-step self-test playbook for validating assignment flows. Co-Authored-By: Claude Opus 4.6 --- skills/paperclip/SKILL.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index b42355d9..92fe3ba4 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -16,6 +16,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. + **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. ## The Heartbeat Procedure @@ -222,6 +224,43 @@ GET /api/companies/{companyId}/issues?q=dockerfile Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`). +## Self-Test Playbook (App-Level) + +Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions). + +1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`): + +```bash +pnpm paperclipai issue create \ + --company-id "$PAPERCLIP_COMPANY_ID" \ + --title "Self-test: assignment/watch flow" \ + --description "Temporary validation issue" \ + --status todo \ + --assignee-agent-id "$PAPERCLIP_AGENT_ID" +``` + +2. Trigger and watch a heartbeat for that assignee: + +```bash +pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" +``` + +3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted: + +```bash +pnpm paperclipai issue get +``` + +4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: + +```bash +pnpm paperclipai issue update --assignee-agent-id --status todo +``` + +5. Cleanup: mark temporary issues done/cancelled with a clear note. + +If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat. + ## Full Reference For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md` From 4bd69610206bc0ef40b0d734df5a61d07242ca9e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 09:22:40 -0600 Subject: [PATCH 11/25] fix(openclaw-gateway): add diagnostics capture and two-lane validation to e2e Capture run events, logs, issue state, and container logs on failures or timeouts for debugging. Write compatibility JSON keys for claimed API key. Add two-lane validation requirement to test plan. Co-Authored-By: Claude Opus 4.6 --- .../doc/ONBOARDING_AND_TEST_PLAN.md | 5 + scripts/smoke/openclaw-gateway-e2e.sh | 128 +++++++++++++++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 965d8179..17057c89 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -21,6 +21,10 @@ These are mandatory for onboarding and smoke testing: - If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). - Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. +3. **Two-lane validation is required** +- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate. +- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path. + ## External Protocol Constraints OpenClaw docs to anchor behavior: - Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook @@ -233,6 +237,7 @@ POST /api/companies/$CLA_COMPANY_ID/invites 3. Approve join request. 4. Claim API key with `claimSecret`. 5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. + - Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch. 6. Ensure Paperclip skill is installed for OpenClaw runtime. 7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only. diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh index e20db35d..e45df9f9 100755 --- a/scripts/smoke/openclaw-gateway-e2e.sh +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -50,6 +50,10 @@ CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}" STRICT_CASES="${STRICT_CASES:-1}" AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}" +OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}" +OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}" +OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}" +PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}" AUTH_HEADERS=() if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then @@ -109,6 +113,57 @@ api_request() { rm -f "$tmp" } +capture_run_diagnostics() { + local run_id="$1" + local label="${2:-run}" + [[ -n "$run_id" ]] || return 0 + + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json" + else + warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi + + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json" + jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true + else + warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi +} + +capture_issue_diagnostics() { + local issue_id="$1" + local label="${2:-issue}" + [[ -n "$issue_id" ]] || return 0 + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json" + fi + + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json" + fi +} + +capture_openclaw_container_logs() { + mkdir -p "$OPENCLAW_DIAG_DIR" + local container + container="$(detect_openclaw_container || true)" + if [[ -z "$container" ]]; then + warn "could not detect OpenClaw container for diagnostics" + return 0 + fi + docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true +} + assert_status() { local expected="$1" if [[ "$RESPONSE_CODE" != "$expected" ]]; then @@ -351,6 +406,8 @@ create_and_approve_gateway_join() { --arg url "$OPENCLAW_GATEWAY_URL" \ --arg token "$gateway_token" \ --arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \ + --argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \ + --argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \ '{ requestType: "agent", agentName: $name, @@ -364,7 +421,8 @@ create_and_approve_gateway_join() { disableDeviceAuth: true, sessionKeyStrategy: "fixed", sessionKey: "paperclip", - waitTimeoutMs: 120000, + timeoutSec: $timeoutSec, + waitTimeoutMs: $waitTimeoutMs, paperclipApiUrl: $paperclipApiUrl } }')" @@ -404,10 +462,27 @@ persist_claimed_key_artifacts() { local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace" local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip" local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json" + local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json" mkdir -p "$workspace_dir" "$skill_dir" - printf "%s\n" "$claim_json" > "$claimed_file" - chmod 600 "$claimed_file" + local token + token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")" + [[ -n "$token" ]] || fail "claim response missing token/apiKey" + + printf "%s\n" "$claim_json" > "$claimed_raw_file" + chmod 600 "$claimed_raw_file" + + jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file" + # Keep this readable for OpenClaw runtime users across sandbox/container contexts. + chmod 644 "$claimed_file" + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true + docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + fi if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then api_request "GET" "/skills/paperclip" @@ -420,7 +495,7 @@ persist_claimed_key_artifacts() { chmod 600 "${skill_dir}/SKILL.md" fi - log "wrote claimed key to ${claimed_file}" + log "wrote claimed key artifacts to ${claimed_file} and ${claimed_raw_file}" } inject_agent_api_key_payload_template() { @@ -430,11 +505,17 @@ inject_agent_api_key_payload_template() { api_request "GET" "/agents/${AGENT_ID}" assert_status "200" + local base_message + base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps." + if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then + base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}" + fi + local patch_payload - patch_payload="$(jq -c --arg token "$AGENT_API_KEY" ' + patch_payload="$(jq -c --arg message "$base_message" ' {adapterConfig: ((.adapterConfig // {}) + { payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + { - message: ("Set PAPERCLIP_API_KEY=" + $token + " in your run context before running Paperclip heartbeat steps.") + message: $message }) })} ' <<<"$RESPONSE_BODY")" @@ -487,12 +568,18 @@ wait_for_run_terminal() { while true; do status="$(get_run_status "$run_id")" if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then + if [[ "$status" != "succeeded" ]]; then + capture_run_diagnostics "$run_id" "run-nonsuccess" + capture_openclaw_container_logs + fi echo "$status" return 0 fi now="$(date +%s)" if (( now - started >= timeout_sec )); then + capture_run_diagnostics "$run_id" "run-timeout" + capture_openclaw_container_logs echo "timeout" return 0 fi @@ -614,6 +701,14 @@ run_case_a() { marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")" log "case A issue_status=${issue_status} marker_found=${marker_found}" + if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-a" + fi + capture_openclaw_container_logs + fi + if [[ "$STRICT_CASES" == "1" ]]; then [[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed" [[ "$issue_status" == "done" ]] || fail "case A issue did not reach done" @@ -647,6 +742,14 @@ run_case_b() { marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")" log "case B issue_status=${issue_status} marker_found=${marker_found}" + if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-b" + fi + capture_openclaw_container_logs + fi + warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat" if [[ "$STRICT_CASES" == "1" ]]; then @@ -689,6 +792,17 @@ run_case_c() { CASE_C_CREATED_ISSUE_ID="$created_issue" log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}" + if [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c" + if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created" + fi + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-c" + fi + capture_openclaw_container_logs + fi + if [[ "$STRICT_CASES" == "1" ]]; then [[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed" [[ "$issue_status" == "done" ]] || fail "case C issue did not reach done" @@ -699,6 +813,8 @@ run_case_c() { main() { log "starting OpenClaw gateway E2E smoke" + mkdir -p "$OPENCLAW_DIAG_DIR" + log "diagnostics dir: ${OPENCLAW_DIAG_DIR}" wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable" api_request "GET" "/health" From a251a535712f2cce61153dc638d3271a3c0a7692 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 09:52:01 -0600 Subject: [PATCH 12/25] fix(openclaw): embed explicit heartbeat workflow in wake text Include the full Paperclip API workflow (checkout, fetch, update) and endpoint bans in the wake text sent to OpenClaw agents, preventing them from guessing non-existent endpoints. Applied to both openclaw and openclaw_gateway adapters. Co-Authored-By: Claude Opus 4.6 --- .../openclaw-gateway/src/server/execute.ts | 35 +++++++++++++++++-- .../openclaw/src/server/execute-common.ts | 35 +++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 3cc20533..407e455b 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -328,15 +328,21 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record"; + const lines = [ "Paperclip wake event for a cloud adapter.", "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", "Set these values in your run context:", ...envLines, `PAPERCLIP_API_KEY=`, "", `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, "", + `api_base=${apiBaseHint}`, `task_id=${payload.taskId ?? ""}`, `issue_id=${payload.issueId ?? ""}`, `wake_reason=${payload.wakeReason ?? ""}`, @@ -344,9 +350,34 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record"; + const lines = [ "Paperclip wake event for a cloud adapter.", "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", "Set these values in your run context:", ...envLines, `PAPERCLIP_API_KEY=`, "", `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, "", + `api_base=${apiBaseHint}`, `task_id=${payload.taskId ?? ""}`, `issue_id=${payload.issueId ?? ""}`, `wake_reason=${payload.wakeReason ?? ""}`, @@ -306,9 +312,34 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record Date: Sat, 7 Mar 2026 09:52:08 -0600 Subject: [PATCH 13/25] docs(openclaw-gateway): record e2e execution findings from stock lane validation Document observed failures before wake-text fix and successful stock-clean lane pass after fix. Note instrumentation lane limitations. Co-Authored-By: Claude Opus 4.6 --- .../doc/ONBOARDING_AND_TEST_PLAN.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 17057c89..6c804d22 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -25,6 +25,28 @@ These are mandatory for onboarding and smoke testing: - Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate. - Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path. +## Execution Findings (2026-03-07) +Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode: + +1. **Baseline failure (before wake-text fix)** +- Stock lane had run-level success but failed functional assertions: + - connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded + - case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded + - issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments +- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints. + +2. **Post-fix validation (stock-clean lane passes)** +- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans: + - connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded` + - case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker + - case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker + - case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10` +- Stock release-gate lane now passes scripted checks. + +3. **Instrumentation lane note** +- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container. +- Keep this lane for diagnostics only; stock lane remains the acceptance gate. + ## External Protocol Constraints OpenClaw docs to anchor behavior: - Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook From fbcd80948efeca5337a778b62255082eeeb7bc9c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 09:56:46 -0600 Subject: [PATCH 14/25] Fix markdown editor escaped list markers --- ui/src/components/MarkdownBody.tsx | 4 ++- ui/src/components/MarkdownEditor.tsx | 20 +++++++----- ui/src/lib/markdown.test.ts | 20 ++++++++++++ ui/src/lib/markdown.ts | 47 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 ui/src/lib/markdown.test.ts create mode 100644 ui/src/lib/markdown.ts diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b996629a..d9a2afb6 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; +import { normalizeMarkdownArtifacts } from "../lib/markdown"; interface MarkdownBodyProps { children: string; @@ -114,6 +115,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); + const normalizedMarkdown = normalizeMarkdownArtifacts(children); return (
- {children} + {normalizedMarkdown}
); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 85b67c32..507308a1 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -29,6 +29,7 @@ import { } from "@mdxeditor/editor"; import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; +import { normalizeMarkdownArtifacts } from "../lib/markdown"; /* ---- Mention types ---- */ @@ -203,7 +204,7 @@ export const MarkdownEditor = forwardRef }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); const ref = useRef(null); - const latestValueRef = useRef(value); + const latestValueRef = useRef(normalizeMarkdownArtifacts(value)); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -281,9 +282,10 @@ export const MarkdownEditor = forwardRef }, [hasImageUpload]); useEffect(() => { - if (value !== latestValueRef.current) { - ref.current?.setMarkdown(value); - latestValueRef.current = value; + const normalizedValue = normalizeMarkdownArtifacts(value); + if (normalizedValue !== latestValueRef.current) { + ref.current?.setMarkdown(normalizedValue); + latestValueRef.current = normalizedValue; } }, [value]); @@ -554,11 +556,15 @@ export const MarkdownEditor = forwardRef > { - latestValueRef.current = next; - onChange(next); + const normalizedNext = normalizeMarkdownArtifacts(next); + latestValueRef.current = normalizedNext; + if (normalizedNext !== next) { + ref.current?.setMarkdown(normalizedNext); + } + onChange(normalizedNext); }} onBlur={() => onBlur?.()} className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} diff --git a/ui/src/lib/markdown.test.ts b/ui/src/lib/markdown.test.ts new file mode 100644 index 00000000..a455ea1f --- /dev/null +++ b/ui/src/lib/markdown.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMarkdownArtifacts } from "./markdown"; + +describe("normalizeMarkdownArtifacts", () => { + it("normalizes escaped unordered list markers and space entities", () => { + const input = "Here is a list:\n\n\\* foo \n\\- bar "; + const output = normalizeMarkdownArtifacts(input); + expect(output).toBe("Here is a list:\n\n* foo \n- bar "); + }); + + it("does not rewrite escaped markers inside fenced code blocks", () => { + const input = "```md\n\\* keep literal \n\\- keep literal \n```"; + expect(normalizeMarkdownArtifacts(input)).toBe(input); + }); + + it("keeps escaped non-list syntax intact", () => { + const input = "\\*not-a-list"; + expect(normalizeMarkdownArtifacts(input)).toBe(input); + }); +}); diff --git a/ui/src/lib/markdown.ts b/ui/src/lib/markdown.ts new file mode 100644 index 00000000..ab485319 --- /dev/null +++ b/ui/src/lib/markdown.ts @@ -0,0 +1,47 @@ +const FENCE_RE = /^\s*(`{3,}|~{3,})/; +const SPACE_ENTITY_RE = / /gi; +const ESCAPED_UNORDERED_LIST_RE = /^(\s{0,3})\\([*+-])([ \t]+)/; + +/** + * Normalize markdown artifacts emitted by rich-text serialization so + * plain markdown list syntax remains usable in Paperclip editors. + */ +export function normalizeMarkdownArtifacts(markdown: string): string { + if (!markdown) return markdown; + + const lines = markdown.split(/\r?\n/); + let inFence = false; + let fenceMarker: "`" | "~" | null = null; + let fenceLength = 0; + let changed = false; + + const normalized = lines.map((line) => { + const fenceMatch = FENCE_RE.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!inFence) { + inFence = true; + fenceMarker = marker[0] as "`" | "~"; + fenceLength = marker.length; + } else if (marker[0] === fenceMarker && marker.length >= fenceLength) { + inFence = false; + fenceMarker = null; + fenceLength = 0; + } + return line; + } + + if (inFence) return line; + + let next = line; + if (next.includes(" ")) { + next = next.replace(SPACE_ENTITY_RE, " "); + } + const unescaped = next.replace(ESCAPED_UNORDERED_LIST_RE, "$1$2$3"); + if (unescaped !== line) changed = true; + return unescaped; + }); + + if (!changed) return markdown; + return normalized.join("\n"); +} From f85f2fbcc278eca3efccd2ee6db3c54001008ab6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 09:57:50 -0600 Subject: [PATCH 15/25] feat(ui): add copy-as-markdown button to comment headers Adds a small copy icon to the right of each comment's date that copies the comment body as raw markdown to the clipboard. Shows a checkmark briefly after copying. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/CommentThread.tsx | 36 +++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f529382b..fa123d31 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re import { Link, useLocation } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; -import { Paperclip } from "lucide-react"; +import { Check, Copy, Paperclip } from "lucide-react"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { MarkdownBody } from "./MarkdownBody"; @@ -92,6 +92,25 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function CopyMarkdownButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -160,12 +179,15 @@ const TimelineList = memo(function TimelineList({ ) : ( )} - - {formatDateTime(comment.createdAt)} - + + + {formatDateTime(comment.createdAt)} + + +
{comment.body} {comment.runId && ( From a4d0901e8957b5ad1aedee79a6b0d3859b765cde Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 10:09:04 -0600 Subject: [PATCH 16/25] Revert "Fix markdown editor escaped list markers" This reverts commit fbcd80948efeca5337a778b62255082eeeb7bc9c. --- ui/src/components/MarkdownBody.tsx | 4 +-- ui/src/components/MarkdownEditor.tsx | 20 +++++------- ui/src/lib/markdown.test.ts | 20 ------------ ui/src/lib/markdown.ts | 47 ---------------------------- 4 files changed, 8 insertions(+), 83 deletions(-) delete mode 100644 ui/src/lib/markdown.test.ts delete mode 100644 ui/src/lib/markdown.ts diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index d9a2afb6..b996629a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -4,7 +4,6 @@ import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; -import { normalizeMarkdownArtifacts } from "../lib/markdown"; interface MarkdownBodyProps { children: string; @@ -115,7 +114,6 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); - const normalizedMarkdown = normalizeMarkdownArtifacts(children); return (
- {normalizedMarkdown} + {children}
); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 507308a1..85b67c32 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -29,7 +29,6 @@ import { } from "@mdxeditor/editor"; import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; -import { normalizeMarkdownArtifacts } from "../lib/markdown"; /* ---- Mention types ---- */ @@ -204,7 +203,7 @@ export const MarkdownEditor = forwardRef }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); const ref = useRef(null); - const latestValueRef = useRef(normalizeMarkdownArtifacts(value)); + const latestValueRef = useRef(value); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -282,10 +281,9 @@ export const MarkdownEditor = forwardRef }, [hasImageUpload]); useEffect(() => { - const normalizedValue = normalizeMarkdownArtifacts(value); - if (normalizedValue !== latestValueRef.current) { - ref.current?.setMarkdown(normalizedValue); - latestValueRef.current = normalizedValue; + if (value !== latestValueRef.current) { + ref.current?.setMarkdown(value); + latestValueRef.current = value; } }, [value]); @@ -556,15 +554,11 @@ export const MarkdownEditor = forwardRef > { - const normalizedNext = normalizeMarkdownArtifacts(next); - latestValueRef.current = normalizedNext; - if (normalizedNext !== next) { - ref.current?.setMarkdown(normalizedNext); - } - onChange(normalizedNext); + latestValueRef.current = next; + onChange(next); }} onBlur={() => onBlur?.()} className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} diff --git a/ui/src/lib/markdown.test.ts b/ui/src/lib/markdown.test.ts deleted file mode 100644 index a455ea1f..00000000 --- a/ui/src/lib/markdown.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeMarkdownArtifacts } from "./markdown"; - -describe("normalizeMarkdownArtifacts", () => { - it("normalizes escaped unordered list markers and space entities", () => { - const input = "Here is a list:\n\n\\* foo \n\\- bar "; - const output = normalizeMarkdownArtifacts(input); - expect(output).toBe("Here is a list:\n\n* foo \n- bar "); - }); - - it("does not rewrite escaped markers inside fenced code blocks", () => { - const input = "```md\n\\* keep literal \n\\- keep literal \n```"; - expect(normalizeMarkdownArtifacts(input)).toBe(input); - }); - - it("keeps escaped non-list syntax intact", () => { - const input = "\\*not-a-list"; - expect(normalizeMarkdownArtifacts(input)).toBe(input); - }); -}); diff --git a/ui/src/lib/markdown.ts b/ui/src/lib/markdown.ts deleted file mode 100644 index ab485319..00000000 --- a/ui/src/lib/markdown.ts +++ /dev/null @@ -1,47 +0,0 @@ -const FENCE_RE = /^\s*(`{3,}|~{3,})/; -const SPACE_ENTITY_RE = / /gi; -const ESCAPED_UNORDERED_LIST_RE = /^(\s{0,3})\\([*+-])([ \t]+)/; - -/** - * Normalize markdown artifacts emitted by rich-text serialization so - * plain markdown list syntax remains usable in Paperclip editors. - */ -export function normalizeMarkdownArtifacts(markdown: string): string { - if (!markdown) return markdown; - - const lines = markdown.split(/\r?\n/); - let inFence = false; - let fenceMarker: "`" | "~" | null = null; - let fenceLength = 0; - let changed = false; - - const normalized = lines.map((line) => { - const fenceMatch = FENCE_RE.exec(line); - if (fenceMatch) { - const marker = fenceMatch[1]; - if (!inFence) { - inFence = true; - fenceMarker = marker[0] as "`" | "~"; - fenceLength = marker.length; - } else if (marker[0] === fenceMarker && marker.length >= fenceLength) { - inFence = false; - fenceMarker = null; - fenceLength = 0; - } - return line; - } - - if (inFence) return line; - - let next = line; - if (next.includes(" ")) { - next = next.replace(SPACE_ENTITY_RE, " "); - } - const unescaped = next.replace(ESCAPED_UNORDERED_LIST_RE, "$1$2$3"); - if (unescaped !== line) changed = true; - return unescaped; - }); - - if (!changed) return markdown; - return normalized.join("\n"); -} From baa71d6a08c7e363cf679ae2fc41618e42c2feae Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 10:16:31 -0600 Subject: [PATCH 17/25] fix(ui): enforce min 2-line height for agent issue titles on dashboard Short titles now always occupy two lines of vertical space, ensuring consistent horizontal alignment across agent cards. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/ActiveAgentsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 5332eb8a..2c382a9e 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -461,7 +461,7 @@ function AgentRunCard({ Date: Sat, 7 Mar 2026 10:19:51 -0600 Subject: [PATCH 18/25] feat(ui): add plus button to sidebar AGENTS header Add a "+" button next to "AGENTS" in the sidebar, matching the existing pattern used by Projects. Clicking it opens the agent creation choice modal. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/SidebarAgents.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 25b59814..9d36377f 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, Plus } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -40,6 +41,7 @@ function sortByHierarchy(agents: Agent[]): Agent[] { export function SidebarAgents() { const [open, setOpen] = useState(true); const { selectedCompanyId } = useCompany(); + const { openNewAgent } = useDialog(); const { isMobile, setSidebarOpen } = useSidebar(); const location = useLocation(); @@ -89,6 +91,16 @@ export function SidebarAgents() { Agents +
From 1dac0ec7cf1358c669504a81f252b1a743e70e79 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 10:32:22 -0600 Subject: [PATCH 19/25] docs: add manual OpenClaw onboarding checklist --- doc/OPENCLAW_ONBOARDING.md | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 doc/OPENCLAW_ONBOARDING.md diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md new file mode 100644 index 00000000..e31a6f8b --- /dev/null +++ b/doc/OPENCLAW_ONBOARDING.md @@ -0,0 +1,55 @@ +Use this exact checklist. + +1. Start Paperclip in auth mode. +```bash +cd +pnpm dev --tailscale-auth +``` +Then verify: +```bash +curl -sS http://127.0.0.1:3100/api/health | jq +``` + +2. Start a clean/stock OpenClaw Docker. +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +Open the printed `Dashboard URL` (includes `#token=...`) in your browser. + +3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. + +4. Use the agent snippet flow. +- Copy the snippet from company settings. +- Paste it into OpenClaw main chat as one message. +- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` + +5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. + +6. Case A (manual issue test). +- Create an issue assigned to the OpenClaw agent. +- Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” +- Verify in UI: issue status becomes `done` and comment exists. + +7. Case B (message tool test). +- Create another issue assigned to OpenClaw. +- Instructions: “send `OPENCLAW_CASE_B_OK_` to main webchat via message tool, then comment same marker on issue, then mark done.” +- Verify both: + - marker comment on issue + - marker text appears in OpenClaw main chat + +8. Case C (new session memory/skills test). +- In OpenClaw, start `/new` session. +- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_`. +- Verify in Paperclip UI that new issue exists. + +9. Watch logs during test (optional but helpful): +```bash +docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway +``` + +10. Expected pass criteria. +- Case A: `done` + marker comment. +- Case B: `done` + marker comment + main-chat message visible. +- Case C: original task done and new issue created from `/new` session. + +If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI. From dd1464384844544e8fd247f869d6ce2bb6590dd5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 10:32:22 -0600 Subject: [PATCH 20/25] ui: unify comment/update issue toasts --- ui/src/context/LiveUpdatesProvider.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 3363b9a5..25d0381e 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -184,8 +184,8 @@ function buildActivityToast( } if (action === "issue.updated") { - if (details?.reopened === true && readString(details.source) === "comment") { - // Reopen-via-comment emits a paired comment event; show one combined toast on the comment event. + if (readString(details?.source) === "comment") { + // Comment-driven updates emit a paired comment event; show one combined toast on the comment event. return null; } const changeDesc = describeIssueUpdate(details); @@ -208,13 +208,18 @@ function buildActivityToast( const commentId = readString(details?.commentId); const bodySnippet = readString(details?.bodySnippet); const reopened = details?.reopened === true; + const updated = details?.updated === true; const reopenedFrom = readString(details?.reopenedFrom); const reopenedLabel = reopened ? reopenedFrom ? `reopened from ${reopenedFrom.replace(/_/g, " ")}` : "reopened" : null; - const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`; + const title = reopened + ? `${actor} reopened and commented on ${issue.ref}` + : updated + ? `${actor} commented and updated ${issue.ref}` + : `${actor} commented on ${issue.ref}`; const body = bodySnippet ? reopenedLabel ? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}` From 9ac2e71187ea5aa33a378543454c7c54da46a281 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:09:52 -0600 Subject: [PATCH 21/25] fix(smoke): pin OpenClaw docker harness to stable stock ref --- scripts/smoke/openclaw-docker-ui.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/smoke/openclaw-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index c8d32068..0ce522e9 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -56,6 +56,7 @@ require_cmd grep OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}" OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" @@ -101,14 +102,23 @@ fi log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR" if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then - git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet origin || true - git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet main || true - git -C "$OPENCLAW_DOCKER_DIR" pull --ff-only --quiet origin main || true + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true else rm -rf "$OPENCLAW_DOCKER_DIR" git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR" + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true fi +resolved_openclaw_ref="" +if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF" +elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="$OPENCLAW_REPO_REF" +fi +[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR" +git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref" +log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))" + if [[ "$OPENCLAW_BUILD" == "1" ]]; then log "building Docker image $OPENCLAW_IMAGE" docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR" From db15dfaf5eaa200f4b547924db05fce8b7189a50 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:15:51 -0600 Subject: [PATCH 22/25] fix(server): return 404 JSON for unmatched API routes Add catch-all handler after API router to return a proper 404 JSON response instead of falling through to the SPA handler. Co-Authored-By: Claude Opus 4.6 --- server/src/app.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/app.ts b/server/src/app.ts index 1faab285..5ce4e292 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -121,6 +121,9 @@ export async function createApp( }), ); app.use("/api", api); + app.use("/api", (_req, res) => { + res.status(404).json({ error: "API route not found" }); + }); const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (opts.uiMode === "static") { From 22053d18e4b60da02d620fe4edcfee90ef32dbc0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:18:23 -0600 Subject: [PATCH 23/25] chore: regenerate pnpm-lock.yaml after merge Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 958 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 958 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2d1d55..f46a192d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -149,6 +152,28 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/openclaw-gateway: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/opencode-local: dependencies: '@paperclipai/adapter-utils': @@ -236,6 +261,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -351,6 +379,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -384,6 +415,9 @@ importers: lucide-react: specifier: ^0.574.0 version: 0.574.0(react@19.2.4) + mermaid: + specifier: ^11.12.0 + version: 11.12.3 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -436,6 +470,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -711,6 +748,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -766,6 +806,21 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@clack/core@0.4.2': resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} @@ -1426,6 +1481,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1596,6 +1657,9 @@ packages: react: '>= 18 || >= 19' react-dom: '>= 18 || >= 19' + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -2823,6 +2887,99 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2841,6 +2998,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2897,6 +3057,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3167,6 +3330,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3211,6 +3382,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3221,6 +3400,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3247,6 +3429,12 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3262,13 +3450,172 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3300,6 +3647,9 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3342,6 +3692,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3701,6 +4055,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3733,6 +4090,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3750,6 +4111,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. @@ -3859,6 +4227,13 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + katex@0.16.37: + resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -3867,6 +4242,16 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lexical@0.35.0: resolution: {integrity: sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==} @@ -3949,6 +4334,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -3980,6 +4368,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4057,6 +4450,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4207,6 +4603,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4289,6 +4688,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4296,6 +4698,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4387,6 +4792,15 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4600,6 +5014,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4608,6 +5025,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4619,6 +5039,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4780,6 +5203,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -4814,6 +5240,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4844,6 +5274,10 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4871,6 +5305,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4943,6 +5380,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -5071,6 +5512,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5125,6 +5586,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5753,6 +6219,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -5896,6 +6364,23 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + '@clack/core@0.4.2': dependencies: picocolors: 1.1.1 @@ -6512,6 +6997,14 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + '@inquirer/external-editor@1.0.3(@types/node@25.2.3)': dependencies: chardet: 2.1.1 @@ -6868,6 +7361,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -8200,6 +8697,123 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -8225,6 +8839,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8290,6 +8906,9 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8527,6 +9146,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8576,6 +9209,10 @@ snapshots: commander@13.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + component-emitter@1.3.1: {} compute-scroll-into-view@2.0.4: {} @@ -8587,6 +9224,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -8601,6 +9240,14 @@ snapshots: cookiejar@2.1.4: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -8613,13 +9260,199 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + d@1.0.2: dependencies: es5-ext: 0.10.64 type: 2.7.3 + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + dateformat@4.6.3: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -8641,6 +9474,10 @@ snapshots: defu@6.1.4: {} + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -8672,6 +9509,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -9078,6 +9919,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -9126,6 +9969,10 @@ snapshots: human-id@4.1.3: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -9138,6 +9985,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + intersection-observer@0.10.0: {} ipaddr.js@1.9.1: {} @@ -9214,10 +10065,28 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.37: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + kleur@4.1.5: {} kysely@0.28.11: {} + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lexical@0.35.0: {} lib0@0.2.117: @@ -9277,6 +10146,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.17.23: {} + lodash.startcase@4.4.0: {} longest-streak@3.1.0: {} @@ -9303,6 +10174,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -9505,6 +10378,29 @@ snapshots: merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.2 + katex: 0.16.37 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + methods@1.1.2: {} micromark-core-commonmark@2.0.3: @@ -9822,6 +10718,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -9893,6 +10796,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9905,6 +10810,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -10007,6 +10914,19 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -10278,6 +11198,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10311,6 +11233,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -10327,6 +11256,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -10490,6 +11421,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -10530,6 +11463,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10551,6 +11486,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -10577,6 +11514,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10653,6 +11592,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 @@ -10858,6 +11799,23 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} which@2.0.2: From 1420b86aa7dd31432933d5a38c35dafabc246da9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:19:03 -0600 Subject: [PATCH 24/25] fix(server): attach raw Error to res.err and avoid pino err key collision Extract attachErrorContext helper to DRY up the error handler, attach the original Error object to res.err so pino can serialize stack traces, and rename the log context key from err to errorContext so it doesn't clash with pino's built-in err serializer. Co-Authored-By: Claude Opus 4.6 --- server/src/__tests__/error-handler.test.ts | 53 ++++++++++++++++++++++ server/src/middleware/error-handler.ts | 50 +++++++++++++------- server/src/middleware/logger.ts | 2 +- 3 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/error-handler.test.ts diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts new file mode 100644 index 00000000..d01a8c3c --- /dev/null +++ b/server/src/__tests__/error-handler.test.ts @@ -0,0 +1,53 @@ +import type { NextFunction, Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import { HttpError } from "../errors.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +function makeReq(): Request { + return { + method: "GET", + originalUrl: "/api/test", + body: { a: 1 }, + params: { id: "123" }, + query: { q: "x" }, + } as unknown as Request; +} + +function makeRes(): Response { + const res = { + status: vi.fn(), + json: vi.fn(), + } as unknown as Response; + (res.status as unknown as ReturnType).mockReturnValue(res); + return res; +} + +describe("errorHandler", () => { + it("attaches the original Error to res.err for 500s", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new Error("boom"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("boom"); + }); + + it("attaches HttpError instances for 500 responses", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new HttpError(500, "db exploded"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "db exploded" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("db exploded"); + }); +}); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 293c42ab..7f86dfd0 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -11,6 +11,25 @@ export interface ErrorContext { reqQuery?: unknown; } +function attachErrorContext( + req: Request, + res: Response, + payload: ErrorContext["error"], + rawError?: Error, +) { + (res as any).__errorContext = { + error: payload, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + } satisfies ErrorContext; + if (rawError) { + (res as any).err = rawError; + } +} + export function errorHandler( err: unknown, req: Request, @@ -19,14 +38,12 @@ export function errorHandler( ) { if (err instanceof HttpError) { if (err.status >= 500) { - (res as any).__errorContext = { - error: { message: err.message, stack: err.stack, name: err.name, details: err.details }, - method: req.method, - url: req.originalUrl, - reqBody: req.body, - reqParams: req.params, - reqQuery: req.query, - } satisfies ErrorContext; + attachErrorContext( + req, + res, + { message: err.message, stack: err.stack, name: err.name, details: err.details }, + err, + ); } res.status(err.status).json({ error: err.message, @@ -40,16 +57,15 @@ export function errorHandler( return; } - (res as any).__errorContext = { - error: err instanceof Error + const rootError = err instanceof Error ? err : new Error(String(err)); + attachErrorContext( + req, + res, + err instanceof Error ? { message: err.message, stack: err.stack, name: err.name } - : { message: String(err), raw: err }, - method: req.method, - url: req.originalUrl, - reqBody: req.body, - reqParams: req.params, - reqQuery: req.query, - } satisfies ErrorContext; + : { message: String(err), raw: err, stack: rootError.stack, name: rootError.name }, + rootError, + ); res.status(500).json({ error: "Internal server error" }); } diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index dd826c06..be47e3c5 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -62,7 +62,7 @@ export const httpLogger = pinoHttp({ const ctx = (res as any).__errorContext; if (ctx) { return { - err: ctx.error, + errorContext: ctx.error, reqBody: ctx.reqBody, reqParams: ctx.reqParams, reqQuery: ctx.reqQuery, From 1c1b86f4956066f4eb79d938421cf54403757295 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:19:08 -0600 Subject: [PATCH 25/25] fix(issues): guard missing companyId and enrich activity log context Add 400 response for /issues without companyId, tag issue.updated activity with source:comment when triggered by a comment, and mark comment activities with updated:true when field changes accompany them. Co-Authored-By: Claude Opus 4.6 --- server/src/routes/issues.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 113bbdb8..8d21fe65 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -184,6 +184,13 @@ export function issueRoutes(db: Db, storage: StorageService) { } }); + // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues". + router.get("/issues", (_req, res) => { + res.status(400).json({ + error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", + }); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -522,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) { } const actor = getActorInfo(req); + const hasFieldChanges = Object.keys(previous).length > 0; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -531,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) { action: "issue.updated", entityType: "issue", entityId: issue.id, - details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined }, + details: { + ...updateFields, + identifier: issue.identifier, + ...(commentBody ? { source: "comment" } : {}), + _previous: hasFieldChanges ? previous : undefined, + }, }); let comment = null; @@ -555,6 +568,7 @@ export function issueRoutes(db: Db, storage: StorageService) { bodySnippet: comment.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, + ...(hasFieldChanges ? { updated: true } : {}), }, });