@swarmclawai/swarmclaw 1.5.53 → 1.5.55

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 (48) hide show
  1. package/README.md +17 -3
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +14 -2
  4. package/src/app/api/agents/agents-route.test.ts +65 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
  6. package/src/app/api/chatrooms/route.ts +3 -0
  7. package/src/app/api/missions/[id]/control/route.ts +21 -0
  8. package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
  9. package/src/app/api/missions/templates/route.ts +8 -0
  10. package/src/app/api/tasks/[id]/route.ts +11 -1
  11. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  12. package/src/app/api/webhooks/[id]/route.ts +18 -15
  13. package/src/app/missions/page.tsx +135 -22
  14. package/src/cli/index.js +2 -0
  15. package/src/cli/spec.js +2 -0
  16. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  17. package/src/components/missions/mission-template-gallery.tsx +113 -0
  18. package/src/components/missions/mission-template-install-dialog.tsx +283 -0
  19. package/src/lib/server/agents/agent-service.ts +10 -2
  20. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  21. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  22. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  23. package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
  24. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  25. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  26. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  27. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  28. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  29. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  30. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  31. package/src/lib/server/chats/chat-session-service.ts +11 -0
  32. package/src/lib/server/connectors/email.test.ts +64 -0
  33. package/src/lib/server/connectors/email.ts +35 -6
  34. package/src/lib/server/connectors/response-media.ts +1 -0
  35. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  36. package/src/lib/server/memory/memory-db.test.ts +8 -0
  37. package/src/lib/server/memory/memory-db.ts +1 -1
  38. package/src/lib/server/missions/mission-service.ts +47 -1
  39. package/src/lib/server/missions/mission-templates.test.ts +208 -0
  40. package/src/lib/server/missions/mission-templates.ts +186 -0
  41. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  42. package/src/lib/server/storage-normalization.ts +6 -0
  43. package/src/lib/server/storage.ts +1 -1
  44. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  45. package/src/lib/server/tasks/task-validation.ts +21 -2
  46. package/src/lib/server/working-state/normalization.ts +5 -1
  47. package/src/lib/validation/schemas.ts +40 -0
  48. package/src/types/mission.ts +27 -0
package/README.md CHANGED
@@ -399,13 +399,27 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
- ### v1.5.53 Highlights
402
+ ### v1.5.55 Highlights
403
+
404
+ - **Fix: mission budget updates with decimal values no longer silently fail with a 400.** The mission UI's `numOrNull` parsed user input with `Number.parseFloat`, but the API requires `int()` for `maxTokens`, `maxToolCalls`, `maxWallclockSec`, and `maxTurns`. Typing `1000.5` returned a cryptic Zod error to the toast and the update was lost. Added `intOrNull` (rounds) in `mission-edit-sheet.tsx`, `mission-template-install-dialog.tsx`, and `app/missions/page.tsx`. `maxUsd` still accepts decimals.
405
+ - **Fix: mission edit sheet's connectors dropdown was always empty.** The sheet fetched `/connectors` expecting a `Connector[]`, but the endpoint returns `Record<string, Connector>`. The defensive `Array.isArray` fallback quietly rendered an empty list, so users could not attach report connectors when editing a running mission. Now typed as `Record<string, Connector>` and projected with `Object.values`.
406
+ - **Fix: memory search returns results for short (3-4 char) words like `cats`, `blue`, `dog`.** `buildFtsQuery` had a `unique[0].length >= 5` guard that returned an empty FTS query for any single-token search shorter than 5 chars, silently dropping valid searches. The upstream filter already requires ≥3 chars, so the extra guard just excluded useful queries. Removed; regression tests cover `cats`, `blue`, and `dog`.
407
+ - **Fix: `PUT /api/agents/:id` now validates its body with a Zod schema.** Previously the route did `{...current, ...body}` without validation, so sending `{"tools": "not_an_array"}` silently wiped the agent's tool list to `[]`. Added `AgentUpdateSchema = AgentCreateSchema.partial()` and a filter step that keeps only keys present in the raw body (so Zod defaults do not overwrite untouched fields). Bad types now return a 400 with field-level errors. `updateAgent()` keeps a `current.tools` / `current.extensions` fallback as defense-in-depth for internal callers.
408
+ - **Fix: `PUT /api/tasks/:id` now validates its body with a Zod schema.** Same class of bug: a numeric `title` silently corrupted the stored field. Added `TaskUpdateSchema = TaskCreateSchema.partial().extend({...})` with the update-only fields (`appendComment`, `result`, `error`, lifecycle timestamps) and the same raw-key filter pattern. Bad types now 400 with untouched storage.
409
+ - **Fix: `PUT /api/webhooks/:id` now validates its body with a Zod schema.** Previously `{"events": "not_an_array"}` wiped the events list. Added `WebhookUpdateSchema` and explicit `rawKeys.has(...)` guards in the mutate closure so only fields actually present in the body are applied.
410
+ - **Fix: classifier JSON no longer leaks into assistant responses.** Some Ollama / Ollama Cloud turns were emitting the internal `MessageClassification` object directly into the stream (e.g. `{"taskIntent":"research",...}` prepended to the real reply). The existing stripper only matched when `isDeliverableTask` was the first key, so leaks starting with `taskIntent` sailed through to the user. Replaced the regex with a principled detector that brace-matches candidate JSON (string-quote aware) and validates against `MessageClassificationSchema.safeParse` — the schema itself is the source of truth, so future schema changes can't break detection.
411
+
412
+ ### v1.5.54 Highlights
403
413
 
