@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
@@ -0,0 +1,299 @@
1
+ import crypto from 'crypto'
2
+ import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
3
+ import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
4
+ import { hmrSingleton } from '@/lib/shared-utils'
5
+ import { log } from '@/lib/server/logger'
6
+ import {
7
+ appendMissionEvent,
8
+ appendMissionMilestone,
9
+ getMission,
10
+ listMissions,
11
+ patchMission,
12
+ upsertMission,
13
+ } from './mission-repository'
14
+
15
+ const TAG = 'mission-service'
16
+
17
+ interface MissionRuntimeCache {
18
+ sessionToMission: Map<string, string>
19
+ hydratedAt: number
20
+ }
21
+
22
+ const runtime = hmrSingleton<MissionRuntimeCache>('mission_runtime_state', () => ({
23
+ sessionToMission: new Map(),
24
+ hydratedAt: 0,
25
+ }))
26
+
27
+ function hydrateSessionMap(force = false): void {
28
+ const now = Date.now()
29
+ if (!force && now - runtime.hydratedAt < 30_000) return
30
+ const missions = listMissions()
31
+ const next = new Map<string, string>()
32
+ for (const m of missions) {
33
+ if (m.status === 'running' || m.status === 'paused') {
34
+ if (m.rootSessionId) next.set(m.rootSessionId, m.id)
35
+ }
36
+ }
37
+ runtime.sessionToMission = next
38
+ runtime.hydratedAt = now
39
+ }
40
+
41
+ export function getMissionIdForSession(sessionId: string): string | null {
42
+ if (!sessionId) return null
43
+ hydrateSessionMap(false)
44
+ return runtime.sessionToMission.get(sessionId) ?? null
45
+ }
46
+
47
+ export function trackSessionMissionMapping(sessionId: string, missionId: string | null): void {
48
+ if (!sessionId) return
49
+ if (missionId) runtime.sessionToMission.set(sessionId, missionId)
50
+ else runtime.sessionToMission.delete(sessionId)
51
+ }
52
+
53
+ export interface CreateMissionInput {
54
+ title: string
55
+ goal: string
56
+ successCriteria?: string[]
57
+ rootSessionId: string
58
+ agentIds?: string[]
59
+ budget?: Partial<MissionBudget>
60
+ reportSchedule?: MissionReportSchedule | null
61
+ reportConnectorIds?: string[]
62
+ }
63
+
64
+ function newMissionId(): string {
65
+ return `mi_${crypto.randomBytes(8).toString('hex')}`
66
+ }
67
+
68
+ function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
69
+ const pick = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
70
+ const warn = Array.isArray(input.warnAtFractions) && input.warnAtFractions.length
71
+ ? input.warnAtFractions.filter((f) => typeof f === 'number' && f > 0 && f < 1)
72
+ : DEFAULT_MISSION_WARN_FRACTIONS
73
+ return {
74
+ maxUsd: pick(input.maxUsd),
75
+ maxTokens: pick(input.maxTokens),
76
+ maxToolCalls: pick(input.maxToolCalls),
77
+ maxWallclockSec: pick(input.maxWallclockSec),
78
+ maxTurns: pick(input.maxTurns),
79
+ warnAtFractions: warn.length ? warn : DEFAULT_MISSION_WARN_FRACTIONS,
80
+ }
81
+ }
82
+
83
+ export function createMission(input: CreateMissionInput): Mission {
84
+ const now = Date.now()
85
+ const mission: Mission = {
86
+ id: newMissionId(),
87
+ title: input.title.trim(),
88
+ goal: input.goal.trim(),
89
+ successCriteria: (input.successCriteria ?? []).map((s) => s.trim()).filter(Boolean),
90
+ rootSessionId: input.rootSessionId,
91
+ agentIds: input.agentIds ?? [],
92
+ status: 'draft',
93
+ budget: sanitizeBudget(input.budget),
94
+ usage: {
95
+ usdSpent: 0,
96
+ tokensUsed: 0,
97
+ toolCallsUsed: 0,
98
+ turnsRun: 0,
99
+ wallclockMsElapsed: 0,
100
+ startedAt: null,
101
+ lastUpdatedAt: now,
102
+ warnFractionsHit: [],
103
+ },
104
+ milestones: [],
105
+ reportSchedule: input.reportSchedule ?? null,
106
+ reportConnectorIds: input.reportConnectorIds ?? [],
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ }
110
+ upsertMission(mission)
111
+ log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
112
+ return mission
113
+ }
114
+
115
+ function applyStatusTransition(
116
+ id: string,
117
+ next: MissionStatus,
118
+ opts: { reason?: string; milestoneSummary?: string; endReason?: string } = {},
119
+ ): Mission | null {
120
+ const updated = patchMission(id, (current) => {
121
+ if (!current) return null
122
+ const patch: Mission = { ...current, status: next }
123
+ if (next === 'running' && !current.usage.startedAt) {
124
+ patch.usage = { ...current.usage, startedAt: Date.now(), lastUpdatedAt: Date.now() }
125
+ patch.startedAt = patch.startedAt ?? Date.now()
126
+ }
127
+ if (next === 'completed' || next === 'failed' || next === 'cancelled' || next === 'budget_exhausted') {
128
+ patch.endedAt = Date.now()
129
+ patch.endReason = opts.endReason ?? opts.reason ?? null
130
+ }
131
+ return patch
132
+ })
133
+ if (!updated) return null
134
+ if (opts.milestoneSummary) {
135
+ appendMissionMilestone(id, {
136
+ kind: mapStatusToMilestoneKind(next),
137
+ summary: opts.milestoneSummary,
138
+ })
139
+ }
140
+ trackSessionMissionMapping(
141
+ updated.rootSessionId,
142
+ next === 'running' || next === 'paused' ? id : null,
143
+ )
144
+ return getMission(id)
145
+ }
146
+
147
+ function mapStatusToMilestoneKind(
148
+ status: MissionStatus,
149
+ ): 'started' | 'paused' | 'resumed' | 'completed' | 'failed' | 'cancelled' | 'budget_hit' {
150
+ switch (status) {
151
+ case 'running': return 'started'
152
+ case 'paused': return 'paused'
153
+ case 'completed': return 'completed'
154
+ case 'failed': return 'failed'
155
+ case 'cancelled': return 'cancelled'
156
+ case 'budget_exhausted': return 'budget_hit'
157
+ default: return 'started'
158
+ }
159
+ }
160
+
161
+ export function startMission(id: string): Mission | null {
162
+ const current = getMission(id)
163
+ if (!current) return null
164
+ if (current.status === 'running') return current
165
+ const next = current.status === 'paused' ? 'running' : 'running'
166
+ const summary = current.status === 'paused' ? 'Mission resumed' : 'Mission started'
167
+ const updated = applyStatusTransition(id, next, { milestoneSummary: summary })
168
+ if (updated && current.status === 'paused') {
169
+ appendMissionMilestone(id, { kind: 'resumed', summary: 'Mission resumed' })
170
+ }
171
+ return updated
172
+ }
173
+
174
+ export function pauseMission(id: string, reason?: string): Mission | null {
175
+ const current = getMission(id)
176
+ if (!current || current.status !== 'running') return current
177
+ return applyStatusTransition(id, 'paused', {
178
+ reason,
179
+ milestoneSummary: reason ?? 'Mission paused',
180
+ })
181
+ }
182
+
183
+ export function cancelMission(id: string, reason?: string): Mission | null {
184
+ const current = getMission(id)
185
+ if (!current) return null
186
+ if (current.status === 'completed' || current.status === 'cancelled') return current
187
+ return applyStatusTransition(id, 'cancelled', {
188
+ endReason: reason,
189
+ milestoneSummary: reason ?? 'Mission cancelled',
190
+ })
191
+ }
192
+
193
+ export function completeMission(id: string, summary?: string): Mission | null {
194
+ const current = getMission(id)
195
+ if (!current) return null
196
+ if (current.status === 'completed') return current
197
+ return applyStatusTransition(id, 'completed', {
198
+ endReason: summary,
199
+ milestoneSummary: summary ?? 'Mission complete',
200
+ })
201
+ }
202
+
203
+ export function failMission(id: string, reason: string): Mission | null {
204
+ const current = getMission(id)
205
+ if (!current) return null
206
+ if (current.status === 'failed') return current
207
+ return applyStatusTransition(id, 'failed', {
208
+ endReason: reason,
209
+ milestoneSummary: reason,
210
+ })
211
+ }
212
+
213
+ export function markBudgetExhausted(id: string, reason: string): Mission | null {
214
+ const current = getMission(id)
215
+ if (!current) return null
216
+ if (current.status === 'budget_exhausted' || current.status === 'completed') return current
217
+ appendMissionEvent(id, 'budget_exhausted', { reason })
218
+ return applyStatusTransition(id, 'budget_exhausted', {
219
+ endReason: reason,
220
+ milestoneSummary: reason,
221
+ })
222
+ }
223
+
224
+ export interface BudgetVerdict {
225
+ allow: boolean
226
+ reason?: string
227
+ hitCap?: 'usd' | 'tokens' | 'toolCalls' | 'wallclock' | 'turns'
228
+ warningFraction?: number
229
+ }
230
+
231
+ export function evaluateMissionBudget(mission: Mission, at: number = Date.now()): BudgetVerdict {
232
+ const { budget, usage } = mission
233
+ if (budget.maxUsd != null && usage.usdSpent >= budget.maxUsd) {
234
+ return { allow: false, hitCap: 'usd', reason: `USD budget exhausted (${usage.usdSpent.toFixed(4)} >= ${budget.maxUsd})` }
235
+ }
236
+ if (budget.maxTokens != null && usage.tokensUsed >= budget.maxTokens) {
237
+ return { allow: false, hitCap: 'tokens', reason: `Token budget exhausted (${usage.tokensUsed} >= ${budget.maxTokens})` }
238
+ }
239
+ if (budget.maxToolCalls != null && usage.toolCallsUsed >= budget.maxToolCalls) {
240
+ return { allow: false, hitCap: 'toolCalls', reason: `Tool-call budget exhausted (${usage.toolCallsUsed} >= ${budget.maxToolCalls})` }
241
+ }
242
+ if (budget.maxTurns != null && usage.turnsRun >= budget.maxTurns) {
243
+ return { allow: false, hitCap: 'turns', reason: `Max turns reached (${usage.turnsRun} >= ${budget.maxTurns})` }
244
+ }
245
+ if (budget.maxWallclockSec != null && usage.startedAt != null) {
246
+ const elapsedSec = (at - usage.startedAt) / 1000
247
+ if (elapsedSec >= budget.maxWallclockSec) {
248
+ return { allow: false, hitCap: 'wallclock', reason: `Wallclock budget exhausted (${Math.round(elapsedSec)}s >= ${budget.maxWallclockSec}s)` }
249
+ }
250
+ }
251
+ // Warn thresholds, highest unfired fraction first
252
+ const unfired = (budget.warnAtFractions ?? [])
253
+ .filter((f) => !usage.warnFractionsHit.includes(f))
254
+ .sort((a, b) => b - a)
255
+ for (const fraction of unfired) {
256
+ const tripped = (
257
+ (budget.maxUsd != null && usage.usdSpent >= budget.maxUsd * fraction)
258
+ || (budget.maxTokens != null && usage.tokensUsed >= budget.maxTokens * fraction)
259
+ || (budget.maxTurns != null && usage.turnsRun >= budget.maxTurns * fraction)
260
+ || (
261
+ budget.maxWallclockSec != null && usage.startedAt != null
262
+ && (at - usage.startedAt) / 1000 >= budget.maxWallclockSec * fraction
263
+ )
264
+ )
265
+ if (tripped) return { allow: true, warningFraction: fraction }
266
+ }
267
+ return { allow: true }
268
+ }
269
+
270
+ export function recordTurnUsage(
271
+ missionId: string,
272
+ delta: { usdDelta?: number; tokensDelta?: number; toolCallsDelta?: number; turnsDelta?: number },
273
+ ): Mission | null {
274
+ let firedWarn: number | null = null
275
+ const result = patchMission(missionId, (current) => {
276
+ if (!current) return null
277
+ const now = Date.now()
278
+ const nextUsage = { ...current.usage }
279
+ if (typeof delta.usdDelta === 'number' && Number.isFinite(delta.usdDelta)) nextUsage.usdSpent += delta.usdDelta
280
+ if (typeof delta.tokensDelta === 'number' && Number.isFinite(delta.tokensDelta)) nextUsage.tokensUsed += delta.tokensDelta
281
+ if (typeof delta.toolCallsDelta === 'number' && Number.isFinite(delta.toolCallsDelta)) nextUsage.toolCallsUsed += delta.toolCallsDelta
282
+ if (typeof delta.turnsDelta === 'number' && Number.isFinite(delta.turnsDelta)) nextUsage.turnsRun += delta.turnsDelta
283
+ if (nextUsage.startedAt) nextUsage.wallclockMsElapsed = now - nextUsage.startedAt
284
+ nextUsage.lastUpdatedAt = now
285
+ const verdict = evaluateMissionBudget({ ...current, usage: nextUsage }, now)
286
+ if (verdict.warningFraction != null) {
287
+ firedWarn = verdict.warningFraction
288
+ nextUsage.warnFractionsHit = [...nextUsage.warnFractionsHit, verdict.warningFraction]
289
+ }
290
+ return { ...current, usage: nextUsage }
291
+ })
292
+ if (result && firedWarn != null) {
293
+ appendMissionMilestone(missionId, {
294
+ kind: 'budget_warn',
295
+ summary: `Budget ${Math.round(firedWarn * 100)}% reached`,
296
+ })
297
+ }
298
+ return result
299
+ }
@@ -31,6 +31,7 @@ import { logExecution } from '@/lib/server/execution-log'
31
31
  import { createNotification } from '@/lib/server/create-notification'
