@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.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/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- package/src/worker.ts +7 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { ModelLimit } from "@ericsanchezok/synergy-util/model-limit";
|
|
2
|
+
import { RolloutAccounting } from "@ericsanchezok/synergy-harness/session/rollout/accounting";
|
|
3
|
+
export var Rollup;
|
|
4
|
+
(function (Rollup) {
|
|
5
|
+
// -----------------------------------------------------------------------
|
|
6
|
+
// Token helpers
|
|
7
|
+
// -----------------------------------------------------------------------
|
|
8
|
+
const ZERO_TOKENS = {
|
|
9
|
+
input: 0,
|
|
10
|
+
output: 0,
|
|
11
|
+
reasoning: 0,
|
|
12
|
+
cache: { read: 0, write: 0 },
|
|
13
|
+
};
|
|
14
|
+
function addTokens(a, b) {
|
|
15
|
+
return {
|
|
16
|
+
input: a.input + b.input,
|
|
17
|
+
output: a.output + b.output,
|
|
18
|
+
reasoning: a.reasoning + b.reasoning,
|
|
19
|
+
cache: {
|
|
20
|
+
read: a.cache.read + b.cache.read,
|
|
21
|
+
write: a.cache.write + b.cache.write,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function totalTokens(t) {
|
|
26
|
+
return ModelLimit.totalTokens(t);
|
|
27
|
+
}
|
|
28
|
+
function dayKey(timestamp) {
|
|
29
|
+
const d = new Date(timestamp);
|
|
30
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
function hourOfDay(timestamp) {
|
|
33
|
+
return new Date(timestamp).getHours();
|
|
34
|
+
}
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
// Dimension 1 — Overview
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
function computeOverview(digests) {
|
|
39
|
+
const archived = digests.filter((d) => d.archived !== undefined);
|
|
40
|
+
const daySet = new Set();
|
|
41
|
+
for (const d of digests)
|
|
42
|
+
daySet.add(dayKey(d.created));
|
|
43
|
+
const scopeSet = new Set();
|
|
44
|
+
for (const d of digests)
|
|
45
|
+
scopeSet.add(d.scopeID);
|
|
46
|
+
let longestStreak = 0;
|
|
47
|
+
let currentStreak = 0;
|
|
48
|
+
if (daySet.size > 0) {
|
|
49
|
+
const sortedDays = [...daySet].sort();
|
|
50
|
+
let prev = 0;
|
|
51
|
+
for (const day of sortedDays) {
|
|
52
|
+
const ts = new Date(day).getTime();
|
|
53
|
+
if (prev > 0 && ts - prev <= 86_400_000) {
|
|
54
|
+
currentStreak++;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
currentStreak = 1;
|
|
58
|
+
}
|
|
59
|
+
longestStreak = Math.max(longestStreak, currentStreak);
|
|
60
|
+
prev = ts;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check if current streak extends to today
|
|
64
|
+
const today = dayKey(Date.now());
|
|
65
|
+
const sortedDays = [...daySet].sort();
|
|
66
|
+
if (sortedDays.length > 0 && sortedDays[sortedDays.length - 1] !== today) {
|
|
67
|
+
// Current streak already ended
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
totalSessions: digests.length,
|
|
71
|
+
activeSessions: digests.length - archived.length,
|
|
72
|
+
archivedSessions: archived.length,
|
|
73
|
+
totalMessages: digests.reduce((s, d) => s + d.messages, 0),
|
|
74
|
+
totalTurns: digests.reduce((s, d) => s + d.turns, 0),
|
|
75
|
+
totalDays: daySet.size,
|
|
76
|
+
longestStreak,
|
|
77
|
+
currentStreak,
|
|
78
|
+
projectCount: scopeSet.size,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// -----------------------------------------------------------------------
|
|
82
|
+
// Dimension 2 — Tokens & Cost
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
function computeTokenCost(digests) {
|
|
85
|
+
const tokens = digests.reduce((acc, d) => addTokens(acc, d.tokens), { ...ZERO_TOKENS });
|
|
86
|
+
const cost = digests.reduce((s, d) => s + d.cost, 0);
|
|
87
|
+
const turns = digests.reduce((s, d) => s + d.turns, 0);
|
|
88
|
+
const days = new Set(digests.map((d) => dayKey(d.created))).size || 1;
|
|
89
|
+
const cacheTotal = tokens.input + tokens.cache.read + tokens.cache.write;
|
|
90
|
+
const total = totalTokens(tokens);
|
|
91
|
+
return {
|
|
92
|
+
tokens,
|
|
93
|
+
cost,
|
|
94
|
+
accounting: RolloutAccounting.merge(digests.flatMap((d) => (d.accounting ? [d.accounting] : []))),
|
|
95
|
+
cacheHitRate: cacheTotal > 0 ? tokens.cache.read / cacheTotal : 0,
|
|
96
|
+
avgCostPerTurn: turns > 0 ? cost / turns : 0,
|
|
97
|
+
avgTokensPerTurn: turns > 0 ? total / turns : 0,
|
|
98
|
+
dailyCost: cost / days,
|
|
99
|
+
dailyTokens: total / days,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// -----------------------------------------------------------------------
|
|
103
|
+
// Dimension 3 — By Model
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
function computeModels(digests) {
|
|
106
|
+
const map = new Map();
|
|
107
|
+
for (const d of digests) {
|
|
108
|
+
for (const [key, usage] of Object.entries(d.modelUsage)) {
|
|
109
|
+
const existing = map.get(key);
|
|
110
|
+
if (existing) {
|
|
111
|
+
const previousCalls = existing.accounting
|
|
112
|
+
? existing.accounting.calls + existing.accounting.legacy.messages
|
|
113
|
+
: existing.messages;
|
|
114
|
+
const incomingCalls = usage.accounting
|
|
115
|
+
? usage.accounting.calls + usage.accounting.legacy.messages
|
|
116
|
+
: usage.messages;
|
|
117
|
+
const responseMs = existing.avgResponseMs * previousCalls + usage.totalResponseMs;
|
|
118
|
+
existing.messages += usage.messages;
|
|
119
|
+
existing.turns += usage.messages;
|
|
120
|
+
existing.tokens = addTokens(existing.tokens, usage.tokens);
|
|
121
|
+
existing.cost += usage.cost;
|
|
122
|
+
existing.accounting = RolloutAccounting.merge([
|
|
123
|
+
existing.accounting ?? RolloutAccounting.empty(),
|
|
124
|
+
usage.accounting ?? RolloutAccounting.empty(),
|
|
125
|
+
]);
|
|
126
|
+
existing.avgResponseMs = previousCalls + incomingCalls > 0 ? responseMs / (previousCalls + incomingCalls) : 0;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const [providerID, modelID] = key.split("/");
|
|
130
|
+
map.set(key, {
|
|
131
|
+
providerID: providerID ?? "",
|
|
132
|
+
modelID: modelID ?? "",
|
|
133
|
+
messages: usage.messages,
|
|
134
|
+
turns: usage.messages,
|
|
135
|
+
tokens: { ...usage.tokens, cache: { ...usage.tokens.cache } },
|
|
136
|
+
cost: usage.cost,
|
|
137
|
+
accounting: usage.accounting,
|
|
138
|
+
avgResponseMs: usage.totalResponseMs /
|
|
139
|
+
Math.max(1, usage.accounting ? usage.accounting.calls + usage.accounting.legacy.messages : usage.messages),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const models = [...map.values()].sort((a, b) => b.messages - a.messages);
|
|
145
|
+
return { models };
|
|
146
|
+
}
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
// Dimension 4 — By Agent
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
function computeAgents(digests) {
|
|
151
|
+
const map = new Map();
|
|
152
|
+
const sessionAgents = new Map();
|
|
153
|
+
let totalSubagentCalls = 0;
|
|
154
|
+
for (const d of digests) {
|
|
155
|
+
if ("parentID" in d && d.parentID)
|
|
156
|
+
totalSubagentCalls++;
|
|
157
|
+
for (const [agent, usage] of Object.entries(d.agentUsage)) {
|
|
158
|
+
const existing = map.get(agent);
|
|
159
|
+
if (existing) {
|
|
160
|
+
existing.messages += usage.messages;
|
|
161
|
+
existing.tokens = addTokens(existing.tokens, usage.tokens);
|
|
162
|
+
existing.cost += usage.cost;
|
|
163
|
+
existing.accounting = RolloutAccounting.merge([
|
|
164
|
+
existing.accounting ?? RolloutAccounting.empty(),
|
|
165
|
+
usage.accounting ?? RolloutAccounting.empty(),
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
map.set(agent, {
|
|
170
|
+
agent,
|
|
171
|
+
messages: usage.messages,
|
|
172
|
+
sessions: 0,
|
|
173
|
+
tokens: { ...usage.tokens, cache: { ...usage.tokens.cache } },
|
|
174
|
+
cost: usage.cost,
|
|
175
|
+
accounting: usage.accounting,
|
|
176
|
+
subagentInvocations: 0,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if ("sessionID" in d) {
|
|
180
|
+
if (!sessionAgents.has(d.sessionID))
|
|
181
|
+
sessionAgents.set(d.sessionID, new Set());
|
|
182
|
+
sessionAgents.get(d.sessionID).add(agent);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Count sessions per agent
|
|
187
|
+
for (const agents of sessionAgents.values()) {
|
|
188
|
+
for (const agent of agents) {
|
|
189
|
+
const entry = map.get(agent);
|
|
190
|
+
if (entry)
|
|
191
|
+
entry.sessions++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Subagent invocations: for each session that IS a subagent (has parentID),
|
|
195
|
+
// credit its agent
|
|
196
|
+
for (const d of digests) {
|
|
197
|
+
if ("parentID" in d && d.parentID) {
|
|
198
|
+
// The agent of this subagent session
|
|
199
|
+
const topAgent = Object.keys(d.agentUsage)[0];
|
|
200
|
+
if (topAgent) {
|
|
201
|
+
const entry = map.get(topAgent);
|
|
202
|
+
if (entry)
|
|
203
|
+
entry.subagentInvocations++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const agents = [...map.values()].sort((a, b) => b.messages - a.messages);
|
|
208
|
+
return { agents, totalSubagentCalls };
|
|
209
|
+
}
|
|
210
|
+
// -----------------------------------------------------------------------
|
|
211
|
+
// Dimension 5 — By Tool
|
|
212
|
+
// -----------------------------------------------------------------------
|
|
213
|
+
function computeTools(digests) {
|
|
214
|
+
const map = new Map();
|
|
215
|
+
const timings = new Map();
|
|
216
|
+
for (const d of digests) {
|
|
217
|
+
for (const [tool, usage] of Object.entries(d.toolUsage)) {
|
|
218
|
+
const timing = timings.get(tool) ?? { duration: 0, samples: 0 };
|
|
219
|
+
timing.duration += usage.totalDurationMs;
|
|
220
|
+
timing.samples += usage.timedSamples ?? usage.calls;
|
|
221
|
+
timings.set(tool, timing);
|
|
222
|
+
const existing = map.get(tool);
|
|
223
|
+
if (existing) {
|
|
224
|
+
existing.calls += usage.calls;
|
|
225
|
+
existing.successes += usage.successes;
|
|
226
|
+
existing.errors += usage.errors;
|
|
227
|
+
existing.avgDurationMs = timing.samples > 0 ? timing.duration / timing.samples : 0;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
map.set(tool, {
|
|
231
|
+
tool,
|
|
232
|
+
calls: usage.calls,
|
|
233
|
+
successes: usage.successes,
|
|
234
|
+
errors: usage.errors,
|
|
235
|
+
avgDurationMs: timing.samples > 0 ? timing.duration / timing.samples : 0,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const tools = [...map.values()].sort((a, b) => b.calls - a.calls);
|
|
241
|
+
return { tools };
|
|
242
|
+
}
|
|
243
|
+
// -----------------------------------------------------------------------
|
|
244
|
+
// Dimension 6 — Code Changes
|
|
245
|
+
// -----------------------------------------------------------------------
|
|
246
|
+
function computeCodeChanges(digests) {
|
|
247
|
+
const totalAdditions = digests.reduce((s, d) => s + d.additions, 0);
|
|
248
|
+
const totalDeletions = digests.reduce((s, d) => s + d.deletions, 0);
|
|
249
|
+
const days = new Set(digests.map((d) => dayKey(d.created))).size || 1;
|
|
250
|
+
return {
|
|
251
|
+
totalAdditions,
|
|
252
|
+
totalDeletions,
|
|
253
|
+
totalFiles: digests.reduce((s, d) => s + d.files, 0),
|
|
254
|
+
netLines: totalAdditions - totalDeletions,
|
|
255
|
+
dailyAdditions: totalAdditions / days,
|
|
256
|
+
dailyDeletions: totalDeletions / days,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// -----------------------------------------------------------------------
|
|
260
|
+
// Dimension 7 — Session Lifecycle
|
|
261
|
+
// -----------------------------------------------------------------------
|
|
262
|
+
function computeLifecycle(digests) {
|
|
263
|
+
const pinnedCount = digests.filter((d) => d.pinned).length;
|
|
264
|
+
const turnsPerSession = digests.map((d) => d.turns).sort((a, b) => a - b);
|
|
265
|
+
const avgTurns = turnsPerSession.length > 0 ? turnsPerSession.reduce((s, t) => s + t, 0) / turnsPerSession.length : 0;
|
|
266
|
+
const mid = Math.floor(turnsPerSession.length / 2);
|
|
267
|
+
const medianTurns = turnsPerSession.length === 0
|
|
268
|
+
? 0
|
|
269
|
+
: turnsPerSession.length % 2 === 0
|
|
270
|
+
? (turnsPerSession[mid - 1] + turnsPerSession[mid]) / 2
|
|
271
|
+
: turnsPerSession[mid];
|
|
272
|
+
const compactionCount = digests.reduce((s, d) => s + d.compactionCount, 0);
|
|
273
|
+
const retryCount = digests.reduce((s, d) => s + d.retryCount, 0);
|
|
274
|
+
const errorCount = digests.reduce((s, d) => s + d.errorCount, 0);
|
|
275
|
+
const totalMessages = digests.reduce((s, d) => s + d.messages, 0);
|
|
276
|
+
let short = 0;
|
|
277
|
+
let medium = 0;
|
|
278
|
+
let long = 0;
|
|
279
|
+
for (const d of digests) {
|
|
280
|
+
if (d.durationMs < 5 * 60_000)
|
|
281
|
+
short++;
|
|
282
|
+
else if (d.durationMs < 30 * 60_000)
|
|
283
|
+
medium++;
|
|
284
|
+
else
|
|
285
|
+
long++;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
pinnedCount,
|
|
289
|
+
avgTurnsPerSession: avgTurns,
|
|
290
|
+
medianTurnsPerSession: medianTurns,
|
|
291
|
+
compactionCount,
|
|
292
|
+
retryCount,
|
|
293
|
+
errorCount,
|
|
294
|
+
errorRate: totalMessages > 0 ? errorCount / totalMessages : 0,
|
|
295
|
+
durationBuckets: { short, medium, long },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// -----------------------------------------------------------------------
|
|
299
|
+
// Dimension 8 — By Channel
|
|
300
|
+
// -----------------------------------------------------------------------
|
|
301
|
+
function computeChannels(digests) {
|
|
302
|
+
const map = new Map();
|
|
303
|
+
let interactiveSessions = 0;
|
|
304
|
+
let unattendedSessions = 0;
|
|
305
|
+
for (const d of digests) {
|
|
306
|
+
const channel = d.endpoint?.type ?? d.endpoint?.kind ?? "web";
|
|
307
|
+
const existing = map.get(channel);
|
|
308
|
+
if (existing) {
|
|
309
|
+
existing.sessions++;
|
|
310
|
+
existing.messages += d.messages;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
map.set(channel, { channel, sessions: 1, messages: d.messages });
|
|
314
|
+
}
|
|
315
|
+
if (d.interaction?.mode === "unattended")
|
|
316
|
+
unattendedSessions++;
|
|
317
|
+
else
|
|
318
|
+
interactiveSessions++;
|
|
319
|
+
}
|
|
320
|
+
const channels = [...map.values()].sort((a, b) => b.sessions - a.sessions);
|
|
321
|
+
return { channels, interactiveSessions, unattendedSessions };
|
|
322
|
+
}
|
|
323
|
+
// -----------------------------------------------------------------------
|
|
324
|
+
// Dimension 9 — Time Series
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
function computeTimeSeries(digests) {
|
|
327
|
+
const dayMap = new Map();
|
|
328
|
+
const hourMap = new Map();
|
|
329
|
+
const hourlyActivity = new Array(24).fill(0);
|
|
330
|
+
for (const d of digests) {
|
|
331
|
+
const day = dayKey(d.created);
|
|
332
|
+
const existing = dayMap.get(day);
|
|
333
|
+
if (existing) {
|
|
334
|
+
existing.sessions++;
|
|
335
|
+
existing.turns += d.turns;
|
|
336
|
+
existing.tokens = addTokens(existing.tokens, d.tokens);
|
|
337
|
+
existing.cost += d.cost;
|
|
338
|
+
existing.accounting = RolloutAccounting.merge([
|
|
339
|
+
existing.accounting ?? RolloutAccounting.empty(),
|
|
340
|
+
d.accounting ?? RolloutAccounting.empty(),
|
|
341
|
+
]);
|
|
342
|
+
existing.additions += d.additions;
|
|
343
|
+
existing.deletions += d.deletions;
|
|
344
|
+
existing.files += d.files;
|
|
345
|
+
const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0);
|
|
346
|
+
existing.toolCalls += totalToolCalls;
|
|
347
|
+
existing.errors += d.errorCount;
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0);
|
|
351
|
+
dayMap.set(day, {
|
|
352
|
+
day,
|
|
353
|
+
sessions: 1,
|
|
354
|
+
turns: d.turns,
|
|
355
|
+
tokens: { ...d.tokens, cache: { ...d.tokens.cache } },
|
|
356
|
+
cost: d.cost,
|
|
357
|
+
accounting: d.accounting,
|
|
358
|
+
additions: d.additions,
|
|
359
|
+
deletions: d.deletions,
|
|
360
|
+
files: d.files,
|
|
361
|
+
toolCalls: totalToolCalls,
|
|
362
|
+
errors: d.errorCount,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
for (const [hour, turns] of Object.entries(d.hourlyTurns ?? {})) {
|
|
366
|
+
hourMap.set(hour, (hourMap.get(hour) ?? 0) + turns);
|
|
367
|
+
const hourNumber = Number(hour.slice(-2));
|
|
368
|
+
hourlyActivity[hourNumber] += turns;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const days = [...dayMap.values()].sort((a, b) => a.day.localeCompare(b.day));
|
|
372
|
+
const hours = [...hourMap.entries()]
|
|
373
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
374
|
+
.map(([hour, turns]) => ({ hour, turns }));
|
|
375
|
+
return { days, hours, hourlyActivity };
|
|
376
|
+
}
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// Full snapshot
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
function snapshot(digests, watermark, operations = []) {
|
|
381
|
+
const usage = [...digests, ...operations];
|
|
382
|
+
const timeSeries = computeTimeSeries(digests);
|
|
383
|
+
const days = new Map(timeSeries.days.map((day) => [day.day, day]));
|
|
384
|
+
for (const operation of operations) {
|
|
385
|
+
const day = dayKey(operation.created);
|
|
386
|
+
days.set(day, mergeDailyBucket(days.get(day), {
|
|
387
|
+
day,
|
|
388
|
+
sessions: 0,
|
|
389
|
+
turns: 0,
|
|
390
|
+
tokens: operation.tokens,
|
|
391
|
+
cost: operation.cost,
|
|
392
|
+
accounting: operation.accounting,
|
|
393
|
+
additions: 0,
|
|
394
|
+
deletions: 0,
|
|
395
|
+
files: 0,
|
|
396
|
+
toolCalls: 0,
|
|
397
|
+
errors: 0,
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
timeSeries.days = [...days.values()].sort((a, b) => a.day.localeCompare(b.day));
|
|
401
|
+
return {
|
|
402
|
+
overview: computeOverview(digests),
|
|
403
|
+
tokenCost: computeTokenCost(usage),
|
|
404
|
+
models: computeModels(usage),
|
|
405
|
+
agents: computeAgents(usage),
|
|
406
|
+
tools: computeTools(digests),
|
|
407
|
+
codeChanges: computeCodeChanges(digests),
|
|
408
|
+
lifecycle: computeLifecycle(digests),
|
|
409
|
+
channels: computeChannels(digests),
|
|
410
|
+
timeSeries,
|
|
411
|
+
computedAt: Date.now(),
|
|
412
|
+
watermark,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
Rollup.snapshot = snapshot;
|
|
416
|
+
// -----------------------------------------------------------------------
|
|
417
|
+
// Incremental daily bucket merge
|
|
418
|
+
// -----------------------------------------------------------------------
|
|
419
|
+
function mergeDailyBucket(existing, incoming) {
|
|
420
|
+
if (!existing)
|
|
421
|
+
return incoming;
|
|
422
|
+
return {
|
|
423
|
+
day: incoming.day,
|
|
424
|
+
sessions: existing.sessions + incoming.sessions,
|
|
425
|
+
turns: existing.turns + incoming.turns,
|
|
426
|
+
tokens: addTokens(existing.tokens, incoming.tokens),
|
|
427
|
+
cost: existing.cost + incoming.cost,
|
|
428
|
+
accounting: RolloutAccounting.merge([
|
|
429
|
+
existing.accounting ?? RolloutAccounting.empty(),
|
|
430
|
+
incoming.accounting ?? RolloutAccounting.empty(),
|
|
431
|
+
]),
|
|
432
|
+
additions: existing.additions + incoming.additions,
|
|
433
|
+
deletions: existing.deletions + incoming.deletions,
|
|
434
|
+
files: existing.files + incoming.files,
|
|
435
|
+
toolCalls: existing.toolCalls + incoming.toolCalls,
|
|
436
|
+
errors: existing.errors + incoming.errors,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
Rollup.mergeDailyBucket = mergeDailyBucket;
|
|
440
|
+
// -----------------------------------------------------------------------
|
|
441
|
+
// Sub-digest from a single session (for daily bucket)
|
|
442
|
+
// -----------------------------------------------------------------------
|
|
443
|
+
function sessionToDailyBucket(d) {
|
|
444
|
+
const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0);
|
|
445
|
+
return {
|
|
446
|
+
day: dayKey(d.created),
|
|
447
|
+
sessions: 1,
|
|
448
|
+
turns: d.turns,
|
|
449
|
+
tokens: { ...d.tokens, cache: { ...d.tokens.cache } },
|
|
450
|
+
cost: d.cost,
|
|
451
|
+
...(d.accounting ? { accounting: d.accounting } : {}),
|
|
452
|
+
additions: d.additions,
|
|
453
|
+
deletions: d.deletions,
|
|
454
|
+
files: d.files,
|
|
455
|
+
toolCalls: totalToolCalls,
|
|
456
|
+
errors: d.errorCount,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
Rollup.sessionToDailyBucket = sessionToDailyBucket;
|
|
460
|
+
})(Rollup || (Rollup = {}));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { describeRoute, validator, resolver } from "hono-openapi";
|
|
4
|
+
import z from "zod";
|
|
5
|
+
import { errors } from "@ericsanchezok/synergy-server/server/error";
|
|
6
|
+
import { Engine } from "../index.js";
|
|
7
|
+
import { ProgressEvent, StatsSnapshot } from "../types.js";
|
|
8
|
+
import { UsageRoute } from "./usage.js";
|
|
9
|
+
const StatsQuery = z.object({
|
|
10
|
+
recompute: z
|
|
11
|
+
.enum(["true", "false"])
|
|
12
|
+
.optional()
|
|
13
|
+
.default("false")
|
|
14
|
+
.meta({ description: "Set to 'true' to force a full recompute from scratch" }),
|
|
15
|
+
});
|
|
16
|
+
export const StatsRoute = () => new Hono()
|
|
17
|
+
.route("/usage", UsageRoute())
|
|
18
|
+
.get("/", describeRoute({
|
|
19
|
+
summary: "Get stats snapshot",
|
|
20
|
+
description: "Read the last computed stats snapshot without scanning history; null means no snapshot exists. Use the progress stream to refresh, or ?recompute=true to force a full recompute.",
|
|
21
|
+
operationId: "global.stats.get",
|
|
22
|
+
responses: {
|
|
23
|
+
200: {
|
|
24
|
+
description: "Stats snapshot",
|
|
25
|
+
content: {
|
|
26
|
+
"application/json": {
|
|
27
|
+
schema: resolver(StatsSnapshot.meta({ ref: "StatsSnapshot" }).nullable()),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
...errors(400),
|
|
32
|
+
},
|
|
33
|
+
}), validator("query", StatsQuery), async (c) => {
|
|
34
|
+
const { recompute } = c.req.valid("query");
|
|
35
|
+
try {
|
|
36
|
+
const snapshot = recompute === "true" ? await Engine.recompute() : await Engine.get();
|
|
37
|
+
return c.json(snapshot);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return c.json({ message: err?.message ?? String(err) }, 400);
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
.get("/progress", describeRoute({
|
|
44
|
+
summary: "Stream stats recompute progress",
|
|
45
|
+
description: "Refresh changed statistics and stream progress updates until the final snapshot is ready. Concurrent refreshes share one computation.",
|
|
46
|
+
operationId: "global.stats.progress",
|
|
47
|
+
responses: {
|
|
48
|
+
200: {
|
|
49
|
+
description: "Server-sent events containing progress and final snapshot payloads",
|
|
50
|
+
content: {
|
|
51
|
+
"text/event-stream": {
|
|
52
|
+
schema: resolver(z.object({
|
|
53
|
+
type: z.enum(["progress", "done", "error"]),
|
|
54
|
+
progress: ProgressEvent.optional(),
|
|
55
|
+
snapshot: StatsSnapshot.optional(),
|
|
56
|
+
message: z.string().optional(),
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}), async (c) => {
|
|
63
|
+
c.header("X-Accel-Buffering", "no");
|
|
64
|
+
c.header("Cache-Control", "no-cache, no-transform");
|
|
65
|
+
return streamSSE(c, async (stream) => {
|
|
66
|
+
try {
|
|
67
|
+
const snapshot = await Engine.update(async (event) => {
|
|
68
|
+
await stream.writeSSE({
|
|
69
|
+
data: JSON.stringify({
|
|
70
|
+
type: "progress",
|
|
71
|
+
progress: event,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
await stream.writeSSE({
|
|
76
|
+
data: JSON.stringify({
|
|
77
|
+
type: "done",
|
|
78
|
+
snapshot,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
await stream.writeSSE({
|
|
84
|
+
data: JSON.stringify({
|
|
85
|
+
type: "error",
|
|
86
|
+
message: err?.message ?? String(err),
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
stream.close();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Usage, UsageSchema } from "@ericsanchezok/synergy-harness/usage";
|
|
5
|
+
import { errors } from "@ericsanchezok/synergy-server/server/error";
|
|
6
|
+
const Query = UsageSchema.Filter.extend({
|
|
7
|
+
includeDescendants: z
|
|
8
|
+
.enum(["true", "false"])
|
|
9
|
+
.default("true")
|
|
10
|
+
.transform((value) => value === "true"),
|
|
11
|
+
from: z.coerce.number().int().nonnegative().safe().optional(),
|
|
12
|
+
to: z.coerce.number().int().nonnegative().safe().optional(),
|
|
13
|
+
});
|
|
14
|
+
const PageQuery = Query.extend({
|
|
15
|
+
cursor: z.string().max(4096).optional(),
|
|
16
|
+
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
17
|
+
});
|
|
18
|
+
const Clear = z.object({ scope: UsageSchema.Filter, throughRevision: z.number().int().nonnegative() }).strict();
|
|
19
|
+
const Cleared = z.object({
|
|
20
|
+
removed: z.number(),
|
|
21
|
+
activeRetained: z.number(),
|
|
22
|
+
newerRetained: z.number(),
|
|
23
|
+
unattributedRetained: z.number().describe("Owner-level gaps retained because the clear selects a specific run"),
|
|
24
|
+
revision: z.number(),
|
|
25
|
+
});
|
|
26
|
+
const response = (schema, description) => ({
|
|
27
|
+
200: { description, content: { "application/json": { schema: resolver(schema) } } },
|
|
28
|
+
...errors(400),
|
|
29
|
+
});
|
|
30
|
+
export const UsageRoute = () => new Hono()
|
|
31
|
+
.get("/", describeRoute({
|
|
32
|
+
operationId: "global.stats.usage",
|
|
33
|
+
summary: "Get canonical usage statistics",
|
|
34
|
+
description: "Read retained usage facts with explicit unknowns, billing bases, transport timing, attribution and migration coverage. Time ranges are half-open; request days use the sent timestamp in the returned timezone.",
|
|
35
|
+
responses: response(Usage.Summary, "Canonical usage summary"),
|
|
36
|
+
}), validator("query", Query), async (c) => {
|
|
37
|
+
try {
|
|
38
|
+
return c.json(await Usage.summary(c.req.valid("query")));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (UsageSchema.InvalidQuery.isInstance(error))
|
|
42
|
+
return c.json({ message: error.data.message }, 400);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.get("/records", describeRoute({
|
|
47
|
+
operationId: "global.stats.usageRecords",
|
|
48
|
+
summary: "Page through retained usage records",
|
|
49
|
+
description: "Read compact records without prompts, tool arguments or raw response content. Cursor filters must match the initial request. Each record has a revision; reconnect through the summary endpoint.",
|
|
50
|
+
responses: response(Usage.Page, "Usage records"),
|
|
51
|
+
}), validator("query", PageQuery), async (c) => {
|
|
52
|
+
const { cursor, limit, ...scope } = c.req.valid("query");
|
|
53
|
+
try {
|
|
54
|
+
return c.json(await Usage.records(scope, { cursor, limit }));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (UsageSchema.InvalidQuery.isInstance(error))
|
|
58
|
+
return c.json({ message: error.data.message }, 400);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.post("/rebuild", describeRoute({
|
|
63
|
+
operationId: "global.stats.usageRebuild",
|
|
64
|
+
summary: "Schedule or resume historical usage capture",
|
|
65
|
+
description: "Returns the durable background job. Rebuilding preserves existing facts, original pricing and explicit deletion markers.",
|
|
66
|
+
responses: response(UsageSchema.Rebuild, "Durable rebuild job"),
|
|
67
|
+
}), async (c) => c.json(await Usage.rebuild()))
|
|
68
|
+
.delete("/records", describeRoute({
|
|
69
|
+
operationId: "global.stats.usageClear",
|
|
70
|
+
summary: "Clear explicitly scoped terminal usage records",
|
|
71
|
+
description: "Requires an explicit Scope, session or time range and the summary revision to clear through. Active and newer records are retained. Run-filtered clears also retain unattributed owner-level gaps. Cleared identities cannot be restored by a rebuild.",
|
|
72
|
+
responses: response(Cleared, "Cleared and retained record counts"),
|
|
73
|
+
}), validator("json", Clear), async (c) => {
|
|
74
|
+
const { scope, throughRevision } = c.req.valid("json");
|
|
75
|
+
try {
|
|
76
|
+
return c.json(await Usage.clear(scope, throughRevision));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (UsageSchema.InvalidQuery.isInstance(error))
|
|
80
|
+
return c.json({ message: error.data.message }, 400);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
});
|