404
414
  - **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
405
415
  - **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
406
- - **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
416
+ - **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
417
+
418
+ > **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
419
+
420
+ ### v1.5.53 Highlights
407
421
 
408
- Thanks to [@borislavnnikolov](https://github.com/borislavnnikolov) for the contribution.
422
+ - **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
409
423
 
410
424
  ### v1.5.52 Highlights
411
425
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.53",
3
+ "version": "1.5.55",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
81
81
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
82
82
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
83
- "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
83
+ "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
84
84
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
85
85
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
86
86
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -4,6 +4,7 @@ import { trashAgent, updateAgent } from '@/lib/server/agents/agent-service'
4
4
  import { loadAgent } from '@/lib/server/agents/agent-repository'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
+ import { AgentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
7
8
 
8
9
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
10
  const { id } = await params
@@ -14,9 +15,20 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
14
15
 
15
16
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
16
17
  const { id } = await params
17
- const { data: body, error } = await safeParseBody(req)
18
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
18
19
  if (error) return error
19
- const result = updateAgent(id, body as Record<string, unknown>)
20
+ const parsed = AgentUpdateSchema.safeParse(raw)
21
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
22
+
23
+ // Filter to keys actually present in the raw body — zod re-applies `.default(...)`
24
+ // to absent fields, which would clobber untouched fields on the stored agent.
25
+ const rawKeys = new Set(Object.keys(raw ?? {}))
26
+ const body: Record<string, unknown> = {}
27
+ for (const [key, value] of Object.entries(parsed.data)) {
28
+ if (rawKeys.has(key)) body[key] = value
29
+ }
30
+
31
+ const result = updateAgent(id, body)
20
32
  if (!result) return notFound()
21
33
  return NextResponse.json(result)
22
34
  }
@@ -4,7 +4,7 @@ import test, { afterEach } from 'node:test'
4
4
  // Disable daemon autostart during tests
5
5
  process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
6
6
 
7
- import { GET as getAgent } from './[id]/route'
7
+ import { GET as getAgent, PUT as putAgent } from './[id]/route'
8
8
  import { POST as createAgent } from './route'
9
9
  import { loadAgents, saveAgents } from '@/lib/server/storage'
10
10
 
@@ -112,3 +112,67 @@ test('POST /api/agents rejects missing required fields with a 400', async () =>
112
112
  const body = await response.json()
113
113
  assert.equal(body.error, 'Validation failed')
114
114
  })
