-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
2864 lines (2601 loc) · 130 KB
/
Copy pathserver.js
File metadata and controls
2864 lines (2601 loc) · 130 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const express = require('express');
const { spawn, execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const { pathToFileURL } = require('url');
const crypto = require('node:crypto');
const { DatabaseSync } = require('node:sqlite');
const ai = require('./ai');
const delphix = require('./delphix');
const app = express();
app.use(express.json());
const DIST = path.join(__dirname, 'frontend', 'dist');
app.use(express.static(DIST));
// ── Version ───────────────────────────────────────────────────────────────────
/**
* What version this is, resolved once at startup.
*
* The git tag is the authority, not package.json: the installer checks out
* `--depth 1 --branch vX.Y.Z` detached and `update` fetches the tag by name, so an installed
* tree always carries the exact tag it was installed from. package.json has to be bumped by
* hand and has already drifted once, so it is only the fallback — for a zip download, or a
* fork that has never tagged.
*
* Nothing here touches the network. The version shown is the one on disk; there is no check
* for a newer release, deliberately — the tool does not call home.
*/
function detectVersion() {
const pkgVersion = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version || null;
} catch { return null; }
})();
const git = (...args) => execFileSync('git', ['-C', __dirname, ...args],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
try {
const commit = git('rev-parse', '--short', 'HEAD');
// On a release tag exactly: that tag is the version. --dirty still matters here — a tree
// sitting on the tag with edits on top is not that release, and claiming it is would send
// a bug report chasing code the reporter does not actually have.
try {
const tag = git('describe', '--tags', '--exact-match', '--dirty');
if (!tag.endsWith('-dirty')) return { display: tag, commit, channel: 'release' };
return { display: tag, commit, channel: 'dev' };
} catch { /* not sitting on a tag — fall through to the development form */ }
// Otherwise the nearest tag plus the distance from it, which says "ahead of v1.0.3"
// rather than pretending to be a release. --always keeps this working before the first tag.
let described = null;
try { described = git('describe', '--tags', '--always', '--dirty'); } catch { /* no tags at all */ }
return { display: described || commit, commit, channel: 'dev' };
} catch {
// No git, or not a checkout: a zip download, or git missing from PATH at runtime.
return { display: pkgVersion, commit: null, channel: 'unknown' };
}
}
const VERSION = detectVersion();
/** Never fails and never blocks: VERSION was resolved at startup. */
app.get('/api/version', (req, res) => res.json(VERSION));
// ── Config ────────────────────────────────────────────────────────────────────
// The masking key is a project constant, not a setting: it is deliberately not exposed
// through /api/config and cannot be changed from the UI. Every deterministic algorithm
// derives its output from it, so changing it changes every masked value the tool produces
// — including the pairs printed in the reference guide. See the internal repository's
// documentation for what this key is and why it is fixed.
const MASKING_KEY = 'delphix-default-key';
const DEFAULT_CONFIG = {
filesDir: path.join(__dirname, 'test-files'),
// 'auto' | 'en' | 'pt-BR' | 'es' — 'auto' lets the browser language decide.
locale: 'auto',
...ai.DEFAULTS,
...delphix.DEFAULTS,
};
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// ── Paths ─────────────────────────────────────────────────────────────────────
const LIB_DIR = path.join(__dirname, 'lib');
const RUNNER_JAR = path.join(__dirname, 'java-runner', 'AlgorithmRunner.jar');
// The jars are NOT distributed with this repository — they are Delphix product files.
// Ask Delphix for the Masking Devkit (SDK) and copy these into lib/. Matching is by
// prefix, so any SDK version works; see the "Delphix libraries" section in the README.
const REQUIRED_JARS = [
'delphix-algorithm-plugin', 'masking-extensibility-api',
'jackson-annotations', 'jackson-core', 'jackson-databind',
'jackson-datatype-jdk8', 'jackson-datatype-jsr310', 'jackson-module-jsonSchema',
'guava', 'failureaccess', 'ant-', 'commons-codec', 'commons-compiler',
'commons-lang-', 'janino',
];
function listJars() {
if (!fs.existsSync(LIB_DIR)) return [];
return fs.readdirSync(LIB_DIR).filter(f => f.endsWith('.jar')).sort();
}
/** Required jar prefixes with nothing in lib/ matching them. */
function missingJars() {
const jars = listJars();
return REQUIRED_JARS.filter(prefix => !jars.some(j => j.startsWith(prefix)));
}
/** The plugin jar is whichever delphix-algorithm-plugin-*.jar was dropped in lib/. */
function findPluginJar() {
if (process.env.DLPX_PLUGIN_JAR) return process.env.DLPX_PLUGIN_JAR;
const jar = listJars().find(f => f.startsWith('delphix-algorithm-plugin'));
return jar ? path.join(LIB_DIR, jar) : null;
}
const SETUP_HINT =
'The Delphix jars are missing from lib/. They are not distributed with this repository: '
+ 'request the Masking Devkit (SDK) from Delphix and copy the jars listed in the README '
+ '("Delphix libraries") into lib/.';
function buildClasspath() {
const jars = listJars().map(f => path.join(LIB_DIR, f));
// path.delimiter, never a literal ':' — Windows separates classpath entries with ';' and
// reads a ':' joined path as one bogus entry, so every command fails with the runner class
// not found.
return [RUNNER_JAR, ...jars].join(path.delimiter);
}
// ── Java runner ───────────────────────────────────────────────────────────────
function runJava(request) {
return new Promise((resolve, reject) => {
const pluginJar = findPluginJar();
if (!pluginJar) return reject(new Error(SETUP_HINT));
const cp = buildClasspath();
const proc = spawn('java', [
`-Dplugin.jar=${pluginJar}`,
// Where the runner looks for a lookup file whose configuration names one held by a
// Masking Engine — an imported algorithm carries the reference, never the contents.
`-Dfiles.dir=${path.resolve(__dirname, readConfig().filesDir)}`,
'-cp', cp,
'AlgorithmRunner'
]);
let stdout = '';
let stderr = '';
proc.stdout.on('data', d => stdout += d);
proc.stderr.on('data', d => stderr += d);
proc.on('close', code => {
if (!stdout.trim()) {
return reject(new Error(stderr.trim() || `Java process exited with code ${code}`));
}
try {
resolve(JSON.parse(stdout.trim()));
} catch {
reject(new Error(`Invalid JSON from runner: ${stdout.trim()}`));
}
});
proc.on('error', err => reject(new Error(`Failed to start Java: ${err.message}`)));
proc.stdin.write(JSON.stringify(request));
proc.stdin.end();
});
}
// ── Database ──────────────────────────────────────────────────────────────────
const DB_PATH = path.join(__dirname, 'db', 'algorithms.db');
const LEGACY_DB_PATH = path.join(__dirname, 'db', 'tests.db');
ensureDir(path.dirname(DB_PATH));
// The file used to be tests.db, from when a saved configuration was called a test. Aligning the
// vocabulary with Delphix — a framework is configured into an algorithm — renamed it. An existing
// install is moved rather than left behind, which would silently start it with an empty list.
if (!fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
fs.renameSync(LEGACY_DB_PATH, DB_PATH);
console.log(' ↻ db/tests.db renamed to db/algorithms.db');
}
const db = new DatabaseSync(DB_PATH);
// Same rename, one level down: the table and the column that names the plugin class.
// ALTER TABLE keeps the rows, so nothing has to be re-imported from the engine.
const tableNames = db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name);
if (tableNames.includes('saved_tests') && !tableNames.includes('saved_algorithms')) {
db.exec('ALTER TABLE saved_tests RENAME TO saved_algorithms');
const cols = db.prepare(`SELECT name FROM pragma_table_info('saved_algorithms')`)
.all().map((r) => r.name);
if (cols.includes('algorithm') && !cols.includes('framework')) {
db.exec('ALTER TABLE saved_algorithms RENAME COLUMN algorithm TO framework');
}
console.log(' ↻ saved_tests migrated to saved_algorithms');
}
// The database holds the engine password and the AI keys in the clear, so it must not be
// readable by other accounts on the machine. This is the protection that actually applies to
// a local tool — the same posture as ~/.aws/credentials or ~/.npmrc. Encrypting the file with
// a key that lives beside it would obscure the values without protecting them.
try {
fs.chmodSync(DB_PATH, 0o600);
} catch (err) {
console.warn(` ⚠ Could not restrict permissions on ${DB_PATH}: ${err.message}`);
}
db.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- The identity, as on the engine, where it travels in the URL path and cannot be renamed.
name TEXT NOT NULL UNIQUE,
-- Both are algorithmName values, exactly as the engine stores them: a domain is a name
-- and two references. Kept as text, not a foreign key, because a domain may point at a
-- built-in the engine has and this machine does not.
default_algorithm TEXT NOT NULL DEFAULT '',
default_tokenization TEXT NOT NULL DEFAULT '',
delphix_origin TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS classifiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Unique, as on the engine — but not identity there: the engine renames a classifier in
-- place and identifies it by delphix_id.
name TEXT NOT NULL UNIQUE,
-- PATH | TYPE | REGEX | LIST. Fixed once created, as the engine keeps it.
framework TEXT NOT NULL,
-- The domain the classifier votes for, by name; text for the same reason a domain's
-- algorithm is text: it may exist only on the engine.
domain_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
config TEXT NOT NULL DEFAULT '{}',
delphix_id INTEGER,
delphix_origin TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS profile_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Unique here and on the engine; like a classifier, the engine renames it in place and
-- identifies it by delphix_id.
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
-- The confidence a domain must reach, 1-100, for a profiling job to assign it. The engine
-- falls back to its own asdd/DefaultAssignmentThreshold when a set carries none.
assignment_threshold INTEGER NOT NULL DEFAULT 80,
delphix_id INTEGER,
delphix_origin TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Which classifiers a set runs. The engine holds the same thing as classifierIds[]; here it is
-- a table so deleting a classifier cannot leave an id behind that points at nothing.
CREATE TABLE IF NOT EXISTS profile_set_classifiers (
profile_set_id INTEGER NOT NULL,
classifier_id INTEGER NOT NULL,
PRIMARY KEY (profile_set_id, classifier_id)
);
CREATE TABLE IF NOT EXISTS saved_algorithms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
framework TEXT NOT NULL,
display_name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
input TEXT NOT NULL DEFAULT '',
key_value TEXT NOT NULL DEFAULT '',
output TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Where this algorithm lives on a Delphix engine, when it came from one or was pushed to one.
// Kept as its own migration so existing databases gain the columns without being recreated.
for (const [col, decl] of [
['delphix_name', 'TEXT'], // algorithmName on the engine — exporting again updates it
['delphix_origin', 'TEXT'], // the engine's API root, so a name is not reused across engines
]) {
const has = db.prepare(`SELECT COUNT(*) AS n FROM pragma_table_info('saved_algorithms') WHERE name = ?`).get(col);
if (!has.n) db.exec(`ALTER TABLE saved_algorithms ADD COLUMN ${col} ${decl}`);
}
// The files this tool has put in each engine's upload store, by content, so sending the same file
// again reuses its address instead of piling up copies there. Only a shortcut: an entry is used
// after the engine confirms the address still resolves.
db.exec(`
CREATE TABLE IF NOT EXISTS engine_uploads (
origin TEXT NOT NULL,
sha256 TEXT NOT NULL,
name TEXT NOT NULL,
reference TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (origin, sha256, name)
)
`);
// ── Config helpers (DB-backed) ────────────────────────────────────────────────
// A secret may come from the environment instead of the database, for anyone who would rather
// not have it on disk at all. The names are namespaced so nothing is adopted by accident.
const SECRET_ENV = {
'delphix.password': 'DLPX_ENGINE_PASSWORD',
'ai.anthropic.apiKey': 'DLPX_AI_ANTHROPIC_KEY',
'ai.gemini.apiKey': 'DLPX_AI_GEMINI_KEY',
'ai.copilot.apiKey': 'DLPX_AI_COPILOT_KEY',
};
/** Which secrets the environment is currently supplying. */
function fromEnv() {
const out = {};
for (const [key, name] of Object.entries(SECRET_ENV)) {
if (process.env[name]) out[key] = process.env[name];
}
return out;
}
/** Config as it is on disk — no environment overlay. This is what may be written back. */
function readStoredConfig() {
const rows = db.prepare('SELECT key, value FROM config').all();
const stored = Object.fromEntries(rows.map(r => [r.key, r.value]));
delete stored.globalKey; // left over from when the key was editable; the constant wins
return { ...DEFAULT_CONFIG, ...stored };
}
/**
* Config as the app should use it. The environment wins over the database, which is how a
* secret is kept off disk entirely — so this must never be the basis of a write, or the
* value it was meant to keep out of the file would be saved straight into it.
*/
function readConfig() {
return { ...readStoredConfig(), ...fromEnv() };
}
function writeConfig(config) {
const stmt = db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)');
for (const [k, v] of Object.entries(config)) stmt.run(k, String(v));
}
// The key used to be an editable setting; drop any value older installs stored.
db.prepare("DELETE FROM config WHERE key = 'globalKey'").run();
ensureDir(path.resolve(__dirname, readConfig().filesDir));
// ── API Routes ────────────────────────────────────────────────────────────────
// Config
const KEY_MASK = '\u2022'.repeat(8);
const isApiKey = (k) =>
(k.startsWith('ai.') && k.endsWith('.apiKey')) || k === 'delphix.password';
/** Replaces stored API keys with a mask, plus a `<key>.set` flag so the UI can show state. */
function publicConfig(config) {
const env = fromEnv();
const out = {};
for (const [k, v] of Object.entries(config)) {
if (isApiKey(k)) {
out[k] = v ? KEY_MASK : '';
out[`${k}.set`] = Boolean(v);
// Saving over an environment-provided secret would have no effect; the UI says so.
if (env[k]) out[`${k}.fromEnv`] = SECRET_ENV[k];
} else {
out[k] = v;
}
}
return out;
}
app.get('/api/config', (req, res) => {
res.json(publicConfig(readConfig()));
});
app.put('/api/config', (req, res) => {
const current = readStoredConfig(); // never readConfig(): env secrets must not be persisted
const patch = {};
for (const [k, v] of Object.entries(req.body)) {
if (k === 'globalKey') continue; // constant, never settable
if (k.endsWith('.set') || k.endsWith('.fromEnv')) continue; // read-only UI flags
if (SECRET_ENV[k] && process.env[SECRET_ENV[k]]) continue; // the environment owns it
if (isApiKey(k) && v === KEY_MASK) continue; // untouched masked field
patch[k] = v;
}
const updated = { ...current, ...patch };
const warmKeys = ['aiProvider', 'ai.ollama.model', 'ai.ollama.baseUrl', 'ai.ollama.numCtx'];
// keepAlive is deliberately not here: it changes how long the model lingers, not the prompt.
const rewarm = warmKeys.some((k) => k in patch && patch[k] !== current[k]);
writeConfig(updated);
ensureDir(path.resolve(__dirname, updated.filesDir));
// A model the user just switched to is cold, and the wait is the same minutes it is at
// startup. Start it now so the chat's indicator has something true to show.
if (rewarm) buildCatalogCached().then(warmProvider, () => {});
res.json(publicConfig(readConfig()));
});
// ── AI assistant ─────────────────────────────────────────────────────────────
// The catalog needs one JVM fork per framework, so it is built once and reused.
// Warmed in the background at startup so the first chat message isn't slow.
const buildCatalogCached = () => ai.buildCatalog(runJava);
// Local models are not given the algorithm-writing button — see buildSystemPrompt. This lives in
// one place because the warm-up and the chat must build a byte-identical prompt: a prefix that
// differs by one character is a cache miss, and a cache miss here costs minutes.
const canSaveWith = (cfg) => cfg.id !== 'ollama';
const systemFor = (cfg, catalog) => ai.buildSystemPrompt(catalog, { canSave: canSaveWith(cfg) });
buildCatalogCached().then(
(c) => {
console.log(`AI: framework catalog ready (${(c.length / 1024).toFixed(0)} KB)`);
warmProvider(c);
},
(err) => console.warn(`AI: could not build the framework catalog — ${err.message}`)
);
// What the warm-up is doing right now, so the UI can say so. Minutes of silence before the
// first answer look exactly like a hung app, and the user has no way to tell the difference
// from the outside - this is what lets the chat say "loading the model", not "…".
const warmth = {
state: 'idle', model: null, startedAt: null, ms: null, promptTokens: null, error: null,
// What is being warmed, not just which model: num_ctx and the endpoint decide the cache as
// much as the name does, so a change to either has to start over. Kept with the controller
// that cancels the run it belongs to.
key: null, controller: null,
};
/** Everything that invalidates Ollama's cached prefix, as one comparable string. */
const warmKeyOf = (cfg) => `${cfg.baseUrl}|${cfg.model}|${cfg.numCtx}`;
/**
* Reads the system prompt into a local model before anyone asks a question. On a machine
* without a GPU that pass costs minutes - the catalog is ~12k tokens - and it is paid once
* per loaded model rather than once per question, so paying it here means the first question
* is answered at the same speed as the tenth. Best effort by design: Ollama may not be
* running, and that is the status card's job to report, not a reason to hold up the server.
*/
function warmProvider(catalog) {
const cfg = ai.providerSettings(readConfig(), readConfig().aiProvider);
if (cfg.id !== 'ollama') {
Object.assign(warmth, { state: 'idle', model: null, startedAt: null, error: null });
return;
}
const key = warmKeyOf(cfg);
if (warmth.state === 'warming' && warmth.key === key) return;
// A warm-up already running for different settings is now heating the wrong thing, and it
// would hold a core for minutes doing it. Drop it.
if (warmth.state === 'warming') warmth.controller?.abort();
const controller = new AbortController();
Object.assign(warmth, {
state: 'warming', model: cfg.model, startedAt: Date.now(), ms: null, promptTokens: null,
error: null, key, controller,
});
console.log(`AI: warming ${cfg.model} …`);
ai.warmOllama({ cfg, system: systemFor(cfg, catalog), signal: controller.signal }).then(
({ ms, promptTokens }) => {
if (warmth.key !== key) return; // superseded by a newer warm-up
Object.assign(warmth, { state: 'ready', ms, promptTokens });
console.log(`AI: ${cfg.model} warm in ${(ms / 1000).toFixed(1)}s`
+ `${promptTokens ? ` (${promptTokens} prompt tokens cached)` : ''}`);
},
(err) => {
if (warmth.key !== key) return; // cancelled on purpose, or superseded
Object.assign(warmth, { state: 'failed', error: err.message });
console.warn(`AI: could not warm ${cfg.model} — ${err.message}`);
}
);
}
/** The warm-up as the UI needs it: state, which model, and how long it has been going. */
function warmStatus() {
return {
state: warmth.state,
model: warmth.model,
elapsedSec: warmth.state === 'warming' && warmth.startedAt
? Math.round((Date.now() - warmth.startedAt) / 1000)
: null,
seconds: warmth.ms != null ? Math.round(warmth.ms / 1000) : null,
error: warmth.error,
};
}
app.get('/api/ai/status', async (req, res) => {
const config = readConfig();
const cfg = ai.providerSettings(config, config.aiProvider);
const status = await ai.probe(cfg);
res.json({ provider: cfg.id, model: cfg.model, baseUrl: cfg.baseUrl, ...status, warm: warmStatus() });
});
/** Runs the algorithm the model proposed; only a configuration that actually masks gets saved. */
async function validateAndSave(spec) {
if (!spec || typeof spec !== 'object') return { error: 'Malformed algorithm block.' };
const { name, className, config, testInput } = spec;
if (!name || !className) return { error: 'The algorithm block is missing name or className.' };
const list = await runJava({ command: 'list' });
const fw = list.find((f) => f.className === className)
|| list.find((f) => f.className.split('.').pop() === String(className).split('.').pop());
if (!fw) return { error: `Unknown framework: ${className}` };
// Without a value to mask there is no validation: every framework "runs" on an empty string,
// so a configuration that solves nothing would be saved as if the runner had approved it.
// A model that omits testInput has skipped the only step that can tell right from plausible.
const input = typeof testInput === 'string' ? testInput.trim() : '';
if (!input) {
return { error: 'The algorithm block has no testInput, so the configuration could not be '
+ 'validated. Ask the assistant again, requesting a sample value to test with.' };
}
let result;
try {
result = await runJava({ command: 'mask', framework: fw.className, config: config || {}, input, key: MASKING_KEY });
} catch (err) {
return { error: `The configuration failed to run: ${err.message}` };
}
if (result.error) return { error: `The configuration failed to run: ${result.error}` };
const stmt = db.prepare(`
INSERT INTO saved_algorithms (name, framework, display_name, config, input, key_value, output)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(name, fw.className, fw.displayName,
JSON.stringify(config || {}), input, MASKING_KEY, result.output ?? null);
return {
saved: {
id: Number(info.lastInsertRowid), name, className: fw.className,
displayName: fw.displayName, input, output: result.output ?? null,
},
};
}
/**
* Asks the model for the configuration again, this time with the framework's schema constraining
* what it is able to emit. Returns a new spec, or null when there is nothing better to try.
*/
async function repairSpec(spec, error, messages, cfg, signal) {
const list = await runJava({ command: 'list' });
const fw = list.find((f) => f.className === spec.className);
if (!fw) return null; // an unknown className is not a configuration problem
let schema;
try {
({ schema } = await runJava({ command: 'schema', framework: fw.className }));
} catch {
return null;
}
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
const config = await ai.repairConfig({
cfg, schema, framework: fw.displayName,
request: lastUser ? lastUser.content : '',
badConfig: spec.config || {}, error, signal,
});
return config ? { ...spec, config } : null;
}
app.post('/api/chat', async (req, res) => {
const config = readConfig();
const cfg = ai.providerSettings(config, config.aiProvider);
const messages = Array.isArray(req.body.messages) ? req.body.messages : [];
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
// Abort on client disconnect. This must listen on the response: on the request,
// 'close' fires as soon as the POST body has been consumed.
const controller = new AbortController();
res.on('close', () => controller.abort());
try {
const catalog = await buildCatalogCached();
const full = await ai.streamChat({
cfg,
system: systemFor(cfg, catalog),
messages: messages.map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content ?? '') })),
onDelta: (delta) => send('delta', { delta }),
signal: controller.signal,
});
const { spec, text, parseError } = ai.extractSaveBlock(full);
if (text !== full) send('replace', { text });
if (parseError) send('warn', { message: 'The assistant emitted an algorithm block that was not valid JSON.' });
if (spec && !canSaveWith(cfg)) {
// The prompt never taught it this, but a model that emits the block anyway must not reach
// the database. The text has already had the block stripped out of it by extractSaveBlock.
send('warn', { message: 'This assistant recommends a configuration but does not create it. '
+ 'Pick the algorithm in the sidebar and fill in the values it gave you.' });
} else if (spec) {
let outcome = await validateAndSave(spec);
// One retry, and only for a local model: the runner's rejection plus the algorithm's own
// schema as a decoding grammar is a far better brief than the catalog was. Costs nothing
// when the first configuration already runs.
if (outcome.error && cfg.id === 'ollama') {
const repaired = await repairSpec(spec, outcome.error, messages, cfg, controller.signal);
if (repaired) {
send('warn', { message: 'The first configuration did not run; retried it against the algorithm schema.' });
outcome = await validateAndSave(repaired);
}
}
send(outcome.error ? 'save-error' : 'saved', outcome.error ? { message: outcome.error } : outcome.saved);
}
send('done', {});
} catch (err) {
if (!controller.signal.aborted) send('error', { message: err.message });
}
res.end();
});
/**
* Whether the Delphix libraries are in place. Without them nothing in the app works — no
* framework list, no masking, no assistant — so the UI blocks on this rather than rendering an
* empty sidebar and leaving the user to guess.
*/
app.get('/api/setup', (req, res) => {
const missing = missingJars();
res.json({
ready: missing.length === 0,
missing,
libDir: LIB_DIR,
required: REQUIRED_JARS.length,
found: listJars().length,
});
});
// ── Delphix engine ────────────────────────────────────────────────────────────
/** Tolerates rows saved before and after the double-serialisation fix, like the frontend does. */
function parseStoredConfig(raw) {
try {
const first = JSON.parse(raw || '{}');
return typeof first === 'string' ? JSON.parse(first) : (first ?? {});
} catch {
return {};
}
}
const delphixCfg = () => delphix.settings(readConfig());
/** Reports whether the configured engine is reachable and the credentials work. */
app.get('/api/delphix/status', async (req, res) => {
const cfg = delphixCfg();
res.json({ configured: delphix.isConfigured(cfg), ...(await delphix.probe(cfg)) });
});
/**
* Tries credentials that have not been saved yet, so "test connection" answers about what is
* on screen. A blank password means "keep the stored one" — the form shows a mask, never the
* real value, so it has nothing to send back.
*/
app.post('/api/delphix/test', async (req, res) => {
const stored = readConfig();
const cfg = delphix.settings({
...stored,
'delphix.baseUrl': req.body.baseUrl ?? stored['delphix.baseUrl'],
'delphix.username': req.body.username ?? stored['delphix.username'],
'delphix.password': req.body.password || stored['delphix.password'],
'delphix.allowSelfSigned': String(req.body.allowSelfSigned ?? stored['delphix.allowSelfSigned']),
});
res.json({ configured: delphix.isConfigured(cfg), ...(await delphix.probe(cfg)) });
});
/**
* Pushes one saved algorithm row to the engine and records where it landed.
*
* What it references goes first: every algorithm its configuration names that is this machine's
* to send, and every file the engine could not otherwise open. The configuration that reaches the
* engine names them as they end up there; the local row keeps its own addresses, which are the
* ones that run here.
*
* Shared with the domain and classifier exports and with sending everything. Returns what
* `delphix.saveAlgorithm` returned plus `renamedLocally`.
*/
async function pushAlgorithmRow(ctx, row, { asked = null, description } = {}) {
if (ctx.done.has(row.id)) return ctx.done.get(row.id);
if (ctx.visiting.has(row.id)) {
const err = new Error(`"${row.name}" ends up referencing itself, so there is no order to send it in.`);
err.code = 'reference-cycle';
throw err;
}
ctx.visiting.add(row.id);
try {
const stored = parseStoredConfig(row.config);
const linked = row.delphix_origin === ctx.origin ? row.delphix_name : null;
const existingName = asked && asked !== linked ? null : linked;
const name = asked || linked || String(row.name).trim();
const renamedLocally = Boolean(existingName && !asked && String(row.name).trim() !== existingName);
const renames = {};
for (const reference of delphix.algorithmReferenceNames(stored)) {
const target = await pushReference(ctx, reference, row.name);
if (target !== reference) renames[reference] = target;
}
const engine = await ctx.engineAlgorithms();
const current = existingName ? engine.get(existingName)?.config : null;
const config = await engineReadyFiles(ctx, delphix.rewriteConfig(stored, { algorithms: renames }), current);
const out = await delphix.saveAlgorithm(ctx.cfg, {
name, className: row.framework, config, description, existingName,
});
db.prepare(`UPDATE saved_algorithms SET delphix_name = ?, delphix_origin = ?,
updated_at = datetime('now') WHERE id = ?`).run(out.name, ctx.origin, row.id);
engine.set(out.name, { algorithmName: out.name, createdBy: ctx.cfg.username, config });
const result = { ...out, renamedLocally };
ctx.done.set(row.id, result);
return result;
} finally {
ctx.visiting.delete(row.id);
}
}
app.post('/api/delphix/export/:id', async (req, res) => {
const cfg = delphixCfg();
if (!delphix.isConfigured(cfg)) {
return res.status(400).json({ code: 'not-configured', error: 'No Delphix engine is configured.' });
}
const row = db.prepare('SELECT * FROM saved_algorithms WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Saved algorithm not found' });
// Only update in place when the row came from *this* engine; the same name on a different
// engine is a different algorithm. The engine refuses a changed algorithmName on update, so
// a rename can only ever mean a new algorithm: asking for a name different from the linked
// one is a deliberate "save a copy under this name", and with no name given the linked
// algorithm is updated and the local rename reported back instead of silently dropped.
const asked = req.body.name ? String(req.body.name).trim() : null;
try {
const ctx = exportContext(cfg);
const out = await pushAlgorithmRow(ctx, row, { asked, description: req.body.description });
res.json({
mode: out.mode, name: out.name, engine: ctx.origin,
renamed: Boolean(out.renamed) || out.renamedLocally,
sent: ctx.sent, skipped: ctx.skipped, uploaded: ctx.uploaded,
});
} catch (err) {
exportFailure(res, err);
}
});
/** The names currently in filesDir — what an imported algorithm's file references can resolve to. */
function localFileNames() {
const dir = path.resolve(__dirname, readConfig().filesDir);
if (!fs.existsSync(dir)) return new Set();
return new Set(fs.readdirSync(dir).filter((f) => !f.startsWith('.')));
}
/**
* The engine-held files this configuration needs and filesDir does not have.
*
* An algorithm imported from an engine references its uploaded files by an address only the
* engine can resolve, so the import brings down a configuration that cannot run until a copy
* of each file exists locally. Reporting it at import time beats letting the first mask fail.
*
* `local` is passed in by callers that check a whole list, so one engine listing reads the
* directory once instead of once per algorithm.
*/
function missingEngineFiles(config, local = localFileNames()) {
return delphix.engineFileNames(config).filter((name) => !local.has(name));
}
// ── What travels together ─────────────────────────────────────────────────────
//
// Nothing moves between this machine and an engine without what it points at. A classifier
// brings its domain, a domain its two algorithms, an algorithm the algorithms and files its
// configuration names — on the way down as on the way up. The plugin's own instances are the
// exception both ways: the engine and this tool's plugin each already hold them.
const filesDirPath = () => path.resolve(__dirname, readConfig().filesDir);
/** Whether the engine will hand over an algorithm's file: only a Secure Lookup's single list. */
const canDownloadLookup = (a) =>
a.frameworkName === 'Secure Lookup' && a.frameworkPlugin === 'dlpx-core'
&& delphix.engineFileNames(a.config).length === 1;
/**
* Copies engine objects down together with everything they reference.
*
* What was picked is followed outwards — a classifier's domain, a domain's algorithms, an
* algorithm's sub-algorithms — and each lands once however many paths reach it. Files come too,
* into filesDir under their own names: a LIST classifier's lists and a Secure Lookup's lookup
* file. What cannot come is reported rather than lost: a framework this tool cannot run, a name
* the engine does not have, a file the engine offers no way to download.
*/
async function importFromEngine(cfg, { algorithms = [], domains = [], classifiers = [], profileSets = [], onProgress = null }) {
const origin = delphix.apiRoot(cfg.baseUrl);
const out = { profileSets: [], classifiers: [], domains: [], algorithms: [], skipped: [], downloaded: [], needsFiles: [] };
// Progress, for the dialog to show. The work is discovered as it goes — a classifier drags its
// domain, a domain its algorithms — so the total is never known up front: it is always what is
// finished plus what is still queued, and it grows. `step` is called as an item is taken up,
// whatever becomes of it, so the count reaches the total even when items are skipped.
let done = 0;
const step = (kind, name, queued) => {
done += 1;
if (onProgress) onProgress({ kind, name, done, total: done + queued });
};
if (onProgress) onProgress({ kind: 'reading', name: null, done: 0, total: 0 });
const [remoteAlgorithms, remoteDomains, runnable] = await Promise.all([
delphix.listAlgorithms(cfg), delphix.listDomains(cfg), runJava({ command: 'list' }),
]);
const algorithmByName = new Map(remoteAlgorithms.map((a) => [a.algorithmName, a]));
const domainByName = new Map(remoteDomains.map((d) => [d.domainName, d]));
const displayName = new Map(runnable.map((a) => [a.className, a.displayName]));
const dir = filesDirPath();
ensureDir(dir);
const local = localFileNames();
// Writes each engine file the owner reads that is not here yet, when `fetchFiles` can get it.
const bringFiles = async (owner, config, fetchFiles) => {
const missing = [...new Set(delphix.configStrings(config).filter((value) => {
const name = delphix.engineFileName(value);
return name && !local.has(path.basename(name));
}))];
if (!missing.length) return;
const fetched = fetchFiles
? await fetchFiles().catch((err) => { console.warn(` ⚠ Could not download the files of "${owner}": ${err.message}`); return []; })
: [];
const still = [];
for (const reference of missing) {
const name = path.basename(delphix.engineFileName(reference));
if (local.has(name)) continue; // two addresses with one file name: the first copy serves both
const hit = fetched.find((f) => f.reference === reference);
if (!hit) { still.push(name); continue; }
fs.writeFileSync(path.join(dir, name), hit.content);
local.add(name);
out.downloaded.push(name);
}
if (still.length) out.needsFiles.push({ name: owner, files: still });
};
const pendingDomains = new Set(domains);
const pendingAlgorithms = algorithms.map((name) => ({ name, asked: true }));
const pendingClassifiers = new Set(classifiers.map(Number));
// Which engine classifier ids each imported set holds. Resolved to local rows only after the
// classifiers themselves are in, because that is when they have local ids to point at.
const memberships = [];
if (profileSets.length) {
const sets = await delphix.listProfileSets(cfg);
const wanted = new Set(profileSets.map(Number));
const picked = sets.filter((s) => wanted.has(Number(s.profileSetId)));
for (const [i, s] of picked.entries()) {
step('profileSet', s.profileSetName, (picked.length - 1 - i)
+ pendingClassifiers.size + pendingDomains.size + pendingAlgorithms.length);
const threshold = Number(s.assignmentThreshold) || THRESHOLD_DEFAULT;
// By the engine id first, since a set renamed there is still the one that was imported.
const existing = db.prepare('SELECT id FROM profile_sets WHERE delphix_id = ? AND delphix_origin = ?').get(s.profileSetId, origin)
?? db.prepare('SELECT id FROM profile_sets WHERE name = ?').get(s.profileSetName);
let localId;
try {
if (existing) {
db.prepare(`
UPDATE profile_sets SET name = ?, description = ?, assignment_threshold = ?,
delphix_id = ?, delphix_origin = ?, updated_at = datetime('now')
WHERE id = ?
`).run(s.profileSetName, s.description ?? '', threshold, s.profileSetId, origin, existing.id);
localId = existing.id;
} else {
localId = db.prepare(`
INSERT INTO profile_sets (name, description, assignment_threshold, delphix_id, delphix_origin)
VALUES (?, ?, ?, ?, ?)
`).run(s.profileSetName, s.description ?? '', threshold, s.profileSetId, origin).lastInsertRowid;
}
} catch {
// Renamed on the engine onto a name another local set holds.
out.skipped.push({ kind: 'profileSet', name: s.profileSetName, reason: 'name-taken' });
continue;
}
const members = (s.classifierIds ?? []).map(Number);
for (const id of members) pendingClassifiers.add(id);
memberships.push({ localId, members });
out.profileSets.push(s.profileSetName);
}
}
if (pendingClassifiers.size) {
const [list, frameworks] = await Promise.all([delphix.listClassifiers(cfg), delphix.classifierFrameworks(cfg)]);
const frameworkOf = Object.fromEntries(Object.entries(frameworks).map(([name, id]) => [id, name]));
const picked = list.filter((c) => pendingClassifiers.has(Number(c.classifierId)));
for (const [i, c] of picked.entries()) {
step('classifier', c.classifierName, (picked.length - 1 - i) + pendingDomains.size + pendingAlgorithms.length);
const framework = frameworkOf[c.frameworkId];
if (!classifierKit.isFramework(framework)) {
out.skipped.push({ kind: 'classifier', name: c.classifierName, reason: 'unsupported-framework' });
continue;
}
const config = c.classifierConfiguration ?? {};
// Matched by the engine id first, since a classifier renamed there is still the same one.
const existing = db.prepare('SELECT id FROM classifiers WHERE delphix_id = ? AND delphix_origin = ?').get(c.classifierId, origin)
?? db.prepare('SELECT id FROM classifiers WHERE name = ?').get(c.classifierName);
try {
if (existing) {
db.prepare(`
UPDATE classifiers SET name = ?, framework = ?, domain_name = ?, description = ?, config = ?,
delphix_id = ?, delphix_origin = ?, updated_at = datetime('now')
WHERE id = ?
`).run(c.classifierName, framework, c.domainName ?? '', c.description ?? '', JSON.stringify(config), c.classifierId, origin, existing.id);
} else {
db.prepare(`
INSERT INTO classifiers (name, framework, domain_name, description, config, delphix_id, delphix_origin)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(c.classifierName, framework, c.domainName ?? '', c.description ?? '', JSON.stringify(config), c.classifierId, origin);
}
} catch {
// Renamed on the engine onto a name another local classifier holds.
out.skipped.push({ kind: 'classifier', name: c.classifierName, reason: 'name-taken' });
continue;
}
out.classifiers.push(c.classifierName);
if (c.domainName) pendingDomains.add(c.domainName);
await bringFiles(c.classifierName, config, () => delphix.classifierFiles(cfg, c.classifierId, config));
}
}
// The sets can only be filled in now: a member is an engine id, and the row it names here has
// just been created or updated. An id the engine holds but this machine could not take (an
// unsupported framework) is simply not in the set — and is already named in `skipped`.
const localByEngineId = new Map(
db.prepare('SELECT id, delphix_id FROM classifiers WHERE delphix_origin = ? AND delphix_id IS NOT NULL')
.all(origin).map((r) => [Number(r.delphix_id), r.id])
);
for (const { localId, members } of memberships) {
setMembers(localId, members.map((id) => localByEngineId.get(id)).filter((id) => id != null));
}
const domainQueue = [...pendingDomains];
for (const [i, name] of domainQueue.entries()) {
step('domain', name, (domainQueue.length - 1 - i) + pendingAlgorithms.length);
const d = domainByName.get(name);
if (!d) { out.skipped.push({ kind: 'domain', name, reason: 'not-found' }); continue; }
if (db.prepare('SELECT id FROM domains WHERE name = ?').get(name)) {
db.prepare(`
UPDATE domains SET default_algorithm = ?, default_tokenization = ?, delphix_origin = ?, updated_at = datetime('now')
WHERE name = ?
`).run(d.defaultAlgorithmCode, d.defaultTokenizationCode, origin, name);
} else {
db.prepare(`
INSERT INTO domains (name, default_algorithm, default_tokenization, delphix_origin) VALUES (?, ?, ?, ?)
`).run(name, d.defaultAlgorithmCode, d.defaultTokenizationCode, origin);
}
out.domains.push(name);
for (const reference of [d.defaultAlgorithmCode, d.defaultTokenizationCode]) {
if (reference) pendingAlgorithms.push({ name: reference, asked: false });
}
}
const seen = new Set();
while (pendingAlgorithms.length) {
const { name, asked } = pendingAlgorithms.shift();
if (seen.has(name)) continue;
seen.add(name);
step('algorithm', name, pendingAlgorithms.length);
const a = algorithmByName.get(name);
if (!a) { out.skipped.push({ kind: 'algorithm', name, reason: 'not-found' }); continue; }
// A plugin instance reached through a reference is already here, in this tool's plugin.
if (!asked && !a.createdBy) continue;
if (!a.className || !displayName.has(a.className)) {
// Importing it would create a row that can never be tested, so it is named instead.
out.skipped.push({ kind: 'algorithm', name, reason: 'unsupported-framework', framework: a.frameworkName });
continue;
}
const config = JSON.stringify(a.config ?? {});
const existing = db.prepare('SELECT id FROM saved_algorithms WHERE delphix_name = ? AND delphix_origin = ?').get(name, origin);
if (existing) {
db.prepare(`
UPDATE saved_algorithms SET name = ?, framework = ?, display_name = ?, config = ?, updated_at = datetime('now')
WHERE id = ?
`).run(name, a.className, displayName.get(a.className), config, existing.id);
} else {
db.prepare(`
INSERT INTO saved_algorithms (name, framework, display_name, config, input, key_value, output, delphix_name, delphix_origin)
VALUES (?, ?, ?, ?, '', ?, NULL, ?, ?)
`).run(name, a.className, displayName.get(a.className), config, MASKING_KEY, name, origin);
}
out.algorithms.push(name);
for (const reference of delphix.algorithmReferenceNames(a.config)) {
pendingAlgorithms.push({ name: reference, asked: false });
}
await bringFiles(name, a.config, canDownloadLookup(a)
? async () => {
const [reference] = delphix.configStrings(a.config).filter((value) => delphix.engineFileName(value));
return [{ reference, content: await delphix.lookupFile(cfg, name) }];
}
: null);
}
return out;
}
/** One reply for every import: what was asked for, what came along with it, what could not come. */