backlens 0.2.1 → 0.2.3
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 +4 -2
- package/ai/fixes.js +57 -15
- package/analyze.js +6 -2
- package/package.json +1 -1
- package/register.cjs +111 -4
- package/server.js +61 -0
- package/ui.html +55 -12
package/README.md
CHANGED
|
@@ -47,7 +47,9 @@ backlens: 2 new issues. Fix them, or accept with --update-baseline.
|
|
|
47
47
|
|
|
48
48
|
## Missing indexes
|
|
49
49
|
|
|
50
|
-
For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too.
|
|
50
|
+
For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too. When the query also sorts (`ORDER BY … LIMIT`), the sort columns go after the filter columns, so Postgres reads rows already in order.
|
|
51
|
+
|
|
52
|
+
**Create on this database** builds the index on the database your app is connected to, through the app's own connection: backlens never sees a database password. It shows which database first, builds with `CONCURRENTLY` so writes are not blocked, checks that Postgres can use the index for the query, and **Undo** drops it again. Only an index on the columns backlens checked can be created this way, never arbitrary SQL. Use it on a development database, then add the SQL to a migration.
|
|
51
53
|
|
|
52
54
|
## Before and after
|
|
53
55
|
|
|
@@ -55,7 +57,7 @@ In the Compare tab, save a snapshot, change your code, restart, use the same pag
|
|
|
55
57
|
|
|
56
58
|
## Explain and fix, with proof
|
|
57
59
|
|
|
58
|
-
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (
|
|
60
|
+
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (with an exact copy of each file kept first, uncommitted changes included; Revert refuses to overwrite edits you made after the fix was applied), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
|
|
59
61
|
|
|
60
62
|
Who answers is set up in **AI settings**, all from the dashboard. The first-run setup asks too.
|
|
61
63
|
|
package/ai/fixes.js
CHANGED
|
@@ -14,12 +14,15 @@ import { promiseAll } from './templates.js';
|
|
|
14
14
|
/**
|
|
15
15
|
* Explain and Fix. A fix runs like a careful developer at a terminal, and every step is written to
|
|
16
16
|
* a live feed the dashboard shows: read the code, ask the model, check each edit fits the code
|
|
17
|
-
* (sending problems back, up to twice), apply
|
|
18
|
-
*
|
|
17
|
+
* (sending problems back, up to twice), apply (keeping an exact copy of each file as it was, uncommitted
|
|
18
|
+
* changes included), typecheck (sending type errors back, up to twice), rebuild if needed, restart, replay
|
|
19
19
|
* the request that showed the issue, run the tests. Only then is it called proven.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
// Replaying a write sends it again, so it changes the dev database twice: reads only unless asked.
|
|
23
|
+
// Replays after a fix: warm-up runs first, then measured runs (reads only).
|
|
24
|
+
const WARMUPS = 3;
|
|
25
|
+
const SAMPLES = 5;
|
|
23
26
|
const DEFAULTS = { provider: null, ollamaModel: null, dailyBudgetUsd: 1, replay: true, replayWrites: false };
|
|
24
27
|
const EDIT_REPAIRS = 2;
|
|
25
28
|
const TYPE_REPAIRS = 2;
|
|
@@ -139,7 +142,7 @@ export function createAI(deps) {
|
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
const view = (job) => {
|
|
142
|
-
const { ctx, issue, backups, convo, abort, afterTrace, ...rest } = job;
|
|
145
|
+
const { ctx, issue, backups, convo, abort, afterTrace, written, ...rest } = job;
|
|
143
146
|
return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
|
|
144
147
|
};
|
|
145
148
|
|
|
@@ -296,6 +299,14 @@ export function createAI(deps) {
|
|
|
296
299
|
}
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
function readSafe(file) {
|
|
303
|
+
try {
|
|
304
|
+
return readFileSync(file, 'utf8');
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
299
310
|
function restoreBackups(job) {
|
|
300
311
|
for (const b of job.backups || []) copyFileSync(b.backup, join(root, b.file));
|
|
301
312
|
}
|
|
@@ -402,17 +413,17 @@ export function createAI(deps) {
|
|
|
402
413
|
return null; // not a git repository: backups are the only undo
|
|
403
414
|
}
|
|
404
415
|
const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--', ...files.map((f) => join(root, f))], { encoding: 'utf8' });
|
|
405
|
-
|
|
416
|
+
// Porcelain lines are 'XY path': the status columns can start with a space, so no trim before slicing.
|
|
417
|
+
return out.split('\n').filter((l) => l.trim()).map((l) => l.slice(3));
|
|
406
418
|
}
|
|
407
419
|
|
|
408
420
|
function apply(id) {
|
|
409
421
|
const job = jobs.get(id);
|
|
410
422
|
if (!job || job.stage !== 'proposed') throw new Error('There is no suggestion ready to apply.');
|
|
411
423
|
const files = [...new Set(job.edits.map((e) => e.file))];
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
424
|
+
// Files with uncommitted changes (an earlier kept fix, work in progress) are fine: the backup
|
|
425
|
+
// is an exact copy of the file as it is now, and Revert puts that copy back.
|
|
426
|
+
const dirty = gitDirty(files) || [];
|
|
416
427
|
const backupDir = join(dir, 'ai', job.id);
|
|
417
428
|
job.backups = [];
|
|
418
429
|
for (const f of files) {
|
|
@@ -422,7 +433,13 @@ export function createAI(deps) {
|
|
|
422
433
|
job.backups.push({ file: f, backup: dest });
|
|
423
434
|
}
|
|
424
435
|
writeEdits(job.edits);
|
|
425
|
-
|
|
436
|
+
// What backlens wrote, so Revert can tell whether someone edited the file afterwards.
|
|
437
|
+
job.written = Object.fromEntries(files.map((f) => [f, readFileSync(join(root, f), 'utf8')]));
|
|
438
|
+
ev(job, 'apply', `Applied ${job.edits.length} edit${job.edits.length === 1 ? '' : 's'} to ${files.join(', ')}`, {
|
|
439
|
+
detail: dirty.length
|
|
440
|
+
? `a copy of ${dirty.join(', ')} as it was, uncommitted changes included, is kept until you keep or revert`
|
|
441
|
+
: 'backup kept until you keep or revert',
|
|
442
|
+
});
|
|
426
443
|
job.stage = 'verifying';
|
|
427
444
|
deps.pauseWatch(true);
|
|
428
445
|
verify(job)
|
|
@@ -469,6 +486,12 @@ export function createAI(deps) {
|
|
|
469
486
|
return { ok: r.code === 0, errors: r.out };
|
|
470
487
|
}
|
|
471
488
|
|
|
489
|
+
const median = (list) => {
|
|
490
|
+
const xs = [...list].sort((a, b) => a - b);
|
|
491
|
+
const mid = Math.floor(xs.length / 2);
|
|
492
|
+
return Math.round((xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2) * 100) / 100;
|
|
493
|
+
};
|
|
494
|
+
|
|
472
495
|
/** What a request cost: the numbers shown before and after a fix. */
|
|
473
496
|
function stats(trace) {
|
|
474
497
|
const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
|
|
@@ -508,6 +531,7 @@ export function createAI(deps) {
|
|
|
508
531
|
const ok = await propose(job, `Asking again with the type errors (${round + 1} of ${TYPE_REPAIRS})`);
|
|
509
532
|
if (!ok.length) return finish(job, false, 'The model could not produce a change that typechecks.');
|
|
510
533
|
writeEdits(ok);
|
|
534
|
+
job.written = Object.fromEntries(job.backups.map((b) => [b.file, readSafe(join(root, b.file))]));
|
|
511
535
|
ev(job, 'apply', `Applied ${ok.length} corrected edit${ok.length === 1 ? '' : 's'}`);
|
|
512
536
|
}
|
|
513
537
|
|
|
@@ -541,20 +565,33 @@ export function createAI(deps) {
|
|
|
541
565
|
} else {
|
|
542
566
|
// The app has just restarted, so its first request pays for cold caches and new connections.
|
|
543
567
|
// A read is sent once to warm up, and the second one is measured.
|
|
544
|
-
|
|
545
|
-
|
|
568
|
+
// A just-restarted app is slow for its first requests (cold code, new database
|
|
569
|
+
// connections). A read is sent a few times to warm up, then measured several times and the
|
|
570
|
+
// median kept. A write runs once: replaying it changes data again.
|
|
571
|
+
const samples = [];
|
|
572
|
+
if (!isWrite) for (let i = 0; i < WARMUPS; i++) await replay(job, payload, deps.appPort());
|
|
573
|
+
for (let i = 0; i < (isWrite ? 1 : SAMPLES); i++) {
|
|
574
|
+
const t = await replay(job, payload, deps.appPort());
|
|
575
|
+
if (!t) break;
|
|
576
|
+
samples.push(t);
|
|
577
|
+
}
|
|
578
|
+
const trace = samples.at(-1);
|
|
546
579
|
if (!trace) {
|
|
547
580
|
r.status = 'fail';
|
|
548
581
|
r.detail = 'the replayed request did not finish within 20 s';
|
|
549
582
|
return finish(job, false, 'The replayed request did not come back.');
|
|
550
583
|
}
|
|
551
584
|
replayed = true;
|
|
552
|
-
const still = checkTrace(
|
|
585
|
+
const still = samples.some((t) => checkTrace(t).some((i) => sameIssue(i, job.issue)));
|
|
553
586
|
const sameClass = original ? Math.floor(trace.status / 100) === Math.floor(original.status / 100) : trace.status < 400;
|
|
554
|
-
|
|
587
|
+
// Before: the same route's recorded requests from before the change, not just one of them.
|
|
588
|
+
const earlier = (deps.routeTraces ? deps.routeTraces(job.issue.route) : []).filter((t) => t.status === (original || trace).status);
|
|
589
|
+
const beforeList = earlier.length ? earlier.slice(-SAMPLES) : original ? [original] : [];
|
|
590
|
+
const after = { ...stats(trace), ms: median(samples.map((t) => t.duration)) };
|
|
591
|
+
const before = beforeList.length ? { ...stats(original || beforeList.at(-1)), ms: median(beforeList.map((t) => t.duration)) } : null;
|
|
555
592
|
r.status = !still && sameClass ? 'done' : 'fail';
|
|
556
|
-
r.detail = `before: ${before}\nafter: ${
|
|
557
|
-
job.compare = { before
|
|
593
|
+
r.detail = `before: ${before ? `${before.status}, median ${before.ms} ms of ${beforeList.length} recorded, ${before.queries} queries` : 'no recorded request'}\nafter: ${after.status}, median ${after.ms} ms of ${samples.length} replays, ${after.queries} queries${still ? ', issue still there' : ', issue gone'}`;
|
|
594
|
+
job.compare = { before, after, beforeRuns: beforeList.length, afterRuns: samples.length, issueGone: !still, warmed: !isWrite };
|
|
558
595
|
job.afterTrace = trace;
|
|
559
596
|
if (trace.status === 401 && original && original.status !== 401) r.detail += '\nThe recorded login may have expired: sign in again and use the page once more.';
|
|
560
597
|
if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
|
|
@@ -606,6 +643,11 @@ export function createAI(deps) {
|
|
|
606
643
|
async function revert(id) {
|
|
607
644
|
const job = jobs.get(id);
|
|
608
645
|
if (!job || !job.backups || !job.backups.length) throw new Error('Nothing to revert.');
|
|
646
|
+
// Never overwrite edits made after the fix was applied.
|
|
647
|
+
const changed = job.backups.filter((b) => job.written && job.written[b.file] !== undefined && readSafe(join(root, b.file)) !== job.written[b.file]);
|
|
648
|
+
if (changed.length) {
|
|
649
|
+
throw new Error(`${changed.map((b) => b.file).join(', ')} changed after the fix was applied, so backlens did not overwrite it. The version from before the fix is at ${changed.map((b) => relative(root, b.backup)).join(', ')}.`);
|
|
650
|
+
}
|
|
609
651
|
restoreBackups(job);
|
|
610
652
|
append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: false });
|
|
611
653
|
cleanup(job);
|
package/analyze.js
CHANGED
|
@@ -291,7 +291,10 @@ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
|
291
291
|
perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
|
|
292
292
|
}
|
|
293
293
|
const big = a.rows >= 1000;
|
|
294
|
-
|
|
294
|
+
// Filter columns first, then the ORDER BY columns, so rows come out already sorted.
|
|
295
|
+
const cols = [...a.columns.map((c) => ({ name: c, desc: false })), ...(Array.isArray(a.sort) ? a.sort : []).filter((c) => c && typeof c.name === 'string')];
|
|
296
|
+
const name = `${a.table}_${cols.map((c) => c.name).join('_')}_idx`.slice(0, 63);
|
|
297
|
+
const colText = cols.map((c) => `${c.name}${c.desc ? ' DESC' : ''}`).join(', ');
|
|
295
298
|
for (const [route, info] of perRoute) {
|
|
296
299
|
const key = `index|${route}|${sql}|${a.table}`;
|
|
297
300
|
routes.get(route)?.issueKeys.add(key);
|
|
@@ -299,7 +302,8 @@ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
|
299
302
|
type: 'index',
|
|
300
303
|
severity: big ? 'warn' : 'hint',
|
|
301
304
|
title: `No index for ${a.table} filtered by ${a.columns.join(', ')}`,
|
|
302
|
-
fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${
|
|
305
|
+
fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${colText});`,
|
|
306
|
+
index: { schema: a.schema || 'public', table: a.table, name, columns: cols, sql: a.sql },
|
|
303
307
|
sql,
|
|
304
308
|
sites: appFrame(info.span.frames) ? [at(appFrame(info.span.frames))] : [],
|
|
305
309
|
frames: [info.span.frames],
|
package/package.json
CHANGED
package/register.cjs
CHANGED
|
@@ -232,6 +232,10 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
232
232
|
if (address && typeof address === 'object') post('/listening', { port: address.port, pid: process.pid, source: SOURCE });
|
|
233
233
|
} catch {}
|
|
234
234
|
}
|
|
235
|
+
if (event === 'request' && req && res && req.url === '/__backlens/index' && req.method === 'POST' && tokenOk(req.headers['x-backlens-token'])) {
|
|
236
|
+
handleCommand(req, res);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
235
239
|
if (event !== 'request' || !req || !res || req.headers['x-backlens'] === 'skip' || INTERNAL.test(req.url || '')) return emit.apply(this, arguments);
|
|
236
240
|
const trace = {
|
|
237
241
|
id: randomUUID(),
|
|
@@ -304,13 +308,25 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
304
308
|
return cols;
|
|
305
309
|
}
|
|
306
310
|
|
|
307
|
-
|
|
311
|
+
/** 'reviews.updated_at DESC' -> { table: 'reviews', name: 'updated_at', desc: true } */
|
|
312
|
+
function sortKey(key) {
|
|
313
|
+
const m = String(key).match(/^(?:"?(\w+)"?\.)?"?([a-z_]\w*)"?(?:\s+(ASC|DESC))?(?:\s+NULLS\s+(?:FIRST|LAST))?$/i);
|
|
314
|
+
return m ? { table: m[1] || null, name: m[2], desc: /desc/i.test(m[3] || '') } : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A sort above the scan (ORDER BY … LIMIT) is worth putting in the index after the filter
|
|
318
|
+
// columns: Postgres then reads rows already in order and stops at the limit.
|
|
319
|
+
function seqScans(node, out = [], sort = []) {
|
|
308
320
|
if (!node) return out;
|
|
321
|
+
if (/Sort$/.test(node['Node Type']) && Array.isArray(node['Sort Key'])) sort = node['Sort Key'].map(sortKey);
|
|
309
322
|
if (node['Node Type'] === 'Seq Scan' && node['Relation Name'] && node.Filter) {
|
|
310
323
|
const columns = columnsCompared(node.Filter);
|
|
311
|
-
|
|
324
|
+
const alias = node.Alias || node['Relation Name'];
|
|
325
|
+
const order = sort.every((k) => k && (!k.table || k.table === alias)) ? sort.filter((k) => !columns.includes(k.name)) : [];
|
|
326
|
+
if (columns.length) out.push({ table: node['Relation Name'], schema: node.Schema || 'public', columns, sort: order.map(({ name, desc }) => ({ name, desc })), filter: node.Filter });
|
|
312
327
|
}
|
|
313
|
-
|
|
328
|
+
const keep = /^(Sort|Incremental Sort|Limit|Gather|Gather Merge|Result)$/.test(node['Node Type']) ? sort : [];
|
|
329
|
+
for (const child of node.Plans || []) seqScans(child, out, keep);
|
|
314
330
|
return out;
|
|
315
331
|
}
|
|
316
332
|
|
|
@@ -336,10 +352,101 @@ if (TARGET && !globalThis.__backlens) {
|
|
|
336
352
|
if (!rows[0]) continue;
|
|
337
353
|
const leading = rows[0].leading || [];
|
|
338
354
|
if (scan.columns.some((col) => leading.includes(col))) continue;
|
|
339
|
-
post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, rows: Math.max(0, Number(rows[0].rows)) });
|
|
355
|
+
post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, sort: scan.sort, rows: Math.max(0, Number(rows[0].rows)) });
|
|
340
356
|
}
|
|
341
357
|
}
|
|
342
358
|
|
|
359
|
+
// ---- creating the suggested index, when the user asks in the dashboard ----------------
|
|
360
|
+
// The dashboard sends one internal request to the app, with its private token. Only an index
|
|
361
|
+
// on named columns can be created or dropped, built here from checked names, never from SQL
|
|
362
|
+
// text; the plan check only re-explains a statement this process already explained.
|
|
363
|
+
const IDENT = /^[a-z_][a-z0-9_$]{0,62}$/i;
|
|
364
|
+
const q = (name) => `"${name}"`;
|
|
365
|
+
|
|
366
|
+
function tokenOk(value) {
|
|
367
|
+
if (!TOKEN || typeof value !== 'string' || value.length !== TOKEN.length) return false;
|
|
368
|
+
return require('node:crypto').timingSafeEqual(Buffer.from(value), Buffer.from(TOKEN));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function indexCommand(body) {
|
|
372
|
+
if (!advisorPool) throw new Error('The app has not used Postgres yet. Use the page once, then try again.');
|
|
373
|
+
const pool = advisorPool;
|
|
374
|
+
if (body.action === 'info') {
|
|
375
|
+
const { rows } = await pool.query('select current_database() as database, inet_server_addr()::text as address, inet_server_port() as port');
|
|
376
|
+
const o = pool.options || {};
|
|
377
|
+
let host = o.host;
|
|
378
|
+
try {
|
|
379
|
+
if (!host && o.connectionString) host = new URL(o.connectionString).hostname;
|
|
380
|
+
} catch {}
|
|
381
|
+
return { database: rows[0].database, host: host || rows[0].address || 'local socket', port: rows[0].port };
|
|
382
|
+
}
|
|
383
|
+
const { schema, table, name } = body;
|
|
384
|
+
const columns = Array.isArray(body.columns) ? body.columns : [];
|
|
385
|
+
if (![schema, table, name].every((x) => typeof x === 'string' && IDENT.test(x)) || !columns.length || columns.length > 8 || !columns.every((c) => c && typeof c.name === 'string' && IDENT.test(c.name))) {
|
|
386
|
+
throw new Error('That is not an index backlens suggested.');
|
|
387
|
+
}
|
|
388
|
+
if (body.action === 'create') {
|
|
389
|
+
const cols = columns.map((c) => `${q(c.name)}${c.desc ? ' DESC' : ''}`).join(', ');
|
|
390
|
+
// CONCURRENTLY builds without blocking writes; it cannot run inside a transaction, so a
|
|
391
|
+
// plain pool query (its own connection, autocommit) is used.
|
|
392
|
+
await pool.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${q(name)} ON ${q(schema)}.${q(table)} (${cols})`);
|
|
393
|
+
const { rows } = await pool.query(
|
|
394
|
+
`select i.indisvalid as valid from pg_class c join pg_index i on i.indexrelid = c.oid join pg_namespace n on n.oid = c.relnamespace where c.relname = $1 and n.nspname = $2`,
|
|
395
|
+
[name, schema],
|
|
396
|
+
);
|
|
397
|
+
if (!rows[0] || !rows[0].valid) throw new Error('Postgres did not finish building the index. Drop it and try again.');
|
|
398
|
+
// Can Postgres use it for the query that showed the issue? On a small table it still
|
|
399
|
+
// prefers reading every row, so the check asks with sequential scans turned off.
|
|
400
|
+
let usable = null;
|
|
401
|
+
if (typeof body.sql === 'string' && explained.has(body.sql)) {
|
|
402
|
+
const client = await pool.connect();
|
|
403
|
+
try {
|
|
404
|
+
await client.query('BEGIN');
|
|
405
|
+
await client.query('SET LOCAL enable_seqscan = off');
|
|
406
|
+
const prefix = /\$\d/.test(body.sql) ? 'EXPLAIN (GENERIC_PLAN, FORMAT JSON) ' : 'EXPLAIN (FORMAT JSON) ';
|
|
407
|
+
const plan = JSON.stringify((await client.query(prefix + body.sql)).rows[0]['QUERY PLAN']);
|
|
408
|
+
usable = plan.includes(`"Index Name":"${name}"`);
|
|
409
|
+
} catch {
|
|
410
|
+
usable = null;
|
|
411
|
+
} finally {
|
|
412
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
413
|
+
client.release();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { created: true, usable };
|
|
417
|
+
}
|
|
418
|
+
if (body.action === 'drop') {
|
|
419
|
+
await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS ${q(schema)}.${q(name)}`);
|
|
420
|
+
return { dropped: true };
|
|
421
|
+
}
|
|
422
|
+
throw new Error('Unknown action.');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function handleCommand(req, res) {
|
|
426
|
+
let raw = '';
|
|
427
|
+
req.on('data', (c) => {
|
|
428
|
+
raw += c;
|
|
429
|
+
if (raw.length > 64 * 1024) req.destroy();
|
|
430
|
+
});
|
|
431
|
+
req.on('end', () => {
|
|
432
|
+
let body = null;
|
|
433
|
+
try {
|
|
434
|
+
body = JSON.parse(raw);
|
|
435
|
+
} catch {}
|
|
436
|
+
const send = (status, payload) => {
|
|
437
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
438
|
+
res.end(JSON.stringify(payload));
|
|
439
|
+
};
|
|
440
|
+
if (!body) return send(400, { error: 'Bad request.' });
|
|
441
|
+
storage.exit(() =>
|
|
442
|
+
indexCommand(body).then(
|
|
443
|
+
(out) => send(200, out),
|
|
444
|
+
(err) => send(400, { error: String((err && err.message) || err) }),
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
343
450
|
function maybeAdvise(text) {
|
|
344
451
|
if (!advisorPool || advisorOff || explained.has(text) || explained.size >= MAX_EXPLAINS) return;
|
|
345
452
|
if (!/^\s*(select|with)\b/i.test(text) || text.includes(';') || /\bpg_(class|index|attribute|namespace)\b/.test(text)) return;
|
package/server.js
CHANGED
|
@@ -32,6 +32,33 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
32
32
|
const traces = [];
|
|
33
33
|
// Issue key -> when its fix was kept (see summarize).
|
|
34
34
|
const fixedAt = new Map();
|
|
35
|
+
// Issue key -> the index created for it from the dashboard, so Undo still knows it.
|
|
36
|
+
const createdIndexes = new Map();
|
|
37
|
+
|
|
38
|
+
/** Sends one command to the recorder inside the app (see register.cjs). */
|
|
39
|
+
function appCommand(port, body) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const payload = JSON.stringify(body);
|
|
42
|
+
const r = http.request(
|
|
43
|
+
{ host: '127.0.0.1', port, method: 'POST', path: '/__backlens/index', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), 'x-backlens-token': token } },
|
|
44
|
+
(res) => {
|
|
45
|
+
let raw = '';
|
|
46
|
+
res.on('data', (c) => (raw += c));
|
|
47
|
+
res.on('end', () => {
|
|
48
|
+
let out = null;
|
|
49
|
+
try {
|
|
50
|
+
out = JSON.parse(raw);
|
|
51
|
+
} catch {}
|
|
52
|
+
if (res.statusCode === 200 && out) resolve(out);
|
|
53
|
+
else reject(new Error((out && out.error) || 'The app did not answer. Is it running with backlens?'));
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
r.setTimeout(10 * 60 * 1000, () => r.destroy(new Error('Creating the index took over 10 minutes.')));
|
|
58
|
+
r.on('error', (err) => reject(new Error(`Could not reach the app: ${err.message}`)));
|
|
59
|
+
r.end(payload);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
35
62
|
let received = 0;
|
|
36
63
|
const known = new Map();
|
|
37
64
|
const advice = new Map();
|
|
@@ -303,6 +330,34 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
303
330
|
tests.start(scriptCommand(pm, config.test), root);
|
|
304
331
|
return json(res, 200, { ok: true });
|
|
305
332
|
}
|
|
333
|
+
// Creating a suggested index on the database the app is using, through the app itself.
|
|
334
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/index/')) {
|
|
335
|
+
const action = url.pathname.slice('/api/index/'.length);
|
|
336
|
+
if (!['info', 'create', 'drop'].includes(action)) return json(res, 404, { error: 'not found' });
|
|
337
|
+
if (!app || !app.port) return json(res, 409, { error: 'The app is not running. Start it first.' });
|
|
338
|
+
const body = (await readJson(req)) || {};
|
|
339
|
+
let index = null;
|
|
340
|
+
if (action !== 'info') {
|
|
341
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
342
|
+
const issue = issues.find((i) => i.key === body.key);
|
|
343
|
+
index = issue && issue.index ? issue.index : createdIndexes.get(body.key);
|
|
344
|
+
if (!index) return json(res, 404, { error: 'That suggestion is gone. Refresh the list.' });
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const out = await appCommand(app.port, { action, ...(index || {}) });
|
|
348
|
+
if (action === 'create') {
|
|
349
|
+
createdIndexes.set(body.key, index);
|
|
350
|
+
fixedAt.set(body.key, Date.now());
|
|
351
|
+
}
|
|
352
|
+
if (action === 'drop') {
|
|
353
|
+
createdIndexes.delete(body.key);
|
|
354
|
+
fixedAt.delete(body.key);
|
|
355
|
+
}
|
|
356
|
+
return json(res, 200, { ...out, index });
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return json(res, 400, { error: err.message });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
306
361
|
if (url.pathname.startsWith('/api/ai/')) {
|
|
307
362
|
try {
|
|
308
363
|
const body = req.method === 'POST' ? (await readJson(req)) || {} : {};
|
|
@@ -367,6 +422,12 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
367
422
|
return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
|
|
368
423
|
},
|
|
369
424
|
getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
|
|
425
|
+
// Recorded requests on a route since the last fix kept there (the code they ran is the
|
|
426
|
+
// code being changed now), oldest first.
|
|
427
|
+
routeTraces: (route) => {
|
|
428
|
+
const since = Math.max(0, ...[...fixedAt].filter(([key]) => key.split('|')[1] === route).map(([, at]) => at));
|
|
429
|
+
return traces.filter((t) => t.at > since && routeOf(t.method, t.url, t.template) === route);
|
|
430
|
+
},
|
|
370
431
|
markFixed: (key, after) => {
|
|
371
432
|
fixedAt.set(key, Date.now());
|
|
372
433
|
if (after) {
|
package/ui.html
CHANGED
|
@@ -157,7 +157,8 @@
|
|
|
157
157
|
a.btn { text-decoration: none; display: inline-block; }
|
|
158
158
|
.compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
|
|
159
159
|
.compare-card h4 { margin: 0 0 8px; font-size: 13px; }
|
|
160
|
-
.compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
|
|
160
|
+
.compare-card table { width: auto; max-width: 100%; border-collapse: collapse; font: 13px var(--mono); margin-bottom: 10px; }
|
|
161
|
+
.compare-card td, .compare-card th { white-space: nowrap; }
|
|
161
162
|
.compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
|
|
162
163
|
.compare-card td { padding: 3px 10px 3px 0; }
|
|
163
164
|
.compare-card .arrow { color: var(--sub); }
|
|
@@ -735,9 +736,10 @@ function renderFix() {
|
|
|
735
736
|
<div class="kicker2">fix${j.attempt ? ', second attempt' : ''}</div><h2>${esc(j.issueTitle)}</h2><div class="meta">${esc(j.issueRoute || '')}</div>
|
|
736
737
|
<div class="chips" style="margin-top:10px"><span class="chip">${esc(r.label)}</span><span class="chip">${r.kind === 'anthropic' ? (j.costUsd ? `spent ${usd(j.costUsd)}` : `about ${usd(r.estimateUsd)}`) : 'free'}</span>${busy ? '<span class="chip live"><span class="spin"></span> working</span>' : ''}</div></div>`;
|
|
737
738
|
if (j.stage === 'template') {
|
|
738
|
-
html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p
|
|
739
|
-
|
|
740
|
-
|
|
739
|
+
html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p>${indexPanel(j)}`;
|
|
740
|
+
el.innerHTML = html;
|
|
741
|
+
if (!idx.db && !idx.loading) loadIndexInfo();
|
|
742
|
+
return;
|
|
741
743
|
}
|
|
742
744
|
if (j.stage === 'preview') {
|
|
743
745
|
html += `<section class="plan"><h4>Why this model</h4><ul class="reasons">${r.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
|
|
@@ -756,7 +758,7 @@ function renderFix() {
|
|
|
756
758
|
if (j.stage === 'failed') act.push('<button class="primary" type="button" id="fix-revert">Revert</button><button class="ghost" type="button" id="fix-keep">Keep anyway</button>');
|
|
757
759
|
if (j.stage === 'unverified') act.push('<button class="ghost" type="button" id="fix-keep">Keep</button><button class="ghost" type="button" id="fix-revert">Revert</button>');
|
|
758
760
|
if (j.stage === 'reverted' || j.stage === 'error') act.push(escalateBtn(j));
|
|
759
|
-
if (j.stage === 'proposed') act.push('<span class="note">
|
|
761
|
+
if (j.stage === 'proposed') act.push('<span class="note">backlens keeps an exact copy of each file first, uncommitted changes included. Revert puts it back.</span>');
|
|
760
762
|
if (act.length || fixError) html += `<div class="fix-actions">${act.join('')}<span class="error" role="alert">${esc(fixError)}</span></div>`;
|
|
761
763
|
el.innerHTML = html;
|
|
762
764
|
el.querySelectorAll('[data-autoscroll]').forEach((t) => (t.scrollTop = t.scrollHeight));
|
|
@@ -768,22 +770,62 @@ function compareCard(j) {
|
|
|
768
770
|
const c = j.compare;
|
|
769
771
|
if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
|
|
770
772
|
const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
|
|
771
|
-
const row = (label, a, b, fmt = (x) => x,
|
|
773
|
+
const row = (label, a, b, fmt = (x) => x, noise = 0) => {
|
|
772
774
|
const d = pct(a, b);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
+
// Small time differences are noise, not a result: shown plain, not green or red.
|
|
776
|
+
const tone = a === b || Math.abs(b - a) <= noise ? 'same' : b < a ? 'better' : 'worse';
|
|
777
|
+
return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${tone === 'same' ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
|
|
775
778
|
};
|
|
776
|
-
const kind = esc((j.issueTitle || '').split(':')[0]);
|
|
777
779
|
return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
|
|
778
|
-
${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
|
|
780
|
+
${row('time (median)', c.before.ms, c.after.ms, (x) => ms(x), Math.max(3, c.before.ms * 0.15))}
|
|
779
781
|
${row('database queries', c.before.queries, c.after.queries)}
|
|
780
782
|
${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
|
|
781
783
|
${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
|
|
782
784
|
<tr><th>status</th><td>${c.before.status}</td><td class="arrow">→</td><td class="${c.before.status === c.after.status ? 'same' : 'worse'}">${c.after.status}</td><td></td></tr>
|
|
783
|
-
<tr><th>issue</th><td
|
|
784
|
-
</table><p class="note">
|
|
785
|
+
<tr><th>issue</th><td>found</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
|
|
786
|
+
</table><p class="note">Before: ${c.beforeRuns} recorded request${c.beforeRuns === 1 ? '' : 's'}. After: ${c.afterRuns} replay${c.afterRuns === 1 ? '' : 's'}${c.warmed ? ', after warming the restarted app up' : ''}. Query counts are exact; a time change within a few ms is noise.</p></section>`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ---- creating a suggested index from the drawer ----------------------------------------
|
|
790
|
+
let idx = {};
|
|
791
|
+
async function loadIndexInfo() {
|
|
792
|
+
idx.loading = true;
|
|
793
|
+
try { idx.db = await aiPost('/api/index/info', {}); } catch (err) { idx.dbError = err.message; }
|
|
794
|
+
idx.loading = false;
|
|
795
|
+
renderFix();
|
|
785
796
|
}
|
|
786
797
|
|
|
798
|
+
function indexPanel(j) {
|
|
799
|
+
const where = idx.db ? `<b>${esc(idx.db.database)}</b> on ${esc(idx.db.host)}${idx.db.port ? `:${idx.db.port}` : ''}` : idx.dbError ? '' : 'checking…';
|
|
800
|
+
const steps = [];
|
|
801
|
+
if (idx.result && idx.result.created) {
|
|
802
|
+
steps.push(`<li class="fi"><span class="dot ok">●</span><div><div class="fh"><b>Created</b> ${esc(idx.result.index.name)}</div><div class="sub2">on ${where}, built without blocking writes</div></div></li>`);
|
|
803
|
+
const u = idx.result.usable;
|
|
804
|
+
steps.push(`<li class="fi"><span class="dot ${u ? 'ok' : u === false ? 'bad' : 'skip'}">●</span><div><div class="fh"><b>Plan check</b></div><div class="sub2">${u ? 'Postgres can answer this query from the new index.' : u === false ? 'Postgres would still not use it for this query.' : 'Could not check the plan for this query.'}</div></div></li>`);
|
|
805
|
+
}
|
|
806
|
+
if (idx.dropped) steps.push(`<li class="fi"><span class="dot skip">●</span><div><div class="fh"><b>Dropped</b> the index again</div></div></li>`);
|
|
807
|
+
const actions = idx.result && idx.result.created
|
|
808
|
+
? `<button class="ghost" type="button" id="idx-drop" ${idx.busy ? 'disabled' : ''}>Undo (drop it)</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL for a migration</button>`
|
|
809
|
+
: `<button class="primary" type="button" id="idx-create" ${idx.busy || !idx.db ? 'disabled' : ''}>${idx.busy ? 'Creating…' : 'Create on this database'}</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL</button>`;
|
|
810
|
+
return `${steps.length ? `<ol class="feed">${steps.join('')}</ol>` : ''}
|
|
811
|
+
<p class="note">${idx.dbError ? esc(idx.dbError) : `Runs on the database your app is connected to right now: ${where}. Only use it on a development database.`}${idx.result && idx.result.created ? ' This changed only this database: add the SQL to a migration so other environments get it too.' : ''}</p>
|
|
812
|
+
<div class="fix-actions">${actions}<span class="error" role="alert">${esc(idx.error || '')}</span></div>`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
document.addEventListener('click', async (e) => {
|
|
816
|
+
const create = e.target.closest('#idx-create');
|
|
817
|
+
const drop = e.target.closest('#idx-drop');
|
|
818
|
+
if (!create && !drop) return;
|
|
819
|
+
idx.busy = true; idx.error = ''; renderFix();
|
|
820
|
+
try {
|
|
821
|
+
if (create) { idx.result = await aiPost('/api/index/create', { key: fixJob.issueKey }); idx.dropped = false; }
|
|
822
|
+
else { await aiPost('/api/index/drop', { key: fixJob.issueKey }); idx.result = null; idx.dropped = true; }
|
|
823
|
+
lastTotal = -1; poll();
|
|
824
|
+
} catch (err) { idx.error = err.message; }
|
|
825
|
+
idx.busy = false;
|
|
826
|
+
renderFix();
|
|
827
|
+
});
|
|
828
|
+
|
|
787
829
|
function escalateBtn(j) {
|
|
788
830
|
if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
|
|
789
831
|
return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
|
|
@@ -915,6 +957,7 @@ document.addEventListener('click', async (e) => {
|
|
|
915
957
|
const issue = issueFrom(fx.dataset.aiFix);
|
|
916
958
|
if (!issue) return;
|
|
917
959
|
fixError = '';
|
|
960
|
+
idx = {};
|
|
918
961
|
try { fixJob = await aiPost('/api/ai/fix', { key: issue.key }); }
|
|
919
962
|
catch (err) { fixJob = null; fixError = err.message; }
|
|
920
963
|
renderFix();
|