@swarmclawai/swarmclaw 1.5.53 → 1.5.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -3
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +14 -2
  4. package/src/app/api/agents/agents-route.test.ts +65 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
  6. package/src/app/api/chatrooms/route.ts +3 -0
  7. package/src/app/api/missions/[id]/control/route.ts +21 -0
  8. package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
  9. package/src/app/api/missions/templates/route.ts +8 -0
  10. package/src/app/api/tasks/[id]/route.ts +11 -1
  11. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  12. package/src/app/api/webhooks/[id]/route.ts +18 -15
  13. package/src/app/missions/page.tsx +135 -22
  14. package/src/cli/index.js +2 -0
  15. package/src/cli/spec.js +2 -0
  16. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  17. package/src/components/missions/mission-template-gallery.tsx +113 -0
  18. package/src/components/missions/mission-template-install-dialog.tsx +283 -0
  19. package/src/lib/server/agents/agent-service.ts +10 -2
  20. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  21. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  22. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  23. package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
  24. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  25. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  26. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  27. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  28. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  29. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  30. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  31. package/src/lib/server/chats/chat-session-service.ts +11 -0
  32. package/src/lib/server/connectors/email.test.ts +64 -0
  33. package/src/lib/server/connectors/email.ts +35 -6
  34. package/src/lib/server/connectors/response-media.ts +1 -0
  35. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  36. package/src/lib/server/memory/memory-db.test.ts +8 -0
  37. package/src/lib/server/memory/memory-db.ts +1 -1
  38. package/src/lib/server/missions/mission-service.ts +47 -1
  39. package/src/lib/server/missions/mission-templates.test.ts +208 -0
  40. package/src/lib/server/missions/mission-templates.ts +186 -0
  41. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  42. package/src/lib/server/storage-normalization.ts +6 -0
  43. package/src/lib/server/storage.ts +1 -1
  44. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  45. package/src/lib/server/tasks/task-validation.ts +21 -2
  46. package/src/lib/server/working-state/normalization.ts +5 -1
  47. package/src/lib/validation/schemas.ts +40 -0
  48. package/src/types/mission.ts +27 -0
@@ -1,7 +1,84 @@
1
+ import { z } from 'zod'
1
2
  import type { Chatroom, Agent } from '@/types'
2
3
  import { patchChatroom } from '@/lib/server/chatrooms/chatroom-repository'
3
4
  import { notify } from '@/lib/server/ws-hub'
4
5
 
6
+ const REACTION_MARKER = '[REACTION]'
7
+
8
+ const ReactionPayloadSchema = z.object({
9
+ emoji: z.string().min(1),
10
+ to: z.string().min(1),
11
+ }).passthrough()
12
+ type ReactionPayload = z.infer<typeof ReactionPayloadSchema>
13
+
14
+ interface ReactionMatch {
15
+ start: number
16
+ end: number
17
+ payload: ReactionPayload
18
+ }
19
+
20
+ function findBalancedJsonObjectEnd(text: string, start: number): number {
21
+ if (text.charAt(start) !== '{') return -1
22
+ let depth = 0
23
+ let inString = false
24
+ let escaped = false
25
+ for (let i = start; i < text.length; i += 1) {
26
+ const c = text.charAt(i)
27
+ if (inString) {
28
+ if (escaped) escaped = false
29
+ else if (c === '\\') escaped = true
30
+ else if (c === '"') inString = false
31
+ continue
32
+ }
33
+ if (c === '"') {
34
+ inString = true
35
+ continue
36
+ }
37
+ if (c === '{') depth += 1
38
+ else if (c === '}') {
39
+ depth -= 1
40
+ if (depth === 0) return i + 1
41
+ }
42
+ }
43
+ return -1
44
+ }
45
+
46
+ function findReactionMatches(text: string): ReactionMatch[] {
47
+ const matches: ReactionMatch[] = []
48
+ if (!text) return matches
49
+ let cursor = 0
50
+ while (cursor < text.length) {
51
+ const markerAt = text.indexOf(REACTION_MARKER, cursor)
52
+ if (markerAt < 0) break
53
+ let jsonStart = markerAt + REACTION_MARKER.length
54
+ while (jsonStart < text.length && /\s/.test(text.charAt(jsonStart))) jsonStart += 1
55
+ if (text.charAt(jsonStart) !== '{') {
56
+ cursor = markerAt + REACTION_MARKER.length
57
+ continue
58
+ }
59
+ const jsonEnd = findBalancedJsonObjectEnd(text, jsonStart)
60
+ if (jsonEnd <= jsonStart) {
61
+ cursor = markerAt + REACTION_MARKER.length
62
+ continue
63
+ }
64
+ let parsed: unknown
65
+ try {
66
+ parsed = JSON.parse(text.slice(jsonStart, jsonEnd))
67
+ } catch {
68
+ cursor = jsonStart + 1
69
+ continue
70
+ }
71
+ const validated = ReactionPayloadSchema.safeParse(parsed)
72
+ if (!validated.success) {
73
+ cursor = jsonEnd
74
+ continue
75
+ }
76
+ matches.push({ start: markerAt, end: jsonEnd, payload: validated.data })
77
+ cursor = jsonEnd
78
+ }
79
+ return matches
80
+ }
81
+
5
82
  /**
6
83
  * Normalizes text for comparison (lowercase, alphanumeric only)
7
84
  */