32
32
  import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
33
33
  import { buildSwarmFeedHeartbeatGuidance } from '@/lib/server/swarmfeed-runtime'
34
+ import { runMissionScheduler } from '@/lib/server/missions/mission-scheduler'
34
35
 
35
36
  const HEARTBEAT_TICK_MS = 60_000
36
37
  const MAX_CONCURRENT_HEARTBEATS = 1
@@ -576,6 +577,10 @@ export async function tickHeartbeats() {
576
577
  const globalOngoing = shouldRunHeartbeats(settings)
577
578
 
578
579
  const now = Date.now()
580
+ // Mission scheduler runs every tick, independent of heartbeat active window,
581
+ // so wallclock budgets and periodic reports still fire overnight.
582
+ try { runMissionScheduler() } catch (error) { log.warn('heartbeat', 'mission scheduler tick failed', error) }
583
+
579
584
  const nowDate = new Date(now)
580
585
  if (!inActiveWindow(nowDate, settings.heartbeatActiveStart, settings.heartbeatActiveEnd, settings.heartbeatTimezone)) {
581
586
  return
@@ -8,6 +8,7 @@ import { getEnabledToolIds } from '@/lib/capability-selection'
8
8
  import { isAllEstopEngaged, isAutonomyEstopEngaged } from '@/lib/server/runtime/estop'
9
9
  import { isRestartRecoverableSource } from '@/lib/server/runtime/run-ledger'
10
10
  import { getActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
11
+ import { checkMissionBudgetForSession } from '@/lib/server/missions/mission-budget-hook'
11
12
 
12
13
  import { cancelPendingForSession } from './cancellation'
13
14
  import {
@@ -49,6 +50,7 @@ const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli'
49
50
  type SessionToolConfig = {
50
51
  tools?: string[] | null
51
52
  extensions?: string[] | null
53
+ missionId?: string | null
52
54
  }
53
55
 
54
56
  function computeEffectiveRunTimeoutMs(
@@ -114,6 +116,13 @@ export function enqueueSessionRun(
114
116
  if (isAutonomyEstopEngaged() && isAutonomyManagedEnqueue(source, internal)) {
115
117
  throw new Error(`Autonomy estop is engaged. New ${source} runs are paused.`)
116
118
  }
119
+ if (isAutonomyManagedEnqueue(source, internal)) {
120
+ const sessionForMission = getSession(input.sessionId) as SessionToolConfig | null
121
+ const missionVerdict = checkMissionBudgetForSession(sessionForMission?.missionId ?? null)
122
+ if (!missionVerdict.allow) {
123
+ throw new Error(`Mission halted: ${missionVerdict.reason ?? 'budget exhausted'}`)
124
+ }
125
+ }
117
126
  const executionKey = typeof input.executionGroupKey === 'string' && input.executionGroupKey.trim()
118
127
  ? input.executionGroupKey.trim()
119
128
  : executionKeyForSession(input.sessionId)
@@ -355,6 +355,135 @@ function normalizeStoredMissionEventRecord(value: unknown): unknown {
355
355
  return event
356
356
  }
357
357
 
358
+ // --- Agent Mission normalizers (autonomous goal-driven runs, v1.5.49+) ---
359
+
360
+ const VALID_AGENT_MISSION_STATUSES = new Set([
361
+ 'draft',
362
+ 'running',
363
+ 'paused',
364
+ 'completed',
365
+ 'failed',
366
+ 'cancelled',
367
+ 'budget_exhausted',
368
+ ])
369
+
370
+ const VALID_AGENT_MISSION_REPORT_FORMATS = new Set(['markdown', 'slack', 'discord', 'email', 'audio'])
371
+
372
+ function normalizeFiniteNumber(value: unknown): number | null {
373
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
374
+ return value
375
+ }
376
+
377
+ function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
378
+ const n = normalizeFiniteNumber(value)
379
+ if (n == null || n < 0) return fallback
380
+ return n
381
+ }
382
+
383
+ function normalizeStoredAgentMissionRecord(value: unknown): unknown {
384
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
385
+ const mission = value as StoredObject
386
+
387
+ const status = typeof mission.status === 'string' ? mission.status.trim().toLowerCase() : ''
388
+ mission.status = VALID_AGENT_MISSION_STATUSES.has(status) ? status : 'draft'
389
+
390
+ mission.successCriteria = normalizeStoredStringArray(mission.successCriteria, 64)
391
+ mission.agentIds = normalizeStoredStringArray(mission.agentIds, 32)
392
+ mission.reportConnectorIds = normalizeStoredStringArray(mission.reportConnectorIds, 16)
393
+
394
+ const budget = mission.budget && typeof mission.budget === 'object' && !Array.isArray(mission.budget)
395
+ ? mission.budget as StoredObject
396
+ : {}
397
+ budget.maxUsd = normalizeFiniteNumber(budget.maxUsd)
398
+ budget.maxTokens = normalizeFiniteNumber(budget.maxTokens)
399
+ budget.maxToolCalls = normalizeFiniteNumber(budget.maxToolCalls)
400
+ budget.maxWallclockSec = normalizeFiniteNumber(budget.maxWallclockSec)
401
+ budget.maxTurns = normalizeFiniteNumber(budget.maxTurns)
402
+ if (!Array.isArray(budget.warnAtFractions)) {
403
+ budget.warnAtFractions = [0.5, 0.8, 0.95]
404
+ } else {
405
+ budget.warnAtFractions = (budget.warnAtFractions as unknown[])
406
+ .map((entry) => normalizeFiniteNumber(entry))
407
+ .filter((entry): entry is number => entry != null && entry > 0 && entry < 1)
408
+ if ((budget.warnAtFractions as number[]).length === 0) {
409
+ budget.warnAtFractions = [0.5, 0.8, 0.95]
410
+ }
411
+ }
412
+ mission.budget = budget
413
+
414
+ const usage = mission.usage && typeof mission.usage === 'object' && !Array.isArray(mission.usage)
415
+ ? mission.usage as StoredObject
416
+ : {}
417
+ usage.usdSpent = normalizeNonNegativeNumber(usage.usdSpent, 0)
418
+ usage.tokensUsed = normalizeNonNegativeNumber(usage.tokensUsed, 0)
419
+ usage.toolCallsUsed = normalizeNonNegativeNumber(usage.toolCallsUsed, 0)
420
+ usage.turnsRun = normalizeNonNegativeNumber(usage.turnsRun, 0)
421
+ usage.wallclockMsElapsed = normalizeNonNegativeNumber(usage.wallclockMsElapsed, 0)
422
+ usage.startedAt = normalizeFiniteNumber(usage.startedAt)
423
+ usage.lastUpdatedAt = normalizeNonNegativeNumber(usage.lastUpdatedAt, 0)
424
+ if (!Array.isArray(usage.warnFractionsHit)) {
425
+ usage.warnFractionsHit = []
426
+ } else {
427
+ usage.warnFractionsHit = (usage.warnFractionsHit as unknown[])
428
+ .map((entry) => normalizeFiniteNumber(entry))
429
+ .filter((entry): entry is number => entry != null)
430
+ }
431
+ mission.usage = usage
432
+
433
+ if (!Array.isArray(mission.milestones)) mission.milestones = []
434
+ // Cap the stored tail so missions don't balloon
435
+ if ((mission.milestones as unknown[]).length > 200) {
436
+ mission.milestones = (mission.milestones as unknown[]).slice(-200)
437
+ }
438
+
439
+ const reportSchedule = mission.reportSchedule
440
+ && typeof mission.reportSchedule === 'object'
441
+ && !Array.isArray(mission.reportSchedule)
442
+ ? mission.reportSchedule as StoredObject
443
+ : null
444
+ if (reportSchedule) {
445
+ const format = typeof reportSchedule.format === 'string' ? reportSchedule.format.trim().toLowerCase() : ''
446
+ reportSchedule.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown'
447
+ reportSchedule.intervalSec = normalizeNonNegativeNumber(reportSchedule.intervalSec, 3600)
448
+ reportSchedule.enabled = reportSchedule.enabled !== false
449
+ reportSchedule.lastReportAt = normalizeFiniteNumber(reportSchedule.lastReportAt)
450
+ mission.reportSchedule = reportSchedule
451
+ } else if (mission.reportSchedule !== undefined) {
452
+ mission.reportSchedule = null
453
+ }
454
+
455
+ if (typeof mission.createdAt !== 'number') mission.createdAt = Date.now()
456
+ if (typeof mission.updatedAt !== 'number') mission.updatedAt = mission.createdAt as number
457
+ if (mission.startedAt === undefined) mission.startedAt = null
458
+ if (mission.endedAt === undefined) mission.endedAt = null
459
+ if (mission.endReason === undefined) mission.endReason = null
460
+
461
+ return mission
462
+ }
463
+
464
+ function normalizeStoredMissionReportRecord(value: unknown): unknown {
465
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
466
+ const report = value as StoredObject
467
+ const format = typeof report.format === 'string' ? report.format.trim().toLowerCase() : ''
468
+ report.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown'
469
+ if (!Array.isArray(report.highlights)) report.highlights = []
470
+ if (!Array.isArray(report.deliveredTo)) report.deliveredTo = []
471
+ if (typeof report.body !== 'string') report.body = ''
472
+ if (typeof report.title !== 'string') report.title = 'Mission report'
473
+ return report
474
+ }
475
+
476
+ function normalizeStoredAgentMissionEventRecord(value: unknown): unknown {
477
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
478
+ const event = value as StoredObject
479
+ if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) {
480
+ event.payload = {}
481
+ }
482
+ if (typeof event.kind !== 'string' || !event.kind.trim()) event.kind = 'unknown'
483
+ if (typeof event.at !== 'number' || !Number.isFinite(event.at)) event.at = Date.now()
484
+ return event
485
+ }
486
+
358
487
  // --- Delegation job normalizer ---
359
488
 
360
489
  function normalizeStoredDelegationJobRecord(value: unknown): unknown {
@@ -428,7 +557,7 @@ export function normalizeStoredRecord(
428
557
  value: unknown,
429
558
  loadItem: CollectionItemLoader,
430
559
  ): NormalizationResult {
431
- // Tables with no normalization early exit
560
+ // Tables with no normalization, early exit.
432
561
  if (
433
562
  table !== 'agents' && table !== 'tasks' && table !== 'missions'
434
563
  && table !== 'mission_events' && table !== 'delegation_jobs'
@@ -436,6 +565,9 @@ export function normalizeStoredRecord(
436
565
  && table !== 'provider_configs'
437
566
  && table !== 'runtime_runs' && table !== 'runtime_run_events'
438
567
  && table !== 'wallets'
568
+ && table !== 'agent_missions'
569
+ && table !== 'mission_reports'
570
+ && table !== 'agent_mission_events'
439
571
  ) {
440
572
  return { value, changed: false }
441
573
  }
@@ -590,6 +722,18 @@ function normalizeStoredRecordInner(
590
722
  return normalizeStoredMissionEventRecord(value)
591
723
  }
592
724
 
725
+ if (table === 'agent_missions') {
726
+ return normalizeStoredAgentMissionRecord(value)
727
+ }
728
+
729
+ if (table === 'mission_reports') {
730
+ return normalizeStoredMissionReportRecord(value)
731
+ }
732
+
733
+ if (table === 'agent_mission_events') {
734
+ return normalizeStoredAgentMissionEventRecord(value)
735
+ }
736
+
593
737
  if (table === 'delegation_jobs') {
594
738
  return normalizeStoredDelegationJobRecord(value)
595
739
  }
@@ -26,6 +26,9 @@ import type {
26
26
  KnowledgeSource,
27
27
  LearnedSkill,
28
28
  Message,
29
+ Mission,
30
+ MissionEvent,
31
+ MissionReport,
29
32
  ProtocolTemplate,
30
33
  ProtocolRun,
31
34
  ProtocolRunEvent,
@@ -176,6 +179,9 @@ const COLLECTIONS = [
176
179
  'wallets',
177
180
  'wallet_transactions',
178
181
  'goals',
182
+ 'agent_missions',
183
+ 'mission_reports',
184
+ 'agent_mission_events',
179
185
  ] as const
180
186
 
181
187
  export type StorageCollection = (typeof COLLECTIONS)[number]
@@ -1686,6 +1692,29 @@ export const loadGoal = goalsStore.loadItem
1686
1692
  export const upsertGoal = goalsStore.upsert
1687
1693
  export const deleteGoalItem = goalsStore.deleteItem
1688
1694
 
1695
+ // --- Agent Missions (autonomous goal-driven runs) ---
1696
+ const agentMissionsStore = createCollectionStore<Mission>('agent_missions', { ttlMs: 5_000 })
1697
+ export const loadAgentMissions = agentMissionsStore.load
1698
+ export const saveAgentMissions = agentMissionsStore.save
1699
+ export const loadAgentMission = agentMissionsStore.loadItem
1700
+ export const upsertAgentMission = agentMissionsStore.upsert
1701
+ export const patchAgentMission = agentMissionsStore.patch
1702
+ export const deleteAgentMission = agentMissionsStore.deleteItem
1703
+
1704
+ const missionReportsStore = createCollectionStore<MissionReport>('mission_reports')
1705
+ export const loadMissionReports = missionReportsStore.load
1706
+ export const saveMissionReports = missionReportsStore.save
1707
+ export const loadMissionReport = missionReportsStore.loadItem
1708
+ export const upsertMissionReport = missionReportsStore.upsert
1709
+ export const deleteMissionReport = missionReportsStore.deleteItem
1710
+
1711
+ const agentMissionEventsStore = createCollectionStore<MissionEvent>('agent_mission_events')
1712
+ export const loadAgentMissionEvents = agentMissionEventsStore.load
1713
+ export const saveAgentMissionEvents = agentMissionEventsStore.save
1714
+ export const loadAgentMissionEvent = agentMissionEventsStore.loadItem
1715
+ export const upsertAgentMissionEvent = agentMissionEventsStore.upsert
1716
+ export const deleteAgentMissionEvent = agentMissionEventsStore.deleteItem
1717
+
1689
1718
  function legacyMissionStatusToWorkingStatus(value: unknown): 'idle' | 'progress' | 'blocked' | 'completed' {
1690
1719
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
1691
1720
  if (normalized === 'achieved' || normalized === 'completed' || normalized === 'ok') return 'completed'
@@ -14,6 +14,7 @@ export * from './run'
14
14
  export * from './approval'
15
15
  export * from './misc'
16
16
  export * from './goal'
17
+ export * from './mission'
17
18
  export * from './swarmdock'
18
19
  export * from './dream'
19
20
  export * from './swarmfeed'
@@ -0,0 +1,115 @@
1
+ export type MissionStatus =
2
+ | 'draft'
3
+ | 'running'
4
+ | 'paused'
5
+ | 'completed'
6
+ | 'failed'
7
+ | 'cancelled'
8
+ | 'budget_exhausted'
9
+
10
+ export type MissionReportFormat = 'markdown' | 'slack' | 'discord' | 'email' | 'audio'
11
+
12
+ export type MissionMilestoneKind =
13
+ | 'started'
14
+ | 'budget_warn'
15
+ | 'budget_hit'
16
+ | 'check_in'
17
+ | 'subgoal_done'
18
+ | 'report_sent'
19
+ | 'paused'
20
+ | 'resumed'
21
+ | 'completed'
22
+ | 'failed'
23
+ | 'cancelled'
24
+
25
+ export interface MissionBudget {
26
+ maxUsd?: number | null
27
+ maxTokens?: number | null
28
+ maxToolCalls?: number | null
29
+ maxWallclockSec?: number | null
30
+ maxTurns?: number | null
31
+ warnAtFractions?: number[]
32
+ }
33
+
34
+ export interface MissionUsage {
35
+ usdSpent: number
36
+ tokensUsed: number
37
+ toolCallsUsed: number
38
+ turnsRun: number
39
+ wallclockMsElapsed: number
40
+ startedAt: number | null
41
+ lastUpdatedAt: number
42
+ warnFractionsHit: number[]
43
+ }
44
+
45
+ export interface MissionMilestone {
46
+ id: string
47
+ at: number
48
+ kind: MissionMilestoneKind
49
+ summary: string
50
+ evidence?: string[]
51
+ sessionId?: string | null
52
+ runId?: string | null
53
+ }
54
+
55
+ export interface MissionReportSchedule {
56
+ intervalSec: number
57
+ format: MissionReportFormat
58
+ enabled: boolean
59
+ lastReportAt?: number | null
60
+ }
61
+
62
+ export interface MissionReportDelivery {
63
+ connectorId?: string | null
64
+ channelId?: string | null
65
+ deliveredAt: number
66
+ status: 'ok' | 'error'
67
+ error?: string | null
68
+ }
69
+
70
+ export interface MissionReport {
71
+ id: string
72
+ missionId: string
73
+ generatedAt: number
74
+ format: MissionReportFormat
75
+ fromAt: number
76
+ toAt: number
77
+ title: string
78
+ body: string
79
+ audioUrl?: string | null
80
+ deliveredTo: MissionReportDelivery[]
81
+ highlights: Array<{ kind: string; summary: string; evidenceRunId?: string | null }>
82
+ }
83
+
84
+ export interface MissionEvent {
85
+ id: string
86
+ missionId: string
87
+ at: number
88
+ kind: string
89
+ payload: Record<string, unknown>
90
+ sessionId?: string | null
91
+ runId?: string | null
92
+ }
93
+
94
+ export interface Mission {
95
+ id: string
96
+ title: string
97
+ goal: string
98
+ successCriteria: string[]
99
+ rootSessionId: string
100
+ agentIds: string[]
101
+ status: MissionStatus
102
+ budget: MissionBudget
103
+ usage: MissionUsage
104
+ milestones: MissionMilestone[]
105
+ reportSchedule?: MissionReportSchedule | null
106
+ reportConnectorIds: string[]
107
+ createdAt: number
108
+ updatedAt: number
109
+ startedAt?: number | null
110
+ endedAt?: number | null
111
+ endReason?: string | null
112
+ }
113
+
114
+ export const DEFAULT_MISSION_WARN_FRACTIONS = [0.5, 0.8, 0.95]
115
+ export const MISSION_MILESTONE_TAIL_CAP = 200