@swarmclawai/swarmclaw 1.3.6 → 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.
Files changed (126) hide show
  1. package/README.md +16 -52
  2. package/next.config.ts +9 -4
  3. package/package.json +18 -10
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  8. package/src/app/api/a2a/route.ts +56 -0
  9. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  10. package/src/app/api/approvals/route.test.ts +29 -3
  11. package/src/app/api/approvals/route.ts +13 -7
  12. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  13. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  14. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  15. package/src/app/api/chats/chat-route.test.ts +68 -0
  16. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  18. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  19. package/src/app/api/logs/route.test.ts +61 -0
  20. package/src/app/api/logs/route.ts +35 -0
  21. package/src/app/api/openclaw/sync/route.ts +1 -1
  22. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  23. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  24. package/src/app/api/swarmfeed/route.ts +37 -0
  25. package/src/app/api/tts/route.test.ts +82 -0
  26. package/src/app/api/tts/route.ts +13 -6
  27. package/src/app/api/tts/stream/route.ts +12 -5
  28. package/src/app/error.tsx +32 -0
  29. package/src/app/global-error.tsx +33 -0
  30. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  31. package/src/app/protocols/page.tsx +16 -7
  32. package/src/app/swarmfeed/page.tsx +7 -0
  33. package/src/cli/index.js +22 -0
  34. package/src/cli/spec.js +9 -0
  35. package/src/components/agents/agent-avatar.tsx +2 -5
  36. package/src/components/agents/agent-sheet.tsx +10 -0
  37. package/src/components/auth/access-key-gate.tsx +25 -0
  38. package/src/components/layout/error-boundary.tsx +12 -30
  39. package/src/components/layout/error-fallback.tsx +61 -0
  40. package/src/components/layout/sidebar-rail.tsx +52 -0
  41. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  42. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  43. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  44. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  45. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  46. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  47. package/src/components/protocols/builder/node-palette.tsx +97 -0
  48. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  49. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  50. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  51. package/src/components/protocols/builder/node-types/index.ts +9 -0
  52. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  53. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  54. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  55. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  56. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  57. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  58. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  59. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  60. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  61. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  62. package/src/components/skills/skills-workspace.tsx +1 -9
  63. package/src/features/protocols/builder/hooks/index.ts +2 -0
  64. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  65. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  66. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  67. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  68. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  69. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  70. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  71. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  72. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  73. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  74. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  75. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  76. package/src/features/swarmfeed/compose-post.tsx +139 -0
  77. package/src/features/swarmfeed/feed-page.tsx +136 -0
  78. package/src/features/swarmfeed/post-card.tsx +114 -0
  79. package/src/features/swarmfeed/queries.ts +28 -0
  80. package/src/lib/a2a/agent-card.ts +61 -0
  81. package/src/lib/a2a/auth.ts +54 -0
  82. package/src/lib/a2a/client.ts +133 -0
  83. package/src/lib/a2a/discovery.ts +116 -0
  84. package/src/lib/a2a/handlers.ts +176 -0
  85. package/src/lib/a2a/json-rpc-router.ts +38 -0
  86. package/src/lib/a2a/types.ts +95 -0
  87. package/src/lib/app/navigation.ts +1 -0
  88. package/src/lib/app/report-client-error.ts +52 -0
  89. package/src/lib/app/view-constants.ts +9 -1
  90. package/src/lib/providers/anthropic.ts +119 -107
  91. package/src/lib/providers/ollama.ts +34 -14
  92. package/src/lib/providers/openai.ts +154 -142
  93. package/src/lib/providers/openclaw.ts +3 -3
  94. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  95. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  96. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  98. package/src/lib/server/connectors/swarmdock.ts +1 -1
  99. package/src/lib/server/extensions.ts +11 -0
  100. package/src/lib/server/messages/message-repository.ts +31 -0
  101. package/src/lib/server/openclaw/sync.ts +4 -4
  102. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  103. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  104. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  105. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  106. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  107. package/src/lib/server/protocols/protocol-types.ts +1 -0
  108. package/src/lib/server/provider-health.ts +19 -3
  109. package/src/lib/server/safe-parse-body.test.ts +32 -0
  110. package/src/lib/server/safe-parse-body.ts +20 -3
  111. package/src/lib/server/session-tools/delegate.ts +151 -77
  112. package/src/lib/server/storage-auth.ts +10 -2
  113. package/src/lib/server/storage-normalization.ts +11 -0
  114. package/src/lib/server/storage.ts +113 -4
  115. package/src/lib/server/working-state/service.test.ts +2 -3
  116. package/src/lib/server/working-state/service.ts +37 -6
  117. package/src/lib/swarmfeed-client.ts +157 -0
  118. package/src/lib/validation/schemas.ts +1 -1
  119. package/src/stores/slices/data-slice.ts +3 -0
  120. package/src/stores/use-approval-store.ts +4 -1
  121. package/src/types/agent.ts +31 -1
  122. package/src/types/index.ts +1 -0
  123. package/src/types/protocol.ts +19 -0
  124. package/src/types/session.ts +1 -1
  125. package/src/types/swarmfeed.ts +30 -0
  126. package/tsconfig.json +1 -2
