@swarmclawai/swarmclaw 1.5.47 → 1.5.49

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 (31) hide show
  1. package/README.md +15 -0
  2. package/package.json +1 -1
  3. package/skills/swarmclaw/SKILL.md +8 -0
  4. package/src/app/api/missions/[id]/control/route.ts +57 -0
  5. package/src/app/api/missions/[id]/events/route.ts +21 -0
  6. package/src/app/api/missions/[id]/reports/route.ts +33 -0
  7. package/src/app/api/missions/[id]/route.ts +82 -0
  8. package/src/app/api/missions/route.test.ts +170 -0
  9. package/src/app/api/missions/route.ts +58 -0
  10. package/src/app/missions/page.tsx +635 -0
  11. package/src/cli/index.js +15 -0
  12. package/src/cli/spec.js +14 -0
  13. package/src/components/layout/sidebar-rail.tsx +8 -0
  14. package/src/components/mcp-servers/mcp-server-sheet.tsx +22 -0
  15. package/src/lib/app/navigation.ts +1 -0
  16. package/src/lib/app/view-constants.ts +10 -1
  17. package/src/lib/server/missions/mission-budget-hook.ts +38 -0
  18. package/src/lib/server/missions/mission-report-builder.test.ts +106 -0
  19. package/src/lib/server/missions/mission-report-builder.ts +158 -0
  20. package/src/lib/server/missions/mission-repository.test.ts +171 -0
  21. package/src/lib/server/missions/mission-repository.ts +137 -0
  22. package/src/lib/server/missions/mission-scheduler.ts +107 -0
  23. package/src/lib/server/missions/mission-service.test.ts +201 -0
  24. package/src/lib/server/missions/mission-service.ts +299 -0
  25. package/src/lib/server/runtime/heartbeat-service.ts +5 -0
  26. package/src/lib/server/runtime/session-run-manager/enqueue.ts +9 -0
  27. package/src/lib/server/storage-normalization.ts +145 -1
  28. package/src/lib/server/storage.ts +29 -0
  29. package/src/types/index.ts +1 -0
  30. package/src/types/mission.ts +115 -0
  31. package/src/types/session.ts +3 -1
package/README.md CHANGED
@@ -396,6 +396,21 @@ Operational docs: https://swarmclaw.ai/docs/observability
396
396
 
397
397
  ## Releases
398
398
 
