@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,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 }
@@ -0,0 +1,107 @@
1
+ import { log } from '@/lib/server/logger'
2
+ import { hmrSingleton } from '@/lib/shared-utils'
3
+ import { createNotification } from '@/lib/server/create-notification'
4
+ import type { Mission } from '@/types'
5
+ import {
6
+ listMissions,
7
+ patchMission,
8
+ saveMissionReport,
9
+ } from './mission-repository'
10
+ import { buildMissionReport } from './mission-report-builder'
11
+ import { markBudgetExhausted } from './mission-service'
12
+
13
+ const TAG = 'mission-scheduler'
14
+
15
+ interface SchedulerState {
16
+ lastTickAt: number
17
+ inFlight: boolean
18
+ }
19
+
20
+ const state = hmrSingleton<SchedulerState>('mission_scheduler_state', () => ({
21
+ lastTickAt: 0,
22
+ inFlight: false,
23
+ }))
24
+
25
+ function dispatchInAppReport(mission: Mission, title: string, message: string): void {
26
+ try {
27
+ createNotification({
28
+ type: 'info',
29
+ title,
30
+ message,
31
+ entityType: 'mission',
32
+ entityId: mission.id,
33
+ actionLabel: 'Open mission',
34
+ actionUrl: `/missions/${mission.id}`,
35
+ dedupKey: `mission-report:${mission.id}:${Date.now()}`,
36
+ })
37
+ } catch (error) {
38
+ log.warn(TAG, `Failed to emit notification for mission ${mission.id}`, error)
39
+ }
40
+ }
41
+
42
+ function generateReportForMission(mission: Mission, isFinal: boolean, reason?: string): void {
43
+ try {
44
+ const from = mission.reportSchedule?.lastReportAt
45
+ ?? mission.usage.startedAt
46
+ ?? mission.createdAt
47
+ const to = Date.now()
48
+ const { report, deliveryMessage, deliveryTitle } = buildMissionReport(mission, { from, to }, {
49
+ isFinal,
50
+ windowSource: isFinal ? 'final' : 'schedule',
51
+ })
52
+ saveMissionReport(report)
53
+ dispatchInAppReport(mission, deliveryTitle, reason ?? deliveryMessage)
54
+ if (!isFinal && mission.reportSchedule) {
55
+ patchMission(mission.id, (current) => {
56
+ if (!current || !current.reportSchedule) return current
57
+ return {
58
+ ...current,
59
+ reportSchedule: { ...current.reportSchedule, lastReportAt: to },
60
+ }
61
+ })
62
+ }
63
+ } catch (error) {
64
+ log.error(TAG, `Failed to generate report for mission ${mission.id}`, error)
65
+ }
66
+ }
67
+
68
+ export function runMissionScheduler(): void {
69
+ if (state.inFlight) return
70
+ state.inFlight = true
71
+ try {
72
+ const now = Date.now()
73
+ state.lastTickAt = now
74
+ const missions = listMissions()
75
+ for (const mission of missions) {
76
+ if (mission.status !== 'running') continue
77
+
78
+ // Wallclock budget enforcement (scheduler is the authority for time-based exhaustion)
79
+ const startedAt = mission.usage.startedAt
80
+ if (mission.budget.maxWallclockSec != null && startedAt != null) {
81
+ const elapsedSec = (now - startedAt) / 1000
82
+ if (elapsedSec >= mission.budget.maxWallclockSec) {
83
+ const reason = `Wallclock budget exceeded (${Math.round(elapsedSec)}s)`
84
+ markBudgetExhausted(mission.id, reason)
85
+ // Reload the mission to pick up the new status before generating the final report.
86
+ const updated = listMissions().find((m) => m.id === mission.id)
87
+ if (updated) generateReportForMission(updated, true, reason)
88
+ continue
89
+ }
90
+ }
91
+
92
+ // Report schedule
93
+ const schedule = mission.reportSchedule
94
+ if (!schedule || !schedule.enabled) continue
95
+ const lastAt = schedule.lastReportAt ?? startedAt ?? mission.createdAt
96
+ const intervalMs = Math.max(30, schedule.intervalSec) * 1000
97
+ if (now - lastAt < intervalMs) continue
98
+ generateReportForMission(mission, false)
99
+ }
100
+ } finally {
101
+ state.inFlight = false
102
+ }
103
+ }
104
+
105
+ export function getSchedulerLastTickAt(): number {
106
+ return state.lastTickAt
107
+ }
@@ -0,0 +1,201 @@
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 service: typeof import('./mission-service')
17
+ let hook: typeof import('./mission-budget-hook')
18
+ let repo: typeof import('./mission-repository')
19
+
20
+ before(async () => {
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-mission-svc-'))
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
+ service = await import('./mission-service')
26
+ hook = await import('./mission-budget-hook')
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 createDraft(sessionId: string, overrides: Partial<Parameters<typeof service.createMission>[0]> = {}): Mission {
41
+ return service.createMission({
42
+ title: 'Smoke',
43
+ goal: 'Do the thing',
44
+ rootSessionId: sessionId,
45
+ agentIds: ['a1'],
46
+ ...overrides,
47
+ })
48
+ }
49
+
50
+ describe('mission-service: lifecycle', () => {
51
+ it('creates a draft mission with zeroed usage', () => {
52
+ const m = createDraft('svc_s_1')
53
+ assert.equal(m.status, 'draft')
54
+ assert.equal(m.usage.usdSpent, 0)
55
+ assert.equal(m.usage.turnsRun, 0)
56
+ assert.equal(m.usage.startedAt, null)
57
+ assert.deepEqual(m.budget.warnAtFractions, [0.5, 0.8, 0.95])
58
+ })
59
+
60
+ it('startMission transitions draft to running, sets startedAt, records milestone', () => {
61
+ const m = createDraft('svc_s_2')
62
+ const started = service.startMission(m.id)
63
+ assert.equal(started?.status, 'running')
64
+ assert.ok((started?.usage.startedAt ?? 0) > 0)
65
+ assert.equal(started?.milestones[0]?.kind, 'started')
66
+ })
67
+
68
+ it('pauseMission transitions running to paused', () => {
69
+ const m = createDraft('svc_s_3')
70
+ service.startMission(m.id)
71
+ const paused = service.pauseMission(m.id, 'checking in')
72
+ assert.equal(paused?.status, 'paused')
73
+ })
74
+
75
+ it('cancelMission records endReason and stops', () => {
76
+ const m = createDraft('svc_s_4')
77
+ service.startMission(m.id)
78
+ const cancelled = service.cancelMission(m.id, 'user stop')
79
+ assert.equal(cancelled?.status, 'cancelled')
80
+ assert.equal(cancelled?.endReason, 'user stop')
81
+ assert.ok((cancelled?.endedAt ?? 0) > 0)
82
+ })
83
+
84
+ it('completeMission transitions to completed', () => {
85
+ const m = createDraft('svc_s_5')
86
+ service.startMission(m.id)
87
+ const done = service.completeMission(m.id, 'goal met')
88
+ assert.equal(done?.status, 'completed')
89
+ })
90
+ })
91
+
92
+ describe('mission-service: budget evaluation', () => {
93
+ it('allows when under all caps', () => {
94
+ const m = createDraft('svc_b_1', { budget: { maxUsd: 1, maxTokens: 1000, maxTurns: 5 } })
95
+ const started = service.startMission(m.id)!
96
+ const verdict = service.evaluateMissionBudget(started)
97
+ assert.equal(verdict.allow, true)
98
+ })
99
+
100
+ it('denies when USD cap is hit', () => {
101
+ const m = createDraft('svc_b_2', { budget: { maxUsd: 0.1 } })
102
+ service.startMission(m.id)
103
+ service.recordTurnUsage(m.id, { usdDelta: 0.15, turnsDelta: 1 })
104
+ const latest = repo.getMission(m.id)!
105
+ const verdict = service.evaluateMissionBudget(latest)
106
+ assert.equal(verdict.allow, false)
107
+ assert.equal(verdict.hitCap, 'usd')
108
+ })
109
+
110
+ it('denies when max turns is reached', () => {
111
+ const m = createDraft('svc_b_3', { budget: { maxTurns: 2 } })
112
+ service.startMission(m.id)
113
+ service.recordTurnUsage(m.id, { turnsDelta: 2 })
114
+ const latest = repo.getMission(m.id)!
115
+ const verdict = service.evaluateMissionBudget(latest)
116
+ assert.equal(verdict.allow, false)
117
+ assert.equal(verdict.hitCap, 'turns')
118
+ })
119
+
120
+ it('denies when wallclock budget is exceeded', () => {
121
+ const m = createDraft('svc_b_4', { budget: { maxWallclockSec: 60 } })
122
+ service.startMission(m.id)
123
+ const now = Date.now()
124
+ const fakeFuture = now + 61_000
125
+ const latest = repo.getMission(m.id)!
126
+ const verdict = service.evaluateMissionBudget(latest, fakeFuture)
127
+ assert.equal(verdict.allow, false)
128
+ assert.equal(verdict.hitCap, 'wallclock')
129
+ })
130
+
131
+ it('fires a budget_warn milestone at the crossed threshold', () => {
132
+ const m = createDraft('svc_b_5', { budget: { maxTurns: 10, warnAtFractions: [0.5] } })
133
+ service.startMission(m.id)
134
+ service.recordTurnUsage(m.id, { turnsDelta: 5 })
135
+ const latest = repo.getMission(m.id)!
136
+ assert.ok(latest.usage.warnFractionsHit.includes(0.5))
137
+ const warn = latest.milestones.find((ms) => ms.kind === 'budget_warn')
138
+ assert.ok(warn, 'expected a budget_warn milestone')
139
+ })
140
+
141
+ it('does not fire the same warn threshold twice', () => {
142
+ const m = createDraft('svc_b_6', { budget: { maxTurns: 10, warnAtFractions: [0.5] } })
143
+ service.startMission(m.id)
144
+ service.recordTurnUsage(m.id, { turnsDelta: 5 })
145
+ service.recordTurnUsage(m.id, { turnsDelta: 1 })
146
+ const latest = repo.getMission(m.id)!
147
+ const warnCount = latest.milestones.filter((ms) => ms.kind === 'budget_warn').length
148
+ assert.equal(warnCount, 1)
149
+ })
150
+ })
151
+
152
+ describe('mission-budget-hook', () => {
153
+ it('allows when missionId is null', () => {
154
+ const verdict = hook.checkMissionBudgetForSession(null)
155
+ assert.equal(verdict.allow, true)
156
+ })
157
+
158
+ it('allows when mission does not exist', () => {
159
+ const verdict = hook.checkMissionBudgetForSession('does-not-exist')
160
+ assert.equal(verdict.allow, true)
161
+ })
162
+
163
+ it('denies when mission is in draft status', () => {
164
+ const m = createDraft('svc_h_1')
165
+ const verdict = hook.checkMissionBudgetForSession(m.id)
166
+ assert.equal(verdict.allow, false)
167
+ })
168
+
169
+ it('allows when mission is running and under budget', () => {
170
+ const m = createDraft('svc_h_2', { budget: { maxTurns: 10 } })
171
+ service.startMission(m.id)
172
+ const verdict = hook.checkMissionBudgetForSession(m.id)
173
+ assert.equal(verdict.allow, true)
174
+ })
175
+
176
+ it('transitions mission to budget_exhausted when cap is hit', () => {
177
+ const m = createDraft('svc_h_3', { budget: { maxTurns: 1 } })
178
+ service.startMission(m.id)
179
+ service.recordTurnUsage(m.id, { turnsDelta: 1 })
180
+ const verdict = hook.checkMissionBudgetForSession(m.id)
181
+ assert.equal(verdict.allow, false)
182
+ const latest = repo.getMission(m.id)!
183
+ assert.equal(latest.status, 'budget_exhausted')
184
+ assert.ok(latest.endedAt)
185
+ })
186
+
187
+ it('session-to-mission map tracks running mission', () => {
188
+ const m = createDraft('svc_map_1')
189
+ service.startMission(m.id)
190
+ const resolved = service.getMissionIdForSession('svc_map_1')
191
+ assert.equal(resolved, m.id)
192
+ })
193
+
194
+ it('session-to-mission map clears after mission ends', () => {
195
+ const m = createDraft('svc_map_2')
196
+ service.startMission(m.id)
197
+ service.completeMission(m.id)
198
+ const resolved = service.getMissionIdForSession('svc_map_2')
199
+ assert.equal(resolved, null)
200
+ })
201
+ })