@@ -123,6 +123,70 @@ test('chat route keeps long-lived user runs alive after stream disconnect and re
123
123
  assert.ok(output.perfLabels.includes('chat-execution/llm-round-trip'))
124
124
  })
125
125
 
126
+ test('chat route rejects malformed JSON with a 400 before queueing work', () => {
127
+ const output = runWithTempDataDir<{
128
+ status: number
129
+ payload: { error?: string }
130
+ runCount: number
131
+ }>(`
132
+ const storageMod = await import('./src/lib/server/storage')
133
+ const routeMod = await import('./src/app/api/chats/[id]/chat/route')
134
+ const runsMod = await import('@/lib/server/runtime/session-run-manager')
135
+ const storage = storageMod.default || storageMod
136
+ const route = routeMod.default || routeMod
137
+ const runs = runsMod.default || runsMod
138
+
139
+ const now = Date.now()
140
+ storage.saveAgents({
141
+ agent_1: {
142
+ id: 'agent_1',
143
+ name: 'Malformed Agent',
144
+ provider: 'openai',
145
+ model: 'gpt-4o-mini',
146
+ extensions: [],
147
+ createdAt: now,
148
+ updatedAt: now,
149
+ },
150
+ })
151
+ storage.saveSessions({
152
+ sess_1: {
153
+ id: 'sess_1',
154
+ name: 'Malformed Session',
155
+ cwd: process.env.WORKSPACE_DIR,
156
+ user: 'workbench',
157
+ provider: 'openai',
158
+ model: 'gpt-4o-mini',
159
+ claudeSessionId: null,
160
+ messages: [],
161
+ createdAt: now,
162
+ lastActiveAt: now,
163
+ sessionType: 'human',
164
+ agentId: 'agent_1',
165
+ extensions: [],
166
+ },
167
+ })
168
+
169
+ const response = await route.POST(
170
+ new Request('http://local/api/chats/sess_1/chat', {
171
+ method: 'POST',
172
+ headers: { 'content-type': 'application/json' },
173
+ body: '{bad-json',
174
+ }),
175
+ { params: Promise.resolve({ id: 'sess_1' }) },
176
+ )
177
+
178
+ console.log(JSON.stringify({
179
+ status: response.status,
180
+ payload: await response.json(),
181
+ runCount: runs.listRuns({ sessionId: 'sess_1' }).length,
182
+ }))
183
+ `, { prefix: 'swarmclaw-chat-route-invalid-json-' })
184
+
185
+ assert.equal(output.status, 400)
186
+ assert.equal(output.payload.error, 'Invalid or missing request body')
187
+ assert.equal(output.runCount, 0)
188
+ })
189
+
126
190
  test('chat route heartbeat runs stay internal and do not persist terminal ack text', () => {
127
191
  const output = runWithTempDataDir<{
128
192
  events: Array<{ t?: string; text?: string }>
@@ -1,10 +1,23 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+
2
4
  import { enqueueSessionRun, type SessionQueueMode } from '@/lib/server/runtime/session-run-manager'
3
5
  import { log } from '@/lib/server/logger'
6
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
7
 
5
8
  export const dynamic = 'force-dynamic'
6
9
  export const maxDuration = 300
7
10
 
11
+ const ChatRouteBodySchema = z.object({
12
+ message: z.string().optional().default(''),
13
+ imagePath: z.string().optional(),
14
+ imageUrl: z.string().optional(),
15
+ attachedFiles: z.array(z.string()).optional(),
16
+ internal: z.boolean().optional().default(false),
17
+ queueMode: z.enum(['steer', 'collect', 'followup']).optional(),
18
+ replyToId: z.string().optional(),
19
+ }).passthrough()
20
+
8
21
  function normalizeQueueMode(raw: unknown, internal: boolean): SessionQueueMode {
9
22
  if (raw === 'steer' || raw === 'collect' || raw === 'followup') return raw
10
23
  return internal ? 'collect' : 'followup'
@@ -13,15 +26,17 @@ function normalizeQueueMode(raw: unknown, internal: boolean): SessionQueueMode {
13
26
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
14
27
  try {
15
28
  const { id } = await params
16
- const body = await req.json().catch(() => ({}))
29
+ const { data: body, error } = await safeParseBody(req, ChatRouteBodySchema)
30
+ if (error) return error
17
31
 
18
- const message = typeof body.message === 'string' ? body.message : ''
19
- const imagePath = typeof body.imagePath === 'string' ? body.imagePath : undefined
20
- const imageUrl = typeof body.imageUrl === 'string' ? body.imageUrl : undefined
21
- const attachedFiles = Array.isArray(body.attachedFiles) ? body.attachedFiles.filter((f: unknown) => typeof f === 'string') as string[] : undefined
22
- const internal = body.internal === true
32
+ const message = body.message
33
+ const imagePath = body.imagePath
34
+ const imageUrl = body.imageUrl
35
+ const attachedFiles = body.attachedFiles
36
+ const internal = body.internal
23
37
  const queueMode = normalizeQueueMode(body.queueMode, internal)
24
- const replyToId = typeof body.replyToId === 'string' ? body.replyToId : undefined
38
+ const replyToId = body.replyToId
39
+ const source = internal ? 'heartbeat' : 'chat'
25
40
 
26
41
  const hasFiles = !!(imagePath || imageUrl || (attachedFiles && attachedFiles.length > 0))
27
42
  if (!message.trim() && !hasFiles) {
@@ -50,7 +65,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
50
65
  imageUrl,
51
66
  attachedFiles,
52
67
  internal,
53
- source: internal ? 'heartbeat' : 'chat',
68
+ source,
54
69
  mode: queueMode,
55
70
  onEvent: (ev) => writeEvent(ev as unknown as Record<string, unknown>),
56
71
  replyToId,
@@ -78,6 +93,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
78
93
  status: run.deduped ? 'deduped' : run.coalesced ? 'coalesced' : 'queued',
79
94
  position: run.position,
80
95
  internal,
96
+ source,
81
97
  mode: queueMode,
82
98
  },
83
99
  }),
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { execSync } from 'child_process'
2
+ import { execSync, execFileSync } from 'child_process'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { safeParseBody } from '@/lib/server/safe-parse-body'
5
5
  import { log } from '@/lib/server/logger'
@@ -21,7 +21,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
21
21
  execSync('git add -A', opts)
22
22
  let committed = false
23
23
  try {
24
- execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, opts)
24
+ execFileSync('git', ['commit', '-m', msg], opts)
25
25
  committed = true
26
26
  } catch (ce: unknown) {
27
27
  const ex = ce as { stdout?: string; stderr?: string }
@@ -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+(.*)$/)
@@ -4,7 +4,7 @@ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
4
  export const dynamic = 'force-dynamic'
5
5
 
6
6
  const VALID_ACTIONS = new Set(['push', 'pull', 'both'])
7
- const VALID_TYPES: SyncType[] = ['memory', 'workspace', 'schedules', 'credentials', 'plugins']
7
+ const VALID_TYPES: SyncType[] = ['memory', 'workspace', 'schedules', 'credentials', 'extensions']
8
8
 
9
9
  export async function POST(req: Request) {
10
10
  try {
@@ -0,0 +1,14 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getChannels } from '@/lib/swarmfeed-client'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export async function GET() {
7
+ try {
8
+ const channels = await getChannels()
9
+ return NextResponse.json({ channels })
10
+ } catch (err: unknown) {
11
+ const message = err instanceof Error ? err.message : 'Failed to fetch channels'
12
+ return NextResponse.json({ error: message }, { status: 502 })
13
+ }
14
+ }
@@ -0,0 +1,60 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createPost, getFeed } from '@/lib/swarmfeed-client'
3
+ import { getAgent } from '@/lib/server/agents/agent-repository'
4
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
5
+ import type { Agent } from '@/types'
6
+
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ export async function GET(req: Request) {
10
+ const { searchParams } = new URL(req.url)
11
+ const cursor = searchParams.get('cursor') || undefined
12
+ const limitStr = searchParams.get('limit')
13
+ const limit = limitStr ? Math.max(1, Math.min(100, Number(limitStr) || 20)) : undefined
14
+
15
+ try {
16
+ const result = await getFeed('for_you', { cursor, limit })
17
+ return NextResponse.json(result)
18
+ } catch (err: unknown) {
19
+ const message = err instanceof Error ? err.message : 'Failed to fetch posts'
20
+ return NextResponse.json({ error: message }, { status: 502 })
21
+ }
22
+ }
23
+
24
+ export async function POST(req: Request) {
25
+ const { data: body, error } = await safeParseBody<{
26
+ agentId?: string
27
+ content?: string
28
+ channelId?: string
29
+ parentId?: string
30
+ }>(req)
31
+ if (error) return error
32
+
33
+ if (!body?.agentId || typeof body.agentId !== 'string') {
34
+ return NextResponse.json({ error: 'agentId is required' }, { status: 400 })
35
+ }
36
+ if (!body.content || typeof body.content !== 'string' || !body.content.trim()) {
37
+ return NextResponse.json({ error: 'content is required' }, { status: 400 })
38
+ }
39
+
40
+ // Look up the agent's SwarmFeed API key
41
+ const agent = getAgent(body.agentId) as Agent | undefined
42
+ if (!agent?.swarmfeedApiKey) {
43
+ return NextResponse.json(
44
+ { error: 'Agent not registered on SwarmFeed. Enable SwarmFeed in agent settings first.' },
45
+ { status: 400 },
46
+ )
47
+ }
48
+
49
+ try {
50
+ const post = await createPost(agent.swarmfeedApiKey, {
51
+ content: body.content.trim(),
52
+ channelId: typeof body.channelId === 'string' ? body.channelId : undefined,
53
+ parentId: typeof body.parentId === 'string' ? body.parentId : undefined,
54
+ })
55
+ return NextResponse.json(post)
56
+ } catch (err: unknown) {
57
+ const message = err instanceof Error ? err.message : 'Failed to create post'
58
+ return NextResponse.json({ error: message }, { status: 502 })
59
+ }
60
+ }
@@ -0,0 +1,37 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getFeed } from '@/lib/swarmfeed-client'
3
+ import { loadAgents } from '@/lib/server/storage'
4
+ import type { FeedType } from '@/types/swarmfeed'
5
+ import type { Agent } from '@/types'
6
+
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ const VALID_FEED_TYPES = new Set<FeedType>(['for_you', 'following', 'channel', 'trending'])
10
+
11
+ export async function GET(req: Request) {
12
+ const { searchParams } = new URL(req.url)
13
+ const type = (searchParams.get('type') || 'for_you') as FeedType
14
+ if (!VALID_FEED_TYPES.has(type)) {
15
+ return NextResponse.json({ error: 'Invalid feed type' }, { status: 400 })
16
+ }
17
+ const channelId = searchParams.get('channelId') || undefined
18
+ const cursor = searchParams.get('cursor') || undefined
19
+ const limitStr = searchParams.get('limit')
20
+ const limit = limitStr ? Math.max(1, Math.min(100, Number(limitStr) || 20)) : undefined
21
+
22
+ // For authenticated feeds (following), find the first enabled agent's API key
23
+ let agentApiKey: string | undefined
24
+ if (type === 'following') {
25
+ const agents = Object.values(loadAgents()) as Agent[]
26
+ const feedAgent = agents.find((a) => a.swarmfeedEnabled && a.swarmfeedApiKey)
27
+ agentApiKey = feedAgent?.swarmfeedApiKey ?? undefined
28
+ }
29
+
30
+ try {
31
+ const result = await getFeed(type, { channelId, cursor, limit }, agentApiKey)
32
+ return NextResponse.json(result)
33
+ } catch (err: unknown) {
34
+ const message = err instanceof Error ? err.message : 'Failed to fetch feed'
35
+ return NextResponse.json({ error: message }, { status: 502 })
36
+ }
37
+ }