@swarmclawai/swarmclaw 1.4.0 → 1.4.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 +6 -73
- package/next.config.ts +9 -4
- package/package.json +10 -8
- package/scripts/build-bootstrap-env.mjs +24 -0
- package/scripts/run-next-build.mjs +74 -0
- package/scripts/run-next-typegen.mjs +61 -0
- package/src/app/api/approvals/route.test.ts +29 -3
- package/src/app/api/approvals/route.ts +13 -7
- package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
- package/src/app/api/chats/[id]/chat/route.ts +24 -8
- package/src/app/api/chats/chat-route.test.ts +68 -0
- package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
- package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
- package/src/app/api/logs/route.test.ts +61 -0
- package/src/app/api/logs/route.ts +35 -0
- package/src/app/api/tts/route.test.ts +82 -0
- package/src/app/api/tts/route.ts +13 -6
- package/src/app/api/tts/stream/route.ts +12 -5
- package/src/app/error.tsx +32 -0
- package/src/app/global-error.tsx +33 -0
- package/src/cli/index.js +3 -0
- package/src/cli/spec.js +1 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/features/swarmfeed/queries.ts +3 -3
- package/src/lib/app/report-client-error.ts +52 -0
- package/src/lib/providers/anthropic.ts +9 -1
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +1 -1
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/provider-health.ts +19 -3
- package/src/lib/server/safe-parse-body.test.ts +32 -0
- package/src/lib/server/safe-parse-body.ts +20 -3
- package/src/lib/server/storage.ts +13 -4
- package/src/lib/swarmfeed-client.ts +1 -1
- package/tsconfig.json +1 -2
- package/src/.env.local +0 -4
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
@@ -22,7 +22,7 @@ interface SwarmDockConfig {
|
|
|
22
22
|
function parseConfig(connector: Connector): SwarmDockConfig {
|
|
23
23
|
const c = connector.config || {}
|
|
24
24
|
return {
|
|
25
|
-
apiUrl: c.apiUrl || 'https://api.
|
|
25
|
+
apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
|
|
26
26
|
walletAddress: c.walletAddress || '',
|
|
27
27
|
agentDescription: c.agentDescription || connector.name || '',
|
|
28
28
|
skills: c.skills || '',
|
|
@@ -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 {
|
|
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
|
})
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { formatZodError } from '@/lib/validation/schemas'
|
|
2
5
|
|
|
3
6
|
type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextResponse }
|
|
4
7
|
|
|
@@ -6,11 +9,25 @@ type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextRes
|
|
|
6
9
|
* Wraps `req.json()` so malformed/empty bodies return a 400
|
|
7
10
|
* instead of throwing an unhandled error (500).
|
|
8
11
|
*/
|
|
9
|
-
export async function safeParseBody<T = Record<string, unknown>>(
|
|
12
|
+
export async function safeParseBody<T = Record<string, unknown>>(
|
|
13
|
+
req: Request,
|
|
14
|
+
schema?: z.ZodType<T>,
|
|
15
|
+
): Promise<SafeResult<T>> {
|
|
16
|
+
let raw: unknown
|
|
10
17
|
try {
|
|
11
|
-
|
|
12
|
-
return { data }
|
|
18
|
+
raw = await req.json()
|
|
13
19
|
} catch {
|
|
14
20
|
return { error: NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 }) }
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
if (!schema) {
|
|
24
|
+
return { data: raw as T }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = schema.safeParse(raw)
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
return { error: NextResponse.json(formatZodError(parsed.error), { status: 400 }) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { data: parsed.data }
|
|
16
33
|
}
|
|
@@ -6,12 +6,13 @@ import Database from 'better-sqlite3'
|
|
|
6
6
|
import { perf } from '@/lib/server/runtime/perf'
|
|
7
7
|
import { log } from '@/lib/server/logger'
|
|
8
8
|
import { notify } from '@/lib/server/ws-hub'
|
|
9
|
-
|
|
10
|
-
const TAG = 'storage'
|
|
11
9
|
import { DATA_DIR, IS_BUILD_BOOTSTRAP, WORKSPACE_DIR } from './data-dir'
|
|
12
10
|
import { normalizeHeartbeatSettingFields } from '@/lib/runtime/heartbeat-defaults'
|
|
13
11
|
import { normalizeRuntimeSettingFields } from '@/lib/runtime/runtime-loop'
|
|
14
12
|
import { normalizeCapabilitySelection } from '@/lib/capability-selection'
|
|
13
|
+
|
|
14
|
+
const TAG = 'storage'
|
|
15
|
+
const malformedRecordWarnings = new Set<string>()
|
|
15
16
|
import type {
|
|
16
17
|
Agent,
|
|
17
18
|
AppNotification,
|
|
@@ -236,8 +237,16 @@ function loadCollectionWithNormalizationState(table: string): {
|
|
|
236
237
|
if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) continue
|
|
237
238
|
result[id] = normalized as StoredObject
|
|
238
239
|
if (changed) normalizedCount += 1
|
|
239
|
-
} catch {
|
|
240
|
-
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const fingerprint = `${table}:${id}`
|
|
242
|
+
if (!malformedRecordWarnings.has(fingerprint)) {
|
|
243
|
+
malformedRecordWarnings.add(fingerprint)
|
|
244
|
+
log.warn(TAG, 'Ignoring malformed stored record during collection load', {
|
|
245
|
+
table,
|
|
246
|
+
id,
|
|
247
|
+
error: err instanceof Error ? err.message : String(err),
|
|
248
|
+
})
|
|
249
|
+
}
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
endPerf({ count: raw.size, normalizedCount })
|
|
@@ -6,7 +6,7 @@ interface SwarmFeedConfig {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const config = hmrSingleton<SwarmFeedConfig>('swarmfeed_config', () => ({
|
|
9
|
-
apiUrl: process.env.SWARMFEED_API_URL || '
|
|
9
|
+
apiUrl: process.env.SWARMFEED_API_URL || 'https://swarmfeed-api.onrender.com',
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
12
|
/**
|
package/tsconfig.json
CHANGED
package/src/.env.local
DELETED