@swarmclawai/swarmclaw 1.5.48 → 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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/app/api/missions/[id]/control/route.ts +57 -0
- package/src/app/api/missions/[id]/events/route.ts +21 -0
- package/src/app/api/missions/[id]/reports/route.ts +33 -0
- package/src/app/api/missions/[id]/route.ts +82 -0
- package/src/app/api/missions/route.test.ts +170 -0
- package/src/app/api/missions/route.ts +58 -0
- package/src/app/missions/page.tsx +635 -0
- package/src/cli/index.js +15 -0
- package/src/cli/spec.js +14 -0
- package/src/components/layout/sidebar-rail.tsx +8 -0
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/view-constants.ts +10 -1
- package/src/lib/server/missions/mission-budget-hook.ts +38 -0
- package/src/lib/server/missions/mission-report-builder.test.ts +106 -0
- package/src/lib/server/missions/mission-report-builder.ts +158 -0
- package/src/lib/server/missions/mission-repository.test.ts +171 -0
- package/src/lib/server/missions/mission-repository.ts +137 -0
- package/src/lib/server/missions/mission-scheduler.ts +107 -0
- package/src/lib/server/missions/mission-service.test.ts +201 -0
- package/src/lib/server/missions/mission-service.ts +299 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -0
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +9 -0
- package/src/lib/server/storage-normalization.ts +145 -1
- package/src/lib/server/storage.ts +29 -0
- package/src/types/index.ts +1 -0
- package/src/types/mission.ts +115 -0
- 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" />
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import type {
|
|
3
|
+
Mission,
|
|
4
|
+
MissionEvent,
|
|
5
|
+
MissionMilestone,
|
|
6
|
+
MissionReport,
|
|
7
|
+
} from '@/types'
|
|
8
|
+
import { MISSION_MILESTONE_TAIL_CAP } from '@/types'
|
|
9
|
+
import {
|
|
10
|
+
loadAgentMission,
|
|
11
|
+
loadAgentMissions,
|
|
12
|
+
upsertAgentMission,
|
|
13
|
+
patchAgentMission,
|
|
14
|
+
deleteAgentMission,
|
|
15
|
+
upsertAgentMissionEvent,
|
|
16
|
+
loadAgentMissionEvents,
|
|
17
|
+
upsertMissionReport,
|
|
18
|
+
loadMissionReports,
|
|
19
|
+
loadMissionReport,
|
|
20
|
+
} from '@/lib/server/storage'
|
|
21
|
+
|
|
22
|
+
function newId(prefix: string): string {
|
|
23
|
+
return `${prefix}_${crypto.randomBytes(8).toString('hex')}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function listMissions(): Mission[] {
|
|
27
|
+
const all = loadAgentMissions()
|
|
28
|
+
return Object.values(all).sort((a, b) => b.createdAt - a.createdAt)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getMission(id: string): Mission | null {
|
|
32
|
+
return loadAgentMission(id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function upsertMission(mission: Mission): void {
|
|
36
|
+
const withTimestamps: Mission = {
|
|
37
|
+
...mission,
|
|
38
|
+
updatedAt: Date.now(),
|
|
39
|
+
}
|
|
40
|
+
upsertAgentMission(mission.id, withTimestamps)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function patchMission(
|
|
44
|
+
id: string,
|
|
45
|
+
updater: (current: Mission | null) => Mission | null,
|
|
46
|
+
): Mission | null {
|
|
47
|
+
return patchAgentMission(id, (current) => {
|
|
48
|
+
const next = updater(current)
|
|
49
|
+
if (!next) return next
|
|
50
|
+
return { ...next, updatedAt: Date.now() }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function removeMission(id: string): void {
|
|
55
|
+
deleteAgentMission(id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function appendMissionEvent(
|
|
59
|
+
missionId: string,
|
|
60
|
+
kind: string,
|
|
61
|
+
payload: Record<string, unknown> = {},
|
|
62
|
+
opts: { sessionId?: string | null; runId?: string | null; at?: number } = {},
|
|
63
|
+
): MissionEvent {
|
|
64
|
+
const event: MissionEvent = {
|
|
65
|
+
id: newId('mev'),
|
|
66
|
+
missionId,
|
|
67
|
+
at: opts.at ?? Date.now(),
|
|
68
|
+
kind,
|
|
69
|
+
payload,
|
|
70
|
+
sessionId: opts.sessionId ?? null,
|
|
71
|
+
runId: opts.runId ?? null,
|
|
72
|
+
}
|
|
73
|
+
upsertAgentMissionEvent(event.id, event)
|
|
74
|
+
return event
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function appendMissionMilestone(
|
|
78
|
+
missionId: string,
|
|
79
|
+
milestone: Omit<MissionMilestone, 'id' | 'at'> & { at?: number; id?: string },
|
|
80
|
+
): Mission | null {
|
|
81
|
+
return patchMission(missionId, (current) => {
|
|
82
|
+
if (!current) return null
|
|
83
|
+
const entry: MissionMilestone = {
|
|
84
|
+
id: milestone.id ?? newId('ms'),
|
|
85
|
+
at: milestone.at ?? Date.now(),
|
|
86
|
+
kind: milestone.kind,
|
|
87
|
+
summary: milestone.summary.slice(0, 240),
|
|
88
|
+
evidence: milestone.evidence,
|
|
89
|
+
sessionId: milestone.sessionId ?? null,
|
|
90
|
+
runId: milestone.runId ?? null,
|
|
91
|
+
}
|
|
92
|
+
const next = [...current.milestones, entry]
|
|
93
|
+
const capped = next.length > MISSION_MILESTONE_TAIL_CAP
|
|
94
|
+
? next.slice(-MISSION_MILESTONE_TAIL_CAP)
|
|
95
|
+
: next
|
|
96
|
+
// Also persist to the events table so the full history is never lost
|
|
97
|
+
appendMissionEvent(missionId, `milestone:${entry.kind}`, {
|
|
98
|
+
milestoneId: entry.id,
|
|
99
|
+
summary: entry.summary,
|
|
100
|
+
evidence: entry.evidence,
|
|
101
|
+
}, { sessionId: entry.sessionId, runId: entry.runId, at: entry.at })
|
|
102
|
+
return { ...current, milestones: capped }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function listMissionEvents(missionId: string, opts: { sinceAt?: number; untilAt?: number } = {}): MissionEvent[] {
|
|
107
|
+
const all = loadAgentMissionEvents()
|
|
108
|
+
const sinceAt = opts.sinceAt ?? 0
|
|
109
|
+
const untilAt = opts.untilAt ?? Number.MAX_SAFE_INTEGER
|
|
110
|
+
return Object.values(all)
|
|
111
|
+
.filter((ev) => ev.missionId === missionId && ev.at >= sinceAt && ev.at <= untilAt)
|
|
112
|
+
.sort((a, b) => a.at - b.at)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function saveMissionReport(report: MissionReport): void {
|
|
116
|
+
upsertMissionReport(report.id, report)
|
|
117
|
+
appendMissionEvent(report.missionId, 'report_generated', {
|
|
118
|
+
reportId: report.id,
|
|
119
|
+
format: report.format,
|
|
120
|
+
fromAt: report.fromAt,
|
|
121
|
+
toAt: report.toAt,
|
|
122
|
+
}, { at: report.generatedAt })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getMissionReport(id: string): MissionReport | null {
|
|
126
|
+
return loadMissionReport(id)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function listMissionReports(missionId: string, limit = 20): MissionReport[] {
|
|
130
|
+
const all = loadMissionReports()
|
|
131
|
+
return Object.values(all)
|
|
132
|
+
.filter((r) => r.missionId === missionId)
|
|
133
|
+
.sort((a, b) => b.generatedAt - a.generatedAt)
|
|
134
|
+
.slice(0, limit)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { newId as newMissionId }
|