@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
@@ -282,6 +282,14 @@ export function SidebarRail({
282
282
  </svg>
283
283
  </NavItem>
284
284
 
285
+ <NavItem view="missions" label="Missions" expanded={railExpanded} isActive={isNavActive('missions')} onClick={() => handleNavClick('missions')}>
286
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
287
+ <circle cx="12" cy="12" r="10" />
288
+ <circle cx="12" cy="12" r="6" />
289
+ <circle cx="12" cy="12" r="2" />
290
+ </svg>
291
+ </NavItem>
292
+
285
293
  <NavItem view="schedules" label="Schedules" expanded={railExpanded} isActive={isNavActive('schedules')} onClick={() => handleNavClick('schedules')}>
286
294
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
287
295
  <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
@@ -21,6 +21,9 @@ interface McpPreset {
21
21
  needsCwd?: boolean
22
22
  cwdHint?: string
23
23
  defaultName: string
24
+ envTemplate?: Record<string, string>
25
+ url?: string
26
+ headersTemplate?: Record<string, string>
24
27
  }
25
28
 
26
29
  const MCP_PRESETS: McpPreset[] = [
@@ -36,6 +39,18 @@ const MCP_PRESETS: McpPreset[] = [
36
39
  cwdHint: 'Absolute path to a SwarmVault workspace (the directory containing swarmvault.config.json). Run `npx @swarmvaultai/cli init` there first if you haven\'t.',
37
40
  defaultName: 'SwarmVault',
38
41
  },
42
+ {
43
+ id: 'swarmdock',
44
+ label: 'SwarmDock',
45
+ description: 'Agent marketplace — browse tasks, bid, submit work, publish MCP services, earn USDC. Connects to the hosted MCP endpoint; generate a key and register an agent at swarmdock.ai/mcp/connect, then paste it into the Bearer header below.',
46
+ helpUrl: 'https://www.swarmdock.ai/mcp/connect',
47
+ transport: 'streamable-http',
48
+ url: 'https://swarmdock-api.onrender.com/mcp',
49
+ defaultName: 'SwarmDock',
50
+ headersTemplate: {
51
+ Authorization: 'Bearer <your-base64-ed25519-secret>',
52
+ },
53
+ },
39
54
  ]
40
55
 
41
56
  function McpServerForm({ editing, onClose, loadMcpServers }: {
@@ -159,7 +174,14 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
159
174
  setTransport(preset.transport)
160
175
  if (preset.command !== undefined) setCommand(preset.command)
161
176
  if (preset.args !== undefined) setArgs(preset.args.join(', '))
177
+ if (preset.url !== undefined) setUrl(preset.url)
162
178
  if (!name.trim()) setName(preset.defaultName)
179
+ if (preset.envTemplate && !envText.trim()) {
180
+ setEnvText(Object.entries(preset.envTemplate).map(([k, v]) => `${k}=${v}`).join('\n'))
181
+ }
182
+ if (preset.headersTemplate && !headersText.trim()) {
183
+ setHeadersText(Object.entries(preset.headersTemplate).map(([k, v]) => `${k}: ${v}`).join('\n'))
184
+ }
163
185
  }
164
186
 
165
187
  const activePreset = activePresetId ? MCP_PRESETS.find((p) => p.id === activePresetId) ?? null : null
@@ -15,6 +15,7 @@ const VIEW_TO_PATH: Record<AppView, string> = {
15
15
  memory: '/memory',
16
16
 
17
17
  tasks: '/tasks',
18
+ missions: '/missions',
18
19
  secrets: '/secrets',
19
20
  wallets: '/wallets',
20
21
  providers: '/providers',
@@ -11,6 +11,7 @@ export const VIEW_LABELS: Record<AppView, string> = {
11
11
  memory: 'Memory',
12
12
 
13
13
  tasks: 'Tasks',
14
+ missions: 'Missions',
14
15
  secrets: 'Secrets',
15
16
  wallets: 'Wallets',
16
17
  providers: 'Providers',
@@ -35,6 +36,7 @@ export const CREATE_LABELS: Partial<Record<AppView, string>> = {
35
36
  agents: 'Agent',
36
37
  schedules: 'Schedule',
37
38
  tasks: 'Task',
39
+ missions: 'Mission',
38
40
  secrets: 'Secret',
39
41
  providers: 'Provider',
40
42
  skills: 'Skill',
@@ -57,6 +59,7 @@ export const VIEW_DESCRIPTIONS: Record<AppView, string> = {
57
59
  memory: 'Long-term agent memory store',
58
60
 
59
61
  tasks: 'Task board for agent work and queued runs',
62
+ missions: 'Autonomous goal-driven agent runs with budgets and morning reports',
60
63
  secrets: 'API keys, tokens, and encrypted credentials',
61
64
  wallets: 'Crypto wallets for agent-initiated on-chain transactions',
62
65
  providers: 'LLM providers & custom endpoints',
@@ -115,6 +118,12 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
115
118
  description: 'A kanban board for managing agent work. Create tasks, assign them to agents, and track progress.',
116
119
  features: ['Kanban columns: Backlog, Queued, Running, Completed, Failed', 'Assign tasks to specific agents', 'Track retries, results, and logs', 'Review status without leaving the board'],
117
120
  },
121
+ missions: {
122
+ icon: 'target',
123
+ title: 'Missions',
124
+ description: 'Hand your agent team a goal and let them run overnight. Budgets, periodic reports, and a full timeline you can review in the morning.',
125
+ features: ['Set USD, token, turn, and wallclock caps enforced at the session level', 'Periodic markdown reports delivered as in-app notifications', 'Full milestone timeline with evidence and end reasons', 'Start, pause, resume, and cancel from the dashboard or CLI'],
126
+ },
118
127
  secrets: {
119
128
  icon: 'lock',
120
129
  title: 'Secrets',
@@ -234,5 +243,5 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
234
243
  export const FULL_WIDTH_VIEWS = new Set<AppView>([
235
244
  'home', 'org_chart', 'inbox', 'chatrooms', 'protocols', 'schedules', 'secrets', 'wallets', 'providers', 'skills',
236
245
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'extensions',
237
- 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed', 'marketplace',
246
+ 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed', 'marketplace', 'missions',
238
247
  ])
@@ -0,0 +1,38 @@
1
+ import { log } from '@/lib/server/logger'
2
+ import { evaluateMissionBudget, markBudgetExhausted } from './mission-service'
3
+ import { getMission } from './mission-repository'
4
+
5
+ const TAG = 'mission-budget'
6
+
7
+ export interface MissionBudgetHookResult {
8
+ allow: boolean
9
+ reason?: string
10
+ }
11
+
12
+ /**
13
+ * Pure, synchronous check suitable for the hot enqueue path. When the hook
14
+ * denies a run it also fires a side-effect to transition the mission to
15
+ * budget_exhausted — callers should throw on `allow: false` to surface the
16
+ * block to the caller (heartbeat loops back off on thrown errors).
17
+ */
18
+ export function checkMissionBudgetForSession(missionId: string | null | undefined): MissionBudgetHookResult {
19
+ if (!missionId) return { allow: true }
20
+ const mission = getMission(missionId)
21
+ if (!mission) return { allow: true }
22
+ if (mission.status !== 'running') {
23
+ if (mission.status === 'paused' || mission.status === 'draft') {
24
+ return { allow: false, reason: `Mission ${mission.id} is ${mission.status}` }
25
+ }
26
+ return { allow: false, reason: `Mission ${mission.id} is ${mission.status}` }
27
+ }
28
+ const verdict = evaluateMissionBudget(mission)
29
+ if (!verdict.allow) {
30
+ try {
31
+ markBudgetExhausted(mission.id, verdict.reason ?? 'Budget exhausted')
32
+ } catch (error) {
33
+ log.warn(TAG, `Failed to transition mission ${mission.id} to budget_exhausted`, error)
34
+ }
35
+ return { allow: false, reason: verdict.reason }
36
+ }
37
+ return { allow: true }
38
+ }
@@ -0,0 +1,106 @@
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 { after, before, describe, it } from 'node:test'
6
+
7
+ import type { Mission } from '@/types'
8
+
9
+ const originalEnv = {
10
+ DATA_DIR: process.env.DATA_DIR,
11
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
12
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
13
+ }
14
+
15
+ let tempDir = ''
16
+ let builder: typeof import('./mission-report-builder')
17
+ let service: typeof import('./mission-service')
18
+ let repo: typeof import('./mission-repository')
19
+
20
+ before(async () => {
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-report-builder-'))
22
+ process.env.DATA_DIR = path.join(tempDir, 'data')
23
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
24
+ process.env.SWARMCLAW_BUILD_MODE = '1'
25
+ builder = await import('./mission-report-builder')
26
+ service = await import('./mission-service')
27
+ repo = await import('./mission-repository')
28
+ })
29
+
30
+ after(() => {
31
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
32
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
33
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
34
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
35
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
36
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
37
+ fs.rmSync(tempDir, { recursive: true, force: true })
38
+ })
39
+
40
+ function makeRunningMission(label: string): Mission {
41
+ const m = service.createMission({
42
+ title: `Test report mission ${label}`,
43
+ goal: 'Write haikus',
44
+ successCriteria: ['3 haikus saved', 'Each 5-7-5'],
45
+ rootSessionId: `rep_s_${label}`,
46
+ agentIds: ['a1'],
47
+ budget: { maxUsd: 0.5, maxTokens: 5000, maxTurns: 10, maxWallclockSec: 600 },
48
+ })
49
+ const started = service.startMission(m.id)
50
+ return started ?? m
51
+ }
52
+
53
+ describe('mission-report-builder', () => {
54
+ it('builds a markdown progress report with usage and milestones', () => {
55
+ const m = makeRunningMission('rep_1')
56
+ service.recordTurnUsage(m.id, { turnsDelta: 2, tokensDelta: 500, usdDelta: 0.05 })
57
+ repo.appendMissionMilestone(m.id, { kind: 'subgoal_done', summary: 'Haiku one drafted' })
58
+ const latest = repo.getMission(m.id)!
59
+ const { report, deliveryTitle, deliveryMessage } = builder.buildMissionReport(latest, {
60
+ from: latest.createdAt,
61
+ to: Date.now(),
62
+ })
63
+ assert.ok(report.body.includes('# Test report mission rep_1: progress update'))
64
+ assert.ok(report.body.includes('**Goal**: Write haikus'))
65
+ assert.ok(report.body.includes('Turns run: 2 / 10'))
66
+ assert.ok(report.body.includes('Tokens used: 500 / 5000'))
67
+ assert.ok(report.body.includes('Spend: $0.05 / $0.50'))
68
+ assert.ok(report.body.includes('3 haikus saved'))
69
+ assert.ok(report.body.includes('Milestones'))
70
+ assert.ok(report.body.includes('Haiku one drafted'))
71
+ assert.equal(report.format, 'markdown')
72
+ assert.equal(report.missionId, m.id)
73
+ assert.ok(deliveryTitle.includes('Test report mission'))
74
+ assert.ok(deliveryMessage.includes('still running'))
75
+ })
76
+
77
+ it('builds a final report when isFinal is set, including end reason', () => {
78
+ const m = makeRunningMission('rep_2')
79
+ service.cancelMission(m.id, 'user aborted')
80
+ const cancelled = repo.getMission(m.id)!
81
+ const { report, deliveryMessage } = builder.buildMissionReport(cancelled, {
82
+ from: cancelled.createdAt,
83
+ to: Date.now(),
84
+ }, { isFinal: true })
85
+ assert.ok(report.body.includes('final report'))
86
+ assert.ok(report.body.includes('## End reason'))
87
+ assert.ok(report.body.includes('user aborted'))
88
+ assert.ok(deliveryMessage.includes('has ended'))
89
+ })
90
+
91
+ it('includes up to the last N milestones when the list is long', () => {
92
+ const m = makeRunningMission('rep_3')
93
+ for (let i = 0; i < 25; i++) {
94
+ repo.appendMissionMilestone(m.id, { kind: 'check_in', summary: `check ${i}` })
95
+ }
96
+ const latest = repo.getMission(m.id)!
97
+ const { report } = builder.buildMissionReport(latest, {
98
+ from: 0,
99
+ to: Date.now(),
100
+ })
101
+ // Body should mention the last check (24), but capped at 20 listed
102
+ assert.ok(report.body.includes('check 24'))
103
+ const milestoneLines = report.body.split('\n').filter((l) => l.includes('**check_in**'))
104
+ assert.ok(milestoneLines.length <= 20)
105
+ })
106
+ })
@@ -0,0 +1,158 @@
1
+ import crypto from 'crypto'
2
+ import type { Mission, MissionEvent, MissionReport } from '@/types'
3
+ import { getRecentMessages } from '@/lib/server/messages/message-repository'
4
+ import { listMissionEvents } from './mission-repository'
5
+
6
+ function formatDuration(ms: number): string {
7
+ if (ms < 1000) return `${ms}ms`
8
+ const sec = Math.round(ms / 1000)
9
+ if (sec < 60) return `${sec}s`
10
+ const min = Math.round(sec / 60)
11
+ if (min < 60) return `${min}m`
12
+ const hr = Math.round(min / 60 * 10) / 10
13
+ return `${hr}h`
14
+ }
15
+
16
+ function formatUsd(n: number): string {
17
+ return `$${n.toFixed(n < 0.01 ? 4 : 2)}`
18
+ }
19
+
20
+ function formatWhen(at: number): string {
21
+ try {
22
+ return new Date(at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
23
+ } catch {
24
+ return String(at)
25
+ }
26
+ }
27
+
28
+ export interface MissionReportOptions {
29
+ isFinal?: boolean
30
+ windowSource?: 'schedule' | 'final' | 'on_demand'
31
+ }
32
+
33
+ export interface BuiltMissionReport {
34
+ report: MissionReport
35
+ deliveryMessage: string
36
+ deliveryTitle: string
37
+ }
38
+
39
+ function extractHighlights(events: MissionEvent[]): MissionReport['highlights'] {
40
+ const milestones = events.filter((e) => e.kind.startsWith('milestone:'))
41
+ return milestones.slice(-5).map((ev) => ({
42
+ kind: ev.kind,
43
+ summary: typeof ev.payload.summary === 'string' ? ev.payload.summary : ev.kind,
44
+ evidenceRunId: ev.runId ?? null,
45
+ }))
46
+ }
47
+
48
+ function collectRecentTranscriptLines(missionRootSessionId: string, window: { from: number; to: number }): string[] {
49
+ try {
50
+ const messages = getRecentMessages(missionRootSessionId, 40)
51
+ return messages
52
+ .filter((m) => {
53
+ const ts = typeof m.time === 'number' ? m.time : 0
54
+ return ts >= window.from && ts <= window.to
55
+ })
56
+ .slice(-20)
57
+ .map((m) => {
58
+ const role = m.role || 'system'
59
+ const text = (typeof m.text === 'string' ? m.text : '').split('\n')[0].slice(0, 280)
60
+ return `- **${role}**: ${text}`
61
+ })
62
+ } catch {
63
+ return []
64
+ }
65
+ }
66
+
67
+ export function renderMissionReportMarkdown(
68
+ mission: Mission,
69
+ events: MissionEvent[],
70
+ transcriptLines: string[],
71
+ window: { from: number; to: number },
72
+ opts: MissionReportOptions,
73
+ ): { title: string; body: string } {
74
+ const title = opts.isFinal
75
+ ? `${mission.title}: final report`
76
+ : `${mission.title}: progress update`
77
+
78
+ const parts: string[] = []
79
+ parts.push(`# ${title}`)
80
+ parts.push('')
81
+ parts.push(`**Goal**: ${mission.goal}`)
82
+ parts.push('')
83
+ parts.push(`**Status**: \`${mission.status}\``)
84
+ parts.push('')
85
+ parts.push(`**Window**: ${formatWhen(window.from)} to ${formatWhen(window.to)}`)
86
+ parts.push('')
87
+
88
+ parts.push('## Usage')
89
+ parts.push(`- Turns run: ${mission.usage.turnsRun}${mission.budget.maxTurns != null ? ` / ${mission.budget.maxTurns}` : ''}`)
90
+ parts.push(`- Tokens used: ${mission.usage.tokensUsed}${mission.budget.maxTokens != null ? ` / ${mission.budget.maxTokens}` : ''}`)
91
+ parts.push(`- Spend: ${formatUsd(mission.usage.usdSpent)}${mission.budget.maxUsd != null ? ` / ${formatUsd(mission.budget.maxUsd)}` : ''}`)
92
+ parts.push(`- Wallclock: ${formatDuration(mission.usage.wallclockMsElapsed)}${mission.budget.maxWallclockSec != null ? ` / ${formatDuration(mission.budget.maxWallclockSec * 1000)}` : ''}`)
93
+ parts.push('')
94
+
95
+ if (mission.successCriteria.length > 0) {
96
+ parts.push('## Success criteria')
97
+ for (const c of mission.successCriteria) parts.push(`- ${c}`)
98
+ parts.push('')
99
+ }
100
+
101
+ const milestones = events.filter((e) => e.kind.startsWith('milestone:'))
102
+ if (milestones.length > 0) {
103
+ parts.push(`## Milestones (${milestones.length})`)
104
+ for (const ev of milestones.slice(-20)) {
105
+ const kind = ev.kind.replace('milestone:', '')
106
+ const summary = typeof ev.payload.summary === 'string' ? ev.payload.summary : kind
107
+ parts.push(`- **${kind}** at ${formatWhen(ev.at)}: ${summary}`)
108
+ }
109
+ parts.push('')
110
+ }
111
+
112
+ if (transcriptLines.length > 0) {
113
+ parts.push('## Recent activity')
114
+ for (const line of transcriptLines) parts.push(line)
115
+ parts.push('')
116
+ }
117
+
118
+ if (opts.isFinal && mission.endReason) {
119
+ parts.push('## End reason')
120
+ parts.push(mission.endReason)
121
+ parts.push('')
122
+ }
123
+
124
+ parts.push('---')
125
+ parts.push('_Generated by SwarmClaw Missions._')
126
+
127
+ return { title, body: parts.join('\n') }
128
+ }
129
+
130
+ export function buildMissionReport(
131
+ mission: Mission,
132
+ window: { from: number; to: number },
133
+ opts: MissionReportOptions = {},
134
+ ): BuiltMissionReport {
135
+ const events = listMissionEvents(mission.id, { sinceAt: window.from, untilAt: window.to })
136
+ const transcriptLines = collectRecentTranscriptLines(mission.rootSessionId, window)
137
+ const { title, body } = renderMissionReportMarkdown(mission, events, transcriptLines, window, opts)
138
+
139
+ const report: MissionReport = {
140
+ id: `mrep_${crypto.randomBytes(8).toString('hex')}`,
141
+ missionId: mission.id,
142
+ generatedAt: Date.now(),
143
+ format: 'markdown',
144
+ fromAt: window.from,
145
+ toAt: window.to,
146
+ title,
147
+ body,
148
+ audioUrl: null,
149
+ deliveredTo: [],
150
+ highlights: extractHighlights(events),
151
+ }
152
+
153
+ const deliveryMessage = opts.isFinal
154
+ ? `Mission "${mission.title}" has ended. Open SwarmClaw to review the final report.`
155
+ : `Mission "${mission.title}" is still running. ${mission.usage.turnsRun} turns done.`
156
+
157
+ return { report, deliveryMessage, deliveryTitle: title }
158
+ }
@@ -0,0 +1,171 @@
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 { after, before, describe, it } from 'node:test'
6
+
7
+ import type { Mission } from '@/types'
8
+
9
+ const originalEnv = {
10
+ DATA_DIR: process.env.DATA_DIR,
11
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
12
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
13
+ }
14
+
15
+ let tempDir = ''
16
+ let repo: typeof import('./mission-repository')
17
+
18
+ function makeMission(overrides: Partial<Mission> = {}): Mission {
19
+ const now = Date.now()
20
+ return {
21
+ id: overrides.id ?? 'mi_test_1',
22
+ title: 'Smoke mission',
23
+ goal: 'Write 3 haikus about budgets',
24
+ successCriteria: ['File haikus.md has 3 stanzas'],
25
+ rootSessionId: 's1',
26
+ agentIds: ['a1'],
27
+ status: 'draft',
28
+ budget: {
29
+ maxUsd: 0.1,
30
+ maxTokens: 5000,
31
+ maxToolCalls: null,
32
+ maxWallclockSec: 600,
33
+ maxTurns: 20,
34
+ warnAtFractions: [0.5, 0.8, 0.95],
35
+ },
36
+ usage: {
37
+ usdSpent: 0,
38
+ tokensUsed: 0,
39
+ toolCallsUsed: 0,
40
+ turnsRun: 0,
41
+ wallclockMsElapsed: 0,
42
+ startedAt: null,
43
+ lastUpdatedAt: now,
44
+ warnFractionsHit: [],
45
+ },
46
+ milestones: [],
47
+ reportSchedule: null,
48
+ reportConnectorIds: [],
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ ...overrides,
52
+ }
53
+ }
54
+
55
+ before(async () => {
56
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-missions-'))
57
+ process.env.DATA_DIR = path.join(tempDir, 'data')
58
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
59
+ process.env.SWARMCLAW_BUILD_MODE = '1'
60
+ repo = await import('./mission-repository')
61
+ })
62
+
63
+ after(() => {
64
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
65
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
66
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
67
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
68
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
69
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
70
+ fs.rmSync(tempDir, { recursive: true, force: true })
71
+ })
72
+
73
+ describe('mission-repository', () => {
74
+ it('persists and retrieves a mission', () => {
75
+ const mission = makeMission({ id: 'mi_persist_1' })
76
+ repo.upsertMission(mission)
77
+ const fetched = repo.getMission('mi_persist_1')
78
+ assert.ok(fetched)
79
+ assert.equal(fetched?.title, 'Smoke mission')
80
+ assert.equal(fetched?.status, 'draft')
81
+ })
82
+
83
+ it('lists missions newest-first', () => {
84
+ const older = makeMission({ id: 'mi_list_old', createdAt: 1_000 })
85
+ const newer = makeMission({ id: 'mi_list_new', createdAt: 2_000 })
86
+ repo.upsertMission(older)
87
+ repo.upsertMission(newer)
88
+ const all = repo.listMissions()
89
+ const olderIdx = all.findIndex((m) => m.id === 'mi_list_old')
90
+ const newerIdx = all.findIndex((m) => m.id === 'mi_list_new')
91
+ assert.ok(newerIdx >= 0 && olderIdx >= 0)
92
+ assert.ok(newerIdx < olderIdx, 'newer mission should appear before older')
93
+ })
94
+
95
+ it('patches a mission and bumps updatedAt', async () => {
96
+ const mission = makeMission({ id: 'mi_patch_1', updatedAt: 1_000 })
97
+ repo.upsertMission(mission)
98
+ await new Promise((resolve) => setTimeout(resolve, 5))
99
+ const patched = repo.patchMission('mi_patch_1', (m) => {
100
+ if (!m) return null
101
+ return { ...m, status: 'running' }
102
+ })
103
+ assert.ok(patched)
104
+ assert.equal(patched?.status, 'running')
105
+ assert.ok((patched?.updatedAt ?? 0) > 1_000)
106
+ })
107
+
108
+ it('appends milestones with cap and writes an event', () => {
109
+ const mission = makeMission({ id: 'mi_milestone_1' })
110
+ repo.upsertMission(mission)
111
+ repo.appendMissionMilestone('mi_milestone_1', {
112
+ kind: 'started',
113
+ summary: 'Mission started',
114
+ })
115
+ const fetched = repo.getMission('mi_milestone_1')
116
+ assert.equal(fetched?.milestones.length, 1)
117
+ assert.equal(fetched?.milestones[0].kind, 'started')
118
+ const events = repo.listMissionEvents('mi_milestone_1')
119
+ assert.ok(events.some((e) => e.kind === 'milestone:started'))
120
+ })
121
+
122
+ it('caps milestone tail at the configured maximum', () => {
123
+ const mission = makeMission({ id: 'mi_cap_1' })
124
+ repo.upsertMission(mission)
125
+ for (let i = 0; i < 205; i++) {
126
+ repo.appendMissionMilestone('mi_cap_1', {
127
+ kind: 'check_in',
128
+ summary: `check ${i}`,
129
+ })
130
+ }
131
+ const fetched = repo.getMission('mi_cap_1')
132
+ assert.equal(fetched?.milestones.length, 200)
133
+ // Oldest retained should be check 5 (first five were trimmed)
134
+ assert.equal(fetched?.milestones[0].summary, 'check 5')
135
+ assert.equal(fetched?.milestones[199].summary, 'check 204')
136
+ })
137
+
138
+ it('saves and lists reports newest-first', () => {
139
+ const mission = makeMission({ id: 'mi_report_1' })
140
+ repo.upsertMission(mission)
141
+ const now = Date.now()
142
+ repo.saveMissionReport({
143
+ id: 'mrep_1',
144
+ missionId: 'mi_report_1',
145
+ generatedAt: now - 1000,
146
+ format: 'markdown',
147
+ fromAt: now - 2000,
148
+ toAt: now - 1000,
149
+ title: 'First report',
150
+ body: 'body 1',
151
+ deliveredTo: [],
152
+ highlights: [],
153
+ })
154
+ repo.saveMissionReport({
155
+ id: 'mrep_2',
156
+ missionId: 'mi_report_1',
157
+ generatedAt: now,
158
+ format: 'markdown',
159
+ fromAt: now - 1000,
160
+ toAt: now,
161
+ title: 'Second report',
162
+ body: 'body 2',
163
+ deliveredTo: [],
164
+ highlights: [],
165
+ })
166
+ const reports = repo.listMissionReports('mi_report_1')
167
+ assert.equal(reports.length, 2)
168
+ assert.equal(reports[0].id, 'mrep_2')
169
+ assert.equal(reports[1].id, 'mrep_1')
170
+ })
171
+ })