@hecer/yoke 1.12.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/.claude-plugin/plugin.json +3 -3
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +42 -30
- package/canon/loop/prd.schema.md +2 -2
- package/canon/manifest.yaml +2 -2
- package/canon/skills/authoring-prd/SKILL.md +1 -1
- package/canon/skills/yoke-retrofit/SKILL.md +2 -2
- package/canon/skills/yoke-workflow/SKILL.md +1 -1
- package/canon/tools/graphify.md +1 -1
- package/canon/tools/playwright-mcp.md +1 -1
- package/canon/tools/serena.md +1 -1
- package/dist/agents/catalog.js +7 -0
- package/dist/agents/contracts.js +3 -1
- package/dist/agents/host.js +4 -0
- package/dist/agents/process-streams.js +62 -0
- package/dist/agents/process.js +43 -3
- package/dist/agents/providers.js +45 -3
- package/dist/agents/telemetry.js +99 -2
- package/dist/canon/manifest.js +2 -1
- package/dist/change/inbox.js +1 -1
- package/dist/cli.js +22 -24
- 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 +87 -33
- package/dist/dashboard/server.js +190 -15
- package/dist/goals/command.js +3 -2
- package/dist/loop/claims.js +2 -1
- package/dist/loop/decision.js +3 -2
- package/dist/loop/parallel-command.js +4 -2
- package/dist/loop/prd.js +2 -1
- package/dist/loop/reporter.js +1 -0
- package/dist/loop/run-command.js +31 -10
- package/dist/observability/events.js +1 -1
- package/dist/observability/history.js +1 -1
- package/dist/prd/command.js +3 -3
- package/dist/quality/candidate-comparison.js +6 -1
- package/dist/quality/command.js +17 -2
- package/dist/quality/types.js +6 -1
- package/dist/retrofit/apply.js +87 -1
- package/dist/retrofit/config.js +8 -0
- package/dist/retrofit/detect.js +6 -0
- package/dist/retrofit/plan.js +6 -0
- package/dist/retrofit/planners/kilo.js +44 -0
- package/dist/retrofit/planners/opencode.js +44 -0
- package/dist/retrofit/planners/pi.js +24 -0
- package/dist/retrofit/skill-actions.js +3 -0
- package/dist/retrofit/tools.js +8 -0
- package/dist/review/command.js +3 -2
- package/dist/review/verdict.js +1 -1
- package/dist/routing/capability.js +2 -2
- package/dist/routing/planning.js +2 -0
- package/dist/routing/registry.js +3 -1
- package/dist/routing/router.js +7 -3
- package/dist/setup/command.js +13 -3
- package/docs/DASHBOARD-EVOLUTION.md +16 -2
- package/docs/DASHBOARD-OVERHAUL.md +146 -0
- package/docs/HARNESSES.md +81 -0
- package/gemini-extension.json +2 -2
- package/package.json +6 -2
|
@@ -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
|
+
}
|
package/dist/dashboard/page.js
CHANGED
|
@@ -3,126 +3,71 @@ export function dashboardPage(token, nonce) {
|
|
|
3
3
|
if (!/^[a-f0-9]{64}$/u.test(token) || !/^[A-Za-z0-9+/=]+$/u.test(nonce))
|
|
4
4
|
throw new Error('Invalid dashboard session');
|
|
5
5
|
return `<!doctype html>
|
|
6
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yoke ·
|
|
6
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0b0f12"><title>Yoke · Control room</title>
|
|
7
7
|
<style nonce="${nonce}">
|
|
8
|
-
:root{color-scheme:light;--
|
|
9
|
-
|
|
8
|
+
:root{color-scheme:light;--canvas:#f1f4f3;--surface:#fff;--raised:#f7f9f8;--rule:#d4dddc;--text:#17242a;--muted:#61727a;--action:#a85f00;--signal:#087d79;--positive:#197345;--warning:#875b00;--danger:#ae3434;--focus:#087d79;--rail:#17242a;--rail-text:#e9eff1;--rail-muted:#a9b9bd}:root[data-theme=dark]{color-scheme:dark;--canvas:#0b0f12;--surface:#11171c;--raised:#172027;--rule:#2a3740;--text:#e9eff1;--muted:#91a0a8;--action:#ffb454;--signal:#69d6d0;--positive:#72d39a;--warning:#f2c66d;--danger:#ff8585;--focus:#69d6d0;--rail:#11171c;--rail-text:#e9eff1;--rail-muted:#91a0a8}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--canvas:#0b0f12;--surface:#11171c;--raised:#172027;--rule:#2a3740;--text:#e9eff1;--muted:#91a0a8;--action:#ffb454;--signal:#69d6d0;--positive:#72d39a;--warning:#f2c66d;--danger:#ff8585;--focus:#69d6d0;--rail:#11171c;--rail-text:#e9eff1;--rail-muted:#91a0a8}}*{box-sizing:border-box}body{margin:0;background:var(--canvas);color:var(--text);font:14px/1.5 'Instrument Sans',system-ui,sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.control-room-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}.rail{background:var(--rail);color:var(--rail-text);padding:24px 18px;display:flex;flex-direction:column;gap:22px}.brand{font-size:27px;font-weight:800;letter-spacing:-1.4px}.brand span{color:var(--action)}.rail-copy{color:var(--rail-muted);font-size:12px;margin:0}.rail-label,.label{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase}.rail .rail-label{color:var(--rail-muted)}.rail nav{display:grid;gap:4px}.nav-button{border:0;border-left:2px solid transparent;background:transparent;border-radius:4px;color:var(--rail-muted);padding:10px 11px;text-align:left}.nav-button:hover,.nav-button[aria-current=true]{background:var(--raised);border-left-color:var(--action);color:var(--rail-text)}.rail-foot{border-top:1px solid var(--rule);color:var(--rail-muted);font-size:12px;margin-top:auto;padding-top:16px}.canvas{min-width:0;max-width:1600px;padding:24px clamp(20px,4vw,56px) 64px;width:100%}.command-bar{align-items:center;border-bottom:1px solid var(--rule);display:flex;gap:18px;justify-content:space-between;padding-bottom:16px}.command-scope{display:grid;gap:2px}.command-title{font-size:18px;font-weight:750}.command-actions{align-items:center;display:flex;gap:8px}.button{background:var(--surface);border:1px solid var(--rule);border-radius:5px;color:var(--text);padding:8px 12px}.button:hover{border-color:var(--action)}.button.primary{background:var(--action);border-color:var(--action);color:#111}.icon-button{white-space:nowrap}.page-heading{margin:30px 0 22px}.page-heading h1{font-size:clamp(26px,3vw,42px);letter-spacing:-1.7px;line-height:1.1;margin:0 0 7px}.summary{color:var(--muted);margin:0;max-width:760px}.attention-region,.command-panel,.metric-strip,.ranked-projects,.panel,.project-card{background:var(--surface);border:1px solid var(--rule);border-radius:7px}.attention-region{border-left:3px solid var(--action);margin:22px 0;padding:18px 20px}.attention-region h2,.ranked-projects h2{font-size:16px;margin:0}.attention-region>p{color:var(--muted);margin:5px 0 14px}.attention-list{display:grid;gap:0}.attention-item{align-items:center;border-top:1px solid var(--rule);display:flex;gap:14px;justify-content:space-between;padding:11px 0}.attention-item:first-child{border-top:0}.attention-item .row-main{min-width:0}.attention-item small{color:var(--muted);display:block;overflow-wrap:anywhere}.metric-strip{display:grid;gap:0;grid-template-columns:repeat(4,minmax(0,1fr));margin:18px 0}.metric{border-left:1px solid var(--rule);padding:16px 18px}.metric:first-child{border-left:0}.metric strong{display:block;font-family:'JetBrains Mono',ui-monospace,monospace;font-size:24px;font-variant-numeric:tabular-nums;margin:5px 0}.metric p{color:var(--muted);font-size:12px;margin:0}.overview-command-bar{align-items:end;display:grid;gap:12px;grid-template-columns:minmax(220px,2fr) repeat(5,minmax(112px,1fr));margin:22px 0}.field{display:grid;gap:5px;min-width:0}.field input,.field select{background:var(--surface);border:1px solid var(--rule);border-radius:4px;color:var(--text);margin:0;min-width:0;padding:9px;width:100%}.field input::placeholder{color:var(--muted)}.command-panel{padding:14px}.ranked-projects{overflow:hidden}.ranked-heading{align-items:center;display:flex;justify-content:space-between;padding:18px 20px}.ranked-heading p{color:var(--muted);font-size:12px;margin:0}.project-row{align-items:center;border-top:1px solid var(--rule);display:grid;gap:15px;grid-template-columns:34px minmax(190px,2fr) minmax(90px,1fr) minmax(100px,1fr) auto;padding:15px 20px}.project-row:hover{background:var(--raised)}.rank{color:var(--muted);font-family:'JetBrains Mono',ui-monospace,monospace;font-size:12px}.project-identity{min-width:0}.project-name{font-size:15px;font-weight:720;overflow-wrap:anywhere}.project-objective,.project-path,.project-stat{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.project-path{font-family:'JetBrains Mono',ui-monospace,monospace;margin-top:3px}.project-stat{display:grid;gap:2px}.project-stat strong{color:var(--text);font-family:'JetBrains Mono',ui-monospace,monospace;font-variant-numeric:tabular-nums;font-weight:650}.badge{background:var(--raised);border-radius:20px;color:var(--muted);display:inline-block;font-size:11px;font-weight:750;padding:4px 9px;white-space:nowrap}.badge.good{background:color-mix(in srgb,var(--positive) 16%,var(--surface));color:var(--positive)}.badge.attention{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}.empty{color:var(--muted);padding:28px 20px}.notice{background:color-mix(in srgb,var(--warning) 12%,var(--surface));border-left:3px solid var(--warning);color:var(--text);margin:12px 0;padding:11px 14px}.row{align-items:flex-start;border-top:1px solid var(--rule);display:flex;gap:18px;justify-content:space-between;padding:13px 0}.row:first-child{border-top:0}.row-main{min-width:0;overflow-wrap:anywhere}.row small{color:var(--muted);display:block;margin-top:4px}.loading{color:var(--muted);padding:50px 0}.actions{align-items:center;display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}.panel{margin-bottom:18px;padding:20px}.panel h2{font-size:16px;margin:0 0 15px}.muted{color:var(--muted)}.path{font-family:'JetBrains Mono',ui-monospace,monospace;overflow-wrap:anywhere}.table-scroll{max-width:100%;overflow-x:auto}table{border-collapse:collapse;font-size:12px;width:100%}th,td{border-bottom:1px solid var(--rule);padding:9px;text-align:left}select,input{max-width:100%}.columns{display:grid;gap:18px;grid-template-columns:1.2fr 1fr}details{font-size:12px;margin-top:8px}details p{max-height:220px;overflow:auto;white-space:pre-wrap}.timeline .row{font-size:13px}.comparison{font-weight:650}.chart-gap-note{margin-top:12px}@media(max-width:1100px){.overview-command-bar{grid-template-columns:repeat(3,minmax(0,1fr))}.overview-command-bar .field:first-child{grid-column:1/-1}}@media(max-width:820px){.control-room-shell{grid-template-columns:1fr}.rail{gap:12px;padding:15px 20px}.rail-copy,.rail-foot,.rail>.rail-label{display:none}.rail nav{display:flex;overflow-x:auto}.nav-button{white-space:nowrap}.canvas{padding:20px 18px 44px}.metric-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.metric:nth-child(3){border-left:0;border-top:1px solid var(--rule)}.metric:nth-child(4){border-top:1px solid var(--rule)}.project-row{grid-template-columns:24px minmax(0,1fr) auto}.project-row .project-stat{display:none}.columns{grid-template-columns:1fr}}@media(max-width:540px){.command-bar{align-items:flex-start;flex-direction:column}.command-actions{width:100%}.command-actions .button{flex:1}.overview-command-bar{grid-template-columns:1fr}.overview-command-bar .field:first-child{grid-column:auto}.metric-strip{grid-template-columns:1fr}.metric,.metric:nth-child(3),.metric:nth-child(4){border-left:0;border-top:1px solid var(--rule)}.metric:first-child{border-top:0}.project-row{align-items:start;grid-template-columns:22px minmax(0,1fr);padding:13px 15px}.project-row .badge,.project-row>.button{grid-column:2}.ranked-heading{align-items:flex-start;flex-direction:column;gap:4px}.attention-item{align-items:flex-start;flex-direction:column;gap:6px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important}}
|
|
9
|
+
</style></head><body><div class="control-room-shell"><aside class="rail"><div class="brand">yoke<span>.</span></div><p class="rail-copy">Verified work. Clear handoffs.<br>Local by default.</p><div class="rail-label">Workspace</div><nav id="navigation" aria-label="Workspace navigation"></nav><div class="rail-foot">Project data stays on this machine.</div></aside><main class="canvas"><header class="command-bar"><div class="command-scope"><span class="label">Yoke / control room</span><span class="command-title">Workspace overview</span></div><div class="command-actions"><button id="theme-toggle" class="button icon-button" type="button">Use dark theme</button><button id="refresh" class="button primary" type="button">Sync workspace</button></div></header><div id="content" aria-live="polite"><section id="attention-region" class="attention-region" hidden></section><div class="metric-strip" hidden></div><div class="project-row" hidden></div><span hidden>Ranked projects</span><p class="loading">Loading workspace…</p></div></main></div>
|
|
10
|
+
<style nonce="${nonce}">.analytics-chart-grid{display:grid;gap:18px;grid-template-columns:repeat(2,minmax(0,1fr))}.analytics-chart{border-top:1px solid var(--rule);padding-top:14px}.analytics-chart h3{font-size:13px;margin:0 0 10px}.analytics-chart svg{background:var(--raised);display:block;height:auto;min-height:140px;width:100%}.analytics-chart table{font-size:11px}.analytics-view>.metric-strip{margin-top:0}@media(max-width:820px){.analytics-chart-grid{grid-template-columns:1fr}}</style>
|
|
11
|
+
<style nonce="${nonce}">textarea{background:var(--surface);border:1px solid var(--rule);border-radius:4px;color:var(--text);font:inherit;line-height:1.45;max-width:100%;padding:9px;resize:vertical;width:100%}textarea:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.compose-feedback{border-left:3px solid var(--signal);padding-left:12px}.compose-feedback.validation{border-color:var(--warning)}.compose-feedback.failure{border-color:var(--danger)}.status-line{align-items:center;display:flex;gap:8px}.status-line::before{background:var(--signal);border-radius:50%;content:'';display:inline-block;height:8px;width:8px}.status-line.stale::before{background:var(--warning)}.status-line.error::before{background:var(--danger)}.rail,.rail nav{min-width:0}.nav-button{max-width:100%;min-width:0;overflow-wrap:anywhere;white-space:normal}.nav-button:hover,.nav-button[aria-current=true]{color:var(--text)}@media(max-width:820px){.nav-button{flex:0 0 auto;max-width:240px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}</style>
|
|
10
12
|
<script nonce="${nonce}">
|
|
11
13
|
const sessionToken = ${JSON.stringify(token)};
|
|
12
|
-
const content=document.getElementById('content'),
|
|
14
|
+
const content=document.getElementById('content'),navigation=document.getElementById('navigation'),themeToggle=document.getElementById('theme-toggle');
|
|
15
|
+
function themeState(){return document.documentElement.dataset.theme||((typeof matchMedia==='function'&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light')}
|
|
16
|
+
function setTheme(value,persist=true){const theme=value==='dark'?'dark':'light';document.documentElement.dataset.theme=theme;document.documentElement.style.colorScheme=theme;if(persist)localStorage.setItem('yoke-dashboard-theme',theme);themeToggle.textContent=theme==='dark'?'Use light theme':'Use dark theme'}
|
|
17
|
+
function initializeTheme(){const saved=localStorage.getItem('yoke-dashboard-theme');setTheme(saved==='dark'||saved==='light'?saved:((typeof matchMedia==='function'&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light'),false)}
|
|
18
|
+
initializeTheme();themeToggle.onclick=()=>setTheme(themeState()==='dark'?'light':'dark');
|
|
13
19
|
const defaultNavigation={screen:'overview',project:null,view:'now',period:30,group:'day',from:'',to:''};
|
|
14
20
|
let navigationState=parseNavigationHash(typeof location==='undefined'?'':location.hash);
|
|
15
|
-
let selected=navigationState.screen==='project'?navigationState.project:null,
|
|
21
|
+
let selected=navigationState.screen==='project'?navigationState.project:null,projects=[],overviewMetrics=[],overviewAnalytics=null;
|
|
16
22
|
let projectView=navigationState.view,periodDays=navigationState.period,bucket=navigationState.group,customFrom=navigationState.from,customTo=navigationState.to;
|
|
17
|
-
let overviewQuery='',overviewFilter='all',requestVersion=0,activeController=null;
|
|
23
|
+
let overviewQuery=navigationState.query||'',overviewFilter=navigationState.status||'all',overviewSort=navigationState.sort||'attention',requestVersion=0,activeController=null;
|
|
18
24
|
function el(tag,text,cls){const node=document.createElement(tag);if(text!==undefined)node.textContent=String(text);if(cls)node.className=cls;return node}
|
|
19
|
-
function append(parent,...children){for(const child of children)parent.append(child);return parent}
|
|
25
|
+
function append(parent,...children){for(const child of children)if(child)parent.append(child);return parent}
|
|
20
26
|
function badge(state){return el('span',state||'unknown','badge '+(['complete','passed'].includes(state)?'good':['blocked','failed','paused','unverified','unconfirmed','unavailable'].includes(state)?'attention':''))}
|
|
21
|
-
function button(label,action,primary=false){const node=el('button',label,'button'+(primary?' primary':''));node.addEventListener('click',()=>Promise.resolve().then(action).catch(error=>content.append(el('p',error.message,'notice'))));return node}
|
|
27
|
+
function button(label,action,primary=false){const node=el('button',label,'button'+(primary?' primary':''));node.type='button';node.addEventListener('click',()=>Promise.resolve().then(action).catch(error=>content.append(el('p',error.message,'notice'))));return node}
|
|
22
28
|
function duration(ms){if(!Number.isFinite(ms))return 'Unknown';if(ms<60000)return Math.round(ms/1000)+'s';if(ms<3600000)return Math.round(ms/60000)+'m';return (ms/3600000).toFixed(1)+'h'}
|
|
23
29
|
function tokenText(value){return Number.isFinite(value)?value.toLocaleString('en-US'):'Unknown'}
|
|
24
30
|
function taskTiming(task,estimate){const planned=estimate?.available?estimate.tasks.find(item=>item.storyId===task.id):undefined;return planned?'Predicted '+duration(planned.endMs-planned.startMs)+' · planned start +'+duration(planned.startMs):task.passes?'Completed · timing unknown':'Duration and planned start unknown'}
|
|
25
31
|
function unknownCallCount(status){return Number.isFinite(status?.measurement?.unknownCalls)?status.measurement.unknownCalls:Array.isArray(status?.tokens?.calls)?status.tokens.calls.filter(call=>call.usageAvailable!==true).length:undefined}
|
|
26
|
-
function renderUsage(project){const status=project.status,usage=status?.tokens,measurement=status?.measurement;const panel=append(el('section',undefined,'panel'),el('h2','Token usage'));panel.append(el('p','Recorded input: '+tokenText(usage?.inputTokens)+' · recorded output: '+tokenText(usage?.outputTokens)));panel.append(el('p','Calls with unknown usage: '+tokenText(unknownCallCount(status))+' · unmeasured attempts: '+tokenText(measurement?.unmeasuredAttempts),'muted'));if(usage?.measurementComplete===false)panel.append(el('p','Partial measurement. These counts are the reported portion; total usage is unknown.','muted'));if(!usage)panel.append(el('p','No token totals have been reported.','muted'));content.append(panel)}
|
|
27
|
-
function metric(title,value,note){return append(el('div',undefined,'metric'),el('div',title,'label'),el('strong',value),el('p',note))}
|
|
28
32
|
function validDate(value){if(!/^\\d{4}-\\d{2}-\\d{2}$/u.test(value))return false;const time=Date.parse(value+'T00:00:00Z');return Number.isFinite(time)&&new Date(time).toISOString().slice(0,10)===value}
|
|
29
33
|
function parseNavigationHash(hash){
|
|
30
|
-
const params=new URLSearchParams(String(hash||'').replace(/^#/u,''));
|
|
31
|
-
const screen=params.get('screen')||'overview',project=params.get('project'),view=params.get('view')||'now';
|
|
32
|
-
const period=Number(params.get('period')||30),group=params.get('group')||'day',from=params.get('from')||'',to=params.get('to')||'';
|
|
34
|
+
const params=new URLSearchParams(String(hash||'').replace(/^#/u,''));const screen=params.get('screen')||'overview',project=params.get('project'),view=params.get('view')||'now',period=Number(params.get('period')||30),group=params.get('group')||'day',from=params.get('from')||'',to=params.get('to')||'',query=params.get('query')||'',status=params.get('status')||'all',sort=params.get('sort')||'attention';
|
|
33
35
|
const datesValid=(!from&&!to)||(validDate(from)&&validDate(to)&&from<=to&&Date.parse(to+'T00:00:00Z')-Date.parse(from+'T00:00:00Z')<=365*86400000);
|
|
34
|
-
if(!['overview','workspace','project'].includes(screen)||!['now','usage','results'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
function serializeNavigationHash(state){
|
|
38
|
-
const params=new URLSearchParams({screen:state.screen,view:state.view,period:String(state.period),group:state.group});
|
|
39
|
-
if(state.project)params.set('project',state.project);
|
|
40
|
-
if(state.from&&state.to){params.set('from',state.from);params.set('to',state.to)}
|
|
41
|
-
return '#'+params.toString();
|
|
42
|
-
}
|
|
43
|
-
function setNavigation(change,replace=false){
|
|
44
|
-
const next=parseNavigationHash(serializeNavigationHash({...navigationState,...change}));
|
|
45
|
-
navigationState=next;selected=next.screen==='project'?next.project:null;projectView=next.view;periodDays=next.period;bucket=next.group;customFrom=next.from;customTo=next.to;
|
|
46
|
-
if(typeof history!=='undefined'&&typeof location!=='undefined')history[replace?'replaceState':'pushState'](null,'',serializeNavigationHash(next));
|
|
47
|
-
renderRoute();
|
|
48
|
-
}
|
|
49
|
-
function restoreNavigation(){
|
|
50
|
-
const restored=parseNavigationHash(location.hash);
|
|
51
|
-
// Back/forward can emit both popstate and hashchange for one transition.
|
|
52
|
-
if(serializeNavigationHash(restored)===serializeNavigationHash(navigationState))return;
|
|
53
|
-
navigationState=restored;selected=navigationState.screen==='project'?navigationState.project:null;projectView=navigationState.view;periodDays=navigationState.period;bucket=navigationState.group;customFrom=navigationState.from;customTo=navigationState.to;
|
|
54
|
-
if(selected&&!projects.some(project=>project.id===selected)){navigationState={...navigationState,screen:'overview',project:null};selected=null}
|
|
55
|
-
if(typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));
|
|
56
|
-
renderRoute();
|
|
36
|
+
if(!['overview','workspace','project'].includes(screen)||!['now','usage','results','history'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!['all','active','attention'].includes(status)||!['attention','activity','tokens','cost','acceptance','name'].includes(sort)||query.length>200||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
|
|
37
|
+
const state={screen,project:screen==='project'?project:null,view,period,group,from,to};if(params.has('query'))state.query=query;if(params.has('status'))state.status=status;if(params.has('sort'))state.sort=sort;return state;
|
|
57
38
|
}
|
|
39
|
+
function serializeNavigationHash(state){const params=new URLSearchParams({screen:state.screen||'overview',view:state.view||'now',period:String(state.period||30),group:state.group||'day'});if(state.project)params.set('project',state.project);if(state.from&&state.to){params.set('from',state.from);params.set('to',state.to)}if(state.query)params.set('query',state.query);if(state.status&&state.status!=='all')params.set('status',state.status);if(state.sort&&state.sort!=='attention')params.set('sort',state.sort);return '#'+params.toString()}
|
|
40
|
+
function applyNavigation(next){navigationState=next;selected=next.screen==='project'?next.project:null;projectView=next.view;periodDays=next.period;bucket=next.group;customFrom=next.from;customTo=next.to;overviewQuery=next.query||'';overviewFilter=next.status||'all';overviewSort=next.sort||'attention'}
|
|
41
|
+
function setNavigation(change,replace=false){const next=parseNavigationHash(serializeNavigationHash({...navigationState,...change}));applyNavigation(next);if(typeof history!=='undefined'&&typeof location!=='undefined')history[replace?'replaceState':'pushState'](null,'',serializeNavigationHash(next));renderRoute()}
|
|
42
|
+
function updateOverviewNavigation(change){const next=parseNavigationHash(serializeNavigationHash({...navigationState,...change}));applyNavigation(next);if(typeof history!=='undefined'&&typeof location!=='undefined')history.replaceState(null,'',serializeNavigationHash(next))}
|
|
43
|
+
function restoreNavigation(){const restored=parseNavigationHash(location.hash);if(serializeNavigationHash(restored)===serializeNavigationHash(navigationState))return;applyNavigation(restored);if(selected&&!projects.some(project=>project.id===selected))applyNavigation({...restored,screen:'overview',project:null});if(typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));renderRoute()}
|
|
58
44
|
function beginRequest(){requestVersion++;if(activeController)activeController.abort();activeController=new AbortController();return {version:requestVersion,signal:activeController.signal}}
|
|
59
45
|
function currentRequest(version){return version===requestVersion}
|
|
60
46
|
function isAbort(error){return error&&error.name==='AbortError'}
|
|
61
47
|
async function api(path,options={},signal){const response=await fetch(path,{...options,signal});const data=await response.json();if(!response.ok)throw Error(data.error||'Request failed');return data}
|
|
62
48
|
function projectStatus(project,now=Date.now()){
|
|
63
|
-
const goal=project.goal?.status||null,loop=project.status?.state||null,attentionStates=['blocked','failed','paused'],activeStates=['active','running'];
|
|
64
|
-
const observed=(project.status?.supervision||[]).filter(p=>p.liveness==='alive'&&now-Date.parse(p.heartbeatAt)<15000).map(p=>Date.parse(p.lastProgressAt||'')).filter(Number.isFinite);
|
|
65
|
-
const updated=Math.max(Date.parse(project.status?.updatedAt||''),...observed),stale=activeStates.includes(loop)&&Number.isFinite(updated)&&now-updated>20*60000;
|
|
66
|
-
const attention=Boolean(project.errors?.length)||attentionStates.includes(loop)||attentionStates.includes(goal)||stale;
|
|
67
|
-
const active=activeStates.includes(loop)||activeStates.includes(goal);
|
|
68
|
-
let primary=project.errors?.length?'unavailable':attentionStates.includes(loop)?loop:attentionStates.includes(goal)?goal:activeStates.includes(loop)?loop:activeStates.includes(goal)?goal:goal||loop||'ready';
|
|
69
|
-
if(stale&&!project.errors?.length&&!attentionStates.includes(goal))primary='unconfirmed';
|
|
70
|
-
return {primary,goal,loop,attention,active,stale};
|
|
49
|
+
const goal=project.goal?.status||null,loop=project.status?.state||null,attentionStates=['blocked','failed','paused'],activeStates=['active','running'];const observed=(project.status?.supervision||[]).filter(p=>p.liveness==='alive'&&now-Date.parse(p.heartbeatAt)<15000).map(p=>Date.parse(p.lastProgressAt||'')).filter(Number.isFinite),updated=Math.max(Date.parse(project.status?.updatedAt||''),...observed),stale=activeStates.includes(loop)&&Number.isFinite(updated)&&now-updated>20*60000;const attention=Boolean(project.errors?.length)||attentionStates.includes(loop)||attentionStates.includes(goal)||stale,active=activeStates.includes(loop)||activeStates.includes(goal);let primary=project.errors?.length?'unavailable':attentionStates.includes(loop)?loop:attentionStates.includes(goal)?goal:activeStates.includes(loop)?loop:activeStates.includes(goal)?goal:goal||loop||'ready';if(stale&&!project.errors?.length&&!attentionStates.includes(goal))primary='unconfirmed';return {primary,goal,loop,attention,active,stale}
|
|
71
50
|
}
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
function nav(){
|
|
80
|
-
navigation.replaceChildren();
|
|
81
|
-
const all=el('button','All projects','nav-button');all.setAttribute('aria-current',String(navigationState.screen==='overview'));all.onclick=()=>setNavigation({screen:'overview',project:null});navigation.append(all);
|
|
82
|
-
const compare=el('button','Compare consumption','nav-button');compare.setAttribute('aria-current',String(navigationState.screen==='workspace'));compare.onclick=()=>setNavigation({screen:'workspace',project:null});navigation.append(compare);
|
|
83
|
-
for(const project of projects){const item=el('button',project.name,'nav-button');item.setAttribute('aria-current',String(navigationState.screen==='project'&&selected===project.id));item.onclick=()=>setNavigation({screen:'project',project:project.id});navigation.append(item)}
|
|
84
|
-
}
|
|
85
|
-
function heading(title,subtitle){content.replaceChildren(el('h1',title),el('p',subtitle,'summary muted'))}
|
|
51
|
+
function rankingValue(project,key){if(key==='name')return project.name;if(Number.isFinite(project[key]))return project[key];const row=overviewMetrics.find(item=>item.id===project.id);if(row&&Number.isFinite(row[key]))return row[key];if(key==='attention')return projectStatus(project).attention?1:0;return null}
|
|
52
|
+
function rankProjects(values,sort='attention'){return [...values].sort((a,b)=>{if(sort==='name')return String(a.name).localeCompare(String(b.name))||String(a.id).localeCompare(String(b.id));const left=rankingValue(a,sort),right=rankingValue(b,sort);if(left===null&&right!==null)return 1;if(left!==null&&right===null)return -1;if(left!==right)return (right??0)-(left??0);return String(a.name).localeCompare(String(b.name))||String(a.id).localeCompare(String(b.id))})}
|
|
53
|
+
function visibleProjects(values,query,filter,now=Date.now(),sort='attention'){const needle=query.trim().toLowerCase();const filtered=values.filter(project=>{const status=projectStatus(project,now),matches=!needle||[project.name,project.root,project.goal?.objective].some(value=>String(value||'').toLowerCase().includes(needle));return matches&&(filter==='all'||filter==='active'&&status.active||filter==='attention'&&status.attention)});return rankProjects(filtered,sort)}
|
|
54
|
+
function nav(){navigation.replaceChildren();const overview=el('button','Overview','nav-button');overview.type='button';overview.setAttribute('aria-current',String(navigationState.screen==='overview'));overview.onclick=()=>setNavigation({screen:'overview',project:null});navigation.append(overview);const compare=el('button','Workspace analytics','nav-button');compare.type='button';compare.setAttribute('aria-current',String(navigationState.screen==='workspace'));compare.onclick=()=>setNavigation({screen:'workspace',project:null});navigation.append(compare);for(const project of projects){const item=el('button',project.name,'nav-button');item.type='button';item.setAttribute('aria-current',String(navigationState.screen==='project'&&selected===project.id));item.onclick=()=>setNavigation({screen:'project',project:project.id});navigation.append(item)}}
|
|
55
|
+
function heading(title,subtitle){content.setAttribute?.('aria-busy','false');content.replaceChildren(append(el('div',undefined,'page-heading'),el('h1',title),el('p',subtitle,'summary')))}
|
|
56
|
+
function projectMetric(project,key){const value=rankingValue(project,key);return key==='cost'?(Number.isFinite(value)?'$'+value.toFixed(3):'Unknown'):key==='acceptance'?(Number.isFinite(value)?String(value):'Unknown'):key==='tokens'?tokenText(value):Number.isFinite(value)?String(value):'Unknown'}
|
|
86
57
|
function renderOverview(){
|
|
87
|
-
beginRequest();nav();heading('A
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const visible=visibleProjects(projects,overviewQuery,overviewFilter);
|
|
96
|
-
for(const project of visible){const status=projectStatus(project),card=append(el('article',undefined,'project-card'),badge(status.primary),el('h2',project.name),el('p',project.goal?.objective||'No active objective yet.'),el('p',project.root,'path'));
|
|
97
|
-
if(status.goal&&status.loop&&status.goal!==status.loop)card.append(el('p','Goal: '+status.goal+' · Loop: '+(status.stale?'unconfirmed (last reported '+status.loop+')':status.loop),'state-line'));
|
|
98
|
-
else if(status.stale)card.append(el('p','Loop activity is unconfirmed; last report was '+status.loop+'.','state-line notice'));
|
|
99
|
-
if(['active','running'].includes(status.loop)&&project.status?.storyTitle)card.append(el('p','Reported task: '+project.status.storyTitle,'state-line'));
|
|
100
|
-
const reason=['blocked','failed','paused'].includes(status.loop)?project.status?.reason:['blocked','paused'].includes(status.goal)?project.goal?.reason:null;
|
|
101
|
-
if(reason)card.append(el('p',reason,'notice'));
|
|
102
|
-
if(project.errors.length)card.append(el('p',project.errors.join('; '),'notice'));
|
|
103
|
-
card.append(button('Open project →',()=>setNavigation({screen:'project',project:project.id})));cards.append(card);
|
|
104
|
-
}
|
|
105
|
-
if(!visible.length){const empty=append(el('div',undefined,'empty'),el('p',projects.length?'No projects match this search and filter.':'No registered projects. Run yoke dashboard from a project directory.'));if(projects.length)empty.append(button('Clear search and filters',()=>{overviewQuery='';overviewFilter='all';renderOverview()}));cards.append(empty)}
|
|
106
|
-
for(const child of filters.children)child.setAttribute('aria-pressed',String(child.dataset.filter===overviewFilter));
|
|
107
|
-
}
|
|
108
|
-
for(const [value,label] of [['all','All'],['active','Active'],['attention','Needs attention']]){const control=button(label,()=>{overviewFilter=value;draw()});control.dataset.filter=value;filters.append(control)}
|
|
109
|
-
search.oninput=()=>{overviewQuery=search.value;draw()};tools.append(filters);content.append(tools,cards);draw();
|
|
58
|
+
const request=beginRequest();nav();content.setAttribute('aria-busy','true');heading('Workspace control room','A ranked view of active work, attention signals, and recorded telemetry.');const statuses=projects.map(project=>projectStatus(project)),attentionProjects=projects.filter(project=>projectStatus(project).attention),active=statuses.filter(status=>status.active).length;
|
|
59
|
+
const attention=append(el('section',undefined,'attention-region'),el('h2','Attention'),el('p',attentionProjects.length?'Projects that need a decision or a fresh report.':'No projects need attention right now.'));attention.id='attention-region';const attentionList=el('div',undefined,'attention-list');for(const project of rankProjects(attentionProjects,'attention').slice(0,4)){const state=projectStatus(project),item=append(el('article',undefined,'attention-item'),append(el('div',undefined,'row-main'),el('strong',project.name),el('small',project.status?.reason||project.goal?.reason||state.primary)));item.append(button('Open',()=>setNavigation({screen:'project',project:project.id}),true));attentionList.append(item)}attention.append(attentionList);content.append(attention);
|
|
60
|
+
const strip=el('div',undefined,'metric-strip');content.append(strip);
|
|
61
|
+
const command=append(el('section',undefined,'command-panel overview-command-bar'),el('label',undefined,'field'),el('label',undefined,'field'),el('label',undefined,'field'),el('label',undefined,'field'),el('label',undefined,'field'),el('label',undefined,'field'));const searchField=command.children[0],statusField=command.children[1],sortField=command.children[2],scopeField=command.children[3],fromField=command.children[4],toField=command.children[5];searchField.append(el('span','Search','label'));const search=el('input');search.type='search';search.placeholder='Project, path or objective';search.value=overviewQuery;search.setAttribute('aria-label','Search projects');searchField.append(search);statusField.append(el('span','Status','label'));const status=el('select');status.setAttribute('aria-label','Project status');for(const [value,label] of [['all','All states'],['attention','Needs attention'],['active','Active work']]){const option=el('option',label);option.value=value;option.selected=value===overviewFilter;status.append(option)}statusField.append(status);sortField.append(el('span','Sort projects','label'));const sort=el('select');sort.setAttribute('aria-label','Sort projects');for(const [value,label] of [['attention','Attention'],['activity','Last activity'],['tokens','Token usage'],['cost','Reported cost'],['acceptance','Accepted work'],['name','Name']]){const option=el('option',label);option.value=value;option.selected=value===overviewSort;sort.append(option)}sortField.append(sort);scopeField.append(el('span','Date scope · UTC','label'));const scope=el('select');scope.setAttribute('aria-label','Date scope');for(const [value,label] of [[1,'Last 24 hours'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days'],[365,'Last 365 days']]){const option=el('option',label);option.value=String(value);option.selected=value===periodDays&&!customFrom;scope.append(option)}scopeField.append(scope);fromField.append(el('span','From','label'));const from=el('input');from.type='date';from.value=customFrom;from.setAttribute('aria-label','From date UTC');fromField.append(from);toField.append(el('span','Through','label'));const to=el('input');to.type='date';to.value=customTo;to.setAttribute('aria-label','Through date UTC');toField.append(to);content.append(command);
|
|
62
|
+
const ranked=append(el('section',undefined,'ranked-projects'),append(el('div',undefined,'ranked-heading'),el('h2','Ranked projects'),el('p','Sorted by '+sort.options[sort.selectedIndex].textContent+' · recorded values only')));const rows=el('div');ranked.append(rows);content.append(ranked);
|
|
63
|
+
function draw(){const total=overviewAnalytics?.total,recorded=total?.tokens?.total;strip.replaceChildren(metric('Projects',String(projects.length),'Registered locally'),metric('In progress',String(active),'Active goals or loops'),metric('Needs attention',String(attentionProjects.length),'Blocked, stale, or unavailable'),metric('Recorded tokens',tokenText(recorded),'Selected UTC scope'));rows.replaceChildren();const visible=visibleProjects(projects,overviewQuery,overviewFilter,Date.now(),overviewSort);for(const [index,project] of visible.entries()){const state=projectStatus(project),row=append(el('article',undefined,'project-row'),el('span',String(index+1).padStart(2,'0'),'rank'),append(el('div',undefined,'project-identity'),el('div',project.name,'project-name'),el('div',project.goal?.objective||'No active objective yet.','project-objective'),el('div',project.root,'project-path')),append(el('div',undefined,'project-stat'),badge(state.primary)),append(el('div',undefined,'project-stat'),el('strong',projectMetric(project,'tokens')),el('span','tokens')),append(el('div',undefined,'project-stat'),el('strong',projectMetric(project,'acceptance')),el('span','accepted')),button('Open',()=>setNavigation({screen:'project',project:project.id})));rows.append(row)}if(!visible.length)rows.append(append(el('div',undefined,'empty'),el('p',projects.length?'No projects match these controls.':'No registered projects.'),projects.length?button('Reset controls',()=>{overviewQuery='';overviewFilter='all';overviewSort='attention';updateOverviewNavigation({query:'',status:'all',sort:'attention'});renderOverview()}):null))}
|
|
64
|
+
search.oninput=()=>{overviewQuery=search.value;updateOverviewNavigation({query:overviewQuery});draw()};status.onchange=()=>{overviewFilter=status.value;updateOverviewNavigation({status:overviewFilter});draw()};sort.onchange=()=>{overviewSort=sort.value;updateOverviewNavigation({sort:overviewSort});draw()};scope.onchange=()=>setNavigation({period:Number(scope.value),from:'',to:''});const applyDates=button('Apply dates',()=>{if(!validDate(from.value)||!validDate(to.value)||from.value>to.value||Date.parse(to.value+'T00:00:00Z')-Date.parse(from.value+'T00:00:00Z')>365*86400000){command.append(el('p','Choose both dates in chronological order within 366 days.','notice'));return}setNavigation({from:from.value,to:to.value})});toField.append(applyDates);draw();
|
|
65
|
+
const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo);void api('/api/workspace/analytics?'+queryPeriod(bounds.current)+'&sort='+encodeURIComponent(overviewSort),{},request.signal).then(data=>{if(!currentRequest(request.version)||navigationState.screen!=='overview')return;overviewAnalytics=data;overviewMetrics=data.projects||[];draw()}).catch(error=>{if(!isAbort(error)&¤tRequest(request.version))ranked.append(el('p','Historical ranking unavailable; live project state remains visible.','notice'))});
|
|
110
66
|
}
|
|
111
67
|
function row(title,note,state){return append(el('div',undefined,'row'),append(el('div',undefined,'row-main'),el('div',title),el('small',note)),badge(state))}
|
|
112
|
-
|
|
113
68
|
${dashboardPanels()}
|
|
114
69
|
function renderRoute(){if(navigationState.screen==='workspace')showWorkspaceUsage();else if(navigationState.screen==='project'&&selected&&projects.some(project=>project.id===selected))showProject(selected);else renderOverview()}
|
|
115
|
-
async function refresh(){
|
|
116
|
-
|
|
117
|
-
try{
|
|
118
|
-
const loaded=await api('/api/projects',{},request.signal);if(!currentRequest(request.version))return;projects=loaded;
|
|
119
|
-
if(selected&&!projects.some(project=>project.id===selected)){navigationState={...navigationState,screen:'overview',project:null};selected=null}
|
|
120
|
-
if(typeof location!=='undefined'&&typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));
|
|
121
|
-
renderRoute();
|
|
122
|
-
}catch(error){if(currentRequest(request.version)&&!isAbort(error))heading('Workspace unavailable',error.message)}
|
|
123
|
-
}
|
|
124
|
-
document.getElementById('refresh').onclick=refresh;
|
|
125
|
-
if(typeof window!=='undefined'){window.addEventListener('popstate',restoreNavigation);window.addEventListener('hashchange',restoreNavigation)}
|
|
126
|
-
refresh();
|
|
70
|
+
async function refresh(){const request=beginRequest();content.setAttribute?.('aria-busy','true');try{const loaded=await api('/api/projects',{},request.signal);if(!currentRequest(request.version))return;projects=loaded;overviewMetrics=[];overviewAnalytics=null;if(selected&&!projects.some(project=>project.id===selected))applyNavigation({...navigationState,screen:'overview',project:null});if(typeof location!=='undefined'&&typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));renderRoute()}catch(error){if(currentRequest(request.version)&&!isAbort(error)){heading('Workspace unavailable','Offline or unavailable: '+error.message);content.append(el('p','Saved local data could not be read. Retry when the local Yoke process is available.','notice'))}}}
|
|
71
|
+
document.getElementById('refresh').onclick=refresh;if(typeof window!=='undefined'){window.addEventListener('popstate',restoreNavigation);window.addEventListener('hashchange',restoreNavigation)}refresh();
|
|
127
72
|
</script></body></html>`;
|
|
128
73
|
}
|