@swarmclawai/swarmclaw 1.4.0 → 1.4.3

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.
Files changed (61) hide show
  1. package/README.md +13 -71
  2. package/next.config.ts +9 -4
  3. package/package.json +10 -8
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +120 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/approvals/route.test.ts +29 -3
  8. package/src/app/api/approvals/route.ts +13 -7
  9. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  10. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  11. package/src/app/api/chats/chat-route.test.ts +68 -0
  12. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  13. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  14. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  15. package/src/app/api/logs/route.test.ts +61 -0
  16. package/src/app/api/logs/route.ts +35 -0
  17. package/src/app/api/swarmdock/route.ts +25 -0
  18. package/src/app/api/swarmfeed/posts/route.ts +44 -6
  19. package/src/app/api/swarmfeed/route.ts +4 -0
  20. package/src/app/api/tts/route.test.ts +82 -0
  21. package/src/app/api/tts/route.ts +13 -6
  22. package/src/app/api/tts/stream/route.ts +12 -5
  23. package/src/app/error.tsx +32 -0
  24. package/src/app/global-error.tsx +33 -0
  25. package/src/app/marketplace/page.tsx +7 -0
  26. package/src/cli/index.js +10 -0
  27. package/src/cli/spec.js +1 -0
  28. package/src/components/agents/agent-sheet.tsx +10 -0
  29. package/src/components/layout/error-boundary.tsx +12 -30
  30. package/src/components/layout/error-fallback.tsx +61 -0
  31. package/src/components/layout/sidebar-rail.tsx +5 -0
  32. package/src/features/swarmdock/agent-marketplace-settings.tsx +303 -0
  33. package/src/features/swarmdock/marketplace-page.tsx +189 -0
  34. package/src/features/swarmfeed/feed-page.tsx +3 -33
  35. package/src/features/swarmfeed/queries.ts +3 -3
  36. package/src/lib/app/navigation.ts +1 -0
  37. package/src/lib/app/report-client-error.ts +52 -0
  38. package/src/lib/app/view-constants.ts +9 -1
  39. package/src/lib/providers/anthropic.ts +9 -1
  40. package/src/lib/providers/ollama.ts +34 -14
  41. package/src/lib/providers/openai.ts +9 -1
  42. package/src/lib/providers/openclaw.ts +3 -3
  43. package/src/lib/server/agents/agent-service.ts +18 -0
  44. package/src/lib/server/agents/agent-swarm-registration.ts +35 -0
  45. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  46. package/src/lib/server/connectors/swarmdock.ts +29 -7
  47. package/src/lib/server/messages/message-repository.ts +31 -0
  48. package/src/lib/server/provider-health.ts +19 -3
  49. package/src/lib/server/safe-parse-body.test.ts +32 -0
  50. package/src/lib/server/safe-parse-body.ts +20 -3
  51. package/src/lib/server/session-tools/index.ts +4 -0
  52. package/src/lib/server/session-tools/swarmdock.ts +104 -0
  53. package/src/lib/server/session-tools/swarmfeed.ts +150 -0
  54. package/src/lib/server/storage-normalization.ts +10 -0
  55. package/src/lib/server/storage.ts +13 -4
  56. package/src/lib/swarmfeed-client.ts +1 -1
  57. package/src/lib/tool-definitions.ts +2 -0
  58. package/src/types/agent.ts +23 -0
  59. package/src/types/session.ts +1 -1
  60. package/tsconfig.json +1 -2
  61. package/src/.env.local +0 -4
@@ -3,7 +3,6 @@
3
3
  import { useCallback, useEffect, useState } from 'react'
4
4
  import { fetchFeed } from './queries'
5
5
  import { PostCard } from './post-card'
6
- import { ComposePost } from './compose-post'
7
6
  import { MainContent } from '@/components/layout/main-content'
8
7
  import { PageLoader } from '@/components/ui/page-loader'
9
8
  import type { SwarmFeedPost, FeedType } from '@/types/swarmfeed'
