tokenrace 0.1.8 → 0.1.10
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/dist/assets/{index-Bq_1mbQ9.js → index-DQk3XNu9.js} +4 -4
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/api-routes.js +30 -11
- package/src/otlp-parser.js +1 -0
- package/src/server.js +10 -2
- package/src/store.js +129 -55
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>tokenrace</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DQk3XNu9.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-B0dy8dWP.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
package/src/api-routes.js
CHANGED
|
@@ -40,17 +40,37 @@ export function broadcast(type, payload) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Crea y devuelve el router Express con todas las rutas /api/*.
|
|
43
|
+
* @param {{ port?: number }} options
|
|
43
44
|
* @returns {Router}
|
|
44
45
|
*/
|
|
45
|
-
export function createRouter() {
|
|
46
|
+
export function createRouter({ port = 1337 } = {}) {
|
|
46
47
|
const router = Router()
|
|
47
48
|
|
|
48
|
-
//
|
|
49
|
+
// Solo se permiten solicitudes del propio dashboard (mismo origen)
|
|
50
|
+
const allowedOrigins = new Set([
|
|
51
|
+
`http://localhost:${port}`,
|
|
52
|
+
`http://127.0.0.1:${port}`,
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
// CORS: responder solo al origen del dashboard, nunca con wildcard
|
|
49
56
|
router.use((req, res, next) => {
|
|
50
|
-
|
|
57
|
+
const origin = req.headers.origin
|
|
58
|
+
if (origin && allowedOrigins.has(origin)) {
|
|
59
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
60
|
+
}
|
|
51
61
|
next()
|
|
52
62
|
})
|
|
53
63
|
|
|
64
|
+
// Guard CSRF: bloquea POST con Origin presente pero no permitido.
|
|
65
|
+
// Requests sin Origin (CLI, tests, curl) pasan sin restricción.
|
|
66
|
+
function requireSafeOrigin(req, res, next) {
|
|
67
|
+
const origin = req.headers.origin
|
|
68
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
69
|
+
return res.status(403).json({ error: 'origen no permitido' })
|
|
70
|
+
}
|
|
71
|
+
return next()
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
// ── SSE ────────────────────────────────────────────────────────────────────
|
|
55
75
|
|
|
56
76
|
/**
|
|
@@ -61,7 +81,6 @@ export function createRouter() {
|
|
|
61
81
|
res.setHeader('Content-Type', 'text/event-stream')
|
|
62
82
|
res.setHeader('Cache-Control', 'no-cache')
|
|
63
83
|
res.setHeader('Connection', 'keep-alive')
|
|
64
|
-
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
65
84
|
res.flushHeaders()
|
|
66
85
|
|
|
67
86
|
const clientId = ++_nextClientId
|
|
@@ -111,7 +130,7 @@ export function createRouter() {
|
|
|
111
130
|
/** GET /api/sessions — lista de sesiones, acepta ?limit, ?project */
|
|
112
131
|
router.get('/api/sessions', (req, res) => {
|
|
113
132
|
res.json(getSessions({
|
|
114
|
-
limit:
|
|
133
|
+
limit: Math.min(Number(req.query.limit) || 50, 500),
|
|
115
134
|
project: req.query.project || null
|
|
116
135
|
}))
|
|
117
136
|
})
|
|
@@ -124,7 +143,7 @@ export function createRouter() {
|
|
|
124
143
|
/** GET /api/events — eventos filtrados, acepta ?limit, ?type, ?project */
|
|
125
144
|
router.get('/api/events', (req, res) => {
|
|
126
145
|
res.json(getEvents({
|
|
127
|
-
limit:
|
|
146
|
+
limit: Math.min(Number(req.query.limit) || 200, 500),
|
|
128
147
|
type: req.query.type || null,
|
|
129
148
|
project: req.query.project || null
|
|
130
149
|
}))
|
|
@@ -150,10 +169,10 @@ export function createRouter() {
|
|
|
150
169
|
* Asigna un proyecto a una sesión.
|
|
151
170
|
* Body: { project: string }
|
|
152
171
|
*/
|
|
153
|
-
router.post('/api/sessions/:sessionId/label', (req, res) => {
|
|
172
|
+
router.post('/api/sessions/:sessionId/label', requireSafeOrigin, (req, res) => {
|
|
154
173
|
const { project } = req.body
|
|
155
|
-
if (!project || typeof project !== 'string') {
|
|
156
|
-
return res.status(400).json({ error: 'project
|
|
174
|
+
if (!project || typeof project !== 'string' || project.length > 200) {
|
|
175
|
+
return res.status(400).json({ error: 'project inválido' })
|
|
157
176
|
}
|
|
158
177
|
labelSession(req.params.sessionId, project)
|
|
159
178
|
broadcast('label_updated', { sessionId: req.params.sessionId, project })
|
|
@@ -164,13 +183,13 @@ export function createRouter() {
|
|
|
164
183
|
* POST /api/sessions/:sessionId/ignore
|
|
165
184
|
* Marca una sesión como ignorada (no aparecerá en notificaciones ni en métricas).
|
|
166
185
|
*/
|
|
167
|
-
router.post('/api/sessions/:sessionId/ignore', (req, res) => {
|
|
186
|
+
router.post('/api/sessions/:sessionId/ignore', requireSafeOrigin, (req, res) => {
|
|
168
187
|
ignoreSession(req.params.sessionId)
|
|
169
188
|
res.json({ ok: true })
|
|
170
189
|
})
|
|
171
190
|
|
|
172
191
|
/** POST /api/reset — resetea todo el estado en memoria */
|
|
173
|
-
router.post('/api/reset', (req, res) => {
|
|
192
|
+
router.post('/api/reset', requireSafeOrigin, (req, res) => {
|
|
174
193
|
reset()
|
|
175
194
|
res.json({ ok: true })
|
|
176
195
|
})
|
package/src/otlp-parser.js
CHANGED
|
@@ -21,6 +21,7 @@ export function extractAttributes(attrs) {
|
|
|
21
21
|
for (const attr of attrs) {
|
|
22
22
|
const { key, value } = attr
|
|
23
23
|
if (!key || !value) continue
|
|
24
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
|
|
24
25
|
|
|
25
26
|
if ('stringValue' in value) {
|
|
26
27
|
result[key] = value.stringValue
|
package/src/server.js
CHANGED
|
@@ -31,6 +31,14 @@ export async function startServer({ port = 1337 } = {}) {
|
|
|
31
31
|
const app = express()
|
|
32
32
|
app.use(express.json({ limit: '10mb' }))
|
|
33
33
|
|
|
34
|
+
// Headers de seguridad HTTP mínimos
|
|
35
|
+
app.use((_req, res, next) => {
|
|
36
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
37
|
+
res.setHeader('X-Frame-Options', 'DENY')
|
|
38
|
+
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
39
|
+
next()
|
|
40
|
+
})
|
|
41
|
+
|
|
34
42
|
// ── Endpoints OTLP ──────────────────────────────────────────────────────────
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -83,7 +91,7 @@ export async function startServer({ port = 1337 } = {}) {
|
|
|
83
91
|
})
|
|
84
92
|
|
|
85
93
|
// ── API REST + SSE ──────────────────────────────────────────────────────────
|
|
86
|
-
app.use(createRouter())
|
|
94
|
+
app.use(createRouter({ port }))
|
|
87
95
|
|
|
88
96
|
// ── Archivos estáticos (web compilada) ──────────────────────────────────────
|
|
89
97
|
// dist/ está en la raíz del proyecto (un nivel arriba de src/)
|
|
@@ -111,7 +119,7 @@ export async function startServer({ port = 1337 } = {}) {
|
|
|
111
119
|
|
|
112
120
|
// ── Arrancar servidor ───────────────────────────────────────────────────────
|
|
113
121
|
return new Promise((resolve) => {
|
|
114
|
-
const server = app.listen(port, () => {
|
|
122
|
+
const server = app.listen(port, '127.0.0.1', () => {
|
|
115
123
|
resolve({ app, server, autoSaveInterval })
|
|
116
124
|
})
|
|
117
125
|
})
|
package/src/store.js
CHANGED
|
@@ -28,23 +28,90 @@ export function setDataPathForTesting(newPath) {
|
|
|
28
28
|
// ─── Estado en memoria ───────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
30
|
const state = {
|
|
31
|
-
timeseries:
|
|
32
|
-
sessions:
|
|
33
|
-
sessionMappings:
|
|
34
|
-
ignoredSessions:
|
|
35
|
-
events:
|
|
36
|
-
eventIndex:
|
|
37
|
-
projects:
|
|
38
|
-
tools:
|
|
39
|
-
agents:
|
|
40
|
-
models:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
timeseries: new Map(), // metricName → [{ ts, value, labels }]
|
|
32
|
+
sessions: new Map(), // sessionId → SessionData
|
|
33
|
+
sessionMappings: new Map(), // sessionId → projectName
|
|
34
|
+
ignoredSessions: new Set(), // sessionIds ignorados
|
|
35
|
+
events: [], // buffer circular, max 1000
|
|
36
|
+
eventIndex: 0,
|
|
37
|
+
projects: new Map(), // projectName → ProjectAggregates
|
|
38
|
+
tools: new Map(), // toolName → ToolStats
|
|
39
|
+
agents: new Map(), // agentId → AgentData
|
|
40
|
+
models: new Map(), // modelName → ModelStats
|
|
41
|
+
cumulativeValues: new Map(), // clave → último valor acumulativo (no persiste)
|
|
42
|
+
lastSeen: null,
|
|
43
|
+
startTime: Date.now(),
|
|
44
|
+
totalEvents: 0
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ─── Helpers internos ────────────────────────────────────────────────────────
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Convierte un valor acumulativo en un delta desde la última lectura.
|
|
51
|
+
* Se usa para métricas que Claude Code exporta con temporalidad cumulativa.
|
|
52
|
+
*/
|
|
53
|
+
function cumulativeDelta(key, newValue) {
|
|
54
|
+
const last = state.cumulativeValues.get(key) ?? 0
|
|
55
|
+
const delta = Math.max(0, newValue - last)
|
|
56
|
+
state.cumulativeValues.set(key, newValue)
|
|
57
|
+
return delta
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normaliza los nombres de métricas nuevos de Claude Code a los nombres
|
|
62
|
+
* canónicos que usa el store, convirtiendo valores cumulativos a deltas.
|
|
63
|
+
* Devuelve { name, value } normalizado, o null para ignorar la métrica.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeIncoming(name, value, labels) {
|
|
66
|
+
const sid = labels['session.id'] ?? ''
|
|
67
|
+
|
|
68
|
+
switch (name) {
|
|
69
|
+
case 'claude_code.token.usage': {
|
|
70
|
+
const type = labels.type
|
|
71
|
+
const key = `token:${sid}:${type}:${labels.model ?? ''}:${labels.query_source ?? ''}`
|
|
72
|
+
const delta = cumulativeDelta(key, value)
|
|
73
|
+
const nameMap = {
|
|
74
|
+
'input': 'claude_code.tokens.input',
|
|
75
|
+
'output': 'claude_code.tokens.output',
|
|
76
|
+
'cacheRead': 'claude_code.tokens.cache.read',
|
|
77
|
+
'cacheCreation': 'claude_code.tokens.cache.creation',
|
|
78
|
+
}
|
|
79
|
+
const canonical = nameMap[type]
|
|
80
|
+
return canonical ? { name: canonical, value: delta } : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'claude_code.cost.usage': {
|
|
84
|
+
const key = `cost:${sid}:${labels.model ?? ''}:${labels.query_source ?? ''}:${labels.effort ?? ''}`
|
|
85
|
+
const delta = cumulativeDelta(key, value)
|
|
86
|
+
return { name: 'claude_code.cost', value: delta }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'claude_code.lines_of_code.count': {
|
|
90
|
+
const type = labels.type
|
|
91
|
+
const key = `lines:${sid}:${type}`
|
|
92
|
+
const delta = cumulativeDelta(key, value)
|
|
93
|
+
if (type === 'added') return { name: 'claude_code.lines_added', value: delta }
|
|
94
|
+
if (type === 'removed') return { name: 'claude_code.lines_removed', value: delta }
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'claude_code.commit.count': {
|
|
99
|
+
const key = `commit:${sid}`
|
|
100
|
+
const delta = cumulativeDelta(key, value)
|
|
101
|
+
return { name: 'claude_code.commits', value: delta }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Métricas que no necesitamos agregar (activo ya se calcula desde eventos)
|
|
105
|
+
case 'claude_code.session.count':
|
|
106
|
+
case 'claude_code.active_time.total':
|
|
107
|
+
case 'claude_code.code_edit_tool.decision':
|
|
108
|
+
return null
|
|
109
|
+
|
|
110
|
+
default:
|
|
111
|
+
return { name, value }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
/**
|
|
49
116
|
* Resuelve el proyecto de una sesión.
|
|
50
117
|
* Prioridad: mapping manual > resource project > null
|
|
@@ -137,7 +204,12 @@ function scheduleSave() {
|
|
|
137
204
|
* Procesa un punto de métrica normalizado.
|
|
138
205
|
* @param {{ name, value, timestamp, labels }} metric
|
|
139
206
|
*/
|
|
140
|
-
export function processMetric(
|
|
207
|
+
export function processMetric(raw) {
|
|
208
|
+
const normalized = normalizeIncoming(raw.name, raw.value, raw.labels)
|
|
209
|
+
if (!normalized) return
|
|
210
|
+
|
|
211
|
+
const { name, value } = normalized
|
|
212
|
+
const { timestamp, labels } = raw
|
|
141
213
|
const sessionId = labels['session.id']
|
|
142
214
|
|
|
143
215
|
// Saltar sesiones ignoradas
|
|
@@ -148,7 +220,10 @@ export function processMetric({ name, value, timestamp, labels }) {
|
|
|
148
220
|
|
|
149
221
|
// ── Timeseries ──
|
|
150
222
|
if (!state.timeseries.has(name)) state.timeseries.set(name, [])
|
|
151
|
-
state.timeseries.get(name)
|
|
223
|
+
const tsPoints = state.timeseries.get(name)
|
|
224
|
+
tsPoints.push({ ts: timestamp, value, labels })
|
|
225
|
+
// Cap por métrica para prevenir DoS por agotamiento de memoria
|
|
226
|
+
if (tsPoints.length > 10_000) tsPoints.splice(0, tsPoints.length - 10_000)
|
|
152
227
|
|
|
153
228
|
// ── Sesiones ──
|
|
154
229
|
if (sessionId) {
|
|
@@ -364,15 +439,9 @@ export function labelSession(sessionId, project) {
|
|
|
364
439
|
state.sessions.get(sessionId).project = project
|
|
365
440
|
}
|
|
366
441
|
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (point.labels['session.id'] === sessionId) {
|
|
371
|
-
point.labels.project = project
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
442
|
+
// No es necesario propagar retroactivamente a los labels de timeseries:
|
|
443
|
+
// resolveProject() consulta sessionMappings primero, por lo que todos los
|
|
444
|
+
// lectores de labels.project ya obtendrán el valor correcto.
|
|
376
445
|
rebuildProjectAggregates()
|
|
377
446
|
scheduleSave()
|
|
378
447
|
}
|
|
@@ -400,6 +469,7 @@ export function reset() {
|
|
|
400
469
|
state.tools.clear()
|
|
401
470
|
state.agents.clear()
|
|
402
471
|
state.models.clear()
|
|
472
|
+
state.cumulativeValues.clear()
|
|
403
473
|
state.lastSeen = null
|
|
404
474
|
state.startTime = Date.now()
|
|
405
475
|
state.totalEvents = 0
|
|
@@ -413,13 +483,16 @@ export function reset() {
|
|
|
413
483
|
* Estado de conexión del servidor.
|
|
414
484
|
*/
|
|
415
485
|
export function getStatus() {
|
|
486
|
+
let sessionCount = 0
|
|
487
|
+
for (const id of state.sessions.keys()) {
|
|
488
|
+
if (!state.ignoredSessions.has(id)) sessionCount++
|
|
489
|
+
}
|
|
416
490
|
return {
|
|
417
|
-
connected:
|
|
418
|
-
lastSeen:
|
|
419
|
-
sessionCount
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
uptime: Date.now() - state.startTime
|
|
491
|
+
connected: state.lastSeen !== null,
|
|
492
|
+
lastSeen: state.lastSeen,
|
|
493
|
+
sessionCount,
|
|
494
|
+
totalEvents: state.totalEvents,
|
|
495
|
+
uptime: Date.now() - state.startTime
|
|
423
496
|
}
|
|
424
497
|
}
|
|
425
498
|
|
|
@@ -514,8 +587,24 @@ export function getProjects(from) {
|
|
|
514
587
|
const minTs = parseTimeRange(from)
|
|
515
588
|
const result = []
|
|
516
589
|
|
|
590
|
+
// Pre-agregar commits/líneas desde timeseries en un único recorrido O(puntos)
|
|
591
|
+
// en lugar de O(proyectos × puntos)
|
|
592
|
+
const tsAgg = new Map() // projectName → { commits, linesAdded, linesRemoved }
|
|
593
|
+
for (const [metric, field] of [
|
|
594
|
+
['claude_code.commits', 'commits'],
|
|
595
|
+
['claude_code.lines_added', 'linesAdded'],
|
|
596
|
+
['claude_code.lines_removed', 'linesRemoved'],
|
|
597
|
+
]) {
|
|
598
|
+
for (const point of state.timeseries.get(metric) ?? []) {
|
|
599
|
+
if (point.ts < minTs) continue
|
|
600
|
+
const proj = resolveProject(point.labels['session.id'], point.labels.project)
|
|
601
|
+
if (!proj) continue
|
|
602
|
+
if (!tsAgg.has(proj)) tsAgg.set(proj, { commits: 0, linesAdded: 0, linesRemoved: 0 })
|
|
603
|
+
tsAgg.get(proj)[field] += point.value
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
517
607
|
for (const [project, proj] of state.projects.entries()) {
|
|
518
|
-
// Filtrar sesiones activas en el rango
|
|
519
608
|
const activeSessions = new Set()
|
|
520
609
|
let cost = 0, tokensInput = 0, tokensOutput = 0, tokensCache = 0
|
|
521
610
|
|
|
@@ -531,20 +620,7 @@ export function getProjects(from) {
|
|
|
531
620
|
|
|
532
621
|
if (activeSessions.size === 0 && minTs > 0) continue
|
|
533
622
|
|
|
534
|
-
|
|
535
|
-
let commits = 0, linesAdded = 0, linesRemoved = 0
|
|
536
|
-
const tsMap = {
|
|
537
|
-
'claude_code.commits': (v) => { commits += v },
|
|
538
|
-
'claude_code.lines_added': (v) => { linesAdded += v },
|
|
539
|
-
'claude_code.lines_removed': (v) => { linesRemoved += v }
|
|
540
|
-
}
|
|
541
|
-
for (const [metric, accum] of Object.entries(tsMap)) {
|
|
542
|
-
for (const point of state.timeseries.get(metric) ?? []) {
|
|
543
|
-
const pointProject = resolveProject(point.labels['session.id'], point.labels.project)
|
|
544
|
-
if (pointProject === project && point.ts >= minTs) accum(point.value)
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
623
|
+
const { commits, linesAdded, linesRemoved } = tsAgg.get(project) ?? { commits: 0, linesAdded: 0, linesRemoved: 0 }
|
|
548
624
|
const cacheHitRate = tokensInput > 0 ? tokensCache / tokensInput : 0
|
|
549
625
|
|
|
550
626
|
result.push({
|
|
@@ -693,7 +769,7 @@ export function getModels(from) {
|
|
|
693
769
|
*/
|
|
694
770
|
export function saveSync() {
|
|
695
771
|
try {
|
|
696
|
-
fs.mkdirSync(dataDir, { recursive: true })
|
|
772
|
+
fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 })
|
|
697
773
|
|
|
698
774
|
const data = {
|
|
699
775
|
timeseries: Array.from(state.timeseries.entries()),
|
|
@@ -706,7 +782,11 @@ export function saveSync() {
|
|
|
706
782
|
startTime: state.startTime
|
|
707
783
|
}
|
|
708
784
|
|
|
709
|
-
fs.writeFileSync(dataFile, JSON.stringify(data))
|
|
785
|
+
fs.writeFileSync(dataFile, JSON.stringify(data), { mode: 0o600 })
|
|
786
|
+
// Forzar permisos en archivos ya existentes (mode en writeFileSync
|
|
787
|
+
// solo aplica al crear el archivo, no al sobreescribirlo)
|
|
788
|
+
try { fs.chmodSync(dataDir, 0o700) } catch {}
|
|
789
|
+
try { fs.chmodSync(dataFile, 0o600) } catch {}
|
|
710
790
|
} catch (err) {
|
|
711
791
|
console.error('[store] Error guardando datos en disco:', err.message)
|
|
712
792
|
}
|
|
@@ -763,18 +843,12 @@ export function loadFromDisk() {
|
|
|
763
843
|
state.startTime = data.startTime
|
|
764
844
|
}
|
|
765
845
|
|
|
766
|
-
// Re-aplicar mappings a sesiones
|
|
846
|
+
// Re-aplicar mappings a sesiones (timeseries no necesitan propagación:
|
|
847
|
+
// resolveProject() consulta sessionMappings primero en todos los lectores)
|
|
767
848
|
for (const [sessionId, project] of state.sessionMappings.entries()) {
|
|
768
849
|
if (state.sessions.has(sessionId)) {
|
|
769
850
|
state.sessions.get(sessionId).project = project
|
|
770
851
|
}
|
|
771
|
-
for (const points of state.timeseries.values()) {
|
|
772
|
-
for (const point of points) {
|
|
773
|
-
if (point.labels['session.id'] === sessionId) {
|
|
774
|
-
point.labels.project = project
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
852
|
}
|
|
779
853
|
|
|
780
854
|
rebuildProjectAggregates()
|