@@ -67,16 +144,35 @@ export function addAgentReaction(chatroomId: string, messageId: string, agentId:
67
144
  /**
68
145
  * Parses [REACTION] tokens from agent output and applies them.
69
146
  * Format: [REACTION]{"emoji": "👍", "to": "msg_id"}
147
+ *
148
+ * Uses a balanced-brace walker + zod validation so nested JSON or noisy
149
+ * payloads don't slip past, and so unrelated `[REACTION]something` text
150
+ * doesn't get spuriously consumed.
70
151
  */
71
152
  export function applyAgentReactionsFromText(text: string, chatroomId: string, agentId: string) {
72
- const reactionRegex = /\[REACTION\]\s*(\{.*?\})/g
73
- let match
74
- while ((match = reactionRegex.exec(text)) !== null) {
75
- try {
76
- const data = JSON.parse(match[1])
77
- if (data.emoji && data.to) {
78
- addAgentReaction(chatroomId, data.to, agentId, data.emoji)
79
- }
80
- } catch { /* ignore invalid JSON */ }
153
+ for (const match of findReactionMatches(text)) {
154
+ addAgentReaction(chatroomId, match.payload.to, agentId, match.payload.emoji)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Removes [REACTION]{...} markers from agent output so they don't bleed into
160
+ * the visible message body. Reactions are persisted separately by
161
+ * applyAgentReactionsFromText.
162
+ */
163
+ export function stripAgentReactionTokens(text: string): string {
164
+ if (!text) return text
165
+ const matches = findReactionMatches(text)
166
+ if (matches.length === 0) return text
167
+ let out = ''
168
+ let cursor = 0
169
+ for (const match of matches) {
170
+ out += text.slice(cursor, match.start)
171
+ cursor = match.end
81
172
  }
173
+ out += text.slice(cursor)
174
+ return out
175
+ .replace(/[ \t]+\n/g, '\n')
176
+ .replace(/\n{3,}/g, '\n\n')
177
+ .trim()
82
178
  }
@@ -7,6 +7,7 @@ import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/
7
7
  import { loadAgent } from '@/lib/server/agents/agent-repository'
8
8
  import { clearMainLoopStateForSession } from '@/lib/server/agents/main-agent-loop'
9
9
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
10
+ import { loadCredentials } from '@/lib/server/credentials/credential-repository'
10
11
  import { cleanupSessionProcesses } from '@/lib/server/runtime/process-manager'
11
12
  import { stopActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
12
13
  import {
@@ -235,6 +236,13 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
235
236
  if (updates.credentialId !== undefined) session.credentialId = updates.credentialId
236
237
  else if (agentIdUpdateProvided && linkedRoute) session.credentialId = linkedRoute.credentialId ?? null
237
238
  else if (agentIdUpdateProvided && linkedAgent) session.credentialId = linkedAgent.credentialId ?? null
239
+ else if (updates.provider !== undefined && updates.provider !== session.provider) {
240
+ // Provider changed without an explicit credentialId — find a stored credential
241
+ // for the new provider so API-key-based providers (Groq, OpenAI, …) work.
242
+ const allCreds = loadCredentials()
243
+ const providerCred = Object.values(allCreds).find(c => c.provider === updates.provider)
244
+ session.credentialId = providerCred?.id ?? null
245
+ }
238
246
  if (updates.fallbackCredentialIds !== undefined) session.fallbackCredentialIds = updates.fallbackCredentialIds
239
247
  else if (agentIdUpdateProvided && linkedRoute) session.fallbackCredentialIds = [...linkedRoute.fallbackCredentialIds]
240
248
  if (updates.gatewayProfileId !== undefined) session.gatewayProfileId = updates.gatewayProfileId
@@ -264,6 +272,9 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
264
272
  session.apiEndpoint = linkedRoute.apiEndpoint ?? null
265
273
  } else if (agentIdUpdateProvided && linkedAgent) {
266
274
  session.apiEndpoint = normalizeProviderEndpoint(linkedAgent.provider, linkedAgent.apiEndpoint ?? null)
275
+ } else if (updates.provider !== undefined && updates.provider !== session.provider) {
276
+ // Provider changed — clear stale endpoint so the new provider uses its own default.
277
+ session.apiEndpoint = null
267
278
  }
268
279
  if (updates.heartbeatEnabled !== undefined) session.heartbeatEnabled = updates.heartbeatEnabled
269
280
  if (updates.heartbeatIntervalSec !== undefined) session.heartbeatIntervalSec = updates.heartbeatIntervalSec
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { describe, it } from 'node:test'
6
+ import { buildAttachments } from './email'
7
+ import { connectorSupportsBinaryMedia } from './response-media'
8
+
9
+ describe('connectorSupportsBinaryMedia — email', () => {
10
+ it('marks email as supporting outbound binary media', () => {
11
+ assert.equal(connectorSupportsBinaryMedia('email'), true)
12
+ })
13
+
14
+ it('still returns false for platforms that do not support outbound binary', () => {
15
+ assert.equal(connectorSupportsBinaryMedia('signal'), false)
16
+ assert.equal(connectorSupportsBinaryMedia('matrix'), false)
17
+ })
18
+ })
19
+
20
+ describe('email buildAttachments', () => {
21
+ it('returns an empty array when no mediaPath is set', () => {
22
+ assert.deepEqual(buildAttachments(), [])
23
+ assert.deepEqual(buildAttachments({}), [])
24
+ })
25
+
26
+ it('returns an empty array when mediaPath points at a missing file', () => {
27
+ const missing = path.join(os.tmpdir(), `swarmclaw-email-missing-${Date.now()}.bin`)
28
+ assert.equal(fs.existsSync(missing), false)
29
+ assert.deepEqual(buildAttachments({ mediaPath: missing }), [])
30
+ })
31
+
32
+ it('builds a single attachment entry from mediaPath, defaulting the filename to basename', () => {
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-email-test-'))
34
+ const file = path.join(dir, 'report.pdf')
35
+ fs.writeFileSync(file, '%PDF-1.4 test')
36
+ try {
37
+ const attachments = buildAttachments({ mediaPath: file })
38
+ assert.equal(attachments.length, 1)
39
+ assert.equal(attachments[0].path, file)
40
+ assert.equal(attachments[0].filename, 'report.pdf')
41
+ assert.equal(attachments[0].contentType, undefined)
42
+ } finally {
43
+ fs.rmSync(dir, { recursive: true, force: true })
44
+ }
45
+ })
46
+
47
+ it('respects explicit fileName and mimeType when provided', () => {
48
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-email-test-'))
49
+ const file = path.join(dir, 'raw.bin')
50
+ fs.writeFileSync(file, 'x')
51
+ try {
52
+ const attachments = buildAttachments({
53
+ mediaPath: file,
54
+ fileName: 'quarterly-report.pdf',
55
+ mimeType: 'application/pdf',
56
+ })
57
+ assert.equal(attachments.length, 1)
58
+ assert.equal(attachments[0].filename, 'quarterly-report.pdf')
59
+ assert.equal(attachments[0].contentType, 'application/pdf')
60
+ } finally {
61
+ fs.rmSync(dir, { recursive: true, force: true })
62
+ }
63
+ })
64
+ })
@@ -1,8 +1,10 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
1
3
  import { ImapFlow } from 'imapflow'
2
4
  import { createTransport, type Transporter } from 'nodemailer'
3
5
  import { simpleParser } from 'mailparser'
4
6
  import type { Connector } from '@/types'
5
- import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
7
+ import type { PlatformConnector, ConnectorInstance, InboundMessage, OutboundSendOptions } from './types'
6
8
  import { resolveConnectorIngressReply } from './ingress-delivery'
7
9
  import { errorMessage } from '@/lib/shared-utils'
8
10
  import { log } from '@/lib/server/logger'
@@ -21,6 +23,26 @@ interface EmailConfig {
21
23
  subjectPrefix?: string
22
24
  }
23
25
 
26
+ interface MailAttachment {
27
+ path: string
28
+ filename: string
29
+ contentType?: string
30
+ }
31
+
32
+ export function buildAttachments(options?: OutboundSendOptions): MailAttachment[] {
33
+ const source = options?.mediaPath
34
+ if (!source) return []
35
+ if (!fs.existsSync(source)) {
36
+ log.warn(TAG, `Attachment file not found: ${source}`)
37
+ return []
38
+ }
39
+ return [{
40
+ path: source,
41
+ filename: options?.fileName || path.basename(source),
42
+ ...(options?.mimeType ? { contentType: options.mimeType } : {}),
43
+ }]
44
+ }
45
+
24
46
  function getConfig(connector: Connector): EmailConfig {
25
47
  const c = connector.config as Record<string, unknown>
26
48
  return {
@@ -206,7 +228,11 @@ const email: PlatformConnector = {
206
228
  }
207
229
  }
208
230
 
209
- async function sendReply(channelId: string, text: string): Promise<void> {
231
+ async function sendReply(
232
+ channelId: string,
233
+ text: string,
234
+ options?: OutboundSendOptions,
235
+ ): Promise<void> {
210
236
  const sender = senderMap.get(channelId)
211
237
  const to = sender?.address || channelId
212
238
  const subject = sender?.subject ? `Re: ${sender.subject.replace(/^Re:\s*/i, '')}` : 'Re: SwarmClaw'
@@ -215,7 +241,7 @@ const email: PlatformConnector = {
215
241
  from: config.user,
216
242
  to,
217
243
  subject,
218
- text,
244
+ text: options?.caption || text,
219
245
  }
220
246
 
221
247
  // Thread the reply using In-Reply-To header
@@ -224,8 +250,11 @@ const email: PlatformConnector = {
224
250
  mailOptions['references'] = sender.messageId
225
251
  }
226
252
 
253
+ const attachments = buildAttachments(options)
254
+ if (attachments.length > 0) mailOptions['attachments'] = attachments
255
+
227
256
  await smtp.sendMail(mailOptions)
228
- log.info(TAG, `Reply sent to ${to}`)
257
+ log.info(TAG, `Reply sent to ${to}${attachments.length ? ` with ${attachments.length} attachment(s)` : ''}`)
229
258
  }
230
259
 
231
260
  // Connect and start polling
@@ -247,8 +276,8 @@ const email: PlatformConnector = {
247
276
  return connected && imap.usable
248
277
  },
249
278
 
250
- async sendMessage(channelId, text) {
251
- await sendReply(channelId, text)
279
+ async sendMessage(channelId, text, options) {
280
+ await sendReply(channelId, text, options)
252
281
  },
253
282
 
254
283
  async stop() {
@@ -226,6 +226,7 @@ export function connectorSupportsBinaryMedia(platform: string): boolean {
226
226
  || platform === 'slack'
227
227
  || platform === 'discord'
228
228
  || platform === 'openclaw'
229
+ || platform === 'email'
229
230
  }
230
231
 
231
232
  export function formatMediaLine(media: InboundMedia): string {
@@ -319,33 +319,45 @@ async function main(): Promise<void> {
319
319
  })
320
320
  }
321
321
  process.on('uncaughtException', (err: Error) => {
322
- patchDaemonStatusRecord((current) => ({
323
- ...current,
324
- lastError: err.message,
325
- updatedAt: Date.now(),
326
- }))
322
+ try {
323
+ patchDaemonStatusRecord((current) => ({
324
+ ...current,
325
+ lastError: err.message,
326
+ updatedAt: Date.now(),
327
+ }))
328
+ } catch (patchErr) {
329
+ log.error(TAG, 'Failed to record uncaughtException in daemon status', patchErr)
330
+ }
327
331
  void shutdown('uncaughtException').finally(() => process.exit(1))
328
332
  })
329
333
  process.on('unhandledRejection', (reason: unknown) => {
330
- patchDaemonStatusRecord((current) => ({
331
- ...current,
332
- lastError: reason instanceof Error ? reason.message : String(reason),
333
- updatedAt: Date.now(),
334
- }))
334
+ try {
335
+ patchDaemonStatusRecord((current) => ({
336
+ ...current,
337
+ lastError: reason instanceof Error ? reason.message : String(reason),
338
+ updatedAt: Date.now(),
339
+ }))
340
+ } catch (patchErr) {
341
+ log.error(TAG, 'Failed to record unhandledRejection in daemon status', patchErr)
342
+ }
335
343
  void shutdown('unhandledRejection').finally(() => process.exit(1))
336
344
  })
337
345
  }
338
346
 
339
347
  void main().catch((err: unknown) => {
340
- patchDaemonStatusRecord((current) => ({
341
- ...current,
342
- pid: null,
343
- adminPort: null,
344
- desiredState: 'stopped',
345
- stoppedAt: Date.now(),
346
- updatedAt: Date.now(),
347
- lastError: err instanceof Error ? err.message : 'Daemon runtime failed to start',
348
- }))
348
+ try {
349
+ patchDaemonStatusRecord((current) => ({
350
+ ...current,
351
+ pid: null,
352
+ adminPort: null,
353
+ desiredState: 'stopped',
354
+ stoppedAt: Date.now(),
355
+ updatedAt: Date.now(),
356
+ lastError: err instanceof Error ? err.message : 'Daemon runtime failed to start',
357
+ }))
358
+ } catch (patchErr) {
359
+ log.error(TAG, 'Failed to record fatal daemon error in status', patchErr)
360
+ }
349
361
  clearDaemonAdminMetadata()
350
362
  log.error(TAG, 'Fatal daemon runtime error', err)
351
363
  process.exit(1)
@@ -214,6 +214,14 @@ describe('memory-db', () => {
214
214
  // All terms are <3 chars or stop words
215
215
  assert.equal(query, '')
216
216
  })
217
+
218
+ it('returns a single-term FTS query for short (3-4 char) words', () => {
219
+ // Single words like "cats", "blue", "dog" must produce a non-empty FTS
220
+ // query so the memory lookup UI works for short meaningful terms.
221
+ assert.equal(memDb.buildFtsQuery('cats'), '"cats"')
222
+ assert.equal(memDb.buildFtsQuery('blue'), '"blue"')
223
+ assert.equal(memDb.buildFtsQuery('dog'), '"dog"')
224
+ })
217
225
  })
218
226
 
219
227
  // --- Content hash dedup ---
@@ -388,7 +388,7 @@ export function buildFtsQuery(input: string): string {
388
388
  }
389
389
 
390
390
  if (unique.length === 1) {
391
- return unique[0].length >= 5 ? `"${unique[0].replace(/"/g, '')}"` : ''
391
+ return `"${unique[0].replace(/"/g, '')}"`
392
392
  }
393
393
 
394
394
  const selected = unique.slice(0, Math.min(4, MAX_FTS_QUERY_TERMS))
@@ -1,5 +1,5 @@
1
1
  import crypto from 'crypto'
2
- import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
2
+ import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus, MissionTemplate } from '@/types'
3
3
  import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
4
4
  import { hmrSingleton } from '@/lib/shared-utils'
5
5
  import { log } from '@/lib/server/logger'
@@ -11,6 +11,7 @@ import {
11
11
  patchMission,
12
12
  upsertMission,
13
13
  } from './mission-repository'
14
+ import { getMissionTemplate } from './mission-templates'
14
15
 
15
16
  const TAG = 'mission-service'
16
17
 
@@ -59,6 +60,7 @@ export interface CreateMissionInput {
59
60
  budget?: Partial<MissionBudget>
60
61
  reportSchedule?: MissionReportSchedule | null
61
62
  reportConnectorIds?: string[]
63
+ templateId?: string | null
62
64
  }
63
65
 
64
66
  function newMissionId(): string {
@@ -82,6 +84,9 @@ function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
82
84
 
83
85
  export function createMission(input: CreateMissionInput): Mission {
84
86
  const now = Date.now()
87
+ const templateId = typeof input.templateId === 'string' && input.templateId.trim()
88
+ ? input.templateId.trim().slice(0, 64)
89
+ : null
85
90
  const mission: Mission = {
86
91
  id: newMissionId(),
87
92
  title: input.title.trim(),
@@ -106,12 +111,53 @@ export function createMission(input: CreateMissionInput): Mission {
106
111
  reportConnectorIds: input.reportConnectorIds ?? [],
107
112
  createdAt: now,
108
113
  updatedAt: now,
114
+ templateId,
109
115
  }
110
116
  upsertMission(mission)
111
117
  log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
112
118
  return mission
113
119
  }
114
120
 
121
+ export interface CreateMissionFromTemplateInput {
122
+ templateId: string
123
+ rootSessionId: string
124
+ overrides?: {
125
+ title?: string
126
+ goal?: string
127
+ successCriteria?: string[]
128
+ budget?: Partial<MissionBudget>
129
+ reportSchedule?: MissionReportSchedule | null
130
+ agentIds?: string[]
131
+ reportConnectorIds?: string[]
132
+ }
133
+ }
134
+
135
+ export interface CreateMissionFromTemplateResult {
136
+ mission: Mission
137
+ template: MissionTemplate
138
+ }
139
+
140
+ export function createMissionFromTemplate(input: CreateMissionFromTemplateInput): CreateMissionFromTemplateResult | null {
141
+ const template = getMissionTemplate(input.templateId)
142
+ if (!template) return null
143
+ const overrides = input.overrides ?? {}
144
+ const mergedBudget: Partial<MissionBudget> = { ...template.defaults.budget, ...(overrides.budget ?? {}) }
145
+ const mission = createMission({
146
+ title: overrides.title?.trim() || template.defaults.title,
147
+ goal: overrides.goal?.trim() || template.defaults.goal,
148
+ successCriteria: overrides.successCriteria ?? template.defaults.successCriteria,
149
+ rootSessionId: input.rootSessionId,
150
+ agentIds: overrides.agentIds ?? [],
151
+ budget: mergedBudget,
152
+ reportSchedule: overrides.reportSchedule === undefined
153
+ ? template.defaults.reportSchedule
154
+ : overrides.reportSchedule,
155
+ reportConnectorIds: overrides.reportConnectorIds ?? [],
156
+ templateId: template.id,
157
+ })
158
+ return { mission, template }
159
+ }
160
+
115
161
  function applyStatusTransition(
116
162
  id: string,
117
163
  next: MissionStatus,