@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
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { api } from '@/lib/app/api-client'
|
|
5
|
+
import { MainContent } from '@/components/layout/main-content'
|
|
6
|
+
import { HintTip } from '@/components/shared/hint-tip'
|
|
7
|
+
import { inputClass } from '@/components/shared/form-styles'
|
|
8
|
+
import type { Mission, MissionReport, MissionEvent, Session } from '@/types'
|
|
9
|
+
import { toast } from 'sonner'
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 4_000
|
|
12
|
+
|
|
13
|
+
const STATUS_BADGE: Record<Mission['status'], { label: string; cls: string }> = {
|
|
14
|
+
draft: { label: 'Draft', cls: 'bg-white/[0.05] text-text-3' },
|
|
15
|
+
running: { label: 'Running', cls: 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30' },
|
|
16
|
+
paused: { label: 'Paused', cls: 'bg-amber-500/15 text-amber-300 border border-amber-500/30' },
|
|
17
|
+
completed: { label: 'Completed', cls: 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/30' },
|
|
18
|
+
failed: { label: 'Failed', cls: 'bg-rose-500/15 text-rose-300 border border-rose-500/30' },
|
|
19
|
+
cancelled: { label: 'Cancelled', cls: 'bg-white/[0.06] text-text-3' },
|
|
20
|
+
budget_exhausted: { label: 'Budget exhausted', cls: 'bg-orange-500/15 text-orange-300 border border-orange-500/30' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatUsd(n: number): string {
|
|
24
|
+
return `$${n.toFixed(n < 0.01 ? 4 : 2)}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatDuration(ms: number): string {
|
|
28
|
+
if (ms < 1000) return `${ms}ms`
|
|
29
|
+
const sec = Math.round(ms / 1000)
|
|
30
|
+
if (sec < 60) return `${sec}s`
|
|
31
|
+
const min = Math.round(sec / 60)
|
|
32
|
+
if (min < 60) return `${min}m`
|
|
33
|
+
return `${Math.round((min / 60) * 10) / 10}h`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatTimestamp(at: number | null | undefined): string {
|
|
37
|
+
if (!at) return ''
|
|
38
|
+
try {
|
|
39
|
+
const d = new Date(at)
|
|
40
|
+
return d.toLocaleString()
|
|
41
|
+
} catch {
|
|
42
|
+
return String(at)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface BudgetBarProps {
|
|
47
|
+
label: string
|
|
48
|
+
used: number
|
|
49
|
+
cap: number | null | undefined
|
|
50
|
+
format: (n: number) => string
|
|
51
|
+
hint?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function BudgetBar({ label, used, cap, format, hint }: BudgetBarProps) {
|
|
55
|
+
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0
|
|
56
|
+
const barCls = pct >= 95 ? 'bg-rose-500' : pct >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex flex-col gap-1">
|
|
59
|
+
<div className="flex items-center justify-between text-[11px] text-text-3">
|
|
60
|
+
<span className="inline-flex items-center gap-1">
|
|
61
|
+
{label}
|
|
62
|
+
{hint && <HintTip text={hint} />}
|
|
63
|
+
</span>
|
|
64
|
+
<span>
|
|
65
|
+
{format(used)}
|
|
66
|
+
{cap != null ? ` / ${format(cap)}` : ' (no cap)'}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="relative h-1.5 w-full rounded-full bg-white/[0.04] overflow-hidden">
|
|
70
|
+
<div className={`absolute inset-y-0 left-0 ${barCls} transition-all`} style={{ width: `${pct}%` }} />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface MissionCardProps {
|
|
77
|
+
mission: Mission
|
|
78
|
+
isSelected: boolean
|
|
79
|
+
onSelect: () => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function MissionCard({ mission, isSelected, onSelect }: MissionCardProps) {
|
|
83
|
+
const badge = STATUS_BADGE[mission.status]
|
|
84
|
+
const lastMilestone = mission.milestones.at(-1)
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
onClick={onSelect}
|
|
88
|
+
className={`text-left w-full rounded-[10px] border transition-all px-4 py-3
|
|
89
|
+
${isSelected ? 'border-white/[0.16] bg-raised' : 'border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.02]'}`}
|
|
90
|
+
>
|
|
91
|
+
<div className="flex items-start justify-between gap-2">
|
|
92
|
+
<div className="min-w-0 flex-1">
|
|
93
|
+
<div className="text-[13px] font-600 text-text truncate">{mission.title}</div>
|
|
94
|
+
<div className="text-[11px] text-text-3 mt-0.5 line-clamp-2">{mission.goal}</div>
|
|
95
|
+
</div>
|
|
96
|
+
<span className={`text-[10px] font-600 uppercase tracking-wide px-1.5 py-0.5 rounded ${badge.cls} shrink-0`}>
|
|
97
|
+
{badge.label}
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="mt-2 flex items-center gap-3 text-[10px] text-text-3/70">
|
|
101
|
+
<span>{mission.usage.turnsRun} turns</span>
|
|
102
|
+
{mission.usage.usdSpent > 0 && <span>{formatUsd(mission.usage.usdSpent)}</span>}
|
|
103
|
+
{lastMilestone && (
|
|
104
|
+
<span className="truncate">
|
|
105
|
+
Last: {lastMilestone.summary.slice(0, 60)}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</button>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ControlsProps {
|
|
114
|
+
mission: Mission
|
|
115
|
+
onAction: (action: string, reason?: string) => Promise<void>
|
|
116
|
+
onForceReport: () => Promise<void>
|
|
117
|
+
busy: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function MissionControls({ mission, onAction, onForceReport, busy }: ControlsProps) {
|
|
121
|
+
const btn = 'text-[11px] font-600 px-2.5 py-1 rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed'
|
|
122
|
+
return (
|
|
123
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
124
|
+
{mission.status === 'draft' || mission.status === 'paused' ? (
|
|
125
|
+
<button
|
|
126
|
+
disabled={busy}
|
|
127
|
+
onClick={() => onAction('start')}
|
|
128
|
+
className={`${btn} border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15`}
|
|
129
|
+
>
|
|
130
|
+
{mission.status === 'paused' ? 'Resume' : 'Start'}
|
|
131
|
+
</button>
|
|
132
|
+
) : null}
|
|
133
|
+
{mission.status === 'running' ? (
|
|
134
|
+
<button
|
|
135
|
+
disabled={busy}
|
|
136
|
+
onClick={() => onAction('pause')}
|
|
137
|
+
className={`${btn} border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/15`}
|
|
138
|
+
>
|
|
139
|
+
Pause
|
|
140
|
+
</button>
|
|
141
|
+
) : null}
|
|
142
|
+
{mission.status === 'running' || mission.status === 'paused' ? (
|
|
143
|
+
<button
|
|
144
|
+
disabled={busy}
|
|
145
|
+
onClick={() => onAction('complete', 'User marked complete')}
|
|
146
|
+
className={`${btn} border-indigo-500/30 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/15`}
|
|
147
|
+
>
|
|
148
|
+
Mark complete
|
|
149
|
+
</button>
|
|
150
|
+
) : null}
|
|
151
|
+
{mission.status !== 'completed' && mission.status !== 'cancelled' ? (
|
|
152
|
+
<button
|
|
153
|
+
disabled={busy}
|
|
154
|
+
onClick={() => {
|
|
155
|
+
const reason = window.prompt('Cancel reason (optional)') || undefined
|
|
156
|
+
void onAction('cancel', reason)
|
|
157
|
+
}}
|
|
158
|
+
className={`${btn} border-rose-500/20 bg-rose-500/5 text-rose-300 hover:bg-rose-500/10`}
|
|
159
|
+
>
|
|
160
|
+
Cancel
|
|
161
|
+
</button>
|
|
162
|
+
) : null}
|
|
163
|
+
<button
|
|
164
|
+
disabled={busy}
|
|
165
|
+
onClick={onForceReport}
|
|
166
|
+
className={`${btn} border-white/[0.08] bg-white/[0.03] text-text-3 hover:bg-white/[0.06]`}
|
|
167
|
+
>
|
|
168
|
+
Generate report now
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface CreateDialogProps {
|
|
175
|
+
open: boolean
|
|
176
|
+
sessions: Session[]
|
|
177
|
+
onClose: () => void
|
|
178
|
+
onCreate: (input: {
|
|
179
|
+
title: string
|
|
180
|
+
goal: string
|
|
181
|
+
successCriteria: string[]
|
|
182
|
+
rootSessionId: string
|
|
183
|
+
budget: {
|
|
184
|
+
maxUsd?: number | null
|
|
185
|
+
maxTokens?: number | null
|
|
186
|
+
maxWallclockSec?: number | null
|
|
187
|
+
maxTurns?: number | null
|
|
188
|
+
}
|
|
189
|
+
reportSchedule: { intervalSec: number; format: 'markdown'; enabled: boolean } | null
|
|
190
|
+
}) => Promise<void>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialogProps) {
|
|
194
|
+
const [title, setTitle] = useState('Autonomous mission')
|
|
195
|
+
const [goal, setGoal] = useState('')
|
|
196
|
+
const [criteriaText, setCriteriaText] = useState('')
|
|
197
|
+
const [rootSessionId, setRootSessionId] = useState('')
|
|
198
|
+
const [maxUsd, setMaxUsd] = useState<string>('2')
|
|
199
|
+
const [maxTokens, setMaxTokens] = useState<string>('50000')
|
|
200
|
+
const [maxWallclockSec, setMaxWallclockSec] = useState<string>('28800')
|
|
201
|
+
const [maxTurns, setMaxTurns] = useState<string>('200')
|
|
202
|
+
const [reportsEnabled, setReportsEnabled] = useState(true)
|
|
203
|
+
const [reportIntervalMin, setReportIntervalMin] = useState<string>('60')
|
|
204
|
+
const [busy, setBusy] = useState(false)
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
|
|
208
|
+
}, [sessions, rootSessionId])
|
|
209
|
+
|
|
210
|
+
if (!open) return null
|
|
211
|
+
|
|
212
|
+
const numOrNull = (s: string): number | null => {
|
|
213
|
+
const n = Number.parseFloat(s)
|
|
214
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const submit = async () => {
|
|
218
|
+
if (!title.trim() || !goal.trim()) {
|
|
219
|
+
toast.error('Title and goal are required')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (!rootSessionId) {
|
|
223
|
+
toast.error('Pick a session to drive this mission')
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
setBusy(true)
|
|
227
|
+
try {
|
|
228
|
+
const successCriteria = criteriaText
|
|
229
|
+
.split('\n')
|
|
230
|
+
.map((s) => s.trim())
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
const intervalMin = numOrNull(reportIntervalMin) ?? 60
|
|
233
|
+
await onCreate({
|
|
234
|
+
title: title.trim(),
|
|
235
|
+
goal: goal.trim(),
|
|
236
|
+
successCriteria,
|
|
237
|
+
rootSessionId,
|
|
238
|
+
budget: {
|
|
239
|
+
maxUsd: numOrNull(maxUsd),
|
|
240
|
+
maxTokens: numOrNull(maxTokens),
|
|
241
|
+
maxWallclockSec: numOrNull(maxWallclockSec),
|
|
242
|
+
maxTurns: numOrNull(maxTurns),
|
|
243
|
+
},
|
|
244
|
+
reportSchedule: reportsEnabled
|
|
245
|
+
? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
|
|
246
|
+
: null,
|
|
247
|
+
})
|
|
248
|
+
onClose()
|
|
249
|
+
} catch (error) {
|
|
250
|
+
toast.error(`Create failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
251
|
+
} finally {
|
|
252
|
+
setBusy(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
258
|
+
<div
|
|
259
|
+
className="w-full max-w-lg rounded-[12px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5 max-h-[90vh] overflow-y-auto"
|
|
260
|
+
onClick={(e) => e.stopPropagation()}
|
|
261
|
+
>
|
|
262
|
+
<div className="text-[14px] font-600 text-text mb-1">New autonomous mission</div>
|
|
263
|
+
<div className="text-[11px] text-text-3 mb-4">Hand your agent team a goal. They run through heartbeats until done or budget hits.</div>
|
|
264
|
+
<div className="flex flex-col gap-3">
|
|
265
|
+
<label className="flex flex-col gap-1">
|
|
266
|
+
<span className="text-[11px] text-text-3">Title</span>
|
|
267
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
|
|
268
|
+
</label>
|
|
269
|
+
<label className="flex flex-col gap-1">
|
|
270
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
271
|
+
Goal <HintTip text="A natural-language objective. The team will work on this until budget or success criteria are hit." />
|
|
272
|
+
</span>
|
|
273
|
+
<textarea
|
|
274
|
+
value={goal}
|
|
275
|
+
onChange={(e) => setGoal(e.target.value)}
|
|
276
|
+
rows={3}
|
|
277
|
+
className={`${inputClass} resize-none`}
|
|
278
|
+
placeholder="e.g., Research the top 3 open-source note-taking apps and draft a comparison doc"
|
|
279
|
+
/>
|
|
280
|
+
</label>
|
|
281
|
+
<label className="flex flex-col gap-1">
|
|
282
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
283
|
+
Success criteria <HintTip text="One per line. Used in reports and final verification." />
|
|
284
|
+
</span>
|
|
285
|
+
<textarea
|
|
286
|
+
value={criteriaText}
|
|
287
|
+
onChange={(e) => setCriteriaText(e.target.value)}
|
|
288
|
+
rows={3}
|
|
289
|
+
className={`${inputClass} resize-none`}
|
|
290
|
+
placeholder="File comparison.md exists\nEach app has pros/cons\nSource links are cited"
|
|
291
|
+
/>
|
|
292
|
+
</label>
|
|
293
|
+
<label className="flex flex-col gap-1">
|
|
294
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
295
|
+
Root session <HintTip text="The session whose heartbeat drives this mission. Usually an agent thread." />
|
|
296
|
+
</span>
|
|
297
|
+
<select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
|
|
298
|
+
{sessions.length === 0 && <option value="">No sessions available</option>}
|
|
299
|
+
{sessions.map((s) => (
|
|
300
|
+
<option key={s.id} value={s.id}>
|
|
301
|
+
{s.name || s.id}
|
|
302
|
+
</option>
|
|
303
|
+
))}
|
|
304
|
+
</select>
|
|
305
|
+
</label>
|
|
306
|
+
|
|
307
|
+
<div className="grid grid-cols-2 gap-3">
|
|
308
|
+
<label className="flex flex-col gap-1">
|
|
309
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
310
|
+
Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
|
|
311
|
+
</span>
|
|
312
|
+
<input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
|
|
313
|
+
</label>
|
|
314
|
+
<label className="flex flex-col gap-1">
|
|
315
|
+
<span className="text-[11px] text-text-3">Max tokens</span>
|
|
316
|
+
<input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
317
|
+
</label>
|
|
318
|
+
<label className="flex flex-col gap-1">
|
|
319
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
320
|
+
Max wallclock (sec) <HintTip text="Scheduler aborts the mission after this many seconds of elapsed wallclock time." />
|
|
321
|
+
</span>
|
|
322
|
+
<input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
323
|
+
</label>
|
|
324
|
+
<label className="flex flex-col gap-1">
|
|
325
|
+
<span className="text-[11px] text-text-3">Max turns</span>
|
|
326
|
+
<input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
327
|
+
</label>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
331
|
+
<div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
|
|
332
|
+
<label className="flex items-center gap-2 flex-wrap">
|
|
333
|
+
<input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
|
|
334
|
+
<span className="text-[11px] text-text-3">Send a markdown progress report every</span>
|
|
335
|
+
<input
|
|
336
|
+
disabled={!reportsEnabled}
|
|
337
|
+
value={reportIntervalMin}
|
|
338
|
+
onChange={(e) => setReportIntervalMin(e.target.value)}
|
|
339
|
+
className={`${inputClass} w-16`}
|
|
340
|
+
inputMode="numeric"
|
|
341
|
+
/>
|
|
342
|
+
<span className="text-[11px] text-text-3">minutes</span>
|
|
343
|
+
</label>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
348
|
+
<button
|
|
349
|
+
onClick={onClose}
|
|
350
|
+
className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
|
|
351
|
+
disabled={busy}
|
|
352
|
+
>
|
|
353
|
+
Cancel
|
|
354
|
+
</button>
|
|
355
|
+
<button
|
|
356
|
+
onClick={submit}
|
|
357
|
+
disabled={busy}
|
|
358
|
+
className="text-[12px] font-600 px-3 py-1.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/25 disabled:opacity-40"
|
|
359
|
+
>
|
|
360
|
+
{busy ? 'Creating...' : 'Create mission'}
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
interface DetailProps {
|
|
369
|
+
mission: Mission
|
|
370
|
+
reports: MissionReport[]
|
|
371
|
+
events: MissionEvent[]
|
|
372
|
+
busy: boolean
|
|
373
|
+
onAction: (action: string, reason?: string) => Promise<void>
|
|
374
|
+
onForceReport: () => Promise<void>
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function MissionDetail({ mission, reports, events, busy, onAction, onForceReport }: DetailProps) {
|
|
378
|
+
const [selectedReport, setSelectedReport] = useState<MissionReport | null>(null)
|
|
379
|
+
const wallclockCapMs = mission.budget.maxWallclockSec != null ? mission.budget.maxWallclockSec * 1000 : null
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div className="flex flex-col gap-4 p-4">
|
|
383
|
+
<div>
|
|
384
|
+
<div className="flex items-center gap-2 mb-1">
|
|
385
|
+
<span className={`text-[10px] font-600 uppercase tracking-wide px-1.5 py-0.5 rounded ${STATUS_BADGE[mission.status].cls}`}>
|
|
386
|
+
{STATUS_BADGE[mission.status].label}
|
|
387
|
+
</span>
|
|
388
|
+
<span className="text-[10px] text-text-3/60">Created {formatTimestamp(mission.createdAt)}</span>
|
|
389
|
+
{mission.endedAt && <span className="text-[10px] text-text-3/60">Ended {formatTimestamp(mission.endedAt)}</span>}
|
|
390
|
+
</div>
|
|
391
|
+
<h2 className="text-[15px] font-600 text-text">{mission.title}</h2>
|
|
392
|
+
<p className="text-[12px] text-text-3 mt-1">{mission.goal}</p>
|
|
393
|
+
{mission.endReason && <p className="text-[11px] text-rose-300/80 mt-2">End reason: {mission.endReason}</p>}
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div className="rounded-[10px] border border-white/[0.06] p-4 flex flex-col gap-3">
|
|
397
|
+
<div className="text-[11px] font-600 uppercase tracking-wide text-text-3">Budget</div>
|
|
398
|
+
<BudgetBar label="USD" used={mission.usage.usdSpent} cap={mission.budget.maxUsd} format={formatUsd} />
|
|
399
|
+
<BudgetBar label="Tokens" used={mission.usage.tokensUsed} cap={mission.budget.maxTokens} format={(n) => `${Math.round(n).toLocaleString()}`} />
|
|
400
|
+
<BudgetBar label="Turns" used={mission.usage.turnsRun} cap={mission.budget.maxTurns} format={(n) => String(Math.round(n))} />
|
|
401
|
+
<BudgetBar label="Wallclock" used={mission.usage.wallclockMsElapsed} cap={wallclockCapMs} format={formatDuration} hint="Hard time budget enforced by the scheduler." />
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div>
|
|
405
|
+
<div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Controls</div>
|
|
406
|
+
<MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} busy={busy} />
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
{mission.successCriteria.length > 0 && (
|
|
410
|
+
<div>
|
|
411
|
+
<div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Success criteria</div>
|
|
412
|
+
<ul className="flex flex-col gap-1">
|
|
413
|
+
{mission.successCriteria.map((c, i) => (
|
|
414
|
+
<li key={i} className="text-[12px] text-text flex items-start gap-2">
|
|
415
|
+
<span className="text-text-3/50 mt-[2px]">-</span>
|
|
416
|
+
<span>{c}</span>
|
|
417
|
+
</li>
|
|
418
|
+
))}
|
|
419
|
+
</ul>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
<div>
|
|
424
|
+
<div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Timeline</div>
|
|
425
|
+
{mission.milestones.length === 0 ? (
|
|
426
|
+
<div className="text-[11px] text-text-3/60">No milestones yet.</div>
|
|
427
|
+
) : (
|
|
428
|
+
<div className="flex flex-col gap-1.5 max-h-[240px] overflow-y-auto">
|
|
429
|
+
{[...mission.milestones].reverse().map((ms) => (
|
|
430
|
+
<div key={ms.id} className="text-[11px] flex items-start gap-2">
|
|
431
|
+
<span className="text-text-3/50 font-mono">{new Date(ms.at).toLocaleTimeString()}</span>
|
|
432
|
+
<span className="text-text-3 font-600">{ms.kind}</span>
|
|
433
|
+
<span className="text-text">{ms.summary}</span>
|
|
434
|
+
</div>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
{events.length > 0 && (
|
|
439
|
+
<div className="text-[10px] text-text-3/50 mt-2">{events.length} total events in log</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<div>
|
|
444
|
+
<div className="flex items-center justify-between mb-2">
|
|
445
|
+
<div className="text-[11px] font-600 uppercase tracking-wide text-text-3">Reports ({reports.length})</div>
|
|
446
|
+
</div>
|
|
447
|
+
{reports.length === 0 ? (
|
|
448
|
+
<div className="text-[11px] text-text-3/60">No reports yet. Click "Generate report now" to produce one.</div>
|
|
449
|
+
) : (
|
|
450
|
+
<div className="flex flex-col gap-1">
|
|
451
|
+
{reports.map((r) => (
|
|
452
|
+
<button
|
|
453
|
+
key={r.id}
|
|
454
|
+
onClick={() => setSelectedReport(r)}
|
|
455
|
+
className="text-left text-[11px] text-text-3 px-2 py-1.5 rounded border border-white/[0.04] hover:border-white/[0.12] hover:bg-white/[0.02]"
|
|
456
|
+
>
|
|
457
|
+
<span className="text-text">{r.title}</span>
|
|
458
|
+
<span className="text-text-3/60 ml-2">{formatTimestamp(r.generatedAt)}</span>
|
|
459
|
+
</button>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
{selectedReport && (
|
|
466
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setSelectedReport(null)}>
|
|
467
|
+
<div
|
|
468
|
+
className="w-full max-w-2xl max-h-[80vh] overflow-y-auto rounded-[12px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5"
|
|
469
|
+
onClick={(e) => e.stopPropagation()}
|
|
470
|
+
>
|
|
471
|
+
<div className="flex items-center justify-between mb-3">
|
|
472
|
+
<div className="text-[13px] font-600 text-text">{selectedReport.title}</div>
|
|
473
|
+
<button onClick={() => setSelectedReport(null)} className="text-text-3 text-[12px] hover:text-text">Close</button>
|
|
474
|
+
</div>
|
|
475
|
+
<pre className="text-[12px] text-text-3 whitespace-pre-wrap font-mono leading-relaxed">{selectedReport.body}</pre>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export default function MissionsPage() {
|
|
484
|
+
const [missions, setMissions] = useState<Mission[]>([])
|
|
485
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
486
|
+
const [reports, setReports] = useState<MissionReport[]>([])
|
|
487
|
+
const [events, setEvents] = useState<MissionEvent[]>([])
|
|
488
|
+
const [sessions, setSessions] = useState<Session[]>([])
|
|
489
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
490
|
+
const [busy, setBusy] = useState(false)
|
|
491
|
+
const [loaded, setLoaded] = useState(false)
|
|
492
|
+
|
|
493
|
+
const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
|
|
494
|
+
|
|
495
|
+
const refreshList = useCallback(async () => {
|
|
496
|
+
try {
|
|
497
|
+
const list = await api<Mission[]>('GET', '/missions')
|
|
498
|
+
setMissions(list)
|
|
499
|
+
if (!selectedId && list.length > 0) setSelectedId(list[0].id)
|
|
500
|
+
} catch {
|
|
501
|
+
// swallow poll errors
|
|
502
|
+
} finally {
|
|
503
|
+
setLoaded(true)
|
|
504
|
+
}
|
|
505
|
+
}, [selectedId])
|
|
506
|
+
|
|
507
|
+
const refreshDetail = useCallback(async (id: string) => {
|
|
508
|
+
try {
|
|
509
|
+
const [r, e] = await Promise.all([
|
|
510
|
+
api<MissionReport[]>('GET', `/missions/${id}/reports`),
|
|
511
|
+
api<MissionEvent[]>('GET', `/missions/${id}/events`),
|
|
512
|
+
])
|
|
513
|
+
setReports(r)
|
|
514
|
+
setEvents(e)
|
|
515
|
+
} catch {
|
|
516
|
+
// swallow
|
|
517
|
+
}
|
|
518
|
+
}, [])
|
|
519
|
+
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
void refreshList()
|
|
522
|
+
const timer = setInterval(() => void refreshList(), POLL_MS)
|
|
523
|
+
return () => clearInterval(timer)
|
|
524
|
+
}, [refreshList])
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
if (!selectedId) return
|
|
528
|
+
void refreshDetail(selectedId)
|
|
529
|
+
const timer = setInterval(() => void refreshDetail(selectedId), POLL_MS)
|
|
530
|
+
return () => clearInterval(timer)
|
|
531
|
+
}, [selectedId, refreshDetail])
|
|
532
|
+
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
api<Session[]>('GET', '/chats').then((s) => {
|
|
535
|
+
setSessions(Array.isArray(s) ? s : Object.values(s))
|
|
536
|
+
}).catch(() => setSessions([]))
|
|
537
|
+
}, [createOpen])
|
|
538
|
+
|
|
539
|
+
const handleAction = useCallback(async (action: string, reason?: string) => {
|
|
540
|
+
if (!selectedId) return
|
|
541
|
+
setBusy(true)
|
|
542
|
+
try {
|
|
543
|
+
await api('POST', `/missions/${selectedId}/control`, reason ? { action, reason } : { action })
|
|
544
|
+
await refreshList()
|
|
545
|
+
await refreshDetail(selectedId)
|
|
546
|
+
} catch (error) {
|
|
547
|
+
toast.error(`Action failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
548
|
+
} finally {
|
|
549
|
+
setBusy(false)
|
|
550
|
+
}
|
|
551
|
+
}, [selectedId, refreshList, refreshDetail])
|
|
552
|
+
|
|
553
|
+
const handleForceReport = useCallback(async () => {
|
|
554
|
+
if (!selectedId) return
|
|
555
|
+
setBusy(true)
|
|
556
|
+
try {
|
|
557
|
+
await api('POST', `/missions/${selectedId}/reports`)
|
|
558
|
+
await refreshDetail(selectedId)
|
|
559
|
+
toast.success('Report generated')
|
|
560
|
+
} catch (error) {
|
|
561
|
+
toast.error(`Report failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
562
|
+
} finally {
|
|
563
|
+
setBusy(false)
|
|
564
|
+
}
|
|
565
|
+
}, [selectedId, refreshDetail])
|
|
566
|
+
|
|
567
|
+
const handleCreate = useCallback(async (input: Parameters<CreateDialogProps['onCreate']>[0]) => {
|
|
568
|
+
const created = await api<Mission>('POST', '/missions', input)
|
|
569
|
+
await refreshList()
|
|
570
|
+
setSelectedId(created.id)
|
|
571
|
+
toast.success(`Mission "${created.title}" created`)
|
|
572
|
+
}, [refreshList])
|
|
573
|
+
|
|
574
|
+
return (
|
|
575
|
+
<MainContent>
|
|
576
|
+
<div className="flex-1 flex min-h-0">
|
|
577
|
+
<div className="w-[340px] shrink-0 border-r border-white/[0.06] flex flex-col min-h-0">
|
|
578
|
+
<div className="p-3 border-b border-white/[0.06] flex items-center justify-between">
|
|
579
|
+
<div>
|
|
580
|
+
<div className="text-[13px] font-600">Missions</div>
|
|
581
|
+
<div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
|
|
582
|
+
</div>
|
|
583
|
+
<button
|
|
584
|
+
onClick={() => setCreateOpen(true)}
|
|
585
|
+
className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
|
|
586
|
+
>
|
|
587
|
+
+ New
|
|
588
|
+
</button>
|
|
589
|
+
</div>
|
|
590
|
+
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
591
|
+
{!loaded ? (
|
|
592
|
+
<div className="text-[11px] text-text-3 p-3">Loading...</div>
|
|
593
|
+
) : missions.length === 0 ? (
|
|
594
|
+
<div className="text-[11px] text-text-3 p-3">
|
|
595
|
+
No missions yet. Click <span className="text-emerald-300 font-600">+ New</span> to start an autonomous run.
|
|
596
|
+
</div>
|
|
597
|
+
) : (
|
|
598
|
+
missions.map((m) => (
|
|
599
|
+
<MissionCard
|
|
600
|
+
key={m.id}
|
|
601
|
+
mission={m}
|
|
602
|
+
isSelected={selectedId === m.id}
|
|
603
|
+
onSelect={() => setSelectedId(m.id)}
|
|
604
|
+
/>
|
|
605
|
+
))
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
610
|
+
{selected ? (
|
|
611
|
+
<MissionDetail
|
|
612
|
+
mission={selected}
|
|
613
|
+
reports={reports}
|
|
614
|
+
events={events}
|
|
615
|
+
busy={busy}
|
|
616
|
+
onAction={handleAction}
|
|
617
|
+
onForceReport={handleForceReport}
|
|
618
|
+
/>
|
|
619
|
+
) : (
|
|
620
|
+
<div className="flex items-center justify-center h-full text-text-3 text-[12px]">
|
|
621
|
+
{loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<CreateMissionDialog
|
|
628
|
+
open={createOpen}
|
|
629
|
+
sessions={sessions}
|
|
630
|
+
onClose={() => setCreateOpen(false)}
|
|
631
|
+
onCreate={handleCreate}
|
|
632
|
+
/>
|
|
633
|
+
</MainContent>
|
|
634
|
+
)
|
|
635
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -803,6 +803,21 @@ const COMMAND_GROUPS = [
|
|
|
803
803
|
cmd('delete', 'DELETE', '/goals/:id', 'Delete a goal'),
|
|
804
804
|
],
|
|
805
805
|
},
|
|
806
|
+
{
|
|
807
|
+
name: 'missions',
|
|
808
|
+
description: 'Manage autonomous missions',
|
|
809
|
+
commands: [
|
|
810
|
+
cmd('list', 'GET', '/missions', 'List autonomous missions'),
|
|
811
|
+
cmd('get', 'GET', '/missions/:id', 'Get a mission by id'),
|
|
812
|
+
cmd('create', 'POST', '/missions', 'Create an autonomous mission', { expectsJsonBody: true }),
|
|
813
|
+
cmd('update', 'PUT', '/missions/:id', 'Update a mission', { expectsJsonBody: true }),
|
|
814
|
+
cmd('delete', 'DELETE', '/missions/:id', 'Delete a mission'),
|
|
815
|
+
cmd('control', 'POST', '/missions/:id/control', 'Start, pause, resume, cancel, complete, or fail a mission', { expectsJsonBody: true }),
|
|
816
|
+
cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
|
|
817
|
+
cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
|
|
818
|
+
cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
|
|
819
|
+
],
|
|
820
|
+
},
|
|
806
821
|
{
|
|
807
822
|
name: 'swarmfeed',
|
|
808
823
|
description: 'SwarmFeed social network',
|
package/src/cli/spec.js
CHANGED
|
@@ -559,6 +559,20 @@ const COMMAND_GROUPS = {
|
|
|
559
559
|
delete: { description: 'Delete a goal', method: 'DELETE', path: '/goals/:id', params: ['id'] },
|
|
560
560
|
},
|
|
561
561
|
},
|
|
562
|
+
missions: {
|
|
563
|
+
description: 'Manage autonomous missions',
|
|
564
|
+
commands: {
|
|
565
|
+
list: { description: 'List autonomous missions', method: 'GET', path: '/missions' },
|
|
566
|
+
get: { description: 'Get a mission by id', method: 'GET', path: '/missions/:id', params: ['id'] },
|
|
567
|
+
create: { description: 'Create an autonomous mission', method: 'POST', path: '/missions' },
|
|
568
|
+
update: { description: 'Update a mission', method: 'PUT', path: '/missions/:id', params: ['id'], body: true },
|
|
569
|
+
delete: { description: 'Delete a mission', method: 'DELETE', path: '/missions/:id', params: ['id'] },
|
|
570
|
+
control: { description: 'Start, pause, resume, cancel, complete, or fail a mission', method: 'POST', path: '/missions/:id/control', params: ['id'] },
|
|
571
|
+
reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
|
|
572
|
+
'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
|
|
573
|
+
events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
|
|
574
|
+
},
|
|
575
|
+
},
|
|
562
576
|
}
|
|
563
577
|
|
|
564
578
|
const GROUP_NAMES = Object.keys(COMMAND_GROUPS)
|