@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
|
@@ -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
|
+
}
|