eklavya 1.66.0 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/artifacts.js +2 -2
- package/dist/assets/dashboard.html +1472 -366
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/dashboard-daemon.js +25 -4
- package/dist/dashboard-daemon.js.map +1 -1
- package/dist/dashboard.js +1163 -227
- package/dist/dashboard.js.map +1 -1
- package/dist/migrations/030_events_rollup_index.sql +26 -0
- package/dist/migrations/031_session_concept_order.sql +33 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -28,9 +28,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import { decayedScore, isKnown, isOwed, MS_PER_DAY } from './srs.js';
|
|
29
29
|
import { correctionStates, correctionTarget, CorrectionError, GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey, promoteIfEarned, recordRetry, } from './store.js';
|
|
30
30
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
31
|
-
import { loadConfig, loadGlobalConfig, loadProjectConfig, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
|
|
31
|
+
import { findRepoConfig, LEGACY_REPO_CONFIG_FILE, loadConfig, loadGlobalConfig, loadProjectConfig, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
|
|
32
32
|
import { applySetting, knownKeys, SETTING_RULES, valueAt } from './config-path.js';
|
|
33
|
-
import { dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath } from './paths.js';
|
|
33
|
+
import { artifactsDir, dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath, projectsDir, } from './paths.js';
|
|
34
34
|
import { ownVersion } from './dashboard-daemon.js';
|
|
35
35
|
import { countUse } from './telemetry.js';
|
|
36
36
|
import { acknowledgeFeedback, deleteFeedback, feedbackSummary, getFeedback, feedbackPending, listAcknowledged } from './feedback.js';
|
|
@@ -50,11 +50,38 @@ export { DEFAULT_PORT };
|
|
|
50
50
|
*/
|
|
51
51
|
const TIMELINE_DAYS = 365;
|
|
52
52
|
/**
|
|
53
|
-
* Cap on the attempt rows shipped for drill-down. Counts and
|
|
54
|
-
* aggregated in SQL over the whole table, so the cap can only
|
|
55
|
-
* history list, never make a number wrong
|
|
53
|
+
* Cap on the attempt rows shipped for drill-down, newest first. Counts and
|
|
54
|
+
* per-day totals are aggregated in SQL over the whole table, so the cap can only
|
|
55
|
+
* ever shorten a history list, never make a number wrong: anything the page
|
|
56
|
+
* counts, or narrows a project to, it takes from those aggregates and not from
|
|
57
|
+
* these rows (`totals`, `concepts`, `daily`, and `projectInventory`'s per-project
|
|
58
|
+
* counts, `totals` and `concept_slugs`).
|
|
59
|
+
*
|
|
60
|
+
* One number with `LOGGED_LIMIT`, and the one that sets the size of `/api/state`:
|
|
61
|
+
* a row is about 0.85 KB (a question, its options, the answer and the tutor's
|
|
62
|
+
* feedback), so these rows are over half of the payload. The budget for the
|
|
63
|
+
* payload at a year of daily use is 1.5 MB (issue #171), and at 2,000 rows of
|
|
64
|
+
* each kind it was 2.5 MB however the rest was trimmed: the two lists alone
|
|
65
|
+
* were more than that. At this size it is 1.15 MB with a 25-character
|
|
66
|
+
* repository path and 1.44 MB with a 105-character one (every row names its
|
|
67
|
+
* repository, so the path's length is in the size), which is why the number is
|
|
68
|
+
* not larger. `scripts/dashboard-perf.mjs medium` prints it.
|
|
69
|
+
*/
|
|
70
|
+
export const ATTEMPT_LIMIT = 800;
|
|
71
|
+
/**
|
|
72
|
+
* Cap on the logged context lines (`session_concepts` rows) shipped in the
|
|
73
|
+
* payload, newest first like `attempts` and the same size: one number for how
|
|
74
|
+
* much history each kind of row brings. Each line carries its context text, so
|
|
75
|
+
* the list grew without bound and was the largest key at scale; a learner with
|
|
76
|
+
* fewer lines sees no change. `logged_shown` and `logged_total` say when it is
|
|
77
|
+
* cut, as `attempts_shown` and `attempts_total` do, and every total stays right
|
|
78
|
+
* because it is aggregated in SQL: `totals.sessions`, the concept catalogue's
|
|
79
|
+
* last context, the project inventory (its counts, its `sessions`, its `totals`
|
|
80
|
+
* across projects and the `concept_slugs` the page narrows a project to). A session whose lines fall
|
|
81
|
+
* outside the cap is still opened through `/api/memory/sessions`
|
|
82
|
+
* (`sessionLearning`).
|
|
56
83
|
*/
|
|
57
|
-
const
|
|
84
|
+
export const LOGGED_LIMIT = 800;
|
|
58
85
|
/**
|
|
59
86
|
* The memory corpus does not travel in `/api/state`.
|
|
60
87
|
*
|
|
@@ -133,19 +160,30 @@ const many = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
|
133
160
|
* is not assessment (PRD LRN-02) — `assessed` is the only one of these that
|
|
134
161
|
* required the developer to answer something.
|
|
135
162
|
*/
|
|
136
|
-
function memorySummary(db) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
function memorySummary(db, events) {
|
|
164
|
+
// One pass over the entries (each carries its narrative, so a pass reads the most bytes of any
|
|
165
|
+
// table here) gives the totals and the types: a group per type, summed for the totals.
|
|
166
|
+
const entries = { total: 0, live: 0, superseded: 0, deleted: 0, notes: 0 };
|
|
167
|
+
const types = [];
|
|
168
|
+
for (const g of many(db, `SELECT type, count(*) AS n,
|
|
169
|
+
SUM(deleted_at IS NULL AND superseded_by IS NULL) AS live,
|
|
170
|
+
SUM(superseded_by IS NOT NULL) AS superseded,
|
|
171
|
+
SUM(deleted_at IS NOT NULL) AS deleted,
|
|
172
|
+
SUM(kind = 'note') AS notes
|
|
173
|
+
FROM memory_entries GROUP BY type ORDER BY type`)) {
|
|
174
|
+
entries.total += g.n;
|
|
175
|
+
entries.live += g.live;
|
|
176
|
+
entries.superseded += g.superseded;
|
|
177
|
+
entries.deleted += g.deleted;
|
|
178
|
+
entries.notes += g.notes;
|
|
179
|
+
// A type counts what is not deleted (superseded entries stay on the timeline, marked), so a type
|
|
180
|
+
// with nothing left is not listed, and an entry with no type is `untyped`.
|
|
181
|
+
if (g.n > g.deleted)
|
|
182
|
+
types.push({ type: g.type ?? 'untyped', n: g.n - g.deleted });
|
|
183
|
+
}
|
|
184
|
+
// Largest first; equal counts keep the types' order. (The statement this replaced left equal counts
|
|
185
|
+
// in whatever order its plan gave them, which was the opposite one until an index served it.)
|
|
186
|
+
types.sort((a, b) => b.n - a.n);
|
|
149
187
|
const candidates = many(db, 'SELECT status, count(*) AS n FROM learning_sources GROUP BY status');
|
|
150
188
|
return {
|
|
151
189
|
// What the hooks accepted from the host.
|
|
@@ -173,11 +211,14 @@ function memorySummary(db) {
|
|
|
173
211
|
JOIN attempts a ON a.concept_id = ls.concept_id
|
|
174
212
|
WHERE ls.concept_id IS NOT NULL`).n,
|
|
175
213
|
candidates: Object.fromEntries(candidates.map((c) => [c.status, c.n])),
|
|
176
|
-
types
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
214
|
+
types,
|
|
215
|
+
// A tag row always names an entry (a foreign key), so while none is deleted every tag counts and
|
|
216
|
+
// the entries need not be read again; once one is, only the live entries' tags do.
|
|
217
|
+
tags: entries.deleted === 0
|
|
218
|
+
? many(db, `SELECT tag, count(*) AS n FROM memory_entry_tags GROUP BY tag ORDER BY n DESC, tag LIMIT 60`)
|
|
219
|
+
: many(db, `SELECT t.tag, count(*) AS n FROM memory_entry_tags t
|
|
220
|
+
JOIN memory_entries e ON e.id = t.entry_id AND e.deleted_at IS NULL
|
|
221
|
+
GROUP BY t.tag ORDER BY n DESC, t.tag LIMIT 60`),
|
|
181
222
|
projects: many(db, `SELECT project, count(*) AS n FROM memory_entries GROUP BY project ORDER BY n DESC`),
|
|
182
223
|
per_page: MEMORY_PER,
|
|
183
224
|
};
|
|
@@ -228,14 +269,13 @@ function reuseSummary(db) {
|
|
|
228
269
|
* `last_error`, never a key, never a log dump (PRD DASH-03). A class is what
|
|
229
270
|
* tells the reader whether to re-authenticate or wait.
|
|
230
271
|
*/
|
|
231
|
-
function healthSummary(db, config) {
|
|
232
|
-
const beat = one(db, 'SELECT max(occurred_at) AS newest, max(received_at) AS received FROM evidence_events');
|
|
272
|
+
function healthSummary(db, config, events) {
|
|
233
273
|
return {
|
|
234
274
|
capture: {
|
|
235
275
|
enabled: config.memory.enabled,
|
|
236
276
|
mode: config.memory.capture,
|
|
237
|
-
newest_event:
|
|
238
|
-
newest_received:
|
|
277
|
+
newest_event: events.newest,
|
|
278
|
+
newest_received: events.received,
|
|
239
279
|
retention_days: config.memory.retention_days,
|
|
240
280
|
// Each project stamps its own sweep; the page shows the latest of them
|
|
241
281
|
// (the bare key is the stamp versions before per-project retention wrote).
|
|
@@ -261,17 +301,86 @@ function healthSummary(db, config) {
|
|
|
261
301
|
},
|
|
262
302
|
};
|
|
263
303
|
}
|
|
264
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Every group, in `(project, session_id, checkout)` order: the order `GROUP BY`
|
|
306
|
+
* already yields, spelled out because the inventory's rows are created in it.
|
|
307
|
+
*/
|
|
308
|
+
function evidenceGroups(db) {
|
|
309
|
+
// Every aggregate here is paid once per event (about 0.1 ms per thousand events
|
|
310
|
+
// each), so a sum that follows from the others is taken from them once per
|
|
311
|
+
// group: `status` is never null, so what is not processed is pending. A group
|
|
312
|
+
// has rows, so its sums are numbers and need no default.
|
|
313
|
+
return many(db, `SELECT project, session_id, checkout, count(*) AS events,
|
|
314
|
+
SUM(status = 'summarized') AS processed,
|
|
315
|
+
count(*) - SUM(status = 'summarized') AS pending,
|
|
316
|
+
SUM(redacted) AS redacted,
|
|
317
|
+
min(occurred_at) AS first, max(occurred_at) AS last, max(received_at) AS received
|
|
318
|
+
FROM evidence_events
|
|
319
|
+
GROUP BY project, session_id, checkout
|
|
320
|
+
ORDER BY project, session_id, checkout`);
|
|
321
|
+
}
|
|
322
|
+
/** The larger of two timestamps; `null` is no timestamp. They are ISO text, so text order is time order. */
|
|
323
|
+
const laterOf = (a, b) => (a === null || b > a ? b : a);
|
|
324
|
+
function evidenceTotals(groups) {
|
|
325
|
+
const t = { captured: 0, processed: 0, pending: 0, redacted: 0, newest: null, received: null };
|
|
326
|
+
for (const g of groups) {
|
|
327
|
+
t.captured += g.events;
|
|
328
|
+
t.processed += g.processed;
|
|
329
|
+
t.pending += g.pending;
|
|
330
|
+
t.redacted += g.redacted;
|
|
331
|
+
t.newest = laterOf(t.newest, g.last);
|
|
332
|
+
t.received = laterOf(t.received, g.received);
|
|
333
|
+
}
|
|
334
|
+
return t;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Text in the order SQLite sorts it (bytes of the UTF-8 form), which is not JavaScript's (UTF-16 units) for every string.
|
|
338
|
+
* The two agree wherever the first difference is between characters below the surrogates (which is
|
|
339
|
+
* every id and path people use), and the bytes are compared only where it is not.
|
|
340
|
+
*/
|
|
341
|
+
const binaryOrder = (a, b) => {
|
|
342
|
+
if (a === b)
|
|
343
|
+
return 0;
|
|
344
|
+
const shared = Math.min(a.length, b.length);
|
|
345
|
+
for (let i = 0; i < shared; i++) {
|
|
346
|
+
const x = a.charCodeAt(i);
|
|
347
|
+
const y = b.charCodeAt(i);
|
|
348
|
+
if (x !== y)
|
|
349
|
+
return x < 0xd800 && y < 0xd800 ? x - y : Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
350
|
+
}
|
|
351
|
+
return a.length - b.length;
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* One row per session that captured evidence, newest first, so a session page can
|
|
355
|
+
* link the two halves. A session that two projects both captured is two rows.
|
|
356
|
+
* Equal newest events keep the order `GROUP BY session_id, project` gave them.
|
|
357
|
+
*/
|
|
358
|
+
function memorySessions(db, groups) {
|
|
359
|
+
const merged = new Map();
|
|
360
|
+
for (const g of groups) {
|
|
361
|
+
const key = `${g.session_id}\u0000${g.project}`;
|
|
362
|
+
const row = merged.get(key);
|
|
363
|
+
if (!row) {
|
|
364
|
+
merged.set(key, { session_id: g.session_id, project: g.project, events: g.events, redacted: g.redacted, first: g.first, last: g.last });
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
row.events += g.events;
|
|
368
|
+
row.redacted += g.redacted;
|
|
369
|
+
if (g.first < row.first)
|
|
370
|
+
row.first = g.first;
|
|
371
|
+
if (g.last > row.last)
|
|
372
|
+
row.last = g.last;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const newest = [...merged.values()]
|
|
376
|
+
.sort((a, b) => binaryOrder(b.last, a.last) || binaryOrder(a.session_id, b.session_id) || binaryOrder(a.project, b.project))
|
|
377
|
+
.slice(0, MEMORY_SESSION_LIMIT);
|
|
378
|
+
// Counted by session alone, not by project, as this list always has. One pass
|
|
379
|
+
// over each table rather than a lookup per row (the candidates one scans).
|
|
380
|
+
const entries = new Map(many(db, 'SELECT session_id, count(*) AS n FROM memory_entries WHERE session_id IS NOT NULL GROUP BY session_id').map((r) => [r.session_id, r.n]));
|
|
381
|
+
const candidates = new Map(many(db, `SELECT m.session_id, count(*) AS n FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
|
|
382
|
+
WHERE m.session_id IS NOT NULL GROUP BY m.session_id`).map((r) => [r.session_id, r.n]));
|
|
383
|
+
return newest.map((r) => ({ ...r, entries: entries.get(r.session_id) ?? 0, candidates: candidates.get(r.session_id) ?? 0 }));
|
|
275
384
|
}
|
|
276
385
|
/**
|
|
277
386
|
* One page of the memory timeline (PRD DASH-02).
|
|
@@ -344,8 +453,28 @@ export function memoryPage(db, q = {}) {
|
|
|
344
453
|
* real total, so the Memory workflow's Sessions list never stops at the cap.
|
|
345
454
|
* A session can exist in `memory_entries` alone — an import carries entries
|
|
346
455
|
* and no evidence — so both tables contribute.
|
|
456
|
+
*
|
|
457
|
+
* Two steps, so the cost follows the page and not the database: the key set
|
|
458
|
+
* (which sessions there are, newest first, which is the total and the order),
|
|
459
|
+
* then the counts for the page's own rows. Equal newest times keep the order
|
|
460
|
+
* `GROUP BY session_id, project` gives them: session id, then project.
|
|
461
|
+
*
|
|
462
|
+
* Asking for one session (`session`) also answers for the learning half, which
|
|
463
|
+
* is in neither table above: see `sessionLearning`. That is how the page opens
|
|
464
|
+
* a session whose rows were cut from `logged` and `attempts`.
|
|
465
|
+
*
|
|
466
|
+
* One read transaction, so the key set and the counts under it are one snapshot
|
|
467
|
+
* of the database. They were one statement before they were two, and another
|
|
468
|
+
* process (a retention sweep, a purge) may delete rows at any moment: without
|
|
469
|
+
* the snapshot a session's last row could go between the two reads, leaving a
|
|
470
|
+
* key with nothing to count, and the page's first and last time would have no
|
|
471
|
+
* time to take (an HTTP 500). A deferred transaction that only reads takes no
|
|
472
|
+
* write lock and, in WAL, never blocks the writer it is a snapshot against.
|
|
347
473
|
*/
|
|
348
474
|
export function memorySessionPage(db, q = {}) {
|
|
475
|
+
return db.transaction(() => sessionPageRows(db, q))();
|
|
476
|
+
}
|
|
477
|
+
function sessionPageRows(db, q) {
|
|
349
478
|
const per = Math.min(MEMORY_PER_MAX, Math.max(1, Math.floor(q.per || MEMORY_PER)));
|
|
350
479
|
const where = [];
|
|
351
480
|
const args = [];
|
|
@@ -358,25 +487,54 @@ export function memorySessionPage(db, q = {}) {
|
|
|
358
487
|
args.push(q.session);
|
|
359
488
|
}
|
|
360
489
|
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const total =
|
|
490
|
+
// The entries half never names a session that is NULL; an import can leave one.
|
|
491
|
+
const entryClause = clause ? `${clause} AND session_id IS NOT NULL` : 'WHERE session_id IS NOT NULL';
|
|
492
|
+
const keys = many(db, `SELECT session_id, project FROM (
|
|
493
|
+
SELECT session_id, project, max(occurred_at) AS last FROM evidence_events ${clause} GROUP BY session_id, project
|
|
494
|
+
UNION ALL
|
|
495
|
+
SELECT session_id, project, max(occurred_at) FROM memory_entries ${entryClause} GROUP BY session_id, project)
|
|
496
|
+
GROUP BY session_id, project ORDER BY max(last) DESC, session_id, project`, ...args, ...args);
|
|
497
|
+
const total = keys.length;
|
|
369
498
|
const pages = Math.max(1, Math.ceil(total / per));
|
|
370
499
|
const p = Math.min(Math.max(1, Math.floor(q.page || 1)), pages);
|
|
371
|
-
const
|
|
372
|
-
min(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
500
|
+
const evidence = db.prepare(`SELECT count(*) AS events, COALESCE(SUM(redacted), 0) AS redacted, COALESCE(SUM(status <> 'summarized'), 0) AS pending,
|
|
501
|
+
min(occurred_at) AS first, max(occurred_at) AS last
|
|
502
|
+
FROM evidence_events WHERE session_id = ? AND project = ?`);
|
|
503
|
+
const entries = db.prepare(`SELECT COALESCE(SUM(deleted_at IS NULL AND superseded_by IS NULL), 0) AS live, min(occurred_at) AS first, max(occurred_at) AS last
|
|
504
|
+
FROM memory_entries WHERE session_id = ? AND project = ?`);
|
|
505
|
+
const candidates = db.prepare(`SELECT count(*) AS n FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
|
|
506
|
+
WHERE m.session_id = ? AND m.project = ?`);
|
|
507
|
+
const rows = keys.slice((p - 1) * per, p * per).map(({ session_id, project }) => {
|
|
508
|
+
const e = evidence.get(session_id, project);
|
|
509
|
+
const m = entries.get(session_id, project);
|
|
510
|
+
const c = candidates.get(session_id, project);
|
|
511
|
+
// The key is in at least one table, so at least one side has times.
|
|
512
|
+
const first = [e.first, m.first].filter((t) => t !== null).reduce((a, b) => (b < a ? b : a));
|
|
513
|
+
const last = [e.last, m.last].filter((t) => t !== null).reduce((a, b) => (b > a ? b : a));
|
|
514
|
+
return { session_id, project, events: e.events, redacted: e.redacted, pending: e.pending, first, last, entries: m.live, candidates: c.n };
|
|
515
|
+
});
|
|
516
|
+
return { total, page: p, pages, per, rows, ...(q.session ? { learning: sessionLearning(db, q.session) } : {}) };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Everything one session logged and was asked, however old: the rows the state
|
|
520
|
+
* payload cut (`LOGGED_LIMIT`, `ATTEMPT_LIMIT`) and the ones it never had to cut
|
|
521
|
+
* because the session has no memory to list it under. Same row shapes as the
|
|
522
|
+
* payload's `logged` and `attempts` (one query each, shared), so the page draws
|
|
523
|
+
* a looked-up session exactly as one it already holds.
|
|
524
|
+
*
|
|
525
|
+
* Whole, and not narrowed by the page's project scope: which project a row
|
|
526
|
+
* belongs to is the page's `pid()`, which folds worktrees and recorded
|
|
527
|
+
* checkouts the way `projectInventory` does, and a second reading of that here
|
|
528
|
+
* would be a second opinion. The session's attempts are capped at
|
|
529
|
+
* `ATTEMPT_LIMIT` like every attempts list, with `attempts_total` to say so;
|
|
530
|
+
* its logged lines are at most one per concept.
|
|
531
|
+
*/
|
|
532
|
+
function sessionLearning(db, session) {
|
|
533
|
+
return {
|
|
534
|
+
logged: loggedRows(db, projectFolder(), -1, session),
|
|
535
|
+
attempts: attemptRows(db, ATTEMPT_LIMIT, session),
|
|
536
|
+
attempts_total: one(db, `SELECT count(*) AS n FROM attempts a WHERE a.session_id = ? AND a.${NOT_CORRECTION}`, session).n,
|
|
537
|
+
};
|
|
380
538
|
}
|
|
381
539
|
/** The id of a project whose rows carry no repository at all: recorded before projects were tracked. */
|
|
382
540
|
export const UNATTRIBUTED = '~';
|
|
@@ -406,9 +564,12 @@ export const UNATTRIBUTED = '~';
|
|
|
406
564
|
* stored tells the two apart.
|
|
407
565
|
*/
|
|
408
566
|
export function projectInventory(db) {
|
|
567
|
+
const evidence = evidenceGroups(db);
|
|
409
568
|
const recorded = new Map();
|
|
410
|
-
for (const
|
|
411
|
-
|
|
569
|
+
for (const g of evidence) {
|
|
570
|
+
if (g.checkout !== null && g.checkout !== g.project) {
|
|
571
|
+
recorded.set(g.checkout, (recorded.get(g.checkout) ?? new Set()).add(g.project));
|
|
572
|
+
}
|
|
412
573
|
}
|
|
413
574
|
const folded = new Map();
|
|
414
575
|
const canonical = (raw) => {
|
|
@@ -436,10 +597,14 @@ export function projectInventory(db) {
|
|
|
436
597
|
p = {
|
|
437
598
|
id, kind, path: kind === 'repo' ? id : null, name: '', available: kind === 'repo' ? fs.existsSync(id) : null,
|
|
438
599
|
sources: [], aliases: [],
|
|
439
|
-
learning: {
|
|
600
|
+
learning: {
|
|
601
|
+
answers: 0, passed: 0, skipped: 0, assessed_concepts: 0, logged_concepts: 0, logged_assessed_concepts: 0,
|
|
602
|
+
sessions: 0, first: null, last: null, concept_slugs: [],
|
|
603
|
+
},
|
|
440
604
|
memory: { events: 0, pending: 0, entries: 0, sessions: 0, receipts: 0, first: null, last: null },
|
|
441
605
|
first_active: null, last_active: null,
|
|
442
|
-
_assessed: new Set(), _logged: new Set(), _lsess: new Set(), _msess: new Set(),
|
|
606
|
+
_assessed: new Set(), _logged: new Set(), _touched: new Set(), _lsess: new Set(), _msess: new Set(),
|
|
607
|
+
_lspan: { min: null, max: null }, _mspan: { min: null, max: null },
|
|
443
608
|
};
|
|
444
609
|
byId.set(id, p);
|
|
445
610
|
}
|
|
@@ -450,26 +615,34 @@ export function projectInventory(db) {
|
|
|
450
615
|
p.aliases.push(v);
|
|
451
616
|
return p;
|
|
452
617
|
};
|
|
618
|
+
// Across every project: the sets whose union is not the sum of the cards.
|
|
619
|
+
const every = { sessions: new Set(), logged: new Set(), assessed: new Set() };
|
|
453
620
|
const sessionIds = new Map();
|
|
454
621
|
const note = (sid, id) => sessionIds.set(sid, (sessionIds.get(sid) ?? new Set()).add(id));
|
|
455
|
-
// Both timestamp shapes,
|
|
456
|
-
|
|
622
|
+
// Both timestamp shapes, read as instants (epoch milliseconds) so min/max compare as instants. A
|
|
623
|
+
// group of one row has the same time at both ends and is read once; the instant becomes ISO
|
|
624
|
+
// text once per project at the end, not once per row (it was most of this function's time).
|
|
625
|
+
const epoch = (ts) => {
|
|
457
626
|
if (!ts)
|
|
458
627
|
return null;
|
|
459
628
|
const t = parseTs(ts);
|
|
460
|
-
return Number.isFinite(t) ?
|
|
629
|
+
return Number.isFinite(t) ? t : null;
|
|
461
630
|
};
|
|
462
631
|
const span = (o, first, last) => {
|
|
463
|
-
const f =
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
632
|
+
const f = epoch(first);
|
|
633
|
+
const l = last === first ? f : epoch(last);
|
|
634
|
+
if (f !== null && (o.min === null || f < o.min))
|
|
635
|
+
o.min = f;
|
|
636
|
+
if (l !== null && (o.max === null || l > o.max))
|
|
637
|
+
o.max = l;
|
|
468
638
|
};
|
|
639
|
+
const isoOf = (t) => (t === null ? null : new Date(t).toISOString());
|
|
640
|
+
// Answers that name no repository: the project they count toward for the concept list is
|
|
641
|
+
// settled below, once the sessions that prove one are known (as the page's `pid()` does).
|
|
642
|
+
const blankAsked = [];
|
|
469
643
|
for (const r of many(db, `SELECT repo, session_id, concept_id, count(*) AS n,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
COALESCE(SUM(outcome IN ('declined','dont_know')), 0) AS skipped,
|
|
644
|
+
SUM(grade >= ${PASSING_GRADE}) AS passed,
|
|
645
|
+
SUM(outcome IN ('declined','dont_know')) AS skipped,
|
|
473
646
|
min(ts) AS first, max(ts) AS last
|
|
474
647
|
FROM attempts WHERE ${NOT_CORRECTION} GROUP BY repo, session_id, concept_id`)) {
|
|
475
648
|
const p = at(r.repo, 'attempts');
|
|
@@ -477,22 +650,34 @@ export function projectInventory(db) {
|
|
|
477
650
|
p.learning.passed += r.passed;
|
|
478
651
|
p.learning.skipped += r.skipped;
|
|
479
652
|
p._assessed.add(r.concept_id);
|
|
653
|
+
every.assessed.add(r.concept_id);
|
|
654
|
+
if (r.repo?.trim())
|
|
655
|
+
p._touched.add(r.concept_id);
|
|
656
|
+
else
|
|
657
|
+
blankAsked.push([r.session_id, r.concept_id]);
|
|
480
658
|
if (r.session_id) {
|
|
481
659
|
p._lsess.add(r.session_id);
|
|
660
|
+
every.sessions.add(r.session_id);
|
|
482
661
|
if (r.repo?.trim())
|
|
483
662
|
note(r.session_id, p.id);
|
|
484
663
|
}
|
|
485
|
-
span(p.
|
|
664
|
+
span(p._lspan, r.first, r.last);
|
|
486
665
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
666
|
+
// A miss corrected from its explainer counts as right, as on the Accuracy tile. They are the answers
|
|
667
|
+
// that have a correction and did not pass (a handful), counted here by repository rather than by
|
|
668
|
+
// a lookup made for every answer above; each one's project is already there.
|
|
669
|
+
for (const r of many(db, `SELECT a.repo, count(*) AS n FROM attempts a JOIN attempts fix ON fix.retry_of = a.id
|
|
670
|
+
WHERE a.${NOT_CORRECTION} AND a.grade < ${PASSING_GRADE} GROUP BY a.repo`)) {
|
|
671
|
+
byId.get(canonical(r.repo)).learning.passed += r.n;
|
|
672
|
+
}
|
|
673
|
+
// One row per project and session, whichever checkouts the events came from.
|
|
674
|
+
for (const g of evidence) {
|
|
675
|
+
const p = at(g.project, 'evidence');
|
|
676
|
+
p.memory.events += g.events;
|
|
677
|
+
p.memory.pending += g.pending;
|
|
678
|
+
p._msess.add(g.session_id);
|
|
679
|
+
note(g.session_id, p.id);
|
|
680
|
+
span(p._mspan, g.first, g.last);
|
|
496
681
|
}
|
|
497
682
|
// A gate row with no repository names no project, but the same session's
|
|
498
683
|
// own answers and evidence may. Where they agree on exactly one project, the
|
|
@@ -512,11 +697,19 @@ export function projectInventory(db) {
|
|
|
512
697
|
// Only work the agent logged. `record_attempt` writes a `review` row for
|
|
513
698
|
// every concept it grades, and counting those would call every answered
|
|
514
699
|
// concept "recorded from your work".
|
|
515
|
-
if ((r.origin ?? 'work') === 'work')
|
|
700
|
+
if ((r.origin ?? 'work') === 'work') {
|
|
516
701
|
p._logged.add(r.concept_id);
|
|
702
|
+
every.logged.add(r.concept_id);
|
|
703
|
+
}
|
|
704
|
+
// Any origin: a concept a question was asked about is as much in the project as one its work touched.
|
|
705
|
+
p._touched.add(r.concept_id);
|
|
517
706
|
p._lsess.add(r.session_id);
|
|
518
|
-
|
|
707
|
+
every.sessions.add(r.session_id);
|
|
708
|
+
span(p._lspan, r.first, r.last);
|
|
519
709
|
}
|
|
710
|
+
// An answer with no repository belongs where its session was proven to belong, else nowhere.
|
|
711
|
+
for (const [sid, concept] of blankAsked)
|
|
712
|
+
byId.get((sid && sessionProjects[sid]) || UNATTRIBUTED)._touched.add(concept);
|
|
520
713
|
for (const r of many(db, `SELECT project, session_id, COALESCE(SUM(deleted_at IS NULL AND superseded_by IS NULL), 0) AS live,
|
|
521
714
|
min(occurred_at) AS first, max(occurred_at) AS last
|
|
522
715
|
FROM memory_entries GROUP BY project, session_id`)) {
|
|
@@ -524,18 +717,26 @@ export function projectInventory(db) {
|
|
|
524
717
|
p.memory.entries += r.live;
|
|
525
718
|
if (r.session_id)
|
|
526
719
|
p._msess.add(r.session_id);
|
|
527
|
-
span(p.
|
|
720
|
+
span(p._mspan, r.first, r.last);
|
|
528
721
|
}
|
|
529
722
|
for (const r of many(db, 'SELECT project, count(*) AS n FROM context_receipts GROUP BY project')) {
|
|
530
723
|
at(r.project, 'receipts').memory.receipts += r.n;
|
|
531
724
|
}
|
|
532
725
|
for (const r of many(db, 'SELECT repo FROM project_levels'))
|
|
533
726
|
at(r.repo, 'levels');
|
|
534
|
-
const
|
|
727
|
+
const slugs = new Map(many(db, 'SELECT id, slug FROM concepts').map((c) => [c.id, c.slug]));
|
|
728
|
+
const projects = [...byId.values()].map(({ _assessed, _logged, _touched, _lsess, _msess, _lspan, _mspan, ...p }) => {
|
|
535
729
|
p.learning.assessed_concepts = _assessed.size;
|
|
536
730
|
p.learning.logged_concepts = _logged.size;
|
|
731
|
+
p.learning.logged_assessed_concepts = [..._logged].filter((id) => _assessed.has(id)).length;
|
|
732
|
+
// Concepts exist for every row that names one (a foreign key), so every id has its slug.
|
|
733
|
+
p.learning.concept_slugs = [..._touched].map((id) => slugs.get(id)).sort();
|
|
537
734
|
p.learning.sessions = _lsess.size;
|
|
538
735
|
p.memory.sessions = _msess.size;
|
|
736
|
+
p.learning.first = isoOf(_lspan.min);
|
|
737
|
+
p.learning.last = isoOf(_lspan.max);
|
|
738
|
+
p.memory.first = isoOf(_mspan.min);
|
|
739
|
+
p.memory.last = isoOf(_mspan.max);
|
|
539
740
|
const firsts = [p.learning.first, p.memory.first].filter(Boolean);
|
|
540
741
|
const lasts = [p.learning.last, p.memory.last].filter(Boolean);
|
|
541
742
|
p.first_active = firsts.sort()[0] ?? null;
|
|
@@ -562,11 +763,18 @@ export function projectInventory(db) {
|
|
|
562
763
|
const aliases = {};
|
|
563
764
|
for (const [raw, id] of folded)
|
|
564
765
|
aliases[raw] = id;
|
|
565
|
-
|
|
766
|
+
const totals = {
|
|
767
|
+
sessions: every.sessions.size,
|
|
768
|
+
logged_concepts: every.logged.size,
|
|
769
|
+
logged_assessed_concepts: [...every.logged].filter((id) => every.assessed.has(id)).length,
|
|
770
|
+
};
|
|
771
|
+
return { projects, aliases, sessions: sessionProjects, totals };
|
|
566
772
|
}
|
|
567
773
|
/**
|
|
568
|
-
* What an open page
|
|
569
|
-
* (PRD DASH-01)
|
|
774
|
+
* What an open page is told about, to notice that work landed while it was
|
|
775
|
+
* reading it (PRD DASH-01): `/api/events` streams it (`createLive`, which reads
|
|
776
|
+
* it for as long as a page is listening) and `/api/cursor` returns it alone,
|
|
777
|
+
* building no payload.
|
|
570
778
|
*
|
|
571
779
|
* Three parts, one per place the page's content comes from:
|
|
572
780
|
* - `change_version` (migration 021), which triggers move on every write to a
|
|
@@ -577,9 +785,9 @@ export function projectInventory(db) {
|
|
|
577
785
|
* - The artifact files' count, total size and newest mtime (`artifactsStamp`).
|
|
578
786
|
*
|
|
579
787
|
* Deliberately not `generated_at`, and not a hash of the payload: both change
|
|
580
|
-
* on every call -- scores are decayed against the clock -- and a
|
|
581
|
-
*
|
|
582
|
-
*
|
|
788
|
+
* on every call -- scores are decayed against the clock -- and a cursor that
|
|
789
|
+
* moves on every call would redraw the page every second. Not watched: the
|
|
790
|
+
* prune stamp and spool drop count in the health panel.
|
|
583
791
|
*/
|
|
584
792
|
export function changeCursor(db, configs = readConfigs(db)) {
|
|
585
793
|
const version = one(db, 'SELECT n FROM change_version WHERE id = 1').n;
|
|
@@ -589,6 +797,154 @@ export function changeCursor(db, configs = readConfigs(db)) {
|
|
|
589
797
|
.slice(0, 12);
|
|
590
798
|
return `${version}:${dials}:${artifactsStamp()}`;
|
|
591
799
|
}
|
|
800
|
+
/*
|
|
801
|
+
* Serving a build once per cursor.
|
|
802
|
+
*
|
|
803
|
+
* A build is the expensive part of opening the page (the state alone is 20 ms
|
|
804
|
+
* to a second, by the size of the database) and almost always rebuilds what the
|
|
805
|
+
* last one said: the page reads the whole payload on every load and the server
|
|
806
|
+
* is one thread. `changeCursor` is the version of everything the page shows and
|
|
807
|
+
* costs a few milliseconds, so every read asks it first and rebuilds only when
|
|
808
|
+
* it moved.
|
|
809
|
+
*
|
|
810
|
+
* One memo per database handle, in a WeakMap, never a global: tests and the
|
|
811
|
+
* daemon hold several databases in one process, and each answers for its own.
|
|
812
|
+
* The exported `settingsState` and `configurableProjects` read the same memo
|
|
813
|
+
* as the server does, so a build is paid for once whoever asks first.
|
|
814
|
+
*
|
|
815
|
+
* Two inputs are not in the cursor, and both expire the build by time instead:
|
|
816
|
+
* the clock (scores decay at read time, so `due` and `overdue_days` move
|
|
817
|
+
* without a write) and the filesystem (a project's `available` flag, whether a
|
|
818
|
+
* checkout still has its `.git`, what `listArtifacts` reads from the files).
|
|
819
|
+
* The health panel's prune stamp and the spool's drop count are not in the
|
|
820
|
+
* cursor either (see its comment) and ride the same minute: they show on the
|
|
821
|
+
* next build, not the next request. One more input is left out of the cursor on
|
|
822
|
+
* purpose and is too visible to wait a minute: `reviewFailed`.
|
|
823
|
+
*/
|
|
824
|
+
/** How long a build that reads the clock or the filesystem is served without being rebuilt. */
|
|
825
|
+
export const MEMO_TTL_MS = 60_000;
|
|
826
|
+
/** Serialised responses kept per database; the least recently used goes first. */
|
|
827
|
+
export const PAGE_CACHE_ENTRIES = 64;
|
|
828
|
+
/** A response larger than this many bytes is served but not kept, so one huge entry cannot pin megabytes. */
|
|
829
|
+
const PAGE_MAX_BYTES = 1024 * 1024;
|
|
830
|
+
const memos = new WeakMap();
|
|
831
|
+
/** The one route whose response a settings write changes. */
|
|
832
|
+
const SETTINGS_PATH = '/api/settings';
|
|
833
|
+
/**
|
|
834
|
+
* Whether the last session Eklavya looked at for feedback could not be reviewed.
|
|
835
|
+
*
|
|
836
|
+
* `feedback_reviewed` has no change triggers, deliberately (migration 026, and
|
|
837
|
+
* `migrate.test.ts` pins it): a session looked at and found quiet is not news
|
|
838
|
+
* for an open page. But the state says "couldn't review the last session" from
|
|
839
|
+
* it, and a page loaded a moment after that row landed has to say so, as it
|
|
840
|
+
* did when every load was a build. So the memo asks, alongside the cursor.
|
|
841
|
+
* An older schema without the table reads as not failed, as `feedbackSummary` does.
|
|
842
|
+
*/
|
|
843
|
+
function reviewFailed(db) {
|
|
844
|
+
try {
|
|
845
|
+
const last = one(db, 'SELECT outcome FROM feedback_reviewed ORDER BY reviewed_at DESC, rowid DESC LIMIT 1');
|
|
846
|
+
return last?.outcome === 'failed';
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Whether a build made at `at` may still be served at `now`. A clock that stepped back expires it too. */
|
|
853
|
+
const served = (at, now) => now >= at && now - at < MEMO_TTL_MS;
|
|
854
|
+
/**
|
|
855
|
+
* The memo for this database, valid for the cursor as it is right now (and for
|
|
856
|
+
* `reviewFailed`, the one thing beside it that the state reads and the cursor does not).
|
|
857
|
+
*
|
|
858
|
+
* The cursor is read here, before any build that is stored in the memo, and
|
|
859
|
+
* stays with it: a write that lands during a build makes the next read see a
|
|
860
|
+
* newer cursor and rebuild, where reading it after would label a build with a
|
|
861
|
+
* cursor newer than the rows it read. A change to the config or to an artifact
|
|
862
|
+
* keeps the inventory, which reads neither: only the database's counter, the
|
|
863
|
+
* first part of the cursor, drops it.
|
|
864
|
+
*/
|
|
865
|
+
function memoFor(db) {
|
|
866
|
+
const cursor = changeCursor(db);
|
|
867
|
+
const version = cursor.slice(0, cursor.indexOf(':'));
|
|
868
|
+
const valid = `${cursor}|${reviewFailed(db)}`;
|
|
869
|
+
let memo = memos.get(db);
|
|
870
|
+
if (memo?.cursor !== valid) {
|
|
871
|
+
memo = { cursor: valid, version, state: null, inventory: memo?.version === version ? memo.inventory : null, pages: new Map() };
|
|
872
|
+
memos.set(db, memo);
|
|
873
|
+
}
|
|
874
|
+
return memo;
|
|
875
|
+
}
|
|
876
|
+
/** `/api/state` as the bytes it is sent as. Built at most once per cursor and minute. */
|
|
877
|
+
function stateBody(memo, db) {
|
|
878
|
+
const now = Date.now();
|
|
879
|
+
if (memo.state && served(memo.state.at, now))
|
|
880
|
+
return memo.state.body;
|
|
881
|
+
const body = Buffer.from(JSON.stringify(dashboardState(db)));
|
|
882
|
+
memo.state = { at: now, body };
|
|
883
|
+
return body;
|
|
884
|
+
}
|
|
885
|
+
/** The project inventory, rebuilt when the database moved, a minute passed, or the caller must not trust a memo. */
|
|
886
|
+
function inventoryOf(memo, db, rebuild = false) {
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
if (rebuild || !memo.inventory || !served(memo.inventory.at, now)) {
|
|
889
|
+
memo.inventory = { at: now, value: projectInventory(db), configurable: null };
|
|
890
|
+
}
|
|
891
|
+
return memo.inventory;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* One response by its full URL (path and query), most recently used last.
|
|
895
|
+
*
|
|
896
|
+
* `build` returns null for a response that is not a page worth keeping (an
|
|
897
|
+
* entry that does not exist); it is not stored. `expires` is for a response
|
|
898
|
+
* that reads the filesystem, and `stamp` for one whose inputs are files the
|
|
899
|
+
* cursor does not cover: an entry made under another stamp is not served.
|
|
900
|
+
*/
|
|
901
|
+
function pageBody(memo, key, build, opts = {}) {
|
|
902
|
+
const now = Date.now();
|
|
903
|
+
const stamp = opts.stamp ? opts.stamp() : '';
|
|
904
|
+
const hit = memo.pages.get(key);
|
|
905
|
+
if (hit && hit.stamp === stamp && (!opts.expires || served(hit.at, now))) {
|
|
906
|
+
memo.pages.delete(key);
|
|
907
|
+
memo.pages.set(key, hit);
|
|
908
|
+
return hit.body;
|
|
909
|
+
}
|
|
910
|
+
const json = build();
|
|
911
|
+
// Replacing an entry must also move it: `Map.set` on a present key keeps its place.
|
|
912
|
+
memo.pages.delete(key);
|
|
913
|
+
if (json === null)
|
|
914
|
+
return null;
|
|
915
|
+
const body = Buffer.from(json);
|
|
916
|
+
if (body.length <= PAGE_MAX_BYTES) {
|
|
917
|
+
memo.pages.set(key, { at: now, stamp, body });
|
|
918
|
+
while (memo.pages.size > PAGE_CACHE_ENTRIES)
|
|
919
|
+
memo.pages.delete(memo.pages.keys().next().value);
|
|
920
|
+
}
|
|
921
|
+
return body;
|
|
922
|
+
}
|
|
923
|
+
/** The state and the inventory, built now so the first request finds them. Throws as the builders do. */
|
|
924
|
+
function warmMemo(db) {
|
|
925
|
+
const memo = memoFor(db);
|
|
926
|
+
stateBody(memo, db);
|
|
927
|
+
inventoryOf(memo, db);
|
|
928
|
+
}
|
|
929
|
+
/** What a settings write changed is on disk before any cursor or clock can say so: drop the pages that show it. */
|
|
930
|
+
function forgetSettings(db) {
|
|
931
|
+
const pages = memos.get(db)?.pages;
|
|
932
|
+
if (!pages)
|
|
933
|
+
return;
|
|
934
|
+
for (const key of pages.keys())
|
|
935
|
+
if (key.startsWith(SETTINGS_PATH))
|
|
936
|
+
pages.delete(key);
|
|
937
|
+
}
|
|
938
|
+
/** One file's identity and version, or `-` when there is none. Inode, size and nanosecond mtime: an atomic rewrite changes at least one. */
|
|
939
|
+
function fileStamp(file) {
|
|
940
|
+
try {
|
|
941
|
+
const s = fs.statSync(file, { bigint: true });
|
|
942
|
+
return `${s.ino}.${s.size}.${s.mtimeNs}`;
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return '-';
|
|
946
|
+
}
|
|
947
|
+
}
|
|
592
948
|
/** One entry with its tags, its raw evidence and the candidates it proposed. */
|
|
593
949
|
export function memoryEntry(db, id) {
|
|
594
950
|
const entry = one(db, 'SELECT * FROM memory_entries WHERE id = ?', id);
|
|
@@ -619,6 +975,187 @@ export function memoryEntry(db, id) {
|
|
|
619
975
|
WHERE i.entry_id = ? ORDER BY r.id DESC LIMIT 20`, id),
|
|
620
976
|
};
|
|
621
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* `projectKey`, once per distinct path in one build. It looks at the checkout's
|
|
980
|
+
* `.git` on disk (a worktree folds into its main checkout), and a context line
|
|
981
|
+
* carries the path of every session's repository: a statement per row is
|
|
982
|
+
* thousands of statements for a few hundred distinct answers.
|
|
983
|
+
*/
|
|
984
|
+
function projectFolder() {
|
|
985
|
+
const keys = new Map();
|
|
986
|
+
return (repo) => {
|
|
987
|
+
let key = keys.get(repo);
|
|
988
|
+
if (key === undefined) {
|
|
989
|
+
key = projectKey(repo);
|
|
990
|
+
keys.set(repo, key);
|
|
991
|
+
}
|
|
992
|
+
return key;
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Graded answers, newest first, as the page reads them: the payload's capped
|
|
997
|
+
* history (`attempts`) and one session's own rows (`sessionLearning`) come from
|
|
998
|
+
* this one statement, so they cannot drift apart. A correction row is a pick on
|
|
999
|
+
* the explainer, not a question asked (`NOT_CORRECTION`), and rides on the row
|
|
1000
|
+
* it corrected.
|
|
1001
|
+
*/
|
|
1002
|
+
function attemptRows(db, limit, session) {
|
|
1003
|
+
return many(db, `SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
|
|
1004
|
+
a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
|
|
1005
|
+
NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
|
|
1006
|
+
-- A miss corrected from its explainer: when, and on which try.
|
|
1007
|
+
fix.ts AS corrected_at,
|
|
1008
|
+
CASE WHEN fix.id IS NULL THEN NULL
|
|
1009
|
+
ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
|
|
1010
|
+
FROM attempts a JOIN concepts c ON c.id = a.concept_id
|
|
1011
|
+
LEFT JOIN attempts fix ON fix.retry_of = a.id
|
|
1012
|
+
WHERE a.${NOT_CORRECTION}${session === undefined ? '' : ' AND a.session_id = ?'}
|
|
1013
|
+
ORDER BY a.id DESC
|
|
1014
|
+
LIMIT ?`, ...(session === undefined ? [] : [session]), limit);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The context lines the agent logged, newest first, as the page reads them: the
|
|
1018
|
+
* payload's capped list and one session's lines (`sessionLearning`, a `limit`
|
|
1019
|
+
* of -1, which SQLite reads as none). Equal times keep the order the rows were
|
|
1020
|
+
* written in, which is the order they have always had.
|
|
1021
|
+
*
|
|
1022
|
+
* `gates.repo` stays the worktree path -- the POSIX gate matches it against
|
|
1023
|
+
* `git rev-parse --show-toplevel`. Every other repo on this page comes from
|
|
1024
|
+
* `attempts`, already folded, so fold this one too or a project filter drops
|
|
1025
|
+
* every context line a worktree session logged.
|
|
1026
|
+
*/
|
|
1027
|
+
function loggedRows(db, fold, limit, session) {
|
|
1028
|
+
const rows = many(db, `SELECT sc.session_id, c.slug, c.name, c.domain, sc.context, sc.ts, sc.origin,
|
|
1029
|
+
NULLIF(trim(COALESCE(g.repo, '')), '') AS repo
|
|
1030
|
+
FROM session_concepts sc
|
|
1031
|
+
JOIN concepts c ON c.id = sc.concept_id
|
|
1032
|
+
LEFT JOIN gates g ON g.session_id = sc.session_id
|
|
1033
|
+
${session === undefined ? '' : 'WHERE sc.session_id = ?'}
|
|
1034
|
+
ORDER BY sc.ts DESC, sc.rowid
|
|
1035
|
+
LIMIT ?`, ...(session === undefined ? [] : [session]), limit);
|
|
1036
|
+
for (const row of rows)
|
|
1037
|
+
row.repo = row.repo ? fold(row.repo) : null;
|
|
1038
|
+
return rows;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Every number the page takes from `attempts` that is not a list of rows: the
|
|
1042
|
+
* per-day split, the per-project totals, the all-time totals and each concept's
|
|
1043
|
+
* counts. They were five statements, each reading the whole table and the
|
|
1044
|
+
* first two converting every timestamp to the learner's day (2 microseconds a
|
|
1045
|
+
* row, 19 ms at 10,000 answers); they are two statements now, one grouped by
|
|
1046
|
+
* day and repository and one by repository and concept, and a few sums in JS.
|
|
1047
|
+
*
|
|
1048
|
+
* Each figure is the one the statement it replaced gave, value for value: a
|
|
1049
|
+
* correction row is no answer (`NOT_CORRECTION`) but is counted as one
|
|
1050
|
+
* `corrected` against the miss it fixes, and an answer that was corrected is
|
|
1051
|
+
* neither missed nor skipped. That last exclusion is taken from the answers
|
|
1052
|
+
* that were corrected (a handful) instead of a join over every answer.
|
|
1053
|
+
*/
|
|
1054
|
+
function answerRollups(db) {
|
|
1055
|
+
const dayGroups = many(db, `SELECT date(a.ts, 'localtime') AS day, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.ts >= date('now', ?) AS recent,
|
|
1056
|
+
count(*) AS answers,
|
|
1057
|
+
sum(CASE WHEN a.grade >= ${PASSING_GRADE} THEN 1 ELSE 0 END) AS passed,
|
|
1058
|
+
sum(CASE WHEN a.grade < ${PASSING_GRADE} AND (a.outcome IS NULL OR a.outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
|
|
1059
|
+
sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
|
|
1060
|
+
min(a.ts) AS first
|
|
1061
|
+
FROM attempts a
|
|
1062
|
+
WHERE a.${NOT_CORRECTION}
|
|
1063
|
+
GROUP BY 1, 2, 3
|
|
1064
|
+
ORDER BY 1, 2, 3`, `-${TIMELINE_DAYS} days`);
|
|
1065
|
+
// The same groups, over the answers that were corrected (a handful): what to take back out of
|
|
1066
|
+
// missed and skipped.
|
|
1067
|
+
const groupKey = (g) => `${g.day ?? ''}\u0000${g.repo ?? ''}\u0000${g.recent}`;
|
|
1068
|
+
const fixed = new Map();
|
|
1069
|
+
for (const f of many(db, `SELECT date(a.ts, 'localtime') AS day, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.ts >= date('now', ?) AS recent,
|
|
1070
|
+
count(*) AS fixed,
|
|
1071
|
+
sum(CASE WHEN a.grade < ${PASSING_GRADE} AND (a.outcome IS NULL OR a.outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
|
|
1072
|
+
sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
|
|
1073
|
+
FROM attempts a JOIN attempts fix ON fix.retry_of = a.id
|
|
1074
|
+
WHERE a.${NOT_CORRECTION}
|
|
1075
|
+
GROUP BY 1, 2, 3`, `-${TIMELINE_DAYS} days`)) {
|
|
1076
|
+
fixed.set(groupKey(f), f);
|
|
1077
|
+
}
|
|
1078
|
+
const none = { fixed: 0, missed: 0, skipped: 0 };
|
|
1079
|
+
const daily = [];
|
|
1080
|
+
const allTime = { answers: 0, passed: 0, missed: 0, skipped: 0, corrected: 0, active_days: 0, first_answer: null };
|
|
1081
|
+
const activeDays = new Set();
|
|
1082
|
+
for (const g of dayGroups) {
|
|
1083
|
+
// Every corrected answer is an answer, so its group is among these.
|
|
1084
|
+
const f = fixed.size === 0 ? none : fixed.get(groupKey(g)) ?? none;
|
|
1085
|
+
const missed = g.missed - f.missed;
|
|
1086
|
+
const skipped = g.skipped - f.skipped;
|
|
1087
|
+
allTime.answers += g.answers;
|
|
1088
|
+
allTime.passed += g.passed;
|
|
1089
|
+
allTime.missed += missed;
|
|
1090
|
+
allTime.skipped += skipped;
|
|
1091
|
+
allTime.corrected += f.fixed;
|
|
1092
|
+
if (g.day !== null)
|
|
1093
|
+
activeDays.add(g.day);
|
|
1094
|
+
if (allTime.first_answer === null || g.first < allTime.first_answer)
|
|
1095
|
+
allTime.first_answer = g.first;
|
|
1096
|
+
// The groups come in the order `GROUP BY day, repo` gave the rows they replace.
|
|
1097
|
+
if (g.recent)
|
|
1098
|
+
daily.push({ day: g.day, repo: g.repo, passed: g.passed, missed, skipped, corrected: f.fixed });
|
|
1099
|
+
}
|
|
1100
|
+
allTime.active_days = activeDays.size;
|
|
1101
|
+
// One group per repository and concept over the answers, which give the project rows and the
|
|
1102
|
+
// catalogue's counts, and the same over the corrections (an index finds them), which the
|
|
1103
|
+
// catalogue counts as `corrected` and which may be the newest row naming a repository.
|
|
1104
|
+
const asked = new Map();
|
|
1105
|
+
const byRepo = new Map();
|
|
1106
|
+
const concept = (id) => {
|
|
1107
|
+
let c = asked.get(id);
|
|
1108
|
+
if (c === undefined) {
|
|
1109
|
+
c = { attempts: 0, passed: 0, skipped: 0, corrected: 0, first_asked: null, last_id: null, last_repo_id: null };
|
|
1110
|
+
asked.set(id, c);
|
|
1111
|
+
}
|
|
1112
|
+
return c;
|
|
1113
|
+
};
|
|
1114
|
+
// Grouped by the repo column as written, which is how the project rows have always been grouped:
|
|
1115
|
+
// a project row is one per spelling (an empty repo, a blank one and an unset one are three rows,
|
|
1116
|
+
// each reading as no repository), and a catalogue row cannot tell them apart.
|
|
1117
|
+
for (const g of many(db, `SELECT a.repo AS raw, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.concept_id,
|
|
1118
|
+
count(*) AS attempts,
|
|
1119
|
+
sum(a.grade >= ${PASSING_GRADE}) AS passed,
|
|
1120
|
+
sum(a.outcome IN ('declined','dont_know')) AS skipped,
|
|
1121
|
+
min(a.ts) AS first, max(a.ts) AS last, max(a.id) AS last_id
|
|
1122
|
+
FROM attempts a
|
|
1123
|
+
WHERE a.${NOT_CORRECTION}
|
|
1124
|
+
GROUP BY a.repo, a.concept_id`)) {
|
|
1125
|
+
const c = concept(g.concept_id);
|
|
1126
|
+
c.attempts += g.attempts;
|
|
1127
|
+
c.passed += g.passed;
|
|
1128
|
+
c.skipped += g.skipped;
|
|
1129
|
+
if (c.first_asked === null || g.first < c.first_asked)
|
|
1130
|
+
c.first_asked = g.first;
|
|
1131
|
+
if (c.last_id === null || g.last_id > c.last_id)
|
|
1132
|
+
c.last_id = g.last_id;
|
|
1133
|
+
if (g.repo !== null && (c.last_repo_id === null || g.last_id > c.last_repo_id))
|
|
1134
|
+
c.last_repo_id = g.last_id;
|
|
1135
|
+
const p = byRepo.get(g.raw) ?? { repo: g.repo, answers: 0, passed: 0, skipped: 0, concepts: 0, first_active: g.first, last_active: g.last };
|
|
1136
|
+
byRepo.set(g.raw, p);
|
|
1137
|
+
p.answers += g.attempts;
|
|
1138
|
+
p.passed += g.passed;
|
|
1139
|
+
p.skipped += g.skipped;
|
|
1140
|
+
p.concepts += 1;
|
|
1141
|
+
if (g.first < p.first_active)
|
|
1142
|
+
p.first_active = g.first;
|
|
1143
|
+
if (g.last > p.last_active)
|
|
1144
|
+
p.last_active = g.last;
|
|
1145
|
+
}
|
|
1146
|
+
for (const g of many(db, `SELECT NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.concept_id, count(*) AS corrections, max(a.id) AS last_id
|
|
1147
|
+
FROM attempts a
|
|
1148
|
+
WHERE a.retry_of IS NOT NULL
|
|
1149
|
+
GROUP BY 1, 2`)) {
|
|
1150
|
+
const c = concept(g.concept_id);
|
|
1151
|
+
c.corrected += g.corrections;
|
|
1152
|
+
if (g.repo !== null && (c.last_repo_id === null || g.last_id > c.last_repo_id))
|
|
1153
|
+
c.last_repo_id = g.last_id;
|
|
1154
|
+
}
|
|
1155
|
+
// Repositories came out in order, so equal last-active times keep it.
|
|
1156
|
+
const projects = [...byRepo.values()].sort((a, b) => (a.last_active === b.last_active ? 0 : a.last_active < b.last_active ? 1 : -1));
|
|
1157
|
+
return { daily, projects, allTime, asked };
|
|
1158
|
+
}
|
|
622
1159
|
export function dashboardState(db) {
|
|
623
1160
|
const now = new Date();
|
|
624
1161
|
const configs = readConfigs(db);
|
|
@@ -626,58 +1163,44 @@ export function dashboardState(db) {
|
|
|
626
1163
|
// speak for every project, and the server's cwd is not any project's choice.
|
|
627
1164
|
const config = configs.user;
|
|
628
1165
|
// First, before any of the payload is read: a write that lands while it is
|
|
629
|
-
// being built
|
|
1166
|
+
// being built moves the next cursor, so the page reads again and never misses it.
|
|
630
1167
|
const cursor = changeCursor(db, configs);
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
|
|
636
|
-
sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
|
|
637
|
-
sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
|
|
638
|
-
sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
|
|
639
|
-
count(fix.id) AS corrected
|
|
640
|
-
FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
|
|
641
|
-
WHERE a.${NOT_CORRECTION} AND a.ts >= date('now', ?)
|
|
642
|
-
GROUP BY 1, 2
|
|
643
|
-
ORDER BY day`)
|
|
644
|
-
.all(PASSING_GRADE, PASSING_GRADE, `-${TIMELINE_DAYS} days`);
|
|
645
|
-
// A null repo is the pre-migration-003 bucket. It is kept, not dropped:
|
|
1168
|
+
// The numbers taken from `attempts` that are not lists of rows: one row per calendar day per
|
|
1169
|
+
// project (split three ways: `grade >= PASSING_GRADE` is the pass line the level ladder uses, so
|
|
1170
|
+
// the chart and the promotion agree), the per-project totals, the all-time totals and each
|
|
1171
|
+
// concept's counts. A null repo is the pre-migration-003 bucket. It is kept, not dropped:
|
|
646
1172
|
// hiding those rows would silently shrink every total on the page.
|
|
647
|
-
const projects = db
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
max(a.ts) AS last_active
|
|
655
|
-
FROM attempts a
|
|
656
|
-
WHERE a.${NOT_CORRECTION}
|
|
657
|
-
GROUP BY repo
|
|
658
|
-
ORDER BY last_active DESC`)
|
|
659
|
-
.all(PASSING_GRADE);
|
|
1173
|
+
const { daily, projects, allTime, asked } = answerRollups(db);
|
|
1174
|
+
// The catalogue with each concept's last context. The newest answer of a concept and its counts
|
|
1175
|
+
// come from the rollup above; the last context is one index lookup per concept
|
|
1176
|
+
// (migration 031), where this used to be eight correlated subqueries per concept over
|
|
1177
|
+
// `attempts` and `session_concepts`, the last of them reading every logged line.
|
|
1178
|
+
const lastGrade = db.prepare('SELECT grade FROM attempts WHERE id = ?').pluck();
|
|
1179
|
+
const lastRepo = db.prepare('SELECT repo FROM attempts WHERE id = ?').pluck();
|
|
660
1180
|
const conceptRows = db
|
|
661
1181
|
.prepare(`SELECT c.id, c.slug, c.name, c.domain, c.description, c.tier, c.source,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1182
|
+
m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
|
|
1183
|
+
-- The latest context a session logged for the concept; equal times go to the row written first.
|
|
1184
|
+
(SELECT sc.context FROM session_concepts sc
|
|
1185
|
+
WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
|
|
1186
|
+
ORDER BY sc.ts DESC, sc.rowid LIMIT 1) AS last_context
|
|
1187
|
+
FROM concepts c
|
|
1188
|
+
LEFT JOIN mastery m ON m.concept_id = c.id
|
|
1189
|
+
ORDER BY c.domain, c.tier, c.name, c.id`)
|
|
1190
|
+
.all().map((c) => {
|
|
1191
|
+
const a = asked.get(c.id);
|
|
1192
|
+
return {
|
|
1193
|
+
...c,
|
|
1194
|
+
attempts: a?.attempts ?? 0,
|
|
1195
|
+
passed: a?.passed ?? 0,
|
|
1196
|
+
skipped: a?.skipped ?? 0,
|
|
1197
|
+
corrected: a?.corrected ?? 0,
|
|
1198
|
+
first_asked: a?.first_asked ?? null,
|
|
1199
|
+
// The backlog rule's grade (OWED_SQL in store.ts): a correction does not clear it.
|
|
1200
|
+
last_grade: a?.last_id != null ? lastGrade.get(a.last_id) : null,
|
|
1201
|
+
last_repo: a?.last_repo_id != null ? lastRepo.get(a.last_repo_id) : null,
|
|
1202
|
+
};
|
|
1203
|
+
});
|
|
681
1204
|
const byId = new Map(conceptRows.map((r) => [r.id, r.slug]));
|
|
682
1205
|
// Edges travel as slugs so the page can link one concept card to another
|
|
683
1206
|
// without carrying the id space around with it.
|
|
@@ -771,51 +1294,19 @@ export function dashboardState(db) {
|
|
|
771
1294
|
// Every graded response, newest first, with its question and the tutor's
|
|
772
1295
|
// explanation. This is what makes a concept page a record of what you were
|
|
773
1296
|
// actually asked rather than a score.
|
|
774
|
-
const attempts = db
|
|
775
|
-
.prepare(`SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
|
|
776
|
-
a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
|
|
777
|
-
NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
|
|
778
|
-
-- A miss corrected from its explainer: when, and on which try.
|
|
779
|
-
fix.ts AS corrected_at,
|
|
780
|
-
CASE WHEN fix.id IS NULL THEN NULL
|
|
781
|
-
ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
|
|
782
|
-
FROM attempts a JOIN concepts c ON c.id = a.concept_id
|
|
783
|
-
LEFT JOIN attempts fix ON fix.retry_of = a.id
|
|
784
|
-
WHERE a.${NOT_CORRECTION}
|
|
785
|
-
ORDER BY a.id DESC
|
|
786
|
-
LIMIT ?`)
|
|
787
|
-
.all(ATTEMPT_LIMIT);
|
|
1297
|
+
const attempts = attemptRows(db, ATTEMPT_LIMIT);
|
|
788
1298
|
// The context lines, which are what a concept means to *this* learner: the
|
|
789
|
-
// code that taught it. Grouped by session on the page.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
LEFT JOIN gates g ON g.session_id = sc.session_id
|
|
796
|
-
ORDER BY sc.ts DESC`)
|
|
797
|
-
.all()
|
|
798
|
-
// `gates.repo` stays the worktree path -- the POSIX gate matches it against
|
|
799
|
-
// `git rev-parse --show-toplevel`. Every other repo on this page comes from
|
|
800
|
-
// `attempts`, already folded, so fold this one too or a project filter drops
|
|
801
|
-
// every context line a worktree session logged.
|
|
802
|
-
.map((r) => {
|
|
803
|
-
const row = r;
|
|
804
|
-
return { ...row, repo: row.repo ? projectKey(row.repo) : null };
|
|
805
|
-
});
|
|
806
|
-
const allTime = db
|
|
807
|
-
.prepare(`SELECT count(*) AS answers,
|
|
808
|
-
sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
|
|
809
|
-
sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
|
|
810
|
-
sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
|
|
811
|
-
count(fix.id) AS corrected,
|
|
812
|
-
count(DISTINCT date(a.ts, 'localtime')) AS active_days,
|
|
813
|
-
min(a.ts) AS first_answer
|
|
814
|
-
FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
|
|
815
|
-
WHERE a.${NOT_CORRECTION}`)
|
|
816
|
-
.get(PASSING_GRADE, PASSING_GRADE);
|
|
1299
|
+
// code that taught it. Grouped by session on the page. Newest first and cut at
|
|
1300
|
+
// `LOGGED_LIMIT`, with the total beside it.
|
|
1301
|
+
const logged = loggedRows(db, projectFolder(), LOGGED_LIMIT);
|
|
1302
|
+
const loggedTotal = logged.length < LOGGED_LIMIT
|
|
1303
|
+
? logged.length
|
|
1304
|
+
: one(db, 'SELECT count(*) AS n FROM session_concepts sc JOIN concepts c ON c.id = sc.concept_id').n;
|
|
817
1305
|
const sessionCount = db.prepare('SELECT count(DISTINCT session_id) AS n FROM session_concepts').get().n;
|
|
818
|
-
|
|
1306
|
+
// The one read of evidence_events: the counts, the heartbeat and the session list all come from it.
|
|
1307
|
+
const evidence = evidenceGroups(db);
|
|
1308
|
+
const evidenceSummary = evidenceTotals(evidence);
|
|
1309
|
+
const memory = memorySummary(db, evidenceSummary);
|
|
819
1310
|
const reuse = reuseSummary(db);
|
|
820
1311
|
return {
|
|
821
1312
|
generated_at: now.toISOString(),
|
|
@@ -824,6 +1315,10 @@ export function dashboardState(db) {
|
|
|
824
1315
|
timeline_days: TIMELINE_DAYS,
|
|
825
1316
|
attempts_shown: attempts.length,
|
|
826
1317
|
attempts_total: allTime.answers,
|
|
1318
|
+
// The same disclosure for the context lines: shown is what travelled, total
|
|
1319
|
+
// is every row, so a cut list cannot pass for the whole one.
|
|
1320
|
+
logged_shown: logged.length,
|
|
1321
|
+
logged_total: loggedTotal,
|
|
827
1322
|
// The user file's dials, before any project's overrides. Each project row
|
|
828
1323
|
// below carries the level and runway its own settings resolve to.
|
|
829
1324
|
config_scope: 'user',
|
|
@@ -841,9 +1336,9 @@ export function dashboardState(db) {
|
|
|
841
1336
|
},
|
|
842
1337
|
totals: {
|
|
843
1338
|
answers: allTime.answers,
|
|
844
|
-
passed: allTime.passed
|
|
845
|
-
missed: allTime.missed
|
|
846
|
-
skipped: allTime.skipped
|
|
1339
|
+
passed: allTime.passed,
|
|
1340
|
+
missed: allTime.missed,
|
|
1341
|
+
skipped: allTime.skipped,
|
|
847
1342
|
corrected: allTime.corrected,
|
|
848
1343
|
mastered: concepts.filter((c) => c.mastered).length,
|
|
849
1344
|
due: concepts.filter((c) => c.due).length,
|
|
@@ -880,8 +1375,8 @@ export function dashboardState(db) {
|
|
|
880
1375
|
// because `/api/state` is a contract an older page still reads.
|
|
881
1376
|
memory,
|
|
882
1377
|
reuse,
|
|
883
|
-
health: healthSummary(db, config),
|
|
884
|
-
memory_sessions: memorySessions(db),
|
|
1378
|
+
health: healthSummary(db, config, evidenceSummary),
|
|
1379
|
+
memory_sessions: memorySessions(db, evidence),
|
|
885
1380
|
// The third workflow. Read from the files themselves on every load
|
|
886
1381
|
// (`artifacts.ts`): there is no table to fall out of step with the disk.
|
|
887
1382
|
artifacts: artifactRows(db),
|
|
@@ -1045,14 +1540,10 @@ export const CLI_ONLY = [
|
|
|
1045
1540
|
{ key: 'sync.target', why: 'writes memory to a shared folder' },
|
|
1046
1541
|
{ key: 'sync.device_id', why: 'pins this device\'s sync identity' },
|
|
1047
1542
|
];
|
|
1048
|
-
/**
|
|
1049
|
-
|
|
1050
|
-
* checkout still exists. The POST checks against this list, so the page can
|
|
1051
|
-
* never name an arbitrary path to write settings for.
|
|
1052
|
-
*/
|
|
1053
|
-
export function configurableProjects(db) {
|
|
1543
|
+
/** The Settings pages' projects, from an inventory: a checkout that still has its `.git`. */
|
|
1544
|
+
function configurableFrom(inventory) {
|
|
1054
1545
|
const out = new Map();
|
|
1055
|
-
for (const p of
|
|
1546
|
+
for (const p of inventory.projects) {
|
|
1056
1547
|
// A checkout, not just a directory: `loadConfig` finds the project file
|
|
1057
1548
|
// through the repository, so a folder without `.git` would show the user
|
|
1058
1549
|
// settings as if they were this project's.
|
|
@@ -1064,6 +1555,59 @@ export function configurableProjects(db) {
|
|
|
1064
1555
|
}
|
|
1065
1556
|
return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1066
1557
|
}
|
|
1558
|
+
/** The memoized list for a memo already in hand; derived with the inventory, so it expires with it. */
|
|
1559
|
+
const configurableOf = (memo, db, rebuild = false) => {
|
|
1560
|
+
const slot = inventoryOf(memo, db, rebuild);
|
|
1561
|
+
return (slot.configurable ??= configurableFrom(slot.value));
|
|
1562
|
+
};
|
|
1563
|
+
/**
|
|
1564
|
+
* Projects the Settings pages may write for: an inventory project whose
|
|
1565
|
+
* checkout still exists. The POST checks against this list, so the page can
|
|
1566
|
+
* never name an arbitrary path to write settings for.
|
|
1567
|
+
*
|
|
1568
|
+
* Read from the per-cursor memo: the inventory behind it is six full-table
|
|
1569
|
+
* scans. A caller that is about to write passes `fresh` and gets a rebuild, so
|
|
1570
|
+
* a write is never validated against a minute-old picture of the filesystem;
|
|
1571
|
+
* that rebuild becomes the memo's.
|
|
1572
|
+
*/
|
|
1573
|
+
export function configurableProjects(db, opts = {}) {
|
|
1574
|
+
return configurableOf(memoFor(db), db, opts.fresh);
|
|
1575
|
+
}
|
|
1576
|
+
/** The project a `?project=` names, by its checkout or its inventory id. */
|
|
1577
|
+
const pickProject = (projects, root) => root ? projects.find((p) => p.id === root || p.inventory === root) ?? null : null;
|
|
1578
|
+
/**
|
|
1579
|
+
* What `/api/settings` reads that the cursor does not cover, as a string that
|
|
1580
|
+
* changes when any of it does.
|
|
1581
|
+
*
|
|
1582
|
+
* Files, by their stat and never by their content: the user file, and the named
|
|
1583
|
+
* project's. A project the inventory knows from memory alone has no answers, so
|
|
1584
|
+
* its settings file is in no cursor; a save from the terminal must still show on
|
|
1585
|
+
* the next read. Beside them, the legacy `<checkout>/.eklavya.json`, because
|
|
1586
|
+
* `loadConfig` still falls back to it for a project that has no settings file
|
|
1587
|
+
* outside the checkout (until a session moves it), and the page shows the
|
|
1588
|
+
* resolved values. It is a file a `git clone` may have brought, so it is
|
|
1589
|
+
* statted from the place `loadConfig` finds it and its bytes go nowhere near a
|
|
1590
|
+
* stamp.
|
|
1591
|
+
*
|
|
1592
|
+
* And when the project list inside the page was built. The list is the
|
|
1593
|
+
* inventory's, which expires on its own clock: a page built late in the
|
|
1594
|
+
* inventory's minute would otherwise carry that list a minute past the
|
|
1595
|
+
* inventory's expiry, and offer (and refuse a write to) a project the next
|
|
1596
|
+
* build would drop. With the build time in the stamp the page lives exactly as
|
|
1597
|
+
* long as the older of its two sources.
|
|
1598
|
+
*/
|
|
1599
|
+
function settingsStamp(memo, db, root) {
|
|
1600
|
+
const projects = configurableOf(memo, db);
|
|
1601
|
+
// Served or rebuilt by the line above, so the slot is there.
|
|
1602
|
+
const stamps = [String(memo.inventory.at), fileStamp(globalConfigPath())];
|
|
1603
|
+
const project = pickProject(projects, root);
|
|
1604
|
+
if (project) {
|
|
1605
|
+
stamps.push(fileStamp(projectConfigPath(project.id)));
|
|
1606
|
+
const checkout = findRepoConfig(project.id).repoRoot;
|
|
1607
|
+
stamps.push(checkout ? fileStamp(path.join(checkout, LEGACY_REPO_CONFIG_FILE)) : '-');
|
|
1608
|
+
}
|
|
1609
|
+
return stamps.join('|');
|
|
1610
|
+
}
|
|
1067
1611
|
const settingKeys = () => [...SETTINGS.map((f) => f.key), ...CLI_ONLY.map((c) => c.key)];
|
|
1068
1612
|
/** What one file sets, key by key, in today's spelling: a key absent here inherits. */
|
|
1069
1613
|
const setIn = (file) => {
|
|
@@ -1077,7 +1621,7 @@ const effectiveOf = (c) => Object.fromEntries(settingKeys().map((k) => [k, value
|
|
|
1077
1621
|
/** `GET /api/settings?project=<checkout>`: both files and what they resolve to. */
|
|
1078
1622
|
export function settingsState(db, root) {
|
|
1079
1623
|
const projects = configurableProjects(db);
|
|
1080
|
-
const project =
|
|
1624
|
+
const project = pickProject(projects, root);
|
|
1081
1625
|
return {
|
|
1082
1626
|
fields: SETTINGS,
|
|
1083
1627
|
cli_only: CLI_ONLY,
|
|
@@ -1114,7 +1658,8 @@ export function updateSetting(db, body) {
|
|
|
1114
1658
|
}
|
|
1115
1659
|
let root = null;
|
|
1116
1660
|
if (b.scope === 'project') {
|
|
1117
|
-
|
|
1661
|
+
// A write is checked against the filesystem as it is, not as a minute ago.
|
|
1662
|
+
const p = configurableProjects(db, { fresh: true }).find((x) => x.id === b.project);
|
|
1118
1663
|
if (!p)
|
|
1119
1664
|
return bad('That project is not one this dashboard can configure.');
|
|
1120
1665
|
if (isGlobalOnlyKey(f.key))
|
|
@@ -1128,6 +1673,7 @@ export function updateSetting(db, body) {
|
|
|
1128
1673
|
// so this refuses exactly what `eklavya config set` refuses, in its words.
|
|
1129
1674
|
try {
|
|
1130
1675
|
const { target } = applySetting(f.key, unset ? undefined : b.value, root);
|
|
1676
|
+
forgetSettings(db);
|
|
1131
1677
|
return { status: 200, body: { ok: true, key: f.key, target, unset } };
|
|
1132
1678
|
}
|
|
1133
1679
|
catch (err) {
|
|
@@ -1328,16 +1874,23 @@ export function withoutWebFonts(html) {
|
|
|
1328
1874
|
* Nothing is reported before the frame has a width: one word per line measures
|
|
1329
1875
|
* tens of thousands of pixels, and if that report lands last the frame sits at
|
|
1330
1876
|
* the cap, never resizes, and the outrun count never reaches three.
|
|
1877
|
+
*
|
|
1878
|
+
* The page's HTML button serializes the live document, so nothing here may be
|
|
1879
|
+
* serialized with it: the script takes its own element out of the document,
|
|
1880
|
+
* its rules live in a constructed stylesheet (`adoptedStyleSheets`, which
|
|
1881
|
+
* `outerHTML` never writes; a browser without one gets a `<style>` element),
|
|
1882
|
+
* and the root gets no class. A file saved from the viewer then scrolls on
|
|
1883
|
+
* its own and carries no frame code. Only the empty measuring block remains.
|
|
1331
1884
|
*/
|
|
1332
1885
|
// ponytail: in-flow content only, watched as it was when the script ran: an absolutely
|
|
1333
1886
|
// positioned box below the end, or a block a page script appends to <body> later, is not counted.
|
|
1334
1887
|
export const EMBED_SCRIPT = `<script>
|
|
1335
1888
|
(function () {
|
|
1336
|
-
if (parent === window || window.__eklavyaEmbed) return; window.__eklavyaEmbed = 1;
|
|
1889
|
+
if (parent === window || window.__eklavyaEmbed) return; window.__eklavyaEmbed = 1; var me = document.currentScript; if (me && me.parentNode) me.parentNode.removeChild(me);
|
|
1337
1890
|
var root = document.documentElement, body = document.body, kids = [].slice.call(body.children), last = 0, strikes = 0, width = innerWidth;
|
|
1338
|
-
var
|
|
1339
|
-
css
|
|
1340
|
-
|
|
1891
|
+
var rules = 'html,body{overflow-y:hidden !important}main{min-height:0 !important}', own = window.CSSStyleSheet && CSSStyleSheet.prototype.replaceSync;
|
|
1892
|
+
var css = own ? new CSSStyleSheet() : document.createElement('style'), end = body.appendChild(document.createElement('div')), seen = window.ResizeObserver && new ResizeObserver(post);
|
|
1893
|
+
if (own) { css.replaceSync(rules); document.adoptedStyleSheets = [].slice.call(document.adoptedStyleSheets).concat(css); } else { css.textContent = rules; (document.head || root).appendChild(css); } end.style.clear = 'both';
|
|
1341
1894
|
function measure() {
|
|
1342
1895
|
var s = getComputedStyle(body);
|
|
1343
1896
|
return Math.ceil(end.getBoundingClientRect().top + scrollY + parseFloat(s.paddingBottom) + parseFloat(s.marginBottom));
|
|
@@ -1399,15 +1952,42 @@ const MASCOT_ASSETS = new Map([
|
|
|
1399
1952
|
]);
|
|
1400
1953
|
/** The tips library, copied from node_modules at build by `copy-assets.mjs`. */
|
|
1401
1954
|
const VENDOR = { '/vendor/driver-hints.js': 'text/javascript', '/vendor/driver-hints.css': 'text/css' };
|
|
1955
|
+
/** What a file that only changes with a release may be kept for: this browser, an hour. */
|
|
1956
|
+
const CACHE_HOUR = 'private, max-age=3600';
|
|
1957
|
+
/**
|
|
1958
|
+
* Whether an `If-None-Match` header names `etag`. A comparison for a GET is
|
|
1959
|
+
* weak (RFC 9110 §13.1.2), so `W/"x"` names `"x"`; the header may be a list,
|
|
1960
|
+
* with any spacing, or `*` for any version at all. Anything that is not a
|
|
1961
|
+
* quoted tag is ignored, never matched.
|
|
1962
|
+
*/
|
|
1963
|
+
export function etagMatches(header, etag) {
|
|
1964
|
+
if (header === undefined)
|
|
1965
|
+
return false;
|
|
1966
|
+
if (header.trim() === '*')
|
|
1967
|
+
return true;
|
|
1968
|
+
return (header.match(/(?:W\/)?"[^"]*"/g) ?? []).some((tag) => tag.replace(/^W\//, '') === etag);
|
|
1969
|
+
}
|
|
1970
|
+
/** What every response carries: its type, `no-store`, `SECURITY_HEADERS`, then whatever the route overrides. The event stream, which is not sent in one piece, shares it. */
|
|
1971
|
+
const headersFor = (type, extra = {}) => ({
|
|
1972
|
+
'content-type': type,
|
|
1973
|
+
'cache-control': 'no-store',
|
|
1974
|
+
...SECURITY_HEADERS,
|
|
1975
|
+
...extra,
|
|
1976
|
+
});
|
|
1977
|
+
/**
|
|
1978
|
+
* Every response goes out here, so every one carries `SECURITY_HEADERS`.
|
|
1979
|
+
*
|
|
1980
|
+
* JSON is never cached (`no-store`, the default): a dashboard read from a stale
|
|
1981
|
+
* cache is one that lies about progress made ten seconds ago, which is the one
|
|
1982
|
+
* thing it is for, and the server's own memo is what makes a repeat cheap, not
|
|
1983
|
+
* the browser's. A file may be, because nothing about it moves with the
|
|
1984
|
+
* learner: the tokens, the mascot and the tips library stay an hour
|
|
1985
|
+
* (`CACHE_HOUR`); the page itself is `no-cache` with an `ETag`, so the browser
|
|
1986
|
+
* keeps it but asks, in one round trip, whether it is still the one this
|
|
1987
|
+
* server start wrote (the write token is in it). A caller passes `extra` to say so.
|
|
1988
|
+
*/
|
|
1402
1989
|
function send(res, status, type, body, extra = {}) {
|
|
1403
|
-
res.writeHead(status,
|
|
1404
|
-
'content-type': type,
|
|
1405
|
-
// A dashboard read from a stale cache is a dashboard that lies about
|
|
1406
|
-
// progress made ten seconds ago, which is the one thing it is for.
|
|
1407
|
-
'cache-control': 'no-store',
|
|
1408
|
-
...SECURITY_HEADERS,
|
|
1409
|
-
...extra,
|
|
1410
|
-
});
|
|
1990
|
+
res.writeHead(status, headersFor(type, extra));
|
|
1411
1991
|
res.end(body);
|
|
1412
1992
|
}
|
|
1413
1993
|
/**
|
|
@@ -1437,10 +2017,312 @@ export function fromLoopback(hostHeader, originHeader) {
|
|
|
1437
2017
|
}
|
|
1438
2018
|
return true;
|
|
1439
2019
|
}
|
|
2020
|
+
/*
|
|
2021
|
+
* Live updates: `GET /api/events`.
|
|
2022
|
+
*
|
|
2023
|
+
* What an open page needs is to be told that something changed, so it can fetch
|
|
2024
|
+
* what. The stream carries one thing, the change cursor (`changeCursor`, the
|
|
2025
|
+
* string `/api/state` carries in its `cursor`): `event: cursor` and `data:
|
|
2026
|
+
* <cursor>` at once on connect, then one more each time it moves, and a
|
|
2027
|
+
* `: keep-alive` comment every `keepAliveMs` so nothing between here and the
|
|
2028
|
+
* browser closes a quiet stream. The payload stays one source of truth: the
|
|
2029
|
+
* stream says *that*, `/api/state` says *what*. `/api/cursor` is unchanged, for
|
|
2030
|
+
* older pages and for anything that polls.
|
|
2031
|
+
*
|
|
2032
|
+
* The writers are other processes (hooks, the MCP server, the CLI, the
|
|
2033
|
+
* observer), so the server can only learn of a change by reading the database
|
|
2034
|
+
* and the filesystem, which a watcher does for as long as a stream is open and
|
|
2035
|
+
* not a moment longer:
|
|
2036
|
+
*
|
|
2037
|
+
* - The floor: `changeCursor` every `floorMs`. It is 0.5 to 3 ms, and it is
|
|
2038
|
+
* what keeps the stream correct on a filesystem where `fs.watch` is silent
|
|
2039
|
+
* (a network mount, an editor that replaces a file instead of writing it).
|
|
2040
|
+
* - The early trigger: `fs.watch` on the database's `-wal` file, the user's
|
|
2041
|
+
* config file, the project config area and the artifacts folder, debounced by
|
|
2042
|
+
* `debounceMs` into one check. Every one of those is optional. A directory
|
|
2043
|
+
* that is not there yet, an `error` event: that watch is skipped or dropped
|
|
2044
|
+
* and the floor carries on. The `-wal` is watched through its directory,
|
|
2045
|
+
* filtered by name, because the file may not exist yet and is replaced when
|
|
2046
|
+
* the database is checkpointed; `-shm` and the database file are never
|
|
2047
|
+
* watched, because a reader touches them and the watcher's own reads must not
|
|
2048
|
+
* retrigger it.
|
|
2049
|
+
* The project config area and the artifacts folder hold their files one level
|
|
2050
|
+
* down (`<folder>/<file>`), and each folder is watched by itself, not through
|
|
2051
|
+
* one recursive watch: on Linux Node's recursive watch holds one watch per
|
|
2052
|
+
* file inode, so the first atomic rewrite of a file (a temporary file renamed
|
|
2053
|
+
* over it, which is how every config write is made, and how most editors save)
|
|
2054
|
+
* leaves the new file unwatched, and every later change to it went unseen. A
|
|
2055
|
+
* watch on the folder names each entry that is replaced in it, however often.
|
|
2056
|
+
* The area's own watch tells of a new or removed folder, and a watch is made
|
|
2057
|
+
* or dropped for it then.
|
|
2058
|
+
* - On a change the memo is refreshed first (the state and the inventory, so
|
|
2059
|
+
* the fetch the event provokes is a repeat), then every stream is written.
|
|
2060
|
+
* Between changes the watcher costs a cursor read a second and no read route
|
|
2061
|
+
* waits on it; on a change it pays, once, for the rebuild the next read would
|
|
2062
|
+
* have made anyway, and that rebuild is the single thread's for as long as it
|
|
2063
|
+
* takes (the builders' cost, unchanged).
|
|
2064
|
+
* - A write the dashboard itself accepted checks at once (`bump`) instead of
|
|
2065
|
+
* waiting for the floor, and does nothing when no stream is open.
|
|
2066
|
+
*
|
|
2067
|
+
* A stream that will not take a write (its buffer is full: the reader has not
|
|
2068
|
+
* read for a long time) is cut, not buffered for; the browser reconnects and
|
|
2069
|
+
* the connect event is the current cursor, so it loses nothing.
|
|
2070
|
+
*/
|
|
2071
|
+
/** How often the watcher reads `changeCursor` while a stream is open. The floor: correct even where `fs.watch` is not. */
|
|
2072
|
+
export const EVENTS_FLOOR_MS = 1000;
|
|
2073
|
+
/** How long after the last filesystem event the early check waits, so a burst of writes (a hook logging eight concepts) is one check. */
|
|
2074
|
+
export const EVENTS_DEBOUNCE_MS = 150;
|
|
2075
|
+
/** An open stream is written to this often when nothing changed, so a proxy or a browser does not close it for silence. */
|
|
2076
|
+
export const EVENTS_KEEPALIVE_MS = 25_000;
|
|
2077
|
+
/**
|
|
2078
|
+
* Open streams at once; the next gets a 503. A browser allows six HTTP/1.1
|
|
2079
|
+
* connections to one origin and every stream holds one for as long as it is
|
|
2080
|
+
* open, so one tab uses one of its six and leaves the page five for its own
|
|
2081
|
+
* fetches; the page closes its stream while its tab is hidden, which keeps the
|
|
2082
|
+
* count at the tabs being read. Thirty-two is several browsers with several
|
|
2083
|
+
* tabs each plus the odd `curl`, with room to spare, and still bounds what a
|
|
2084
|
+
* client that opens streams in a loop can cost: a socket and a few bytes of
|
|
2085
|
+
* writes each per change.
|
|
2086
|
+
*/
|
|
2087
|
+
export const EVENTS_MAX_STREAMS = 32;
|
|
2088
|
+
export const LIVE_DEFAULTS = {
|
|
2089
|
+
floorMs: EVENTS_FLOOR_MS,
|
|
2090
|
+
debounceMs: EVENTS_DEBOUNCE_MS,
|
|
2091
|
+
keepAliveMs: EVENTS_KEEPALIVE_MS,
|
|
2092
|
+
maxStreams: EVENTS_MAX_STREAMS,
|
|
2093
|
+
};
|
|
2094
|
+
const EVENTS_PATH = '/api/events';
|
|
2095
|
+
/** One watcher and its streams, for one server: nothing runs until the first stream opens and nothing outlives the last. */
|
|
2096
|
+
function createLive(db, opts) {
|
|
2097
|
+
const { floorMs, debounceMs, keepAliveMs, maxStreams } = { ...LIVE_DEFAULTS, ...opts };
|
|
2098
|
+
const streams = new Set();
|
|
2099
|
+
/** The cursor the open streams have been sent last. */
|
|
2100
|
+
let last = '';
|
|
2101
|
+
let floor;
|
|
2102
|
+
let keepAlive;
|
|
2103
|
+
let debounce;
|
|
2104
|
+
/** Set by `shutdown` and never cleared: the server it belongs to is closing and does not open again. */
|
|
2105
|
+
let closed = false;
|
|
2106
|
+
/** Every watch that is open, so `stop` can close each one and one that failed is not closed twice. */
|
|
2107
|
+
const watchers = new Set();
|
|
2108
|
+
const push = (res, chunk) => {
|
|
2109
|
+
if (!res.write(chunk))
|
|
2110
|
+
res.destroy();
|
|
2111
|
+
};
|
|
2112
|
+
const emit = (cursor) => {
|
|
2113
|
+
for (const res of streams)
|
|
2114
|
+
push(res, `event: cursor\ndata: ${cursor}\n\n`);
|
|
2115
|
+
};
|
|
2116
|
+
/**
|
|
2117
|
+
* Reads the cursor; when it moved and someone is listening, refreshes the memo
|
|
2118
|
+
* and tells them. Returns the cursor as read. Throws as `changeCursor` does.
|
|
2119
|
+
*/
|
|
2120
|
+
const check = () => {
|
|
2121
|
+
const cursor = changeCursor(db);
|
|
2122
|
+
if (cursor === last)
|
|
2123
|
+
return cursor;
|
|
2124
|
+
last = cursor;
|
|
2125
|
+
if (streams.size > 0) {
|
|
2126
|
+
try {
|
|
2127
|
+
warmMemo(db);
|
|
2128
|
+
}
|
|
2129
|
+
catch {
|
|
2130
|
+
/* the fetch the event provokes builds it instead, and reports the error if it still fails */
|
|
2131
|
+
}
|
|
2132
|
+
emit(cursor);
|
|
2133
|
+
}
|
|
2134
|
+
return cursor;
|
|
2135
|
+
};
|
|
2136
|
+
/** A check from a timer, a file event or a write: nothing waits on it, so a failure (a busy or closed database) is only the next check's to retry. */
|
|
2137
|
+
const run = () => {
|
|
2138
|
+
try {
|
|
2139
|
+
check();
|
|
2140
|
+
}
|
|
2141
|
+
catch {
|
|
2142
|
+
/* the next check asks again */
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
/** A file event: check once `debounceMs` after the last of a burst. */
|
|
2146
|
+
const early = () => {
|
|
2147
|
+
clearTimeout(debounce);
|
|
2148
|
+
debounce = setTimeout(run, debounceMs).unref();
|
|
2149
|
+
};
|
|
2150
|
+
const targets = () => {
|
|
2151
|
+
const out = [];
|
|
2152
|
+
if (!db.memory) {
|
|
2153
|
+
const file = path.resolve(db.name);
|
|
2154
|
+
out.push({ dir: path.dirname(file), match: (name) => name === `${path.basename(file)}-wal` });
|
|
2155
|
+
}
|
|
2156
|
+
const config = globalConfigPath();
|
|
2157
|
+
out.push({ dir: path.dirname(config), match: (name) => name === path.basename(config) });
|
|
2158
|
+
return out;
|
|
2159
|
+
};
|
|
2160
|
+
const trees = () => [
|
|
2161
|
+
{ root: projectsDir(), match: (file) => file === 'config.json' },
|
|
2162
|
+
{ root: artifactsDir(), match: () => true },
|
|
2163
|
+
];
|
|
2164
|
+
/** One watch on one directory, not its subdirectories, or null when it cannot be made: optional, and the floor covers it. */
|
|
2165
|
+
const watchDir = (dir, onName) => {
|
|
2166
|
+
try {
|
|
2167
|
+
const watcher = fs.watch(dir, { persistent: false, recursive: false }, (_event, name) => onName(name));
|
|
2168
|
+
// A watch that fails is closed and forgotten, once; the floor carries on.
|
|
2169
|
+
watcher.on('error', () => release(watcher));
|
|
2170
|
+
watchers.add(watcher);
|
|
2171
|
+
return watcher;
|
|
2172
|
+
}
|
|
2173
|
+
catch {
|
|
2174
|
+
return null;
|
|
2175
|
+
}
|
|
2176
|
+
};
|
|
2177
|
+
const release = (watcher) => {
|
|
2178
|
+
watchers.delete(watcher);
|
|
2179
|
+
watcher.close();
|
|
2180
|
+
};
|
|
2181
|
+
/**
|
|
2182
|
+
* Which folder this is, not only what it is called: the inode and the time it was made. A watch is on a
|
|
2183
|
+
* folder, so one removed and made again under the same name (before the area's event is read) is a
|
|
2184
|
+
* folder the old watch will never hear from, and the filesystem may hand the new one the old inode: the
|
|
2185
|
+
* time it was made is what tells them apart. `-` when it cannot be read.
|
|
2186
|
+
*/
|
|
2187
|
+
const identity = (dir) => {
|
|
2188
|
+
try {
|
|
2189
|
+
const s = fs.statSync(dir, { bigint: true });
|
|
2190
|
+
return `${s.ino}.${s.birthtimeNs}`;
|
|
2191
|
+
}
|
|
2192
|
+
catch {
|
|
2193
|
+
return '-';
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
/**
|
|
2197
|
+
* A watch on `root` for its folders, and one on each folder for its files. The
|
|
2198
|
+
* root's own events (a folder made, renamed or removed) adopt what is there
|
|
2199
|
+
* now: a watch for each new folder, none for one that has gone, a new one for
|
|
2200
|
+
* one that was made again. The check they also ask for is what sees a file
|
|
2201
|
+
* already written into a folder by the time its watch was made.
|
|
2202
|
+
*/
|
|
2203
|
+
const watchTree = ({ root, match }) => {
|
|
2204
|
+
const folders = new Map();
|
|
2205
|
+
const adopt = () => {
|
|
2206
|
+
let present;
|
|
2207
|
+
try {
|
|
2208
|
+
// The folders the cursor lists: no dot-names, no links (`artifactFiles`).
|
|
2209
|
+
present = fs.readdirSync(root, { withFileTypes: true })
|
|
2210
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
2211
|
+
.map((entry) => entry.name);
|
|
2212
|
+
}
|
|
2213
|
+
catch {
|
|
2214
|
+
present = [];
|
|
2215
|
+
}
|
|
2216
|
+
for (const [name, { watcher, id }] of folders) {
|
|
2217
|
+
// Gone, replaced by another folder of the name, or its watch failed (and was closed): forget it, and the
|
|
2218
|
+
// loop below makes a new one if the folder is there.
|
|
2219
|
+
const open = watchers.has(watcher);
|
|
2220
|
+
if (open && present.includes(name) && identity(path.join(root, name)) === id)
|
|
2221
|
+
continue;
|
|
2222
|
+
folders.delete(name);
|
|
2223
|
+
if (open)
|
|
2224
|
+
release(watcher);
|
|
2225
|
+
}
|
|
2226
|
+
for (const name of present) {
|
|
2227
|
+
if (folders.has(name))
|
|
2228
|
+
continue;
|
|
2229
|
+
const dir = path.join(root, name);
|
|
2230
|
+
// Read before the watch is made: a folder replaced in between is seen as changed by the next event.
|
|
2231
|
+
const id = identity(dir);
|
|
2232
|
+
const watcher = watchDir(dir, (file) => {
|
|
2233
|
+
if (file !== null && match(file))
|
|
2234
|
+
early();
|
|
2235
|
+
});
|
|
2236
|
+
if (watcher)
|
|
2237
|
+
folders.set(name, { watcher, id });
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
const rootWatch = watchDir(root, () => {
|
|
2241
|
+
adopt();
|
|
2242
|
+
early();
|
|
2243
|
+
});
|
|
2244
|
+
if (rootWatch)
|
|
2245
|
+
adopt();
|
|
2246
|
+
};
|
|
2247
|
+
const watchFiles = () => {
|
|
2248
|
+
for (const { dir, match } of targets()) {
|
|
2249
|
+
watchDir(dir, (name) => {
|
|
2250
|
+
if (name !== null && match(name))
|
|
2251
|
+
early();
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
for (const tree of trees())
|
|
2255
|
+
watchTree(tree);
|
|
2256
|
+
};
|
|
2257
|
+
const start = () => {
|
|
2258
|
+
floor = setInterval(run, floorMs).unref();
|
|
2259
|
+
keepAlive = setInterval(() => {
|
|
2260
|
+
for (const res of streams)
|
|
2261
|
+
push(res, ': keep-alive\n\n');
|
|
2262
|
+
}, keepAliveMs).unref();
|
|
2263
|
+
watchFiles();
|
|
2264
|
+
};
|
|
2265
|
+
const stop = () => {
|
|
2266
|
+
clearInterval(floor);
|
|
2267
|
+
clearInterval(keepAlive);
|
|
2268
|
+
clearTimeout(debounce);
|
|
2269
|
+
for (const watcher of watchers)
|
|
2270
|
+
watcher.close();
|
|
2271
|
+
watchers.clear();
|
|
2272
|
+
};
|
|
2273
|
+
return {
|
|
2274
|
+
connect(res) {
|
|
2275
|
+
// A request already on a connection when `close()` ran (the next one of a
|
|
2276
|
+
// pipelined pair, or a keep-alive request that was mid-flight) still reaches
|
|
2277
|
+
// the handler: it must not open what `shutdown` just ended. The connection is
|
|
2278
|
+
// told to close too, so this refusal is not what `server.close()` waits on.
|
|
2279
|
+
if (closed)
|
|
2280
|
+
return send(res, 503, 'text/plain', 'The dashboard is shutting down.\n', { connection: 'close' });
|
|
2281
|
+
if (streams.size >= maxStreams)
|
|
2282
|
+
return send(res, 503, 'text/plain', 'Too many open event streams.\n');
|
|
2283
|
+
// Before anything is written, so a database that cannot be read is the
|
|
2284
|
+
// route's ordinary 500. With streams already open, a change since the last
|
|
2285
|
+
// check is sent to them first, so no stream is ever sent the same cursor twice.
|
|
2286
|
+
const cursor = check();
|
|
2287
|
+
res.writeHead(200, headersFor('text/event-stream; charset=utf-8'));
|
|
2288
|
+
push(res, `event: cursor\ndata: ${cursor}\n\n`);
|
|
2289
|
+
streams.add(res);
|
|
2290
|
+
// A write to a dead socket can surface as an 'error' on the response; a
|
|
2291
|
+
// stream that errors is a stream that is gone.
|
|
2292
|
+
res.on('error', () => res.destroy());
|
|
2293
|
+
res.on('close', () => {
|
|
2294
|
+
if (streams.delete(res) && streams.size === 0)
|
|
2295
|
+
stop();
|
|
2296
|
+
});
|
|
2297
|
+
if (streams.size === 1)
|
|
2298
|
+
start();
|
|
2299
|
+
},
|
|
2300
|
+
bump() {
|
|
2301
|
+
if (streams.size > 0)
|
|
2302
|
+
run();
|
|
2303
|
+
},
|
|
2304
|
+
shutdown() {
|
|
2305
|
+
closed = true;
|
|
2306
|
+
const open = [...streams];
|
|
2307
|
+
streams.clear();
|
|
2308
|
+
stop();
|
|
2309
|
+
// A finished response is what lets `server.close` take its connection, kept alive or not: an
|
|
2310
|
+
// idle one is closed at once, and without this the stream would hold the close open for ever.
|
|
2311
|
+
for (const res of open)
|
|
2312
|
+
res.end();
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* `opts.live` overrides the event stream's intervals and cap (`LIVE_DEFAULTS`);
|
|
2318
|
+
* only tests have a reason to. `close()` ends the open streams and resolves
|
|
2319
|
+
* once the server has closed.
|
|
2320
|
+
*/
|
|
1440
2321
|
export function startDashboard(db, opts = {}) {
|
|
1441
2322
|
const host = opts.host ?? '127.0.0.1';
|
|
1442
2323
|
const wanted = opts.port ?? dashboardPort();
|
|
1443
2324
|
const assets = path.join(moduleDir, 'assets');
|
|
2325
|
+
const live = createLive(db, opts.live ?? {});
|
|
1444
2326
|
// Per server start. Every write must carry it (see `acceptWrite`).
|
|
1445
2327
|
const token = randomBytes(24).toString('hex');
|
|
1446
2328
|
const json = (res, status, body) => send(res, status, 'application/json', JSON.stringify(body));
|
|
@@ -1496,6 +2378,9 @@ export function startDashboard(db, opts = {}) {
|
|
|
1496
2378
|
}
|
|
1497
2379
|
const out = route.handler(db, body);
|
|
1498
2380
|
json(res, out.status, out.body);
|
|
2381
|
+
// Whatever the handler changed is told to the open streams now, not at the
|
|
2382
|
+
// next check; the answer above is already on its way, so the writer does not wait for it.
|
|
2383
|
+
live.bump();
|
|
1499
2384
|
});
|
|
1500
2385
|
};
|
|
1501
2386
|
const server = http.createServer((req, res) => {
|
|
@@ -1528,7 +2413,8 @@ export function startDashboard(db, opts = {}) {
|
|
|
1528
2413
|
return acceptWrite(req, res, write);
|
|
1529
2414
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1530
2415
|
return send(res, 405, 'text/plain', `This route is read-only. Writes: ${Object.keys(WRITES).join(', ')}.\n`, {
|
|
1531
|
-
|
|
2416
|
+
// A stream has no HEAD: there is nothing to answer without opening one.
|
|
2417
|
+
allow: write ? 'GET, HEAD, POST' : url.pathname === EVENTS_PATH ? 'GET' : 'GET, HEAD',
|
|
1532
2418
|
});
|
|
1533
2419
|
}
|
|
1534
2420
|
try {
|
|
@@ -1537,20 +2423,36 @@ export function startDashboard(db, opts = {}) {
|
|
|
1537
2423
|
if (url.pathname === '/api/health') {
|
|
1538
2424
|
return send(res, 200, 'application/json', JSON.stringify({ app: 'eklavya', version: ownVersion(), pid: process.pid, db: dbPath() }));
|
|
1539
2425
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
2426
|
+
// The cursor as a stream (`createLive`). A GET only: a HEAD has no stream to
|
|
2427
|
+
// open and falls through to the 404 it always had.
|
|
2428
|
+
if (url.pathname === EVENTS_PATH && req.method === 'GET')
|
|
2429
|
+
return live.connect(res);
|
|
2430
|
+
// The reads below are served from the per-cursor memo (`memoFor`): the cursor
|
|
2431
|
+
// is asked for first, and a build runs only when it moved or a minute passed.
|
|
2432
|
+
// Not memoized, on purpose: `/api/cursor`, which must always say what the
|
|
2433
|
+
// database says now; `/api/health`, which names the process; one feedback
|
|
2434
|
+
// item, which a POST acknowledges; and a correction, which is graded live.
|
|
2435
|
+
const pageKey = url.pathname + url.search;
|
|
2436
|
+
const reply = (body) => body === null ? send(res, 404, 'application/json', '{"error":"no such entry"}') : send(res, 200, 'application/json', body);
|
|
2437
|
+
if (url.pathname === SETTINGS_PATH) {
|
|
2438
|
+
const memo = memoFor(db);
|
|
2439
|
+
const project = url.searchParams.get('project');
|
|
2440
|
+
return reply(pageBody(memo, pageKey, () => JSON.stringify(settingsState(db, project)), {
|
|
2441
|
+
expires: true,
|
|
2442
|
+
stamp: () => settingsStamp(memo, db, project),
|
|
2443
|
+
}));
|
|
1542
2444
|
}
|
|
1543
2445
|
if (url.pathname === '/api/cursor') {
|
|
1544
2446
|
return send(res, 200, 'application/json', JSON.stringify({ cursor: changeCursor(db) }));
|
|
1545
2447
|
}
|
|
1546
2448
|
if (url.pathname === '/api/state') {
|
|
1547
|
-
return
|
|
2449
|
+
return reply(stateBody(memoFor(db), db));
|
|
1548
2450
|
}
|
|
1549
2451
|
// The memory timeline is a paged resource rather than part of the state
|
|
1550
2452
|
// payload: the corpus is the one thing here that grows without bound.
|
|
1551
2453
|
if (url.pathname === '/api/memory') {
|
|
1552
2454
|
const g = (k) => url.searchParams.get(k);
|
|
1553
|
-
return
|
|
2455
|
+
return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(memoryPage(db, {
|
|
1554
2456
|
project: g('project'),
|
|
1555
2457
|
session: g('session'),
|
|
1556
2458
|
type: g('type'),
|
|
@@ -1560,19 +2462,19 @@ export function startDashboard(db, opts = {}) {
|
|
|
1560
2462
|
until: g('until'),
|
|
1561
2463
|
page: Number(g('page')) || 1,
|
|
1562
2464
|
per: Number(g('per')) || MEMORY_PER,
|
|
1563
|
-
})));
|
|
2465
|
+
}))));
|
|
1564
2466
|
}
|
|
1565
2467
|
if (url.pathname === '/api/projects') {
|
|
1566
|
-
return
|
|
2468
|
+
return reply(Buffer.from(JSON.stringify(inventoryOf(memoFor(db), db).value)));
|
|
1567
2469
|
}
|
|
1568
2470
|
if (url.pathname === '/api/memory/sessions') {
|
|
1569
2471
|
const g = (k) => url.searchParams.get(k);
|
|
1570
|
-
return
|
|
2472
|
+
return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(memorySessionPage(db, {
|
|
1571
2473
|
project: g('project'),
|
|
1572
2474
|
session: g('session'),
|
|
1573
2475
|
page: Number(g('page')) || 1,
|
|
1574
2476
|
per: Number(g('per')) || MEMORY_PER,
|
|
1575
|
-
})));
|
|
2477
|
+
}))));
|
|
1576
2478
|
}
|
|
1577
2479
|
if (url.pathname === '/api/feedback') {
|
|
1578
2480
|
const out = feedbackItem(db, url.searchParams.get('id'));
|
|
@@ -1580,17 +2482,17 @@ export function startDashboard(db, opts = {}) {
|
|
|
1580
2482
|
}
|
|
1581
2483
|
if (url.pathname === '/api/feedback/list') {
|
|
1582
2484
|
const g = (k) => url.searchParams.get(k);
|
|
1583
|
-
return
|
|
2485
|
+
return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(listAcknowledged(db, { project: g('project'), page: Number(g('page')) || 1, per: Number(g('per')) || 20 }))));
|
|
1584
2486
|
}
|
|
1585
2487
|
if (url.pathname === '/api/attempts/correction') {
|
|
1586
2488
|
const out = correctionState(db, Number(url.searchParams.get('id')));
|
|
1587
2489
|
return send(res, out.status, 'application/json', JSON.stringify(out.body));
|
|
1588
2490
|
}
|
|
1589
2491
|
if (url.pathname === '/api/memory/entry') {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
return
|
|
1593
|
-
|
|
2492
|
+
return reply(pageBody(memoFor(db), pageKey, () => {
|
|
2493
|
+
const entry = memoryEntry(db, Number(url.searchParams.get('id')));
|
|
2494
|
+
return entry ? JSON.stringify(entry) : null;
|
|
2495
|
+
}));
|
|
1594
2496
|
}
|
|
1595
2497
|
if (url.pathname.startsWith('/artifacts/')) {
|
|
1596
2498
|
let id;
|
|
@@ -1611,7 +2513,7 @@ export function startDashboard(db, opts = {}) {
|
|
|
1611
2513
|
return send(res, 400, 'text/plain', 'thumb is ink or paper');
|
|
1612
2514
|
return send(res, 200, 'image/svg+xml; charset=utf-8', artifactThumb(file, thumb), {
|
|
1613
2515
|
'content-security-policy': ARTIFACT_CSP,
|
|
1614
|
-
'cache-control':
|
|
2516
|
+
'cache-control': CACHE_HOUR,
|
|
1615
2517
|
});
|
|
1616
2518
|
}
|
|
1617
2519
|
const embed = url.searchParams.has('embed');
|
|
@@ -1634,17 +2536,26 @@ export function startDashboard(db, opts = {}) {
|
|
|
1634
2536
|
}
|
|
1635
2537
|
const mascotAsset = MASCOT_ASSETS.get(url.pathname);
|
|
1636
2538
|
if (mascotAsset) {
|
|
1637
|
-
return send(res, 200, mascotAsset[1], fs.readFileSync(path.join(assets, mascotAsset[0])));
|
|
2539
|
+
return send(res, 200, mascotAsset[1], fs.readFileSync(path.join(assets, mascotAsset[0])), { 'cache-control': CACHE_HOUR });
|
|
1638
2540
|
}
|
|
1639
2541
|
if (url.pathname === '/tokens.css') {
|
|
1640
|
-
return send(res, 200, 'text/css', localTokens(fs.readFileSync(path.join(assets, 'tokens.css'), 'utf8'))
|
|
2542
|
+
return send(res, 200, 'text/css', localTokens(fs.readFileSync(path.join(assets, 'tokens.css'), 'utf8')), {
|
|
2543
|
+
'cache-control': CACHE_HOUR,
|
|
2544
|
+
});
|
|
1641
2545
|
}
|
|
1642
2546
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
1643
2547
|
// The write token rides in the page itself: a hostile origin cannot read
|
|
1644
2548
|
// this response, so it cannot learn the token to send back.
|
|
1645
2549
|
const html = fs.readFileSync(path.join(assets, 'dashboard.html'), 'utf8')
|
|
1646
2550
|
.replace('<meta name="eklavya-token" content="">', `<meta name="eklavya-token" content="${token}">`);
|
|
1647
|
-
|
|
2551
|
+
// The tag is a hash of what is sent, so a new server start (a new token)
|
|
2552
|
+
// changes it, and so does an edit to the file on disk, which the page's
|
|
2553
|
+
// own development loop relies on a reload to pick up.
|
|
2554
|
+
const etag = `"${createHash('sha256').update(html).digest('hex').slice(0, 32)}"`;
|
|
2555
|
+
const headers = { etag, 'cache-control': 'no-cache' };
|
|
2556
|
+
if (etagMatches(req.headers['if-none-match'], etag))
|
|
2557
|
+
return send(res, 304, 'text/html; charset=utf-8', '', headers);
|
|
2558
|
+
return send(res, 200, 'text/html; charset=utf-8', html, headers);
|
|
1648
2559
|
}
|
|
1649
2560
|
return send(res, 404, 'text/plain', 'not found');
|
|
1650
2561
|
}
|
|
@@ -1662,7 +2573,32 @@ export function startDashboard(db, opts = {}) {
|
|
|
1662
2573
|
const addr = server.address();
|
|
1663
2574
|
/* c8 ignore next -- a TCP listener's address() is always an object; a string is a pipe */
|
|
1664
2575
|
const port = typeof addr === 'object' && addr ? addr.port : wanted;
|
|
1665
|
-
|
|
2576
|
+
// The daemon starts at SessionStart, long before anyone opens the page, so
|
|
2577
|
+
// the first build is made now rather than by the first request. After
|
|
2578
|
+
// 'listening' and off the current turn, so neither the bind nor a probe of
|
|
2579
|
+
// `/api/health` waits on it; unref'd, so it never holds a process or a test
|
|
2580
|
+
// open; cancelled by `close()`; and a failure of any kind (a database that
|
|
2581
|
+
// is closed, busy or locked) only means the first request builds it instead.
|
|
2582
|
+
const warming = setImmediate(() => {
|
|
2583
|
+
try {
|
|
2584
|
+
warmMemo(db);
|
|
2585
|
+
}
|
|
2586
|
+
catch {
|
|
2587
|
+
/* the first request builds it, and reports the error if it still fails */
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
warming.unref();
|
|
2591
|
+
resolve({
|
|
2592
|
+
url: `http://${host}:${port}`,
|
|
2593
|
+
// Ends every open stream first: `server.close` waits for every connection,
|
|
2594
|
+
// and an event stream never ends by itself. Resolves once the server has
|
|
2595
|
+
// closed; calling it again is harmless and resolves too.
|
|
2596
|
+
close: () => {
|
|
2597
|
+
clearImmediate(warming);
|
|
2598
|
+
live.shutdown();
|
|
2599
|
+
return new Promise((done) => server.close(() => done()));
|
|
2600
|
+
},
|
|
2601
|
+
});
|
|
1666
2602
|
});
|
|
1667
2603
|
server.listen(wanted, host);
|
|
1668
2604
|
});
|