115
+
116
+ // --- PUT /api/agents/:id (validation & field preservation) ---
117
+
118
+ test('PUT /api/agents/:id rejects a non-array tools value with a 400', async () => {
119
+ seedAgent('agent-tools-reject', { tools: ['memory', 'files', 'web_search'] })
120
+
121
+ const response = await putAgent(new Request('http://local/api/agents/agent-tools-reject', {
122
+ method: 'PUT',
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify({ tools: 'not_an_array' }),
125
+ }), routeParams('agent-tools-reject'))
126
+
127
+ assert.equal(response.status, 400)
128
+ const body = await response.json()
129
+ assert.equal(body.error, 'Validation failed')
130
+ assert.ok(body.issues.some((i: { path: string }) => i.path === 'tools'))
131
+
132
+ const agentAfter = loadAgents()['agent-tools-reject']
133
+ assert.deepEqual(agentAfter.tools, ['memory', 'files', 'web_search'], 'stored tools must be untouched')
134
+ })
135
+
136
+ test('PUT /api/agents/:id does not clobber untouched fields with schema defaults', async () => {
137
+ // Seed with non-default values; PUT a body that omits those fields. The route
138
+ // must filter zod defaults so missing keys do NOT reset the stored values.
139
+ seedAgent('agent-partial-update', {
140
+ name: 'Original',
141
+ tools: ['memory'],
142
+ delegationEnabled: true,
143
+ delegationTargetMode: 'selected',
144
+ delegationTargetAgentIds: ['other-agent'],
145
+ heartbeatEnabled: false,
146
+ proactiveMemory: false,
147
+ })
148
+
149
+ const response = await putAgent(new Request('http://local/api/agents/agent-partial-update', {
150
+ method: 'PUT',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({ description: 'edited' }),
153
+ }), routeParams('agent-partial-update'))
154
+
155
+ assert.equal(response.status, 200)
156
+ const body = await response.json()
157
+ assert.equal(body.description, 'edited')
158
+ assert.equal(body.name, 'Original')
159
+ assert.deepEqual(body.tools, ['memory'])
160
+ assert.equal(body.delegationEnabled, true)
161
+ assert.equal(body.delegationTargetMode, 'selected')
162
+ assert.equal(body.heartbeatEnabled, false)
163
+ assert.equal(body.proactiveMemory, false)
164
+ })
165
+
166
+ test('PUT /api/agents/:id rejects non-string name', async () => {
167
+ seedAgent('agent-bad-name', { name: 'Good' })
168
+
169
+ const response = await putAgent(new Request('http://local/api/agents/agent-bad-name', {
170
+ method: 'PUT',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ name: 12345 }),
173
+ }), routeParams('agent-bad-name'))
174
+
175
+ assert.equal(response.status, 400)
176
+ const agentAfter = loadAgents()['agent-bad-name']
177
+ assert.equal(agentAfter.name, 'Good')
178
+ })
@@ -25,7 +25,7 @@ import {
25
25
  selectChatroomRecipients,
26
26
  } from '@/lib/server/chatrooms/chatroom-routing'
27
27
  import { markProviderFailure, markProviderSuccess } from '@/lib/server/provider-health'
28
- import { applyAgentReactionsFromText } from '@/lib/server/chatrooms/chatroom-agent-signals'
28
+ import { applyAgentReactionsFromText, stripAgentReactionTokens } from '@/lib/server/chatrooms/chatroom-agent-signals'
29
29
  import { resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
30
30
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
31
31
  import type { Chatroom, ChatroomMessage, Agent } from '@/types'
@@ -46,7 +46,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
46
46
  const chatroom = chatrooms[id] as Chatroom | undefined
47
47
  if (!chatroom) return notFound()
48
48
 
49
- const text = typeof body.text === 'string' ? body.text : ''
49
+ const text = typeof body.text === 'string'
50
+ ? body.text
51
+ : (typeof body.message === 'string' ? body.message : '')
50
52
  const senderId = typeof body.senderId === 'string' ? body.senderId : 'user'
51
53
  const imagePath = typeof body.imagePath === 'string' ? body.imagePath : undefined
52
54
  const attachedFiles = Array.isArray(body.attachedFiles)
@@ -260,7 +262,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
260
262
  })
