@swarmclawai/swarmclaw 1.5.54 → 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 (37) hide show
  1. package/README.md +10 -0
  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/tasks/[id]/route.ts +11 -1
  9. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  10. package/src/app/api/webhooks/[id]/route.ts +18 -15
  11. package/src/app/missions/page.tsx +38 -8
  12. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  13. package/src/components/missions/mission-template-install-dialog.tsx +8 -3
  14. package/src/lib/server/agents/agent-service.ts +10 -2
  15. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  16. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  17. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  18. package/src/lib/server/chat-execution/chat-turn-preparation.ts +21 -12
  19. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  20. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  21. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  22. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  23. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  24. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  25. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  26. package/src/lib/server/connectors/email.test.ts +64 -0
  27. package/src/lib/server/connectors/email.ts +35 -6
  28. package/src/lib/server/connectors/response-media.ts +1 -0
  29. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  30. package/src/lib/server/memory/memory-db.test.ts +8 -0
  31. package/src/lib/server/memory/memory-db.ts +1 -1
  32. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  33. package/src/lib/server/storage.ts +1 -1
  34. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  35. package/src/lib/server/tasks/task-validation.ts +21 -2
  36. package/src/lib/server/working-state/normalization.ts +5 -1
  37. package/src/lib/validation/schemas.ts +40 -0
package/README.md CHANGED
@@ -399,6 +399,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
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
+
402
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.54",
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': {
@@ -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
  })
@@ -10,6 +10,7 @@ import {
10
10
  MissionTemplateInstallDialog,
11
11
  type InstantiateInput,
12
12
  } from '@/components/missions/mission-template-install-dialog'
13
+ import { MissionEditSheet, isMissionEditable } from '@/components/missions/mission-edit-sheet'
13
14
  import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
14
15
  import { toast } from 'sonner'
15
16
 
@@ -119,11 +120,13 @@ interface ControlsProps {
119
120
  mission: Mission
120
121
  onAction: (action: string, reason?: string) => Promise<void>
121
122
  onForceReport: () => Promise<void>
123
+ onEdit: () => void
122
124
  busy: boolean
123
125
  }
124
126
 
125
- function MissionControls({ mission, onAction, onForceReport, busy }: ControlsProps) {
127
+ function MissionControls({ mission, onAction, onForceReport, onEdit, busy }: ControlsProps) {
126
128
  const btn = 'text-[11px] font-600 px-2.5 py-1 rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed'
129
+ const editable = isMissionEditable(mission.status)
127
130
  return (
128
131
  <div className="flex flex-wrap items-center gap-2">
129
132
  {mission.status === 'draft' || mission.status === 'paused' ? (
@@ -153,6 +156,15 @@ function MissionControls({ mission, onAction, onForceReport, busy }: ControlsPro
153
156
  Mark complete
154
157
  </button>
155
158
  ) : null}
159
+ {editable ? (
160
+ <button
161
+ disabled={busy}
162
+ onClick={onEdit}
163
+ className={`${btn} border-white/[0.12] bg-white/[0.04] text-text hover:bg-white/[0.08]`}
164
+ >
165
+ Edit
166
+ </button>
167
+ ) : null}
156
168
  {mission.status !== 'completed' && mission.status !== 'cancelled' ? (
157
169
  <button
158
170
  disabled={busy}
@@ -218,6 +230,10 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
218
230
  const n = Number.parseFloat(s)
219
231
  return Number.isFinite(n) && n > 0 ? n : null
220
232
  }
233
+ const intOrNull = (s: string): number | null => {
234
+ const n = numOrNull(s)
235
+ return n == null ? null : Math.round(n)
236
+ }
221
237
 
222
238
  const submit = async () => {
223
239
  if (!title.trim() || !goal.trim()) {
@@ -242,9 +258,9 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
242
258
  rootSessionId,
243
259
  budget: {
244
260
  maxUsd: numOrNull(maxUsd),
245
- maxTokens: numOrNull(maxTokens),
246
- maxWallclockSec: numOrNull(maxWallclockSec),
247
- maxTurns: numOrNull(maxTurns),
261
+ maxTokens: intOrNull(maxTokens),
262
+ maxWallclockSec: intOrNull(maxWallclockSec),
263
+ maxTurns: intOrNull(maxTurns),
248
264
  },
249
265
  reportSchedule: reportsEnabled
250
266
  ? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
@@ -377,9 +393,10 @@ interface DetailProps {
377
393
  busy: boolean
378
394
  onAction: (action: string, reason?: string) => Promise<void>
379
395
  onForceReport: () => Promise<void>
396
+ onEdit: () => void
380
397
  }
381
398
 
382
- function MissionDetail({ mission, reports, events, busy, onAction, onForceReport }: DetailProps) {
399
+ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport, onEdit }: DetailProps) {
383
400
  const [selectedReport, setSelectedReport] = useState<MissionReport | null>(null)
384
401
  const wallclockCapMs = mission.budget.maxWallclockSec != null ? mission.budget.maxWallclockSec * 1000 : null
385
402
 
@@ -408,7 +425,7 @@ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport
408
425
 
409
426
  <div>
410
427
  <div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Controls</div>
411
- <MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} busy={busy} />
428
+ <MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} onEdit={onEdit} busy={busy} />
412
429
  </div>
413
430
 
414
431
  {mission.successCriteria.length > 0 && (
@@ -497,6 +514,7 @@ export default function MissionsPage() {
497
514
  const [templates, setTemplates] = useState<MissionTemplate[]>([])
498
515
  const [galleryOpen, setGalleryOpen] = useState(false)
499
516
  const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
517
+ const [editMission, setEditMission] = useState<Mission | null>(null)
500
518
 
501
519
  const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
502
520
 
@@ -539,8 +557,8 @@ export default function MissionsPage() {
539
557
  }, [selectedId, refreshDetail])
540
558
 
541
559
  useEffect(() => {
542
- api<Session[]>('GET', '/chats').then((s) => {
543
- setSessions(Array.isArray(s) ? s : Object.values(s))
560
+ api<Record<string, Session>>('GET', '/chats').then((s) => {
561
+ setSessions(s ? Object.values(s) : [])
544
562
  }).catch(() => setSessions([]))
545
563
  }, [createOpen, galleryOpen, installTemplate])
546
564
 
@@ -587,6 +605,11 @@ export default function MissionsPage() {
587
605
  toast.success(`Mission "${created.title}" created`)
588
606
  }, [refreshList])
589
607
 
608
+ const handleMissionSaved = useCallback((updated: Mission) => {
609
+ setMissions((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
610
+ void refreshList()
611
+ }, [refreshList])
612
+
590
613
  const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
591
614
  const result = await api<{ mission: Mission }>(
592
615
  'POST',
@@ -663,6 +686,7 @@ export default function MissionsPage() {
663
686
  busy={busy}
664
687
  onAction={handleAction}
665
688
  onForceReport={handleForceReport}
689
+ onEdit={() => setEditMission(selected)}
666
690
  />
667
691
  ) : loaded && missions.length === 0 && templates.length > 0 ? (
668
692
  <div className="p-6">
@@ -713,6 +737,12 @@ export default function MissionsPage() {
713
737
  onClose={() => setInstallTemplate(null)}
714
738
  onInstall={handleInstallTemplate}
715
739
  />
740
+
741
+ <MissionEditSheet
742
+ mission={editMission}
743
+ onClose={() => setEditMission(null)}
744
+ onSaved={handleMissionSaved}
745
+ />
716
746
  </MainContent>
717
747
  )
718
748
  }