@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.
- package/README.md +15 -0
- package/package.json +1 -1
- package/skills/swarmclaw/SKILL.md +8 -0
- 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/components/mcp-servers/mcp-server-sheet.tsx +22 -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
|
@@ -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
|
+
})
|