@worca/app 1.2.0 → 1.3.0
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// src/shared/team-metrics/aggregate.mjs
|
|
2
|
+
// Team metrics aggregation (team-metrics-design.md §4.9). PURE: no I/O, no node: imports —
|
|
3
|
+
// shared by the server (read.mjs) and the browser (the page re-aggregates per range/group/filter).
|
|
4
|
+
|
|
5
|
+
export const SUPPORTED_RECORD_VERSION = 1;
|
|
6
|
+
export const RANGES = Object.freeze(['this-month', 'last-month', 'quarter', 'year', 'all', 'custom']);
|
|
7
|
+
// Only dimensions where a run has exactly ONE key: a run's cost is one number for the whole
|
|
8
|
+
// pipeline, so it is never split across the projects it touched (`project` stays a breakdown and
|
|
9
|
+
// a filter — "runs that touched X" — but not a stack).
|
|
10
|
+
export const GROUP_BYS = Object.freeze(['workflow', 'result', 'actor']);
|
|
11
|
+
export const FILTER_DIMS = Object.freeze(['workflow', 'source', 'actor', 'project', 'models', 'result']);
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
const WEEK = 7 * DAY;
|
|
14
|
+
|
|
15
|
+
// UTC ISO-8601 only (what record.mjs writes). Anything else — "2026-09-01 00:30" (parsed in LOCAL time,
|
|
16
|
+
// so server and browser would bucket it differently), "+200000-01-01…" (expanded years) — is malformed.
|
|
17
|
+
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$/;
|
|
18
|
+
|
|
19
|
+
/** @returns {{record:object}|{malformed:true}|{unknownV:true}} */
|
|
20
|
+
export function parseRecordLine(line) {
|
|
21
|
+
let v;
|
|
22
|
+
try { v = JSON.parse(line); } catch { return { malformed: true }; }
|
|
23
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) || !Number.isInteger(v.v)) return { malformed: true };
|
|
24
|
+
if (v.v > SUPPORTED_RECORD_VERSION) return { unknownV: true };
|
|
25
|
+
if (v.v < 1 || typeof v.id !== 'string' || typeof v.startedAt !== 'string'
|
|
26
|
+
|| !ISO_UTC_RE.test(v.startedAt) || !Number.isFinite(Date.parse(v.startedAt))) return { malformed: true };
|
|
27
|
+
return { record: v };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_CUSTOM_DAYS = 3660; // ~10 years: bounds the weekly series
|
|
31
|
+
const MAX_WEEKS = Math.ceil(MAX_CUSTOM_DAYS / 7) + 2;
|
|
32
|
+
|
|
33
|
+
function parseDay(s) {
|
|
34
|
+
if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
35
|
+
const ms = Date.parse(`${s}T00:00:00Z`);
|
|
36
|
+
// Reject roll-overs such as 2026-02-31 (Date.parse would silently give Mar 3).
|
|
37
|
+
return Number.isFinite(ms) && new Date(ms).toISOString().startsWith(s) ? ms : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* UTC window + the previous window for deltas. startMs inclusive, endMs exclusive.
|
|
42
|
+
* Deltas are like-for-like: while `now` is inside the current window (this month / quarter /
|
|
43
|
+
* year so far), the previous window is cut to the same elapsed length, so "vs prev. month" on
|
|
44
|
+
* Sep 16 compares Sep 1–16 with Aug 1–16, not with all of August.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveRange(range = 'this-month', { now = Date.now(), from = null, to = null } = {}) {
|
|
47
|
+
const d = new Date(now);
|
|
48
|
+
const y = d.getUTCFullYear();
|
|
49
|
+
const m = d.getUTCMonth();
|
|
50
|
+
const win = (startMs, endMs, prevStartMs) => {
|
|
51
|
+
// `>=`, not `>`: at exactly 00:00 on the 1st the window is open with 0 elapsed, so the previous
|
|
52
|
+
// window must be 0 long too. With `>` it fell through to the closed-window branch and compared
|
|
53
|
+
// "nothing so far this month" against ALL of last month for one millisecond.
|
|
54
|
+
const elapsed = now >= startMs && now < endMs ? now - startMs : startMs - prevStartMs;
|
|
55
|
+
return { range, startMs, endMs, prevStartMs, prevEndMs: Math.min(startMs, prevStartMs + elapsed) };
|
|
56
|
+
};
|
|
57
|
+
switch (range) {
|
|
58
|
+
case 'this-month': return win(Date.UTC(y, m, 1), Date.UTC(y, m + 1, 1), Date.UTC(y, m - 1, 1));
|
|
59
|
+
case 'last-month': return win(Date.UTC(y, m - 1, 1), Date.UTC(y, m, 1), Date.UTC(y, m - 2, 1));
|
|
60
|
+
case 'quarter': { const q = Math.floor(m / 3) * 3; return win(Date.UTC(y, q, 1), Date.UTC(y, q + 3, 1), Date.UTC(y, q - 3, 1)); }
|
|
61
|
+
case 'year': return win(Date.UTC(y, 0, 1), Date.UTC(y + 1, 0, 1), Date.UTC(y - 1, 0, 1));
|
|
62
|
+
case 'all': return { range, startMs: null, endMs: null, prevStartMs: null, prevEndMs: null };
|
|
63
|
+
case 'custom': {
|
|
64
|
+
const s = parseDay(from);
|
|
65
|
+
const e = parseDay(to);
|
|
66
|
+
if (s == null || e == null || e < s) throw new RangeError('custom range needs from and to as valid YYYY-MM-DD dates with from <= to');
|
|
67
|
+
if ((e - s) / DAY > MAX_CUSTOM_DAYS) throw new RangeError(`custom range is limited to ${MAX_CUSTOM_DAYS} days`);
|
|
68
|
+
const endMs = e + DAY;
|
|
69
|
+
return { range, startMs: s, endMs, prevStartMs: s - (endMs - s), prevEndMs: s };
|
|
70
|
+
}
|
|
71
|
+
default: throw new RangeError(`unknown range "${range}" (expected ${RANGES.join(' | ')})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Monday 00:00 UTC of the week containing ms. */
|
|
76
|
+
export function weekStartMs(ms) {
|
|
77
|
+
const d = new Date(ms);
|
|
78
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const startOf = (r) => Date.parse(r.startedAt);
|
|
82
|
+
const usdOf = (r) => (Number.isFinite(r?.cost?.usd) ? r.cost.usd : 0);
|
|
83
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
84
|
+
const sum = (a) => a.reduce((s, x) => s + x, 0);
|
|
85
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
86
|
+
const round1 = (v) => Math.round(v * 10) / 10;
|
|
87
|
+
|
|
88
|
+
function percentile(values, p) {
|
|
89
|
+
const xs = values.filter(isNum).sort((a, b) => a - b);
|
|
90
|
+
if (!xs.length) return null;
|
|
91
|
+
return xs[Math.min(xs.length - 1, Math.max(0, Math.ceil(p * xs.length) - 1))]; // nearest rank
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const NONE = '__none__';
|
|
95
|
+
|
|
96
|
+
/** Dimension keys for filtering/breakdowns. Each returns [{key,label,sub?}] (a run may touch several projects). */
|
|
97
|
+
export const DIMENSIONS = {
|
|
98
|
+
workflow: (r) => [{ key: r.workflow?.id || r.workflow?.name || NONE, label: r.workflow?.name || r.workflow?.id || 'Unknown workflow' }],
|
|
99
|
+
// A hand-written or future record may carry no `result`: without the guard it became the stack
|
|
100
|
+
// key "undefined" with an undefined label.
|
|
101
|
+
result: (r) => [{ key: typeof r.result === 'string' && r.result ? r.result : NONE, label: typeof r.result === 'string' && r.result ? r.result : 'Unknown result' }],
|
|
102
|
+
actor: (r) => [{ key: r.actor || NONE, label: r.actor || 'No actor' }],
|
|
103
|
+
source: (r) => (r.source
|
|
104
|
+
? [{ key: `${r.source.type}:${r.source.ref ?? r.source.url ?? ''}`, label: [r.source.ref, r.source.title].filter(Boolean).join(' ') || r.source.url || r.source.type, sub: r.source.type }]
|
|
105
|
+
: [{ key: NONE, label: 'No ticket', sub: 'ad-hoc prompts' }]),
|
|
106
|
+
models: (r) => {
|
|
107
|
+
const ms = Array.isArray(r.agents?.models) ? [...r.agents.models].sort() : [];
|
|
108
|
+
return [{ key: ms.join(' + ') || NONE, label: ms.join(' + ') || 'Unknown models' }];
|
|
109
|
+
},
|
|
110
|
+
project: (r) => {
|
|
111
|
+
if (r.target?.kind === 'workspace') {
|
|
112
|
+
const t = Array.isArray(r.target.touched) ? r.target.touched : [];
|
|
113
|
+
// `files`: this member's own changed-file count (target.touchedFiles, additive); null on a
|
|
114
|
+
// record that predates it — the run's total is NOT this project's, so the row says "–".
|
|
115
|
+
const tf = r.target.touchedFiles && typeof r.target.touchedFiles === 'object' ? r.target.touchedFiles : null;
|
|
116
|
+
return t.length
|
|
117
|
+
? t.map((p) => ({ key: p, label: p, files: tf && Number.isInteger(tf[p]) ? tf[p] : null }))
|
|
118
|
+
: [{ key: NONE, label: 'No project touched', files: null }];
|
|
119
|
+
}
|
|
120
|
+
return [{ key: r.target?.project || NONE, label: r.target?.project || 'Unknown project' }];
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function applyFilter(records, filter = {}) {
|
|
125
|
+
const active = Object.entries(filter || {}).filter(([dim, key]) => FILTER_DIMS.includes(dim) && key != null && key !== '');
|
|
126
|
+
if (!active.length) return records;
|
|
127
|
+
return records.filter((r) => active.every(([dim, key]) => DIMENSIONS[dim](r).some((k) => k.key === key)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function computeKpis(rs, now) {
|
|
131
|
+
const n = rs.length;
|
|
132
|
+
const count = (res) => rs.filter((r) => r.result === res).length;
|
|
133
|
+
const usd = sum(rs.map(usdOf));
|
|
134
|
+
const paired = rs.filter((r) => isNum(r.wallMs) && isNum(r.activeMs));
|
|
135
|
+
const wallSum = sum(paired.map((r) => r.wallMs));
|
|
136
|
+
const withPr = rs.filter((r) => r.pr && (r.pr.url || r.pr.number != null));
|
|
137
|
+
const reviews = rs.map((r) => r.cycles?.review).filter(isNum);
|
|
138
|
+
const d = new Date(now);
|
|
139
|
+
const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
|
140
|
+
return {
|
|
141
|
+
spendUsd: round2(usd),
|
|
142
|
+
spendThisMonthUsd: round2(sum(rs.filter((r) => startOf(r) >= monthStart).map(usdOf))),
|
|
143
|
+
runs: n,
|
|
144
|
+
done: count('done'), failed: count('failed'), stopped: count('stopped'),
|
|
145
|
+
successRate: n ? count('done') / n : null,
|
|
146
|
+
costPerRunUsd: n ? round2(usd / n) : null,
|
|
147
|
+
costPerRunMedianUsd: percentile(rs.map(usdOf), 0.5),
|
|
148
|
+
costPerRunP90Usd: percentile(rs.map(usdOf), 0.9),
|
|
149
|
+
runsWithPr: withPr.length,
|
|
150
|
+
costPerRunWithPrUsd: withPr.length ? round2(sum(withPr.map(usdOf)) / withPr.length) : null,
|
|
151
|
+
durationMedianMs: percentile(rs.map((r) => r.wallMs), 0.5),
|
|
152
|
+
machineMs: sum(rs.map((r) => r.activeMs).filter(isNum)),
|
|
153
|
+
autonomy: wallSum > 0 ? sum(paired.map((r) => r.activeMs)) / wallSum : null,
|
|
154
|
+
interventionsPerRun: n ? round1(sum(rs.map((r) => (r.interventions?.questions | 0) + (r.interventions?.pauses | 0))) / n) : null,
|
|
155
|
+
reviewCyclesMean: reviews.length ? round1(sum(reviews) / reviews.length) : null,
|
|
156
|
+
convergeInOneRate: reviews.length ? reviews.filter((c) => c <= 1).length / reviews.length : null,
|
|
157
|
+
filesChanged: sum(rs.map((r) => r.git?.filesChanged).filter(isNum)),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pct = (cur, prev) => (isNum(cur) && isNum(prev) && prev > 0 ? (cur - prev) / prev : null);
|
|
162
|
+
|
|
163
|
+
function deltas(k, p) {
|
|
164
|
+
return {
|
|
165
|
+
spendPct: pct(k.spendUsd, p.spendUsd),
|
|
166
|
+
runsPct: pct(k.runs, p.runs),
|
|
167
|
+
costPerRunPct: pct(k.costPerRunUsd, p.costPerRunUsd),
|
|
168
|
+
durationPct: pct(k.durationMedianMs, p.durationMedianMs),
|
|
169
|
+
autonomyPts: isNum(k.autonomy) && isNum(p.autonomy) ? Math.round((k.autonomy - p.autonomy) * 100) : null,
|
|
170
|
+
reviewCycles: isNum(k.reviewCyclesMean) && isNum(p.reviewCyclesMean) ? round1(k.reviewCyclesMean - p.reviewCyclesMean) : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function breakdown(rs, dim) {
|
|
175
|
+
const rows = new Map();
|
|
176
|
+
for (const r of rs) {
|
|
177
|
+
for (const k of DIMENSIONS[dim](r)) {
|
|
178
|
+
const row = rows.get(k.key) || { key: k.key, label: k.label, sub: k.sub ?? null, runs: 0, usd: 0, done: 0, reviews: [], filesChanged: 0, filesUnknown: 0 };
|
|
179
|
+
row.runs += 1;
|
|
180
|
+
row.usd += usdOf(r); // "a run touching two projects counts in both"
|
|
181
|
+
if (r.result === 'done') row.done += 1;
|
|
182
|
+
if (isNum(r.cycles?.review)) row.reviews.push(r.cycles.review);
|
|
183
|
+
// A key that carries its own count (a touched member) uses it; every other dimension is
|
|
184
|
+
// whole-run, so the run's total applies. A member without a count is counted as unknown.
|
|
185
|
+
const files = 'files' in k ? k.files : (isNum(r.git?.filesChanged) ? r.git.filesChanged : null);
|
|
186
|
+
if (isNum(files)) row.filesChanged += files; else row.filesUnknown += 1;
|
|
187
|
+
rows.set(k.key, row);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// `share` is normalised against THIS column's own sum, not against the run total. For every
|
|
191
|
+
// single-key dimension the two are equal; for `project` on a workspace scope a run touching two
|
|
192
|
+
// projects deliberately counts its full spend in BOTH rows (decision 19, the mockup subtitle),
|
|
193
|
+
// so dividing by the run total made the bars sum to 200%. The table answers "what did the runs
|
|
194
|
+
// that touched this project cost"; no chart splits a run's cost across projects (GROUP_BYS).
|
|
195
|
+
const colTotal = [...rows.values()].reduce((a, r) => a + r.usd, 0);
|
|
196
|
+
// `runShare`: the fraction of the runs in range that touched this key — the per-project bar
|
|
197
|
+
// (spend is not a per-project fact, so the project table shows this instead of a spend share).
|
|
198
|
+
const runTotal = rs.length;
|
|
199
|
+
return [...rows.values()]
|
|
200
|
+
.map((row) => ({
|
|
201
|
+
key: row.key, label: row.label, sub: row.sub, runs: row.runs, usd: round2(row.usd),
|
|
202
|
+
runShare: runTotal > 0 ? row.runs / runTotal : 0,
|
|
203
|
+
perRunUsd: row.runs ? round2(row.usd / row.runs) : null,
|
|
204
|
+
successRate: row.runs ? row.done / row.runs : null,
|
|
205
|
+
cyclesMean: row.reviews.length ? round1(sum(row.reviews) / row.reviews.length) : null,
|
|
206
|
+
share: colTotal > 0 ? row.usd / colTotal : 0,
|
|
207
|
+
// null when no run in the row carries a count: the cell shows "–", never a wrong number.
|
|
208
|
+
filesChanged: row.filesUnknown === row.runs ? null : row.filesChanged,
|
|
209
|
+
filesUnknown: row.filesUnknown,
|
|
210
|
+
isNone: row.key === NONE,
|
|
211
|
+
}))
|
|
212
|
+
// Projects order by what the table shows (runs touched, then files); every other dimension by spend.
|
|
213
|
+
.sort((a, b) => (a.isNone - b.isNone) || (dim === 'project'
|
|
214
|
+
? (b.runs - a.runs) || ((b.filesChanged ?? -1) - (a.filesChanged ?? -1)) || a.label.localeCompare(b.label)
|
|
215
|
+
: (b.usd - a.usd) || (b.runs - a.runs)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function weekSpan(rs, win, now) {
|
|
219
|
+
// reduce, not Math.min(...spread): a spread over ~100k+ records overflows the call stack.
|
|
220
|
+
let minStart = Infinity;
|
|
221
|
+
let maxStart = -Infinity;
|
|
222
|
+
for (const r of rs) { const t = startOf(r); if (t < minStart) minStart = t; if (t > maxStart) maxStart = t; }
|
|
223
|
+
const first = win.startMs ?? (rs.length ? minStart : null);
|
|
224
|
+
if (first == null) return [];
|
|
225
|
+
// Series stop at `now`, but never before the newest record in range (a teammate's clock may run
|
|
226
|
+
// ahead) — by at most 31 days: records come from a branch anyone with push access can write,
|
|
227
|
+
// and one record dated year 20000 must not produce ~900k weeks (a 100 MB response / Map overflow).
|
|
228
|
+
const cap = Math.max(now, rs.length ? Math.min(maxStart, now + 31 * DAY) : -Infinity);
|
|
229
|
+
const last = Math.min(win.endMs != null ? win.endMs - 1 : cap, cap);
|
|
230
|
+
const to = weekStartMs(Math.max(first, last));
|
|
231
|
+
const from = Math.max(weekStartMs(first), to - (MAX_WEEKS - 1) * WEEK); // at most ~10 years of columns
|
|
232
|
+
const weeks = [];
|
|
233
|
+
for (let w = from; w <= to; w += WEEK) weeks.push(w);
|
|
234
|
+
return weeks;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function series(rs, win, groupBy, now) {
|
|
238
|
+
const weeks = weekSpan(rs, win, now);
|
|
239
|
+
const idx = new Map(weeks.map((w, i) => [w, i]));
|
|
240
|
+
// Null-prototype maps: keys are record-supplied strings ("constructor", "__proto__" must not collide).
|
|
241
|
+
const spend = weeks.map((w) => ({ weekStartMs: w, totalUsd: 0, stacks: Object.create(null) }));
|
|
242
|
+
const runs = weeks.map((w) => ({ weekStartMs: w, done: 0, failed: 0, stopped: 0 }));
|
|
243
|
+
const labels = Object.create(null);
|
|
244
|
+
const totals = Object.create(null);
|
|
245
|
+
for (const r of rs) {
|
|
246
|
+
const i = idx.get(weekStartMs(startOf(r)));
|
|
247
|
+
if (i == null) continue;
|
|
248
|
+
if (r.result === 'done' || r.result === 'failed' || r.result === 'stopped') runs[i][r.result] += 1;
|
|
249
|
+
spend[i].totalUsd += usdOf(r);
|
|
250
|
+
for (const k of DIMENSIONS[groupBy](r)) {
|
|
251
|
+
const v = usdOf(r); // one key per run for every GROUP_BYS dimension
|
|
252
|
+
spend[i].stacks[k.key] = (spend[i].stacks[k.key] || 0) + v;
|
|
253
|
+
totals[k.key] = (totals[k.key] || 0) + v;
|
|
254
|
+
labels[k.key] = k.label;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const pt of spend) {
|
|
258
|
+
pt.totalUsd = round2(pt.totalUsd);
|
|
259
|
+
// Back to a plain object (Object.fromEntries defines own data properties, so "__proto__" survives)
|
|
260
|
+
// — callers and deepStrictEqual expect ordinary objects.
|
|
261
|
+
pt.stacks = Object.fromEntries(Object.entries(pt.stacks).map(([key, v]) => [key, round2(v)]));
|
|
262
|
+
}
|
|
263
|
+
const stackKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a])
|
|
264
|
+
.map((key) => ({ key, label: labels[key], totalUsd: round2(totals[key]) }));
|
|
265
|
+
return { spend, runs, stackKeys };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Records come from a branch anyone with push access can write: only http(s) links survive. */
|
|
269
|
+
export function safeHttpUrl(u) {
|
|
270
|
+
if (typeof u !== 'string' || !u) return null;
|
|
271
|
+
try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toRunRow(r) {
|
|
275
|
+
return {
|
|
276
|
+
id: r.id, title: r.title ?? '(untitled)', startedAt: r.startedAt,
|
|
277
|
+
workflow: r.workflow?.name ?? r.workflow?.id ?? null, result: r.result,
|
|
278
|
+
usd: usdOf(r), wallMs: isNum(r.wallMs) ? r.wallMs : null, activeMs: isNum(r.activeMs) ? r.activeMs : null,
|
|
279
|
+
reviewCycles: isNum(r.cycles?.review) ? r.cycles.review : null,
|
|
280
|
+
pr: r.pr && (r.pr.url || r.pr.number != null) ? { number: Number.isInteger(r.pr.number) ? r.pr.number : null, url: safeHttpUrl(r.pr.url) } : null,
|
|
281
|
+
actor: r.actor ?? null,
|
|
282
|
+
source: r.source ? [r.source.ref, r.source.title].filter(Boolean).join(' ') : null,
|
|
283
|
+
projects: r.target?.kind === 'workspace'
|
|
284
|
+
? (Array.isArray(r.target.touched) ? r.target.touched.filter((p) => typeof p === 'string') : [])
|
|
285
|
+
: [r.target?.project].filter((p) => typeof p === 'string' && p),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {object[]} records v1 records (already parsed; unknown v / malformed already dropped)
|
|
291
|
+
* @param {{range?:string, from?:string|null, to?:string|null, groupBy?:string, filter?:object, now?:number}} [opts]
|
|
292
|
+
*/
|
|
293
|
+
export function aggregate(records, { range = 'this-month', from = null, to = null, groupBy = 'workflow', filter = {}, now = Date.now() } = {}) {
|
|
294
|
+
if (!GROUP_BYS.includes(groupBy)) throw new RangeError(`unknown groupBy "${groupBy}" (expected ${GROUP_BYS.join(' | ')})`);
|
|
295
|
+
const win = resolveRange(range, { now, from, to });
|
|
296
|
+
const usable = (records || []).filter((r) => r && Number.isFinite(startOf(r)));
|
|
297
|
+
const filtered = applyFilter(usable, filter);
|
|
298
|
+
const inWin = (s, e) => (r) => (s == null || startOf(r) >= s) && (e == null || startOf(r) < e);
|
|
299
|
+
const inRange = filtered.filter(inWin(win.startMs, win.endMs));
|
|
300
|
+
const kpis = computeKpis(inRange, now);
|
|
301
|
+
const prevKpis = win.prevStartMs == null ? null : computeKpis(filtered.filter(inWin(win.prevStartMs, win.prevEndMs)), now);
|
|
302
|
+
const hasActor = inRange.some((r) => r.actor);
|
|
303
|
+
const hasWorkspace = inRange.some((r) => r.target?.kind === 'workspace');
|
|
304
|
+
return {
|
|
305
|
+
range: win, groupBy, filter: { ...filter },
|
|
306
|
+
kpis, prev: prevKpis, deltas: prevKpis ? deltas(kpis, prevKpis) : null,
|
|
307
|
+
series: series(inRange, win, groupBy, now),
|
|
308
|
+
breakdowns: {
|
|
309
|
+
workflow: breakdown(inRange, 'workflow'),
|
|
310
|
+
source: breakdown(inRange, 'source'),
|
|
311
|
+
actor: hasActor ? breakdown(inRange, 'actor') : null,
|
|
312
|
+
project: hasWorkspace ? breakdown(inRange, 'project') : null,
|
|
313
|
+
models: breakdown(inRange, 'models'),
|
|
314
|
+
},
|
|
315
|
+
runs: [...inRange].sort((a, b) => startOf(b) - startOf(a)).map(toRunRow),
|
|
316
|
+
totalRecords: usable.length,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const CSV_COLUMNS = Object.freeze(['startedAt', 'title', 'workflow', 'result', 'costUsd', 'wallMs', 'activeMs', 'reviewCycles', 'prNumber', 'prUrl', 'actor', 'source', 'projects', 'id']);
|
|
321
|
+
|
|
322
|
+
function csvCell(v) {
|
|
323
|
+
if (v == null) return '';
|
|
324
|
+
let s = String(v);
|
|
325
|
+
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; // spreadsheet formula injection guard
|
|
326
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** CSV of aggregate().runs rows (the filtered run table). */
|
|
330
|
+
export function toCsv(runRows) {
|
|
331
|
+
const lines = [CSV_COLUMNS.join(',')];
|
|
332
|
+
for (const r of runRows) {
|
|
333
|
+
lines.push([
|
|
334
|
+
r.startedAt, r.title, r.workflow, r.result, r.usd, r.wallMs, r.activeMs, r.reviewCycles,
|
|
335
|
+
r.pr?.number, r.pr?.url, r.actor, r.source, (r.projects || []).join(' '), r.id,
|
|
336
|
+
].map(csvCell).join(','));
|
|
337
|
+
}
|
|
338
|
+
// BOM: Excel otherwise reads the file as the local code page and mangles every non-ASCII
|
|
339
|
+
// actor/title ("Siniša Đukić"). Sheets and LibreOffice ignore it.
|
|
340
|
+
return '' + lines.join('\r\n') + '\r\n';
|
|
341
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/shared/team-metrics/workspace-match.mjs
|
|
2
|
+
// Does a workspace run record belong to workspace `ws`? Records written since
|
|
3
|
+
// `target.workspaceId` exists match on the stable id; older records (and hand-written
|
|
4
|
+
// ones) carry only the display name, so they fall back to a case-insensitive name
|
|
5
|
+
// match. Matching on the name alone orphaned a workspace's whole history on rename and
|
|
6
|
+
// pooled two same-named workspaces of different teammates.
|
|
7
|
+
export function matchesWorkspace(target, ws) {
|
|
8
|
+
if (!target || target.kind !== 'workspace' || !ws) return false;
|
|
9
|
+
const id = typeof target.workspaceId === 'string' && target.workspaceId ? target.workspaceId : null;
|
|
10
|
+
if (id && typeof ws.id === 'string' && ws.id) return id === ws.id;
|
|
11
|
+
const name = typeof ws.name === 'string' ? ws.name.toLowerCase() : '';
|
|
12
|
+
return !!name && String(target.workspace || '').toLowerCase() === name;
|
|
13
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ui/public/about-links.mjs
|
|
2
|
+
// The About card's outbound links. Split out of app.js's paintAbout so a test can
|
|
3
|
+
// drive it against the real #about-card markup without booting the whole app —
|
|
4
|
+
// app.js has no exports at all. Pure DOM in, pure DOM out; no fetch, no listeners.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Point the two feedback anchors at `info.bugsUrl` (package.json bugs.url, served
|
|
8
|
+
* as `app.bugsUrl` by GET /api/settings). A missing or empty bugsUrl leaves the
|
|
9
|
+
* static hrefs in index.html alone, so the links work before /api/settings lands.
|
|
10
|
+
*
|
|
11
|
+
* @param {ParentNode} root any node containing #aboutBugLink / #aboutIdeaLink
|
|
12
|
+
* @param {{bugsUrl?:string}} info
|
|
13
|
+
*/
|
|
14
|
+
export function paintAboutInto(root, info = {}) {
|
|
15
|
+
if (!root || !info || typeof info.bugsUrl !== 'string' || !info.bugsUrl) return;
|
|
16
|
+
const base = info.bugsUrl.replace(/\/+$/, '');
|
|
17
|
+
const bug = root.querySelector('#aboutBugLink');
|
|
18
|
+
const idea = root.querySelector('#aboutIdeaLink');
|
|
19
|
+
if (bug) bug.href = `${base}/new?labels=bug`;
|
|
20
|
+
if (idea) idea.href = `${base}/new?labels=enhancement`;
|
|
21
|
+
}
|