@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.
- package/README.md +17 -3
- 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/missions/templates/[id]/instantiate/route.ts +64 -0
- package/src/app/api/missions/templates/route.ts +8 -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 +135 -22
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/missions/mission-edit-sheet.tsx +319 -0
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +283 -0
- 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 +46 -26
- 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/chats/chat-session-service.ts +11 -0
- 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/missions/mission-service.ts +47 -1
- package/src/lib/server/missions/mission-templates.test.ts +208 -0
- package/src/lib/server/missions/mission-templates.ts +186 -0
- package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
- package/src/lib/server/storage-normalization.ts +6 -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/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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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': {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
})
|