@bongos/core 1.19.315 → 1.19.317
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/.bongos-core.json +104 -29
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +35 -27
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/skill-preflight.js +336 -0
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/skill_preflight.mjs +251 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// modules/lifecycle/db-analytics.js — estimation drift, grade aggregates, builder/ecosystem analytics, merge lease + peer-vote karma.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original db.js lines 4916-5598 unchanged. db.js remains the facade that
|
|
6
|
+
// re-exports every name, so every existing db call site is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
|
|
9
|
+
// doorway ../../src/module-api.js — never a core internal, never a sibling module.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const api = require('../../src/module-api');
|
|
14
|
+
const { pool, withTx } = api;
|
|
15
|
+
const { SMOKE_NOT_LIKE, gradePassCount, gradeQualityWhere, median } = require('./db-shared.js');
|
|
16
|
+
const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
|
|
17
|
+
|
|
18
|
+
// -------------------------------------------------------------------------
|
|
19
|
+
// Cost log + summary carved into modules/economy/cost.js (BV1.R77 / ADR 0093
|
|
20
|
+
// §§1-2): logCost (the one cost_log writer), reconcileSessionCosts,
|
|
21
|
+
// costSummary, and costCalibrationSummary left this monolith. routes/cost.js
|
|
22
|
+
// + the reconciler + the public dashboard resolve them via the `reward` kernel
|
|
23
|
+
// port. (The DAY_SERIES_CTE constant they shared moved with them.)
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
// Recent shipped tasks (for status page feed)
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
// GDS #191: optional `days` (positive integer) restricts to rows shipped within
|
|
31
|
+
// the trailing N days. `null` / `undefined` / `0` means no date filter (all time).
|
|
32
|
+
// Limit is still applied either way so the status page never pulls an unbounded
|
|
33
|
+
// payload. Default keeps the historical 30-row "recent" feel for callers that
|
|
34
|
+
// don't pass options.
|
|
35
|
+
|
|
36
|
+
async function recentShippedTasks(limit = 30, { days = null } = {}) {
|
|
37
|
+
const params = [limit];
|
|
38
|
+
let whereClause = `t.status = 'shipped'
|
|
39
|
+
AND ${SMOKE_NOT_LIKE}`;
|
|
40
|
+
if (days && Number.isFinite(days) && days > 0) {
|
|
41
|
+
params.push(days);
|
|
42
|
+
whereClause += `\n AND t.shipped_at > now() - ($${params.length}::int * interval '1 day')`;
|
|
43
|
+
}
|
|
44
|
+
const { rows } = await pool.query(
|
|
45
|
+
`SELECT t.id, t.title, t.value_summary, t.version_id, t.status, t.shipped_at, t.credits_reward,
|
|
46
|
+
${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
|
|
47
|
+
t.est_minutes, t.actual_minutes,
|
|
48
|
+
b.github_login, b.display_name
|
|
49
|
+
FROM tasks t
|
|
50
|
+
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
51
|
+
WHERE ${whereClause}
|
|
52
|
+
ORDER BY t.shipped_at DESC NULLS LAST
|
|
53
|
+
LIMIT $1`,
|
|
54
|
+
params
|
|
55
|
+
);
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Rolling median of actual/est ratio for a builder's last N shipped tasks.
|
|
60
|
+
// Returns null if there is insufficient data.
|
|
61
|
+
|
|
62
|
+
async function getEstVsActualRatio(builderId, limit = 10) {
|
|
63
|
+
const { rows } = await pool.query(
|
|
64
|
+
`SELECT actual_minutes::float / est_minutes AS ratio
|
|
65
|
+
FROM tasks
|
|
66
|
+
WHERE shipped_by = $1
|
|
67
|
+
AND status = 'shipped'
|
|
68
|
+
AND actual_minutes IS NOT NULL
|
|
69
|
+
AND est_minutes IS NOT NULL
|
|
70
|
+
AND est_minutes > 0
|
|
71
|
+
ORDER BY shipped_at DESC NULLS LAST
|
|
72
|
+
LIMIT $2`,
|
|
73
|
+
[builderId, limit]
|
|
74
|
+
);
|
|
75
|
+
if (rows.length === 0) return null;
|
|
76
|
+
const ratios = rows.map((r) => Number(r.ratio));
|
|
77
|
+
const med = median(ratios);
|
|
78
|
+
return { median: Math.round(med * 100) / 100, sample_size: ratios.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Aggregated estimation drift for the public status page.
|
|
82
|
+
// Returns overall median ratio + per-version breakdown.
|
|
83
|
+
|
|
84
|
+
async function estimationDriftSummary() {
|
|
85
|
+
const { rows } = await pool.query(
|
|
86
|
+
`SELECT version_id,
|
|
87
|
+
actual_minutes::float / est_minutes AS ratio
|
|
88
|
+
FROM tasks
|
|
89
|
+
WHERE status = 'shipped'
|
|
90
|
+
AND actual_minutes IS NOT NULL
|
|
91
|
+
AND est_minutes IS NOT NULL
|
|
92
|
+
AND est_minutes > 0
|
|
93
|
+
AND ${SMOKE_NOT_LIKE}
|
|
94
|
+
ORDER BY shipped_at DESC NULLS LAST`
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (rows.length === 0) {
|
|
98
|
+
return { overall_median: null, sample_size: 0, by_version: [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const allRatios = rows.map((r) => Number(r.ratio));
|
|
102
|
+
const overallMedian = Math.round(median(allRatios) * 100) / 100;
|
|
103
|
+
|
|
104
|
+
const byVersion = new Map();
|
|
105
|
+
for (const r of rows) {
|
|
106
|
+
if (!byVersion.has(r.version_id)) byVersion.set(r.version_id, []);
|
|
107
|
+
byVersion.get(r.version_id).push(Number(r.ratio));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const versionBreakdown = Array.from(byVersion.entries()).map(([version_id, ratios]) => ({
|
|
111
|
+
version_id,
|
|
112
|
+
median_ratio: Math.round(median(ratios) * 100) / 100,
|
|
113
|
+
task_count: ratios.length,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
overall_median: overallMedian,
|
|
118
|
+
sample_size: allRatios.length,
|
|
119
|
+
by_version: versionBreakdown,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async function recentShippedTasksByBuilder(builderId, limit = 10) {
|
|
125
|
+
const { rows } = await pool.query(
|
|
126
|
+
`SELECT t.id, t.title, t.value_summary, t.version_id, t.status, t.shipped_at, t.credits_reward,
|
|
127
|
+
${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
|
|
128
|
+
FROM tasks t
|
|
129
|
+
WHERE t.shipped_by = $1
|
|
130
|
+
AND t.status = 'shipped'
|
|
131
|
+
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
132
|
+
ORDER BY t.shipped_at DESC NULLS LAST
|
|
133
|
+
LIMIT $2`,
|
|
134
|
+
[builderId, limit]
|
|
135
|
+
);
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -------------------------------------------------------------------------
|
|
140
|
+
// Achievement getters (listAchievements / getBuilderAchievements /
|
|
141
|
+
// getLeaderboardWithAchievements / getLockedAchievementsProgress /
|
|
142
|
+
// renderProgressText) carved into modules/economy/achievements.js (BV1.R77 /
|
|
143
|
+
// ADR 0093 §§1-2). me.js + public.js resolve them via the `reward` kernel port.
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
// Karma feed (V3.R28 / #248)
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
// Last N karma_log entries for this builder, newest first. Powers the
|
|
151
|
+
// proposer's karma feed — the "Done when: visible in the proposer's karma
|
|
152
|
+
// feed" criterion for V3.R28. When R26 (#246) ships the profile-page karma
|
|
153
|
+
// trend UI, the page reads from this helper. Lightweight join to builders
|
|
154
|
+
// so the UI can render "granted by @<login>" without a second query.
|
|
155
|
+
//
|
|
156
|
+
// Append-only by construction (karma_log has no DELETE/UPDATE paths), so
|
|
157
|
+
// this is safe to call freely.
|
|
158
|
+
|
|
159
|
+
async function recentGradesForBuilder(builderId, limit = 5) {
|
|
160
|
+
// panel_outcome rides along so consumers can LABEL an outage row ("not
|
|
161
|
+
// graded") instead of rendering its placeholder 0.0 as a fail (task 1002652).
|
|
162
|
+
// A list read stays unfiltered — honesty here means labeling, not hiding.
|
|
163
|
+
const { rows } = await pool.query(
|
|
164
|
+
`SELECT g.task_id, g.score, g.passed, g.threshold, g.graded_at,
|
|
165
|
+
g.grader_kind, g.grader_version, g.rubric_scores,
|
|
166
|
+
g.signals->>'panel_outcome' AS panel_outcome,
|
|
167
|
+
COALESCE(g.signals->'issues', '[]'::jsonb) AS issues,
|
|
168
|
+
COALESCE((g.signals->>'confidence')::numeric, NULL) AS confidence,
|
|
169
|
+
t.title, t.kind, t.discipline
|
|
170
|
+
FROM task_grades g
|
|
171
|
+
JOIN tasks t ON t.id = g.task_id
|
|
172
|
+
WHERE t.shipped_by = $1
|
|
173
|
+
ORDER BY g.graded_at DESC
|
|
174
|
+
LIMIT $2`,
|
|
175
|
+
[builderId, limit]
|
|
176
|
+
);
|
|
177
|
+
return rows;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Rolling 7d avg grade across ALL builders, plus pass-rate. Powers the
|
|
181
|
+
// public status tile + leaderboard hover.
|
|
182
|
+
|
|
183
|
+
async function rollingAvgGrade(days = 7) {
|
|
184
|
+
// Quality stats aggregate ONLY quality-graded rows; outage / null-axis rows
|
|
185
|
+
// are counted apart as n_unavailable (task 1002652 — 21 outage rows once
|
|
186
|
+
// read as a month of pass_rate 0.000).
|
|
187
|
+
const q = gradeQualityWhere();
|
|
188
|
+
const { rows } = await pool.query(
|
|
189
|
+
`SELECT
|
|
190
|
+
COUNT(*) FILTER (WHERE ${q})::int AS n,
|
|
191
|
+
COALESCE(AVG(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
|
|
192
|
+
${gradePassCount('passed', { where: q })} AS n_passed,
|
|
193
|
+
COALESCE(MIN(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS min_score,
|
|
194
|
+
COALESCE(MAX(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS max_score,
|
|
195
|
+
COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
|
|
196
|
+
FROM task_grades
|
|
197
|
+
WHERE graded_at >= now() - ($1 || ' days')::interval`,
|
|
198
|
+
[String(days)]
|
|
199
|
+
);
|
|
200
|
+
return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0, n_unavailable: 0 };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-builder rolling avg, last `days` days. Powers the /builders profile.
|
|
204
|
+
|
|
205
|
+
async function gradeByBuilder(days = 30) {
|
|
206
|
+
// pass_rate is NULL (not 0.000) for a builder whose window holds only
|
|
207
|
+
// outage / null-axis rows — "no graded work", never a fabricated fail-streak
|
|
208
|
+
// (task 1002652). fmtPct-style consumers already render null as '—'.
|
|
209
|
+
const q = gradeQualityWhere('g');
|
|
210
|
+
const { rows } = await pool.query(
|
|
211
|
+
`SELECT
|
|
212
|
+
t.shipped_by AS builder_id,
|
|
213
|
+
b.github_login,
|
|
214
|
+
b.display_name,
|
|
215
|
+
b.avatar_url,
|
|
216
|
+
COUNT(*) FILTER (WHERE ${q})::int AS n,
|
|
217
|
+
COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
|
|
218
|
+
${gradePassCount('g.passed', { where: q })} AS n_passed,
|
|
219
|
+
ROUND(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 3) AS pass_rate,
|
|
220
|
+
COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable,
|
|
221
|
+
MAX(g.graded_at) AS last_graded_at
|
|
222
|
+
FROM task_grades g
|
|
223
|
+
JOIN tasks t ON t.id = g.task_id
|
|
224
|
+
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
225
|
+
WHERE g.graded_at >= now() - ($1 || ' days')::interval
|
|
226
|
+
AND t.shipped_by IS NOT NULL
|
|
227
|
+
GROUP BY t.shipped_by, b.github_login, b.display_name, b.avatar_url
|
|
228
|
+
ORDER BY n DESC, avg_score DESC`,
|
|
229
|
+
[String(days)]
|
|
230
|
+
);
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Public grade-trend timeline — array of { day, n, avg_score }. Powers a
|
|
235
|
+
// chart on status.amazonprimea.com.
|
|
236
|
+
|
|
237
|
+
async function gradeTrendByDay(days = 30) {
|
|
238
|
+
// A day whose rows are all outages keeps its row (n=0, avg_score NULL,
|
|
239
|
+
// n_unavailable>0) so a chart can show "grader down", never a 0.0-score day
|
|
240
|
+
// (task 1002652 — the 2026-07-12..19 zero-score week was five outage days).
|
|
241
|
+
const q = gradeQualityWhere();
|
|
242
|
+
const { rows } = await pool.query(
|
|
243
|
+
`SELECT
|
|
244
|
+
to_char(date_trunc('day', graded_at), 'YYYY-MM-DD') AS day,
|
|
245
|
+
COUNT(*) FILTER (WHERE ${q})::int AS n,
|
|
246
|
+
AVG(score) FILTER (WHERE ${q})::numeric(3,1) AS avg_score,
|
|
247
|
+
${gradePassCount('passed', { where: q })} AS n_passed,
|
|
248
|
+
COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
|
|
249
|
+
FROM task_grades
|
|
250
|
+
WHERE graded_at >= now() - ($1 || ' days')::interval
|
|
251
|
+
GROUP BY date_trunc('day', graded_at)
|
|
252
|
+
ORDER BY day ASC`,
|
|
253
|
+
[String(days)]
|
|
254
|
+
);
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// Builder analytics — the "AI dev curve" (V3.R72 / #291, criterion C5).
|
|
260
|
+
//
|
|
261
|
+
// Per-builder historical time-series powering the /builders profile analytics
|
|
262
|
+
// panel and (aggregated, individuals stripped) the public /status ecosystem
|
|
263
|
+
// tile. Everything derives from existing columns — tasks.shipped_at /
|
|
264
|
+
// actual_minutes / est_minutes / kind, cost_log.builder_id (migration 046),
|
|
265
|
+
// claims.outcome, and task_grades — so no migration is needed. Smoke tasks
|
|
266
|
+
// (`__smoke__*`) are excluded everywhere, mirroring the other status feeds.
|
|
267
|
+
// One round trip per series, fanned out with Promise.all.
|
|
268
|
+
// (SMOKE_NOT_LIKE is now declared once at module top — task 1087, C3.)
|
|
269
|
+
// -------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async function builderAnalytics(builderId, days = 90) {
|
|
273
|
+
const d = String(days);
|
|
274
|
+
const [shipRate, costTrend, durationTrend, gradeTrend, topKinds, headline, claimRollup] =
|
|
275
|
+
await Promise.all([
|
|
276
|
+
// Ships per day — the core "dev curve".
|
|
277
|
+
pool.query(
|
|
278
|
+
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
279
|
+
COUNT(*)::int AS n
|
|
280
|
+
FROM tasks
|
|
281
|
+
WHERE shipped_by = $1 AND status = 'shipped'
|
|
282
|
+
AND shipped_at >= now() - ($2 || ' days')::interval
|
|
283
|
+
AND ${SMOKE_NOT_LIKE}
|
|
284
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
285
|
+
[builderId, d]
|
|
286
|
+
),
|
|
287
|
+
// Cost per day + tasks touched that day → cost-per-task in JS.
|
|
288
|
+
pool.query(
|
|
289
|
+
`SELECT to_char(date_trunc('day', recorded_at), 'YYYY-MM-DD') AS day,
|
|
290
|
+
SUM(amount_usd)::numeric(12,2) AS total_usd,
|
|
291
|
+
COUNT(DISTINCT task_id)::int AS n_tasks
|
|
292
|
+
FROM cost_log
|
|
293
|
+
WHERE builder_id = $1
|
|
294
|
+
AND recorded_at >= now() - ($2 || ' days')::interval
|
|
295
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
296
|
+
[builderId, d]
|
|
297
|
+
),
|
|
298
|
+
// Avg actual vs estimated minutes per day (the "getting faster" curve).
|
|
299
|
+
pool.query(
|
|
300
|
+
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
301
|
+
ROUND(AVG(actual_minutes))::int AS avg_actual,
|
|
302
|
+
ROUND(AVG(est_minutes))::int AS avg_est
|
|
303
|
+
FROM tasks
|
|
304
|
+
WHERE shipped_by = $1 AND status = 'shipped'
|
|
305
|
+
AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
|
|
306
|
+
AND shipped_at >= now() - ($2 || ' days')::interval
|
|
307
|
+
AND ${SMOKE_NOT_LIKE}
|
|
308
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
309
|
+
[builderId, d]
|
|
310
|
+
),
|
|
311
|
+
// Grade pass-rate per day (quality holding up as throughput rises?).
|
|
312
|
+
// Quality-graded rows only — outage / null-axis rows would read as
|
|
313
|
+
// fail-days (task 1002652); headline.grade_pass_rate derives from these
|
|
314
|
+
// rows so it inherits the same honesty.
|
|
315
|
+
pool.query(
|
|
316
|
+
`SELECT to_char(date_trunc('day', g.graded_at), 'YYYY-MM-DD') AS day,
|
|
317
|
+
COUNT(*) FILTER (WHERE ${gradeQualityWhere('g')})::int AS n,
|
|
318
|
+
${gradePassCount('g.passed', { where: gradeQualityWhere('g') })} AS n_passed,
|
|
319
|
+
AVG(g.score) FILTER (WHERE ${gradeQualityWhere('g')})::numeric(3,1) AS avg_score
|
|
320
|
+
FROM task_grades g
|
|
321
|
+
JOIN tasks t ON t.id = g.task_id
|
|
322
|
+
WHERE t.shipped_by = $1
|
|
323
|
+
AND g.graded_at >= now() - ($2 || ' days')::interval
|
|
324
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
325
|
+
[builderId, d]
|
|
326
|
+
),
|
|
327
|
+
// Top kinds shipped (all-time — a "what do I build" fingerprint).
|
|
328
|
+
pool.query(
|
|
329
|
+
`SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
|
|
330
|
+
FROM tasks
|
|
331
|
+
WHERE shipped_by = $1 AND status = 'shipped'
|
|
332
|
+
AND ${SMOKE_NOT_LIKE}
|
|
333
|
+
GROUP BY 1 ORDER BY n DESC, kind ASC`,
|
|
334
|
+
[builderId]
|
|
335
|
+
),
|
|
336
|
+
// Window headline numbers.
|
|
337
|
+
pool.query(
|
|
338
|
+
`SELECT COUNT(*) FILTER (WHERE status = 'shipped')::int AS total_shipped,
|
|
339
|
+
COALESCE(SUM(actual_minutes) FILTER (
|
|
340
|
+
WHERE status = 'shipped' AND actual_minutes IS NOT NULL), 0)::int AS total_minutes,
|
|
341
|
+
COALESCE(ROUND(AVG(actual_minutes) FILTER (
|
|
342
|
+
WHERE status = 'shipped' AND actual_minutes IS NOT NULL)), 0)::int AS avg_duration_min
|
|
343
|
+
FROM tasks
|
|
344
|
+
WHERE shipped_by = $1
|
|
345
|
+
AND ${SMOKE_NOT_LIKE}
|
|
346
|
+
AND (shipped_at IS NULL OR shipped_at >= now() - ($2 || ' days')::interval)`,
|
|
347
|
+
[builderId, d]
|
|
348
|
+
),
|
|
349
|
+
// Confirmation rate — share of resolved claims that ended in a ship
|
|
350
|
+
// (vs. abandoned / expired / partial). Distinct from the grade pass-rate.
|
|
351
|
+
pool.query(
|
|
352
|
+
`SELECT COUNT(*)::int AS resolved_claims,
|
|
353
|
+
SUM(CASE WHEN outcome = 'shipped' THEN 1 ELSE 0 END)::int AS shipped_claims
|
|
354
|
+
FROM claims
|
|
355
|
+
WHERE builder_id = $1 AND outcome IS NOT NULL
|
|
356
|
+
AND claimed_at >= now() - ($2 || ' days')::interval`,
|
|
357
|
+
[builderId, d]
|
|
358
|
+
),
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
// task 1503: the "Cost / work ($)" chart plots per-day cost-per-work. The old
|
|
362
|
+
// per_task = day's cost / COUNT(DISTINCT cost_log.task_id) flat-lined at 0 —
|
|
363
|
+
// most cost rows carry a NULL task_id (session-token / infra / api spend), so
|
|
364
|
+
// the DISTINCT count was 0 for those days and per_task fell to 0; on the rare
|
|
365
|
+
// task-attributed day it spiked wildly. Cost (recorded_at) and works
|
|
366
|
+
// (shipped_at) don't align by day, so a same-day ratio can't be meaningful.
|
|
367
|
+
// Instead expose the CUMULATIVE cost-per-work as of each day — running total
|
|
368
|
+
// spend / running works shipped — which is always defined once there's a ship
|
|
369
|
+
// and converges to headline.cost_per_task.
|
|
370
|
+
const shipsByDay = new Map(shipRate.rows.map((r) => [r.day, Number(r.n) || 0]));
|
|
371
|
+
const costByDay = new Map(costTrend.rows.map((r) => [r.day, Number(r.total_usd) || 0]));
|
|
372
|
+
const curveDays = Array.from(new Set([
|
|
373
|
+
...costTrend.rows.map((r) => r.day),
|
|
374
|
+
...shipRate.rows.map((r) => r.day),
|
|
375
|
+
])).sort();
|
|
376
|
+
let cumCost = 0;
|
|
377
|
+
let cumShips = 0;
|
|
378
|
+
const perWorkByDay = new Map();
|
|
379
|
+
for (const day of curveDays) {
|
|
380
|
+
cumCost += costByDay.get(day) || 0;
|
|
381
|
+
cumShips += shipsByDay.get(day) || 0;
|
|
382
|
+
perWorkByDay.set(day, cumShips > 0 ? Math.round((cumCost / cumShips) * 100) / 100 : 0);
|
|
383
|
+
}
|
|
384
|
+
const cost_trend = costTrend.rows.map((r) => ({
|
|
385
|
+
day: r.day,
|
|
386
|
+
total_usd: Number(r.total_usd),
|
|
387
|
+
n_tasks: r.n_tasks,
|
|
388
|
+
// Cumulative cost-per-work as of this day (see note above). Converges to
|
|
389
|
+
// headline.cost_per_task; never the old all-zero flat line.
|
|
390
|
+
per_work: perWorkByDay.get(r.day) ?? 0,
|
|
391
|
+
}));
|
|
392
|
+
const totalCost = cost_trend.reduce((s, r) => s + r.total_usd, 0);
|
|
393
|
+
|
|
394
|
+
const h = headline.rows[0] || { total_shipped: 0, total_minutes: 0, avg_duration_min: 0 };
|
|
395
|
+
const c = claimRollup.rows[0] || { resolved_claims: 0, shipped_claims: 0 };
|
|
396
|
+
const grades = gradeTrend.rows;
|
|
397
|
+
const gradedN = grades.reduce((s, r) => s + r.n, 0);
|
|
398
|
+
const gradedPassed = grades.reduce((s, r) => s + r.n_passed, 0);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
window_days: days,
|
|
402
|
+
ship_rate: shipRate.rows, // [{ day, n }]
|
|
403
|
+
cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503)
|
|
404
|
+
duration_trend: durationTrend.rows, // [{ day, avg_actual, avg_est }]
|
|
405
|
+
grade_trend: grades, // [{ day, n, n_passed, avg_score }]
|
|
406
|
+
top_kinds: topKinds.rows, // [{ kind, n }]
|
|
407
|
+
headline: {
|
|
408
|
+
total_shipped: h.total_shipped,
|
|
409
|
+
total_cost_usd: Math.round(totalCost * 100) / 100,
|
|
410
|
+
cost_per_task: h.total_shipped > 0 ? Math.round((totalCost / h.total_shipped) * 100) / 100 : 0,
|
|
411
|
+
avg_duration_min: h.avg_duration_min,
|
|
412
|
+
confirmation_rate: c.resolved_claims > 0 ? Math.round((c.shipped_claims / c.resolved_claims) * 100) / 100 : null,
|
|
413
|
+
resolved_claims: c.resolved_claims,
|
|
414
|
+
grade_pass_rate: gradedN > 0 ? Math.round((gradedPassed / gradedN) * 100) / 100 : null,
|
|
415
|
+
graded_n: gradedN,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Ecosystem-wide aggregate — the SAME shape of series as builderAnalytics but
|
|
421
|
+
// summed across every builder, with NO per-builder identity. This is the only
|
|
422
|
+
// analytics surface the public /status page is allowed to render (individuals
|
|
423
|
+
// stay behind the authenticated /analytics/builder/:id route). Grade + cost
|
|
424
|
+
// daily series already have public feeds (/public/grades, /public/cost-summary),
|
|
425
|
+
// so this adds just the ship-rate, duration, top-kinds, and headline aggregates.
|
|
426
|
+
|
|
427
|
+
async function ecosystemAnalytics(days = 90) {
|
|
428
|
+
const d = String(days);
|
|
429
|
+
const [shipRate, durationTrend, topKinds, headline] = await Promise.all([
|
|
430
|
+
pool.query(
|
|
431
|
+
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
432
|
+
COUNT(*)::int AS n
|
|
433
|
+
FROM tasks
|
|
434
|
+
WHERE status = 'shipped' AND shipped_by IS NOT NULL
|
|
435
|
+
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
436
|
+
AND ${SMOKE_NOT_LIKE}
|
|
437
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
438
|
+
[d]
|
|
439
|
+
),
|
|
440
|
+
pool.query(
|
|
441
|
+
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
442
|
+
ROUND(AVG(actual_minutes))::int AS avg_actual,
|
|
443
|
+
ROUND(AVG(est_minutes))::int AS avg_est
|
|
444
|
+
FROM tasks
|
|
445
|
+
WHERE status = 'shipped'
|
|
446
|
+
AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
|
|
447
|
+
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
448
|
+
AND ${SMOKE_NOT_LIKE}
|
|
449
|
+
GROUP BY 1 ORDER BY 1 ASC`,
|
|
450
|
+
[d]
|
|
451
|
+
),
|
|
452
|
+
pool.query(
|
|
453
|
+
`SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
|
|
454
|
+
FROM tasks
|
|
455
|
+
WHERE status = 'shipped' AND shipped_by IS NOT NULL
|
|
456
|
+
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
457
|
+
AND ${SMOKE_NOT_LIKE}
|
|
458
|
+
GROUP BY 1 ORDER BY n DESC, kind ASC`,
|
|
459
|
+
[d]
|
|
460
|
+
),
|
|
461
|
+
pool.query(
|
|
462
|
+
`SELECT COUNT(*)::int AS total_shipped,
|
|
463
|
+
COUNT(DISTINCT shipped_by)::int AS active_builders,
|
|
464
|
+
COALESCE(ROUND(AVG(actual_minutes) FILTER (
|
|
465
|
+
WHERE actual_minutes IS NOT NULL)), 0)::int AS avg_duration_min
|
|
466
|
+
FROM tasks
|
|
467
|
+
WHERE status = 'shipped' AND shipped_by IS NOT NULL
|
|
468
|
+
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
469
|
+
AND ${SMOKE_NOT_LIKE}`,
|
|
470
|
+
[d]
|
|
471
|
+
),
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
const h = headline.rows[0] || { total_shipped: 0, active_builders: 0, avg_duration_min: 0 };
|
|
475
|
+
return {
|
|
476
|
+
window_days: days,
|
|
477
|
+
ship_rate: shipRate.rows,
|
|
478
|
+
duration_trend: durationTrend.rows,
|
|
479
|
+
top_kinds: topKinds.rows,
|
|
480
|
+
headline: {
|
|
481
|
+
total_shipped: h.total_shipped,
|
|
482
|
+
active_builders: h.active_builders,
|
|
483
|
+
avg_duration_min: h.avg_duration_min,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// -------------------------------------------------------------------------
|
|
489
|
+
// audit_log (V3.R35 / #256) — append-only stream of authenticated writes.
|
|
490
|
+
//
|
|
491
|
+
// The writing middleware (src/bongos/middleware/audit.js) calls insertAuditLog
|
|
492
|
+
// AFTER the response has been sent, wrapped in try/catch — so the request
|
|
493
|
+
// path never blocks on this insert and a DB failure here can never break a
|
|
494
|
+
// real user request. The reader is the archon-only GET /api/gds/audit-log
|
|
495
|
+
// endpoint backed by listAuditLog below (paginated, newest-first).
|
|
496
|
+
// -------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
// insertAuditLog — the append-only audit write — moved to ./db-kernel (BV1.R69 /
|
|
499
|
+
// ADR 0091 §2: audit is a kernel primitive). Imported + re-exported at the top
|
|
500
|
+
// of this file; the writing middleware + the listAuditLog reader below unchanged.
|
|
501
|
+
|
|
502
|
+
// listAuditLog — paginated, newest-first. Clamps limit to 1..500 and offset to
|
|
503
|
+
// >= 0 at the route layer; this helper trusts its inputs.
|
|
504
|
+
//
|
|
505
|
+
// Optional filters:
|
|
506
|
+
// builderId — only entries for that builder
|
|
507
|
+
// method — one of POST/PATCH/PUT/DELETE
|
|
508
|
+
// surface — originating surface (e.g. 'discord_bot'); the literal string
|
|
509
|
+
// 'none' selects direct human writes (surface IS NULL). This is
|
|
510
|
+
// what makes the documented forensic isolation of bot-origin
|
|
511
|
+
// writes actually queryable through the API, not just via psql
|
|
512
|
+
// (ADR 0033 §1 / F4 finding).
|
|
513
|
+
|
|
514
|
+
async function logGradeAttempt({ taskId, builderId, passed, score }, queryFn) {
|
|
515
|
+
const q = queryFn || ((text, params) => pool.query(text, params));
|
|
516
|
+
try {
|
|
517
|
+
await q(
|
|
518
|
+
`INSERT INTO grade_attempts (task_id, builder_id, passed, score) VALUES ($1, $2, $3, $4)`,
|
|
519
|
+
[
|
|
520
|
+
Number(taskId),
|
|
521
|
+
builderId == null ? null : Number(builderId),
|
|
522
|
+
passed === true,
|
|
523
|
+
score == null ? null : Number(score),
|
|
524
|
+
]
|
|
525
|
+
);
|
|
526
|
+
return true;
|
|
527
|
+
} catch (err) {
|
|
528
|
+
console.error('[gds] logGradeAttempt (non-blocking):', err && err.message ? err.message : err);
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// countGradeAttempts — how many times a task has been graded (re-grade thrash).
|
|
534
|
+
|
|
535
|
+
async function countGradeAttempts(taskId, queryFn) {
|
|
536
|
+
const q = queryFn || ((text, params) => pool.query(text, params));
|
|
537
|
+
const { rows } = await q(`SELECT COUNT(*)::int AS n FROM grade_attempts WHERE task_id = $1`, [Number(taskId)]);
|
|
538
|
+
return rows[0] ? rows[0].n : 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// gradeAttemptStats — the grade_attempts READER (task 1002661). The table has
|
|
542
|
+
// been written on every grade since 6D.6 and read by nothing, leaving
|
|
543
|
+
// remediation rate structurally unmeasurable. Windowed per-task rollup:
|
|
544
|
+
// remediation_rate = of the tasks that FAILED at least one attempt, the share
|
|
545
|
+
// whose latest attempt passed (null when no task failed — no denominator).
|
|
546
|
+
// waived_zero_issue_passes counts the ADR 0164 implausible_pass_waived audit
|
|
547
|
+
// markers in the same window, so the views can tell "waived on panel
|
|
548
|
+
// evidence" from "never evaluated" at a glance.
|
|
549
|
+
async function gradeAttemptStats(days = 7, queryFn) {
|
|
550
|
+
const q = queryFn || ((text, params) => pool.query(text, params));
|
|
551
|
+
const d = Number.isFinite(Number(days)) && Number(days) > 0 ? Math.min(Number(days), 90) : 7;
|
|
552
|
+
const [{ rows }, waived] = await Promise.all([q(
|
|
553
|
+
`SELECT
|
|
554
|
+
COALESCE(SUM(n), 0)::int AS total_attempts,
|
|
555
|
+
COUNT(*)::int AS graded_tasks,
|
|
556
|
+
COUNT(*) FILTER (WHERE n > 1)::int AS retried_tasks,
|
|
557
|
+
COUNT(*) FILTER (WHERE had_fail)::int AS tasks_with_fail,
|
|
558
|
+
COUNT(*) FILTER (WHERE had_fail AND last_passed)::int AS remediated_tasks
|
|
559
|
+
FROM (
|
|
560
|
+
SELECT task_id,
|
|
561
|
+
COUNT(*) AS n,
|
|
562
|
+
BOOL_OR(NOT passed) AS had_fail,
|
|
563
|
+
(ARRAY_AGG(passed ORDER BY attempted_at DESC))[1] AS last_passed
|
|
564
|
+
FROM grade_attempts
|
|
565
|
+
WHERE attempted_at > now() - ($1 * interval '1 day')
|
|
566
|
+
GROUP BY task_id
|
|
567
|
+
) t`,
|
|
568
|
+
[d]
|
|
569
|
+
), q(
|
|
570
|
+
`SELECT COUNT(*)::int AS n
|
|
571
|
+
FROM task_grades
|
|
572
|
+
WHERE graded_at > now() - ($1 * interval '1 day')
|
|
573
|
+
AND signals->'server_guard'->>'implausible_pass_waived' IS NOT NULL`,
|
|
574
|
+
[d]
|
|
575
|
+
)]);
|
|
576
|
+
const s = rows[0] || {};
|
|
577
|
+
const tasksWithFail = Number(s.tasks_with_fail) || 0;
|
|
578
|
+
const remediated = Number(s.remediated_tasks) || 0;
|
|
579
|
+
return {
|
|
580
|
+
window_days: d,
|
|
581
|
+
total_attempts: Number(s.total_attempts) || 0,
|
|
582
|
+
graded_tasks: Number(s.graded_tasks) || 0,
|
|
583
|
+
retried_tasks: Number(s.retried_tasks) || 0,
|
|
584
|
+
tasks_with_fail: tasksWithFail,
|
|
585
|
+
remediated_tasks: remediated,
|
|
586
|
+
remediation_rate: tasksWithFail > 0 ? Math.round((remediated / tasksWithFail) * 100) / 100 : null,
|
|
587
|
+
waived_zero_issue_passes: (waived.rows[0] && Number(waived.rows[0].n)) || 0,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---- security_docs: Archon-only sensitive security documents (task 991) -----
|
|
592
|
+
//
|
|
593
|
+
// The DATED security engagement reports (exploit detail) live in the DB, NOT in
|
|
594
|
+
// the git clone (threat-model §6.3 / ADR 0020). The route layer gates every read
|
|
595
|
+
// to requireRank('archon'); these helpers are pure parameterized SQL.
|
|
596
|
+
|
|
597
|
+
// Upsert a doc by slug — insert if new, update title/content (and bump
|
|
598
|
+
// updated_at) if the slug already exists. Returns the full row.
|
|
599
|
+
|
|
600
|
+
async function acquireMergeLease({ key = 'main', holder, ttlSeconds = 600 } = {}) {
|
|
601
|
+
if (!holder) throw new Error('acquireMergeLease: holder is required');
|
|
602
|
+
const { rows } = await pool.query(
|
|
603
|
+
`INSERT INTO merge_lease (key, holder, acquired_at, expires_at)
|
|
604
|
+
VALUES ($1, $2, now(), now() + make_interval(secs => $3))
|
|
605
|
+
ON CONFLICT (key) DO UPDATE
|
|
606
|
+
SET holder = EXCLUDED.holder, acquired_at = now(), expires_at = EXCLUDED.expires_at
|
|
607
|
+
WHERE merge_lease.expires_at <= now() OR merge_lease.holder = EXCLUDED.holder
|
|
608
|
+
RETURNING holder, expires_at`,
|
|
609
|
+
[key, holder, ttlSeconds]
|
|
610
|
+
);
|
|
611
|
+
if (rows.length) return { acquired: true, holder, expires_at: rows[0].expires_at };
|
|
612
|
+
const cur = await pool.query('SELECT holder, expires_at FROM merge_lease WHERE key = $1', [key]);
|
|
613
|
+
return { acquired: false, holder: cur.rows[0]?.holder ?? null, expires_at: cur.rows[0]?.expires_at ?? null };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Release the lease IFF still held by this holder (defensive against releasing a lease that was
|
|
617
|
+
// already reclaimed + re-acquired by someone else after a TTL expiry). Returns { released }.
|
|
618
|
+
|
|
619
|
+
async function releaseMergeLease({ key = 'main', holder } = {}) {
|
|
620
|
+
if (!holder) throw new Error('releaseMergeLease: holder is required');
|
|
621
|
+
const { rowCount } = await pool.query(
|
|
622
|
+
'DELETE FROM merge_lease WHERE key = $1 AND holder = $2',
|
|
623
|
+
[key, holder]
|
|
624
|
+
);
|
|
625
|
+
return { released: rowCount > 0 };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Current lease holder + expiry (null if free). Read-only; safe on the request path.
|
|
629
|
+
|
|
630
|
+
async function mergeLeaseStatus({ key = 'main' } = {}) {
|
|
631
|
+
const { rows } = await pool.query(
|
|
632
|
+
`SELECT key, holder, acquired_at, expires_at, (expires_at <= now()) AS expired
|
|
633
|
+
FROM merge_lease WHERE key = $1`,
|
|
634
|
+
[key]
|
|
635
|
+
);
|
|
636
|
+
return rows[0] ?? null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Peer-vote → karma fold-in (V3.R25 / #245). The exact transaction that
|
|
640
|
+
// POST /tasks/peer-votes/tally runs, extracted so the route AND the
|
|
641
|
+
// server-internal weekly timer (the carved autonomy module's poller,
|
|
642
|
+
// modules/autonomy/pollers/routine-timers.js — it reaches this via the doorway
|
|
643
|
+
// `api.tallyPeerVotes`, BV1.R83 / ADR 0078 amendment) share ONE code path and
|
|
644
|
+
// can never diverge on tally semantics. Idempotent:
|
|
645
|
+
// only folds matured (>= lag days), not-yet-tallied votes, then stamps them
|
|
646
|
+
// tallied_at = now() — a re-run within the week finds nothing new. Writes
|
|
647
|
+
// karma_log with granted_by = NULL (a system fold-in has no requester); the
|
|
648
|
+
// route's Archon gate is an HTTP-auth concern, not a property of this SQL. The
|
|
649
|
+
// `FOR UPDATE OF pv` row lock is the real multi-instance guard: two processes
|
|
650
|
+
// (e.g. the timer + a manual run, or a second instance on the same DB) can't
|
|
651
|
+
// double-fold the same votes — the loser blocks, then sees tallied_at set.
|
|
652
|
+
|
|
653
|
+
const PEER_VOTE_KARMA_LAG_DAYS = 7;
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
async function tallyPeerVotes() {
|
|
657
|
+
return withTx(async (client) => {
|
|
658
|
+
const { rows: matured } = await client.query(
|
|
659
|
+
`SELECT pv.id, t.shipped_by AS builder_id, pv.vote
|
|
660
|
+
FROM peer_votes pv
|
|
661
|
+
JOIN tasks t ON t.id = pv.target_task_id
|
|
662
|
+
WHERE pv.tallied_at IS NULL
|
|
663
|
+
AND pv.recorded_at <= now() - ($1::int * interval '1 day')
|
|
664
|
+
AND t.shipped_by IS NOT NULL
|
|
665
|
+
FOR UPDATE OF pv`,
|
|
666
|
+
[PEER_VOTE_KARMA_LAG_DAYS]
|
|
667
|
+
);
|
|
668
|
+
const netByBuilder = new Map();
|
|
669
|
+
const voteIds = [];
|
|
670
|
+
for (const r of matured) {
|
|
671
|
+
voteIds.push(r.id);
|
|
672
|
+
netByBuilder.set(r.builder_id, (netByBuilder.get(r.builder_id) || 0) + r.vote);
|
|
673
|
+
}
|
|
674
|
+
const karmaRows = [];
|
|
675
|
+
for (const [builderId, net] of netByBuilder) {
|
|
676
|
+
if (net === 0) continue; // no net signal → no karma row, but still mark tallied
|
|
677
|
+
await client.query(
|
|
678
|
+
`INSERT INTO karma_log (builder_id, delta, reason, granted_by)
|
|
679
|
+
VALUES ($1, $2, 'peer_vote_weekly', NULL)`,
|
|
680
|
+
[builderId, net]
|
|
681
|
+
);
|
|
682
|
+
karmaRows.push({ builder_id: builderId, delta: net });
|
|
683
|
+
}
|
|
684
|
+
if (voteIds.length) {
|
|
685
|
+
await client.query(
|
|
686
|
+
`UPDATE peer_votes SET tallied_at = now() WHERE id = ANY($1::bigint[])`,
|
|
687
|
+
[voteIds]
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
ok: true,
|
|
692
|
+
votes_tallied: voteIds.length,
|
|
693
|
+
builders_credited: karmaRows.length,
|
|
694
|
+
karma: karmaRows,
|
|
695
|
+
lag_days: PEER_VOTE_KARMA_LAG_DAYS,
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
module.exports = {
|
|
701
|
+
PEER_VOTE_KARMA_LAG_DAYS,
|
|
702
|
+
acquireMergeLease,
|
|
703
|
+
builderAnalytics,
|
|
704
|
+
countGradeAttempts,
|
|
705
|
+
ecosystemAnalytics,
|
|
706
|
+
estimationDriftSummary,
|
|
707
|
+
getEstVsActualRatio,
|
|
708
|
+
gradeAttemptStats,
|
|
709
|
+
gradeByBuilder,
|
|
710
|
+
gradeTrendByDay,
|
|
711
|
+
logGradeAttempt,
|
|
712
|
+
mergeLeaseStatus,
|
|
713
|
+
recentGradesForBuilder,
|
|
714
|
+
recentShippedTasks,
|
|
715
|
+
recentShippedTasksByBuilder,
|
|
716
|
+
releaseMergeLease,
|
|
717
|
+
rollingAvgGrade,
|
|
718
|
+
tallyPeerVotes,
|
|
719
|
+
};
|