@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.
@@ -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" />
@@ -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
+ })
@@ -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 }