@jhizzard/termdeck 0.10.4 → 0.12.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/package.json +3 -2
- package/packages/cli/src/init-rumen.js +153 -83
- package/packages/client/public/app.js +207 -4
- package/packages/client/public/flashback-history.html +331 -0
- package/packages/client/public/flashback-history.js +258 -0
- package/packages/client/public/graph-controls.js +217 -0
- package/packages/client/public/graph.html +36 -0
- package/packages/client/public/graph.js +131 -15
- package/packages/client/public/index.html +25 -0
- package/packages/client/public/style.css +230 -0
- package/packages/server/src/config.js +49 -0
- package/packages/server/src/database.js +49 -1
- package/packages/server/src/flashback-diag.js +187 -13
- package/packages/server/src/index.js +132 -19
- package/packages/server/src/projects-routes.js +119 -0
- package/packages/server/src/pty-reaper.js +297 -0
- package/packages/server/src/setup/index.js +1 -0
- package/packages/server/src/setup/migration-templating.js +76 -0
- package/packages/server/src/setup/migrations.js +44 -4
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +381 -0
- package/packages/server/src/setup/rumen/functions/graph-inference/tsconfig.json +14 -0
|
@@ -389,6 +389,31 @@
|
|
|
389
389
|
flex: 1;
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/* Sprint 42 T4 — drag handle for PTY panel reordering. Only the handle
|
|
393
|
+
is grabbable; the rest of the panel header / xterm body are unaffected. */
|
|
394
|
+
.panel-drag-handle {
|
|
395
|
+
cursor: grab;
|
|
396
|
+
color: var(--tg-text-dim);
|
|
397
|
+
font-size: 12px;
|
|
398
|
+
letter-spacing: -2px;
|
|
399
|
+
user-select: none;
|
|
400
|
+
flex-shrink: 0;
|
|
401
|
+
padding: 0 2px;
|
|
402
|
+
opacity: 0.5;
|
|
403
|
+
transition: opacity 0.15s;
|
|
404
|
+
}
|
|
405
|
+
.panel-drag-handle:hover { opacity: 1; color: var(--tg-accent); }
|
|
406
|
+
.panel-drag-handle:active { cursor: grabbing; }
|
|
407
|
+
.term-panel.dragging {
|
|
408
|
+
opacity: 0.5;
|
|
409
|
+
outline: 2px dashed var(--tg-accent);
|
|
410
|
+
outline-offset: -2px;
|
|
411
|
+
}
|
|
412
|
+
.term-panel.drag-over {
|
|
413
|
+
outline: 2px solid var(--tg-accent);
|
|
414
|
+
outline-offset: -2px;
|
|
415
|
+
}
|
|
416
|
+
|
|
392
417
|
.status-dot {
|
|
393
418
|
width: 8px; height: 8px;
|
|
394
419
|
border-radius: 50%;
|
|
@@ -768,6 +793,156 @@
|
|
|
768
793
|
filter: none;
|
|
769
794
|
}
|
|
770
795
|
|
|
796
|
+
/* ===== REMOVE PROJECT BUTTON + MODAL (Sprint 42 T4) ===== */
|
|
797
|
+
.prompt-remove-project {
|
|
798
|
+
background: var(--tg-bg);
|
|
799
|
+
border: 1px solid var(--tg-border);
|
|
800
|
+
color: var(--tg-text-dim);
|
|
801
|
+
font-size: 18px;
|
|
802
|
+
line-height: 1;
|
|
803
|
+
padding: 0;
|
|
804
|
+
width: 26px;
|
|
805
|
+
height: 26px;
|
|
806
|
+
border-radius: var(--tg-radius-sm);
|
|
807
|
+
cursor: pointer;
|
|
808
|
+
font-family: var(--tg-sans);
|
|
809
|
+
transition: all 0.15s;
|
|
810
|
+
display: flex;
|
|
811
|
+
align-items: center;
|
|
812
|
+
justify-content: center;
|
|
813
|
+
margin-left: 4px;
|
|
814
|
+
}
|
|
815
|
+
.prompt-remove-project:hover {
|
|
816
|
+
border-color: var(--tg-red, #f7768e);
|
|
817
|
+
color: var(--tg-red, #f7768e);
|
|
818
|
+
}
|
|
819
|
+
.remove-project-modal {
|
|
820
|
+
display: none;
|
|
821
|
+
position: fixed;
|
|
822
|
+
inset: 0;
|
|
823
|
+
z-index: 3000;
|
|
824
|
+
align-items: center;
|
|
825
|
+
justify-content: center;
|
|
826
|
+
}
|
|
827
|
+
.remove-project-modal.open { display: flex; }
|
|
828
|
+
.remove-project-backdrop {
|
|
829
|
+
position: absolute;
|
|
830
|
+
inset: 0;
|
|
831
|
+
background: rgba(0, 0, 0, 0.72);
|
|
832
|
+
}
|
|
833
|
+
.remove-project-card {
|
|
834
|
+
position: relative;
|
|
835
|
+
background: var(--tg-surface);
|
|
836
|
+
border: 1px solid var(--tg-accent-dim);
|
|
837
|
+
border-radius: 10px;
|
|
838
|
+
padding: 22px 24px 18px;
|
|
839
|
+
width: 460px;
|
|
840
|
+
max-width: calc(100vw - 40px);
|
|
841
|
+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
|
842
|
+
font-family: var(--tg-sans);
|
|
843
|
+
color: var(--tg-text);
|
|
844
|
+
}
|
|
845
|
+
.remove-project-card h3 {
|
|
846
|
+
margin: 0 0 4px;
|
|
847
|
+
font-size: 16px;
|
|
848
|
+
color: var(--tg-accent);
|
|
849
|
+
}
|
|
850
|
+
.remove-project-card .rpm-help {
|
|
851
|
+
margin: 0 0 14px;
|
|
852
|
+
font-size: 12px;
|
|
853
|
+
color: var(--tg-text-dim);
|
|
854
|
+
line-height: 1.5;
|
|
855
|
+
}
|
|
856
|
+
.remove-project-card .rpm-help code {
|
|
857
|
+
background: var(--tg-bg);
|
|
858
|
+
padding: 1px 5px;
|
|
859
|
+
border-radius: 3px;
|
|
860
|
+
font-family: var(--tg-mono);
|
|
861
|
+
font-size: 11px;
|
|
862
|
+
}
|
|
863
|
+
.remove-project-card .rpm-help strong {
|
|
864
|
+
color: var(--tg-green);
|
|
865
|
+
}
|
|
866
|
+
.remove-project-card label {
|
|
867
|
+
display: block;
|
|
868
|
+
margin-bottom: 10px;
|
|
869
|
+
}
|
|
870
|
+
.remove-project-card label > span {
|
|
871
|
+
display: block;
|
|
872
|
+
font-size: 11px;
|
|
873
|
+
color: var(--tg-text-dim);
|
|
874
|
+
margin-bottom: 3px;
|
|
875
|
+
text-transform: uppercase;
|
|
876
|
+
letter-spacing: 0.5px;
|
|
877
|
+
}
|
|
878
|
+
.remove-project-card select {
|
|
879
|
+
width: 100%;
|
|
880
|
+
background: var(--tg-bg);
|
|
881
|
+
border: 1px solid var(--tg-border);
|
|
882
|
+
color: var(--tg-text);
|
|
883
|
+
font-size: 13px;
|
|
884
|
+
padding: 7px 10px;
|
|
885
|
+
border-radius: var(--tg-radius-sm);
|
|
886
|
+
font-family: var(--tg-mono);
|
|
887
|
+
box-sizing: border-box;
|
|
888
|
+
}
|
|
889
|
+
.remove-project-card select:focus {
|
|
890
|
+
outline: none;
|
|
891
|
+
border-color: var(--tg-accent-dim);
|
|
892
|
+
}
|
|
893
|
+
.remove-project-card .rpm-warning {
|
|
894
|
+
background: rgba(247, 118, 142, 0.08);
|
|
895
|
+
border: 1px solid rgba(247, 118, 142, 0.4);
|
|
896
|
+
color: var(--tg-text);
|
|
897
|
+
font-size: 12px;
|
|
898
|
+
padding: 8px 10px;
|
|
899
|
+
border-radius: 4px;
|
|
900
|
+
margin: 6px 0 8px;
|
|
901
|
+
line-height: 1.5;
|
|
902
|
+
}
|
|
903
|
+
.remove-project-card .rpm-status {
|
|
904
|
+
font-size: 12px;
|
|
905
|
+
min-height: 16px;
|
|
906
|
+
margin: 4px 0 8px;
|
|
907
|
+
color: var(--tg-text-dim);
|
|
908
|
+
}
|
|
909
|
+
.remove-project-card .rpm-status.error { color: var(--tg-red); }
|
|
910
|
+
.remove-project-card .rpm-status.ok { color: var(--tg-green); }
|
|
911
|
+
.remove-project-card .rpm-actions {
|
|
912
|
+
display: flex;
|
|
913
|
+
justify-content: flex-end;
|
|
914
|
+
gap: 8px;
|
|
915
|
+
margin-top: 6px;
|
|
916
|
+
}
|
|
917
|
+
.remove-project-card button {
|
|
918
|
+
font-size: 12px;
|
|
919
|
+
font-weight: 600;
|
|
920
|
+
padding: 6px 16px;
|
|
921
|
+
border-radius: 4px;
|
|
922
|
+
cursor: pointer;
|
|
923
|
+
font-family: var(--tg-sans);
|
|
924
|
+
border: 1px solid var(--tg-border);
|
|
925
|
+
}
|
|
926
|
+
.remove-project-card .rpm-cancel {
|
|
927
|
+
background: transparent;
|
|
928
|
+
color: var(--tg-text-dim);
|
|
929
|
+
}
|
|
930
|
+
.remove-project-card .rpm-cancel:hover {
|
|
931
|
+
color: var(--tg-text);
|
|
932
|
+
border-color: var(--tg-border-active);
|
|
933
|
+
}
|
|
934
|
+
.remove-project-card .rpm-confirm {
|
|
935
|
+
background: var(--tg-red, #f7768e);
|
|
936
|
+
color: var(--tg-bg);
|
|
937
|
+
border-color: var(--tg-red, #f7768e);
|
|
938
|
+
}
|
|
939
|
+
.remove-project-card .rpm-confirm:hover { filter: brightness(1.1); }
|
|
940
|
+
.remove-project-card .rpm-confirm:disabled {
|
|
941
|
+
opacity: 0.5;
|
|
942
|
+
cursor: not-allowed;
|
|
943
|
+
filter: none;
|
|
944
|
+
}
|
|
945
|
+
|
|
771
946
|
/* ===== Orchestration preview button + modal (Sprint 37 T3) ===== */
|
|
772
947
|
.prompt-preview-project {
|
|
773
948
|
background: transparent;
|
|
@@ -3392,6 +3567,61 @@
|
|
|
3392
3567
|
background: rgba(255, 255, 255, 0.08);
|
|
3393
3568
|
}
|
|
3394
3569
|
|
|
3570
|
+
/* Sprint 43 T1 — second toolbar row: hide-isolated / min-degree / window
|
|
3571
|
+
/ layout selectors. Mirrors .graph-filters spacing but shifts the
|
|
3572
|
+
background a notch darker so the two rows are visually distinguishable
|
|
3573
|
+
and the edge-type chips above feel like their own band. */
|
|
3574
|
+
.graph-filters-row2 {
|
|
3575
|
+
gap: 14px;
|
|
3576
|
+
padding: 6px 16px;
|
|
3577
|
+
background: rgba(0, 0, 0, 0.18);
|
|
3578
|
+
border-bottom: 1px solid var(--tg-border);
|
|
3579
|
+
}
|
|
3580
|
+
.graph-control {
|
|
3581
|
+
display: inline-flex;
|
|
3582
|
+
align-items: center;
|
|
3583
|
+
gap: 6px;
|
|
3584
|
+
font-size: 11px;
|
|
3585
|
+
font-family: var(--tg-mono);
|
|
3586
|
+
color: var(--tg-text-dim);
|
|
3587
|
+
}
|
|
3588
|
+
.graph-control select {
|
|
3589
|
+
background: var(--tg-bg);
|
|
3590
|
+
color: var(--tg-text);
|
|
3591
|
+
border: 1px solid var(--tg-border);
|
|
3592
|
+
border-radius: var(--tg-radius-sm);
|
|
3593
|
+
padding: 3px 6px;
|
|
3594
|
+
font-family: var(--tg-mono);
|
|
3595
|
+
font-size: 11px;
|
|
3596
|
+
cursor: pointer;
|
|
3597
|
+
}
|
|
3598
|
+
.graph-control select:focus {
|
|
3599
|
+
outline: none;
|
|
3600
|
+
border-color: var(--tg-accent);
|
|
3601
|
+
}
|
|
3602
|
+
.graph-control-toggle {
|
|
3603
|
+
cursor: pointer;
|
|
3604
|
+
user-select: none;
|
|
3605
|
+
}
|
|
3606
|
+
.graph-control-toggle input[type="checkbox"] {
|
|
3607
|
+
accent-color: var(--tg-accent);
|
|
3608
|
+
cursor: pointer;
|
|
3609
|
+
}
|
|
3610
|
+
.graph-control-stat {
|
|
3611
|
+
margin-left: auto;
|
|
3612
|
+
font-size: 10px;
|
|
3613
|
+
color: var(--tg-text-dim);
|
|
3614
|
+
font-family: var(--tg-mono);
|
|
3615
|
+
padding: 2px 8px;
|
|
3616
|
+
border: 1px solid transparent;
|
|
3617
|
+
border-radius: 999px;
|
|
3618
|
+
}
|
|
3619
|
+
.graph-control-stat:not(:empty) {
|
|
3620
|
+
background: rgba(122, 162, 247, 0.10);
|
|
3621
|
+
border-color: rgba(122, 162, 247, 0.35);
|
|
3622
|
+
color: var(--tg-text);
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3395
3625
|
.graph-stage {
|
|
3396
3626
|
position: relative;
|
|
3397
3627
|
flex: 1;
|
|
@@ -298,6 +298,54 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
|
|
|
298
298
|
return parsed.projects;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// Remove a project from ~/.termdeck/config.yaml and return the updated projects
|
|
302
|
+
// map. Mirrors addProject for the inverse operation. Throws ENOENT-shaped
|
|
303
|
+
// errors with `code` set so callers can map cleanly to HTTP status. Files on
|
|
304
|
+
// disk at the project's `path` are NEVER touched — this only edits the YAML
|
|
305
|
+
// entry. The user retains all source code.
|
|
306
|
+
function removeProject(name, configPath = CONFIG_PATH) {
|
|
307
|
+
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
308
|
+
const err = new Error('Project name must be non-empty and contain only letters, digits, . _ or -');
|
|
309
|
+
err.code = 'BAD_NAME';
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const yaml = require('yaml');
|
|
314
|
+
let parsed = {};
|
|
315
|
+
if (fs.existsSync(configPath)) {
|
|
316
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
317
|
+
try {
|
|
318
|
+
parsed = yaml.parse(raw) || {};
|
|
319
|
+
} catch (err) {
|
|
320
|
+
throw new Error(`config.yaml is not valid YAML — cannot safely rewrite: ${err.message}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!parsed.projects || typeof parsed.projects !== 'object' || !parsed.projects[name]) {
|
|
325
|
+
const err = new Error(`Project "${name}" not found in config.yaml`);
|
|
326
|
+
err.code = 'NOT_FOUND';
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
delete parsed.projects[name];
|
|
331
|
+
|
|
332
|
+
if (fs.existsSync(configPath)) {
|
|
333
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
334
|
+
const bak = `${configPath}.${ts}.bak`;
|
|
335
|
+
try {
|
|
336
|
+
fs.copyFileSync(configPath, bak);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.warn('[config] Could not write backup before removing project:', err.message);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const out = yaml.stringify(parsed);
|
|
343
|
+
fs.writeFileSync(configPath, out, 'utf-8');
|
|
344
|
+
console.log(`[config] Removed project "${name}" (files on disk untouched)`);
|
|
345
|
+
|
|
346
|
+
return parsed.projects;
|
|
347
|
+
}
|
|
348
|
+
|
|
301
349
|
// Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
|
|
302
350
|
// this for the dashboard RAG toggle (PATCH /api/config) but the helper is
|
|
303
351
|
// generic — pass a deep partial of the config tree, every leaf in `patch` that
|
|
@@ -394,6 +442,7 @@ function updateConfig(patch, configPath = CONFIG_PATH) {
|
|
|
394
442
|
module.exports = {
|
|
395
443
|
loadConfig,
|
|
396
444
|
addProject,
|
|
445
|
+
removeProject,
|
|
397
446
|
updateConfig,
|
|
398
447
|
// exported for tests / introspection
|
|
399
448
|
_parseDotenv: parseDotenv,
|
|
@@ -2,12 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
// Sprint 43 T2: load a numbered migration .sql file from the repo-root
|
|
8
|
+
// `migrations/` directory if present, falling back to an inline string for
|
|
9
|
+
// packaged npm installs where the `files` allowlist in package.json does not
|
|
10
|
+
// ship `migrations/`. Authoritative source-of-truth is the .sql file; the
|
|
11
|
+
// fallback is kept in lockstep when the schema changes.
|
|
12
|
+
function loadMigrationSql(name, fallback) {
|
|
13
|
+
const candidates = [
|
|
14
|
+
path.join(__dirname, '..', '..', '..', 'migrations', name),
|
|
15
|
+
path.join(__dirname, '..', '..', '..', '..', 'migrations', name),
|
|
16
|
+
];
|
|
17
|
+
for (const candidate of candidates) {
|
|
18
|
+
try {
|
|
19
|
+
const sql = fs.readFileSync(candidate, 'utf8');
|
|
20
|
+
if (sql && sql.trim().length) return sql;
|
|
21
|
+
} catch (_e) { /* try next */ }
|
|
22
|
+
}
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FLASHBACK_EVENTS_INLINE_SQL = `
|
|
27
|
+
CREATE TABLE IF NOT EXISTS flashback_events (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
fired_at TEXT NOT NULL,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
project TEXT,
|
|
32
|
+
error_text TEXT NOT NULL,
|
|
33
|
+
hits_count INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
top_hit_id TEXT,
|
|
35
|
+
top_hit_score REAL,
|
|
36
|
+
dismissed_at TEXT,
|
|
37
|
+
clicked_through INTEGER NOT NULL DEFAULT 0
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS flashback_events_fired_at_idx
|
|
40
|
+
ON flashback_events(fired_at DESC);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS flashback_events_session_idx
|
|
42
|
+
ON flashback_events(session_id);
|
|
43
|
+
`;
|
|
5
44
|
|
|
6
45
|
function initDatabase(Database) {
|
|
7
46
|
const dbPath = path.join(os.homedir(), '.termdeck', 'termdeck.db');
|
|
8
47
|
|
|
9
48
|
// Ensure directory exists
|
|
10
|
-
const fs = require('fs');
|
|
11
49
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
12
50
|
|
|
13
51
|
const db = new Database(dbPath);
|
|
@@ -115,6 +153,16 @@ function initDatabase(Database) {
|
|
|
115
153
|
console.warn('[db] projects.default_theme drop migration failed:', err.message);
|
|
116
154
|
}
|
|
117
155
|
|
|
156
|
+
// Sprint 43 T2: flashback_events durable audit table. Schema lives in
|
|
157
|
+
// migrations/001_flashback_events.sql (repo-root, source-of-truth) with
|
|
158
|
+
// an inline fallback for packaged installs. Idempotent CREATE so this
|
|
159
|
+
// replays safely on every server start.
|
|
160
|
+
try {
|
|
161
|
+
db.exec(loadMigrationSql('001_flashback_events.sql', FLASHBACK_EVENTS_INLINE_SQL));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.warn('[db] flashback_events migration failed:', err.message);
|
|
164
|
+
}
|
|
165
|
+
|
|
118
166
|
return db;
|
|
119
167
|
}
|
|
120
168
|
|
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
// Flashback diagnostic ring buffer (Sprint 39 T1)
|
|
1
|
+
// Flashback diagnostic ring buffer (Sprint 39 T1) + durable audit table
|
|
2
|
+
// (Sprint 43 T2).
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
// here so production-flow regressions surface as a readable timeline instead
|
|
5
|
-
// of a silent gate failure. The ring is in-memory and lost on restart by
|
|
6
|
-
// design — persistence is a Sprint-40+ concern. Public surface:
|
|
4
|
+
// Two layers of observability for the Flashback pipeline:
|
|
7
5
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// (1) IN-MEMORY RING — six decision points along the pipeline write
|
|
7
|
+
// structured events to a 200-event ring. Lost on restart. Powers the
|
|
8
|
+
// /api/flashback/diag endpoint and the live diagnostic UI. This is
|
|
9
|
+
// fine-grained: every pattern match, every rate-limit hit, every
|
|
10
|
+
// bridge query gets logged.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// (2) SQLITE AUDIT TABLE (flashback_events) — every actual fire (the
|
|
13
|
+
// moment a proactive_memory frame is sent over WS to the user's
|
|
14
|
+
// panel) gets one durable row. Survives restart. Powers the
|
|
15
|
+
// /flashback-history.html dashboard and the click-through funnel.
|
|
16
|
+
// This is coarse-grained: one row per fire, plus dismiss/click-through
|
|
17
|
+
// outcome.
|
|
18
|
+
//
|
|
19
|
+
// Public surface:
|
|
20
|
+
//
|
|
21
|
+
// In-memory ring (Sprint 39):
|
|
22
|
+
// log({ sessionId, event, ...fields }) — append one event
|
|
23
|
+
// snapshot({ sessionId?, eventType?, limit? }) — read back filtered tail
|
|
24
|
+
// _resetForTest() — test-only ring clear
|
|
25
|
+
//
|
|
26
|
+
// SQLite audit (Sprint 43 T2):
|
|
27
|
+
// recordFlashback(db, { sessionId, project, error_text, hits_count,
|
|
28
|
+
// top_hit_id, top_hit_score, fired_at? }) → id
|
|
29
|
+
// markDismissed(db, eventId, dismissedAt?) → bool
|
|
30
|
+
// markClickedThrough(db, eventId) → bool
|
|
31
|
+
// getRecentFlashbacks(db, { since?, limit? }) → row[]
|
|
32
|
+
// getFunnelStats(db, { since? }) → { fires, dismissed, clicked_through }
|
|
33
|
+
//
|
|
34
|
+
// Event shape (ring): { ts, sessionId, event, ...event-specific fields }.
|
|
13
35
|
//
|
|
14
36
|
// Event types and their producers:
|
|
15
37
|
// pattern_match — session.js _detectErrors (PATTERNS.error /
|
|
@@ -21,9 +43,13 @@
|
|
|
21
43
|
// bridge_result — mnestra-bridge queryMnestra at call return
|
|
22
44
|
// proactive_memory_emit — index.js onErrorDetected WS send block
|
|
23
45
|
//
|
|
24
|
-
// The
|
|
25
|
-
//
|
|
26
|
-
//
|
|
46
|
+
// The audit table is an EXTENSION of the ring, not a replacement. Ring stays
|
|
47
|
+
// for the live UI; SQLite is for the historical question "did flashback fire
|
|
48
|
+
// when I needed it, and did I act on it?"
|
|
49
|
+
//
|
|
50
|
+
// SQLite functions are SAFE when db is null/undefined: they no-op and return
|
|
51
|
+
// null/false/[] so test fixtures and Database-unavailable installs don't
|
|
52
|
+
// break the live emit path.
|
|
27
53
|
|
|
28
54
|
const RING_SIZE = 200;
|
|
29
55
|
|
|
@@ -48,4 +74,152 @@ function _resetForTest() {
|
|
|
48
74
|
ring = [];
|
|
49
75
|
}
|
|
50
76
|
|
|
51
|
-
|
|
77
|
+
// ---- SQLite audit (Sprint 43 T2) ----------------------------------------
|
|
78
|
+
|
|
79
|
+
// Persists one row per actual flashback fire. Returns the inserted row id
|
|
80
|
+
// (number) or null when persistence is unavailable. Errors are caught and
|
|
81
|
+
// logged — flashback persistence must never break the live emit path.
|
|
82
|
+
function recordFlashback(db, event) {
|
|
83
|
+
if (!db) return null;
|
|
84
|
+
if (!event || (!event.sessionId && !event.session_id)) return null;
|
|
85
|
+
try {
|
|
86
|
+
const fired_at = event.fired_at || new Date().toISOString();
|
|
87
|
+
const session_id = event.session_id || event.sessionId;
|
|
88
|
+
const hits_count = Number.isFinite(event.hits_count) ? event.hits_count : 0;
|
|
89
|
+
const top_hit_score = (typeof event.top_hit_score === 'number'
|
|
90
|
+
&& Number.isFinite(event.top_hit_score)) ? event.top_hit_score : null;
|
|
91
|
+
const result = db.prepare(`
|
|
92
|
+
INSERT INTO flashback_events
|
|
93
|
+
(fired_at, session_id, project, error_text, hits_count,
|
|
94
|
+
top_hit_id, top_hit_score)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
`).run(
|
|
97
|
+
fired_at,
|
|
98
|
+
session_id,
|
|
99
|
+
event.project || null,
|
|
100
|
+
event.error_text || '',
|
|
101
|
+
hits_count,
|
|
102
|
+
event.top_hit_id || null,
|
|
103
|
+
top_hit_score,
|
|
104
|
+
);
|
|
105
|
+
// better-sqlite3 returns BigInt for lastInsertRowid; coerce to Number
|
|
106
|
+
// so it serializes naturally into JSON and the WS frame.
|
|
107
|
+
return Number(result.lastInsertRowid);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.warn('[flashback-diag] recordFlashback INSERT failed:', err.message);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Marks an event as dismissed (toast went away — by user, by 30s timeout,
|
|
115
|
+
// or implicitly via click-through). Idempotent: only writes when
|
|
116
|
+
// dismissed_at is currently NULL, so the FIRST dismiss wins. Returns true
|
|
117
|
+
// when a row was actually updated.
|
|
118
|
+
function markDismissed(db, eventId, dismissedAt) {
|
|
119
|
+
if (!db || !eventId) return false;
|
|
120
|
+
const id = Number(eventId);
|
|
121
|
+
if (!Number.isFinite(id) || id <= 0) return false;
|
|
122
|
+
try {
|
|
123
|
+
const ts = dismissedAt || new Date().toISOString();
|
|
124
|
+
const result = db.prepare(`
|
|
125
|
+
UPDATE flashback_events
|
|
126
|
+
SET dismissed_at = ?
|
|
127
|
+
WHERE id = ? AND dismissed_at IS NULL
|
|
128
|
+
`).run(ts, id);
|
|
129
|
+
return result.changes > 0;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.warn('[flashback-diag] markDismissed UPDATE failed:', err.message);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Marks an event as clicked-through (user opened the modal). Click-through
|
|
137
|
+
// is also an implicit dismiss, so if dismissed_at is still NULL we set it
|
|
138
|
+
// at the same moment. Idempotent: clicking twice is a no-op on the second
|
|
139
|
+
// pass. Returns true when a row was actually updated.
|
|
140
|
+
function markClickedThrough(db, eventId) {
|
|
141
|
+
if (!db || !eventId) return false;
|
|
142
|
+
const id = Number(eventId);
|
|
143
|
+
if (!Number.isFinite(id) || id <= 0) return false;
|
|
144
|
+
try {
|
|
145
|
+
const ts = new Date().toISOString();
|
|
146
|
+
const result = db.prepare(`
|
|
147
|
+
UPDATE flashback_events
|
|
148
|
+
SET clicked_through = 1,
|
|
149
|
+
dismissed_at = COALESCE(dismissed_at, ?)
|
|
150
|
+
WHERE id = ? AND clicked_through = 0
|
|
151
|
+
`).run(ts, id);
|
|
152
|
+
return result.changes > 0;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn('[flashback-diag] markClickedThrough UPDATE failed:', err.message);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reads the most-recent N flashback fires, optionally filtered to events
|
|
160
|
+
// fired at-or-after the `since` ISO timestamp. Hard cap of 500 rows so
|
|
161
|
+
// pathological queries can't OOM the dashboard.
|
|
162
|
+
function getRecentFlashbacks(db, { since, limit } = {}) {
|
|
163
|
+
if (!db) return [];
|
|
164
|
+
try {
|
|
165
|
+
const cap = Math.max(1, Math.min(500, Number(limit) || 100));
|
|
166
|
+
const cols = `id, fired_at, session_id, project, error_text, hits_count,
|
|
167
|
+
top_hit_id, top_hit_score, dismissed_at, clicked_through`;
|
|
168
|
+
if (since) {
|
|
169
|
+
return db.prepare(
|
|
170
|
+
`SELECT ${cols} FROM flashback_events
|
|
171
|
+
WHERE fired_at >= ?
|
|
172
|
+
ORDER BY fired_at DESC
|
|
173
|
+
LIMIT ?`
|
|
174
|
+
).all(since, cap);
|
|
175
|
+
}
|
|
176
|
+
return db.prepare(
|
|
177
|
+
`SELECT ${cols} FROM flashback_events
|
|
178
|
+
ORDER BY fired_at DESC
|
|
179
|
+
LIMIT ?`
|
|
180
|
+
).all(cap);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.warn('[flashback-diag] getRecentFlashbacks SELECT failed:', err.message);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Click-through funnel aggregates: total fires, dismissed (any reason),
|
|
188
|
+
// clicked-through (modal opened). Optional `since` ISO timestamp filter.
|
|
189
|
+
// All three are scalar counts — the dashboard renders them as a percentage
|
|
190
|
+
// funnel chart.
|
|
191
|
+
function getFunnelStats(db, { since } = {}) {
|
|
192
|
+
const empty = { fires: 0, dismissed: 0, clicked_through: 0 };
|
|
193
|
+
if (!db) return empty;
|
|
194
|
+
try {
|
|
195
|
+
const where = since ? `WHERE fired_at >= ?` : '';
|
|
196
|
+
const args = since ? [since] : [];
|
|
197
|
+
const row = db.prepare(
|
|
198
|
+
`SELECT
|
|
199
|
+
COUNT(*) AS fires,
|
|
200
|
+
SUM(CASE WHEN dismissed_at IS NOT NULL THEN 1 ELSE 0 END) AS dismissed,
|
|
201
|
+
SUM(CASE WHEN clicked_through = 1 THEN 1 ELSE 0 END) AS clicked_through
|
|
202
|
+
FROM flashback_events ${where}`
|
|
203
|
+
).get(...args);
|
|
204
|
+
return {
|
|
205
|
+
fires: Number(row?.fires || 0),
|
|
206
|
+
dismissed: Number(row?.dismissed || 0),
|
|
207
|
+
clicked_through: Number(row?.clicked_through || 0),
|
|
208
|
+
};
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.warn('[flashback-diag] getFunnelStats SELECT failed:', err.message);
|
|
211
|
+
return empty;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
log,
|
|
217
|
+
snapshot,
|
|
218
|
+
_resetForTest,
|
|
219
|
+
RING_SIZE,
|
|
220
|
+
recordFlashback,
|
|
221
|
+
markDismissed,
|
|
222
|
+
markClickedThrough,
|
|
223
|
+
getRecentFlashbacks,
|
|
224
|
+
getFunnelStats,
|
|
225
|
+
};
|