@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.
Files changed (42) hide show
  1. package/README.md +20 -0
  2. package/next.config.ts +38 -8
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +51 -3
  5. package/src/app/api/artifacts/route.ts +15 -0
  6. package/src/app/api/clawhub/install/route.ts +4 -4
  7. package/src/app/api/dirs/route.ts +8 -5
  8. package/src/app/api/files/open/route.ts +3 -3
  9. package/src/app/api/files/serve/route.ts +2 -2
  10. package/src/app/api/operations/pulse/route.ts +9 -0
  11. package/src/app/api/runs/[id]/brief/route.ts +12 -0
  12. package/src/app/api/runs/[id]/events/route.ts +4 -13
  13. package/src/app/api/runs/[id]/route.ts +2 -6
  14. package/src/app/api/runs/route.ts +3 -43
  15. package/src/app/api/s/[token]/raw/route.ts +1 -1
  16. package/src/app/home/page.tsx +11 -1
  17. package/src/app/missions/page.tsx +182 -3
  18. package/src/app/s/[token]/page.tsx +173 -48
  19. package/src/cli/index.js +15 -0
  20. package/src/cli/spec.js +13 -0
  21. package/src/components/connectors/connector-list.tsx +36 -20
  22. package/src/components/evidence/evidence-shelf.tsx +97 -0
  23. package/src/components/home/home-launchpad.tsx +52 -2
  24. package/src/components/missions/mission-template-install-dialog.tsx +33 -1
  25. package/src/components/operations/operations-pulse-panel.tsx +184 -0
  26. package/src/components/quality/quality-workspace.tsx +34 -6
  27. package/src/components/runs/run-list.tsx +94 -12
  28. package/src/lib/connectors/connector-readiness.ts +127 -0
  29. package/src/lib/server/artifacts/artifact-resolver.test.ts +98 -0
  30. package/src/lib/server/artifacts/artifact-resolver.ts +241 -0
  31. package/src/lib/server/operations/operation-pulse.test.ts +108 -0
  32. package/src/lib/server/operations/operation-pulse.ts +197 -0
  33. package/src/lib/server/resolve-workspace-path.ts +10 -10
  34. package/src/lib/server/runs/run-brief.test.ts +92 -0
  35. package/src/lib/server/runs/run-brief.ts +107 -0
  36. package/src/lib/server/runs/unified-run-queries.ts +84 -0
  37. package/src/lib/server/sharing/share-resolver.test.ts +129 -0
  38. package/src/lib/server/sharing/share-resolver.ts +48 -3
  39. package/src/types/artifact.ts +28 -0
  40. package/src/types/index.ts +3 -0
  41. package/src/types/operations.ts +39 -0
  42. 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 type { RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
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
- api<RunEventRecord[]>('GET', `/runs/${selected.id}/events?limit=200`)
65
- .then((events) => {
66
- if (cancelled) return
67
- setSelectedEvents(Array.isArray(events) ? events : [])
68
- })
69
- .catch(() => {
70
- if (!cancelled) setSelectedEvents([])
71
- })
72
- .finally(() => {
73
- if (!cancelled) setEventsLoading(false)
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
+ }