@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.
- package/README.md +10 -0
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +14 -2
- package/src/app/api/agents/agents-route.test.ts +65 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
- package/src/app/api/chatrooms/route.ts +3 -0
- package/src/app/api/missions/[id]/control/route.ts +21 -0
- package/src/app/api/tasks/[id]/route.ts +11 -1
- package/src/app/api/tasks/tasks-route.test.ts +81 -0
- package/src/app/api/webhooks/[id]/route.ts +18 -15
- package/src/app/missions/page.tsx +38 -8
- package/src/components/missions/mission-edit-sheet.tsx +319 -0
- package/src/components/missions/mission-template-install-dialog.tsx +8 -3
- package/src/lib/server/agents/agent-service.ts +10 -2
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
- package/src/lib/server/agents/main-agent-loop.ts +111 -4
- package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +21 -12
- package/src/lib/server/chat-execution/message-classifier.ts +11 -7
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
- package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
- package/src/lib/server/chat-execution/response-completeness.ts +11 -3
- package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
- package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
- package/src/lib/server/connectors/email.test.ts +64 -0
- package/src/lib/server/connectors/email.ts +35 -6
- package/src/lib/server/connectors/response-media.ts +1 -0
- package/src/lib/server/daemon/daemon-runtime.ts +31 -19
- package/src/lib/server/memory/memory-db.test.ts +8 -0
- package/src/lib/server/memory/memory-db.ts +1 -1
- package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-validation.test.ts +30 -0
- package/src/lib/server/tasks/task-validation.ts +21 -2
- package/src/lib/server/working-state/normalization.ts +5 -1
- 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.
|
|
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:
|
|
18
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
18
19
|
if (error) return error
|
|
19
|
-
const
|
|
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'
|
|
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:
|
|
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
|
|
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)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (body.
|
|
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:
|
|
246
|
-
maxWallclockSec:
|
|
247
|
-
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
|
|
543
|
-
setSessions(
|
|
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
|
}
|