@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
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('chat route rejects malformed JSON with a 400 before queueing work', () => {
7
+ const output = runWithTempDataDir<{
8
+ status: number
9
+ payload: { error?: string }
10
+ runCount: number
11
+ }>(`
12
+ const storageMod = await import('./src/lib/server/storage')
13
+ const routeMod = await import('./src/app/api/chats/[id]/chat/route')
14
+ const runsMod = await import('@/lib/server/runtime/session-run-manager')
15
+ const storage = storageMod.default || storageMod
16
+ const route = routeMod.default || routeMod
17
+ const runs = runsMod.default || runsMod
18
+
19
+ const now = Date.now()
20
+ storage.saveAgents({
21
+ agent_1: {
22
+ id: 'agent_1',
23
+ name: 'Malformed Agent',
24
+ provider: 'openai',
25
+ model: 'gpt-4o-mini',
26
+ extensions: [],
27
+ createdAt: now,
28
+ updatedAt: now,
29
+ },
30
+ })
31
+ storage.saveSessions({
32
+ sess_1: {
33
+ id: 'sess_1',
34
+ name: 'Malformed Session',
35
+ cwd: process.env.WORKSPACE_DIR,
36
+ user: 'workbench',
37
+ provider: 'openai',
38
+ model: 'gpt-4o-mini',
39
+ claudeSessionId: null,
40
+ messages: [],
41
+ createdAt: now,
42
+ lastActiveAt: now,
43
+ sessionType: 'human',
44
+ agentId: 'agent_1',
45
+ extensions: [],
46
+ },
47
+ })
48
+
49
+ const response = await route.POST(
50
+ new Request('http://local/api/chats/sess_1/chat', {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: '{bad-json',
54
+ }),
55
+ { params: Promise.resolve({ id: 'sess_1' }) },
56
+ )
57
+
58
+ console.log(JSON.stringify({
59
+ status: response.status,
60
+ payload: await response.json(),
61
+ runCount: runs.listRuns({ sessionId: 'sess_1' }).length,
62
+ }))
63
+ `, { prefix: 'swarmclaw-chat-route-invalid-json-' })
64
+
65
+ assert.equal(output.status, 400)
66
+ assert.equal(output.payload.error, 'Invalid or missing request body')
67
+ assert.equal(output.runCount, 0)
68
+ })
@@ -0,0 +1,97 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('connector doctor route rejects malformed JSON with a 400', () => {
7
+ const output = runWithTempDataDir<{
8
+ status: number
9
+ payload: { error?: string }
10
+ }>(`
11
+ const repoMod = await import('./src/lib/server/connectors/connector-repository')
12
+ const routeMod = await import('./src/app/api/connectors/[id]/doctor/route')
13
+ const repo = repoMod.default || repoMod
14
+ const route = routeMod.default || routeMod
15
+
16
+ repo.saveConnectors({
17
+ conn_1: {
18
+ id: 'conn_1',
19
+ name: 'Doctor Test',
20
+ platform: 'discord',
21
+ agentId: 'agent_1',
22
+ chatroomId: null,
23
+ credentialId: null,
24
+ config: {},
25
+ isEnabled: true,
26
+ status: 'running',
27
+ createdAt: 1,
28
+ updatedAt: 1,
29
+ },
30
+ })
31
+
32
+ const response = await route.POST(new Request('http://local/api/connectors/conn_1/doctor', {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json' },
35
+ body: '{bad-json',
36
+ }), { params: Promise.resolve({ id: 'conn_1' }) })
37
+
38
+ console.log(JSON.stringify({
39
+ status: response.status,
40
+ payload: await response.json(),
41
+ }))
42
+ `, { prefix: 'swarmclaw-connector-doctor-route-' })
43
+
44
+ assert.equal(output.status, 400)
45
+ assert.equal(output.payload.error, 'Invalid or missing request body')
46
+ })
47
+
48
+ test('connector doctor route returns a preview report for valid input', () => {
49
+ const output = runWithTempDataDir<{
50
+ status: number
51
+ payload: { warnings?: string[]; policy?: { mode?: string } }
52
+ }>(`
53
+ const repoMod = await import('./src/lib/server/connectors/connector-repository')
54
+ const routeMod = await import('./src/app/api/connectors/[id]/doctor/route')
55
+ const repo = repoMod.default || repoMod
56
+ const route = routeMod.default || routeMod
57
+
58
+ repo.saveConnectors({
59
+ conn_1: {
60
+ id: 'conn_1',
61
+ name: 'Doctor Test',
62
+ platform: 'discord',
63
+ agentId: 'agent_1',
64
+ chatroomId: null,
65
+ credentialId: null,
66
+ config: {},
67
+ isEnabled: true,
68
+ status: 'running',
69
+ createdAt: 1,
70
+ updatedAt: 1,
71
+ },
72
+ })
73
+
74
+ const response = await route.POST(new Request('http://local/api/connectors/conn_1/doctor', {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({
78
+ sampleMsg: {
79
+ channelId: 'channel-1',
80
+ channelName: 'general',
81
+ senderId: 'user-1',
82
+ senderName: 'User',
83
+ text: 'hello',
84
+ },
85
+ }),
86
+ }), { params: Promise.resolve({ id: 'conn_1' }) })
87
+
88
+ console.log(JSON.stringify({
89
+ status: response.status,
90
+ payload: await response.json(),
91
+ }))
92
+ `, { prefix: 'swarmclaw-connector-doctor-route-' })
93
+
94
+ assert.equal(output.status, 200)
95
+ assert.ok(Array.isArray(output.payload.warnings))
96
+ assert.ok(output.payload.policy)
97
+ })
@@ -1,10 +1,34 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+
2
4
  import { notFound } from '@/lib/server/collection-helpers'