399
+ ### v1.5.49 Highlights
400
+
401
+ - **Autonomous Missions**: a new first-class concept for long-running, goal-driven agent work. Hand your agent team a goal on Friday, come back Monday to see what they shipped. Each mission carries a title, a natural-language objective, bulleted success criteria, hard budgets (USD, tokens, turns, wallclock), periodic markdown reports, and a full milestone timeline. Missions drive any session through the existing heartbeat pipeline, so delegation to Claude Code, Codex, OpenCode, Cursor, Droid, Goose, Qwen, or native SwarmClaw agents all work without changes.
402
+ - **Budget enforcement in the run pipeline**: `enqueueSessionRun` now consults the mission's budget before every autonomous turn. When any cap is hit the mission transitions to `budget_exhausted`, the queue drains, and a final report fires. Warn thresholds (default 50% / 80% / 95% of each cap) emit `budget_warn` milestones exactly once each.
403
+ - **Scheduler tick from heartbeat**: `runMissionScheduler()` fires every heartbeat tick, independent of the active-hours window, so wallclock budgets and periodic reports still fire overnight. Report cadence is configurable per mission; reports land as in-app notifications today and ship as Slack/Discord/audio in a follow-up.
404
+ - **`/missions` dashboard**: new page with a live mission list, status pills, four-axis budget gauges, a scrollable milestone timeline, a reports drawer, and start / pause / cancel / mark-complete / generate-report-now controls.
405
+ - **CLI commands**: `swarmclaw missions list|get|create|update|delete|control|reports|report-now|events`. Create a mission, start it, and watch the timeline from the terminal or CI.
406
+ - **New storage collections**: `agent_missions`, `mission_reports`, and `agent_mission_events`. The legacy deprecated `missions` table is left untouched so nothing in existing installs is disturbed.
407
+
408
+ ### v1.5.48 Highlights
409
+
410
+ - **SwarmDock MCP preset now points at the hosted endpoint**: *MCP Servers → Quick Setup → SwarmDock* is pre-filled with `streamable-http` transport pointed at `https://swarmdock-api.onrender.com/mcp` and a ready-to-edit `Authorization: Bearer <key>` header template. Users no longer need to run `npx swarmdock-mcp` locally — the SwarmDock team hosts the MCP server in-process on the existing API service. First-time setup (browser keygen + agent registration) lives at [swarmdock.ai/mcp/connect](https://www.swarmdock.ai/mcp/connect).
411
+ - **McpPreset gains `url` and `headersTemplate`**: `applyPreset` now prefills the URL input and the Headers textarea in addition to command/args/env, so remote presets can ship complete configs.
412
+ - **Skills doc refresh**: the `swarmclaw` skill's MCP Servers section points to the hosted flow instead of the prior stdio instructions.
413
+
399
414
  ### v1.5.47 Highlights
400
415
 
401
416
  - **MCP injection for GitHub Copilot CLI and OpenAI Codex CLI agents**: agents using the `copilot-cli` or `codex-cli` providers now run with their assigned MCP servers attached at runtime. Copilot CLI receives the servers via `--additional-mcp-config @<tempfile>`; Codex CLI gets per-session `[mcp_servers.*]` TOML sections appended to a scoped `config.toml`. Stdio transports (command, args, env, cwd) and SSE / streamable-http transports (url, headers) are both supported. Skills assigned to the agent continue to be injected via the system prompt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.47",
3
+ "version": "1.5.49",
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",
@@ -125,6 +125,14 @@ Agents can communicate through external platforms:
125
125
  - Messages sent via `platform` tool with `communicate.send_message`
126
126
  - Inbound messages from connectors trigger agent sessions automatically
127
127
 
128
+ ### MCP Servers
129
+
130
+ Agents can also use tools served by external Model Context Protocol servers:
131
+
132
+ - Register MCP servers under **MCP Servers** in the UI (stdio / sse / streamable-http transports supported).
133
+ - Quick-setup presets include **SwarmVault** (local-first knowledge vault) and **SwarmDock** (agent marketplace — browse tasks, bid, submit work, earn USDC). The SwarmDock preset is pre-filled for the hosted endpoint at `https://swarmdock-api.onrender.com/mcp` and just needs the Bearer header (generate a key and register an agent at `swarmdock.ai/mcp/connect`). See `docs/mcp-servers.md` for the full workflow.
134
+ - Once attached to an agent, MCP tools appear alongside the built-in tools at execution time.
135
+
128
136
  ## Workspace Conventions
129
137
 
130
138
  - The workspace root is the agent's working directory
@@ -0,0 +1,57 @@
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 { getMission } from '@/lib/server/missions/mission-repository'
7
+ import {
8
+ cancelMission,
9
+ completeMission,
10
+ failMission,
11
+ pauseMission,
12
+ startMission,
13
+ } from '@/lib/server/missions/mission-service'
14
+
15
+ export const dynamic = 'force-dynamic'
16
+
17
+ const ControlSchema = z.object({
18
+ action: z.enum(['start', 'pause', 'resume', 'cancel', 'complete', 'fail']),
19
+ reason: z.string().max(1000).optional(),
20
+ }).strict()
21
+
22
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
23
+ const { id } = await params
24
+ const mission = getMission(id)
25
+ if (!mission) return notFound()
26
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
27
+ if (error) return error
28
+ const parsed = ControlSchema.safeParse(body)
29
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
30
+
31
+ switch (parsed.data.action) {
32
+ case 'start':
33
+ case 'resume': {
34
+ const updated = startMission(id)
35
+ return NextResponse.json(updated)
36
+ }
37
+ case 'pause': {
38
+ const updated = pauseMission(id, parsed.data.reason)
39
+ return NextResponse.json(updated)
40
+ }
41
+ case 'cancel': {
42
+ const updated = cancelMission(id, parsed.data.reason)
43
+ return NextResponse.json(updated)
44
+ }
45
+ case 'complete': {
46
+ const updated = completeMission(id, parsed.data.reason)
47
+ return NextResponse.json(updated)
48
+ }
49
+ case 'fail': {
50
+ if (!parsed.data.reason) {
51
+ return NextResponse.json({ error: 'reason is required for fail action' }, { status: 400 })
52
+ }
53
+ const updated = failMission(id, parsed.data.reason)
54
+ return NextResponse.json(updated)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { getMission, listMissionEvents } from '@/lib/server/missions/mission-repository'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
8
+ const { id } = await params
9
+ const mission = getMission(id)
10
+ if (!mission) return notFound()
11
+ const url = new URL(req.url)
12
+ const sinceRaw = url.searchParams.get('sinceAt')
13
+ const untilRaw = url.searchParams.get('untilAt')
14
+ const sinceAt = sinceRaw ? Number.parseInt(sinceRaw, 10) : undefined
15
+ const untilAt = untilRaw ? Number.parseInt(untilRaw, 10) : undefined
16
+ const events = listMissionEvents(id, {
17
+ sinceAt: Number.isFinite(sinceAt) ? sinceAt : undefined,
18
+ untilAt: Number.isFinite(untilAt) ? untilAt : undefined,
19
+ })
20
+ return NextResponse.json(events)
21
+ }
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import {
4
+ getMission,
5
+ listMissionReports,
6
+ saveMissionReport,
7
+ } from '@/lib/server/missions/mission-repository'
8
+ import { buildMissionReport } from '@/lib/server/missions/mission-report-builder'
9
+
10
+ export const dynamic = 'force-dynamic'
11
+
12
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
13
+ const { id } = await params
14
+ const mission = getMission(id)
15
+ if (!mission) return notFound()
16
+ const url = new URL(req.url)
17
+ const limitRaw = url.searchParams.get('limit')
18
+ const limit = limitRaw ? Math.min(100, Math.max(1, Number.parseInt(limitRaw, 10) || 20)) : 20
19
+ return NextResponse.json(listMissionReports(id, limit))
20
+ }
21
+
22
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
23
+ const { id } = await params
24
+ const mission = getMission(id)
25
+ if (!mission) return notFound()
26
+ const from = mission.reportSchedule?.lastReportAt
27
+ ?? mission.usage.startedAt
28
+ ?? mission.createdAt
29
+ const to = Date.now()
30
+ const { report } = buildMissionReport(mission, { from, to }, { windowSource: 'on_demand' })
31
+ saveMissionReport(report)
32
+ return NextResponse.json(report)
33
+ }
@@ -0,0 +1,82 @@
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 {
7
+ getMission,
8
+ patchMission,
9
+ removeMission,
10
+ } from '@/lib/server/missions/mission-repository'
11
+ import { patchSession } from '@/lib/server/sessions/session-repository'
12
+
13
+ export const dynamic = 'force-dynamic'
14
+
15
+ const MissionUpdateSchema = z.object({
16
+ title: z.string().min(1).max(200).optional(),
17
+ goal: z.string().min(1).max(4000).optional(),
18
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
19
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
20
+ budget: z.object({
21
+ maxUsd: z.number().positive().nullable().optional(),
22
+ maxTokens: z.number().positive().int().nullable().optional(),
23
+ maxToolCalls: z.number().positive().int().nullable().optional(),
24
+ maxWallclockSec: z.number().positive().int().nullable().optional(),
25
+ maxTurns: z.number().positive().int().nullable().optional(),
26
+ warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
27
+ }).partial().optional(),
28
+ reportSchedule: z.object({
29
+ intervalSec: z.number().int().min(30),
30
+ format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
31
+ enabled: z.boolean().default(true),
32
+ lastReportAt: z.number().nullable().optional(),
33
+ }).strict().nullable().optional(),
34
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
35
+ }).strict()
36
+
37
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
38
+ const { id } = await params
39
+ const mission = getMission(id)
40
+ if (!mission) return notFound()
41
+ return NextResponse.json(mission)
42
+ }
43
+
44
+ export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
45
+ const { id } = await params
46
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
47
+ if (error) return error
48
+ const parsed = MissionUpdateSchema.safeParse(body)
49
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
50
+ const updated = patchMission(id, (current) => {
51
+ if (!current) return null
52
+ return {
53
+ ...current,
54
+ ...(parsed.data.title != null ? { title: parsed.data.title } : {}),
55
+ ...(parsed.data.goal != null ? { goal: parsed.data.goal } : {}),
56
+ ...(parsed.data.successCriteria != null ? { successCriteria: parsed.data.successCriteria } : {}),
57
+ ...(parsed.data.agentIds != null ? { agentIds: parsed.data.agentIds } : {}),
58
+ ...(parsed.data.budget != null ? { budget: { ...current.budget, ...parsed.data.budget } } : {}),
59
+ ...(parsed.data.reportSchedule !== undefined ? { reportSchedule: parsed.data.reportSchedule } : {}),
60
+ ...(parsed.data.reportConnectorIds != null ? { reportConnectorIds: parsed.data.reportConnectorIds } : {}),
61
+ }
62
+ })
63
+ if (!updated) return notFound()
64
+ return NextResponse.json(updated)
65
+ }
66
+
67
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
68
+ const { id } = await params
69
+ const mission = getMission(id)
70
+ if (!mission) return notFound()
71
+ removeMission(id)
72
+ try {
73
+ patchSession(mission.rootSessionId, (current) => {
74
+ if (!current) return null
75
+ if (current.missionId !== id) return current
76
+ return { ...current, missionId: null }
77
+ })
78
+ } catch {
79
+ // Session may already be gone.
80
+ }
81
+ return new NextResponse('OK')
82
+ }
@@ -0,0 +1,170 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ // Disable daemon autostart during tests
5
+ process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
6
+
7
+ import { GET as listMissionsRoute, POST as createMissionRoute } from './route'
8
+ import { GET as getMissionRoute, PUT as updateMissionRoute, DELETE as deleteMissionRoute } from './[id]/route'
9
+ import { POST as controlMissionRoute } from './[id]/control/route'
10
+ import { GET as listReportsRoute, POST as forceReportRoute } from './[id]/reports/route'
11
+ import {
12
+ loadAgentMissions,
13
+ saveAgentMissions,
14
+ loadMissionReports,
15
+ saveMissionReports,
16
+ loadAgentMissionEvents,
17
+ saveAgentMissionEvents,
18
+ } from '@/lib/server/storage'
19
+
20
+ const originalMissions = loadAgentMissions()
21
+ const originalReports = loadMissionReports()
22
+ const originalEvents = loadAgentMissionEvents()
23
+
24
+ afterEach(() => {
25
+ saveAgentMissions(originalMissions)
26
+ saveMissionReports(originalReports)
27
+ saveAgentMissionEvents(originalEvents)
28
+ })
29
+
30
+ function routeParams(id: string) {
31
+ return { params: Promise.resolve({ id }) }
32
+ }
33
+
34
+ function jsonRequest(url: string, body: unknown, method = 'POST'): Request {
35
+ return new Request(url, {
36
+ method,
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ })
40
+ }
41
+
42
+ test('POST /api/missions creates a mission and GET lists it', async () => {
43
+ const req = jsonRequest('http://local/api/missions', {
44
+ title: 'Route smoke',
45
+ goal: 'Demonstrate the API',
46
+ rootSessionId: 'route_smoke_session_1',
47
+ budget: { maxTurns: 5, maxWallclockSec: 120 },
48
+ })
49
+ const createRes = await createMissionRoute(req)
50
+ assert.equal(createRes.status, 200)
51
+ const created = await createRes.json()
52
+ assert.equal(created.status, 'draft')
53
+ assert.ok(created.id)
54
+
55
+ const listRes = await listMissionsRoute()
56
+ const items = await listRes.json()
57
+ const found = items.find((m: { id: string }) => m.id === created.id)
58
+ assert.ok(found, 'created mission should appear in list')
59
+ })
60
+
61
+ test('POST /api/missions rejects a body missing rootSessionId', async () => {
62
+ const req = jsonRequest('http://local/api/missions', {
63
+ title: 'Broken',
64
+ goal: 'No session',
65
+ })
66
+ const res = await createMissionRoute(req)
67
+ assert.equal(res.status, 400)
68
+ })
69
+
70
+ test('GET /api/missions/:id returns 404 for unknown id', async () => {
71
+ const res = await getMissionRoute(
72
+ new Request('http://local/api/missions/does-not-exist'),
73
+ routeParams('does-not-exist'),
74
+ )
75
+ assert.equal(res.status, 404)
76
+ })
77
+
78
+ test('PUT /api/missions/:id patches allowed fields only', async () => {
79
+ const created = await createMissionRoute(
80
+ jsonRequest('http://local/api/missions', {
81
+ title: 'Update target',
82
+ goal: 'before',
83
+ rootSessionId: 'route_update_session',
84
+ }),
85
+ ).then((r) => r.json())
86
+
87
+ const res = await updateMissionRoute(
88
+ jsonRequest(`http://local/api/missions/${created.id}`, {
89
+ goal: 'after',
90
+ budget: { maxTurns: 99 },
91
+ }, 'PUT'),
92
+ routeParams(created.id),
93
+ )
94
+ assert.equal(res.status, 200)
95
+ const body = await res.json()
96
+ assert.equal(body.goal, 'after')
97
+ assert.equal(body.budget.maxTurns, 99)
98
+ assert.equal(body.title, 'Update target', 'unchanged fields preserved')
99
+ })
100
+
101
+ test('POST /api/missions/:id/control transitions draft to running', async () => {
102
+ const created = await createMissionRoute(
103
+ jsonRequest('http://local/api/missions', {
104
+ title: 'Control target',
105
+ goal: 'run',
106
+ rootSessionId: 'route_control_session',
107
+ }),
108
+ ).then((r) => r.json())
109
+
110
+ const res = await controlMissionRoute(
111
+ jsonRequest(`http://local/api/missions/${created.id}/control`, { action: 'start' }),
112
+ routeParams(created.id),
113
+ )
114
+ assert.equal(res.status, 200)
115
+ const body = await res.json()
116
+ assert.equal(body.status, 'running')
117
+ })
118
+
119
+ test('POST /api/missions/:id/reports force-generates a report', async () => {
120
+ const created = await createMissionRoute(
121
+ jsonRequest('http://local/api/missions', {
122
+ title: 'Report target',
123
+ goal: 'produce reports',
124
+ rootSessionId: 'route_report_session',
125
+ }),
126
+ ).then((r) => r.json())
127
+ await controlMissionRoute(
128
+ jsonRequest(`http://local/api/missions/${created.id}/control`, { action: 'start' }),
129
+ routeParams(created.id),
130
+ )
131
+
132
+ const genRes = await forceReportRoute(
133
+ new Request(`http://local/api/missions/${created.id}/reports`, { method: 'POST' }),
134
+ routeParams(created.id),
135
+ )
136
+ assert.equal(genRes.status, 200)
137
+ const report = await genRes.json()
138
+ assert.equal(report.format, 'markdown')
139
+ assert.ok(report.body.includes('Report target'))
140
+
141
+ const listRes = await listReportsRoute(
142
+ new Request(`http://local/api/missions/${created.id}/reports`),
143
+ routeParams(created.id),
144
+ )
145
+ assert.equal(listRes.status, 200)
146
+ const reports = await listRes.json()
147
+ assert.ok(reports.find((r: { id: string }) => r.id === report.id))
148
+ })
149
+
150
+ test('DELETE /api/missions/:id removes the mission', async () => {
151
+ const created = await createMissionRoute(
152
+ jsonRequest('http://local/api/missions', {
153
+ title: 'Delete target',
154
+ goal: 'bye',
155
+ rootSessionId: 'route_delete_session',
156
+ }),
157
+ ).then((r) => r.json())
158
+
159
+ const res = await deleteMissionRoute(
160
+ new Request(`http://local/api/missions/${created.id}`, { method: 'DELETE' }),
161
+ routeParams(created.id),
162
+ )
163
+ assert.equal(res.status, 200)
164
+
165
+ const getRes = await getMissionRoute(
166
+ new Request(`http://local/api/missions/${created.id}`),
167
+ routeParams(created.id),
168
+ )
169
+ assert.equal(getRes.status, 404)
170
+ })
@@ -0,0 +1,58 @@
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 { listMissions } from '@/lib/server/missions/mission-repository'
6
+ import { createMission } 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 MissionBudgetSchema = 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
+ }).strict()
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 MissionCreateSchema = z.object({
28
+ title: z.string().min(1, 'Title is required').max(200),
29
+ goal: z.string().min(1, 'Goal is required').max(4000),
30
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
31
+ rootSessionId: z.string().min(1, 'rootSessionId is required'),
32
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
33
+ budget: MissionBudgetSchema.optional(),
34
+ reportSchedule: ReportScheduleSchema.nullable().optional(),
35
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
36
+ })
37
+
38
+ export async function GET() {
39
+ return NextResponse.json(listMissions())
40
+ }
41
+
42
+ export async function POST(req: Request) {
43
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
44
+ if (error) return error
45
+ const parsed = MissionCreateSchema.safeParse(body)
46
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
47
+ const mission = createMission(parsed.data)
48
+ // Wire the session back-reference so enqueue.ts budget hook picks it up fast.
49
+ try {
50
+ patchSession(mission.rootSessionId, (current) => {
51
+ if (!current) return null
52
+ return { ...current, missionId: mission.id }
53
+ })
54
+ } catch {
55
+ // Session may not exist yet, budget hook falls back to the service map.
56
+ }
57
+ return NextResponse.json(mission)
58
+ }