@hecer/yoke 1.13.0 → 1.14.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/CHANGELOG.md +22 -0
- package/README.md +8 -8
- package/canon/manifest.yaml +1 -1
- package/dist/dashboard/analytics.js +193 -29
- package/dist/dashboard/contracts.js +23 -0
- package/dist/dashboard/page.js +39 -94
- package/dist/dashboard/panels.js +85 -31
- package/dist/dashboard/server.js +190 -15
- package/dist/observability/events.js +1 -1
- package/dist/observability/history.js +1 -1
- package/docs/DASHBOARD-EVOLUTION.md +16 -2
- package/docs/DASHBOARD-OVERHAUL.md +146 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.14.0 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Add a local-first workspace control room that ranks registered projects by attention, activity, recorded tokens, reported cost, acceptance, or name, with composable search, status filters, UTC scopes, and restorable view links.
|
|
7
|
+
- Add workspace and project analytics with time buckets, token/call/duration/outcome summaries, provider/model/agent/role/phase/run rankings, usage comparisons, and visible measurement coverage.
|
|
8
|
+
- Add project live operations for task and phase state, worker metadata, bounded event timelines, safe-boundary pause/resume, append-only operator notes, and queued change requests.
|
|
9
|
+
- Add a responsive dashboard shell with dark/light themes, keyboard and reduced-motion support, explicit loading/empty/error/stale states, and readable narrow-screen navigation.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Base dashboard views on durable local history and versioned events while keeping unknown, partial, corrupt, unavailable, and stale telemetry explicit instead of treating it as zero.
|
|
13
|
+
- Keep dashboard controls on the existing loop/goal runner and lock boundaries; browser requests remain typed, same-origin, loopback-only, and unable to execute arbitrary shell commands.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Scope dashboard loop verification to the intended local project and retain focused coverage for dashboard authorization, path safety, partial history, concurrency, navigation, and control behavior.
|
|
17
|
+
- Improve light-theme contrast for the active navigation state and keep long project names inside desktop and mobile navigation areas.
|
|
18
|
+
|
|
19
|
+
### Migration and validation limits
|
|
20
|
+
- No migration is required. Start the local view with `yoke dashboard --no-register`, then register projects with `yoke projects add <path>` as needed. Existing dashboard settings remain local and authoritative.
|
|
21
|
+
- Validated with the dashboard tests, TypeScript lint/build, canonical manifest validation, release metadata checks, package dry run, and real local browser screenshots at desktop and mobile sizes.
|
|
22
|
+
- Dashboard data is limited to explicitly registered local projects and retained local telemetry. It does not provide remote multi-user access, reconstruct missing history, prove requested models were used, or execute arbitrary browser-supplied commands. Pause/resume operates only at existing safe loop/goal boundaries.
|
|
23
|
+
- This release targets npm package 1.14.0; npm publication is triggered by the matching published GitHub release and verified separately.
|
|
24
|
+
|
|
3
25
|
## 1.13.0 — 2026-09-08
|
|
4
26
|
|
|
5
27
|
### Added
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<h1><img src="https://raw.githubusercontent.com/HECer/yoke/v1.
|
|
3
|
+
<h1><img src="https://raw.githubusercontent.com/HECer/yoke/v1.14.0/docs/assets/yoke-logo.png" alt="Yoke" width="100" height="63"></h1>
|
|
4
4
|
|
|
5
|
-
<!-- yoke:version:start -->1.
|
|
6
|
-
<!-- yoke:tests:start -->
|
|
5
|
+
<!-- yoke:version:start -->1.14.0<!-- yoke:version:end -->
|
|
6
|
+
<!-- yoke:tests:start -->1258<!-- yoke:tests:end -->
|
|
7
7
|
<!-- yoke:skills:start -->34<!-- yoke:skills:end -->
|
|
8
8
|
<!-- yoke:agents:start -->Claude | Codex | Gemini | Qwen | OpenCode | Kilo | Pi<!-- yoke:agents:end -->
|
|
9
9
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
[](#-license)
|
|
18
18
|

|
|
19
19
|

|
|
20
|
-

|
|
21
21
|

|
|
22
22
|

|
|
23
23
|
|
|
@@ -40,11 +40,11 @@ yoke projects add /path/to/backend
|
|
|
40
40
|
yoke dashboard --no-register
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Open the printed `http://127.0.0.1:...` URL. Each registered project has its own goals, tasks and evidence. The dashboard shows
|
|
43
|
+
Open the printed `http://127.0.0.1:...` URL. Each registered project has its own goals, tasks and evidence. The dashboard is a local control room with an overview/ranking screen, project live view, bounded **History explorer**, and Workspace analytics by UTC time bucket. It shows worker state, agent/provider/model/variant/role/phase metadata when recorded, per-task duration estimates, planned start offsets, input/output tokens, reported costs, outcomes, and explicit unknown or partial measurements. Dark and light themes, keyboard navigation, responsive layouts, and reduced-motion handling are included.
|
|
44
44
|
|
|
45
|
-
The dashboard includes attention-first project search and filters, restorable view links, and usage comparisons against the preceding period. See [dashboard behavior and measurement limits](docs/DASHBOARD-EVOLUTION.md).
|
|
45
|
+
The dashboard includes attention-first project search and filters, project ranking by attention, last activity, token usage, reported cost, acceptance, or name, restorable view links, and usage comparisons against the preceding period. From a project’s live view you can request a safe-boundary pause or resume, add an operator note, and use **Queue a change** for the next planning boundary. See [dashboard behavior and measurement limits](docs/DASHBOARD-EVOLUTION.md) and the [dashboard overhaul contract](docs/DASHBOARD-OVERHAUL.md).
|
|
46
46
|
|
|
47
|
-
Projects are registered explicitly; this version does not automatically discover every process or aggregate other computers.
|
|
47
|
+
Projects are registered explicitly; this version does not automatically discover every process or aggregate other computers. Dashboard controls call the existing goal/loop pause and resume boundaries and never execute arbitrary shell commands. Change requests are append-only pending inbox entries, not immediate code changes. The server stays loopback-only and POST actions require same-origin session authorization; the local Yoke process remains the authority for execution. Missing history appears as unknown; time ranges are empirical estimates, not exact deadlines. Read the [overhaul contract](docs/DASHBOARD-OVERHAUL.md) for data limits and non-goals.
|
|
48
48
|
|
|
49
49
|
### Verified goals and efficient execution
|
|
50
50
|
|
|
@@ -925,7 +925,7 @@ release provenance.
|
|
|
925
925
|
## 🧪 Development
|
|
926
926
|
|
|
927
927
|
```bash
|
|
928
|
-
npm test # vitest (
|
|
928
|
+
npm test # vitest (1258 tests)
|
|
929
929
|
npm run build # tsc, no emit errors
|
|
930
930
|
npm run yoke -- validate canon
|
|
931
931
|
```
|
package/canon/manifest.yaml
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readMeasurements } from '../observability/history.js';
|
|
2
|
-
import { readEvents } from '../observability/events.js';
|
|
2
|
+
import { EVENT_CAP, readEvents } from '../observability/events.js';
|
|
3
|
+
import { DASHBOARD_LIMITS } from './contracts.js';
|
|
3
4
|
export function parsePeriod(params, now = Date.now()) {
|
|
4
5
|
const to = params.has('to') ? Date.parse(params.get('to')) : now;
|
|
5
6
|
const from = params.has('from') ? Date.parse(params.get('from')) : to - 30 * 86400000;
|
|
@@ -9,7 +10,6 @@ export function parsePeriod(params, now = Date.now()) {
|
|
|
9
10
|
return { from, to, bucket: bucket };
|
|
10
11
|
}
|
|
11
12
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
12
|
-
const numeric = (value) => finite(value) ? value : 0;
|
|
13
13
|
const name = (value) => typeof value === 'string' ? value.slice(0, 200) : 'unknown';
|
|
14
14
|
function bucketOf(timestamp, bucket) {
|
|
15
15
|
const date = new Date(timestamp);
|
|
@@ -20,12 +20,17 @@ function bucketOf(timestamp, bucket) {
|
|
|
20
20
|
return date.toISOString().slice(0, 10);
|
|
21
21
|
}
|
|
22
22
|
function empty() {
|
|
23
|
-
return { inputTokens:
|
|
24
|
-
measuredCalls: 0, unknownCalls: 0, costReportedCalls: 0, incompleteCosts: 0, callDurationMs:
|
|
25
|
-
attempts: 0, successfulAttempts: 0, accepted: 0, repairs: 0, escalations: 0, unmeasuredAttempts: 0 };
|
|
23
|
+
return { inputTokens: null, outputTokens: null, cachedInputTokens: null, cacheWriteInputTokens: null, reasoningOutputTokens: null, reportedCostUsd: null,
|
|
24
|
+
measuredCalls: 0, unknownCalls: 0, costReportedCalls: 0, incompleteCosts: 0, callDurationMs: null, attemptDurationMs: null,
|
|
25
|
+
attempts: 0, successfulAttempts: 0, accepted: 0, repairs: 0, escalations: 0, unmeasuredAttempts: 0, activity: 0 };
|
|
26
|
+
}
|
|
27
|
+
function addMetric(target, key, value) {
|
|
28
|
+
if (finite(value))
|
|
29
|
+
target[key] = (target[key] ?? 0) + value;
|
|
26
30
|
}
|
|
27
31
|
export function aggregateMeasurements(events, period) {
|
|
28
|
-
const total = empty(), buckets = new Map(), models = new Map(), modelBuckets = new Map(), tasks = new Map()
|
|
32
|
+
const total = empty(), buckets = new Map(), models = new Map(), modelBuckets = new Map(), tasks = new Map();
|
|
33
|
+
const dimensions = { agents: new Map(), providers: new Map(), variants: new Map(), roles: new Map(), phases: new Map() };
|
|
29
34
|
const modelDetails = new Map();
|
|
30
35
|
const seen = new Set(), accepted = new Set();
|
|
31
36
|
let earliest, latest;
|
|
@@ -42,23 +47,40 @@ export function aggregateMeasurements(events, period) {
|
|
|
42
47
|
latest = event.timestamp;
|
|
43
48
|
const data = event.data ?? {}, bucket = get(buckets, bucketOf(event.timestamp, period.bucket)), task = get(tasks, event.storyId ?? 'unattributed');
|
|
44
49
|
const targets = [total, bucket, task];
|
|
50
|
+
for (const target of targets)
|
|
51
|
+
target.activity++;
|
|
45
52
|
if (event.type === 'tokens') {
|
|
46
53
|
const calls = Array.isArray(data.calls) && data.calls.length ? data.calls : [{ ...data, actualModel: data.model, durationMs: event.durationMs }];
|
|
47
54
|
for (const item of calls) {
|
|
48
55
|
if (!item || typeof item !== 'object')
|
|
49
56
|
continue;
|
|
50
57
|
const call = item;
|
|
51
|
-
const provider = name(call.provider), model = name(call.actualModel), role = name(call.role);
|
|
58
|
+
const provider = name(call.provider ?? data.provider), model = name(call.actualModel ?? call.model ?? data.actualModel ?? data.model), role = name(call.role ?? data.role);
|
|
52
59
|
const key = JSON.stringify([provider, model, role]);
|
|
53
60
|
modelDetails.set(key, { provider, model, role });
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
61
|
+
const dimensionTargets = [];
|
|
62
|
+
for (const [kind, value] of Object.entries({
|
|
63
|
+
agents: name(call.agent ?? data.agent ?? data.agentName ?? data.provider),
|
|
64
|
+
providers: provider,
|
|
65
|
+
variants: name(call.variant ?? data.variant),
|
|
66
|
+
roles: role,
|
|
67
|
+
})) {
|
|
68
|
+
const dimension = get(dimensions[kind], value);
|
|
69
|
+
dimension.activity++;
|
|
70
|
+
dimensionTargets.push(dimension);
|
|
71
|
+
}
|
|
72
|
+
for (const target of [...targets, get(models, key), get(modelBuckets, JSON.stringify([bucketOf(event.timestamp, period.bucket), provider, model, role])), ...dimensionTargets]) {
|
|
73
|
+
const usageAvailable = call.usageAvailable !== false && data.usageAvailable !== false && call.measurementComplete !== false && data.measurementComplete !== false;
|
|
74
|
+
if (usageAvailable) {
|
|
75
|
+
addMetric(target, 'inputTokens', call.inputTokens);
|
|
76
|
+
addMetric(target, 'outputTokens', call.outputTokens);
|
|
77
|
+
addMetric(target, 'cachedInputTokens', call.cachedInputTokens);
|
|
78
|
+
addMetric(target, 'cacheWriteInputTokens', call.cacheWriteInputTokens);
|
|
79
|
+
addMetric(target, 'reasoningOutputTokens', call.reasoningOutputTokens);
|
|
80
|
+
}
|
|
81
|
+
addMetric(target, 'reportedCostUsd', call.totalCostUsd);
|
|
82
|
+
addMetric(target, 'callDurationMs', call.durationMs);
|
|
83
|
+
const measured = finite(call.inputTokens) && finite(call.outputTokens) && usageAvailable;
|
|
62
84
|
if (measured)
|
|
63
85
|
target.measuredCalls++;
|
|
64
86
|
else
|
|
@@ -75,7 +97,11 @@ export function aggregateMeasurements(events, period) {
|
|
|
75
97
|
}
|
|
76
98
|
else if (event.type === 'phase-ended') {
|
|
77
99
|
const phase = name(event.phase);
|
|
78
|
-
|
|
100
|
+
const phaseTarget = get(dimensions.phases, phase);
|
|
101
|
+
phaseTarget.activity++;
|
|
102
|
+
addMetric(phaseTarget, 'attemptDurationMs', event.durationMs);
|
|
103
|
+
if (phase === 'repairing')
|
|
104
|
+
phaseTarget.repairs++;
|
|
79
105
|
if (phase === 'repairing')
|
|
80
106
|
for (const target of targets)
|
|
81
107
|
target.repairs++;
|
|
@@ -83,7 +109,7 @@ export function aggregateMeasurements(events, period) {
|
|
|
83
109
|
else if (event.type === 'attempt-ended') {
|
|
84
110
|
for (const target of targets) {
|
|
85
111
|
target.attempts++;
|
|
86
|
-
target
|
|
112
|
+
addMetric(target, 'attemptDurationMs', event.durationMs);
|
|
87
113
|
if (['completed', 'passed'].includes(event.outcome ?? ''))
|
|
88
114
|
target.successfulAttempts++;
|
|
89
115
|
if (data.usageAvailable !== true)
|
|
@@ -99,25 +125,163 @@ export function aggregateMeasurements(events, period) {
|
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
}
|
|
102
|
-
const decorate = (value) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
const decorate = (value) => {
|
|
129
|
+
const tokens = value.inputTokens !== null && value.outputTokens !== null ? value.inputTokens + value.outputTokens : null;
|
|
130
|
+
const costState = value.costReportedCalls === 0 ? 'unknown' : value.costReportedCalls === value.measuredCalls && value.unknownCalls === 0 && value.unmeasuredAttempts === 0 && value.incompleteCosts === 0 ? 'measured' : 'partial';
|
|
131
|
+
return { ...value,
|
|
132
|
+
tokensPerCallMinute: tokens !== null && value.callDurationMs !== null && value.callDurationMs > 0 ? tokens / (value.callDurationMs / 60000) : null,
|
|
133
|
+
tokensPerAccepted: tokens !== null && value.accepted ? tokens / value.accepted : null,
|
|
134
|
+
timePerAcceptedMs: value.accepted && value.attemptDurationMs !== null ? value.attemptDurationMs / value.accepted : null,
|
|
135
|
+
costState,
|
|
136
|
+
attention: value.unknownCalls + value.unmeasuredAttempts + value.incompleteCosts + Math.max(0, value.attempts - value.successfulAttempts) + value.repairs + value.escalations,
|
|
137
|
+
tokens: { input: value.inputTokens, output: value.outputTokens, cachedInput: value.cachedInputTokens, cacheWriteInput: value.cacheWriteInputTokens, reasoningOutput: value.reasoningOutputTokens, total: tokens },
|
|
138
|
+
calls: { measured: value.measuredCalls, unknown: value.unknownCalls, total: value.measuredCalls + value.unknownCalls },
|
|
139
|
+
outcomes: { attempts: value.attempts, successfulAttempts: value.successfulAttempts, accepted: value.accepted, repairs: value.repairs, escalations: value.escalations },
|
|
140
|
+
time: { callDurationMs: value.callDurationMs, attemptDurationMs: value.attemptDurationMs, elapsedMs: value.attemptDurationMs },
|
|
141
|
+
cost: { reportedUsd: value.reportedCostUsd, reportedCalls: value.costReportedCalls, incompleteCalls: value.incompleteCosts, state: costState, coverage: costState },
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
const dimensionRows = (kind) => [...dimensions[kind]].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => ({ name, ...decorate(value) }));
|
|
145
|
+
const phaseRows = dimensionRows('phases').map(row => ({ phase: row.name, durationMs: row.time.attemptDurationMs, ...row }));
|
|
146
|
+
const decoratedTotal = decorate(total);
|
|
147
|
+
return { total: { ...decoratedTotal, tokensPerElapsedMinute: decoratedTotal.tokensPerCallMinute === null && (total.inputTokens === null || total.outputTokens === null) ? null : (total.inputTokens + total.outputTokens) / ((period.to - period.from) / 60000) },
|
|
109
148
|
buckets: [...buckets].sort(([a], [b]) => a.localeCompare(b)).map(([label, value]) => ({ label, ...decorate(value) })),
|
|
110
149
|
models: [...models].map(([key, value]) => ({ ...modelDetails.get(key), ...decorate(value) })),
|
|
111
150
|
modelBuckets: [...modelBuckets].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => { const [label, provider, model, role] = JSON.parse(key); return { label, provider, model, role, ...decorate(value) }; }),
|
|
112
151
|
tasks: [...tasks].map(([storyId, value]) => ({ storyId, ...decorate(value) })),
|
|
113
|
-
phases:
|
|
152
|
+
phases: phaseRows, earliest, latest,
|
|
153
|
+
dimensions: { agents: dimensionRows('agents'), providers: dimensionRows('providers'), variants: dimensionRows('variants'), roles: dimensionRows('roles'), phases: phaseRows },
|
|
154
|
+
agents: dimensionRows('agents'), providers: dimensionRows('providers'), variants: dimensionRows('variants'), roles: dimensionRows('roles'), };
|
|
155
|
+
}
|
|
156
|
+
export function parseRanking(params) {
|
|
157
|
+
const value = params.get('sort') ?? 'attention';
|
|
158
|
+
if (!['attention', 'activity', 'tokens', 'cost', 'acceptance', 'name'].includes(value))
|
|
159
|
+
throw Error('Choose a valid ranking: attention, activity, tokens, cost, acceptance or name');
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
function compareNames(left, right) { return left < right ? -1 : left > right ? 1 : 0; }
|
|
163
|
+
function compareMetric(left, right) {
|
|
164
|
+
if (left === null && right === null)
|
|
165
|
+
return 0;
|
|
166
|
+
if (left === null)
|
|
167
|
+
return 1;
|
|
168
|
+
if (right === null)
|
|
169
|
+
return -1;
|
|
170
|
+
return right - left;
|
|
171
|
+
}
|
|
172
|
+
function rankingComparator(key) {
|
|
173
|
+
return (left, right) => {
|
|
174
|
+
if (key === 'name')
|
|
175
|
+
return compareNames(left.name, right.name) || compareNames(JSON.stringify(left), JSON.stringify(right));
|
|
176
|
+
return compareMetric(left[key], right[key]) || compareNames(left.name, right.name) || compareNames(JSON.stringify(left), JSON.stringify(right));
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function rankProjections(rows) {
|
|
180
|
+
const views = {};
|
|
181
|
+
for (const key of ['attention', 'activity', 'tokens', 'cost', 'acceptance', 'name'])
|
|
182
|
+
views[key] = [...rows].sort(rankingComparator(key));
|
|
183
|
+
return views;
|
|
184
|
+
}
|
|
185
|
+
function rankingRow(name, value) {
|
|
186
|
+
return { name, attention: value.attention, activity: value.activity, tokens: value.tokens.total, cost: value.cost.reportedUsd, acceptance: value.outcomes.accepted, costState: value.costState, total: value };
|
|
187
|
+
}
|
|
188
|
+
function dimensionRankings(aggregate, runs = []) {
|
|
189
|
+
return {
|
|
190
|
+
agents: rankProjections(aggregate.agents.map(row => rankingRow(row.name, row))),
|
|
191
|
+
providers: rankProjections(aggregate.providers.map(row => rankingRow(row.name, row))),
|
|
192
|
+
models: rankProjections(aggregate.models.map(row => rankingRow(row.model, row))),
|
|
193
|
+
variants: rankProjections(aggregate.variants.map(row => rankingRow(row.name, row))),
|
|
194
|
+
roles: rankProjections(aggregate.roles.map(row => rankingRow(row.name, row))),
|
|
195
|
+
phases: rankProjections(aggregate.dimensions.phases.map(row => rankingRow(row.name, row))),
|
|
196
|
+
runs: rankProjections(runs.map(run => rankingRow(run.runId, run.total))),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function projectEvents(root, period) {
|
|
200
|
+
const history = readMeasurements(root, period.from, period.to), seen = new Set();
|
|
201
|
+
const events = [...history.events, ...readEvents(root, EVENT_CAP)].filter(event => {
|
|
202
|
+
const time = Date.parse(event.timestamp);
|
|
203
|
+
if (seen.has(event.id) || !Number.isFinite(time) || time < period.from || time >= period.to)
|
|
204
|
+
return false;
|
|
205
|
+
seen.add(event.id);
|
|
206
|
+
return true;
|
|
207
|
+
}).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
208
|
+
return { events, errors: history.errors };
|
|
209
|
+
}
|
|
210
|
+
function textValue(value) { return typeof value === 'string' && value.length > 0 ? value.slice(0, 200) : undefined; }
|
|
211
|
+
function eventMetadata(event) {
|
|
212
|
+
const direct = event, data = event.data ?? {};
|
|
213
|
+
const calls = Array.isArray(data.calls) ? data.calls.filter(item => item && typeof item === 'object') : [];
|
|
214
|
+
const sources = [direct, ...calls, data];
|
|
215
|
+
const first = (keys) => { for (const source of sources)
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
const value = textValue(source[key]);
|
|
218
|
+
if (value)
|
|
219
|
+
return value;
|
|
220
|
+
} return undefined; };
|
|
221
|
+
return { agent: first(['agent']), provider: first(['provider']), model: first(['actualModel', 'model']), variant: first(['variant']), role: first(['role']) };
|
|
222
|
+
}
|
|
223
|
+
function runProjections(events, period) {
|
|
224
|
+
const byRun = new Map();
|
|
225
|
+
for (const event of events)
|
|
226
|
+
byRun.set(event.runId, [...(byRun.get(event.runId) ?? []), event]);
|
|
227
|
+
return [...byRun].map(([runId, runEvents]) => {
|
|
228
|
+
const metadata = {}, phases = [], timestamps = runEvents.map(event => new Date(event.timestamp).toISOString());
|
|
229
|
+
for (const event of runEvents) {
|
|
230
|
+
const values = eventMetadata(event);
|
|
231
|
+
for (const key of ['agent', 'provider', 'model', 'variant', 'role'])
|
|
232
|
+
if (metadata[key] === undefined && values[key] !== undefined)
|
|
233
|
+
metadata[key] = values[key];
|
|
234
|
+
const phase = textValue(event.phase ?? event.data?.phase);
|
|
235
|
+
if (phase && !phases.includes(phase))
|
|
236
|
+
phases.push(phase);
|
|
237
|
+
}
|
|
238
|
+
const total = aggregateMeasurements(runEvents, period).total;
|
|
239
|
+
return { runId, ...metadata, ...(phases.length ? { phase: phases[phases.length - 1], phases } : {}), timestamps, startedAt: timestamps[0], endedAt: timestamps[timestamps.length - 1], events: runEvents, total, analytics: total };
|
|
240
|
+
}).sort((left, right) => compareNames(left.startedAt, right.startedAt) || compareNames(left.runId, right.runId));
|
|
241
|
+
}
|
|
242
|
+
export function projectHistory(root, period, limit = DASHBOARD_LIMITS.events) {
|
|
243
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > DASHBOARD_LIMITS.events)
|
|
244
|
+
throw Error('Limit must be an integer from 1 to ' + DASHBOARD_LIMITS.events);
|
|
245
|
+
const history = projectEvents(root, period), events = history.events.slice(-limit);
|
|
246
|
+
return { from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC', events, runs: runProjections(events, period), errors: history.errors,
|
|
247
|
+
coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.' };
|
|
248
|
+
}
|
|
249
|
+
export function workspaceAnalytics(projects, period, sort = 'attention') {
|
|
250
|
+
const source = [];
|
|
251
|
+
const allEvents = [];
|
|
252
|
+
for (const project of projects.slice(0, DASHBOARD_LIMITS.projects)) {
|
|
253
|
+
if (project.error || !project.root) {
|
|
254
|
+
source.push({ project, analytics: null, errors: [project.error ?? 'Project directory is unavailable'] });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const history = projectEvents(project.root, period);
|
|
259
|
+
allEvents.push(...history.events);
|
|
260
|
+
const aggregate = aggregateMeasurements(history.events, period);
|
|
261
|
+
source.push({ project, analytics: { ...aggregate, from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC', errors: history.errors, coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.' }, errors: history.errors });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
source.push({ project, analytics: null, errors: [error.message] });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const aggregate = aggregateMeasurements(allEvents, period);
|
|
268
|
+
const runs = runProjections(allEvents, period);
|
|
269
|
+
const projectsWithMetrics = source.map(({ project, analytics, errors }) => {
|
|
270
|
+
const total = analytics?.total;
|
|
271
|
+
return { id: project.id, name: project.name, analytics, errors, attention: project.attention ?? ((project.error ? 1 : 0) + (total?.attention ?? 0)), activity: total?.activity ?? null, tokens: total?.tokens.total ?? null, cost: total?.cost.reportedUsd ?? null, acceptance: total?.outcomes.accepted ?? null, costState: total?.costState ?? 'unknown' };
|
|
272
|
+
});
|
|
273
|
+
const projectRankings = rankProjections(projectsWithMetrics);
|
|
274
|
+
const rankings = { projects: projectRankings, ...dimensionRankings(aggregate, runs) };
|
|
275
|
+
return { ...aggregate, from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC',
|
|
276
|
+
projects: projectRankings[sort], runs, rankings, errors: source.flatMap(row => row.errors) };
|
|
114
277
|
}
|
|
115
278
|
export function projectAnalytics(root, period) {
|
|
116
|
-
const history =
|
|
117
|
-
const
|
|
118
|
-
|
|
279
|
+
const history = projectEvents(root, period);
|
|
280
|
+
const aggregate = aggregateMeasurements(history.events, period);
|
|
281
|
+
const runs = runProjections(history.events, period);
|
|
282
|
+
return { ...aggregate,
|
|
119
283
|
from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC',
|
|
120
|
-
errors: history.errors,
|
|
284
|
+
runs, rankings: dimensionRankings(aggregate, runs), errors: history.errors,
|
|
121
285
|
coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.',
|
|
122
286
|
};
|
|
123
287
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const DASHBOARD_LIMITS = {
|
|
3
|
+
projects: 500,
|
|
4
|
+
events: 100,
|
|
5
|
+
historyBytes: 32 * 1024 * 1024,
|
|
6
|
+
requestBytes: 16 * 1024,
|
|
7
|
+
fileBytes: 1_048_576,
|
|
8
|
+
noteChars: 4000,
|
|
9
|
+
changeChars: 8000,
|
|
10
|
+
};
|
|
11
|
+
export const DashboardControlPayloadSchema = z.object({ action: z.literal('pause') }).strict();
|
|
12
|
+
export const DashboardResumePayloadSchema = z.object({ action: z.literal('resume') }).strict();
|
|
13
|
+
export const DashboardNotePayloadSchema = z.object({ note: z.string().trim().min(1).max(DASHBOARD_LIMITS.noteChars) }).strict();
|
|
14
|
+
export const DashboardChangePayloadSchema = z.object({ request: z.string().trim().min(1).max(DASHBOARD_LIMITS.changeChars) }).strict();
|
|
15
|
+
export const DashboardControlResponseSchema = z.object({ status: z.literal('pause-requested') });
|
|
16
|
+
export function parseDashboardLimit(value) {
|
|
17
|
+
if (value === null)
|
|
18
|
+
return DASHBOARD_LIMITS.events;
|
|
19
|
+
const limit = Number(value);
|
|
20
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > DASHBOARD_LIMITS.events)
|
|
21
|
+
throw Error(`Limit must be an integer from 1 to ${DASHBOARD_LIMITS.events}`);
|
|
22
|
+
return limit;
|
|
23
|
+
}
|