261
263
 
262
264
  const rawResponseText = result.finalResponse || result.fullText || fullText
263
- const responseText = stripHiddenControlTokens(rawResponseText)
265
+ const responseText = stripAgentReactionTokens(stripHiddenControlTokens(rawResponseText))
264
266
 
265
267
  // Don't persist empty or error-only messages — they pollute chat history
266
268
  if (!responseText.trim() && agentError) {
@@ -41,6 +41,9 @@ export async function GET(req: Request) {
41
41
  export async function POST(req: Request) {
42
42
  const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
43
43
  if (error) return error
44
+ if (raw && Array.isArray(raw.memberAgentIds) && !Array.isArray(raw.agentIds)) {
45
+ raw.agentIds = raw.memberAgentIds
46
+ }
44
47
  const parsed = ChatroomCreateSchema.safeParse(raw)
45
48
  if (!parsed.success) {
46
49
  return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
@@ -11,6 +11,9 @@ import {
11
11
  pauseMission,
12
12
  startMission,
13
13
  } from '@/lib/server/missions/mission-service'
14
+ import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
15
+ import { loadSessions } from '@/lib/server/storage'
16
+ import { log } from '@/lib/server/logger'
14
17
 
15
18
  export const dynamic = 'force-dynamic'
16
19
 
@@ -31,7 +34,25 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
31
34
  switch (parsed.data.action) {
32
35
  case 'start':
33
36
  case 'resume': {
37
+ const wasDraft = mission.status === 'draft'
34
38
  const updated = startMission(id)
39
+ if (updated && wasDraft && updated.rootSessionId && updated.goal) {
40
+ const sessions = loadSessions()
41
+ if (sessions[updated.rootSessionId]) {
42
+ try {
43
+ enqueueSessionRun({
44
+ sessionId: updated.rootSessionId,
45
+ message: `Mission goal: ${updated.goal}`,
46
+ missionId: updated.id,
47
+ source: 'mission',
48
+ internal: true,
49
+ dedupeKey: `mission:${updated.id}:kickoff`,
50
+ })
51
+ } catch (kickErr) {
52
+ log.warn('api-mission-control', `Mission kickoff enqueue failed for ${updated.id}`, kickErr)
53
+ }
54
+ }
55
+ }
35
56
  return NextResponse.json(updated)
36
57
  }
37
58
  case 'pause': {
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { formatZodError } from '@/lib/validation/schemas'
5
+ import { notFound } from '@/lib/server/collection-helpers'
6
+ import { createMissionFromTemplate } from '@/lib/server/missions/mission-service'
7
+ import { patchSession } from '@/lib/server/sessions/session-repository'
8
+
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ const BudgetOverrideSchema = z.object({
12
+ maxUsd: z.number().positive().nullable().optional(),
13
+ maxTokens: z.number().positive().int().nullable().optional(),
14
+ maxToolCalls: z.number().positive().int().nullable().optional(),
15
+ maxWallclockSec: z.number().positive().int().nullable().optional(),
16
+ maxTurns: z.number().positive().int().nullable().optional(),
17
+ warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
18
+ }).partial()
19
+
20
+ const ReportScheduleSchema = z.object({
21
+ intervalSec: z.number().int().min(30),
22
+ format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
23
+ enabled: z.boolean().default(true),
24
+ lastReportAt: z.number().nullable().optional(),
25
+ }).strict()
26
+
27
+ const InstantiateSchema = z.object({
28
+ rootSessionId: z.string().min(1, 'rootSessionId is required'),
29
+ overrides: z.object({
30
+ title: z.string().min(1).max(200).optional(),
31
+ goal: z.string().min(1).max(4000).optional(),
32
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
33
+ budget: BudgetOverrideSchema.optional(),
34
+ reportSchedule: ReportScheduleSchema.nullable().optional(),
35
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
36
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
37
+ }).optional(),
38
+ }).strict()
39
+
40
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
41
+ const { id } = await params
42
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
43
+ if (error) return error
44
+ const parsed = InstantiateSchema.safeParse(body)
45
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
46
+
47
+ const result = createMissionFromTemplate({
48
+ templateId: id,
49
+ rootSessionId: parsed.data.rootSessionId,
50
+ overrides: parsed.data.overrides,
51
+ })
52
+ if (!result) return notFound()
53
+
54
+ try {
55
+ patchSession(result.mission.rootSessionId, (current) => {
56
+ if (!current) return null
57
+ return { ...current, missionId: result.mission.id }
58
+ })
59
+ } catch {
60
+ // Session may not exist yet; budget hook falls back to service map.
61
+ }
62
+
63
+ return NextResponse.json({ mission: result.mission, template: result.template })
64
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { listMissionTemplates } from '@/lib/server/missions/mission-templates'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(listMissionTemplates())
8
+ }
@@ -7,6 +7,7 @@ import {
7
7
  prepareTasksForListing,
8
8
  updateTaskFromRoute,
9
9
  } from '@/lib/server/tasks/task-route-service'
10
+ import { TaskUpdateSchema, formatZodError } from '@/lib/validation/schemas'
10
11
 
11
12
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
12
13
  const { id } = await params
@@ -17,8 +18,17 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
17
18
 
18
19
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
20
  const { id } = await params
20
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
21
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
21
22
  if (error) return error
23
+ const parsed = TaskUpdateSchema.safeParse(raw)
24
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
25
+
26
+ const rawKeys = new Set(Object.keys(raw ?? {}))
27
+ const body: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(parsed.data)) {
29
+ if (rawKeys.has(key)) body[key] = value
30
+ }
31
+
22
32
  const result = updateTaskFromRoute(id, body)
23
33
  if (!result.ok && result.status === 404) return notFound()
24
34
  return result.ok
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
5
+
6
+ import { PUT as putTask } from './[id]/route'
7
+ import { loadTasks, saveTasks } from '@/lib/server/storage'
8
+ import type { BoardTask } from '@/types'
9
+
10
+ const originalTasks = loadTasks()
11
+
12
+ function routeParams(id: string) {
13
+ return { params: Promise.resolve({ id }) }
14
+ }
15
+
16
+ function seedTask(id: string, overrides: Partial<BoardTask> = {}) {
17
+ const tasks = loadTasks()
18
+ const now = Date.now()
19
+ tasks[id] = {
20
+ id,
21
+ title: 'Seed Task',
22
+ description: '',
23
+ status: 'backlog',
24
+ createdAt: now,
25
+ updatedAt: now,
26
+ ...overrides,
27
+ } as BoardTask
28
+ saveTasks(tasks)
29
+ }
30
+
31
+ afterEach(() => {
32
+ saveTasks(originalTasks)
33
+ })
34
+
35
+ test('PUT /api/tasks/:id rejects a non-string title with a 400', async () => {
36
+ seedTask('task-bad-title', { title: 'Original' })
37
+
38
+ const response = await putTask(new Request('http://local/api/tasks/task-bad-title', {
39
+ method: 'PUT',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({ title: 42 }),
42
+ }), routeParams('task-bad-title'))
43
+
44
+ assert.equal(response.status, 400)
45
+ const stored = loadTasks()['task-bad-title']
46
+ assert.equal(stored.title, 'Original', 'stored title must be unchanged')
47
+ })
48
+
49
+ test('PUT /api/tasks/:id partial update does not clobber untouched fields', async () => {
50
+ seedTask('task-partial', {
51
+ title: 'Keep me',
52
+ description: 'Keep me too',
53
+ status: 'queued',
54
+ })
55
+
56
+ const response = await putTask(new Request('http://local/api/tasks/task-partial', {
57
+ method: 'PUT',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({ description: 'updated' }),
60
+ }), routeParams('task-partial'))
61
+
62
+ assert.equal(response.status, 200)
63
+ const body = await response.json()
64
+ assert.equal(body.title, 'Keep me')
65
+ assert.equal(body.description, 'updated')
66
+ assert.equal(body.status, 'queued')
67
+ })
68
+
69
+ test('PUT /api/tasks/:id rejects a non-array blockedBy with a 400', async () => {
70
+ seedTask('task-bad-blocked', { title: 'T', blockedBy: ['dep-1'] })
71
+
72
+ const response = await putTask(new Request('http://local/api/tasks/task-bad-blocked', {
73
+ method: 'PUT',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify({ blockedBy: 'not_an_array' }),
76
+ }), routeParams('task-bad-blocked'))
77
+
78
+ assert.equal(response.status, 400)
79
+ const stored = loadTasks()['task-bad-blocked']
80
+ assert.deepEqual(stored.blockedBy, ['dep-1'], 'stored blockedBy must be unchanged')
81
+ })
@@ -1,19 +1,12 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { loadWebhooks, saveWebhooks } from '@/lib/server/storage'
3
3
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
4
+ import { WebhookUpdateSchema, formatZodError } from '@/lib/validation/schemas'
4
5
  import { handleWebhookPost } from './helpers'
5
6
 
6
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
8
  const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
8
9
 
9
- function normalizeEvents(value: unknown): string[] {
10
- if (!Array.isArray(value)) return []
11
- return value
12
- .filter((v): v is string => typeof v === 'string')
13
- .map((v) => v.trim())
14
- .filter(Boolean)
15
- }
16
-
17
10
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
18
11
  const { id } = await params
19
12
  const webhooks = loadWebhooks()
@@ -24,14 +17,24 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
24
17
 
25
18
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
26
19
  const { id } = await params
27
- const body = await req.json().catch(() => ({}))
20
+ const raw = await req.json().catch(() => null)
21
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
22
+ return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
23
+ }
24
+ const parsed = WebhookUpdateSchema.safeParse(raw)
25
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
26
+
27
+ const rawKeys = new Set(Object.keys(raw))
28
+ const body = parsed.data
28
29
  const result = mutateItem(ops, id, (webhook) => {
29
- if (body.name !== undefined) webhook.name = body.name
30
- if (body.source !== undefined) webhook.source = body.source
31
- if (body.events !== undefined) webhook.events = normalizeEvents(body.events)
32
- if (body.agentId !== undefined) webhook.agentId = body.agentId
33
- if (body.secret !== undefined) webhook.secret = body.secret
34
- if (body.isEnabled !== undefined) webhook.isEnabled = !!body.isEnabled
30
+ if (rawKeys.has('name') && body.name !== undefined) webhook.name = body.name
31
+ if (rawKeys.has('source') && body.source !== undefined) webhook.source = body.source
32
+ if (rawKeys.has('events') && body.events !== undefined) {
33
+ webhook.events = body.events.map((e) => e.trim()).filter(Boolean)
34
+ }
35
+ if (rawKeys.has('agentId') && body.agentId !== undefined) webhook.agentId = body.agentId
36
+ if (rawKeys.has('secret') && body.secret !== undefined) webhook.secret = body.secret
37
+ if (rawKeys.has('isEnabled') && body.isEnabled !== undefined) webhook.isEnabled = body.isEnabled
35
38
  webhook.updatedAt = Date.now()
36
39
  return webhook
37
40
  })