@swarmclawai/swarmclaw 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/package.json +2 -2
- package/src/app/api/notifications/route.ts +11 -12
- package/src/app/page.tsx +9 -0
- package/src/components/chat/chat-list.tsx +10 -9
- package/src/components/home/home-view.tsx +13 -2
- package/src/components/layout/app-layout.tsx +1 -0
- package/src/components/shared/command-palette.tsx +4 -1
- package/src/components/shared/notification-center.tsx +7 -1
- package/src/components/shared/search-dialog.tsx +10 -2
- package/src/lib/local-observability.test.ts +73 -0
- package/src/lib/local-observability.ts +47 -0
- package/src/lib/notification-utils.test.ts +72 -0
- package/src/lib/notification-utils.ts +68 -0
- package/src/lib/providers/openclaw.test.ts +21 -1
- package/src/lib/providers/openclaw.ts +22 -0
- package/src/lib/runtime-loop.ts +1 -1
- package/src/lib/server/agent-thread-session.test.ts +41 -0
- package/src/lib/server/agent-thread-session.ts +1 -0
- package/src/lib/server/chat-execution-advanced.test.ts +7 -0
- package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
- package/src/lib/server/chat-execution.ts +22 -5
- package/src/lib/server/create-notification.test.ts +94 -0
- package/src/lib/server/create-notification.ts +31 -25
- package/src/lib/server/daemon-state.test.ts +50 -0
- package/src/lib/server/daemon-state.ts +121 -38
- package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
- package/src/lib/server/eval/agent-regression.test.ts +13 -1
- package/src/lib/server/eval/agent-regression.ts +221 -1
- package/src/lib/server/memory-policy.test.ts +32 -0
- package/src/lib/server/memory-policy.ts +25 -0
- package/src/lib/server/plugins-advanced.test.ts +7 -0
- package/src/lib/server/runtime-settings.test.ts +2 -2
- package/src/lib/server/session-tools/crud.test.ts +136 -0
- package/src/lib/server/session-tools/crud.ts +44 -2
- package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
- package/src/lib/server/session-tools/delegate.ts +30 -0
- package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
- package/src/lib/server/session-tools/discovery.ts +7 -6
- package/src/lib/server/session-tools/memory.ts +156 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
- package/src/lib/server/session-tools/subagent.ts +4 -4
- package/src/lib/server/storage.ts +14 -1
- package/src/lib/server/stream-agent-chat.test.ts +78 -1
- package/src/lib/server/stream-agent-chat.ts +225 -22
- package/src/lib/server/tool-aliases.ts +1 -1
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/stores/use-app-store.ts +26 -1
- package/src/types/index.ts +4 -0
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
148
148
|
```
|
|
149
149
|
|
|
150
150
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
151
|
-
To pin a version: `SWARMCLAW_VERSION=v0.8.
|
|
151
|
+
To pin a version: `SWARMCLAW_VERSION=v0.8.2 curl ... | bash`
|
|
152
152
|
|
|
153
153
|
Or run locally from the repo (friendly for non-technical users):
|
|
154
154
|
|
|
@@ -701,7 +701,7 @@ npm run update:easy # safe update helper for local installs
|
|
|
701
701
|
SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
|
|
702
702
|
|
|
703
703
|
```bash
|
|
704
|
-
# example minor release (v0.8.
|
|
704
|
+
# example minor release (v0.8.2 style)
|
|
705
705
|
npm version minor
|
|
706
706
|
git push origin main --follow-tags
|
|
707
707
|
```
|
|
@@ -711,13 +711,14 @@ On `v*` tags, GitHub Actions will:
|
|
|
711
711
|
2. Create a GitHub Release
|
|
712
712
|
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
|
|
713
713
|
|
|
714
|
-
#### v0.8.
|
|
714
|
+
#### v0.8.2 Release Readiness Notes
|
|
715
715
|
|
|
716
|
-
Before shipping `v0.8.
|
|
716
|
+
Before shipping `v0.8.2`, confirm the following user-facing changes are reflected in docs:
|
|
717
717
|
|
|
718
|
-
1.
|
|
719
|
-
2.
|
|
720
|
-
3.
|
|
718
|
+
1. Runtime/defaults docs mention the higher default agent recursion limit so long-running bounded turns get more headroom without custom tuning.
|
|
719
|
+
2. Memory/tooling docs mention the narrower direct-memory-write routing: remember-and-confirm turns stay on `memory_store`/`memory_update`, bundled related facts should be stored as one canonical write, and same-thread recall should not steal those turns.
|
|
720
|
+
3. File-output guidance notes that exact bullet-count and titled-section constraints are now treated as hard structure requirements during deliverable follow-through.
|
|
721
|
+
4. Site and README install/version strings are updated to `v0.8.2`, including install snippets, release notes index text, and sidebar/footer labels.
|
|
721
722
|
|
|
722
723
|
## CLI
|
|
723
724
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/swarmclawai/swarmclaw.git"
|
|
12
|
+
"url": "git+https://github.com/swarmclawai/swarmclaw.git"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"ai",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { getNotificationActivityAt } from '@/lib/notification-utils'
|
|
3
|
+
import { createNotification } from '@/lib/server/create-notification'
|
|
4
|
+
import { loadNotifications, deleteNotification } from '@/lib/server/storage'
|
|
4
5
|
import { notify } from '@/lib/server/ws-hub'
|
|
5
6
|
import type { AppNotification } from '@/types'
|
|
6
7
|
export const dynamic = 'force-dynamic'
|
|
@@ -20,7 +21,7 @@ export async function GET(req: Request) {
|
|
|
20
21
|
entries = entries.filter((e) => !e.read)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
entries.sort((a, b) => b
|
|
24
|
+
entries.sort((a, b) => getNotificationActivityAt(b) - getNotificationActivityAt(a))
|
|
24
25
|
entries = entries.slice(0, limit)
|
|
25
26
|
|
|
26
27
|
return NextResponse.json(entries)
|
|
@@ -30,9 +31,10 @@ export async function POST(req: Request) {
|
|
|
30
31
|
const body = (await req.json()) as Record<string, unknown>
|
|
31
32
|
const actionLabel = typeof body.actionLabel === 'string' ? body.actionLabel : undefined
|
|
32
33
|
const actionUrl = typeof body.actionUrl === 'string' ? body.actionUrl : undefined
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
|
|
35
|
+
? body.dedupKey.trim()
|
|
36
|
+
: undefined
|
|
37
|
+
const { notification, created } = createNotification({
|
|
36
38
|
type: (['info', 'success', 'warning', 'error'].includes(body.type as string) ? body.type : 'info') as AppNotification['type'],
|
|
37
39
|
title: typeof body.title === 'string' ? body.title : 'Notification',
|
|
38
40
|
message: typeof body.message === 'string' ? body.message : undefined,
|
|
@@ -40,13 +42,10 @@ export async function POST(req: Request) {
|
|
|
40
42
|
actionUrl,
|
|
41
43
|
entityType: typeof body.entityType === 'string' ? body.entityType : undefined,
|
|
42
44
|
entityId: typeof body.entityId === 'string' ? body.entityId : undefined,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
45
|
+
dedupKey,
|
|
46
|
+
})
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
notify('notifications')
|
|
49
|
-
return NextResponse.json(notification, { status: 201 })
|
|
48
|
+
return NextResponse.json(notification, { status: created ? 201 : 200 })
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
export async function DELETE(req: Request) {
|
package/src/app/page.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
|
|
|
7
7
|
import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
|
|
8
8
|
import { connectWs, disconnectWs } from '@/lib/ws-client'
|
|
9
9
|
import { fetchWithTimeout } from '@/lib/fetch-timeout'
|
|
10
|
+
import { isLocalhostBrowser } from '@/lib/local-observability'
|
|
10
11
|
import { useWs } from '@/hooks/use-ws'
|
|
11
12
|
import { AccessKeyGate } from '@/components/auth/access-key-gate'
|
|
12
13
|
import { UserPicker } from '@/components/auth/user-picker'
|
|
@@ -270,6 +271,14 @@ export default function Home() {
|
|
|
270
271
|
|
|
271
272
|
useWs('sessions', loadSessions, 5000)
|
|
272
273
|
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (!authenticated || !isLocalhostBrowser()) return
|
|
276
|
+
const pollId = setInterval(() => {
|
|
277
|
+
void loadSessions()
|
|
278
|
+
}, 5000)
|
|
279
|
+
return () => clearInterval(pollId)
|
|
280
|
+
}, [authenticated, loadSessions])
|
|
281
|
+
|
|
273
282
|
// Auto-select agent's thread on load — resolves a persisted agentId into a session,
|
|
274
283
|
// or falls back to defaultAgentId from settings, then first agent.
|
|
275
284
|
const [agentReady, setAgentReady] = useState(false)
|
|
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
5
5
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
6
6
|
import { ChatCard } from './chat-card'
|
|
7
7
|
import { fetchMessages } from '@/lib/chats'
|
|
8
|
+
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
8
9
|
import { toast } from 'sonner'
|
|
9
10
|
import { Skeleton } from '@/components/shared/skeleton'
|
|
10
11
|
import { EmptyState } from '@/components/shared/empty-state'
|
|
@@ -38,6 +39,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
38
39
|
const [typeFilter, setTypeFilter] = useState<SessionFilter>('all')
|
|
39
40
|
const [sortMode, setSortMode] = useState<SortMode>('lastActive')
|
|
40
41
|
const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
|
|
42
|
+
const [showLocalPlatformSessions, setShowLocalPlatformSessions] = useState(false)
|
|
41
43
|
const [bulkMenuOpen, setBulkMenuOpen] = useState(false)
|
|
42
44
|
const [confirmClearIds, setConfirmClearIds] = useState<string[] | null>(null)
|
|
43
45
|
const [clearing, setClearing] = useState(false)
|
|
@@ -50,16 +52,15 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
50
52
|
void loadConnectors()
|
|
51
53
|
}, [loadConnectors])
|
|
52
54
|
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setShowLocalPlatformSessions(isLocalhostBrowser())
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
53
59
|
const allUserSessions = useMemo(() => {
|
|
54
|
-
return Object.values(sessions).filter((s) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const isUnownedLegacy = !owner
|
|
59
|
-
if (!isCurrentUserOwned && !isPlatformOwned && !isUnownedLegacy) return false
|
|
60
|
-
return true
|
|
61
|
-
})
|
|
62
|
-
}, [sessions, currentUser])
|
|
60
|
+
return Object.values(sessions).filter((s) => isVisibleSessionForViewer(s, currentUser, {
|
|
61
|
+
localhost: showLocalPlatformSessions,
|
|
62
|
+
}))
|
|
63
|
+
}, [sessions, currentUser, showLocalPlatformSessions])
|
|
63
64
|
|
|
64
65
|
const filtered = useMemo(() => {
|
|
65
66
|
return allUserSessions
|
|
@@ -6,6 +6,8 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
6
6
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
7
7
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
8
8
|
import { api } from '@/lib/api-client'
|
|
9
|
+
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
10
|
+
import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
|
|
9
11
|
import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
|
|
10
12
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
11
13
|
|
|
@@ -68,6 +70,7 @@ const PLATFORM_LABELS: Record<string, string> = {
|
|
|
68
70
|
export function HomeView() {
|
|
69
71
|
const agents = useAppStore((s) => s.agents)
|
|
70
72
|
const sessions = useAppStore((s) => s.sessions)
|
|
73
|
+
const currentUser = useAppStore((s) => s.currentUser)
|
|
71
74
|
const tasks = useAppStore((s) => s.tasks)
|
|
72
75
|
const connectors = useAppStore((s) => s.connectors)
|
|
73
76
|
const schedules = useAppStore((s) => s.schedules)
|
|
@@ -95,9 +98,10 @@ export function HomeView() {
|
|
|
95
98
|
const recentChats = useMemo(
|
|
96
99
|
() =>
|
|
97
100
|
Object.values(sessions)
|
|
101
|
+
.filter((session) => isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() }))
|
|
98
102
|
.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0))
|
|
99
103
|
.slice(0, 5),
|
|
100
|
-
[sessions],
|
|
104
|
+
[currentUser, sessions],
|
|
101
105
|
)
|
|
102
106
|
|
|
103
107
|
// Quick stats
|
|
@@ -382,7 +386,14 @@ export function HomeView() {
|
|
|
382
386
|
<span className="text-[13px] font-500 text-text">{n.title}</span>
|
|
383
387
|
{n.message && <p className="text-[11px] text-text-3/60 truncate mt-0.5 m-0">{n.message}</p>}
|
|
384
388
|
</div>
|
|
385
|
-
<
|
|
389
|
+
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
|
390
|
+
{getNotificationOccurrenceCount(n) > 1 && (
|
|
391
|
+
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-600 text-text-3/80">
|
|
392
|
+
x{getNotificationOccurrenceCount(n)}
|
|
393
|
+
</span>
|
|
394
|
+
)}
|
|
395
|
+
<span className="text-[10px] text-text-3/40">{timeAgo(getNotificationActivityAt(n))}</span>
|
|
396
|
+
</div>
|
|
386
397
|
</button>
|
|
387
398
|
))}
|
|
388
399
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
+
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
5
6
|
import { toast } from 'sonner'
|
|
6
7
|
|
|
7
8
|
interface CommandItem {
|
|
@@ -22,6 +23,7 @@ export function CommandPalette() {
|
|
|
22
23
|
|
|
23
24
|
const agents = useAppStore((s) => s.agents)
|
|
24
25
|
const sessions = useAppStore((s) => s.sessions)
|
|
26
|
+
const currentUser = useAppStore((s) => s.currentUser)
|
|
25
27
|
const tasks = useAppStore((s) => s.tasks)
|
|
26
28
|
const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
|
|
27
29
|
const setCurrentSession = useAppStore((s) => s.setCurrentSession)
|
|
@@ -149,6 +151,7 @@ export function CommandPalette() {
|
|
|
149
151
|
|
|
150
152
|
// Chats (sessions)
|
|
151
153
|
for (const session of Object.values(sessions)) {
|
|
154
|
+
if (!isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() })) continue
|
|
152
155
|
const sessionAgent = session.agentId ? agents[session.agentId] : null
|
|
153
156
|
result.push({
|
|
154
157
|
id: `chat:${session.id}`,
|
|
@@ -174,7 +177,7 @@ export function CommandPalette() {
|
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
return result
|
|
177
|
-
}, [agents, openSettingsSection, sessions, setActiveView, setCurrentAgent, setCurrentSession, setEditingTaskId, setTaskSheetOpen, tasks])
|
|
180
|
+
}, [agents, currentUser, openSettingsSection, sessions, setActiveView, setCurrentAgent, setCurrentSession, setEditingTaskId, setTaskSheetOpen, tasks])
|
|
178
181
|
|
|
179
182
|
const filtered = useMemo(() => {
|
|
180
183
|
if (!query.trim()) return items.slice(0, 20)
|
|
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
|
|
|
5
5
|
import type { CSSProperties } from 'react'
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
7
|
import { useWs } from '@/hooks/use-ws'
|
|
8
|
+
import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
|
|
8
9
|
import type { AppNotification } from '@/types'
|
|
9
10
|
|
|
10
11
|
function timeAgo(ts: number): string {
|
|
@@ -207,7 +208,12 @@ export function NotificationCenter({
|
|
|
207
208
|
<div className="flex-1 min-w-0">
|
|
208
209
|
<div className="flex items-center gap-2">
|
|
209
210
|
<span className="text-[12px] font-600 text-text truncate flex-1">{n.title}</span>
|
|
210
|
-
|
|
211
|
+
{getNotificationOccurrenceCount(n) > 1 && (
|
|
212
|
+
<span className="shrink-0 rounded-full border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-600 text-text-3/80">
|
|
213
|
+
x{getNotificationOccurrenceCount(n)}
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
<span className="text-[10px] text-text-3/50 shrink-0">{timeAgo(getNotificationActivityAt(n))}</span>
|
|
211
217
|
</div>
|
|
212
218
|
{n.message && (
|
|
213
219
|
<p className="text-[11px] text-text-3 mt-0.5 leading-relaxed line-clamp-2 m-0">
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
4
4
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
import { api } from '@/lib/api-client'
|
|
7
|
+
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
7
8
|
import type { AppView } from '@/types'
|
|
8
9
|
|
|
9
10
|
interface SearchResult {
|
|
@@ -61,6 +62,8 @@ export function SearchDialog() {
|
|
|
61
62
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
62
63
|
const listRef = useRef<HTMLDivElement>(null)
|
|
63
64
|
|
|
65
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
66
|
+
const currentUser = useAppStore((s) => s.currentUser)
|
|
64
67
|
const setActiveView = useAppStore((s) => s.setActiveView)
|
|
65
68
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
|
66
69
|
const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
|
|
@@ -114,14 +117,19 @@ export function SearchDialog() {
|
|
|
114
117
|
setLoading(true)
|
|
115
118
|
try {
|
|
116
119
|
const data = await api<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`)
|
|
117
|
-
setResults(data.results)
|
|
120
|
+
setResults(data.results.filter((result) => {
|
|
121
|
+
if (result.type !== 'session' && result.type !== 'message') return true
|
|
122
|
+
const session = sessions[result.id]
|
|
123
|
+
if (!session) return true
|
|
124
|
+
return isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() })
|
|
125
|
+
}))
|
|
118
126
|
setSelectedIdx(0)
|
|
119
127
|
} catch {
|
|
120
128
|
setResults([])
|
|
121
129
|
} finally {
|
|
122
130
|
setLoading(false)
|
|
123
131
|
}
|
|
124
|
-
}, [])
|
|
132
|
+
}, [currentUser, sessions])
|
|
125
133
|
|
|
126
134
|
const handleQueryChange = (value: string) => {
|
|
127
135
|
setQuery(value)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { afterEach, describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import type { Session } from '@/types'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
findLatestObservablePlatformSession,
|
|
8
|
+
isLocalhostBrowser,
|
|
9
|
+
isVisibleSessionForViewer,
|
|
10
|
+
} from './local-observability'
|
|
11
|
+
|
|
12
|
+
const originalWindow = globalThis.window
|
|
13
|
+
|
|
14
|
+
function makeSession(overrides: Partial<Session>): Session {
|
|
15
|
+
return {
|
|
16
|
+
id: overrides.id || 'session-test',
|
|
17
|
+
name: overrides.name || 'Test Session',
|
|
18
|
+
user: overrides.user || 'default',
|
|
19
|
+
messages: overrides.messages || [],
|
|
20
|
+
createdAt: overrides.createdAt || 1,
|
|
21
|
+
updatedAt: overrides.updatedAt || overrides.createdAt || 1,
|
|
22
|
+
lastActiveAt: overrides.lastActiveAt || overrides.updatedAt || overrides.createdAt || 1,
|
|
23
|
+
provider: overrides.provider || 'openai',
|
|
24
|
+
model: overrides.model || 'gpt-test',
|
|
25
|
+
...overrides,
|
|
26
|
+
} as Session
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (originalWindow === undefined) {
|
|
31
|
+
delete (globalThis as { window?: Window }).window
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
globalThis.window = originalWindow
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('local observability', () => {
|
|
38
|
+
it('shows observable platform sessions only on localhost', () => {
|
|
39
|
+
const workbench = makeSession({ id: 'wb-1', user: 'workbench' })
|
|
40
|
+
const swarm = makeSession({ id: 'sw-1', user: 'swarm' })
|
|
41
|
+
const mine = makeSession({ id: 'me-1', user: 'wayde' })
|
|
42
|
+
|
|
43
|
+
assert.equal(isVisibleSessionForViewer(workbench, 'wayde', { localhost: false }), false)
|
|
44
|
+
assert.equal(isVisibleSessionForViewer(workbench, 'wayde', { localhost: true }), true)
|
|
45
|
+
assert.equal(isVisibleSessionForViewer(swarm, 'wayde', { localhost: false }), true)
|
|
46
|
+
assert.equal(isVisibleSessionForViewer(mine, 'wayde', { localhost: false }), true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('prefers the latest live observable platform session for an agent', () => {
|
|
50
|
+
const sessions: Record<string, Session> = {
|
|
51
|
+
old: makeSession({ id: 'old', agentId: 'agent-1', user: 'workbench', lastActiveAt: 100 }),
|
|
52
|
+
shortcut: makeSession({
|
|
53
|
+
id: 'shortcut',
|
|
54
|
+
agentId: 'agent-1',
|
|
55
|
+
user: 'workbench',
|
|
56
|
+
lastActiveAt: 500,
|
|
57
|
+
shortcutForAgentId: 'agent-1',
|
|
58
|
+
}),
|
|
59
|
+
latest: makeSession({ id: 'latest', agentId: 'agent-1', user: 'comparison-bench', lastActiveAt: 300 }),
|
|
60
|
+
otherAgent: makeSession({ id: 'other', agentId: 'agent-2', user: 'workbench', lastActiveAt: 999 }),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
assert.equal(findLatestObservablePlatformSession(sessions, 'agent-1')?.id, 'latest')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('detects localhost browser hosts', () => {
|
|
67
|
+
globalThis.window = { location: { hostname: 'localhost' } } as Window
|
|
68
|
+
assert.equal(isLocalhostBrowser(), true)
|
|
69
|
+
|
|
70
|
+
globalThis.window = { location: { hostname: 'swarmclaw.ai' } } as Window
|
|
71
|
+
assert.equal(isLocalhostBrowser(), false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Session } from '@/types'
|
|
2
|
+
|
|
3
|
+
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0'])
|
|
4
|
+
const OBSERVABLE_PLATFORM_SESSION_OWNERS = new Set(['workbench', 'comparison-bench'])
|
|
5
|
+
const VISIBLE_NON_USER_SESSION_OWNERS = new Set(['system', 'connector', 'swarm'])
|
|
6
|
+
|
|
7
|
+
function normalizeHostname(hostname: string): string {
|
|
8
|
+
return hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isLocalhostBrowser(): boolean {
|
|
12
|
+
if (typeof window === 'undefined') return false
|
|
13
|
+
return LOCALHOST_HOSTNAMES.has(normalizeHostname(window.location.hostname))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isObservablePlatformSessionOwner(user: string | null | undefined): boolean {
|
|
17
|
+
const normalized = typeof user === 'string' ? user.trim().toLowerCase() : ''
|
|
18
|
+
return OBSERVABLE_PLATFORM_SESSION_OWNERS.has(normalized)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isVisibleSessionForViewer(
|
|
22
|
+
session: Session,
|
|
23
|
+
currentUser: string | null | undefined,
|
|
24
|
+
options?: { localhost?: boolean },
|
|
25
|
+
): boolean {
|
|
26
|
+
const owner = (session.user || '').trim().toLowerCase()
|
|
27
|
+
if (!owner) return true
|
|
28
|
+
if (currentUser && owner === currentUser.trim().toLowerCase()) return true
|
|
29
|
+
if (VISIBLE_NON_USER_SESSION_OWNERS.has(owner)) return true
|
|
30
|
+
return options?.localhost === true && isObservablePlatformSessionOwner(owner)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findLatestObservablePlatformSession(
|
|
34
|
+
sessions: Record<string, Session>,
|
|
35
|
+
agentId: string,
|
|
36
|
+
): Session | null {
|
|
37
|
+
let latest: Session | null = null
|
|
38
|
+
for (const session of Object.values(sessions)) {
|
|
39
|
+
if (session.agentId !== agentId) continue
|
|
40
|
+
if (!isObservablePlatformSessionOwner(session.user)) continue
|
|
41
|
+
if (session.shortcutForAgentId) continue
|
|
42
|
+
if (!latest || (session.lastActiveAt || session.createdAt || 0) > (latest.lastActiveAt || latest.createdAt || 0)) {
|
|
43
|
+
latest = session
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return latest
|
|
47
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getNotificationActivityAt,
|
|
6
|
+
getNotificationOccurrenceCount,
|
|
7
|
+
upsertNotificationRecord,
|
|
8
|
+
} from './notification-utils'
|
|
9
|
+
|
|
10
|
+
describe('notification utils', () => {
|
|
11
|
+
it('creates fresh notifications with a count and activity timestamp', () => {
|
|
12
|
+
const { created, notification } = upsertNotificationRecord(
|
|
13
|
+
null,
|
|
14
|
+
{
|
|
15
|
+
type: 'info',
|
|
16
|
+
title: 'Provider unreachable',
|
|
17
|
+
message: 'Gateway timeout',
|
|
18
|
+
dedupKey: 'provider-down:test',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
now: 1_700_000_000_000,
|
|
22
|
+
createId: () => 'notif_1',
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
assert.equal(created, true)
|
|
27
|
+
assert.equal(notification.id, 'notif_1')
|
|
28
|
+
assert.equal(notification.read, false)
|
|
29
|
+
assert.equal(notification.createdAt, 1_700_000_000_000)
|
|
30
|
+
assert.equal(notification.updatedAt, 1_700_000_000_000)
|
|
31
|
+
assert.equal(notification.occurrenceCount, 1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('refreshes an existing notification instead of duplicating it', () => {
|
|
35
|
+
const { created, notification } = upsertNotificationRecord(
|
|
36
|
+
{
|
|
37
|
+
id: 'notif_existing',
|
|
38
|
+
type: 'warning',
|
|
39
|
+
title: 'Provider unreachable',
|
|
40
|
+
message: 'Old failure',
|
|
41
|
+
dedupKey: 'provider-down:test',
|
|
42
|
+
read: true,
|
|
43
|
+
createdAt: 1_700_000_000_000,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'warning',
|
|
47
|
+
title: 'Provider unreachable',
|
|
48
|
+
message: 'Still down',
|
|
49
|
+
dedupKey: 'provider-down:test',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
now: 1_700_000_123_000,
|
|
53
|
+
createId: () => 'unused',
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert.equal(created, false)
|
|
58
|
+
assert.equal(notification.id, 'notif_existing')
|
|
59
|
+
assert.equal(notification.read, false)
|
|
60
|
+
assert.equal(notification.createdAt, 1_700_000_000_000)
|
|
61
|
+
assert.equal(notification.updatedAt, 1_700_000_123_000)
|
|
62
|
+
assert.equal(notification.occurrenceCount, 2)
|
|
63
|
+
assert.equal(notification.message, 'Still down')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('prefers updatedAt when sorting and formatting activity', () => {
|
|
67
|
+
assert.equal(getNotificationActivityAt({ createdAt: 100, updatedAt: 250 }), 250)
|
|
68
|
+
assert.equal(getNotificationActivityAt({ createdAt: 100 }), 100)
|
|
69
|
+
assert.equal(getNotificationOccurrenceCount({ occurrenceCount: 4 }), 4)
|
|
70
|
+
assert.equal(getNotificationOccurrenceCount({ occurrenceCount: 0 }), 1)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AppNotification } from '@/types'
|
|
2
|
+
|
|
3
|
+
export type NotificationDraft = Pick<
|
|
4
|
+
AppNotification,
|
|
5
|
+
'type' | 'title' | 'message' | 'actionLabel' | 'actionUrl' | 'entityType' | 'entityId' | 'dedupKey'
|
|
6
|
+
>
|
|
7
|
+
|
|
8
|
+
export function getNotificationActivityAt(
|
|
9
|
+
notification: Pick<AppNotification, 'createdAt' | 'updatedAt'>,
|
|
10
|
+
): number {
|
|
11
|
+
return typeof notification.updatedAt === 'number' ? notification.updatedAt : notification.createdAt
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getNotificationOccurrenceCount(
|
|
15
|
+
notification: Pick<AppNotification, 'occurrenceCount'>,
|
|
16
|
+
): number {
|
|
17
|
+
return typeof notification.occurrenceCount === 'number' && notification.occurrenceCount > 1
|
|
18
|
+
? notification.occurrenceCount
|
|
19
|
+
: 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function upsertNotificationRecord(
|
|
23
|
+
existing: AppNotification | null | undefined,
|
|
24
|
+
draft: NotificationDraft,
|
|
25
|
+
options: {
|
|
26
|
+
now: number
|
|
27
|
+
createId: () => string
|
|
28
|
+
},
|
|
29
|
+
): { notification: AppNotification; created: boolean } {
|
|
30
|
+
if (existing) {
|
|
31
|
+
return {
|
|
32
|
+
created: false,
|
|
33
|
+
notification: {
|
|
34
|
+
...existing,
|
|
35
|
+
type: draft.type,
|
|
36
|
+
title: draft.title,
|
|
37
|
+
message: draft.message,
|
|
38
|
+
actionLabel: draft.actionLabel,
|
|
39
|
+
actionUrl: draft.actionUrl,
|
|
40
|
+
entityType: draft.entityType,
|
|
41
|
+
entityId: draft.entityId,
|
|
42
|
+
dedupKey: draft.dedupKey,
|
|
43
|
+
read: false,
|
|
44
|
+
updatedAt: options.now,
|
|
45
|
+
occurrenceCount: getNotificationOccurrenceCount(existing) + 1,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
created: true,
|
|
52
|
+
notification: {
|
|
53
|
+
id: options.createId(),
|
|
54
|
+
type: draft.type,
|
|
55
|
+
title: draft.title,
|
|
56
|
+
message: draft.message,
|
|
57
|
+
actionLabel: draft.actionLabel,
|
|
58
|
+
actionUrl: draft.actionUrl,
|
|
59
|
+
entityType: draft.entityType,
|
|
60
|
+
entityId: draft.entityId,
|
|
61
|
+
dedupKey: draft.dedupKey,
|
|
62
|
+
read: false,
|
|
63
|
+
createdAt: options.now,
|
|
64
|
+
updatedAt: options.now,
|
|
65
|
+
occurrenceCount: 1,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { afterEach, test } from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { resolveGatewayAgentId } from './openclaw'
|
|
4
|
+
import { buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
|
|
5
5
|
import { loadAgents, saveAgents } from '../server/storage'
|
|
6
6
|
import type { Agent } from '@/types'
|
|
7
7
|
|
|
@@ -52,3 +52,23 @@ test('resolveGatewayAgentId falls back to the session name when no OpenClaw agen
|
|
|
52
52
|
|
|
53
53
|
assert.equal(resolved, 'fallback-agent-name')
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
test('buildOpenClawSessionKey namespaces sessions by agent and local session id', () => {
|
|
57
|
+
const sessionKey = buildOpenClawSessionKey({
|
|
58
|
+
id: 'cmp-session-1',
|
|
59
|
+
agentId: 'openclaw-agent-test',
|
|
60
|
+
name: 'Ignored Name',
|
|
61
|
+
}, 'Research Operator')
|
|
62
|
+
|
|
63
|
+
assert.equal(sessionKey, 'agent:research-operator:swarm:cmp-session-1')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('buildOpenClawSessionKey honors explicit OpenClaw session keys when provided', () => {
|
|
67
|
+
const sessionKey = buildOpenClawSessionKey({
|
|
68
|
+
id: 'cmp-session-2',
|
|
69
|
+
name: 'Ignored Name',
|
|
70
|
+
openclawSessionKey: 'agent:ops:benchmark:fixed-key',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
assert.equal(sessionKey, 'agent:ops:benchmark:fixed-key')
|
|
74
|
+
})
|
|
@@ -302,6 +302,25 @@ export function resolveGatewayAgentId(session: Record<string, unknown> & { id: s
|
|
|
302
302
|
return 'main'
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
export function buildOpenClawSessionKey(
|
|
306
|
+
session: Record<string, unknown> & { id: string },
|
|
307
|
+
gatewayAgentId?: string,
|
|
308
|
+
): string {
|
|
309
|
+
const explicit = typeof (session as { openclawSessionKey?: unknown }).openclawSessionKey === 'string'
|
|
310
|
+
? String((session as { openclawSessionKey?: unknown }).openclawSessionKey).trim()
|
|
311
|
+
: ''
|
|
312
|
+
if (explicit) return explicit
|
|
313
|
+
|
|
314
|
+
const sessionId = typeof session.id === 'string' && session.id.trim()
|
|
315
|
+
? session.id.trim()
|
|
316
|
+
: randomUUID()
|
|
317
|
+
const agentId = typeof gatewayAgentId === 'string' && gatewayAgentId.trim()
|
|
318
|
+
? normalizeOpenClawAgentId(gatewayAgentId)
|
|
319
|
+
: resolveGatewayAgentId(session)
|
|
320
|
+
|
|
321
|
+
return `agent:${agentId}:swarm:${sessionId}`
|
|
322
|
+
}
|
|
323
|
+
|
|
305
324
|
async function rpcOnConnectedGateway(
|
|
306
325
|
ws: InstanceType<typeof WebSocket>,
|
|
307
326
|
method: string,
|
|
@@ -425,6 +444,7 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
|
|
|
425
444
|
|
|
426
445
|
const ws = result.ws
|
|
427
446
|
const gatewayAgentId = await resolveConnectedGatewayAgentId(ws, session)
|
|
447
|
+
const sessionKey = buildOpenClawSessionKey(session, gatewayAgentId)
|
|
428
448
|
const timeout = setTimeout(() => {
|
|
429
449
|
ws.close()
|
|
430
450
|
finish('OpenClaw gateway timed out after 120s.')
|
|
@@ -445,6 +465,8 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
|
|
|
445
465
|
params: {
|
|
446
466
|
message: prompt,
|
|
447
467
|
agentId: gatewayAgentId,
|
|
468
|
+
sessionKey,
|
|
469
|
+
label: typeof session.name === 'string' && session.name.trim() ? session.name.trim() : undefined,
|
|
448
470
|
timeout: 120,
|
|
449
471
|
idempotencyKey: randomUUID(),
|
|
450
472
|
},
|