@@ -19,8 +18,6 @@ export function FeedPage() {
19
18
  const [posts, setPosts] = useState<SwarmFeedPost[]>([])
20
19
  const [loading, setLoading] = useState(true)
21
20
  const [error, setError] = useState<string | null>(null)
22
- const [showCompose, setShowCompose] = useState(false)
23
-
24
21
  const loadFeed = useCallback(async (type: FeedType) => {
25
22
  setLoading(true)
26
23
  setError(null)
@@ -44,42 +41,15 @@ export function FeedPage() {
44
41
  setActiveTab(tab)
45
42
  }
46
43
 
47
- const handlePostCreated = (post: SwarmFeedPost) => {
48
- setPosts((prev) => [post, ...prev])
49
- setShowCompose(false)
50
- }
51
-
52
44
  return (
53
45
  <MainContent>
54
46
  <div className="flex-1 overflow-y-auto overscroll-contain">
55
47
  <div className="mx-auto max-w-2xl px-4 sm:px-6 py-8">
56
- <div className="flex items-center justify-between mb-6">
57
- <div>
58
- <h1 className="font-display text-[22px] font-700 tracking-[-0.02em] text-text">Feed</h1>
59
- <p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
60
- </div>
61
- <button
62
- onClick={() => setShowCompose((c) => !c)}
63
- className="px-4 py-2 rounded-[12px] bg-accent-bright text-white text-[13px] font-600 transition-all
64
- hover:bg-accent-bright/90 border-none cursor-pointer flex items-center gap-2"
65
- >
66
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
67
- <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
68
- </svg>
69
- Compose
70
- </button>
48
+ <div className="mb-6">
49
+ <h1 className="font-display text-[22px] font-700 tracking-[-0.02em] text-text">Feed</h1>
50
+ <p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
71
51
  </div>
72
52
 
73
- {/* Compose area */}
74
- {showCompose && (
75
- <div className="mb-6">
76
- <ComposePost
77
- onPostCreated={handlePostCreated}
78
- onClose={() => setShowCompose(false)}
79
- />
80
- </div>
81
- )}
82
-
83
53
  {/* Tab bar */}
84
54
  <div className="flex gap-1 mb-6 rounded-[14px] border border-white/[0.06] bg-surface/50 p-1">
85
55
  {FEED_TABS.map((tab) => (
@@ -10,16 +10,16 @@ export async function fetchFeed(
10
10
  if (params?.channelId) searchParams.set('channelId', params.channelId)
11
11
  if (params?.cursor) searchParams.set('cursor', params.cursor)
12
12
  if (params?.limit) searchParams.set('limit', String(params.limit))
13
- return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/api/swarmfeed?${searchParams.toString()}`)
13
+ return api<{ posts: SwarmFeedPost[]; nextCursor?: string }>('GET', `/swarmfeed?${searchParams.toString()}`)
14
14
  }
15
15
 
16
16
  export async function fetchChannels(): Promise<SwarmFeedChannel[]> {
17
- const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/api/swarmfeed/channels')
17
+ const result = await api<{ channels: SwarmFeedChannel[] }>('GET', '/swarmfeed/channels')
18
18
  return result.channels
19
19
  }
20
20
 
21
21
  export async function submitPost(agentId: string, content: string, channelId?: string, parentId?: string): Promise<SwarmFeedPost> {
22
- return api<SwarmFeedPost>('POST', '/api/swarmfeed/posts', {
22
+ return api<SwarmFeedPost>('POST', '/swarmfeed/posts', {
23
23
  agentId,
24
24
  content,
25
25
  channelId,
@@ -32,6 +32,7 @@ const VIEW_TO_PATH: Record<AppView, string> = {
32
32
  projects: '/projects',
33
33
  activity: '/activity',
34
34
  swarmfeed: '/swarmfeed',
35
+ marketplace: '/marketplace',
35
36
  }
36
37
 
37
38
  /** Build a URL path for a given view, optionally with an entity ID. */
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ type ReportClientErrorInput = {
4
+ source: string
5
+ error: Error | string | unknown
6
+ componentStack?: string | null
7
+ digest?: string | null
8
+ }
9
+
10
+ const reportedClientErrors = new Set<string>()
11
+
12
+ function truncate(value: string | null | undefined, max: number): string | undefined {
13
+ if (!value) return undefined
14
+ return value.length > max ? value.slice(0, max) : value
15
+ }
16
+
17
+ export function reportClientError(input: ReportClientErrorInput) {
18
+ if (typeof window === 'undefined') return
19
+
20
+ const message = input.error instanceof Error
21
+ ? input.error.message
22
+ : typeof input.error === 'string'
23
+ ? input.error
24
+ : String(input.error)
25
+
26
+ const stack = input.error instanceof Error ? input.error.stack : undefined
27
+ const fingerprint = [
28
+ input.source,
29
+ message,
30
+ input.digest || '',
31
+ input.componentStack || '',
32
+ ].join('|')
33
+
34
+ if (reportedClientErrors.has(fingerprint)) return
35
+ reportedClientErrors.add(fingerprint)
36
+
37
+ void fetch('/api/logs', {
38
+ method: 'POST',
39
+ headers: { 'content-type': 'application/json' },
40
+ body: JSON.stringify({
41
+ source: input.source,
42
+ message: truncate(message, 1000),
43
+ stack: truncate(stack, 8000),
44
+ componentStack: truncate(input.componentStack, 8000),
45
+ digest: truncate(input.digest, 200),
46
+ url: truncate(window.location.href, 2000),
47
+ pathname: truncate(window.location.pathname, 1000),
48
+ userAgent: truncate(window.navigator.userAgent, 1000),
49
+ }),
50
+ keepalive: true,
51
+ }).catch(() => {})
52
+ }
@@ -28,6 +28,7 @@ export const VIEW_LABELS: Record<AppView, string> = {
28
28
  projects: 'Projects',
29
29
  activity: 'Activity',
30
30
  swarmfeed: 'Feed',
31
+ marketplace: 'Marketplace',
31
32
  }
32
33
 
33
34
  export const CREATE_LABELS: Partial<Record<AppView, string>> = {
@@ -73,6 +74,7 @@ export const VIEW_DESCRIPTIONS: Record<AppView, string> = {
73
74
  projects: 'Group agents, tasks & schedules into projects',
74
75
  activity: 'Audit trail of all entity mutations',
75
76
  swarmfeed: 'Social feed for AI agents to post, follow, and engage',
77
+ marketplace: 'AI agent marketplace — browse tasks, agents, and skills on SwarmDock',
76
78
  }
77
79
 
78
80
  export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -221,10 +223,16 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
221
223
  description: 'A social feed where your AI agents post updates, follow each other, and engage with content.',
222
224
  features: ['Agents post status updates and insights', 'Follow agents and browse trending content', 'Channel-based topic organization', 'Like, repost, and reply interactions'],
223
225
  },
226
+ marketplace: {
227
+ icon: 'store',
228
+ title: 'Marketplace',
229
+ description: 'Browse the SwarmDock agent marketplace — discover tasks, agents, and skills.',
230
+ features: ['Browse available tasks and bid on work', 'View registered agents and their skills', 'Track task status and completions', 'USDC-based payments on Base L2'],
231
+ },
224
232
  }
225
233
 
226
234
  export const FULL_WIDTH_VIEWS = new Set<AppView>([
227
235
  'home', 'org_chart', 'inbox', 'chatrooms', 'protocols', 'schedules', 'secrets', 'wallets', 'providers', 'skills',
228
236
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'extensions',
229
- 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed',
237
+ 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed', 'marketplace',
230
238
  ])
@@ -90,6 +90,7 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
90
90
  }
91
91
 
92
92
  let buf = ''
93
+ let malformedChunkLogged = false
93
94
  apiRes.on('data', (chunk: Buffer) => {
94
95
  if (abortController.aborted) return
95
96
  buf += chunk.toString()
@@ -112,7 +113,14 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
112
113
  if (parsed.type === 'message_delta' && parsed.usage) {
113
114
  usageOutput = parsed.usage.output_tokens || 0
114
115
  }
115
- } catch {}
116
+ } catch {
117
+ if (!malformedChunkLogged) {
118
+ malformedChunkLogged = true
119
+ log.warn(TAG, `[${session.id}] failed to parse Anthropic stream chunk`, {
120
+ sample: data.slice(0, 200),
121
+ })
122
+ }
123
+ }
116
124
  }
117
125
  })
118
126
 
@@ -2,6 +2,7 @@ import fs from 'fs'
2
2
  import http from 'http'
3
3
  import https from 'https'
4
4
  import type { StreamChatOptions } from './index'
5
+ import { streamOpenAiChat } from './openai'
5
6
  import { IMAGE_EXTS, TEXT_EXTS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
6
7
  import { log } from '@/lib/server/logger'
7
8
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
@@ -9,23 +10,34 @@ import { resolveImagePath } from '@/lib/server/resolve-image'
9
10
 
10
11
  const TAG = 'provider-ollama'
11
12
 
12
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
13
+ /** Ollama Cloud uses the OpenAI-compatible /v1 endpoint, not the native /api/chat protocol. */
14
+ const OLLAMA_CLOUD_OPENAI_ENDPOINT = 'https://ollama.com/v1'
15
+
16
+ export function streamOllamaChat(opts: StreamChatOptions): Promise<string> {
17
+ const { session, apiKey, write, active } = opts
18
+ const runtime = resolveOllamaRuntimeConfig({
19
+ model: session.model,
20
+ ollamaMode: session.ollamaMode,
21
+ apiKey,
22
+ apiEndpoint: session.apiEndpoint,
23
+ })
24
+
25
+ if (runtime.useCloud) {
26
+ if (!runtime.apiKey) {
27
+ writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
28
+ active.delete(session.id)
29
+ return Promise.resolve('')
30
+ }
31
+ // Delegate to OpenAI-compatible handler with the cloud endpoint
32
+ const cloudSession = { ...session, model: runtime.model || 'llama3', apiEndpoint: OLLAMA_CLOUD_OPENAI_ENDPOINT }
33
+ return streamOpenAiChat({ ...opts, session: cloudSession, apiKey: runtime.apiKey })
34
+ }
35
+
36
+ const { message, imagePath, loadHistory, onUsage, signal } = opts
13
37
  return new Promise((resolve, reject) => {
14
38
  const messages = buildMessages(session, message, imagePath, loadHistory)
15
- const runtime = resolveOllamaRuntimeConfig({
16
- model: session.model,
17
- ollamaMode: session.ollamaMode,
18
- apiKey,
19
- apiEndpoint: session.apiEndpoint,
20
- })
21
39
  const model = runtime.model || 'llama3'
22
40
  const endpoint = runtime.endpoint
23
- if (runtime.useCloud && !runtime.apiKey) {
24
- writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
25
- active.delete(session.id)
26
- resolve('')
27
- return
28
- }
29
41
 
30
42
  const parsed = new URL(endpoint)
31
43
  const isHttps = parsed.protocol === 'https:'
@@ -81,6 +93,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
81
93
  }
82
94
 
83
95
  let buf = ''
96
+ let malformedChunkLogged = false
84
97
  apiRes.on('data', (chunk: Buffer) => {
85
98
  if (abortController.aborted) return
86
99
  buf += chunk.toString()
@@ -103,7 +116,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
103
116
  onUsage({ inputTokens: input, outputTokens: output })
104
117
  }
105
118
  }
106
- } catch {}
119
+ } catch {
120
+ if (!malformedChunkLogged) {
121
+ malformedChunkLogged = true
122
+ log.warn(TAG, `[${session.id}] failed to parse Ollama stream chunk`, {
123
+ sample: line.slice(0, 200),
124
+ })
125
+ }
126
+ }
107
127
  }
108
128
  })
109
129
 
@@ -147,6 +147,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
147
147
  const reader = res.body.getReader()
148
148
  const decoder = new TextDecoder()
149
149
  let buf = ''
150
+ let malformedChunkLogged = false
150
151
 
151
152
  while (true) {
152
153
  const { done, value } = await reader.read()
@@ -175,7 +176,14 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
175
176
  outputTokens: parsed.usage.completion_tokens || 0,
176
177
  })
177
178
  }
178
- } catch {}
179
+ } catch {
180
+ if (!malformedChunkLogged) {
181
+ malformedChunkLogged = true
182
+ log.warn(TAG, `[${session.id}] failed to parse OpenAI stream chunk`, {
183
+ sample: data.slice(0, 200),
184
+ })
185
+ }
186
+ }
179
187
  }
180
188
  }
181
189
 
@@ -9,6 +9,7 @@ import { deriveOpenClawWsUrl } from '@/lib/openclaw/openclaw-endpoint'
9
9
  import { normalizeOpenClawAgentId } from '@/lib/openclaw/openclaw-agent-id'
10
10
  import { loadAgents } from '../server/storage'
11
11
  import { getSharedDeviceToken } from '../server/openclaw/sync'
12
+ import { DATA_DIR } from '../server/data-dir'
12
13
  import {
13
14
  resolveOpenClawGatewayAgentIdFromList,
14
15
  type OpenClawGatewayAgentSummary,
@@ -54,9 +55,8 @@ function resolveCliStateDir(): string {
54
55
  }
55
56
 
56
57
  function getSwarmClawIdentityPath(): string {
57
- const dataDir = path.join(process.cwd(), 'data')
58
- if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
59
- return path.join(dataDir, 'openclaw-device.json')
58
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
59
+ return path.join(DATA_DIR, 'openclaw-device.json')
60
60
  }
61
61
 
62
62
  function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
@@ -21,6 +21,8 @@ import { serviceFail, serviceOk } from '@/lib/server/service-result'
21
21
  import { listSessions, saveSession } from '@/lib/server/sessions/session-repository'
22
22
  import { loadUsage } from '@/lib/server/usage/usage-repository'
23
23
  import { notify } from '@/lib/server/ws-hub'
24
+ import { log } from '@/lib/server/logger'
25
+ import { tryAutoRegisterSwarmFeed } from '@/lib/server/agents/agent-swarm-registration'
24
26
  import type { Agent, Session } from '@/types'
25
27
  import type { ServiceResult } from '@/lib/server/service-result'
26
28
 
@@ -191,6 +193,14 @@ export function createAgent(input: {
191
193
  saveAgent(id, agent)
192
194
  logActivity({ entityType: 'agent', entityId: id, action: 'created', actor: 'user', summary: `Agent created: "${agent.name}"` })
193
195
  notify('agents')
196
+
197
+ // Auto-register on SwarmFeed when created with it enabled
198
+ if (agent.swarmfeedEnabled && !agent.swarmfeedApiKey) {
199
+ tryAutoRegisterSwarmFeed(agent).catch((err) => {
200
+ log.error('agent-service', `SwarmFeed auto-registration failed for "${agent.name}": ${err instanceof Error ? err.message : err}`)
201
+ })
202
+ }
203
+
194
204
  return agent
195
205
  }
196
206
 
@@ -315,6 +325,14 @@ export function updateAgent(agentId: string, body: Record<string, unknown>): Age
315
325
  if (Object.keys(budgetChanges).length > 0) {
316
326
  logActivity({ entityType: 'budget', entityId: agentId, action: 'configured', actor: 'user', summary: `Budget updated for agent "${updated.name}"`, detail: budgetChanges })
317
327
  }
328
+
329
+ // Auto-register on SwarmFeed/SwarmDock when enabled without existing credentials
330
+ if (updated.swarmfeedEnabled && !updated.swarmfeedApiKey) {
331
+ tryAutoRegisterSwarmFeed(updated).catch((err) => {
332
+ log.error('agent-service', `SwarmFeed auto-registration failed for "${updated.name}": ${err instanceof Error ? err.message : err}`)
333
+ })
334
+ }
335
+
318
336
  return updated
319
337
  }
320
338
 
@@ -0,0 +1,35 @@
1
+ import { registerAgent } from '@/lib/swarmfeed-client'
2
+ import { patchAgent } from '@/lib/server/agents/agent-repository'
3
+ import { log } from '@/lib/server/logger'
4
+ import type { Agent } from '@/types'
5
+
6
+ /**
7
+ * Auto-register an agent on SwarmFeed when enabled but missing API key.
8
+ * Fire-and-forget — called after agent save, patches agent with the returned credentials.
9
+ */
10
+ export async function tryAutoRegisterSwarmFeed(agent: Agent): Promise<void> {
11
+ if (!agent.swarmfeedEnabled || agent.swarmfeedApiKey) return
12
+
13
+ log.info('swarm-registration', `Auto-registering agent "${agent.name}" on SwarmFeed`)
14
+ const reg = await registerAgent({
15
+ name: agent.name,
16
+ description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
17
+ framework: 'swarmclaw',
18
+ model: agent.model,
19
+ avatar: agent.avatarUrl || undefined,
20
+ bio: agent.swarmfeedBio || undefined,
21
+ })
22
+
23
+ patchAgent(agent.id, (current) => {
24
+ if (!current) return null
25
+ return {
26
+ ...current,
27
+ swarmfeedApiKey: reg.apiKey,
28
+ swarmfeedAgentId: reg.agentId,
29
+ swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
30
+ updatedAt: Date.now(),
31
+ }
32
+ })
33
+
34
+ log.info('swarm-registration', `Agent "${agent.name}" registered on SwarmFeed as ${reg.agentId}`)
35
+ }
@@ -513,7 +513,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
513
513
  const session = getSession(sessionId)
514
514
  if (!session) throw new Error(`Session not found: ${sessionId}`)
515
515
  const runStartedAt = Date.now()
516
- const runMessageStartIndex = getMessageCount(sessionId)
516
+ let runMessageStartIndex = getMessageCount(sessionId)
517
517
 
518
518
  const appSettings = loadSettings()
519
519
  const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
@@ -725,17 +725,10 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
725
725
  }
726
726
  }
727
727
 
728
- const providerType = sessionForRun.provider || 'claude-cli'
729
- const provider = getProvider(providerType)
730
- if (!provider) throw new Error(`Unknown provider: ${providerType}`)
731
-
732
- if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
733
- throw new Error(`Directory not found: ${session.cwd}`)
734
- }
735
-
736
- const apiKey = resolveApiKeyForSession(sessionForRun, provider)
737
- const hideAssistantTranscript = internal && source === 'main-loop-followup'
738
-
728
+ // Persist the user message BEFORE provider/credential resolution so that if
729
+ // provider resolution throws (unknown provider, missing credentials, etc.),
730
+ // the user message is already in the DB and won't disappear from the chat
731
+ // when the frontend's refreshMessages overwrites the optimistic local copy.
739
732
  const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
740
733
  if (shouldPersistUserMessage) {
741
734
  const [linkAnalysis, semantics] = await Promise.all([
@@ -805,8 +798,22 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
805
798
  }
806
799
  }
807
800
  }
801
+ // Update runMessageStartIndex to account for newly appended user message(s)
802
+ // so that partial persistence and finalization don't overwrite them.
803
+ runMessageStartIndex = getMessageCount(sessionId)
808
804
  }
809
805
 
806
+ const providerType = sessionForRun.provider || 'claude-cli'
807
+ const provider = getProvider(providerType)
808
+ if (!provider) throw new Error(`Unknown provider: ${providerType}`)
809
+
810
+ if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
811
+ throw new Error(`Directory not found: ${session.cwd}`)
812
+ }
813
+
814
+ const apiKey = resolveApiKeyForSession(sessionForRun, provider)
815
+ const hideAssistantTranscript = internal && source === 'main-loop-followup'
816
+
810
817
  const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
811
818
  const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
812
819
  const hasExtensions = enabledSessionExtensions.length > 0
@@ -2,6 +2,7 @@ import { log } from '@/lib/server/logger'
2
2
  import { hmrSingleton } from '@/lib/shared-utils'
3
3
  import { logActivity } from '@/lib/server/activity/activity-log'
4
4
  import type { Connector, InboundMessage } from '@/types/connector'
5
+ import type { Agent } from '@/types/agent'
5
6
  import type { PlatformConnector, ConnectorInstance } from '@/lib/server/connectors/types'
6
7
  import { createBoardTaskFromAssignment, updateBoardTaskFromEvent, findBoardTaskBySwarmdockId } from './swarmdock-tasks'
7
8
  import { shouldAutoBid, submitAutoBid } from './swarmdock-bidding'
@@ -19,15 +20,15 @@ interface SwarmDockConfig {
19
20
  paymentPrivateKey?: string
20
21
  }
21
22
 
22
- function parseConfig(connector: Connector): SwarmDockConfig {
23
+ function parseConfig(connector: Connector, agent?: Agent): SwarmDockConfig {
23
24
  const c = connector.config || {}
24
25
  return {
25
- apiUrl: c.apiUrl || 'https://api.swarmdock.ai',
26
+ apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
26
27
  walletAddress: c.walletAddress || '',
27
- agentDescription: c.agentDescription || connector.name || '',
28
- skills: c.skills || '',
29
- autoDiscover: c.autoDiscover === 'true',
30
- maxBudget: c.maxBudget || '0',
28
+ agentDescription: c.agentDescription || agent?.swarmdockDescription || connector.name || '',
29
+ skills: c.skills || (agent?.swarmdockSkills?.join(',') ?? ''),
30
+ autoDiscover: c.autoDiscover === 'true' || (agent?.swarmdockMarketplace?.autoDiscover ?? false),
31
+ maxBudget: c.maxBudget || agent?.swarmdockMarketplace?.maxBudgetUsdc || '0',
31
32
  paymentPrivateKey: c.paymentPrivateKey || undefined,
32
33
  }
33
34
  }
@@ -82,7 +83,13 @@ const taskIdMap = hmrSingleton('__swarmclaw_swarmdock_task_map__', () => new Map
82
83
 
83
84
  const swarmdock: PlatformConnector = {
84
85
  async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
85
- const config = parseConfig(connector)
86
+ // Load agent to use agent-level fields as fallbacks for connector config
87
+ let agent: Agent | undefined
88
+ if (connector.agentId) {
89
+ const { loadAgent } = await import('@/lib/server/agents/agent-repository')
90
+ agent = (await loadAgent(connector.agentId)) ?? undefined
91
+ }
92
+ const config = parseConfig(connector, agent)
86
93
  const connectorId = connector.id
87
94
  const agentId = connector.agentId || ''
88
95
  const privateKey = _botToken || ''
@@ -138,6 +145,21 @@ const swarmdock: PlatformConnector = {
138
145
  })
139
146
  log.info(TAG, `Registered as ${registration.agent.did} (trust level ${registration.agent.trustLevel})`)
140
147
 
148
+ // Write SwarmDock IDs back to agent record if not already set
149
+ if (agent && (!agent.swarmdockAgentId || !agent.swarmdockDid)) {
150
+ const { patchAgent } = await import('@/lib/server/agents/agent-repository')
151
+ patchAgent(agent.id, (current) => {
152
+ if (!current) return null
153
+ return {
154
+ ...current,
155
+ swarmdockAgentId: registration.agent.id,
156
+ swarmdockDid: registration.agent.did,
157
+ swarmdockListedAt: current.swarmdockListedAt ?? Date.now(),
158
+ updatedAt: Date.now(),
159
+ }
160
+ })
161
+ }
162
+
141
163
  logActivity({
142
164
  entityType: 'connector',
143
165
  entityId: connectorId,
@@ -286,6 +286,37 @@ export function clearMessages(sessionId: string): void {
286
286
  /** Replace the entire message list (used after in-memory prune operations). */
287
287
  export function replaceAllMessages(sessionId: string, messages: Message[]): void {
288
288
  perf.measureSync('message-repo', 'replaceAllMessages', () => {
289
+ // Safety guard: reload current user messages from DB and ensure none are
290
+ // dropped by the replacement. This prevents races where partial persistence
291
+ // or finalization load a stale snapshot that's missing recently-appended
292
+ // user messages.
293
+ const currentRows = stmts().selectAll.all(sessionId) as Array<{ data: string }>
294
+ const currentUserMessages: Message[] = []
295
+ for (const row of currentRows) {
296
+ const m = parseMsg(row.data)
297
+ if (m && m.role === 'user') currentUserMessages.push(m)
298
+ }
299
+ const replacementUserTimes = new Set(
300
+ messages.filter(m => m.role === 'user' && typeof m.time === 'number').map(m => m.time),
301
+ )
302
+ const missingUsers = currentUserMessages.filter(
303
+ m => typeof m.time === 'number' && !replacementUserTimes.has(m.time),
304
+ )
305
+ if (missingUsers.length > 0) {
306
+ // Re-insert missing user messages at their correct position (before the
307
+ // first assistant message that follows them chronologically).
308
+ for (const user of missingUsers) {
309
+ let insertIdx = messages.length
310
+ for (let i = 0; i < messages.length; i++) {
311
+ if (messages[i].role === 'assistant' && typeof messages[i].time === 'number'
312
+ && (messages[i].time as number) >= (user.time as number)) {
313
+ insertIdx = i
314
+ break
315
+ }
316
+ }
317
+ messages.splice(insertIdx, 0, user)
318
+ }
319
+ }
289
320
  withTransaction(() => {
290
321
  stmts().deleteAll.run(sessionId)
291
322
  const ins = stmts().insert
@@ -1,6 +1,9 @@
1
1
  import { spawnSync } from 'child_process'
2
2
  import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
3
3
  import { upsertStoredItem, loadCollection } from './storage'
4
+ import { log } from './logger'
5
+
6
+ const TAG = 'provider-health'
4
7
 
5
8
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
6
9
 
@@ -72,7 +75,12 @@ export function markProviderFailure(providerId: string, error: string, credentia
72
75
  })
73
76
  try {
74
77
  upsertStoredItem('provider_health', key, states.get(key)!)
75
- } catch {}
78
+ } catch (err) {
79
+ log.warn(TAG, 'Failed to persist provider failure state', {
80
+ providerKey: key,
81
+ error: errorMessage(err),
82
+ })
83
+ }
76
84
  }
77
85
 
78
86
  export function markProviderSuccess(providerId: string, credentialId?: string | null): void {
@@ -88,7 +96,12 @@ export function markProviderSuccess(providerId: string, credentialId?: string |
88
96
  })
89
97
  try {
90
98
  upsertStoredItem('provider_health', key, states.get(key)!)
91
- } catch {}
99
+ } catch (err) {
100
+ log.warn(TAG, 'Failed to persist provider success state', {
101
+ providerKey: key,
102
+ error: errorMessage(err),
103
+ })
104
+ }
92
105
  }
93
106
 
94
107
  export function isProviderCoolingDown(providerId: string, credentialId?: string | null): boolean {
@@ -195,7 +208,10 @@ export function restoreProviderHealthState(): number {
195
208
  }
196
209
  }
197
210
  return restored
198
- } catch { return 0 }
211
+ } catch (err) {
212
+ log.warn(TAG, 'Failed to restore persisted provider health state', { error: errorMessage(err) })
213
+ return 0
214
+ }
199
215
  }
200
216
 
201
217
  // ---------------------------------------------------------------------------
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it, before, after } from 'node:test'
3
+ import { z } from 'zod'
3
4
 
4
5
  let safeParseBody: typeof import('@/lib/server/safe-parse-body').safeParseBody
5
6
  before(async () => {
@@ -50,4 +51,35 @@ describe('safeParseBody', () => {
50
51
  assert.equal(result.data!.name, 'test')
51
52
  assert.equal(result.data!.count, 7)
52
53
  })
54
+
55
+ it('validates the parsed body against a provided zod schema', async () => {
56
+ const result = await safeParseBody(
57
+ jsonRequest(JSON.stringify({ name: 'ok', count: 3 })),
58
+ z.object({
59
+ name: z.string().min(1),
60
+ count: z.number().int().nonnegative(),
61
+ }),
62
+ )
63
+ assert.equal(result.error, undefined)
64
+ assert.deepEqual(result.data, { name: 'ok', count: 3 })
65
+ })
66
+
67
+ it('returns a 400 validation error when schema parsing fails', async () => {
68
+ const result = await safeParseBody(
69
+ jsonRequest(JSON.stringify({ name: '', count: -1 })),
70
+ z.object({
71
+ name: z.string().min(1, 'name is required'),
72
+ count: z.number().int().nonnegative('count must be non-negative'),
73
+ }),
74
+ )
75
+ assert.equal(result.data, undefined)
76
+ assert.ok(result.error)
77
+ assert.equal(result.error.status, 400)
78
+ const body = await result.error.json()
79
+ assert.equal(body.error, 'Validation failed')
80
+ assert.deepEqual(body.issues, [
81
+ { path: 'name', message: 'name is required' },
82
+ { path: 'count', message: 'count must be non-negative' },
83
+ ])
84
+ })
53
85
  })