3
5
  import { buildConnectorDoctorPreview, buildConnectorDoctorReport, type ConnectorDoctorPreviewInput } from '@/lib/server/connectors/doctor'
4
6
  import { loadConnectors } from '@/lib/server/connectors/connector-repository'
7
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
5
8
 
6
9
  export const dynamic = 'force-dynamic'
7
10
 
11
+ const ConnectorDoctorPreviewSchema: z.ZodType<ConnectorDoctorPreviewInput> = z.object({
12
+ id: z.unknown().optional(),
13
+ name: z.unknown().optional(),
14
+ platform: z.unknown().optional(),
15
+ agentId: z.unknown().optional(),
16
+ chatroomId: z.unknown().optional(),
17
+ credentialId: z.unknown().optional(),
18
+ config: z.record(z.string(), z.unknown()).optional(),
19
+ sampleMsg: z.object({
20
+ channelId: z.string().optional(),
21
+ channelName: z.string().optional(),
22
+ senderId: z.string().optional(),
23
+ senderName: z.string().optional(),
24
+ text: z.string().optional(),
25
+ isGroup: z.boolean().optional(),
26
+ messageId: z.string().optional(),
27
+ replyToMessageId: z.string().optional(),
28
+ threadId: z.string().optional(),
29
+ }).passthrough().nullable().optional(),
30
+ }).passthrough()
31
+
8
32
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
33
  const { id } = await params
10
34
  const connectors = loadConnectors()
@@ -20,7 +44,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
20
44
  const baseConnector = connectors[id]
21
45
  if (!baseConnector) return notFound()
22
46
 
23
- const body = await req.json().catch(() => ({})) as ConnectorDoctorPreviewInput
47
+ const { data: body, error } = await safeParseBody(req, ConnectorDoctorPreviewSchema)
48
+ if (error) return error
24
49
  const connector = buildConnectorDoctorPreview({ baseConnector, input: body, fallbackId: id })
25
50
  return NextResponse.json(buildConnectorDoctorReport(connector, body.sampleMsg, { baseConnector }))
26
51
  }
