@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.
- package/README.md +577 -0
- package/bin/server-cmd.js +359 -0
- package/bin/swarmclaw.js +29 -0
- package/bin/swarmclaw.mjs +1504 -0
- package/next.config.ts +33 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/branding/swarmclaw-org-avatar.png +0 -0
- package/public/branding/swarmclaw-org-avatar.svg +58 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/connectors.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/new-session-openclaw.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/schedules.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agents/[id]/route.ts +30 -0
- package/src/app/api/agents/[id]/thread/route.ts +66 -0
- package/src/app/api/agents/generate/route.ts +42 -0
- package/src/app/api/agents/route.ts +33 -0
- package/src/app/api/auth/route.ts +25 -0
- package/src/app/api/claude-skills/route.ts +42 -0
- package/src/app/api/clawhub/install/route.ts +39 -0
- package/src/app/api/clawhub/search/route.ts +11 -0
- package/src/app/api/connectors/[id]/route.ts +79 -0
- package/src/app/api/connectors/route.ts +60 -0
- package/src/app/api/credentials/[id]/route.ts +14 -0
- package/src/app/api/credentials/route.ts +31 -0
- package/src/app/api/daemon/health-check/route.ts +11 -0
- package/src/app/api/daemon/route.ts +22 -0
- package/src/app/api/dirs/pick/route.ts +60 -0
- package/src/app/api/dirs/route.ts +29 -0
- package/src/app/api/documents/[id]/route.ts +47 -0
- package/src/app/api/documents/route.ts +93 -0
- package/src/app/api/files/serve/route.ts +69 -0
- package/src/app/api/generate/info/route.ts +12 -0
- package/src/app/api/generate/route.ts +106 -0
- package/src/app/api/ip/route.ts +6 -0
- package/src/app/api/knowledge/[id]/route.ts +61 -0
- package/src/app/api/knowledge/route.ts +48 -0
- package/src/app/api/knowledge/upload/route.ts +86 -0
- package/src/app/api/logs/route.ts +65 -0
- package/src/app/api/mcp-servers/[id]/route.ts +32 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
- package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
- package/src/app/api/mcp-servers/route.ts +27 -0
- package/src/app/api/memory/[id]/route.ts +126 -0
- package/src/app/api/memory/maintenance/route.ts +63 -0
- package/src/app/api/memory/route.ts +111 -0
- package/src/app/api/memory-images/[filename]/route.ts +36 -0
- package/src/app/api/orchestrator/run/route.ts +43 -0
- package/src/app/api/plugins/install/route.ts +58 -0
- package/src/app/api/plugins/marketplace/route.ts +33 -0
- package/src/app/api/plugins/route.ts +21 -0
- package/src/app/api/preview-server/route.ts +339 -0
- package/src/app/api/providers/[id]/models/route.ts +29 -0
- package/src/app/api/providers/[id]/route.ts +34 -0
- package/src/app/api/providers/configs/route.ts +7 -0
- package/src/app/api/providers/ollama/route.ts +30 -0
- package/src/app/api/providers/openclaw/health/route.ts +23 -0
- package/src/app/api/providers/route.ts +28 -0
- package/src/app/api/runs/[id]/route.ts +9 -0
- package/src/app/api/runs/route.ts +13 -0
- package/src/app/api/schedules/[id]/route.ts +28 -0
- package/src/app/api/schedules/[id]/run/route.ts +104 -0
- package/src/app/api/schedules/route.ts +78 -0
- package/src/app/api/secrets/[id]/route.ts +29 -0
- package/src/app/api/secrets/route.ts +42 -0
- package/src/app/api/sessions/[id]/browser/route.ts +13 -0
- package/src/app/api/sessions/[id]/chat/route.ts +96 -0
- package/src/app/api/sessions/[id]/clear/route.ts +19 -0
- package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
- package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
- package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
- package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
- package/src/app/api/sessions/[id]/messages/route.ts +9 -0
- package/src/app/api/sessions/[id]/retry/route.ts +28 -0
- package/src/app/api/sessions/[id]/route.ts +103 -0
- package/src/app/api/sessions/[id]/stop/route.ts +13 -0
- package/src/app/api/sessions/heartbeat/route.ts +26 -0
- package/src/app/api/sessions/route.ts +85 -0
- package/src/app/api/settings/route.ts +58 -0
- package/src/app/api/setup/check-provider/route.ts +326 -0
- package/src/app/api/setup/doctor/route.ts +250 -0
- package/src/app/api/skills/[id]/route.ts +40 -0
- package/src/app/api/skills/import/route.ts +69 -0
- package/src/app/api/skills/route.ts +28 -0
- package/src/app/api/tasks/[id]/route.ts +102 -0
- package/src/app/api/tasks/route.ts +115 -0
- package/src/app/api/tts/route.ts +40 -0
- package/src/app/api/upload/route.ts +18 -0
- package/src/app/api/uploads/[filename]/route.ts +59 -0
- package/src/app/api/usage/route.ts +35 -0
- package/src/app/api/version/route.ts +81 -0
- package/src/app/api/version/update/route.ts +95 -0
- package/src/app/api/webhooks/[id]/history/route.ts +13 -0
- package/src/app/api/webhooks/[id]/route.ts +204 -0
- package/src/app/api/webhooks/route.ts +37 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +370 -0
- package/src/app/layout.tsx +52 -0
- package/src/app/page.tsx +172 -0
- package/src/cli/index.js +1232 -0
- package/src/cli/index.test.js +281 -0
- package/src/cli/index.ts +1158 -0
- package/src/cli/spec.js +284 -0
- package/src/components/agents/agent-card.tsx +219 -0
- package/src/components/agents/agent-chat-list.tsx +165 -0
- package/src/components/agents/agent-list.tsx +110 -0
- package/src/components/agents/agent-sheet.tsx +1220 -0
- package/src/components/auth/access-key-gate.tsx +248 -0
- package/src/components/auth/setup-wizard.tsx +940 -0
- package/src/components/auth/user-picker.tsx +88 -0
- package/src/components/chat/chat-area.tsx +406 -0
- package/src/components/chat/chat-header.tsx +491 -0
- package/src/components/chat/chat-tool-toggles.tsx +161 -0
- package/src/components/chat/code-block.tsx +146 -0
- package/src/components/chat/dev-server-bar.tsx +39 -0
- package/src/components/chat/message-bubble.tsx +486 -0
- package/src/components/chat/message-list.tsx +299 -0
- package/src/components/chat/session-debug-panel.tsx +196 -0
- package/src/components/chat/streaming-bubble.tsx +85 -0
- package/src/components/chat/thinking-indicator.tsx +26 -0
- package/src/components/chat/tool-call-bubble.tsx +438 -0
- package/src/components/chat/tool-request-banner.tsx +103 -0
- package/src/components/connectors/connector-list.tsx +196 -0
- package/src/components/connectors/connector-sheet.tsx +804 -0
- package/src/components/input/chat-input.tsx +235 -0
- package/src/components/knowledge/knowledge-list.tsx +206 -0
- package/src/components/knowledge/knowledge-sheet.tsx +316 -0
- package/src/components/layout/app-layout.tsx +1016 -0
- package/src/components/layout/daemon-indicator.tsx +56 -0
- package/src/components/layout/mobile-header.tsx +31 -0
- package/src/components/layout/network-banner.tsx +17 -0
- package/src/components/layout/update-banner.tsx +130 -0
- package/src/components/logs/log-list.tsx +358 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
- package/src/components/memory/memory-card.tsx +63 -0
- package/src/components/memory/memory-detail.tsx +339 -0
- package/src/components/memory/memory-list.tsx +198 -0
- package/src/components/memory/memory-sheet.tsx +70 -0
- package/src/components/plugins/plugin-list.tsx +60 -0
- package/src/components/plugins/plugin-sheet.tsx +311 -0
- package/src/components/providers/provider-list.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +542 -0
- package/src/components/runs/run-list.tsx +231 -0
- package/src/components/schedules/schedule-card.tsx +63 -0
- package/src/components/schedules/schedule-list.tsx +76 -0
- package/src/components/schedules/schedule-sheet.tsx +336 -0
- package/src/components/secrets/secret-sheet.tsx +180 -0
- package/src/components/secrets/secrets-list.tsx +91 -0
- package/src/components/sessions/new-session-sheet.tsx +478 -0
- package/src/components/sessions/session-card.tsx +144 -0
- package/src/components/sessions/session-list.tsx +202 -0
- package/src/components/shared/ai-gen-block.tsx +77 -0
- package/src/components/shared/avatar.tsx +48 -0
- package/src/components/shared/bottom-sheet.tsx +30 -0
- package/src/components/shared/confirm-dialog.tsx +47 -0
- package/src/components/shared/connector-platform-icon.tsx +113 -0
- package/src/components/shared/dir-browser.tsx +285 -0
- package/src/components/shared/dropdown.tsx +55 -0
- package/src/components/shared/icon-button.tsx +25 -0
- package/src/components/shared/settings/plugin-manager.tsx +207 -0
- package/src/components/shared/settings/section-capability-policy.tsx +93 -0
- package/src/components/shared/settings/section-embedding.tsx +99 -0
- package/src/components/shared/settings/section-heartbeat.tsx +168 -0
- package/src/components/shared/settings/section-memory.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +108 -0
- package/src/components/shared/settings/section-providers.tsx +181 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
- package/src/components/shared/settings/section-secrets.tsx +132 -0
- package/src/components/shared/settings/section-user-preferences.tsx +24 -0
- package/src/components/shared/settings/section-voice.tsx +53 -0
- package/src/components/shared/settings/settings-sheet.tsx +88 -0
- package/src/components/shared/settings/types.ts +7 -0
- package/src/components/shared/settings/utils.ts +13 -0
- package/src/components/shared/settings-sheet.tsx +1 -0
- package/src/components/shared/skeleton.tsx +19 -0
- package/src/components/shared/usage-badge.tsx +28 -0
- package/src/components/skills/clawhub-browser.tsx +225 -0
- package/src/components/skills/skill-list.tsx +70 -0
- package/src/components/skills/skill-sheet.tsx +254 -0
- package/src/components/tasks/task-board.tsx +96 -0
- package/src/components/tasks/task-card.tsx +179 -0
- package/src/components/tasks/task-column.tsx +73 -0
- package/src/components/tasks/task-list.tsx +118 -0
- package/src/components/tasks/task-sheet.tsx +415 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sonner.tsx +22 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/usage/usage-list.tsx +105 -0
- package/src/components/webhooks/webhook-list.tsx +166 -0
- package/src/components/webhooks/webhook-sheet.tsx +402 -0
- package/src/hooks/use-auto-resize.ts +20 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-speech-recognition.ts +83 -0
- package/src/instrumentation.ts +8 -0
- package/src/lib/agents.ts +13 -0
- package/src/lib/api-client.ts +100 -0
- package/src/lib/chat.ts +60 -0
- package/src/lib/memory.ts +42 -0
- package/src/lib/openclaw-endpoint.test.ts +48 -0
- package/src/lib/openclaw-endpoint.ts +67 -0
- package/src/lib/provider-config.ts +13 -0
- package/src/lib/providers/anthropic.ts +135 -0
- package/src/lib/providers/claude-cli.ts +202 -0
- package/src/lib/providers/codex-cli.ts +260 -0
- package/src/lib/providers/index.ts +351 -0
- package/src/lib/providers/ollama.ts +131 -0
- package/src/lib/providers/openai.ts +164 -0
- package/src/lib/providers/openclaw.ts +330 -0
- package/src/lib/providers/opencode-cli.ts +164 -0
- package/src/lib/runtime-loop.ts +15 -0
- package/src/lib/schedule-dedupe.test.ts +84 -0
- package/src/lib/schedule-dedupe.ts +174 -0
- package/src/lib/schedule-name.ts +62 -0
- package/src/lib/schedules.ts +16 -0
- package/src/lib/server/agent-registry.ts +70 -0
- package/src/lib/server/api-routes.test.ts +362 -0
- package/src/lib/server/autonomy-contract.ts +200 -0
- package/src/lib/server/build-llm.ts +155 -0
- package/src/lib/server/capability-router.test.ts +21 -0
- package/src/lib/server/capability-router.ts +172 -0
- package/src/lib/server/chat-execution.ts +894 -0
- package/src/lib/server/clawhub-client.test.ts +161 -0
- package/src/lib/server/clawhub-client.ts +26 -0
- package/src/lib/server/connectors/connector-routing.test.ts +243 -0
- package/src/lib/server/connectors/discord.ts +116 -0
- package/src/lib/server/connectors/googlechat.ts +66 -0
- package/src/lib/server/connectors/manager.ts +559 -0
- package/src/lib/server/connectors/matrix.ts +78 -0
- package/src/lib/server/connectors/media.ts +149 -0
- package/src/lib/server/connectors/openclaw.test.ts +375 -0
- package/src/lib/server/connectors/openclaw.ts +1132 -0
- package/src/lib/server/connectors/signal.ts +183 -0
- package/src/lib/server/connectors/slack.ts +258 -0
- package/src/lib/server/connectors/teams.ts +94 -0
- package/src/lib/server/connectors/telegram.ts +221 -0
- package/src/lib/server/connectors/types.ts +62 -0
- package/src/lib/server/connectors/whatsapp.ts +349 -0
- package/src/lib/server/context-manager.ts +232 -0
- package/src/lib/server/cost.ts +31 -0
- package/src/lib/server/daemon-state.ts +354 -0
- package/src/lib/server/data-dir.ts +3 -0
- package/src/lib/server/embeddings.ts +111 -0
- package/src/lib/server/execution-log.ts +257 -0
- package/src/lib/server/gateway/protocol.test.ts +54 -0
- package/src/lib/server/gateway/protocol.ts +114 -0
- package/src/lib/server/heartbeat-service.ts +366 -0
- package/src/lib/server/knowledge-db.test.ts +441 -0
- package/src/lib/server/logger.ts +47 -0
- package/src/lib/server/main-agent-loop.ts +1017 -0
- package/src/lib/server/mcp-client.test.ts +342 -0
- package/src/lib/server/mcp-client.ts +130 -0
- package/src/lib/server/memory-db.ts +1078 -0
- package/src/lib/server/memory-graph.test.ts +153 -0
- package/src/lib/server/memory-graph.ts +138 -0
- package/src/lib/server/openclaw-health.ts +245 -0
- package/src/lib/server/orchestrator-lg.ts +431 -0
- package/src/lib/server/orchestrator.ts +364 -0
- package/src/lib/server/playwright-proxy.mjs +70 -0
- package/src/lib/server/plugins.ts +229 -0
- package/src/lib/server/process-manager.ts +327 -0
- package/src/lib/server/provider-health.ts +113 -0
- package/src/lib/server/queue.ts +859 -0
- package/src/lib/server/runtime-settings.ts +119 -0
- package/src/lib/server/scheduler.ts +196 -0
- package/src/lib/server/session-mailbox.ts +129 -0
- package/src/lib/server/session-run-manager.ts +512 -0
- package/src/lib/server/session-tools/connector.ts +124 -0
- package/src/lib/server/session-tools/context-mgmt.ts +103 -0
- package/src/lib/server/session-tools/context.ts +114 -0
- package/src/lib/server/session-tools/crud.ts +673 -0
- package/src/lib/server/session-tools/delegate.ts +708 -0
- package/src/lib/server/session-tools/file.ts +264 -0
- package/src/lib/server/session-tools/index.ts +164 -0
- package/src/lib/server/session-tools/memory.ts +230 -0
- package/src/lib/server/session-tools/session-info.ts +422 -0
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
- package/src/lib/server/session-tools/shell.ts +171 -0
- package/src/lib/server/session-tools/web.ts +408 -0
- package/src/lib/server/session-tools.ts +9 -0
- package/src/lib/server/skills-normalize.ts +130 -0
- package/src/lib/server/storage-mcp.test.ts +161 -0
- package/src/lib/server/storage.ts +670 -0
- package/src/lib/server/stream-agent-chat.ts +571 -0
- package/src/lib/server/task-reports.ts +122 -0
- package/src/lib/server/task-result.ts +161 -0
- package/src/lib/server/task-validation.test.ts +27 -0
- package/src/lib/server/task-validation.ts +90 -0
- package/src/lib/server/tool-capability-policy.test.ts +58 -0
- package/src/lib/server/tool-capability-policy.ts +262 -0
- package/src/lib/sessions.ts +68 -0
- package/src/lib/tasks.ts +20 -0
- package/src/lib/tts.ts +42 -0
- package/src/lib/upload.ts +10 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +43 -0
- package/src/stores/use-app-store.ts +468 -0
- package/src/stores/use-chat-store.ts +323 -0
- package/src/types/index.ts +621 -0
- 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
|
+
})
|