@swarmclawai/swarmclaw 0.2.0

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 (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,174 @@
1
+ import type { ScheduleType } from '@/types'
2
+
3
+ export type ScheduleLike = {
4
+ id?: string
5
+ name?: string | null
6
+ agentId?: string | null
7
+ taskPrompt?: string | null
8
+ scheduleType?: ScheduleType | string | null
9
+ cron?: string | null
10
+ intervalMs?: number | null
11
+ runAt?: number | null
12
+ status?: string | null
13
+ updatedAt?: number | null
14
+ createdAt?: number | null
15
+ createdByAgentId?: string | null
16
+ createdInSessionId?: string | null
17
+ }
18
+
19
+ export interface ScheduleDuplicateCandidate {
20
+ id?: string | null
21
+ agentId?: string | null
22
+ taskPrompt?: string | null
23
+ scheduleType?: ScheduleType | string | null
24
+ cron?: string | null
25
+ intervalMs?: number | null
26
+ runAt?: number | null
27
+ createdByAgentId?: string | null
28
+ createdInSessionId?: string | null
29
+ }
30
+
31
+ export interface FindDuplicateScheduleOptions {
32
+ ignoreId?: string | null
33
+ includeStatuses?: string[]
34
+ creatorScope?: {
35
+ agentId?: string | null
36
+ sessionId?: string | null
37
+ } | null
38
+ }
39
+
40
+ interface ScheduleSignature {
41
+ id: string
42
+ agentId: string
43
+ taskPrompt: string
44
+ scheduleType: ScheduleType
45
+ cron: string
46
+ intervalMs: number | null
47
+ runAt: number | null
48
+ }
49
+
50
+ function normalizeString(value: unknown): string {
51
+ return typeof value === 'string' ? value.trim() : ''
52
+ }
53
+
54
+ function normalizePrompt(value: unknown): string {
55
+ const text = normalizeString(value)
56
+ if (!text) return ''
57
+ return text.replace(/\s+/g, ' ').trim().toLowerCase()
58
+ }
59
+
60
+ function normalizeCron(value: unknown): string {
61
+ const cron = normalizeString(value)
62
+ if (!cron) return ''
63
+ return cron.replace(/\s+/g, ' ').trim()
64
+ }
65
+
66
+ function normalizePositiveInt(value: unknown): number | null {
67
+ const parsed = typeof value === 'number'
68
+ ? value
69
+ : typeof value === 'string'
70
+ ? Number.parseInt(value, 10)
71
+ : Number.NaN
72
+ if (!Number.isFinite(parsed)) return null
73
+ const intVal = Math.trunc(parsed)
74
+ return intVal > 0 ? intVal : null
75
+ }
76
+
77
+ function normalizeScheduleType(value: unknown): ScheduleType {
78
+ if (value === 'cron' || value === 'once' || value === 'interval') return value
79
+ return 'interval'
80
+ }
81
+
82
+ function toSignature(raw: ScheduleLike | ScheduleDuplicateCandidate): ScheduleSignature {
83
+ return {
84
+ id: normalizeString(raw.id),
85
+ agentId: normalizeString(raw.agentId),
86
+ taskPrompt: normalizePrompt(raw.taskPrompt),
87
+ scheduleType: normalizeScheduleType(raw.scheduleType),
88
+ cron: normalizeCron(raw.cron),
89
+ intervalMs: normalizePositiveInt(raw.intervalMs),
90
+ runAt: normalizePositiveInt(raw.runAt),
91
+ }
92
+ }
93
+
94
+ function cadenceKey(signature: ScheduleSignature): string {
95
+ if (signature.scheduleType === 'cron') return `cron:${signature.cron || ''}`
96
+ if (signature.scheduleType === 'interval') return `interval:${signature.intervalMs ?? ''}`
97
+ if (signature.scheduleType === 'once') return `once:${signature.runAt ?? ''}`
98
+ return signature.scheduleType
99
+ }
100
+
101
+ export function getScheduleSignatureKey(input: ScheduleLike | ScheduleDuplicateCandidate): string {
102
+ const signature = toSignature(input)
103
+ if (!signature.agentId || !signature.taskPrompt) return ''
104
+ if (!sameCadence(signature, signature)) return ''
105
+ return `${signature.agentId}::${signature.taskPrompt}::${signature.scheduleType}::${cadenceKey(signature)}`
106
+ }
107
+
108
+ function sameCadence(a: ScheduleSignature, b: ScheduleSignature): boolean {
109
+ if (a.scheduleType !== b.scheduleType) return false
110
+ if (a.scheduleType === 'cron') return a.cron !== '' && a.cron === b.cron
111
+ if (a.scheduleType === 'interval') return a.intervalMs != null && a.intervalMs === b.intervalMs
112
+ if (a.scheduleType === 'once') {
113
+ if (a.runAt == null || b.runAt == null) return false
114
+ return Math.abs(a.runAt - b.runAt) <= 1000
115
+ }
116
+ return false
117
+ }
118
+
119
+ function isEligibleStatus(status: unknown, includeStatuses: Set<string>): boolean {
120
+ const normalized = normalizeString(status).toLowerCase() || 'active'
121
+ return includeStatuses.has(normalized)
122
+ }
123
+
124
+ function matchesCreatorScope(
125
+ schedule: ScheduleLike,
126
+ scope: FindDuplicateScheduleOptions['creatorScope'],
127
+ ): boolean {
128
+ if (!scope) return true
129
+ const scopeAgent = normalizeString(scope.agentId)
130
+ const scopeSession = normalizeString(scope.sessionId)
131
+ if (!scopeAgent && !scopeSession) return true
132
+
133
+ const existingAgent = normalizeString(schedule.createdByAgentId)
134
+ const existingSession = normalizeString(schedule.createdInSessionId)
135
+
136
+ if (scopeAgent && existingAgent && scopeAgent !== existingAgent) return false
137
+ if (scopeSession && existingSession && scopeSession !== existingSession) return false
138
+ return true
139
+ }
140
+
141
+ function compareUpdatedDesc(a: ScheduleLike, b: ScheduleLike): number {
142
+ const aTs = typeof a.updatedAt === 'number' ? a.updatedAt : (typeof a.createdAt === 'number' ? a.createdAt : 0)
143
+ const bTs = typeof b.updatedAt === 'number' ? b.updatedAt : (typeof b.createdAt === 'number' ? b.createdAt : 0)
144
+ return bTs - aTs
145
+ }
146
+
147
+ export function findDuplicateSchedule(
148
+ schedules: Record<string, ScheduleLike>,
149
+ candidateRaw: ScheduleDuplicateCandidate,
150
+ opts: FindDuplicateScheduleOptions = {},
151
+ ): ScheduleLike | null {
152
+ const candidate = toSignature(candidateRaw)
153
+ if (!candidate.agentId) return null
154
+ if (!candidate.taskPrompt) return null
155
+
156
+ const ignoreId = normalizeString(opts.ignoreId || candidate.id)
157
+ const statuses = new Set((opts.includeStatuses?.length ? opts.includeStatuses : ['active', 'paused']).map((s) => s.toLowerCase()))
158
+
159
+ const matches = Object.values(schedules)
160
+ .filter((existing) => existing && typeof existing === 'object')
161
+ .filter((existing) => {
162
+ const signature = toSignature(existing)
163
+ if (!signature.id) return false
164
+ if (ignoreId && signature.id === ignoreId) return false
165
+ if (!isEligibleStatus(existing.status, statuses)) return false
166
+ if (!matchesCreatorScope(existing, opts.creatorScope || null)) return false
167
+ if (signature.agentId !== candidate.agentId) return false
168
+ if (signature.taskPrompt !== candidate.taskPrompt) return false
169
+ return sameCadence(signature, candidate)
170
+ })
171
+ .sort(compareUpdatedDesc)
172
+
173
+ return matches[0] || null
174
+ }
@@ -0,0 +1,62 @@
1
+ const MAX_SCHEDULE_NAME_LENGTH = 80
2
+
3
+ function normalizeWhitespace(value: string): string {
4
+ return value.replace(/\s+/g, ' ').trim()
5
+ }
6
+
7
+ function truncate(value: string, maxLength = MAX_SCHEDULE_NAME_LENGTH): string {
8
+ if (value.length <= maxLength) return value
9
+ return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`
10
+ }
11
+
12
+ function isGenericName(name: string): boolean {
13
+ const normalized = normalizeWhitespace(name).toLowerCase()
14
+ return normalized === '' || normalized === 'schedule' || normalized === 'new schedule' || normalized === 'unnamed schedule'
15
+ }
16
+
17
+ function deriveFromPrompt(taskPrompt: string): string {
18
+ const prompt = normalizeWhitespace(taskPrompt)
19
+ if (!prompt) return 'Scheduled Task'
20
+
21
+ const lower = prompt.toLowerCase()
22
+ if (lower.includes('wikipedia') && (lower.includes('screenshot') || lower.includes('screen shot'))) {
23
+ return 'Wikipedia Screenshot'
24
+ }
25
+ if (lower.includes('screenshot')) {
26
+ return 'Screenshot Task'
27
+ }
28
+ if (lower.includes('backup')) {
29
+ return 'Backup Task'
30
+ }
31
+ if (lower.includes('health check') || lower.includes('heartbeat')) {
32
+ return 'Health Check'
33
+ }
34
+ if (lower.includes('report')) {
35
+ return 'Report Task'
36
+ }
37
+
38
+ const firstLine = prompt.split('\n')[0] || prompt
39
+ const firstClause = firstLine.split(/[.,;:!?]/)[0] || firstLine
40
+ const cleaned = normalizeWhitespace(
41
+ firstClause
42
+ .replace(/^(please\s+)?(can you|could you|would you)\s+/i, '')
43
+ .replace(/^(create|make|set up|setup|schedule|run|execute|trigger|perform|generate|send|take|capture|navigate|go|open|check|monitor|fetch|pull|build|test)\b\s*/i, '')
44
+ .replace(/^to\s+/i, ''),
45
+ )
46
+ if (!cleaned) return 'Scheduled Task'
47
+ return `${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`
48
+ }
49
+
50
+ export function resolveScheduleName(input: {
51
+ name?: unknown
52
+ taskPrompt?: unknown
53
+ }): string {
54
+ const providedName = typeof input.name === 'string' ? normalizeWhitespace(input.name) : ''
55
+ if (providedName && !isGenericName(providedName)) {
56
+ return truncate(providedName)
57
+ }
58
+
59
+ const taskPrompt = typeof input.taskPrompt === 'string' ? input.taskPrompt : ''
60
+ return truncate(deriveFromPrompt(taskPrompt))
61
+ }
62
+
@@ -0,0 +1,16 @@
1
+ import { api } from './api-client'
2
+ import type { Schedule } from '../types'
3
+
4
+ export const fetchSchedules = () => api<Record<string, Schedule>>('GET', '/schedules')
5
+
6
+ export const createSchedule = (data: Omit<Schedule, 'id' | 'createdAt' | 'lastRunAt' | 'nextRunAt'>) =>
7
+ api<Schedule>('POST', '/schedules', data)
8
+
9
+ export const updateSchedule = (id: string, data: Partial<Schedule>) =>
10
+ api<Schedule>('PUT', `/schedules/${id}`, data)
11
+
12
+ export const deleteSchedule = (id: string) =>
13
+ api<string>('DELETE', `/schedules/${id}`)
14
+
15
+ export const runSchedule = (id: string) =>
16
+ api<{ ok: boolean }>('POST', `/schedules/${id}/run`)
@@ -0,0 +1,70 @@
1
+ import { loadAgents, loadTasks, loadSessions } from './storage'
2
+ import type { Agent, BoardTask } from '@/types'
3
+
4
+ export interface AgentDirectoryEntry {
5
+ id: string
6
+ name: string
7
+ description: string
8
+ capabilities: string[]
9
+ status: 'idle' | 'working'
10
+ statusDetail?: string
11
+ }
12
+
13
+ export function getAgentDirectory(excludeId?: string): AgentDirectoryEntry[] {
14
+ const agents = loadAgents() as Record<string, Agent>
15
+ const tasks = loadTasks() as Record<string, BoardTask>
16
+ const sessions = loadSessions()
17
+
18
+ // Find running tasks per agent
19
+ const runningTasks = new Map<string, string>()
20
+ for (const task of Object.values(tasks)) {
21
+ if (task.status === 'running' && task.agentId) {
22
+ runningTasks.set(task.agentId, task.title)
23
+ }
24
+ }
25
+
26
+ // Find active sessions per agent
27
+ const activeSessions = new Set<string>()
28
+ for (const session of Object.values(sessions) as Record<string, unknown>[]) {
29
+ if (session.active && session.agentId) {
30
+ activeSessions.add(session.agentId as string)
31
+ }
32
+ }
33
+
34
+ const entries: AgentDirectoryEntry[] = []
35
+ for (const agent of Object.values(agents)) {
36
+ if (excludeId && agent.id === excludeId) continue
37
+
38
+ const runningTask = runningTasks.get(agent.id)
39
+ const isActive = activeSessions.has(agent.id)
40
+ const isWorking = !!runningTask || isActive
41
+
42
+ entries.push({
43
+ id: agent.id,
44
+ name: agent.name,
45
+ description: agent.description,
46
+ capabilities: agent.capabilities || [],
47
+ status: isWorking ? 'working' : 'idle',
48
+ statusDetail: runningTask ? `working on: ${runningTask}` : undefined,
49
+ })
50
+ }
51
+
52
+ return entries
53
+ }
54
+
55
+ export function buildAgentAwarenessBlock(excludeId: string): string {
56
+ const directory = getAgentDirectory(excludeId)
57
+ if (!directory.length) return ''
58
+
59
+ const lines = directory.map((entry) => {
60
+ const caps = entry.capabilities.length ? ` (${entry.capabilities.join(', ')})` : ''
61
+ const status = entry.statusDetail || entry.status
62
+ return `- **${entry.name}** [id: ${entry.id}]${caps} — ${status}`
63
+ })
64
+
65
+ return [
66
+ '## Available Agents',
67
+ ...lines,
68
+ 'You can delegate tasks to any agent using the delegate_to_agent tool.',
69
+ ].join('\n')
70
+ }
@@ -0,0 +1,362 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers — validate request body shapes the same way the route handlers do
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function validateKnowledgePost(body: unknown): { ok: true; data: { title: string; content: string; tags?: string[] } } | { ok: false; error: string } {
11
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
12
+ return { ok: false, error: 'Invalid JSON body.' }
13
+ }
14
+ const { title, content, tags } = body as Record<string, unknown>
15
+ if (typeof title !== 'string' || !title.trim()) {
16
+ return { ok: false, error: 'title is required.' }
17
+ }
18
+ if (typeof content !== 'string') {
19
+ return { ok: false, error: 'content is required.' }
20
+ }
21
+ const normalizedTags = Array.isArray(tags)
22
+ ? (tags as unknown[]).filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
23
+ : undefined
24
+ return { ok: true, data: { title: title.trim(), content, tags: normalizedTags } }
25
+ }
26
+
27
+ function validateKnowledgePut(body: unknown): { ok: true; updates: Record<string, unknown> } | { ok: false; error: string } {
28
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
29
+ return { ok: false, error: 'Invalid JSON body.' }
30
+ }
31
+ const { title, content, tags } = body as Record<string, unknown>
32
+ const updates: Record<string, unknown> = {}
33
+ if (typeof title === 'string' && title.trim()) updates.title = title.trim()
34
+ if (typeof content === 'string') updates.content = content
35
+ if (Array.isArray(tags)) {
36
+ updates.tags = (tags as unknown[]).filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
37
+ }
38
+ return { ok: true, updates }
39
+ }
40
+
41
+ type McpTransport = 'stdio' | 'sse' | 'streamable-http'
42
+
43
+ const VALID_TRANSPORTS: McpTransport[] = ['stdio', 'sse', 'streamable-http']
44
+
45
+ interface McpServerBody {
46
+ name: string
47
+ transport: McpTransport
48
+ command?: string
49
+ args?: string[]
50
+ url?: string
51
+ env?: Record<string, string>
52
+ headers?: Record<string, string>
53
+ }
54
+
55
+ function validateMcpServerPost(body: unknown): { ok: true; data: McpServerBody } | { ok: false; error: string } {
56
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
57
+ return { ok: false, error: 'Invalid JSON body.' }
58
+ }
59
+ const b = body as Record<string, unknown>
60
+ if (typeof b.name !== 'string' || !b.name.trim()) {
61
+ return { ok: false, error: 'name is required.' }
62
+ }
63
+ if (typeof b.transport !== 'string' || !VALID_TRANSPORTS.includes(b.transport as McpTransport)) {
64
+ return { ok: false, error: 'transport must be one of: stdio, sse, streamable-http.' }
65
+ }
66
+ const transport = b.transport as McpTransport
67
+ if (transport === 'stdio' && (typeof b.command !== 'string' || !b.command.trim())) {
68
+ return { ok: false, error: 'command is required for stdio transport.' }
69
+ }
70
+ if ((transport === 'sse' || transport === 'streamable-http') && (typeof b.url !== 'string' || !b.url.trim())) {
71
+ return { ok: false, error: 'url is required for sse/streamable-http transport.' }
72
+ }
73
+ return { ok: true, data: b as unknown as McpServerBody }
74
+ }
75
+
76
+ function parseKnowledgeQueryParams(url: string) {
77
+ const { searchParams } = new URL(url)
78
+ const q = searchParams.get('q')
79
+ const tagsParam = searchParams.get('tags')
80
+ const limitParam = searchParams.get('limit')
81
+ const tags = tagsParam ? tagsParam.split(',').map((t) => t.trim()).filter(Boolean) : undefined
82
+ const limit = limitParam ? Math.max(1, Math.min(500, Number.parseInt(limitParam, 10) || 50)) : undefined
83
+ return { q, tags, limit }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Route source-code existence checks
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const thisFile = new URL(import.meta.url).pathname
91
+ const routeDir = path.resolve(path.dirname(thisFile), '../../app/api')
92
+
93
+ function readRoute(...segments: string[]): string {
94
+ return fs.readFileSync(path.join(routeDir, ...segments), 'utf-8')
95
+ }
96
+
97
+ // ===========================================================================
98
+ // Tests
99
+ // ===========================================================================
100
+
101
+ describe('Knowledge API contract', () => {
102
+ // --- POST validation ---------------------------------------------------
103
+ describe('POST body validation', () => {
104
+ it('accepts valid body with title and content', () => {
105
+ const result = validateKnowledgePost({ title: 'My doc', content: 'Some text' })
106
+ assert.equal(result.ok, true)
107
+ if (result.ok) {
108
+ assert.equal(result.data.title, 'My doc')
109
+ assert.equal(result.data.content, 'Some text')
110
+ }
111
+ })
112
+
113
+ it('rejects missing title', () => {
114
+ const result = validateKnowledgePost({ content: 'Hello' })
115
+ assert.equal(result.ok, false)
116
+ if (!result.ok) assert.match(result.error, /title/)
117
+ })
118
+
119
+ it('rejects empty-string title', () => {
120
+ const result = validateKnowledgePost({ title: ' ', content: 'Hello' })
121
+ assert.equal(result.ok, false)
122
+ if (!result.ok) assert.match(result.error, /title/)
123
+ })
124
+
125
+ it('rejects missing content', () => {
126
+ const result = validateKnowledgePost({ title: 'T' })
127
+ assert.equal(result.ok, false)
128
+ if (!result.ok) assert.match(result.error, /content/)
129
+ })
130
+
131
+ it('rejects non-object bodies', () => {
132
+ assert.equal(validateKnowledgePost(null).ok, false)
133
+ assert.equal(validateKnowledgePost([]).ok, false)
134
+ assert.equal(validateKnowledgePost('str').ok, false)
135
+ })
136
+
137
+ it('normalizes tags — filters out non-strings and empty strings', () => {
138
+ const result = validateKnowledgePost({ title: 'T', content: 'C', tags: ['a', '', 42, 'b', ' '] })
139
+ assert.equal(result.ok, true)
140
+ if (result.ok) {
141
+ assert.deepEqual(result.data.tags, ['a', 'b'])
142
+ }
143
+ })
144
+
145
+ it('trims title', () => {
146
+ const result = validateKnowledgePost({ title: ' Trimmed ', content: 'C' })
147
+ assert.equal(result.ok, true)
148
+ if (result.ok) assert.equal(result.data.title, 'Trimmed')
149
+ })
150
+ })
151
+
152
+ // --- PUT validation ----------------------------------------------------
153
+ describe('PUT body validation', () => {
154
+ it('accepts partial updates (title only)', () => {
155
+ const result = validateKnowledgePut({ title: 'New title' })
156
+ assert.equal(result.ok, true)
157
+ if (result.ok) {
158
+ assert.equal(result.updates.title, 'New title')
159
+ assert.equal(result.updates.content, undefined)
160
+ }
161
+ })
162
+
163
+ it('accepts partial updates (content only)', () => {
164
+ const result = validateKnowledgePut({ content: 'New content' })
165
+ assert.equal(result.ok, true)
166
+ if (result.ok) {
167
+ assert.equal(result.updates.content, 'New content')
168
+ assert.equal(result.updates.title, undefined)
169
+ }
170
+ })
171
+
172
+ it('ignores empty-string title in PUT (does not overwrite)', () => {
173
+ const result = validateKnowledgePut({ title: '' })
174
+ assert.equal(result.ok, true)
175
+ if (result.ok) assert.equal(result.updates.title, undefined)
176
+ })
177
+
178
+ it('rejects non-object bodies', () => {
179
+ assert.equal(validateKnowledgePut(null).ok, false)
180
+ assert.equal(validateKnowledgePut([1, 2]).ok, false)
181
+ })
182
+
183
+ it('normalizes tags in PUT', () => {
184
+ const result = validateKnowledgePut({ tags: ['x', 99, '', 'y'] })
185
+ assert.equal(result.ok, true)
186
+ if (result.ok) assert.deepEqual(result.updates.tags, ['x', 'y'])
187
+ })
188
+ })
189
+
190
+ // --- GET query parsing -------------------------------------------------
191
+ describe('GET query param parsing', () => {
192
+ it('parses q param', () => {
193
+ const { q } = parseKnowledgeQueryParams('http://localhost/api/knowledge?q=hello')
194
+ assert.equal(q, 'hello')
195
+ })
196
+
197
+ it('parses tags as comma-separated list', () => {
198
+ const { tags } = parseKnowledgeQueryParams('http://localhost/api/knowledge?tags=a,b,%20c')
199
+ assert.deepEqual(tags, ['a', 'b', 'c'])
200
+ })
201
+
202
+ it('filters empty tag segments', () => {
203
+ const { tags } = parseKnowledgeQueryParams('http://localhost/api/knowledge?tags=a,,b,')
204
+ assert.deepEqual(tags, ['a', 'b'])
205
+ })
206
+
207
+ it('returns undefined tags when param is absent', () => {
208
+ const { tags } = parseKnowledgeQueryParams('http://localhost/api/knowledge')
209
+ assert.equal(tags, undefined)
210
+ })
211
+
212
+ it('clamps limit between 1 and 500', () => {
213
+ // parseInt('0') === 0 which is falsy, so || 50 kicks in => max(1,min(500,50)) = 50
214
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?limit=0').limit, 50)
215
+ // parseInt('-5') === -5 which is truthy, so max(1, min(500, -5)) = max(1, -5) = 1
216
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?limit=-5').limit, 1)
217
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?limit=9999').limit, 500)
218
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?limit=50').limit, 50)
219
+ })
220
+
221
+ it('defaults to 50 for non-numeric limit', () => {
222
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge?limit=abc').limit, 50)
223
+ })
224
+
225
+ it('returns undefined limit when param is absent', () => {
226
+ assert.equal(parseKnowledgeQueryParams('http://localhost/api/knowledge').limit, undefined)
227
+ })
228
+ })
229
+
230
+ // --- Route file structure -----------------------------------------------
231
+ describe('route file exports', () => {
232
+ it('knowledge/route.ts exports GET and POST', () => {
233
+ const src = readRoute('knowledge', 'route.ts')
234
+ assert.match(src, /export\s+async\s+function\s+GET/)
235
+ assert.match(src, /export\s+async\s+function\s+POST/)
236
+ })
237
+
238
+ it('knowledge/[id]/route.ts exports GET, PUT, DELETE', () => {
239
+ const src = readRoute('knowledge', '[id]', 'route.ts')
240
+ assert.match(src, /export\s+async\s+function\s+GET/)
241
+ assert.match(src, /export\s+async\s+function\s+PUT/)
242
+ assert.match(src, /export\s+async\s+function\s+DELETE/)
243
+ })
244
+ })
245
+ })
246
+
247
+ describe('MCP Server API contract', () => {
248
+ // --- POST validation ---------------------------------------------------
249
+ describe('POST body validation', () => {
250
+ it('accepts valid stdio server', () => {
251
+ const result = validateMcpServerPost({
252
+ name: 'My MCP',
253
+ transport: 'stdio',
254
+ command: 'node',
255
+ args: ['server.js'],
256
+ })
257
+ assert.equal(result.ok, true)
258
+ if (result.ok) {
259
+ assert.equal(result.data.name, 'My MCP')
260
+ assert.equal(result.data.transport, 'stdio')
261
+ assert.equal(result.data.command, 'node')
262
+ }
263
+ })
264
+
265
+ it('accepts valid sse server', () => {
266
+ const result = validateMcpServerPost({
267
+ name: 'SSE Server',
268
+ transport: 'sse',
269
+ url: 'http://localhost:8080/sse',
270
+ })
271
+ assert.equal(result.ok, true)
272
+ })
273
+
274
+ it('accepts valid streamable-http server', () => {
275
+ const result = validateMcpServerPost({
276
+ name: 'HTTP Server',
277
+ transport: 'streamable-http',
278
+ url: 'http://localhost:8080/mcp',
279
+ })
280
+ assert.equal(result.ok, true)
281
+ })
282
+
283
+ it('rejects missing name', () => {
284
+ const result = validateMcpServerPost({ transport: 'stdio', command: 'node' })
285
+ assert.equal(result.ok, false)
286
+ if (!result.ok) assert.match(result.error, /name/)
287
+ })
288
+
289
+ it('rejects missing transport', () => {
290
+ const result = validateMcpServerPost({ name: 'Server' })
291
+ assert.equal(result.ok, false)
292
+ if (!result.ok) assert.match(result.error, /transport/)
293
+ })
294
+
295
+ it('rejects invalid transport value', () => {
296
+ const result = validateMcpServerPost({ name: 'S', transport: 'websocket' })
297
+ assert.equal(result.ok, false)
298
+ if (!result.ok) assert.match(result.error, /transport/)
299
+ })
300
+
301
+ it('rejects stdio without command', () => {
302
+ const result = validateMcpServerPost({ name: 'S', transport: 'stdio' })
303
+ assert.equal(result.ok, false)
304
+ if (!result.ok) assert.match(result.error, /command/)
305
+ })
306
+
307
+ it('rejects sse without url', () => {
308
+ const result = validateMcpServerPost({ name: 'S', transport: 'sse' })
309
+ assert.equal(result.ok, false)
310
+ if (!result.ok) assert.match(result.error, /url/)
311
+ })
312
+
313
+ it('rejects streamable-http without url', () => {
314
+ const result = validateMcpServerPost({ name: 'S', transport: 'streamable-http' })
315
+ assert.equal(result.ok, false)
316
+ if (!result.ok) assert.match(result.error, /url/)
317
+ })
318
+
319
+ it('rejects non-object body', () => {
320
+ assert.equal(validateMcpServerPost(null).ok, false)
321
+ assert.equal(validateMcpServerPost('hello').ok, false)
322
+ assert.equal(validateMcpServerPost([]).ok, false)
323
+ })
324
+ })
325
+
326
+ // --- Transport enum completeness ----------------------------------------
327
+ describe('transport enum', () => {
328
+ it('includes exactly three valid transport values', () => {
329
+ assert.deepEqual(VALID_TRANSPORTS, ['stdio', 'sse', 'streamable-http'])
330
+ assert.equal(VALID_TRANSPORTS.length, 3)
331
+ })
332
+ })
333
+
334
+ // --- Route file structure -----------------------------------------------
335
+ describe('route file exports', () => {
336
+ it('mcp-servers/route.ts exports GET and POST', () => {
337
+ const src = readRoute('mcp-servers', 'route.ts')
338
+ assert.match(src, /export\s+async\s+function\s+GET/)
339
+ assert.match(src, /export\s+async\s+function\s+POST/)
340
+ })
341
+
342
+ it('mcp-servers/[id]/route.ts exports GET, PUT, DELETE', () => {
343
+ const src = readRoute('mcp-servers', '[id]', 'route.ts')
344
+ assert.match(src, /export\s+async\s+function\s+GET/)
345
+ assert.match(src, /export\s+async\s+function\s+PUT/)
346
+ assert.match(src, /export\s+async\s+function\s+DELETE/)
347
+ })
348
+
349
+ it('MCP POST route assigns an id via crypto.randomBytes', () => {
350
+ const src = readRoute('mcp-servers', 'route.ts')
351
+ assert.match(src, /crypto\.randomBytes/)
352
+ })
353
+
354
+ it('MCP PUT route preserves id and sets updatedAt', () => {
355
+ const src = readRoute('mcp-servers', '[id]', 'route.ts')
356
+ assert.match(src, /updatedAt:\s*Date\.now\(\)/)
357
+ // Verify id is pinned (spread then override)
358
+ assert.match(src, /\.\.\.servers\[id\]/)
359
+ assert.match(src, /\bid\b,/)
360
+ })
361
+ })
362
+ })