@@ -0,0 +1 @@
1
+ import './[id]/doctor/route.test'
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('logs route accepts client-side error reports and persists them', () => {
7
+ const output = runWithTempDataDir<{
8
+ status: number
9
+ payload: { ok?: boolean }
10
+ entries: Array<{ tag?: string; message?: string; data?: string }>
11
+ }>(`
12
+ const routeMod = await import('./src/app/api/logs/route')
13
+ const route = routeMod.default || routeMod
14
+
15
+ const postResponse = await route.POST(new Request('http://local/api/logs', {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json' },
18
+ body: JSON.stringify({
19
+ source: 'error-boundary',
20
+ message: 'Client render failed',
21
+ componentStack: 'at DemoComponent',
22
+ }),
23
+ }))
24
+
25
+ const getResponse = await route.GET(new Request('http://local/api/logs?lines=5&search=Client%20render%20failed'))
26
+
27
+ console.log(JSON.stringify({
28
+ status: postResponse.status,
29
+ payload: await postResponse.json(),
30
+ entries: (await getResponse.json()).entries,
31
+ }))
32
+ `, { prefix: 'swarmclaw-logs-route-' })
33
+
34
+ assert.equal(output.status, 200)
35
+ assert.equal(output.payload.ok, true)
36
+ assert.ok(output.entries.some((entry) => entry.tag === 'error-boundary' && entry.message === 'Client render failed'))
37
+ })
38
+
39
+ test('logs route rejects malformed client error payloads with a 400', () => {
40
+ const output = runWithTempDataDir<{
41
+ status: number
42
+ payload: { error?: string }
43
+ }>(`
44
+ const routeMod = await import('./src/app/api/logs/route')
45
+ const route = routeMod.default || routeMod
46
+
47
+ const response = await route.POST(new Request('http://local/api/logs', {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: '{bad-json',
51
+ }))
52
+
53
+ console.log(JSON.stringify({
54
+ status: response.status,
55
+ payload: await response.json(),
56
+ }))
57
+ `, { prefix: 'swarmclaw-logs-route-' })
58
+
59
+ assert.equal(output.status, 400)
60
+ assert.equal(output.payload.error, 'Invalid or missing request body')
61
+ })
@@ -1,10 +1,25 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import fs from 'fs'
3
+ import { z } from 'zod'
4
+
3
5
  import { APP_LOG_PATH } from '@/lib/server/data-dir'
6
+ import { log } from '@/lib/server/logger'
7
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
8
 
5
9
  /** Max bytes to read from the tail of the log file (256 KB). */
6
10
  const TAIL_BYTES = 256 * 1024
7
11
 
12
+ const ClientLogSchema = z.object({
13
+ source: z.string().trim().min(1).max(120).optional().default('client'),
14
+ message: z.string().trim().min(1).max(1000),
15
+ stack: z.string().max(8000).optional(),
16
+ componentStack: z.string().max(8000).optional(),
17
+ digest: z.string().max(200).optional(),
18
+ url: z.string().max(2000).optional(),
19
+ pathname: z.string().max(1000).optional(),
20
+ userAgent: z.string().max(1000).optional(),
21
+ })
22
+
8
23
  export async function GET(req: Request) {
9
24
  const { searchParams } = new URL(req.url)
10
25
  const lines = parseInt(searchParams.get('lines') || '200', 10)
@@ -76,6 +91,26 @@ export async function DELETE() {
76
91
  }
77
92
  }
78
93
 
94
+ export async function POST(req: Request) {
95
+ const { data: body, error } = await safeParseBody(req, ClientLogSchema)
96
+ if (error) return error
97
+
98
+ try {
99
+ log.error(body.source, body.message, {
100
+ stack: body.stack,
101
+ componentStack: body.componentStack,
102
+ digest: body.digest,
103
+ url: body.url,
104
+ pathname: body.pathname,
105
+ userAgent: body.userAgent,
106
+ })
107
+ return NextResponse.json({ ok: true })
108
+ } catch (err: unknown) {
109
+ const message = err instanceof Error ? err.message : String(err)
110
+ return NextResponse.json({ error: message }, { status: 500 })
111
+ }
112
+ }
113
+
79
114
  function parseLine(line: string) {
80
115
  // Format: [2026-02-19T17:06:00.000Z] [INFO] [tag] message | data
81
116
  const match = line.match(/^\[([^\]]+)\]\s+\[(\w+)\]\s+\[([^\]]+)\]\s+(.*)$/)
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ const API_URL = process.env.SWARMDOCK_API_URL || 'https://swarmdock-api.onrender.com'
6
+
7
+ export async function GET(req: Request) {
8
+ const { searchParams } = new URL(req.url)
9
+ const type = searchParams.get('type') || 'tasks'
10
+ const limit = searchParams.get('limit') || '50'
11
+
12
+ const endpoint = type === 'agents' ? '/api/v1/agents' : '/api/v1/tasks'
13
+ try {
14
+ const res = await fetch(`${API_URL}${endpoint}?limit=${limit}`)
15
+ if (!res.ok) {
16
+ const text = await res.text().catch(() => 'Unknown error')
17
+ return NextResponse.json({ error: `SwarmDock API error ${res.status}: ${text}` }, { status: 502 })
18
+ }
19
+ const data = await res.json()
20
+ return NextResponse.json(data)
21
+ } catch (err: unknown) {
22
+ const message = err instanceof Error ? err.message : 'Failed to fetch'
23
+ return NextResponse.json({ error: message }, { status: 502 })
24
+ }
25
+ }
@@ -1,7 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { createPost, getFeed } from '@/lib/swarmfeed-client'
3
- import { getAgent } from '@/lib/server/agents/agent-repository'
2
+ import { createPost, getFeed, registerAgent } from '@/lib/swarmfeed-client'
3
+ import { getAgent, patchAgent } from '@/lib/server/agents/agent-repository'
4
4
  import { safeParseBody } from '@/lib/server/safe-parse-body'
