@swarmclawai/swarmclaw 1.5.53 → 1.5.55
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 +17 -3
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +14 -2
- package/src/app/api/agents/agents-route.test.ts +65 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
- package/src/app/api/chatrooms/route.ts +3 -0
- package/src/app/api/missions/[id]/control/route.ts +21 -0
- package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
- package/src/app/api/missions/templates/route.ts +8 -0
- package/src/app/api/tasks/[id]/route.ts +11 -1
- package/src/app/api/tasks/tasks-route.test.ts +81 -0
- package/src/app/api/webhooks/[id]/route.ts +18 -15
- package/src/app/missions/page.tsx +135 -22
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/missions/mission-edit-sheet.tsx +319 -0
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +283 -0
- package/src/lib/server/agents/agent-service.ts +10 -2
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
- package/src/lib/server/agents/main-agent-loop.ts +111 -4
- package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
- package/src/lib/server/chat-execution/message-classifier.ts +11 -7
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
- package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
- package/src/lib/server/chat-execution/response-completeness.ts +11 -3
- package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
- package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
- package/src/lib/server/chats/chat-session-service.ts +11 -0
- package/src/lib/server/connectors/email.test.ts +64 -0
- package/src/lib/server/connectors/email.ts +35 -6
- package/src/lib/server/connectors/response-media.ts +1 -0
- package/src/lib/server/daemon/daemon-runtime.ts +31 -19
- package/src/lib/server/memory/memory-db.test.ts +8 -0
- package/src/lib/server/memory/memory-db.ts +1 -1
- package/src/lib/server/missions/mission-service.ts +47 -1
- package/src/lib/server/missions/mission-templates.test.ts +208 -0
- package/src/lib/server/missions/mission-templates.ts +186 -0
- package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
- package/src/lib/server/storage-normalization.ts +6 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-validation.test.ts +30 -0
- package/src/lib/server/tasks/task-validation.ts +21 -2
- package/src/lib/server/working-state/normalization.ts +5 -1
- package/src/lib/validation/schemas.ts +40 -0
- package/src/types/mission.ts +27 -0
|
@@ -5,7 +5,13 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { MainContent } from '@/components/layout/main-content'
|
|
6
6
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
7
7
|
import { inputClass } from '@/components/shared/form-styles'
|
|
8
|
-
import
|
|
8
|
+
import { MissionTemplateGallery } from '@/components/missions/mission-template-gallery'
|
|
9
|
+
import {
|
|
10
|
+
MissionTemplateInstallDialog,
|
|
11
|
+
type InstantiateInput,
|
|
12
|
+
} from '@/components/missions/mission-template-install-dialog'
|
|
13
|
+
import { MissionEditSheet, isMissionEditable } from '@/components/missions/mission-edit-sheet'
|
|
14
|
+
import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
|
|
9
15
|
import { toast } from 'sonner'
|
|
10
16
|
|
|
11
17
|
const POLL_MS = 4_000
|
|
@@ -114,11 +120,13 @@ interface ControlsProps {
|
|
|
114
120
|
mission: Mission
|
|
115
121
|
onAction: (action: string, reason?: string) => Promise<void>
|
|
116
122
|
onForceReport: () => Promise<void>
|
|
123
|
+
onEdit: () => void
|
|
117
124
|
busy: boolean
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
function MissionControls({ mission, onAction, onForceReport, busy }: ControlsProps) {
|
|
127
|
+
function MissionControls({ mission, onAction, onForceReport, onEdit, busy }: ControlsProps) {
|
|
121
128
|
const btn = 'text-[11px] font-600 px-2.5 py-1 rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed'
|
|
129
|
+
const editable = isMissionEditable(mission.status)
|
|
122
130
|
return (
|
|
123
131
|
<div className="flex flex-wrap items-center gap-2">
|
|
124
132
|
{mission.status === 'draft' || mission.status === 'paused' ? (
|
|
@@ -148,6 +156,15 @@ function MissionControls({ mission, onAction, onForceReport, busy }: ControlsPro
|
|
|
148
156
|
Mark complete
|
|
149
157
|
</button>
|
|
150
158
|
) : null}
|
|
159
|
+
{editable ? (
|
|
160
|
+
<button
|
|
161
|
+
disabled={busy}
|
|
162
|
+
onClick={onEdit}
|
|
163
|
+
className={`${btn} border-white/[0.12] bg-white/[0.04] text-text hover:bg-white/[0.08]`}
|
|
164
|
+
>
|
|
165
|
+
Edit
|
|
166
|
+
</button>
|
|
167
|
+
) : null}
|
|
151
168
|
{mission.status !== 'completed' && mission.status !== 'cancelled' ? (
|
|
152
169
|
<button
|
|
153
170
|
disabled={busy}
|
|
@@ -213,6 +230,10 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
|
|
|
213
230
|
const n = Number.parseFloat(s)
|
|
214
231
|
return Number.isFinite(n) && n > 0 ? n : null
|
|
215
232
|
}
|
|
233
|
+
const intOrNull = (s: string): number | null => {
|
|
234
|
+
const n = numOrNull(s)
|
|
235
|
+
return n == null ? null : Math.round(n)
|
|
236
|
+
}
|
|
216
237
|
|
|
217
238
|
const submit = async () => {
|
|
218
239
|
if (!title.trim() || !goal.trim()) {
|
|
@@ -237,9 +258,9 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
|
|
|
237
258
|
rootSessionId,
|
|
238
259
|
budget: {
|
|
239
260
|
maxUsd: numOrNull(maxUsd),
|
|
240
|
-
maxTokens:
|
|
241
|
-
maxWallclockSec:
|
|
242
|
-
maxTurns:
|
|
261
|
+
maxTokens: intOrNull(maxTokens),
|
|
262
|
+
maxWallclockSec: intOrNull(maxWallclockSec),
|
|
263
|
+
maxTurns: intOrNull(maxTurns),
|
|
243
264
|
},
|
|
244
265
|
reportSchedule: reportsEnabled
|
|
245
266
|
? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
|
|
@@ -372,9 +393,10 @@ interface DetailProps {
|
|
|
372
393
|
busy: boolean
|
|
373
394
|
onAction: (action: string, reason?: string) => Promise<void>
|
|
374
395
|
onForceReport: () => Promise<void>
|
|
396
|
+
onEdit: () => void
|
|
375
397
|
}
|
|
376
398
|
|
|
377
|
-
function MissionDetail({ mission, reports, events, busy, onAction, onForceReport }: DetailProps) {
|
|
399
|
+
function MissionDetail({ mission, reports, events, busy, onAction, onForceReport, onEdit }: DetailProps) {
|
|
378
400
|
const [selectedReport, setSelectedReport] = useState<MissionReport | null>(null)
|
|
379
401
|
const wallclockCapMs = mission.budget.maxWallclockSec != null ? mission.budget.maxWallclockSec * 1000 : null
|
|
380
402
|
|
|
@@ -403,7 +425,7 @@ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport
|
|
|
403
425
|
|
|
404
426
|
<div>
|
|
405
427
|
<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} />
|
|
428
|
+
<MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} onEdit={onEdit} busy={busy} />
|
|
407
429
|
</div>
|
|
408
430
|
|
|
409
431
|
{mission.successCriteria.length > 0 && (
|
|
@@ -489,6 +511,10 @@ export default function MissionsPage() {
|
|
|
489
511
|
const [createOpen, setCreateOpen] = useState(false)
|
|
490
512
|
const [busy, setBusy] = useState(false)
|
|
491
513
|
const [loaded, setLoaded] = useState(false)
|
|
514
|
+
const [templates, setTemplates] = useState<MissionTemplate[]>([])
|
|
515
|
+
const [galleryOpen, setGalleryOpen] = useState(false)
|
|
516
|
+
const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
|
|
517
|
+
const [editMission, setEditMission] = useState<Mission | null>(null)
|
|
492
518
|
|
|
493
519
|
const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
|
|
494
520
|
|
|
@@ -531,10 +557,18 @@ export default function MissionsPage() {
|
|
|
531
557
|
}, [selectedId, refreshDetail])
|
|
532
558
|
|
|
533
559
|
useEffect(() => {
|
|
534
|
-
api<Session
|
|
535
|
-
setSessions(
|
|
560
|
+
api<Record<string, Session>>('GET', '/chats').then((s) => {
|
|
561
|
+
setSessions(s ? Object.values(s) : [])
|
|
536
562
|
}).catch(() => setSessions([]))
|
|
537
|
-
}, [createOpen])
|
|
563
|
+
}, [createOpen, galleryOpen, installTemplate])
|
|
564
|
+
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
let cancelled = false
|
|
567
|
+
api<MissionTemplate[]>('GET', '/missions/templates')
|
|
568
|
+
.then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
|
|
569
|
+
.catch(() => { if (!cancelled) setTemplates([]) })
|
|
570
|
+
return () => { cancelled = true }
|
|
571
|
+
}, [])
|
|
538
572
|
|
|
539
573
|
const handleAction = useCallback(async (action: string, reason?: string) => {
|
|
540
574
|
if (!selectedId) return
|
|
@@ -571,28 +605,65 @@ export default function MissionsPage() {
|
|
|
571
605
|
toast.success(`Mission "${created.title}" created`)
|
|
572
606
|
}, [refreshList])
|
|
573
607
|
|
|
608
|
+
const handleMissionSaved = useCallback((updated: Mission) => {
|
|
609
|
+
setMissions((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
|
610
|
+
void refreshList()
|
|
611
|
+
}, [refreshList])
|
|
612
|
+
|
|
613
|
+
const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
|
|
614
|
+
const result = await api<{ mission: Mission }>(
|
|
615
|
+
'POST',
|
|
616
|
+
`/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
|
|
617
|
+
input,
|
|
618
|
+
)
|
|
619
|
+
await refreshList()
|
|
620
|
+
setSelectedId(result.mission.id)
|
|
621
|
+
setGalleryOpen(false)
|
|
622
|
+
toast.success(`Mission "${result.mission.title}" installed`)
|
|
623
|
+
}, [refreshList])
|
|
624
|
+
|
|
574
625
|
return (
|
|
575
626
|
<MainContent>
|
|
576
627
|
<div className="flex-1 flex min-h-0">
|
|
577
628
|
<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]
|
|
579
|
-
<div>
|
|
580
|
-
<div
|
|
581
|
-
|
|
629
|
+
<div className="p-3 border-b border-white/[0.06]">
|
|
630
|
+
<div className="flex items-center justify-between mb-2">
|
|
631
|
+
<div>
|
|
632
|
+
<div className="text-[13px] font-600">Missions</div>
|
|
633
|
+
<div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
|
|
634
|
+
</div>
|
|
635
|
+
<button
|
|
636
|
+
onClick={() => setCreateOpen(true)}
|
|
637
|
+
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"
|
|
638
|
+
>
|
|
639
|
+
+ New
|
|
640
|
+
</button>
|
|
582
641
|
</div>
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
642
|
+
{templates.length > 0 && (
|
|
643
|
+
<button
|
|
644
|
+
onClick={() => setGalleryOpen(true)}
|
|
645
|
+
className="w-full text-left text-[11px] font-600 px-2.5 py-1.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text"
|
|
646
|
+
>
|
|
647
|
+
Browse {templates.length} starter templates →
|
|
648
|
+
</button>
|
|
649
|
+
)}
|
|
589
650
|
</div>
|
|
590
651
|
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
591
652
|
{!loaded ? (
|
|
592
653
|
<div className="text-[11px] text-text-3 p-3">Loading...</div>
|
|
593
654
|
) : missions.length === 0 ? (
|
|
594
|
-
<div className="
|
|
595
|
-
|
|
655
|
+
<div className="flex flex-col gap-2 p-3">
|
|
656
|
+
<div className="text-[11px] text-text-3">
|
|
657
|
+
No missions yet. Start from a template or create one from scratch.
|
|
658
|
+
</div>
|
|
659
|
+
{templates.length > 0 && (
|
|
660
|
+
<button
|
|
661
|
+
onClick={() => setGalleryOpen(true)}
|
|
662
|
+
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 self-start"
|
|
663
|
+
>
|
|
664
|
+
Open template gallery
|
|
665
|
+
</button>
|
|
666
|
+
)}
|
|
596
667
|
</div>
|
|
597
668
|
) : (
|
|
598
669
|
missions.map((m) => (
|
|
@@ -615,7 +686,15 @@ export default function MissionsPage() {
|
|
|
615
686
|
busy={busy}
|
|
616
687
|
onAction={handleAction}
|
|
617
688
|
onForceReport={handleForceReport}
|
|
689
|
+
onEdit={() => setEditMission(selected)}
|
|
618
690
|
/>
|
|
691
|
+
) : loaded && missions.length === 0 && templates.length > 0 ? (
|
|
692
|
+
<div className="p-6">
|
|
693
|
+
<MissionTemplateGallery
|
|
694
|
+
templates={templates}
|
|
695
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
696
|
+
/>
|
|
697
|
+
</div>
|
|
619
698
|
) : (
|
|
620
699
|
<div className="flex items-center justify-center h-full text-text-3 text-[12px]">
|
|
621
700
|
{loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
|
|
@@ -630,6 +709,40 @@ export default function MissionsPage() {
|
|
|
630
709
|
onClose={() => setCreateOpen(false)}
|
|
631
710
|
onCreate={handleCreate}
|
|
632
711
|
/>
|
|
712
|
+
|
|
713
|
+
{galleryOpen && (
|
|
714
|
+
<div
|
|
715
|
+
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
716
|
+
onClick={() => setGalleryOpen(false)}
|
|
717
|
+
>
|
|
718
|
+
<div
|
|
719
|
+
className="w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-6"
|
|
720
|
+
onClick={(e) => e.stopPropagation()}
|
|
721
|
+
>
|
|
722
|
+
<div className="flex items-center justify-between mb-4">
|
|
723
|
+
<div className="text-[15px] font-700 text-text">Mission templates</div>
|
|
724
|
+
<button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
|
|
725
|
+
</div>
|
|
726
|
+
<MissionTemplateGallery
|
|
727
|
+
templates={templates}
|
|
728
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
729
|
+
/>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
)}
|
|
733
|
+
|
|
734
|
+
<MissionTemplateInstallDialog
|
|
735
|
+
template={installTemplate}
|
|
736
|
+
sessions={sessions}
|
|
737
|
+
onClose={() => setInstallTemplate(null)}
|
|
738
|
+
onInstall={handleInstallTemplate}
|
|
739
|
+
/>
|
|
740
|
+
|
|
741
|
+
<MissionEditSheet
|
|
742
|
+
mission={editMission}
|
|
743
|
+
onClose={() => setEditMission(null)}
|
|
744
|
+
onSaved={handleMissionSaved}
|
|
745
|
+
/>
|
|
633
746
|
</MainContent>
|
|
634
747
|
)
|
|
635
748
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -817,6 +817,8 @@ const COMMAND_GROUPS = [
|
|
|
817
817
|
cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
|
|
818
818
|
cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
|
|
819
819
|
cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
|
|
820
|
+
cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
|
|
821
|
+
cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
|
|
820
822
|
],
|
|
821
823
|
},
|
|
822
824
|
{
|
package/src/cli/spec.js
CHANGED
|
@@ -571,6 +571,8 @@ const COMMAND_GROUPS = {
|
|
|
571
571
|
reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
|
|
572
572
|
'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
|
|
573
573
|
events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
|
|
574
|
+
templates: { description: 'List built-in mission templates', method: 'GET', path: '/missions/templates' },
|
|
575
|
+
instantiate: { description: 'Create a mission from a template', method: 'POST', path: '/missions/templates/:id/instantiate', params: ['id'], body: true },
|
|
574
576
|
},
|
|
575
577
|
},
|
|
576
578
|
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { api } from '@/lib/app/api-client'
|
|
5
|
+
import { HintTip } from '@/components/shared/hint-tip'
|
|
6
|
+
import { inputClass } from '@/components/shared/form-styles'
|
|
7
|
+
import type { Connector, Mission, MissionReportFormat } from '@/types'
|
|
8
|
+
import { toast } from 'sonner'
|
|
9
|
+
|
|
10
|
+
const EDITABLE_STATUSES: Mission['status'][] = ['draft', 'running', 'paused']
|
|
11
|
+
const REPORT_FORMATS: MissionReportFormat[] = ['markdown', 'slack', 'discord', 'email', 'audio']
|
|
12
|
+
|
|
13
|
+
export function isMissionEditable(status: Mission['status']): boolean {
|
|
14
|
+
return EDITABLE_STATUSES.includes(status)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface EditSheetProps {
|
|
18
|
+
mission: Mission | null
|
|
19
|
+
onClose: () => void
|
|
20
|
+
onSaved: (updated: Mission) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function numOrNull(value: string): number | null {
|
|
24
|
+
const trimmed = value.trim()
|
|
25
|
+
if (!trimmed) return null
|
|
26
|
+
const n = Number.parseFloat(trimmed)
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function intOrNull(value: string): number | null {
|
|
31
|
+
const n = numOrNull(value)
|
|
32
|
+
return n == null ? null : Math.round(n)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderCap(value: number | null | undefined): string {
|
|
36
|
+
return value == null ? '' : String(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function MissionEditSheet({ mission, onClose, onSaved }: EditSheetProps) {
|
|
40
|
+
const [title, setTitle] = useState('')
|
|
41
|
+
const [goal, setGoal] = useState('')
|
|
42
|
+
const [criteriaText, setCriteriaText] = useState('')
|
|
43
|
+
const [maxUsd, setMaxUsd] = useState('')
|
|
44
|
+
const [maxTokens, setMaxTokens] = useState('')
|
|
45
|
+
const [maxWallclockSec, setMaxWallclockSec] = useState('')
|
|
46
|
+
const [maxTurns, setMaxTurns] = useState('')
|
|
47
|
+
const [maxToolCalls, setMaxToolCalls] = useState('')
|
|
48
|
+
const [reportsEnabled, setReportsEnabled] = useState(false)
|
|
49
|
+
const [reportIntervalMin, setReportIntervalMin] = useState('60')
|
|
50
|
+
const [reportFormat, setReportFormat] = useState<MissionReportFormat>('markdown')
|
|
51
|
+
const [reportConnectorIds, setReportConnectorIds] = useState<string[]>([])
|
|
52
|
+
const [connectors, setConnectors] = useState<Connector[]>([])
|
|
53
|
+
const [busy, setBusy] = useState(false)
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!mission) return
|
|
57
|
+
setTitle(mission.title)
|
|
58
|
+
setGoal(mission.goal)
|
|
59
|
+
setCriteriaText(mission.successCriteria.join('\n'))
|
|
60
|
+
setMaxUsd(renderCap(mission.budget.maxUsd))
|
|
61
|
+
setMaxTokens(renderCap(mission.budget.maxTokens))
|
|
62
|
+
setMaxWallclockSec(renderCap(mission.budget.maxWallclockSec))
|
|
63
|
+
setMaxTurns(renderCap(mission.budget.maxTurns))
|
|
64
|
+
setMaxToolCalls(renderCap(mission.budget.maxToolCalls))
|
|
65
|
+
const schedule = mission.reportSchedule
|
|
66
|
+
setReportsEnabled(Boolean(schedule?.enabled))
|
|
67
|
+
setReportIntervalMin(schedule ? String(Math.max(1, Math.round(schedule.intervalSec / 60))) : '60')
|
|
68
|
+
setReportFormat(schedule?.format ?? 'markdown')
|
|
69
|
+
setReportConnectorIds(mission.reportConnectorIds)
|
|
70
|
+
}, [mission])
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!mission) return
|
|
74
|
+
let cancelled = false
|
|
75
|
+
api<Record<string, Connector>>('GET', '/connectors')
|
|
76
|
+
.then((map) => {
|
|
77
|
+
if (!cancelled) setConnectors(map ? Object.values(map) : [])
|
|
78
|
+
})
|
|
79
|
+
.catch(() => { if (!cancelled) setConnectors([]) })
|
|
80
|
+
return () => { cancelled = true }
|
|
81
|
+
}, [mission])
|
|
82
|
+
|
|
83
|
+
const canEdit = useMemo(() => (mission ? isMissionEditable(mission.status) : false), [mission])
|
|
84
|
+
|
|
85
|
+
if (!mission) return null
|
|
86
|
+
|
|
87
|
+
const toggleConnector = (id: string) => {
|
|
88
|
+
setReportConnectorIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const submit = async () => {
|
|
92
|
+
if (!title.trim() || !goal.trim()) {
|
|
93
|
+
toast.error('Title and goal are required')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const intervalMin = Number.parseFloat(reportIntervalMin)
|
|
97
|
+
if (reportsEnabled && (!Number.isFinite(intervalMin) || intervalMin <= 0)) {
|
|
98
|
+
toast.error('Report interval must be a positive number')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
setBusy(true)
|
|
102
|
+
try {
|
|
103
|
+
const successCriteria = criteriaText
|
|
104
|
+
.split('\n')
|
|
105
|
+
.map((s) => s.trim())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
const payload = {
|
|
108
|
+
title: title.trim(),
|
|
109
|
+
goal: goal.trim(),
|
|
110
|
+
successCriteria,
|
|
111
|
+
budget: {
|
|
112
|
+
maxUsd: numOrNull(maxUsd),
|
|
113
|
+
maxTokens: intOrNull(maxTokens),
|
|
114
|
+
maxWallclockSec: intOrNull(maxWallclockSec),
|
|
115
|
+
maxTurns: intOrNull(maxTurns),
|
|
116
|
+
maxToolCalls: intOrNull(maxToolCalls),
|
|
117
|
+
},
|
|
118
|
+
reportSchedule: reportsEnabled
|
|
119
|
+
? {
|
|
120
|
+
intervalSec: Math.max(60, Math.round(intervalMin * 60)),
|
|
121
|
+
format: reportFormat,
|
|
122
|
+
enabled: true,
|
|
123
|
+
}
|
|
124
|
+
: null,
|
|
125
|
+
reportConnectorIds,
|
|
126
|
+
}
|
|
127
|
+
const updated = await api<Mission>('PUT', `/missions/${mission.id}`, payload)
|
|
128
|
+
onSaved(updated)
|
|
129
|
+
onClose()
|
|
130
|
+
toast.success('Mission updated')
|
|
131
|
+
} catch (error) {
|
|
132
|
+
toast.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
133
|
+
} finally {
|
|
134
|
+
setBusy(false)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
140
|
+
<div
|
|
141
|
+
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"
|
|
142
|
+
onClick={(e) => e.stopPropagation()}
|
|
143
|
+
>
|
|
144
|
+
<div className="text-[14px] font-600 text-text mb-1">Edit mission</div>
|
|
145
|
+
<div className="text-[11px] text-text-3 mb-4">
|
|
146
|
+
Adjust the goal, budget, or reporting schedule. Changes apply to the next turn.
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{!canEdit && (
|
|
150
|
+
<div className="mb-3 rounded border border-amber-500/30 bg-amber-500/10 text-amber-200 px-3 py-2 text-[11px]">
|
|
151
|
+
This mission is {mission.status}. Only draft, running, or paused missions can be edited.
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<fieldset disabled={!canEdit || busy} className="flex flex-col gap-3">
|
|
156
|
+
<label className="flex flex-col gap-1">
|
|
157
|
+
<span className="text-[11px] text-text-3">Title</span>
|
|
158
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
|
|
159
|
+
</label>
|
|
160
|
+
|
|
161
|
+
<label className="flex flex-col gap-1">
|
|
162
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
163
|
+
Goal <HintTip text="The natural-language objective. The team works toward this until budget or success criteria are hit." />
|
|
164
|
+
</span>
|
|
165
|
+
<textarea
|
|
166
|
+
value={goal}
|
|
167
|
+
onChange={(e) => setGoal(e.target.value)}
|
|
168
|
+
rows={3}
|
|
169
|
+
className={`${inputClass} resize-none`}
|
|
170
|
+
/>
|
|
171
|
+
</label>
|
|
172
|
+
|
|
173
|
+
<label className="flex flex-col gap-1">
|
|
174
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
175
|
+
Success criteria <HintTip text="One per line. Used in reports and final verification." />
|
|
176
|
+
</span>
|
|
177
|
+
<textarea
|
|
178
|
+
value={criteriaText}
|
|
179
|
+
onChange={(e) => setCriteriaText(e.target.value)}
|
|
180
|
+
rows={4}
|
|
181
|
+
className={`${inputClass} resize-none`}
|
|
182
|
+
/>
|
|
183
|
+
</label>
|
|
184
|
+
|
|
185
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
186
|
+
<div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-2">
|
|
187
|
+
Budget <HintTip text="Leave any field blank to remove that cap." />
|
|
188
|
+
</div>
|
|
189
|
+
<div className="grid grid-cols-2 gap-3">
|
|
190
|
+
<label className="flex flex-col gap-1">
|
|
191
|
+
<span className="text-[11px] text-text-3">Max USD</span>
|
|
192
|
+
<input
|
|
193
|
+
value={maxUsd}
|
|
194
|
+
onChange={(e) => setMaxUsd(e.target.value)}
|
|
195
|
+
className={inputClass}
|
|
196
|
+
inputMode="decimal"
|
|
197
|
+
placeholder="No cap"
|
|
198
|
+
/>
|
|
199
|
+
</label>
|
|
200
|
+
<label className="flex flex-col gap-1">
|
|
201
|
+
<span className="text-[11px] text-text-3">Max tokens</span>
|
|
202
|
+
<input
|
|
203
|
+
value={maxTokens}
|
|
204
|
+
onChange={(e) => setMaxTokens(e.target.value)}
|
|
205
|
+
className={inputClass}
|
|
206
|
+
inputMode="numeric"
|
|
207
|
+
placeholder="No cap"
|
|
208
|
+
/>
|
|
209
|
+
</label>
|
|
210
|
+
<label className="flex flex-col gap-1">
|
|
211
|
+
<span className="text-[11px] text-text-3">Max wallclock (sec)</span>
|
|
212
|
+
<input
|
|
213
|
+
value={maxWallclockSec}
|
|
214
|
+
onChange={(e) => setMaxWallclockSec(e.target.value)}
|
|
215
|
+
className={inputClass}
|
|
216
|
+
inputMode="numeric"
|
|
217
|
+
placeholder="No cap"
|
|
218
|
+
/>
|
|
219
|
+
</label>
|
|
220
|
+
<label className="flex flex-col gap-1">
|
|
221
|
+
<span className="text-[11px] text-text-3">Max turns</span>
|
|
222
|
+
<input
|
|
223
|
+
value={maxTurns}
|
|
224
|
+
onChange={(e) => setMaxTurns(e.target.value)}
|
|
225
|
+
className={inputClass}
|
|
226
|
+
inputMode="numeric"
|
|
227
|
+
placeholder="No cap"
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
<label className="flex flex-col gap-1 col-span-2">
|
|
231
|
+
<span className="text-[11px] text-text-3">Max tool calls</span>
|
|
232
|
+
<input
|
|
233
|
+
value={maxToolCalls}
|
|
234
|
+
onChange={(e) => setMaxToolCalls(e.target.value)}
|
|
235
|
+
className={inputClass}
|
|
236
|
+
inputMode="numeric"
|
|
237
|
+
placeholder="No cap"
|
|
238
|
+
/>
|
|
239
|
+
</label>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
244
|
+
<div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-2">Periodic reports</div>
|
|
245
|
+
<label className="flex items-center gap-2 flex-wrap mb-2">
|
|
246
|
+
<input
|
|
247
|
+
type="checkbox"
|
|
248
|
+
checked={reportsEnabled}
|
|
249
|
+
onChange={(e) => setReportsEnabled(e.target.checked)}
|
|
250
|
+
/>
|
|
251
|
+
<span className="text-[11px] text-text-3">Send a report every</span>
|
|
252
|
+
<input
|
|
253
|
+
disabled={!reportsEnabled}
|
|
254
|
+
value={reportIntervalMin}
|
|
255
|
+
onChange={(e) => setReportIntervalMin(e.target.value)}
|
|
256
|
+
className={`${inputClass} w-16`}
|
|
257
|
+
inputMode="numeric"
|
|
258
|
+
/>
|
|
259
|
+
<span className="text-[11px] text-text-3">minutes in</span>
|
|
260
|
+
<select
|
|
261
|
+
disabled={!reportsEnabled}
|
|
262
|
+
value={reportFormat}
|
|
263
|
+
onChange={(e) => setReportFormat(e.target.value as MissionReportFormat)}
|
|
264
|
+
className={`${inputClass} w-28`}
|
|
265
|
+
>
|
|
266
|
+
{REPORT_FORMATS.map((f) => (
|
|
267
|
+
<option key={f} value={f}>{f}</option>
|
|
268
|
+
))}
|
|
269
|
+
</select>
|
|
270
|
+
<span className="text-[11px] text-text-3">format</span>
|
|
271
|
+
</label>
|
|
272
|
+
|
|
273
|
+
{reportsEnabled && (
|
|
274
|
+
<div>
|
|
275
|
+
<div className="text-[11px] text-text-3 mb-1 inline-flex items-center gap-1">
|
|
276
|
+
Deliver via connectors <HintTip text="Reports post to the selected connector channels. Leave empty to keep reports in-app only." />
|
|
277
|
+
</div>
|
|
278
|
+
{connectors.length === 0 ? (
|
|
279
|
+
<div className="text-[11px] text-text-3/60">No connectors configured.</div>
|
|
280
|
+
) : (
|
|
281
|
+
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto">
|
|
282
|
+
{connectors.map((c) => (
|
|
283
|
+
<label key={c.id} className="flex items-center gap-2 text-[11px] text-text">
|
|
284
|
+
<input
|
|
285
|
+
type="checkbox"
|
|
286
|
+
checked={reportConnectorIds.includes(c.id)}
|
|
287
|
+
onChange={() => toggleConnector(c.id)}
|
|
288
|
+
/>
|
|
289
|
+
<span className="font-600">{c.name}</span>
|
|
290
|
+
<span className="text-text-3/60">({c.platform})</span>
|
|
291
|
+
</label>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
</fieldset>
|
|
299
|
+
|
|
300
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
301
|
+
<button
|
|
302
|
+
onClick={onClose}
|
|
303
|
+
className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
|
|
304
|
+
disabled={busy}
|
|
305
|
+
>
|
|
306
|
+
Cancel
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
onClick={submit}
|
|
310
|
+
disabled={busy || !canEdit}
|
|
311
|
+
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"
|
|
312
|
+
>
|
|
313
|
+
{busy ? 'Saving...' : 'Save changes'}
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|