@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
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { buildOperationPulse, normalizeOperationPulseRange } from './operation-pulse'
5
+ import type { ApprovalRequest, Connector, Mission, SessionRunRecord } from '@/types'
6
+
7
+ const now = 10_000_000
8
+
9
+ function run(overrides: Partial<SessionRunRecord>): SessionRunRecord {
10
+ return {
11
+ id: overrides.id || 'run_1',
12
+ sessionId: overrides.sessionId || 'sess_1',
13
+ source: overrides.source || 'chat',
14
+ internal: false,
15
+ mode: 'direct',
16
+ status: overrides.status || 'completed',
17
+ messagePreview: overrides.messagePreview || 'Run',
18
+ queuedAt: overrides.queuedAt ?? now - 1000,
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ function mission(overrides: Partial<Mission>): Mission {
24
+ return {
25
+ id: overrides.id || 'mission_1',
26
+ title: overrides.title || 'Release QA',
27
+ goal: overrides.goal || 'Verify release',
28
+ successCriteria: overrides.successCriteria || [],
29
+ rootSessionId: overrides.rootSessionId || 'sess_1',
30
+ agentIds: overrides.agentIds || [],
31
+ status: overrides.status || 'running',
32
+ budget: overrides.budget || { maxUsd: 10 },
33
+ usage: overrides.usage || {
34
+ usdSpent: 9,
35
+ tokensUsed: 0,
36
+ toolCallsUsed: 0,
37
+ turnsRun: 3,
38
+ wallclockMsElapsed: 0,
39
+ startedAt: now - 60_000,
40
+ lastUpdatedAt: now,
41
+ warnFractionsHit: [],
42
+ },
43
+ milestones: overrides.milestones || [],
44
+ reportSchedule: null,
45
+ reportConnectorIds: [],
46
+ createdAt: overrides.createdAt ?? now - 5000,
47
+ updatedAt: overrides.updatedAt ?? now - 1000,
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ function approval(overrides: Partial<ApprovalRequest>): ApprovalRequest {
53
+ return {
54
+ id: overrides.id || 'approval_1',
55
+ category: overrides.category || 'human_loop',
56
+ title: overrides.title || 'Approve tool use',
57
+ data: {},
58
+ createdAt: overrides.createdAt ?? now - 3000,
59
+ updatedAt: overrides.updatedAt ?? now - 3000,
60
+ status: overrides.status || 'pending',
61
+ ...overrides,
62
+ }
63
+ }
64
+
65
+ function connector(overrides: Partial<Connector>): Connector {
66
+ return {
67
+ id: overrides.id || 'conn_1',
68
+ name: overrides.name || 'Slack',
69
+ platform: overrides.platform || 'slack',
70
+ agentId: overrides.agentId,
71
+ chatroomId: overrides.chatroomId,
72
+ credentialId: overrides.credentialId,
73
+ config: overrides.config || {},
74
+ isEnabled: overrides.isEnabled ?? true,
75
+ status: overrides.status || 'stopped',
76
+ lastError: overrides.lastError,
77
+ createdAt: overrides.createdAt ?? now - 4000,
78
+ updatedAt: overrides.updatedAt ?? now - 2000,
79
+ }
80
+ }
81
+
82
+ describe('operation pulse', () => {
83
+ it('normalizes unsupported ranges to the 24-hour default', () => {
84
+ assert.equal(normalizeOperationPulseRange('7d'), '7d')
85
+ assert.equal(normalizeOperationPulseRange('30d'), '24h')
86
+ assert.equal(normalizeOperationPulseRange(null), '24h')
87
+ })
88
+
89
+ it('combines failed runs, approvals, connector readiness, and budget pressure', () => {
90
+ const pulse = buildOperationPulse({
91
+ range: '24h',
92
+ now,
93
+ missions: [mission({})],
94
+ runs: [run({ id: 'failed', status: 'failed', error: 'bad', endedAt: now - 100 }), run({ id: 'running', status: 'running' })],
95
+ approvals: [approval({ category: 'budget_change' })],
96
+ connectors: [connector({ lastError: 'token rejected' })],
97
+ })
98
+
99
+ assert.equal(pulse.kpis.activeMissions, 1)
100
+ assert.equal(pulse.kpis.runningRuns, 1)
101
+ assert.equal(pulse.kpis.failedRuns, 1)
102
+ assert.equal(pulse.kpis.pendingApprovals, 1)
103
+ assert.equal(pulse.kpis.connectorAttention, 1)
104
+ assert.equal(pulse.kpis.budgetWarnings, 1)
105
+ assert.deepEqual(pulse.actions.slice(0, 3).map((action) => action.severity), ['high', 'high', 'high'])
106
+ assert.ok(pulse.actions.some((action) => action.kind === 'budget' && action.summary.includes('90%')))
107
+ })
108
+ })
@@ -0,0 +1,197 @@
1
+ import { listPendingApprovals } from '@/lib/server/approvals'
2
+ import { getConnectorReadiness } from '@/lib/connectors/connector-readiness'
3
+ import { loadConnectors } from '@/lib/server/connectors/connector-repository'
4
+ import { listMissions } from '@/lib/server/missions/mission-repository'
5
+ import { listUnifiedRuns } from '@/lib/server/runs/unified-run-queries'
6
+ import type {
7
+ ApprovalRequest,
8
+ Connector,
9
+ Mission,
10
+ OperationPulse,
11
+ OperationPulseAction,
12
+ OperationPulseRange,
13
+ OperationPulseSeverity,
14
+ SessionRunRecord,
15
+ } from '@/types'
16
+
17
+ const RANGE_MS: Record<OperationPulseRange, number> = {
18
+ '24h': 24 * 60 * 60_000,
19
+ '7d': 7 * 24 * 60 * 60_000,
20
+ }
21
+
22
+ const SEVERITY_RANK: Record<OperationPulseSeverity, number> = {
23
+ high: 0,
24
+ medium: 1,
25
+ low: 2,
26
+ }
27
+
28
+ const ACTIVE_MISSION_STATUSES = new Set<Mission['status']>(['running', 'paused'])
29
+
30
+ export function normalizeOperationPulseRange(value: string | null | undefined): OperationPulseRange {
31
+ return value === '7d' ? '7d' : '24h'
32
+ }
33
+
34
+ function isWithinWindow(at: number | null | undefined, windowStart: number): boolean {
35
+ return typeof at === 'number' && Number.isFinite(at) && at >= windowStart
36
+ }
37
+
38
+ function runActivityAt(run: SessionRunRecord): number {
39
+ return run.endedAt || run.startedAt || run.queuedAt || 0
40
+ }
41
+
42
+ function budgetFractions(mission: Mission, now: number): Array<{ label: string; fraction: number }> {
43
+ const usage = mission.usage
44
+ const budget = mission.budget
45
+ const rows: Array<{ label: string; fraction: number }> = []
46
+ if (budget.maxUsd && budget.maxUsd > 0) rows.push({ label: 'USD', fraction: usage.usdSpent / budget.maxUsd })
47
+ if (budget.maxTokens && budget.maxTokens > 0) rows.push({ label: 'tokens', fraction: usage.tokensUsed / budget.maxTokens })
48
+ if (budget.maxToolCalls && budget.maxToolCalls > 0) rows.push({ label: 'tool calls', fraction: usage.toolCallsUsed / budget.maxToolCalls })
49
+ if (budget.maxTurns && budget.maxTurns > 0) rows.push({ label: 'turns', fraction: usage.turnsRun / budget.maxTurns })
50
+ if (budget.maxWallclockSec && budget.maxWallclockSec > 0) {
51
+ const elapsed = usage.startedAt ? Math.max(usage.wallclockMsElapsed, now - usage.startedAt) : usage.wallclockMsElapsed
52
+ rows.push({ label: 'wallclock', fraction: elapsed / (budget.maxWallclockSec * 1000) })
53
+ }
54
+ return rows
55
+ }
56
+
57
+ function budgetPressure(mission: Mission, now: number): { label: string; fraction: number } | null {
58
+ const rows = budgetFractions(mission, now).sort((left, right) => right.fraction - left.fraction)
59
+ const top = rows[0]
60
+ return top && top.fraction >= 0.8 ? top : null
61
+ }
62
+
63
+ function percent(value: number): string {
64
+ return `${Math.round(value * 100)}%`
65
+ }
66
+
67
+ function addAction(actions: OperationPulseAction[], action: OperationPulseAction): void {
68
+ actions.push(action)
69
+ }
70
+
71
+ function sortActions(actions: OperationPulseAction[]): OperationPulseAction[] {
72
+ return [...actions]
73
+ .sort((left, right) => {
74
+ const severityDelta = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
75
+ if (severityDelta !== 0) return severityDelta
76
+ return (right.createdAt || 0) - (left.createdAt || 0)
77
+ })
78
+ .slice(0, 12)
79
+ }
80
+
81
+ export function buildOperationPulse(input: {
82
+ range: OperationPulseRange
83
+ now: number
84
+ missions: Mission[]
85
+ runs: SessionRunRecord[]
86
+ approvals: ApprovalRequest[]
87
+ connectors: Connector[]
88
+ }): OperationPulse {
89
+ const windowStart = input.now - RANGE_MS[input.range]
90
+ const windowRuns = input.runs.filter((run) => run.status === 'running' || run.status === 'queued' || isWithinWindow(runActivityAt(run), windowStart))
91
+ const activeMissions = input.missions.filter((mission) => ACTIVE_MISSION_STATUSES.has(mission.status))
92
+ const runningRuns = windowRuns.filter((run) => run.status === 'running' || run.status === 'queued')
93
+ const failedRuns = windowRuns.filter((run) => run.status === 'failed')
94
+ const pendingApprovals = input.approvals.filter((approval) => approval.status === 'pending')
95
+ const connectorReadiness = input.connectors.map((connector) => ({ connector, readiness: getConnectorReadiness(connector) }))
96
+ const connectorAttention = connectorReadiness.filter((item) => item.readiness.state !== 'healthy')
97
+ const budgetWarnings = input.missions
98
+ .map((mission) => ({ mission, pressure: budgetPressure(mission, input.now) }))
99
+ .filter((item) => item.pressure)
100
+
101
+ const actions: OperationPulseAction[] = []
102
+
103
+ for (const run of failedRuns.slice(0, 5)) {
104
+ addAction(actions, {
105
+ id: `run:${run.id}`,
106
+ kind: 'run',
107
+ severity: 'high',
108
+ title: 'Review failed run',
109
+ summary: run.error || run.resultPreview || run.messagePreview || run.id,
110
+ href: '/quality?tab=runs',
111
+ evidence: [run.source, run.ownerType && run.ownerId ? `${run.ownerType}:${run.ownerId}` : 'runtime run'].filter(Boolean) as string[],
112
+ createdAt: runActivityAt(run),
113
+ })
114
+ }
115
+
116
+ for (const approval of pendingApprovals.slice(0, 5)) {
117
+ addAction(actions, {
118
+ id: `approval:${approval.id}`,
119
+ kind: 'approval',
120
+ severity: approval.category === 'budget_change' ? 'high' : 'medium',
121
+ title: 'Resolve pending approval',
122
+ summary: approval.title || approval.description || approval.category,
123
+ href: '/quality?tab=approvals',
124
+ evidence: [approval.category, approval.agentId ? `agent:${approval.agentId}` : '', approval.taskId ? `task:${approval.taskId}` : ''].filter(Boolean),
125
+ createdAt: approval.createdAt,
126
+ })
127
+ }
128
+
129
+ for (const item of connectorAttention.slice(0, 5)) {
130
+ addAction(actions, {
131
+ id: `connector:${item.connector.id}`,
132
+ kind: 'connector',
133
+ severity: item.connector.status === 'error' || item.readiness.recentError ? 'high' : 'medium',
134
+ title: 'Fix connector readiness',
135
+ summary: `${item.connector.name}: ${item.readiness.summary}`,
136
+ href: '/connectors',
137
+ evidence: item.readiness.checks
138
+ .filter((check) => check.status !== 'ready')
139
+ .map((check) => `${check.label}: ${check.detail}`),
140
+ createdAt: item.connector.updatedAt || item.connector.createdAt,
141
+ })
142
+ }
143
+
144
+ for (const item of budgetWarnings.slice(0, 5)) {
145
+ if (!item.pressure) continue
146
+ addAction(actions, {
147
+ id: `budget:${item.mission.id}:${item.pressure.label}`,
148
+ kind: 'budget',
149
+ severity: item.pressure.fraction >= 0.95 ? 'high' : 'medium',
150
+ title: 'Check mission budget',
151
+ summary: `${item.mission.title} is at ${percent(item.pressure.fraction)} of its ${item.pressure.label} budget.`,
152
+ href: `/missions?mission=${encodeURIComponent(item.mission.id)}`,
153
+ evidence: [`status:${item.mission.status}`, `goal:${item.mission.goal}`],
154
+ createdAt: item.mission.updatedAt || item.mission.createdAt,
155
+ })
156
+ }
157
+
158
+ for (const mission of activeMissions.slice(0, 3)) {
159
+ addAction(actions, {
160
+ id: `mission:${mission.id}`,
161
+ kind: 'mission',
162
+ severity: mission.status === 'paused' ? 'medium' : 'low',
163
+ title: mission.status === 'paused' ? 'Resume or close paused mission' : 'Monitor active mission',
164
+ summary: mission.title,
165
+ href: `/missions?mission=${encodeURIComponent(mission.id)}`,
166
+ evidence: [`${mission.usage.turnsRun} turns`, `${mission.usage.tokensUsed.toLocaleString()} tokens`],
167
+ createdAt: mission.updatedAt || mission.createdAt,
168
+ })
169
+ }
170
+
171
+ return {
172
+ generatedAt: input.now,
173
+ range: input.range,
174
+ windowStart,
175
+ kpis: {
176
+ activeMissions: activeMissions.length,
177
+ runningRuns: runningRuns.length,
178
+ failedRuns: failedRuns.length,
179
+ pendingApprovals: pendingApprovals.length,
180
+ connectorAttention: connectorAttention.length,
181
+ budgetWarnings: budgetWarnings.length,
182
+ },
183
+ actions: sortActions(actions),
184
+ }
185
+ }
186
+
187
+ export function getOperationPulse(range: OperationPulseRange): OperationPulse {
188
+ const now = Date.now()
189
+ return buildOperationPulse({
190
+ range,
191
+ now,
192
+ missions: listMissions(),
193
+ runs: listUnifiedRuns({ limit: 500 }),
194
+ approvals: listPendingApprovals(),
195
+ connectors: Object.values(loadConnectors()),
196
+ })
197
+ }
@@ -14,8 +14,8 @@ import path from 'path'
14
14
  */
15
15
  export function resolveWorkspacePath(filePath: string, cwd?: string | null): string | null {
16
16
  // 1. Try as-is
17
- const asIs = path.resolve(filePath)
18
- if (fs.existsSync(asIs)) return asIs
17
+ const asIs = path.resolve(/*turbopackIgnore: true*/ filePath)
18
+ if (fs.existsSync(/*turbopackIgnore: true*/ asIs)) return asIs
19
19
 
20
20
  if (!cwd) return null
21
21
 
@@ -23,20 +23,20 @@ export function resolveWorkspacePath(filePath: string, cwd?: string | null): str
23
23
  if (!stripped) return null
24
24
 
25
25
  // 2. Try relative to cwd
26
- const fromCwd = path.resolve(cwd, stripped)
27
- if (fs.existsSync(fromCwd)) return fromCwd
26
+ const fromCwd = path.resolve(/*turbopackIgnore: true*/ cwd, stripped)
27
+ if (fs.existsSync(/*turbopackIgnore: true*/ fromCwd)) return fromCwd
28
28
 
29
29
  // 3. Try each immediate subdirectory of cwd (project dirs within workspace)
30
30
  try {
31
- for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
31
+ for (const entry of fs.readdirSync(/*turbopackIgnore: true*/ cwd, { withFileTypes: true })) {
32
32
  if (!entry.isDirectory()) continue
33
- const subdir = path.resolve(cwd, entry.name)
33
+ const subdir = path.resolve(/*turbopackIgnore: true*/ cwd, entry.name)
34
34
  // Direct match: cwd/project/stripped
35
- const direct = path.resolve(subdir, stripped)
36
- if (fs.existsSync(direct)) return direct
35
+ const direct = path.resolve(/*turbopackIgnore: true*/ subdir, stripped)
36
+ if (fs.existsSync(/*turbopackIgnore: true*/ direct)) return direct
37
37
  // Next.js route match: cwd/project/src/app/stripped (for route paths like /dashboard/compliance)
38
- const srcApp = path.resolve(subdir, 'src', 'app', stripped)
39
- if (fs.existsSync(srcApp)) return srcApp
38
+ const srcApp = path.resolve(/*turbopackIgnore: true*/ subdir, 'src', 'app', stripped)
39
+ if (fs.existsSync(/*turbopackIgnore: true*/ srcApp)) return srcApp
40
40
  }
41
41
  } catch {
42
42
  // cwd doesn't exist or isn't readable
@@ -0,0 +1,92 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { buildRunBrief } from './run-brief'
5
+ import type { KnowledgeCitation, RunEventRecord, SessionRunRecord } from '@/types'
6
+
7
+ function run(overrides: Partial<SessionRunRecord>): SessionRunRecord {
8
+ return {
9
+ id: overrides.id || 'run_1',
10
+ sessionId: overrides.sessionId || 'sess_1',
11
+ source: overrides.source || 'chat',
12
+ internal: overrides.internal ?? false,
13
+ mode: overrides.mode || 'direct',
14
+ status: overrides.status || 'completed',
15
+ messagePreview: overrides.messagePreview || 'Review release evidence',
16
+ queuedAt: overrides.queuedAt ?? 1000,
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ function citation(overrides: Partial<KnowledgeCitation> = {}): KnowledgeCitation {
22
+ return {
23
+ sourceId: overrides.sourceId || 'source_1',
24
+ sourceTitle: overrides.sourceTitle || 'Release checklist',
25
+ sourceKind: overrides.sourceKind || 'manual',
26
+ sourceUrl: overrides.sourceUrl ?? 'https://example.test/checklist',
27
+ sourceLabel: overrides.sourceLabel ?? null,
28
+ chunkId: overrides.chunkId || 'chunk_1',
29
+ chunkIndex: overrides.chunkIndex ?? 0,
30
+ chunkCount: overrides.chunkCount ?? 2,
31
+ charStart: overrides.charStart ?? 0,
32
+ charEnd: overrides.charEnd ?? 120,
33
+ sectionLabel: overrides.sectionLabel ?? null,
34
+ snippet: overrides.snippet || 'Run release QA and attach the verification evidence.',
35
+ whyMatched: overrides.whyMatched ?? null,
36
+ score: overrides.score ?? 0.92,
37
+ }
38
+ }
39
+
40
+ function event(overrides: Partial<RunEventRecord>): RunEventRecord {
41
+ return {
42
+ id: overrides.id || 'event_1',
43
+ runId: overrides.runId || 'run_1',
44
+ sessionId: overrides.sessionId || 'sess_1',
45
+ timestamp: overrides.timestamp ?? 2000,
46
+ phase: overrides.phase || 'event',
47
+ status: overrides.status,
48
+ summary: overrides.summary,
49
+ event: overrides.event || { t: 'md', text: 'Collected evidence.' },
50
+ citations: overrides.citations,
51
+ retrievalTrace: overrides.retrievalTrace,
52
+ }
53
+ }
54
+
55
+ describe('buildRunBrief', () => {
56
+ it('summarizes objective, timeline, usage, and citations', () => {
57
+ const brief = buildRunBrief(
58
+ run({
59
+ startedAt: 1500,
60
+ endedAt: 3000,
61
+ resultPreview: 'Release QA completed with two attached artifacts.',
62
+ totalInputTokens: 100,
63
+ totalOutputTokens: 50,
64
+ estimatedCost: 0.012,
65
+ retrievalSummary: { citationCount: 1, sourceIds: ['source_1'] },
66
+ }),
67
+ [
68
+ event({ id: 'started', phase: 'status', status: 'running', timestamp: 1500, summary: 'Started' }),
69
+ event({ id: 'done', phase: 'status', status: 'completed', timestamp: 3000, citations: [citation()] }),
70
+ ],
71
+ 4000,
72
+ )
73
+
74
+ assert.equal(brief.runId, 'run_1')
75
+ assert.equal(brief.objective, 'Review release evidence')
76
+ assert.equal(brief.result, 'Release QA completed with two attached artifacts.')
77
+ assert.deepEqual(brief.usage.sourceIds, ['source_1'])
78
+ assert.equal(brief.usage.estimatedCost, 0.012)
79
+ assert.deepEqual(brief.timeline.map((item) => item.status), ['queued', 'running', 'running', 'completed', 'completed'])
80
+ assert.equal(brief.evidence[0].title, 'Release checklist')
81
+ assert.equal(brief.evidence[0].url, 'https://example.test/checklist')
82
+ })
83
+
84
+ it('flags failed and long-running runs for operator review', () => {
85
+ const failed = buildRunBrief(run({ status: 'failed', error: 'Provider timed out.', endedAt: 4000 }), [], 5000)
86
+ assert.ok(failed.warnings.some((warning) => warning.includes('failed')))
87
+ assert.ok(failed.warnings.some((warning) => warning.includes('No replay events')))
88
+
89
+ const running = buildRunBrief(run({ status: 'running', startedAt: 1000 }), [], 1_900_000)
90
+ assert.ok(running.warnings.some((warning) => warning.includes('30 minutes')))
91
+ })
92
+ })
@@ -0,0 +1,107 @@
1
+ import type { RunBrief, RunBriefEvidenceItem, RunBriefTimelineItem, RunEventRecord, SessionRunRecord } from '@/types'
2
+
3
+ const MAX_TEXT = 420
4
+ const MAX_EVIDENCE = 10
5
+ const LONG_RUNNING_MS = 30 * 60_000
6
+
7
+ function compactText(value: string | null | undefined, maxChars = MAX_TEXT): string | null {
8
+ const text = (value || '').split(/\s+/).filter(Boolean).join(' ').trim()
9
+ if (!text) return null
10
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}...` : text
11
+ }
12
+
13
+ function timelineItem(label: string, at: number | undefined, status?: RunBriefTimelineItem['status'], detail?: string | null): RunBriefTimelineItem | null {
14
+ if (!at || !Number.isFinite(at)) return null
15
+ return { label, status, at, detail: detail || null }
16
+ }
17
+
18
+ function eventText(event: RunEventRecord): string | null {
19
+ return compactText(event.summary || event.event.text || event.event.toolOutput || event.event.toolName || event.event.t || '')
20
+ }
21
+
22
+ function collectEvidence(events: RunEventRecord[]): RunBriefEvidenceItem[] {
23
+ const evidence: RunBriefEvidenceItem[] = []
24
+ const seen = new Set<string>()
25
+ for (const event of events) {
26
+ const citations = [
27
+ ...(event.citations || []),
28
+ ...(event.retrievalTrace?.hits || []),
29
+ ]
30
+ for (const citation of citations) {
31
+ const id = `${citation.sourceId}:${citation.chunkId}:${citation.chunkIndex}`
32
+ if (seen.has(id)) continue
33
+ seen.add(id)
34
+ evidence.push({
35
+ id,
36
+ kind: event.retrievalTrace?.hits?.includes(citation) ? 'retrieval' : 'citation',
37
+ title: citation.sourceTitle || citation.sourceLabel || citation.sourceId,
38
+ summary: compactText(citation.snippet || citation.whyMatched || '', 240) || 'Referenced knowledge source.',
39
+ url: citation.sourceUrl || null,
40
+ sourceId: citation.sourceId,
41
+ createdAt: event.timestamp,
42
+ })
43
+ if (evidence.length >= MAX_EVIDENCE) return evidence
44
+ }
45
+ }
46
+ for (const event of events) {
47
+ const summary = eventText(event)
48
+ if (!summary) continue
49
+ evidence.push({
50
+ id: event.id,
51
+ kind: 'event',
52
+ title: event.phase === 'status' ? 'Status event' : 'Replay event',
53
+ summary,
54
+ createdAt: event.timestamp,
55
+ })
56
+ if (evidence.length >= MAX_EVIDENCE) return evidence
57
+ }
58
+ return evidence
59
+ }
60
+
61
+ export function buildRunBrief(run: SessionRunRecord, events: RunEventRecord[], now = Date.now()): RunBrief {
62
+ const timeline = [
63
+ timelineItem('Queued', run.queuedAt, 'queued', run.source),
64
+ timelineItem('Started', run.startedAt, 'running'),
65
+ ...events
66
+ .filter((event) => event.phase === 'status' && event.status)
67
+ .map((event) => timelineItem(event.status || 'status', event.timestamp, event.status, eventText(event)))
68
+ .filter((item): item is RunBriefTimelineItem => Boolean(item)),
69
+ timelineItem('Ended', run.endedAt, run.status),
70
+ ]
71
+ .filter((item): item is RunBriefTimelineItem => Boolean(item))
72
+ .sort((left, right) => left.at - right.at)
73
+
74
+ const warnings: string[] = []
75
+ if (run.status === 'failed') warnings.push('Run failed and needs review before using the result.')
76
+ if (run.status === 'running' && run.startedAt && now - run.startedAt > LONG_RUNNING_MS) {
77
+ warnings.push('Run has been running longer than 30 minutes.')
78
+ }
79
+ if ((run.status === 'completed' || run.status === 'failed') && events.length === 0) {
80
+ warnings.push('No replay events were persisted for this run.')
81
+ }
82
+ if (run.recoveredFromRestart) warnings.push('Run was recovered after a process restart.')
83
+ if (run.interruptedAt) warnings.push(run.interruptedReason || 'Run was interrupted.')
84
+
85
+ return {
86
+ runId: run.id,
87
+ sessionId: run.sessionId,
88
+ title: compactText(run.messagePreview, 120) || run.id,
89
+ objective: compactText(run.messagePreview, 280) || run.mode || run.source,
90
+ status: run.status,
91
+ source: run.source,
92
+ owner: run.ownerType && run.ownerId ? { type: run.ownerType, id: run.ownerId } : null,
93
+ timeline,
94
+ result: compactText(run.resultPreview, 1200),
95
+ error: compactText(run.error, 1200),
96
+ warnings,
97
+ usage: {
98
+ inputTokens: typeof run.totalInputTokens === 'number' ? run.totalInputTokens : null,
99
+ outputTokens: typeof run.totalOutputTokens === 'number' ? run.totalOutputTokens : null,
100
+ estimatedCost: typeof run.estimatedCost === 'number' ? run.estimatedCost : null,
101
+ citationCount: run.retrievalSummary?.citationCount || 0,
102
+ sourceIds: run.retrievalSummary?.sourceIds || [],
103
+ },
104
+ evidence: collectEvidence(events),
105
+ generatedAt: now,
106
+ }
107
+ }
@@ -0,0 +1,84 @@
1
+ import { getRunById, listRunEvents, listRuns } from '@/lib/server/runtime/session-run-manager'
2
+ import {
3
+ listProtocolRunEventsForRun,
4
+ listProtocolRuns,
5
+ loadProtocolRunById,
6
+ } from '@/lib/server/protocols/protocol-queries'
7
+ import {
8
+ protocolEventToRunEventRecord,
9
+ protocolRunToSessionRunRecord,
10
+ } from '@/lib/server/runs/unified-run-records'
11
+ import type {
12
+ ProtocolRun,
13
+ ProtocolRunStatus,
14
+ RunEventRecord,
15
+ SessionRunRecord,
16
+ SessionRunStatus,
17
+ } from '@/types'
18
+
19
+ function protocolStatusesForRunStatus(status?: SessionRunStatus): ProtocolRunStatus[] {
20
+ switch (status) {
21
+ case 'queued':
22
+ return ['draft']
23
+ case 'running':
24
+ return ['running', 'waiting', 'paused']
25
+ case 'completed':
26
+ return ['completed']
27
+ case 'failed':
28
+ return ['failed']
29
+ case 'cancelled':
30
+ return ['cancelled', 'archived']
31
+ default:
32
+ return []
33
+ }
34
+ }
35
+
36
+ function uniqueProtocolRuns(runs: ProtocolRun[]): ProtocolRun[] {
37
+ return Array.from(new Map(runs.map((run) => [run.id, run])).values())
38
+ }
39
+
40
+ export function listUnifiedRuns(params: {
41
+ sessionId?: string
42
+ status?: SessionRunStatus
43
+ limit?: number
44
+ } = {}): SessionRunRecord[] {
45
+ const fetchLimit = Math.max(1, Math.min(1000, Math.trunc(params.limit ?? 200)))
46
+ const sessionRuns = listRuns({ sessionId: params.sessionId, status: params.status, limit: fetchLimit })
47
+ const protocolRuns = params.status
48
+ ? protocolStatusesForRunStatus(params.status).flatMap((protocolStatus) => listProtocolRuns({
49
+ includeSystemOwned: true,
50
+ sessionId: params.sessionId,
51
+ status: protocolStatus,
52
+ limit: fetchLimit,
53
+ }))
54
+ : listProtocolRuns({
55
+ includeSystemOwned: true,
56
+ sessionId: params.sessionId,
57
+ limit: fetchLimit,
58
+ })
59
+
60
+ return [
61
+ ...sessionRuns,
62
+ ...uniqueProtocolRuns(protocolRuns)
63
+ .filter((run) => run.status !== 'archived')
64
+ .map(protocolRunToSessionRunRecord)
65
+ .filter((run) => !params.status || run.status === params.status),
66
+ ]
67
+ .sort((left, right) => (right.queuedAt || 0) - (left.queuedAt || 0))
68
+ .slice(0, fetchLimit)
69
+ }
70
+
71
+ export function getUnifiedRunById(runId: string): SessionRunRecord | null {
72
+ const run = getRunById(runId)
73
+ if (run) return run
74
+ const protocolRun = loadProtocolRunById(runId)
75
+ return protocolRun ? protocolRunToSessionRunRecord(protocolRun) : null
76
+ }
77
+
78
+ export function listUnifiedRunEvents(runId: string, limit = 200): RunEventRecord[] {
79
+ const run = getRunById(runId)
80
+ if (run) return listRunEvents(runId, limit)
81
+ const protocolRun = loadProtocolRunById(runId)
82
+ if (!protocolRun) return []
83
+ return listProtocolRunEventsForRun(runId, limit).map((event) => protocolEventToRunEventRecord(protocolRun, event))
84
+ }