@swarmclawai/swarmclaw 0.5.1 → 0.5.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/LICENSE +21 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/src/app/api/activity/route.ts +30 -0
- package/src/app/api/agents/[id]/route.ts +3 -1
- package/src/app/api/agents/route.ts +2 -1
- package/src/app/api/connectors/[id]/route.ts +4 -1
- package/src/app/api/openclaw/approvals/route.ts +20 -0
- package/src/app/api/tasks/[id]/route.ts +37 -1
- package/src/app/api/tasks/route.ts +7 -1
- package/src/app/api/usage/route.ts +74 -22
- package/src/app/api/webhooks/[id]/route.ts +62 -22
- package/src/cli/index.js +7 -0
- package/src/cli/spec.js +6 -0
- package/src/components/activity/activity-feed.tsx +91 -0
- package/src/components/chat/exec-approval-card.tsx +6 -3
- package/src/components/layout/app-layout.tsx +21 -7
- package/src/components/tasks/task-board.tsx +40 -2
- package/src/components/tasks/task-card.tsx +40 -2
- package/src/components/tasks/task-sheet.tsx +147 -1
- package/src/components/usage/metrics-dashboard.tsx +278 -0
- package/src/hooks/use-page-active.ts +21 -0
- package/src/hooks/use-ws.ts +13 -1
- package/src/lib/fetch-dedup.ts +20 -0
- package/src/lib/optimistic.ts +25 -0
- package/src/lib/server/connectors/manager.ts +18 -0
- package/src/lib/server/daemon-state.ts +205 -20
- package/src/lib/server/queue.ts +16 -0
- package/src/lib/server/storage.ts +34 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/lib/ws-client.ts +2 -1
- package/src/stores/use-app-store.ts +48 -1
- package/src/stores/use-approval-store.ts +21 -7
- package/src/types/index.ts +40 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
|
6
|
+
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
|
7
|
+
} from 'recharts'
|
|
8
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
9
|
+
import { useWs } from '@/hooks/use-ws'
|
|
10
|
+
import { api } from '@/lib/api-client'
|
|
11
|
+
import type { BoardTask } from '@/types'
|
|
12
|
+
|
|
13
|
+
type Range = '24h' | '7d' | '30d'
|
|
14
|
+
|
|
15
|
+
interface TimePoint {
|
|
16
|
+
bucket: string
|
|
17
|
+
tokens: number
|
|
18
|
+
cost: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UsageResponse {
|
|
22
|
+
records: unknown[]
|
|
23
|
+
totalTokens: number
|
|
24
|
+
totalCost: number
|
|
25
|
+
byAgent: Record<string, { tokens: number; cost: number }>
|
|
26
|
+
byProvider: Record<string, { tokens: number; cost: number }>
|
|
27
|
+
timeSeries: TimePoint[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RANGES: Range[] = ['24h', '7d', '30d']
|
|
31
|
+
const RANGE_LABELS: Record<Range, string> = { '24h': '24 Hours', '7d': '7 Days', '30d': '30 Days' }
|
|
32
|
+
|
|
33
|
+
const CHART_COLORS = [
|
|
34
|
+
'#818CF8', '#34D399', '#F59E0B', '#F87171',
|
|
35
|
+
'#A78BFA', '#2DD4BF', '#FB923C', '#E879F9',
|
|
36
|
+
'#60A5FA', '#4ADE80',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function formatTokens(n: number): string {
|
|
40
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
41
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
42
|
+
return String(n)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatCost(n: number): string {
|
|
46
|
+
return `$${n.toFixed(4)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatBucketLabel(bucket: string, range: Range): string {
|
|
50
|
+
if (range === '24h') {
|
|
51
|
+
// "2026-03-01T14" → "14:00"
|
|
52
|
+
const hour = bucket.split('T')[1]
|
|
53
|
+
return hour ? `${hour}:00` : bucket
|
|
54
|
+
}
|
|
55
|
+
// "2026-03-01" → "Mar 1"
|
|
56
|
+
const parts = bucket.split('-')
|
|
57
|
+
if (parts.length === 3) {
|
|
58
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
59
|
+
const monthIdx = parseInt(parts[1], 10) - 1
|
|
60
|
+
return `${months[monthIdx]} ${parseInt(parts[2], 10)}`
|
|
61
|
+
}
|
|
62
|
+
return bucket
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function computeCompletionRate(tasks: Record<string, BoardTask>): number {
|
|
66
|
+
const all = Object.values(tasks)
|
|
67
|
+
const eligible = all.filter((t) => t.status !== 'backlog' && t.status !== 'archived')
|
|
68
|
+
if (eligible.length === 0) return 0
|
|
69
|
+
const completed = eligible.filter((t) => t.status === 'completed').length
|
|
70
|
+
return Math.round((completed / eligible.length) * 100)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function MetricsDashboard() {
|
|
74
|
+
const [range, setRange] = useState<Range>('24h')
|
|
75
|
+
const [data, setData] = useState<UsageResponse | null>(null)
|
|
76
|
+
const [loading, setLoading] = useState(true)
|
|
77
|
+
const tasks = useAppStore((s) => s.tasks)
|
|
78
|
+
const loadTasks = useAppStore((s) => s.loadTasks)
|
|
79
|
+
|
|
80
|
+
const loadData = useCallback(async () => {
|
|
81
|
+
try {
|
|
82
|
+
const res = await api<UsageResponse>('GET', `/usage?range=${range}`)
|
|
83
|
+
setData(res)
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
} finally {
|
|
87
|
+
setLoading(false)
|
|
88
|
+
}
|
|
89
|
+
}, [range])
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
setLoading(true)
|
|
93
|
+
loadData()
|
|
94
|
+
}, [loadData])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
loadTasks()
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
useWs('usage', loadData, 30_000)
|
|
102
|
+
|
|
103
|
+
const completionRate = computeCompletionRate(tasks)
|
|
104
|
+
|
|
105
|
+
// Prepare chart data
|
|
106
|
+
const timeSeriesFormatted = (data?.timeSeries ?? []).map((pt) => ({
|
|
107
|
+
...pt,
|
|
108
|
+
label: formatBucketLabel(pt.bucket, range),
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
const providerData = Object.entries(data?.byProvider ?? {}).map(([name, v]) => ({
|
|
112
|
+
name,
|
|
113
|
+
cost: Math.round(v.cost * 10000) / 10000,
|
|
114
|
+
tokens: v.tokens,
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
const agentData = Object.entries(data?.byAgent ?? {})
|
|
118
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
119
|
+
.slice(0, 10)
|
|
120
|
+
.map(([name, v]) => ({
|
|
121
|
+
name: name.length > 12 ? name.slice(0, 12) + '…' : name,
|
|
122
|
+
cost: Math.round(v.cost * 10000) / 10000,
|
|
123
|
+
}))
|
|
124
|
+
|
|
125
|
+
const tooltipStyle = {
|
|
126
|
+
contentStyle: {
|
|
127
|
+
background: '#1a1a2e',
|
|
128
|
+
border: '1px solid rgba(255,255,255,0.08)',
|
|
129
|
+
borderRadius: 8,
|
|
130
|
+
fontSize: 12,
|
|
131
|
+
color: '#e0e0e0',
|
|
132
|
+
},
|
|
133
|
+
itemStyle: { color: '#e0e0e0' },
|
|
134
|
+
labelStyle: { color: '#a0a0b0' },
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex-1 flex flex-col h-full overflow-y-auto">
|
|
139
|
+
<div className="px-8 pt-6 pb-4 shrink-0">
|
|
140
|
+
<h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Usage</h1>
|
|
141
|
+
<p className="text-[13px] text-text-3 mt-1">Token usage, cost tracking & agent performance</p>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Range tabs */}
|
|
145
|
+
<div className="px-8 pb-4 shrink-0">
|
|
146
|
+
<div className="flex gap-1 bg-surface-2 rounded-[10px] p-1 w-fit">
|
|
147
|
+
{RANGES.map((r) => (
|
|
148
|
+
<button
|
|
149
|
+
key={r}
|
|
150
|
+
onClick={() => setRange(r)}
|
|
151
|
+
className={`px-3.5 py-1.5 rounded-[8px] text-[12px] font-600 transition-all cursor-pointer ${
|
|
152
|
+
range === r
|
|
153
|
+
? 'bg-accent-soft text-accent-bright'
|
|
154
|
+
: 'text-text-3 hover:text-text-2'
|
|
155
|
+
}`}
|
|
156
|
+
>
|
|
157
|
+
{RANGE_LABELS[r]}
|
|
158
|
+
</button>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{loading && !data ? (
|
|
164
|
+
<div className="flex-1 flex items-center justify-center">
|
|
165
|
+
<p className="text-text-3 text-[13px]">Loading metrics…</p>
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="px-8 pb-8 space-y-6">
|
|
169
|
+
{/* Stats cards */}
|
|
170
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
171
|
+
<StatCard label="Total Tokens" value={formatTokens(data?.totalTokens ?? 0)} />
|
|
172
|
+
<StatCard label="Total Cost" value={formatCost(data?.totalCost ?? 0)} />
|
|
173
|
+
<StatCard label="Requests" value={String(data?.records.length ?? 0)} />
|
|
174
|
+
<StatCard label="Completion Rate" value={`${completionRate}%`} />
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Token usage over time */}
|
|
178
|
+
<ChartCard title="Token Usage Over Time">
|
|
179
|
+
{timeSeriesFormatted.length > 0 ? (
|
|
180
|
+
<ResponsiveContainer width="100%" height={280}>
|
|
181
|
+
<LineChart data={timeSeriesFormatted} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
182
|
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
183
|
+
<XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
184
|
+
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
|
|
185
|
+
<Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatTokens(value ?? 0), 'Tokens']} />
|
|
186
|
+
<Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
|
|
187
|
+
</LineChart>
|
|
188
|
+
</ResponsiveContainer>
|
|
189
|
+
) : (
|
|
190
|
+
<EmptyChart />
|
|
191
|
+
)}
|
|
192
|
+
</ChartCard>
|
|
193
|
+
|
|
194
|
+
{/* Cost by provider + cost by agent */}
|
|
195
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
196
|
+
<ChartCard title="Cost by Provider">
|
|
197
|
+
{providerData.length > 0 ? (
|
|
198
|
+
<ResponsiveContainer width="100%" height={280}>
|
|
199
|
+
<BarChart data={providerData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
200
|
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
201
|
+
<XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
202
|
+
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v}`} />
|
|
203
|
+
<Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
|
|
204
|
+
<Bar dataKey="cost" radius={[4, 4, 0, 0]}>
|
|
205
|
+
{providerData.map((_entry, i) => (
|
|
206
|
+
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
207
|
+
))}
|
|
208
|
+
</Bar>
|
|
209
|
+
</BarChart>
|
|
210
|
+
</ResponsiveContainer>
|
|
211
|
+
) : (
|
|
212
|
+
<EmptyChart />
|
|
213
|
+
)}
|
|
214
|
+
</ChartCard>
|
|
215
|
+
|
|
216
|
+
<ChartCard title="Cost by Session">
|
|
217
|
+
{agentData.length > 0 ? (
|
|
218
|
+
<ResponsiveContainer width="100%" height={280}>
|
|
219
|
+
<PieChart>
|
|
220
|
+
<Pie
|
|
221
|
+
data={agentData}
|
|
222
|
+
cx="50%"
|
|
223
|
+
cy="50%"
|
|
224
|
+
innerRadius={60}
|
|
225
|
+
outerRadius={100}
|
|
226
|
+
paddingAngle={2}
|
|
227
|
+
dataKey="cost"
|
|
228
|
+
nameKey="name"
|
|
229
|
+
>
|
|
230
|
+
{agentData.map((_entry, i) => (
|
|
231
|
+
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
232
|
+
))}
|
|
233
|
+
</Pie>
|
|
234
|
+
<Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
|
|
235
|
+
<Legend
|
|
236
|
+
verticalAlign="bottom"
|
|
237
|
+
iconType="circle"
|
|
238
|
+
iconSize={8}
|
|
239
|
+
formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>}
|
|
240
|
+
/>
|
|
241
|
+
</PieChart>
|
|
242
|
+
</ResponsiveContainer>
|
|
243
|
+
) : (
|
|
244
|
+
<EmptyChart />
|
|
245
|
+
)}
|
|
246
|
+
</ChartCard>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function StatCard({ label, value }: { label: string; value: string }) {
|
|
255
|
+
return (
|
|
256
|
+
<div className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04]">
|
|
257
|
+
<p className="text-[11px] font-500 text-text-3 uppercase tracking-[0.05em] mb-1">{label}</p>
|
|
258
|
+
<p className="text-[22px] font-display font-700 tracking-[-0.02em] text-text">{value}</p>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
|
264
|
+
return (
|
|
265
|
+
<div className="bg-surface-2 rounded-[12px] p-5 border border-white/[0.04]">
|
|
266
|
+
<h3 className="font-display text-[14px] font-600 text-text-2 mb-4">{title}</h3>
|
|
267
|
+
{children}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function EmptyChart() {
|
|
273
|
+
return (
|
|
274
|
+
<div className="h-[280px] flex items-center justify-center">
|
|
275
|
+
<p className="text-text-3 text-[13px]">No data for this time range</p>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
function subscribe(cb: () => void) {
|
|
6
|
+
document.addEventListener('visibilitychange', cb)
|
|
7
|
+
return () => document.removeEventListener('visibilitychange', cb)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getSnapshot(): boolean {
|
|
11
|
+
return document.visibilityState === 'visible'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getServerSnapshot(): boolean {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Returns `true` when the page is visible, `false` when hidden. SSR-safe. */
|
|
19
|
+
export function usePageActive(): boolean {
|
|
20
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
21
|
+
}
|
package/src/hooks/use-ws.ts
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from 'react'
|
|
4
4
|
import { subscribeWs, unsubscribeWs, isWsConnected } from '@/lib/ws-client'
|
|
5
|
+
import { usePageActive } from './use-page-active'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Subscribe to a WebSocket topic. Calls `handler` on push events.
|
|
8
9
|
* Falls back to polling at `fallbackMs` when WS is disconnected.
|
|
9
10
|
*/
|
|
10
11
|
export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
12
|
+
const isActive = usePageActive()
|
|
11
13
|
const handlerRef = useRef(handler)
|
|
12
14
|
handlerRef.current = handler
|
|
13
15
|
const fallbackMsRef = useRef(fallbackMs)
|
|
@@ -23,12 +25,21 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
|
23
25
|
}, [topic])
|
|
24
26
|
|
|
25
27
|
// Fallback polling — separate effect so it doesn't tear down WS subscription
|
|
28
|
+
// Pauses when tab is hidden; triggers immediate fetch when tab becomes visible again
|
|
26
29
|
useEffect(() => {
|
|
27
30
|
if (!topic) return
|
|
28
31
|
|
|
29
32
|
let fallbackId: ReturnType<typeof setInterval> | null = null
|
|
30
33
|
const cb = () => handlerRef.current()
|
|
31
34
|
|
|
35
|
+
// When page becomes visible again, fire an immediate refresh
|
|
36
|
+
if (isActive) {
|
|
37
|
+
cb()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Don't run polling while the tab is hidden
|
|
41
|
+
if (!isActive) return
|
|
42
|
+
|
|
32
43
|
const startFallback = () => {
|
|
33
44
|
const ms = fallbackMsRef.current
|
|
34
45
|
if (fallbackId || !ms || ms <= 0) return
|
|
@@ -62,5 +73,6 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
|
62
73
|
stopFallback()
|
|
63
74
|
clearInterval(checkId)
|
|
64
75
|
}
|
|
65
|
-
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [topic, isActive])
|
|
66
78
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const inflight = new Map<string, Promise<Response>>()
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deduplicates concurrent GET requests to the same URL.
|
|
5
|
+
* Non-GET requests pass through without dedup.
|
|
6
|
+
*/
|
|
7
|
+
export function dedupedFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
8
|
+
const method = (init?.method ?? 'GET').toUpperCase()
|
|
9
|
+
if (method !== 'GET') return fetch(url, init)
|
|
10
|
+
|
|
11
|
+
const existing = inflight.get(url)
|
|
12
|
+
if (existing) return existing
|
|
13
|
+
|
|
14
|
+
const promise = fetch(url, init).finally(() => {
|
|
15
|
+
inflight.delete(url)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
inflight.set(url, promise)
|
|
19
|
+
return promise
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic optimistic mutation helper for Zustand stores.
|
|
3
|
+
* Applies a patch immediately, then fires the API call.
|
|
4
|
+
* On failure, rolls back to the previous state and calls an optional error handler.
|
|
5
|
+
*/
|
|
6
|
+
export async function optimistic<T>(
|
|
7
|
+
storeSetter: (updater: (state: T) => Partial<T>) => void,
|
|
8
|
+
patch: (state: T) => Partial<T>,
|
|
9
|
+
apiCall: () => Promise<unknown>,
|
|
10
|
+
rollback: (state: T) => Partial<T>,
|
|
11
|
+
onError?: (err: unknown) => void,
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
// Apply optimistic update
|
|
14
|
+
storeSetter(patch)
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await apiCall()
|
|
18
|
+
return true
|
|
19
|
+
} catch (err: unknown) {
|
|
20
|
+
// Rollback
|
|
21
|
+
storeSetter(rollback)
|
|
22
|
+
if (onError) onError(err)
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -53,6 +53,21 @@ const lockKey = '__swarmclaw_connector_locks__' as const
|
|
|
53
53
|
const locks: Map<string, Promise<void>> =
|
|
54
54
|
g[lockKey] ?? (g[lockKey] = new Map<string, Promise<void>>())
|
|
55
55
|
|
|
56
|
+
/** Generation counter per connector — used to detect stale lifecycle events after restart */
|
|
57
|
+
const genCounterKey = '__swarmclaw_connector_gen__' as const
|
|
58
|
+
const generationCounter: Map<string, number> =
|
|
59
|
+
g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
|
|
60
|
+
|
|
61
|
+
/** Get the current generation number for a connector (0 if never started) */
|
|
62
|
+
export function getConnectorGeneration(connectorId: string): number {
|
|
63
|
+
return generationCounter.get(connectorId) ?? 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check whether a given generation is still the current one for a connector */
|
|
67
|
+
export function isCurrentGeneration(connectorId: string, gen: number): boolean {
|
|
68
|
+
return generationCounter.get(connectorId) === gen
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
/** Get platform implementation lazily */
|
|
57
72
|
export async function getPlatform(platform: string) {
|
|
58
73
|
switch (platform) {
|
|
@@ -726,6 +741,9 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
726
741
|
|
|
727
742
|
const platform = await getPlatform(connector.platform)
|
|
728
743
|
|
|
744
|
+
// Bump generation counter so stale events from previous instances are ignored
|
|
745
|
+
generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
|
|
746
|
+
|
|
729
747
|
try {
|
|
730
748
|
const instance = await platform.start(connector, botToken, (msg) => routeMessage(connector, msg))
|
|
731
749
|
running.set(connectorId, instance)
|