@swarmclawai/swarmclaw 1.7.3 → 1.8.1
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 +20 -0
- package/next.config.ts +38 -8
- package/package.json +2 -2
- package/scripts/run-next-build.mjs +51 -3
- package/src/app/api/artifacts/route.ts +15 -0
- package/src/app/api/clawhub/install/route.ts +4 -4
- package/src/app/api/dirs/route.ts +8 -5
- package/src/app/api/files/open/route.ts +3 -3
- package/src/app/api/files/serve/route.ts +2 -2
- package/src/app/api/operations/pulse/route.ts +9 -0
- package/src/app/api/runs/[id]/brief/route.ts +12 -0
- package/src/app/api/runs/[id]/events/route.ts +4 -13
- package/src/app/api/runs/[id]/route.ts +2 -6
- package/src/app/api/runs/route.ts +3 -43
- package/src/app/api/s/[token]/raw/route.ts +1 -1
- package/src/app/home/page.tsx +11 -1
- package/src/app/missions/page.tsx +182 -3
- package/src/app/s/[token]/page.tsx +173 -48
- package/src/cli/index.js +15 -0
- package/src/cli/spec.js +13 -0
- package/src/components/connectors/connector-list.tsx +36 -20
- package/src/components/evidence/evidence-shelf.tsx +97 -0
- package/src/components/home/home-launchpad.tsx +52 -2
- package/src/components/missions/mission-template-install-dialog.tsx +33 -1
- package/src/components/operations/operations-pulse-panel.tsx +184 -0
- package/src/components/quality/quality-workspace.tsx +34 -6
- package/src/components/runs/run-list.tsx +94 -12
- package/src/lib/connectors/connector-readiness.ts +127 -0
- package/src/lib/server/artifacts/artifact-resolver.test.ts +98 -0
- package/src/lib/server/artifacts/artifact-resolver.ts +241 -0
- package/src/lib/server/operations/operation-pulse.test.ts +108 -0
- package/src/lib/server/operations/operation-pulse.ts +197 -0
- package/src/lib/server/resolve-workspace-path.ts +10 -10
- package/src/lib/server/runs/run-brief.test.ts +92 -0
- package/src/lib/server/runs/run-brief.ts +107 -0
- package/src/lib/server/runs/unified-run-queries.ts +84 -0
- package/src/lib/server/sharing/share-resolver.test.ts +129 -0
- package/src/lib/server/sharing/share-resolver.ts +48 -3
- package/src/types/artifact.ts +28 -0
- package/src/types/index.ts +3 -0
- package/src/types/operations.ts +39 -0
- package/src/types/run-brief.ts +41 -0
|
@@ -5,7 +5,8 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { useNow } from '@/hooks/use-now'
|
|
6
6
|
import { useWs } from '@/hooks/use-ws'
|
|
7
7
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
8
|
-
import
|
|
8
|
+
import { EvidenceShelf } from '@/components/evidence/evidence-shelf'
|
|
9
|
+
import type { EvidenceArtifact, RunBrief, RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
|
|
9
10
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
10
11
|
import { formatElapsed } from '@/lib/format-display'
|
|
11
12
|
import { GroundingPanel } from '@/components/knowledge/grounding-panel'
|
|
@@ -41,7 +42,11 @@ export function RunList() {
|
|
|
41
42
|
const [query, setQuery] = useState('')
|
|
42
43
|
const [selected, setSelected] = useState<SessionRunRecord | null>(null)
|
|
43
44
|
const [selectedEvents, setSelectedEvents] = useState<RunEventRecord[]>([])
|
|
45
|
+
const [selectedBrief, setSelectedBrief] = useState<RunBrief | null>(null)
|
|
46
|
+
const [selectedArtifacts, setSelectedArtifacts] = useState<EvidenceArtifact[]>([])
|
|
44
47
|
const [eventsLoading, setEventsLoading] = useState(false)
|
|
48
|
+
const [briefLoading, setBriefLoading] = useState(false)
|
|
49
|
+
const [artifactsLoading, setArtifactsLoading] = useState(false)
|
|
45
50
|
|
|
46
51
|
const fetchRuns = useCallback(async () => {
|
|
47
52
|
try {
|
|
@@ -61,29 +66,42 @@ export function RunList() {
|
|
|
61
66
|
useEffect(() => {
|
|
62
67
|
if (!selected) return
|
|
63
68
|
let cancelled = false
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
void Promise.allSettled([
|
|
70
|
+
api<RunEventRecord[]>('GET', `/runs/${selected.id}/events?limit=200`),
|
|
71
|
+
api<RunBrief>('GET', `/runs/${selected.id}/brief`),
|
|
72
|
+
api<EvidenceArtifact[]>('GET', `/artifacts?runId=${encodeURIComponent(selected.id)}`),
|
|
73
|
+
]).then(([eventsResult, briefResult, artifactsResult]) => {
|
|
74
|
+
if (cancelled) return
|
|
75
|
+
setSelectedEvents(eventsResult.status === 'fulfilled' && Array.isArray(eventsResult.value) ? eventsResult.value : [])
|
|
76
|
+
setSelectedBrief(briefResult.status === 'fulfilled' ? briefResult.value : null)
|
|
77
|
+
setSelectedArtifacts(artifactsResult.status === 'fulfilled' && Array.isArray(artifactsResult.value) ? artifactsResult.value : [])
|
|
78
|
+
}).finally(() => {
|
|
79
|
+
if (cancelled) return
|
|
80
|
+
setEventsLoading(false)
|
|
81
|
+
setBriefLoading(false)
|
|
82
|
+
setArtifactsLoading(false)
|
|
83
|
+
})
|
|
75
84
|
return () => { cancelled = true }
|
|
76
85
|
}, [selected])
|
|
77
86
|
|
|
78
87
|
const closeSelected = useCallback(() => {
|
|
79
88
|
setSelected(null)
|
|
80
89
|
setSelectedEvents([])
|
|
90
|
+
setSelectedBrief(null)
|
|
91
|
+
setSelectedArtifacts([])
|
|
81
92
|
setEventsLoading(false)
|
|
93
|
+
setBriefLoading(false)
|
|
94
|
+
setArtifactsLoading(false)
|
|
82
95
|
}, [])
|
|
83
96
|
|
|
84
97
|
const openSelected = useCallback((run: SessionRunRecord) => {
|
|
85
98
|
setSelected(run)
|
|
99
|
+
setSelectedEvents([])
|
|
100
|
+
setSelectedBrief(null)
|
|
101
|
+
setSelectedArtifacts([])
|
|
86
102
|
setEventsLoading(true)
|
|
103
|
+
setBriefLoading(true)
|
|
104
|
+
setArtifactsLoading(true)
|
|
87
105
|
}, [])
|
|
88
106
|
|
|
89
107
|
const sources = useMemo(() => {
|
|
@@ -242,6 +260,61 @@ export function RunList() {
|
|
|
242
260
|
<p className="text-[12px] text-text-3/60 font-mono">{selected.id}</p>
|
|
243
261
|
</div>
|
|
244
262
|
|
|
263
|
+
{/* Brief */}
|
|
264
|
+
<div className="mb-6">
|
|
265
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Brief</label>
|
|
266
|
+
{briefLoading ? (
|
|
267
|
+
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] p-4 text-[11px] text-text-3/60">
|
|
268
|
+
Loading brief...
|
|
269
|
+
</div>
|
|
270
|
+
) : selectedBrief ? (
|
|
271
|
+
<div className="rounded-[12px] border border-white/[0.05] bg-white/[0.025] p-4">
|
|
272
|
+
<div className="text-[13px] font-700 text-text">{selectedBrief.title}</div>
|
|
273
|
+
<p className="mt-1 text-[12px] leading-relaxed text-text-3/70">{selectedBrief.objective}</p>
|
|
274
|
+
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
275
|
+
<div className="rounded-[10px] border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
|
276
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/55">Owner</div>
|
|
277
|
+
<div className="mt-1 text-[11px] text-text-2">{selectedBrief.owner ? `${selectedBrief.owner.type}:${selectedBrief.owner.id}` : selectedBrief.source}</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="rounded-[10px] border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
|
280
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/55">Usage</div>
|
|
281
|
+
<div className="mt-1 text-[11px] text-text-2">
|
|
282
|
+
{selectedBrief.usage.inputTokens ?? 0} in / {selectedBrief.usage.outputTokens ?? 0} out
|
|
283
|
+
{selectedBrief.usage.estimatedCost != null ? ` - $${selectedBrief.usage.estimatedCost.toFixed(4)}` : ''}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
{selectedBrief.warnings.length > 0 && (
|
|
288
|
+
<div className="mt-3 flex flex-col gap-1.5">
|
|
289
|
+
{selectedBrief.warnings.map((warning) => (
|
|
290
|
+
<div key={warning} className="rounded-[9px] border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-200">
|
|
291
|
+
{warning}
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
{selectedBrief.timeline.length > 0 && (
|
|
297
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
298
|
+
{selectedBrief.timeline.slice(0, 5).map((item, index) => (
|
|
299
|
+
<span key={`${item.label}:${item.at}:${index}`} className="rounded-full bg-white/[0.05] px-2 py-1 text-[10px] font-700 text-text-3/80">
|
|
300
|
+
{item.label} {new Date(item.at).toLocaleTimeString()}
|
|
301
|
+
</span>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
{selectedBrief.evidence.length > 0 && (
|
|
306
|
+
<div className="mt-3 text-[11px] text-text-3/65">
|
|
307
|
+
{selectedBrief.evidence.length} brief evidence item{selectedBrief.evidence.length === 1 ? '' : 's'} found.
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
) : (
|
|
312
|
+
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] p-4 text-[11px] text-text-3/60">
|
|
313
|
+
No brief available for this run.
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
245
318
|
{/* Timing */}
|
|
246
319
|
<div className="mb-6 space-y-2">
|
|
247
320
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Timing</label>
|
|
@@ -308,6 +381,15 @@ export function RunList() {
|
|
|
308
381
|
</div>
|
|
309
382
|
)}
|
|
310
383
|
|
|
384
|
+
<div className="mb-6">
|
|
385
|
+
<EvidenceShelf
|
|
386
|
+
artifacts={selectedArtifacts}
|
|
387
|
+
loading={artifactsLoading}
|
|
388
|
+
title="Evidence Shelf"
|
|
389
|
+
emptyLabel="No linked artifacts, files, reports, or citations for this run."
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
311
393
|
<div className="mb-2">
|
|
312
394
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Replay</label>
|
|
313
395
|
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] max-h-[260px] overflow-auto">
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Connector } from '@/types'
|
|
2
|
+
|
|
3
|
+
export type ConnectorReadinessState = 'needs_setup' | 'attention' | 'healthy'
|
|
4
|
+
export type ConnectorReadinessCheckStatus = 'ready' | 'warning' | 'error'
|
|
5
|
+
|
|
6
|
+
export interface ConnectorReadinessCheck {
|
|
7
|
+
id: 'credentials' | 'route' | 'pairing' | 'connection' | 'gateway'
|
|
8
|
+
label: string
|
|
9
|
+
status: ConnectorReadinessCheckStatus
|
|
10
|
+
detail: string
|
|
11
|
+
actionLabel?: string | null
|
|
12
|
+
actionHref?: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConnectorReadiness {
|
|
16
|
+
state: ConnectorReadinessState
|
|
17
|
+
summary: string
|
|
18
|
+
checks: ConnectorReadinessCheck[]
|
|
19
|
+
recentError: string | null
|
|
20
|
+
doctorHref: string
|
|
21
|
+
dashboardHref?: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasConnectorCredentials(connector: Connector): boolean {
|
|
25
|
+
return connector.platform === 'whatsapp'
|
|
26
|
+
|| connector.platform === 'openclaw'
|
|
27
|
+
|| connector.platform === 'signal'
|
|
28
|
+
|| connector.platform === 'email'
|
|
29
|
+
|| connector.platform === 'swarmdock'
|
|
30
|
+
|| (connector.platform === 'bluebubbles' && (!!connector.credentialId || !!connector.config?.password))
|
|
31
|
+
|| !!connector.credentialId
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasRoute(connector: Connector): boolean {
|
|
35
|
+
return Boolean(connector.agentId || connector.chatroomId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function connectionLabel(connector: Connector): string {
|
|
39
|
+
if (connector.status === 'running') return 'Connected'
|
|
40
|
+
if (connector.status === 'starting') return 'Starting'
|
|
41
|
+
if (connector.status === 'error') return 'Error'
|
|
42
|
+
return 'Stopped'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function openClawEndpointLabel(connector: Connector): string {
|
|
46
|
+
const wsUrl = typeof connector.config?.wsUrl === 'string' && connector.config.wsUrl.trim()
|
|
47
|
+
? connector.config.wsUrl.trim()
|
|
48
|
+
: 'ws://localhost:18789'
|
|
49
|
+
return `Gateway ${wsUrl}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getConnectorReadiness(connector: Connector): ConnectorReadiness {
|
|
53
|
+
const credentialsReady = hasConnectorCredentials(connector)
|
|
54
|
+
const routeReady = hasRoute(connector)
|
|
55
|
+
const checks: ConnectorReadinessCheck[] = [
|
|
56
|
+
{
|
|
57
|
+
id: 'credentials',
|
|
58
|
+
label: 'Credentials',
|
|
59
|
+
status: credentialsReady ? 'ready' : 'error',
|
|
60
|
+
detail: credentialsReady ? 'Credential path is configured.' : 'Add the token, password, or pairing credential.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'route',
|
|
64
|
+
label: 'Route target',
|
|
65
|
+
status: routeReady ? 'ready' : 'warning',
|
|
66
|
+
detail: routeReady ? 'Inbound messages have an agent or room target.' : 'Choose an agent or chatroom route.',
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
if (connector.qrDataUrl) {
|
|
71
|
+
checks.push({
|
|
72
|
+
id: 'pairing',
|
|
73
|
+
label: 'Pairing',
|
|
74
|
+
status: 'warning',
|
|
75
|
+
detail: 'Pairing is waiting for a QR scan.',
|
|
76
|
+
actionLabel: 'Pair device',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (connector.platform === 'openclaw') {
|
|
81
|
+
checks.push({
|
|
82
|
+
id: 'gateway',
|
|
83
|
+
label: 'OpenClaw Gateway',
|
|
84
|
+
status: connector.status === 'error' ? 'error' : 'ready',
|
|
85
|
+
detail: openClawEndpointLabel(connector),
|
|
86
|
+
actionLabel: 'Dashboard',
|
|
87
|
+
actionHref: connector.agentId
|
|
88
|
+
? `/api/openclaw/dashboard-url?agentId=${encodeURIComponent(connector.agentId)}`
|
|
89
|
+
: null,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
checks.push({
|
|
94
|
+
id: 'connection',
|
|
95
|
+
label: 'Connection',
|
|
96
|
+
status: connector.status === 'running'
|
|
97
|
+
? 'ready'
|
|
98
|
+
: connector.status === 'error'
|
|
99
|
+
? 'error'
|
|
100
|
+
: 'warning',
|
|
101
|
+
detail: connector.lastError || connectionLabel(connector),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const hasError = checks.some((check) => check.status === 'error')
|
|
105
|
+
const hasWarning = checks.some((check) => check.status === 'warning')
|
|
106
|
+
const state: ConnectorReadinessState = hasError || !credentialsReady || !routeReady
|
|
107
|
+
? 'needs_setup'
|
|
108
|
+
: hasWarning
|
|
109
|
+
? 'attention'
|
|
110
|
+
: 'healthy'
|
|
111
|
+
const summary = state === 'healthy'
|
|
112
|
+
? 'Ready for inbound autonomy'
|
|
113
|
+
: state === 'attention'
|
|
114
|
+
? 'Configured, but not fully connected'
|
|
115
|
+
: 'Setup work required before it can run'
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
state,
|
|
119
|
+
summary,
|
|
120
|
+
checks,
|
|
121
|
+
recentError: connector.lastError || null,
|
|
122
|
+
doctorHref: `/api/connectors/${encodeURIComponent(connector.id)}/doctor`,
|
|
123
|
+
dashboardHref: connector.platform === 'openclaw' && connector.agentId
|
|
124
|
+
? `/api/openclaw/dashboard-url?agentId=${encodeURIComponent(connector.agentId)}`
|
|
125
|
+
: null,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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 { test } from 'node:test'
|
|
6
|
+
|
|
7
|
+
import type { BoardTask, KnowledgeCitation, Mission, MissionReport, ProtocolRun, RunEventRecord, SessionRunRecord } from '@/types'
|
|
8
|
+
import type { ShareLink } from '@/lib/server/sharing/share-link-repository'
|
|
9
|
+
|
|
10
|
+
test('buildEvidenceArtifactsFromRecords merges run, task, protocol, mission, and share evidence', async () => {
|
|
11
|
+
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-artifacts-'))
|
|
12
|
+
process.env.ACCESS_KEY = 'test-key'
|
|
13
|
+
process.env.CREDENTIAL_SECRET = 'test-secret-32-characters-long!!'
|
|
14
|
+
|
|
15
|
+
const { buildEvidenceArtifactsFromRecords } = await import('./artifact-resolver')
|
|
16
|
+
const run: SessionRunRecord = {
|
|
17
|
+
id: 'run_1',
|
|
18
|
+
sessionId: 'sess_1',
|
|
19
|
+
source: 'chat',
|
|
20
|
+
internal: false,
|
|
21
|
+
mode: 'direct',
|
|
22
|
+
status: 'completed',
|
|
23
|
+
messagePreview: 'Release QA',
|
|
24
|
+
queuedAt: 1000,
|
|
25
|
+
endedAt: 5000,
|
|
26
|
+
resultPreview: 'QA finished.',
|
|
27
|
+
}
|
|
28
|
+
const citation: KnowledgeCitation = {
|
|
29
|
+
sourceId: 'source_1',
|
|
30
|
+
sourceTitle: 'Runbook',
|
|
31
|
+
sourceKind: 'manual',
|
|
32
|
+
sourceUrl: 'https://example.test/runbook',
|
|
33
|
+
sourceLabel: null,
|
|
34
|
+
chunkId: 'chunk_1',
|
|
35
|
+
chunkIndex: 0,
|
|
36
|
+
chunkCount: 1,
|
|
37
|
+
charStart: 0,
|
|
38
|
+
charEnd: 10,
|
|
39
|
+
sectionLabel: null,
|
|
40
|
+
snippet: 'Attach evidence.',
|
|
41
|
+
whyMatched: null,
|
|
42
|
+
score: 0.8,
|
|
43
|
+
}
|
|
44
|
+
const events: RunEventRecord[] = [{
|
|
45
|
+
id: 'event_1',
|
|
46
|
+
runId: 'run_1',
|
|
47
|
+
sessionId: 'sess_1',
|
|
48
|
+
timestamp: 4000,
|
|
49
|
+
phase: 'event',
|
|
50
|
+
event: { t: 'md', text: 'cited' },
|
|
51
|
+
citations: [citation],
|
|
52
|
+
}]
|
|
53
|
+
const protocolRun = {
|
|
54
|
+
id: 'protocol_1',
|
|
55
|
+
title: 'Protocol',
|
|
56
|
+
templateName: 'Template',
|
|
57
|
+
artifacts: [{ id: 'artifact_1', kind: 'summary', title: 'Summary', content: 'Structured output.', createdAt: 3000 }],
|
|
58
|
+
} as ProtocolRun
|
|
59
|
+
const task = {
|
|
60
|
+
id: 'task_1',
|
|
61
|
+
title: 'Build package',
|
|
62
|
+
description: '',
|
|
63
|
+
status: 'completed',
|
|
64
|
+
agentId: 'agent_1',
|
|
65
|
+
createdAt: 1000,
|
|
66
|
+
updatedAt: 3500,
|
|
67
|
+
completedAt: 3500,
|
|
68
|
+
outputFiles: ['dist/report.md'],
|
|
69
|
+
completionReportPath: 'reports/task.md',
|
|
70
|
+
result: 'Package built.',
|
|
71
|
+
} as BoardTask
|
|
72
|
+
const mission = {
|
|
73
|
+
id: 'mission_1',
|
|
74
|
+
title: 'Mission',
|
|
75
|
+
goal: 'Ship',
|
|
76
|
+
status: 'completed',
|
|
77
|
+
milestones: [{ id: 'ms_1', at: 4500, kind: 'completed', summary: 'Done', evidence: ['run_1'] }],
|
|
78
|
+
} as Mission
|
|
79
|
+
const report = { id: 'report_1', missionId: 'mission_1', title: 'Report', body: 'Launch report.', format: 'markdown', generatedAt: 6000 } as MissionReport
|
|
80
|
+
const share = { id: 'share_1', token: 'token_1', entityType: 'mission', entityId: 'mission_1', label: 'Public report', createdAt: 6500, expiresAt: null, revokedAt: null } as ShareLink
|
|
81
|
+
|
|
82
|
+
const artifacts = buildEvidenceArtifactsFromRecords({
|
|
83
|
+
run,
|
|
84
|
+
runEvents: events,
|
|
85
|
+
protocolRun,
|
|
86
|
+
task,
|
|
87
|
+
mission,
|
|
88
|
+
missionReports: [report],
|
|
89
|
+
shareLinks: [share],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
artifacts.map((artifact) => artifact.kind),
|
|
94
|
+
['share_link', 'mission_report', 'run_result', 'mission_milestone', 'run_citation', 'task_output', 'completion_report', 'task_result', 'protocol_artifact'],
|
|
95
|
+
)
|
|
96
|
+
assert.equal(artifacts.find((artifact) => artifact.kind === 'run_citation')?.url, 'https://example.test/runbook')
|
|
97
|
+
assert.equal(artifacts.find((artifact) => artifact.kind === 'task_output')?.url, '/api/files/serve?path=dist%2Freport.md')
|
|
98
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { getMission, listMissionReports } from '@/lib/server/missions/mission-repository'
|
|
2
|
+
import { loadProtocolRunById } from '@/lib/server/protocols/protocol-queries'
|
|
3
|
+
import { listShareLinks, type ShareLink } from '@/lib/server/sharing/share-link-repository'
|
|
4
|
+
import { loadTask } from '@/lib/server/tasks/task-repository'
|
|
5
|
+
import { getUnifiedRunById, listUnifiedRunEvents } from '@/lib/server/runs/unified-run-queries'
|
|
6
|
+
import type {
|
|
7
|
+
BoardTask,
|
|
8
|
+
EvidenceArtifact,
|
|
9
|
+
KnowledgeCitation,
|
|
10
|
+
Mission,
|
|
11
|
+
MissionReport,
|
|
12
|
+
ProtocolRun,
|
|
13
|
+
RunEventRecord,
|
|
14
|
+
SessionRunRecord,
|
|
15
|
+
} from '@/types'
|
|
16
|
+
|
|
17
|
+
const MAX_PREVIEW = 360
|
|
18
|
+
|
|
19
|
+
function compactText(value: string | null | undefined, maxChars = MAX_PREVIEW): string | null {
|
|
20
|
+
const text = (value || '').split(/\s+/).filter(Boolean).join(' ').trim()
|
|
21
|
+
if (!text) return null
|
|
22
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 1)}...` : text
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fileServeUrl(filePath: string): string {
|
|
26
|
+
return `/api/files/serve?path=${encodeURIComponent(filePath)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addUnique(items: EvidenceArtifact[], item: EvidenceArtifact, seen: Set<string>): void {
|
|
30
|
+
const key = `${item.kind}:${item.id}`
|
|
31
|
+
if (seen.has(key)) return
|
|
32
|
+
seen.add(key)
|
|
33
|
+
items.push(item)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function artifactsForTask(task: BoardTask, seen: Set<string>): EvidenceArtifact[] {
|
|
37
|
+
const items: EvidenceArtifact[] = []
|
|
38
|
+
for (const artifact of task.artifacts || []) {
|
|
39
|
+
addUnique(items, {
|
|
40
|
+
id: `${task.id}:${artifact.filename}`,
|
|
41
|
+
kind: 'task_artifact',
|
|
42
|
+
title: artifact.filename || artifact.type,
|
|
43
|
+
description: `${artifact.type} artifact from task ${task.title}`,
|
|
44
|
+
url: artifact.url,
|
|
45
|
+
createdAt: task.completedAt || task.updatedAt || task.createdAt,
|
|
46
|
+
source: { type: 'task', id: task.id, label: task.title },
|
|
47
|
+
}, seen)
|
|
48
|
+
}
|
|
49
|
+
for (const outputPath of task.outputFiles || []) {
|
|
50
|
+
addUnique(items, {
|
|
51
|
+
id: `${task.id}:output:${outputPath}`,
|
|
52
|
+
kind: 'task_output',
|
|
53
|
+
title: outputPath.split('/').pop() || outputPath,
|
|
54
|
+
description: 'Task output file',
|
|
55
|
+
url: fileServeUrl(outputPath),
|
|
56
|
+
createdAt: task.completedAt || task.updatedAt || task.createdAt,
|
|
57
|
+
source: { type: 'task', id: task.id, label: task.title },
|
|
58
|
+
}, seen)
|
|
59
|
+
}
|
|
60
|
+
if (task.completionReportPath) {
|
|
61
|
+
addUnique(items, {
|
|
62
|
+
id: `${task.id}:completion-report`,
|
|
63
|
+
kind: 'completion_report',
|
|
64
|
+
title: 'Completion report',
|
|
65
|
+
description: task.completionReportPath,
|
|
66
|
+
url: fileServeUrl(task.completionReportPath),
|
|
67
|
+
createdAt: task.completedAt || task.updatedAt || task.createdAt,
|
|
68
|
+
source: { type: 'task', id: task.id, label: task.title },
|
|
69
|
+
}, seen)
|
|
70
|
+
}
|
|
71
|
+
if (task.file) {
|
|
72
|
+
addUnique(items, {
|
|
73
|
+
id: `${task.id}:source-file`,
|
|
74
|
+
kind: 'task_output',
|
|
75
|
+
title: task.file.split('/').pop() || task.file,
|
|
76
|
+
description: 'Linked task file',
|
|
77
|
+
url: fileServeUrl(task.file),
|
|
78
|
+
createdAt: task.updatedAt || task.createdAt,
|
|
79
|
+
source: { type: 'task', id: task.id, label: task.title },
|
|
80
|
+
}, seen)
|
|
81
|
+
}
|
|
82
|
+
if (task.result) {
|
|
83
|
+
addUnique(items, {
|
|
84
|
+
id: `${task.id}:result`,
|
|
85
|
+
kind: 'task_result',
|
|
86
|
+
title: 'Task result',
|
|
87
|
+
preview: compactText(task.result),
|
|
88
|
+
createdAt: task.completedAt || task.updatedAt || task.createdAt,
|
|
89
|
+
source: { type: 'task', id: task.id, label: task.title },
|
|
90
|
+
}, seen)
|
|
91
|
+
}
|
|
92
|
+
return items
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function artifactsForProtocolRun(run: ProtocolRun, seen: Set<string>): EvidenceArtifact[] {
|
|
96
|
+
const items: EvidenceArtifact[] = []
|
|
97
|
+
for (const artifact of run.artifacts || []) {
|
|
98
|
+
addUnique(items, {
|
|
99
|
+
id: artifact.id,
|
|
100
|
+
kind: 'protocol_artifact',
|
|
101
|
+
title: artifact.title || artifact.kind,
|
|
102
|
+
description: artifact.kind,
|
|
103
|
+
preview: compactText(artifact.content),
|
|
104
|
+
createdAt: artifact.createdAt,
|
|
105
|
+
source: { type: 'protocol', id: run.id, label: run.title || run.templateName },
|
|
106
|
+
}, seen)
|
|
107
|
+
}
|
|
108
|
+
return items
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function citationArtifact(run: SessionRunRecord, event: RunEventRecord, citation: KnowledgeCitation): EvidenceArtifact {
|
|
112
|
+
return {
|
|
113
|
+
id: `${run.id}:${citation.sourceId}:${citation.chunkId}:${citation.chunkIndex}`,
|
|
114
|
+
kind: 'run_citation',
|
|
115
|
+
title: citation.sourceTitle || citation.sourceLabel || citation.sourceId,
|
|
116
|
+
description: citation.whyMatched || `Citation ${citation.chunkIndex + 1} of ${citation.chunkCount}`,
|
|
117
|
+
preview: compactText(citation.snippet),
|
|
118
|
+
url: citation.sourceUrl || null,
|
|
119
|
+
createdAt: event.timestamp,
|
|
120
|
+
source: { type: 'run', id: run.id, label: run.messagePreview || run.source },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function artifactsForRun(run: SessionRunRecord, events: RunEventRecord[], seen: Set<string>): EvidenceArtifact[] {
|
|
125
|
+
const items: EvidenceArtifact[] = []
|
|
126
|
+
if (run.resultPreview) {
|
|
127
|
+
addUnique(items, {
|
|
128
|
+
id: `${run.id}:result`,
|
|
129
|
+
kind: 'run_result',
|
|
130
|
+
title: 'Run result',
|
|
131
|
+
preview: compactText(run.resultPreview),
|
|
132
|
+
createdAt: run.endedAt || run.startedAt || run.queuedAt,
|
|
133
|
+
source: { type: 'run', id: run.id, label: run.messagePreview || run.source },
|
|
134
|
+
}, seen)
|
|
135
|
+
}
|
|
136
|
+
if (run.error) {
|
|
137
|
+
addUnique(items, {
|
|
138
|
+
id: `${run.id}:error`,
|
|
139
|
+
kind: 'run_error',
|
|
140
|
+
title: 'Run error',
|
|
141
|
+
preview: compactText(run.error),
|
|
142
|
+
createdAt: run.endedAt || run.startedAt || run.queuedAt,
|
|
143
|
+
source: { type: 'run', id: run.id, label: run.messagePreview || run.source },
|
|
144
|
+
}, seen)
|
|
145
|
+
}
|
|
146
|
+
for (const event of events) {
|
|
147
|
+
for (const citation of [...(event.citations || []), ...(event.retrievalTrace?.hits || [])]) {
|
|
148
|
+
addUnique(items, citationArtifact(run, event, citation), seen)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return items
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function artifactsForMission(mission: Mission, reports: MissionReport[], shareLinks: ShareLink[], seen: Set<string>): EvidenceArtifact[] {
|
|
155
|
+
const items: EvidenceArtifact[] = []
|
|
156
|
+
for (const report of reports) {
|
|
157
|
+
addUnique(items, {
|
|
158
|
+
id: report.id,
|
|
159
|
+
kind: 'mission_report',
|
|
160
|
+
title: report.title,
|
|
161
|
+
description: `${report.format} report`,
|
|
162
|
+
preview: compactText(report.body),
|
|
163
|
+
createdAt: report.generatedAt,
|
|
164
|
+
source: { type: 'mission', id: mission.id, label: mission.title },
|
|
165
|
+
}, seen)
|
|
166
|
+
}
|
|
167
|
+
for (const milestone of mission.milestones || []) {
|
|
168
|
+
if (!milestone.evidence?.length) continue
|
|
169
|
+
addUnique(items, {
|
|
170
|
+
id: milestone.id,
|
|
171
|
+
kind: 'mission_milestone',
|
|
172
|
+
title: milestone.summary,
|
|
173
|
+
description: milestone.kind,
|
|
174
|
+
preview: milestone.evidence.join('\n'),
|
|
175
|
+
createdAt: milestone.at,
|
|
176
|
+
source: { type: 'mission', id: mission.id, label: mission.title },
|
|
177
|
+
}, seen)
|
|
178
|
+
}
|
|
179
|
+
for (const link of shareLinks.filter((entry) => entry.entityType === 'mission' && entry.entityId === mission.id)) {
|
|
180
|
+
addUnique(items, {
|
|
181
|
+
id: link.id,
|
|
182
|
+
kind: 'share_link',
|
|
183
|
+
title: link.label || 'Mission share link',
|
|
184
|
+
description: link.revokedAt ? 'Revoked public share' : 'Active public share',
|
|
185
|
+
href: `/s/${link.token}`,
|
|
186
|
+
createdAt: link.createdAt,
|
|
187
|
+
source: { type: 'share', id: link.id, label: mission.title },
|
|
188
|
+
}, seen)
|
|
189
|
+
}
|
|
190
|
+
return items
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildEvidenceArtifactsFromRecords(input: {
|
|
194
|
+
run?: SessionRunRecord | null
|
|
195
|
+
runEvents?: RunEventRecord[]
|
|
196
|
+
protocolRun?: ProtocolRun | null
|
|
197
|
+
task?: BoardTask | null
|
|
198
|
+
mission?: Mission | null
|
|
199
|
+
missionReports?: MissionReport[]
|
|
200
|
+
shareLinks?: ShareLink[]
|
|
201
|
+
}): EvidenceArtifact[] {
|
|
202
|
+
const seen = new Set<string>()
|
|
203
|
+
const items: EvidenceArtifact[] = []
|
|
204
|
+
if (input.run) {
|
|
205
|
+
for (const item of artifactsForRun(input.run, input.runEvents || [], seen)) items.push(item)
|
|
206
|
+
}
|
|
207
|
+
if (input.protocolRun) {
|
|
208
|
+
for (const item of artifactsForProtocolRun(input.protocolRun, seen)) items.push(item)
|
|
209
|
+
}
|
|
210
|
+
if (input.task) {
|
|
211
|
+
for (const item of artifactsForTask(input.task, seen)) items.push(item)
|
|
212
|
+
}
|
|
213
|
+
if (input.mission) {
|
|
214
|
+
for (const item of artifactsForMission(input.mission, input.missionReports || [], input.shareLinks || [], seen)) items.push(item)
|
|
215
|
+
}
|
|
216
|
+
return items.sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function listEvidenceArtifacts(params: {
|
|
220
|
+
runId?: string | null
|
|
221
|
+
missionId?: string | null
|
|
222
|
+
taskId?: string | null
|
|
223
|
+
mission?: Mission | null
|
|
224
|
+
}): EvidenceArtifact[] {
|
|
225
|
+
const run = params.runId ? getUnifiedRunById(params.runId) : null
|
|
226
|
+
const runEvents = params.runId && run ? listUnifiedRunEvents(params.runId, 300) : []
|
|
227
|
+
const protocolRun = params.runId ? loadProtocolRunById(params.runId) : null
|
|
228
|
+
const protocolTask = protocolRun?.taskId ? loadTask(protocolRun.taskId) : null
|
|
229
|
+
const task = params.taskId ? loadTask(params.taskId) : protocolTask
|
|
230
|
+
const mission = params.mission || (params.missionId ? getMission(params.missionId) : null)
|
|
231
|
+
|
|
232
|
+
return buildEvidenceArtifactsFromRecords({
|
|
233
|
+
run,
|
|
234
|
+
runEvents,
|
|
235
|
+
protocolRun,
|
|
236
|
+
task,
|
|
237
|
+
mission,
|
|
238
|
+
missionReports: params.missionId ? listMissionReports(params.missionId, 20) : [],
|
|
239
|
+
shareLinks: params.missionId ? listShareLinks() : [],
|
|
240
|
+
})
|
|
241
|
+
}
|