5
+ import { log } from '@/lib/server/logger'
5
6
  import type { Agent } from '@/types'
6
7
 
7
8
  export const dynamic = 'force-dynamic'
@@ -37,15 +38,52 @@ export async function POST(req: Request) {
37
38
  return NextResponse.json({ error: 'content is required' }, { status: 400 })
38
39
  }
39
40
 
40
- // Look up the agent's SwarmFeed API key
41
- const agent = getAgent(body.agentId) as Agent | undefined
42
- if (!agent?.swarmfeedApiKey) {
41
+ // Look up the agent and auto-register on SwarmFeed if needed
42
+ let agent = getAgent(body.agentId) as Agent | undefined
43
+ if (!agent) {
44
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
45
+ }
46
+ if (!agent.swarmfeedEnabled) {
43
47
  return NextResponse.json(
44
- { error: 'Agent not registered on SwarmFeed. Enable SwarmFeed in agent settings first.' },
48
+ { error: 'SwarmFeed is not enabled for this agent. Enable it in agent settings first.' },
45
49
  { status: 400 },
46
50
  )
47
51
  }
48
52
 
53
+ // Auto-register if enabled but no API key yet
54
+ if (!agent.swarmfeedApiKey) {
55
+ const agentName = agent.name
56
+ try {
57
+ log.info('swarmfeed', `Auto-registering agent "${agentName}" on SwarmFeed`)
58
+ const reg = await registerAgent({
59
+ name: agent.name,
60
+ description: agent.description || agent.swarmfeedBio || `${agent.name} agent on SwarmClaw`,
61
+ framework: 'swarmclaw',
62
+ model: agent.model,
63
+ avatar: agent.avatarUrl || undefined,
64
+ bio: agent.swarmfeedBio || undefined,
65
+ })
66
+ patchAgent(agent.id, (current) => {
67
+ if (!current) return null
68
+ return {
69
+ ...current,
70
+ swarmfeedApiKey: reg.apiKey,
71
+ swarmfeedAgentId: reg.agentId,
72
+ swarmfeedJoinedAt: current.swarmfeedJoinedAt ?? Date.now(),
73
+ updatedAt: Date.now(),
74
+ }
75
+ })
76
+ agent = getAgent(body.agentId) as Agent | undefined
77
+ if (!agent?.swarmfeedApiKey) {
78
+ return NextResponse.json({ error: 'Registration succeeded but API key not saved' }, { status: 500 })
79
+ }
80
+ } catch (err: unknown) {
81
+ const message = err instanceof Error ? err.message : 'Registration failed'
82
+ log.error('swarmfeed', `Auto-registration failed for "${agentName}": ${message}`)
83
+ return NextResponse.json({ error: `SwarmFeed registration failed: ${message}` }, { status: 502 })
84
+ }
85
+ }
86
+
49
87
  try {
50
88
  const post = await createPost(agent.swarmfeedApiKey, {
51
89
  content: body.content.trim(),
@@ -25,6 +25,10 @@ export async function GET(req: Request) {
25
25
  const agents = Object.values(loadAgents()) as Agent[]
26
26
  const feedAgent = agents.find((a) => a.swarmfeedEnabled && a.swarmfeedApiKey)
27
27
  agentApiKey = feedAgent?.swarmfeedApiKey ?? undefined
28
+ // No registered agent — return empty feed instead of triggering a 401
29
+ if (!agentApiKey) {
30
+ return NextResponse.json({ posts: [], nextCursor: undefined })
31
+ }
28
32
  }
29
33
 
30
34
  try {
@@ -0,0 +1,82 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('tts routes reject malformed JSON with a 400', () => {
7
+ const output = runWithTempDataDir<{
8
+ ttsStatus: number
9
+ ttsPayload: { error?: string }
10
+ streamStatus: number
11
+ streamPayload: { error?: string }
12
+ }>(`
13
+ const ttsRouteMod = await import('./src/app/api/tts/route')
14
+ const ttsStreamRouteMod = await import('./src/app/api/tts/stream/route')
15
+ const ttsRoute = ttsRouteMod.default || ttsRouteMod
16
+ const ttsStreamRoute = ttsStreamRouteMod.default || ttsStreamRouteMod
17
+
18
+ const ttsResponse = await ttsRoute.POST(new Request('http://local/api/tts', {
19
+ method: 'POST',
20
+ headers: { 'content-type': 'application/json' },
21
+ body: '{bad-json',
22
+ }))
23
+
24
+ const ttsStreamResponse = await ttsStreamRoute.POST(new Request('http://local/api/tts/stream', {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: '{bad-json',
28
+ }))
29
+
30
+ console.log(JSON.stringify({
31
+ ttsStatus: ttsResponse.status,
32
+ ttsPayload: await ttsResponse.json(),
33
+ streamStatus: ttsStreamResponse.status,
34
+ streamPayload: await ttsStreamResponse.json(),
35
+ }))
36
+ `, { prefix: 'swarmclaw-tts-route-' })
37
+
38
+ assert.equal(output.ttsStatus, 400)
39
+ assert.equal(output.ttsPayload.error, 'Invalid or missing request body')
40
+ assert.equal(output.streamStatus, 400)
41
+ assert.equal(output.streamPayload.error, 'Invalid or missing request body')
42
+ })
43
+
44
+ test('tts routes reject empty text with a validation error', () => {
45
+ const output = runWithTempDataDir<{
46
+ ttsStatus: number
47
+ ttsPayload: { error?: string; issues?: Array<{ path: string; message: string }> }
48
+ streamStatus: number
49
+ streamPayload: { error?: string; issues?: Array<{ path: string; message: string }> }
50
+ }>(`
51
+ const ttsRouteMod = await import('./src/app/api/tts/route')
52
+ const ttsStreamRouteMod = await import('./src/app/api/tts/stream/route')
53
+ const ttsRoute = ttsRouteMod.default || ttsRouteMod
54
+ const ttsStreamRoute = ttsStreamRouteMod.default || ttsStreamRouteMod
55
+
56
+ const ttsResponse = await ttsRoute.POST(new Request('http://local/api/tts', {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({ text: ' ' }),
60
+ }))
61
+
62
+ const ttsStreamResponse = await ttsStreamRoute.POST(new Request('http://local/api/tts/stream', {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ text: ' ' }),
66
+ }))
67
+
68
+ console.log(JSON.stringify({
69
+ ttsStatus: ttsResponse.status,
70
+ ttsPayload: await ttsResponse.json(),
71
+ streamStatus: ttsStreamResponse.status,
72
+ streamPayload: await ttsStreamResponse.json(),
73
+ }))
74
+ `, { prefix: 'swarmclaw-tts-route-' })
75
+
76
+ assert.equal(output.ttsStatus, 400)
77
+ assert.equal(output.ttsPayload.error, 'Validation failed')
78
+ assert.deepEqual(output.ttsPayload.issues, [{ path: 'text', message: 'No text provided' }])
79
+ assert.equal(output.streamStatus, 400)
80
+ assert.equal(output.streamPayload.error, 'Validation failed')
81
+ assert.deepEqual(output.streamPayload.issues, [{ path: 'text', message: 'No text provided' }])
82
+ })
@@ -1,14 +1,21 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+
2
4
  import { explainElevenLabsError, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from '@/lib/server/elevenlabs'
5
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
6
+
7
+ const TtsRequestSchema = z.object({
8
+ text: z.string().trim().min(1, 'No text provided'),
9
+ voiceId: z.string().nullable().optional(),
10
+ })
3
11
 
4
12
  export async function POST(req: Request) {
13
+ const { data: body, error } = await safeParseBody(req, TtsRequestSchema)
14
+ if (error) return error
15
+
5
16
  try {
6
- const { text, voiceId } = await req.json()
7
- if (!String(text || '').trim()) {
8
- return new NextResponse('No text provided', { status: 400 })
9
- }
10
- resolveElevenLabsConfig(voiceId)
11
- const audioBuffer = await synthesizeElevenLabsMp3({ text: String(text || ''), voiceId })
17
+ resolveElevenLabsConfig(body.voiceId)
18
+ const audioBuffer = await synthesizeElevenLabsMp3({ text: body.text, voiceId: body.voiceId })
12
19
  return new NextResponse(new Uint8Array(audioBuffer), {
13
20
  headers: {
14
21
  'Content-Type': 'audio/mpeg',
@@ -1,12 +1,19 @@
1
+ import { z } from 'zod'
2
+
1
3
  import { explainElevenLabsError, requestElevenLabsMp3Stream } from '@/lib/server/elevenlabs'
4
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
5
+
6
+ const TtsStreamRequestSchema = z.object({
7
+ text: z.string().trim().min(1, 'No text provided'),
8
+ voiceId: z.string().nullable().optional(),
9
+ })
2
10
 
3
11
  export async function POST(req: Request) {
12
+ const { data: body, error } = await safeParseBody(req, TtsStreamRequestSchema)
13
+ if (error) return error
14
+
4
15
  try {
5
- const { text, voiceId } = await req.json()
6
- if (!String(text || '').trim()) {
7
- return new Response('No text provided', { status: 400 })
8
- }
9
- const apiRes = await requestElevenLabsMp3Stream({ text: String(text || ''), voiceId })
16
+ const apiRes = await requestElevenLabsMp3Stream({ text: body.text, voiceId: body.voiceId })
10
17
  return new Response(apiRes.body, {
11
18
  headers: {
12
19
  'Content-Type': 'audio/mpeg',
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import { ErrorFallback } from '@/components/layout/error-fallback'
6
+ import { reportClientError } from '@/lib/app/report-client-error'
7
+
8
+ export default function AppError({
9
+ error,
10
+ reset,
11
+ }: {
12
+ error: Error & { digest?: string }
13
+ reset: () => void
14
+ }) {
15
+ useEffect(() => {
16
+ reportClientError({
17
+ source: 'app-error',
18
+ error,
19
+ digest: error.digest,
20
+ })
21
+ }, [error])
22
+
23
+ return (
24
+ <ErrorFallback
25
+ message="A route-level error interrupted the current view. Try the request again or reload the app."
26
+ primaryLabel="Try Again"
27
+ onPrimaryAction={() => reset()}
28
+ secondaryLabel="Reload"
29
+ onSecondaryAction={() => window.location.reload()}
30
+ />
31
+ )
32
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import './globals.css'
6
+
7
+ import { ErrorFallback } from '@/components/layout/error-fallback'
8
+ import { reportClientError } from '@/lib/app/report-client-error'
9
+
10
+ export default function GlobalError({
11
+ error,
12
+ }: {
13
+ error: Error & { digest?: string }
14
+ }) {
15
+ useEffect(() => {
16
+ reportClientError({
17
+ source: 'global-error',
18
+ error,
19
+ digest: error.digest,
20
+ })
21
+ }, [error])
22
+
23
+ return (
24
+ <html lang="en" className="dark">
25
+ <body className="antialiased">
26
+ <ErrorFallback
27
+ message="A fatal application error occurred before the normal shell could recover. Reload the app to continue."
28
+ onPrimaryAction={() => window.location.reload()}
29
+ />
30
+ </body>
31
+ </html>
32
+ )
33
+ }
@@ -0,0 +1,7 @@
1
+ 'use client'
2
+
3
+ import { MarketplacePage } from '@/features/swarmdock/marketplace-page'
4
+
5
+ export default function MarketplaceRoute() {
6
+ return <MarketplacePage />
7
+ }
package/src/cli/index.js CHANGED
@@ -298,6 +298,9 @@ const COMMAND_GROUPS = [
298
298
  commands: [
299
299
  cmd('list', 'GET', '/logs', 'List logs (use --query lines=200, --query level=INFO,ERROR)'),
300
300
  cmd('clear', 'DELETE', '/logs', 'Clear logs file'),
301
+ cmd('report', 'POST', '/logs', 'Write a client/browser error entry to the application log', {
302
+ expectsJsonBody: true,
303
+ }),
301
304
  ],
302
305
  },
303
306
  {
@@ -803,6 +806,13 @@ const COMMAND_GROUPS = [
803
806
  cmd('post', 'POST', '/swarmfeed/posts', 'Create a post', { expectsJsonBody: true }),
804
807
  ],
805
808
  },
809
+ {
810
+ name: 'swarmdock',
811
+ description: 'SwarmDock marketplace',
812
+ commands: [
813
+ cmd('browse', 'GET', '/swarmdock', 'Browse SwarmDock marketplace tasks and agents'),
814
+ ],
815
+ },
806
816
  ]
807
817
 
808
818
  const GROUP_MAP = new Map(COMMAND_GROUPS.map((group) => [group.name, group]))