@melaya/runner 1.1.37 → 1.1.40
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/dist/assistantHost.py +99 -29
- package/dist/connection.js +108 -0
- package/dist/localDbProbe.py +252 -0
- package/localDbProbe.py +252 -0
- package/package.json +3 -2
package/dist/assistantHost.py
CHANGED
|
@@ -159,6 +159,17 @@ def _config_hash() -> str:
|
|
|
159
159
|
]
|
|
160
160
|
if _browser_capable():
|
|
161
161
|
fields.append("browser")
|
|
162
|
+
# Surgical tool pinning: PRESENCE of pinned tool names is part of the host
|
|
163
|
+
# boot config, exactly like browser capability above — a warm host that
|
|
164
|
+
# never registered a pinned tool active must reboot when a turn first
|
|
165
|
+
# requests one. Byte-for-byte identical to runnerNamespace.ts
|
|
166
|
+
# _assistantConfigHash: lowercased+sorted+joined, appended ONLY when
|
|
167
|
+
# non-empty so a turn with no pinned tools hashes exactly like a
|
|
168
|
+
# pre-pinning build (parity preserved).
|
|
169
|
+
raw_pinned = os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "") or ""
|
|
170
|
+
pinned_tools = ",".join(sorted(t.lower() for t in raw_pinned.split(",") if t.strip()))
|
|
171
|
+
if pinned_tools:
|
|
172
|
+
fields.append(pinned_tools)
|
|
162
173
|
canon = "|".join(fields)
|
|
163
174
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
164
175
|
|
|
@@ -184,7 +195,16 @@ def _render_summary_text(summary) -> str:
|
|
|
184
195
|
# Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
|
|
185
196
|
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
186
197
|
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
187
|
-
_stream = {"turnId": "", "lens": {}, "cancel": False, "usedBrowser": False
|
|
198
|
+
_stream = {"turnId": "", "lens": {}, "cancel": False, "usedBrowser": False,
|
|
199
|
+
"deltaBuf": "", "deltaMid": ""}
|
|
200
|
+
|
|
201
|
+
# Coalesce per-token pre_print fragments before emitting a `delta`. agentscope
|
|
202
|
+
# hands us one cumulative snapshot per model chunk; a streaming Anthropic/OpenAI
|
|
203
|
+
# response fragments the answer into 2-15 char pieces, and emitting a MELASSIST
|
|
204
|
+
# line (→ socket → server → SSE → client) PER fragment made streaming read as
|
|
205
|
+
# ultra-choppy / ultra-slow (hundreds of round-trips for one answer). Hold the
|
|
206
|
+
# suffix until it reaches ~a clause or hits a line break, then flush one delta.
|
|
207
|
+
_DELTA_FLUSH_CHARS = 48
|
|
188
208
|
|
|
189
209
|
# Sentinel returned by a turn's coroutine when the user pressed STOP.
|
|
190
210
|
_CANCELLED = object()
|
|
@@ -417,6 +437,18 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
|
417
437
|
pass
|
|
418
438
|
|
|
419
439
|
|
|
440
|
+
def _flush_delta(turn_id: str) -> None:
|
|
441
|
+
"""Emit any buffered pre_print suffix as one `delta` and clear the buffer.
|
|
442
|
+
|
|
443
|
+
Called from the pre_print hook when the buffer fills / a message boundary is
|
|
444
|
+
crossed, and from _run_turn just before the authoritative `text` swap so the
|
|
445
|
+
last partial clause is not left un-streamed."""
|
|
446
|
+
buf = _stream.get("deltaBuf") or ""
|
|
447
|
+
if buf and turn_id:
|
|
448
|
+
_emit(turn_id, "delta", content=buf)
|
|
449
|
+
_stream["deltaBuf"] = ""
|
|
450
|
+
|
|
451
|
+
|
|
420
452
|
def _log(msg: str) -> None:
|
|
421
453
|
# Diagnostics go to stderr (the runner logs stderr; stdout is the event channel).
|
|
422
454
|
try:
|
|
@@ -526,6 +558,13 @@ def _build_agent():
|
|
|
526
558
|
# explodes context. Bounded to the selected services so the model can't reach
|
|
527
559
|
# a connector the user didn't enable / has no creds for.
|
|
528
560
|
connector_services = [s.strip().lower() for s in os.environ.get("MEL_ASSISTANT_CONNECTORS", "").split(",") if s.strip()]
|
|
561
|
+
# Surgical tool pinning (Melaya Marketing): exact connector tool NAMES the
|
|
562
|
+
# server already resolved creds for (e.g. gsc_search_analytics,
|
|
563
|
+
# gads_update_budget) — passed straight through, no lowercasing, since tool
|
|
564
|
+
# names are case-sensitive Python identifiers (unlike connector_services,
|
|
565
|
+
# which are lowercased service ids). Registered ACTIVE at boot below so the
|
|
566
|
+
# model calls them directly instead of search_tools/activate_tool.
|
|
567
|
+
pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
|
|
529
568
|
# Self-arm the write-approval gate whenever connectors are active, so it can't
|
|
530
569
|
# be silently OFF on an older runner build that didn't set this env. Writes
|
|
531
570
|
# then ALWAYS require the in-chat approval card (fail-safe).
|
|
@@ -566,10 +605,17 @@ def _build_agent():
|
|
|
566
605
|
# management + every browser action is directly callable, no search/activate.
|
|
567
606
|
if browser_enabled:
|
|
568
607
|
_budget = max(_budget, 64)
|
|
608
|
+
# Surgical tool pinning: widen the budget so every pinned tool fits
|
|
609
|
+
# alongside the base active set (melaya_agent + phone/browser if
|
|
610
|
+
# enabled) — mirrors the phone_enabled/browser_enabled widening above,
|
|
611
|
+
# sized to the ACTUAL pinned count instead of a fixed guess.
|
|
612
|
+
if pinned_tool_names:
|
|
613
|
+
_budget = max(_budget, len(pinned_tool_names) + len(categories))
|
|
569
614
|
toolkit = build_lazy_toolkit(
|
|
570
615
|
active_categories=categories,
|
|
571
616
|
include_categories=categories + core_categories + connector_services,
|
|
572
617
|
budget=_budget,
|
|
618
|
+
pinned_names=pinned_tool_names or None,
|
|
573
619
|
)
|
|
574
620
|
except Exception as exc:
|
|
575
621
|
_log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
|
|
@@ -668,22 +714,35 @@ def _build_agent():
|
|
|
668
714
|
if browser_enabled else ""
|
|
669
715
|
)
|
|
670
716
|
connector_rule = (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
717
|
+
# Surgical tool pinning: these exact tool names are already ACTIVE (not
|
|
718
|
+
# deferred) — telling the model to search_tools/activate_tool them first
|
|
719
|
+
# would be a wasted round-trip against a tool that's already callable.
|
|
720
|
+
(
|
|
721
|
+
"- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
|
|
722
|
+
"Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
|
|
723
|
+
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
724
|
+
"call the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
725
|
+
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
726
|
+
)
|
|
727
|
+
if pinned_tool_names else
|
|
728
|
+
(
|
|
729
|
+
"- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
|
|
730
|
+
"These are the ONLY external systems available RIGHT NOW. This overrides the "
|
|
731
|
+
"conversation history: if earlier in this chat you used a DIFFERENT connector "
|
|
732
|
+
"(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
|
|
733
|
+
"NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
|
|
734
|
+
"that system (e.g. do not look for another ERP's model/method names here).\n"
|
|
735
|
+
"- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
|
|
736
|
+
"BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
|
|
737
|
+
"- never another system's internal API names - then activate_tool(name=...) ONCE, then "
|
|
738
|
+
"call it. The results ARE the available tools: pick the closest match and USE it; do "
|
|
739
|
+
"NOT keep re-searching for a tool from a different system. If two searches for the same "
|
|
740
|
+
"need return the same kind of tool, STOP and activate it.\n"
|
|
741
|
+
"- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
|
|
742
|
+
"activate the connector's GENERIC list/query tool, read the rows, and compute the "
|
|
743
|
+
"aggregation yourself. Never invent data - read it from the connector.\n"
|
|
744
|
+
if connector_services else ""
|
|
745
|
+
)
|
|
687
746
|
)
|
|
688
747
|
# Core primitives are ALWAYS in the lazy pool now, so the model must be told
|
|
689
748
|
# they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
|
|
@@ -826,19 +885,27 @@ def _register_stream_hooks(agent) -> None:
|
|
|
826
885
|
return
|
|
827
886
|
prev = _stream["lens"].get(mid, 0)
|
|
828
887
|
if len(text) > prev:
|
|
829
|
-
# ReAct emits one message PER iteration
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
#
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
#
|
|
840
|
-
|
|
888
|
+
# ReAct emits one message PER iteration. We stream every message's
|
|
889
|
+
# growing suffix into the answer bubble as `delta` so the reply
|
|
890
|
+
# TYPES OUT live (this is the same channel cloud providers stream
|
|
891
|
+
# on) instead of only landing whole at end-of-turn. The end-of-turn
|
|
892
|
+
# `text` event (_run_turn) still swaps in the clean, authoritative
|
|
893
|
+
# markdown answer, so transient tool preambles that streamed here
|
|
894
|
+
# get replaced by the final professional reply. A blank line
|
|
895
|
+
# separates successive ReAct messages so rounds don't run together
|
|
896
|
+
# ("...page content.I notice..."). Fragments are coalesced (see
|
|
897
|
+
# _DELTA_FLUSH_CHARS) so we emit clause-sized deltas, not one
|
|
898
|
+
# socket round-trip per token.
|
|
899
|
+
suffix = text[prev:]
|
|
841
900
|
_stream["lens"][mid] = len(text)
|
|
901
|
+
if mid != _stream.get("deltaMid"):
|
|
902
|
+
_flush_delta(tid) # flush the previous message's tail
|
|
903
|
+
if _stream.get("deltaMid"):
|
|
904
|
+
_stream["deltaBuf"] = "\n\n"
|
|
905
|
+
_stream["deltaMid"] = mid
|
|
906
|
+
_stream["deltaBuf"] = _stream.get("deltaBuf", "") + suffix
|
|
907
|
+
if len(_stream["deltaBuf"]) >= _DELTA_FLUSH_CHARS or "\n" in suffix:
|
|
908
|
+
_flush_delta(tid)
|
|
842
909
|
except Exception:
|
|
843
910
|
pass
|
|
844
911
|
|
|
@@ -921,6 +988,8 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
921
988
|
_emit(turn_id, "round", n=1)
|
|
922
989
|
_stream["turnId"] = turn_id
|
|
923
990
|
_stream["lens"] = {}
|
|
991
|
+
_stream["deltaBuf"] = "" # fresh turn — drop any stale streamed suffix
|
|
992
|
+
_stream["deltaMid"] = ""
|
|
924
993
|
_stream["cancel"] = False # fresh turn — clear any stale STOP
|
|
925
994
|
_stream["usedBrowser"] = False
|
|
926
995
|
|
|
@@ -993,6 +1062,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
993
1062
|
_emit(turn_id, "done")
|
|
994
1063
|
return
|
|
995
1064
|
|
|
1065
|
+
_flush_delta(turn_id) # push the last buffered clause before the swap
|
|
996
1066
|
_stream["turnId"] = ""
|
|
997
1067
|
# Authoritative final answer — the client swaps the streamed plain text for
|
|
998
1068
|
# this markdown-rendered version.
|
package/dist/connection.js
CHANGED
|
@@ -1374,6 +1374,12 @@ export async function connect(opts) {
|
|
|
1374
1374
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1375
1375
|
// are reachable without exploding context.
|
|
1376
1376
|
MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
|
|
1377
|
+
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1378
|
+
// the server already resolved creds for. The host registers these ACTIVE
|
|
1379
|
+
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
1380
|
+
// search_tools -> activate_tool round-trip for known marketing tools.
|
|
1381
|
+
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1382
|
+
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1377
1383
|
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1378
1384
|
MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
|
|
1379
1385
|
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
@@ -2198,6 +2204,108 @@ export async function connect(opts) {
|
|
|
2198
2204
|
});
|
|
2199
2205
|
}
|
|
2200
2206
|
});
|
|
2207
|
+
// ── DB "Test connection via runner" ───────────────────────────────
|
|
2208
|
+
// The server hands us DB credentials (over the authenticated socket) so we can
|
|
2209
|
+
// probe a database that only THIS machine can reach — IP-allow-listed or in a
|
|
2210
|
+
// VPC the cloud can't see. We open one read-only connection and report back.
|
|
2211
|
+
// Credentials arrive via stdin (never argv, never disk) and are not persisted.
|
|
2212
|
+
socket.on("runner:db-test", async (payload, ack) => {
|
|
2213
|
+
const sid = payload?.sessionId;
|
|
2214
|
+
if (!sid)
|
|
2215
|
+
return;
|
|
2216
|
+
ack?.({ ok: true });
|
|
2217
|
+
const reply = (ok, message, error) => socket.emit("runner:db-test-result", { session_id: sid, ok, message, error });
|
|
2218
|
+
try {
|
|
2219
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2220
|
+
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
2221
|
+
// Reuse the venv already on disk — pass the ACTUAL installed shared version
|
|
2222
|
+
// (the venv marker is an exact match on `${version}::${depsHash}`, so a
|
|
2223
|
+
// sentinel like "latest" would force a needless full rebuild). If the
|
|
2224
|
+
// bundle isn't present yet, bail with a clear message rather than build.
|
|
2225
|
+
const localVersion = getLocalSharedVersion();
|
|
2226
|
+
if (!localVersion) {
|
|
2227
|
+
reply(false, undefined, "runner is still setting up its Python runtime — try again in a moment");
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
|
|
2231
|
+
console.log(chalk.gray(` [db-test venv] ${m}`)); });
|
|
2232
|
+
if (!env.ok) {
|
|
2233
|
+
reply(false, undefined, `venv bootstrap failed: ${env.reason}`);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
const { existsSync, copyFileSync, mkdirSync } = await import("fs");
|
|
2237
|
+
const workDir = join(tmpdir(), `melaya-db-test-${Date.now()}`);
|
|
2238
|
+
mkdirSync(workDir, { recursive: true });
|
|
2239
|
+
const candidates = [
|
|
2240
|
+
join(__dirname, "localDbProbe.py"),
|
|
2241
|
+
join(__dirname, "..", "localDbProbe.py"),
|
|
2242
|
+
];
|
|
2243
|
+
let found = "";
|
|
2244
|
+
for (const c of candidates) {
|
|
2245
|
+
if (existsSync(c)) {
|
|
2246
|
+
found = c;
|
|
2247
|
+
break;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
if (!found) {
|
|
2251
|
+
reply(false, undefined, "localDbProbe.py not found — update @melaya/runner");
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
const staged = join(workDir, "localDbProbe.py");
|
|
2255
|
+
copyFileSync(found, staged);
|
|
2256
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
2257
|
+
const sslEnv = certBundle
|
|
2258
|
+
? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
2259
|
+
const proc = spawn(env.pythonPath, ["-u", staged], {
|
|
2260
|
+
env: { ...process.env, ...sslEnv },
|
|
2261
|
+
cwd: workDir,
|
|
2262
|
+
});
|
|
2263
|
+
let stdout = "";
|
|
2264
|
+
let stderr = "";
|
|
2265
|
+
let done = false;
|
|
2266
|
+
const finish = (ok, message, error) => {
|
|
2267
|
+
if (done)
|
|
2268
|
+
return;
|
|
2269
|
+
done = true;
|
|
2270
|
+
reply(ok, message, error);
|
|
2271
|
+
};
|
|
2272
|
+
// Hard wall-clock cap so a hung TCP connect can't wedge the session.
|
|
2273
|
+
const killTimer = setTimeout(() => {
|
|
2274
|
+
try {
|
|
2275
|
+
proc.kill();
|
|
2276
|
+
}
|
|
2277
|
+
catch { /* already gone */ }
|
|
2278
|
+
finish(false, undefined, "probe timed out after 30s (host unreachable from the runner?)");
|
|
2279
|
+
}, 30_000);
|
|
2280
|
+
proc.stdout.on("data", (d) => { stdout += d.toString("utf-8"); });
|
|
2281
|
+
proc.stderr.on("data", (d) => { stderr += d.toString("utf-8"); });
|
|
2282
|
+
proc.on("error", (err) => { clearTimeout(killTimer); finish(false, undefined, err?.message || String(err)); });
|
|
2283
|
+
proc.on("close", (code) => {
|
|
2284
|
+
clearTimeout(killTimer);
|
|
2285
|
+
const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
|
|
2286
|
+
let parsed = null;
|
|
2287
|
+
try {
|
|
2288
|
+
parsed = JSON.parse(jsonLine);
|
|
2289
|
+
}
|
|
2290
|
+
catch { /* handled below */ }
|
|
2291
|
+
if (parsed && typeof parsed.ok === "boolean") {
|
|
2292
|
+
finish(parsed.ok, parsed.message, parsed.error);
|
|
2293
|
+
}
|
|
2294
|
+
else {
|
|
2295
|
+
finish(false, undefined, `probe exited ${code ?? "?"}${stderr ? `: ${stderr.slice(-300)}` : ""}`);
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
// Hand the credentials to the probe over stdin — never argv/disk.
|
|
2299
|
+
try {
|
|
2300
|
+
proc.stdin.write(JSON.stringify({ service: payload.service, creds: payload.creds || {} }));
|
|
2301
|
+
proc.stdin.end();
|
|
2302
|
+
}
|
|
2303
|
+
catch { /* proc.on('error') handles it */ }
|
|
2304
|
+
}
|
|
2305
|
+
catch (e) {
|
|
2306
|
+
reply(false, undefined, e?.message || String(e));
|
|
2307
|
+
}
|
|
2308
|
+
});
|
|
2201
2309
|
socket.on("linkedin:start-login", async (req) => {
|
|
2202
2310
|
const sid = req?.session_id;
|
|
2203
2311
|
if (!sid)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
localDbProbe.py — one-shot, read-only database reachability probe run BY THE
|
|
4
|
+
RUNNER on behalf of the Melaya server.
|
|
5
|
+
|
|
6
|
+
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
+
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
+
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
+
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
+
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
+
outcome. We never persist the credentials and never write to the database.
|
|
12
|
+
|
|
13
|
+
Contract:
|
|
14
|
+
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
+
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
+
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
+
and reported as {"ok": false, "error": ...}.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
import json
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _out(ok, text):
|
|
27
|
+
key = "message" if ok else "error"
|
|
28
|
+
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pick(creds, *keys):
|
|
34
|
+
for k in keys:
|
|
35
|
+
v = (creds.get(k) or "").strip()
|
|
36
|
+
if v:
|
|
37
|
+
return v
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
|
|
42
|
+
def probe_postgres(creds):
|
|
43
|
+
dsn = _pick(creds, "dsn", "POSTGRES_DSN")
|
|
44
|
+
if not dsn:
|
|
45
|
+
_out(False, "Connection DSN required")
|
|
46
|
+
try:
|
|
47
|
+
import asyncio
|
|
48
|
+
import asyncpg # type: ignore
|
|
49
|
+
except ImportError:
|
|
50
|
+
_out(False, "asyncpg not installed on this runner (pip install asyncpg)")
|
|
51
|
+
|
|
52
|
+
async def _run():
|
|
53
|
+
conn = await asyncpg.connect(dsn, timeout=10)
|
|
54
|
+
try:
|
|
55
|
+
await conn.fetchval("SELECT 1")
|
|
56
|
+
ver = await conn.fetchval("SELECT version()")
|
|
57
|
+
finally:
|
|
58
|
+
await conn.close()
|
|
59
|
+
return ver or ""
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
|
|
63
|
+
short = str(ver).split(" on ")[0] if ver else ""
|
|
64
|
+
_out(True, f"Connected to PostgreSQL{(' — ' + short) if short else ''}")
|
|
65
|
+
except Exception as e: # noqa: BLE001
|
|
66
|
+
_out(False, f"PostgreSQL connection failed: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── MySQL / MariaDB (PyMySQL — pure-python, sync) ────────────────────────────
|
|
70
|
+
def probe_mysql(creds):
|
|
71
|
+
dsn = _pick(creds, "dsn", "MYSQL_DSN")
|
|
72
|
+
if not dsn:
|
|
73
|
+
_out(False, "Connection DSN required")
|
|
74
|
+
try:
|
|
75
|
+
import pymysql # type: ignore
|
|
76
|
+
except ImportError:
|
|
77
|
+
_out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
|
|
78
|
+
try:
|
|
79
|
+
p = urllib.parse.urlparse(dsn)
|
|
80
|
+
conn = pymysql.connect(
|
|
81
|
+
host=p.hostname,
|
|
82
|
+
port=p.port or 3306,
|
|
83
|
+
user=urllib.parse.unquote(p.username or ""),
|
|
84
|
+
password=urllib.parse.unquote(p.password or ""),
|
|
85
|
+
db=(p.path or "").lstrip("/") or None,
|
|
86
|
+
connect_timeout=10,
|
|
87
|
+
read_timeout=10,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
with conn.cursor() as cur:
|
|
91
|
+
cur.execute("SELECT VERSION()")
|
|
92
|
+
row = cur.fetchone()
|
|
93
|
+
ver = (row[0] if row else "") or ""
|
|
94
|
+
finally:
|
|
95
|
+
conn.close()
|
|
96
|
+
_out(True, f"Connected to MySQL{(' — ' + str(ver)) if ver else ''}")
|
|
97
|
+
except Exception as e: # noqa: BLE001
|
|
98
|
+
_out(False, f"MySQL connection failed: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
|
|
102
|
+
def probe_snowflake(creds):
|
|
103
|
+
account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
|
|
104
|
+
user = _pick(creds, "user", "SNOWFLAKE_USER")
|
|
105
|
+
pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
|
|
106
|
+
if not account or not user or not pk:
|
|
107
|
+
_out(False, "Account + Username + Private Key required")
|
|
108
|
+
try:
|
|
109
|
+
from cryptography.hazmat.primitives import serialization # type: ignore
|
|
110
|
+
import requests # type: ignore
|
|
111
|
+
except ImportError:
|
|
112
|
+
_out(False, "cryptography/requests not installed on this runner")
|
|
113
|
+
|
|
114
|
+
def b64url(b):
|
|
115
|
+
if isinstance(b, str):
|
|
116
|
+
b = b.encode()
|
|
117
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
import time
|
|
121
|
+
key = serialization.load_pem_private_key(pk.encode(), password=None)
|
|
122
|
+
der_spki = key.public_key().public_bytes(
|
|
123
|
+
serialization.Encoding.DER,
|
|
124
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
125
|
+
)
|
|
126
|
+
fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
|
|
127
|
+
acct_upper = account.upper()
|
|
128
|
+
if "." in acct_upper:
|
|
129
|
+
acct_upper = acct_upper.split(".")[0]
|
|
130
|
+
qual = f"{acct_upper}.{user.upper()}"
|
|
131
|
+
now = int(time.time())
|
|
132
|
+
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
|
|
133
|
+
payload = b64url(json.dumps(
|
|
134
|
+
{"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
|
|
135
|
+
signing_input = f"{header}.{payload}".encode()
|
|
136
|
+
from cryptography.hazmat.primitives import hashes # type: ignore
|
|
137
|
+
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
|
138
|
+
sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
139
|
+
jwt = f"{header}.{payload}.{b64url(sig)}"
|
|
140
|
+
host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
|
|
141
|
+
r = requests.post(
|
|
142
|
+
f"{host}/api/v2/statements",
|
|
143
|
+
headers={
|
|
144
|
+
"Authorization": f"Bearer {jwt}",
|
|
145
|
+
"X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"Accept": "application/json",
|
|
148
|
+
},
|
|
149
|
+
json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
|
|
150
|
+
timeout=15,
|
|
151
|
+
)
|
|
152
|
+
if r.ok:
|
|
153
|
+
v = ""
|
|
154
|
+
try:
|
|
155
|
+
v = (r.json().get("data") or [[None]])[0][0] or ""
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
pass
|
|
158
|
+
_out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
|
|
159
|
+
_out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
|
|
160
|
+
except Exception as e: # noqa: BLE001
|
|
161
|
+
_out(False, f"Snowflake connection failed: {e}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
165
|
+
def probe_databricks(creds):
|
|
166
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
167
|
+
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
168
|
+
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
169
|
+
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
170
|
+
if not host:
|
|
171
|
+
_out(False, "Workspace URL required")
|
|
172
|
+
try:
|
|
173
|
+
import requests # type: ignore
|
|
174
|
+
except ImportError:
|
|
175
|
+
_out(False, "requests not installed on this runner")
|
|
176
|
+
try:
|
|
177
|
+
bearer = pat
|
|
178
|
+
if not bearer:
|
|
179
|
+
if not cid or not sec:
|
|
180
|
+
_out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
|
|
181
|
+
tr = requests.post(
|
|
182
|
+
f"{host}/oidc/v1/token",
|
|
183
|
+
auth=(cid, sec),
|
|
184
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
185
|
+
timeout=12,
|
|
186
|
+
)
|
|
187
|
+
tj = {}
|
|
188
|
+
try:
|
|
189
|
+
tj = tr.json()
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
pass
|
|
192
|
+
if not tr.ok or not tj.get("access_token"):
|
|
193
|
+
_out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
|
|
194
|
+
bearer = tj["access_token"]
|
|
195
|
+
r = requests.get(
|
|
196
|
+
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
197
|
+
headers={"Authorization": f"Bearer {bearer}"},
|
|
198
|
+
timeout=12,
|
|
199
|
+
)
|
|
200
|
+
if r.ok:
|
|
201
|
+
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
202
|
+
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
203
|
+
except Exception as e: # noqa: BLE001
|
|
204
|
+
_out(False, f"Databricks connection failed: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── SQLite (local file on the runner) ────────────────────────────────────────
|
|
208
|
+
def probe_sqlite(creds):
|
|
209
|
+
import os
|
|
210
|
+
import sqlite3
|
|
211
|
+
path = _pick(creds, "dsn", "db_path", "path")
|
|
212
|
+
if not path:
|
|
213
|
+
_out(False, "Database file path required")
|
|
214
|
+
if not os.path.exists(path):
|
|
215
|
+
_out(False, f"No SQLite file at {path}")
|
|
216
|
+
try:
|
|
217
|
+
conn = sqlite3.connect(path, timeout=8)
|
|
218
|
+
try:
|
|
219
|
+
conn.execute("SELECT 1")
|
|
220
|
+
finally:
|
|
221
|
+
conn.close()
|
|
222
|
+
_out(True, f"Opened SQLite database ({os.path.basename(path)})")
|
|
223
|
+
except Exception as e: # noqa: BLE001
|
|
224
|
+
_out(False, f"SQLite open failed: {e}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_PROBES = {
|
|
228
|
+
"postgres": probe_postgres,
|
|
229
|
+
"mysql": probe_mysql,
|
|
230
|
+
"snowflake": probe_snowflake,
|
|
231
|
+
"databricks": probe_databricks,
|
|
232
|
+
"sqlite": probe_sqlite,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
try:
|
|
238
|
+
job = json.loads(sys.stdin.read() or "{}")
|
|
239
|
+
except Exception as e: # noqa: BLE001
|
|
240
|
+
_out(False, f"bad probe job: {e}")
|
|
241
|
+
return
|
|
242
|
+
service = (job.get("service") or "").strip()
|
|
243
|
+
creds = job.get("creds") or {}
|
|
244
|
+
fn = _PROBES.get(service)
|
|
245
|
+
if not fn:
|
|
246
|
+
_out(False, f"unsupported service: {service}")
|
|
247
|
+
return
|
|
248
|
+
fn(creds)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
package/localDbProbe.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
localDbProbe.py — one-shot, read-only database reachability probe run BY THE
|
|
4
|
+
RUNNER on behalf of the Melaya server.
|
|
5
|
+
|
|
6
|
+
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
+
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
+
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
+
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
+
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
+
outcome. We never persist the credentials and never write to the database.
|
|
12
|
+
|
|
13
|
+
Contract:
|
|
14
|
+
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
+
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
+
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
+
and reported as {"ok": false, "error": ...}.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
import json
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _out(ok, text):
|
|
27
|
+
key = "message" if ok else "error"
|
|
28
|
+
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pick(creds, *keys):
|
|
34
|
+
for k in keys:
|
|
35
|
+
v = (creds.get(k) or "").strip()
|
|
36
|
+
if v:
|
|
37
|
+
return v
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
|
|
42
|
+
def probe_postgres(creds):
|
|
43
|
+
dsn = _pick(creds, "dsn", "POSTGRES_DSN")
|
|
44
|
+
if not dsn:
|
|
45
|
+
_out(False, "Connection DSN required")
|
|
46
|
+
try:
|
|
47
|
+
import asyncio
|
|
48
|
+
import asyncpg # type: ignore
|
|
49
|
+
except ImportError:
|
|
50
|
+
_out(False, "asyncpg not installed on this runner (pip install asyncpg)")
|
|
51
|
+
|
|
52
|
+
async def _run():
|
|
53
|
+
conn = await asyncpg.connect(dsn, timeout=10)
|
|
54
|
+
try:
|
|
55
|
+
await conn.fetchval("SELECT 1")
|
|
56
|
+
ver = await conn.fetchval("SELECT version()")
|
|
57
|
+
finally:
|
|
58
|
+
await conn.close()
|
|
59
|
+
return ver or ""
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
|
|
63
|
+
short = str(ver).split(" on ")[0] if ver else ""
|
|
64
|
+
_out(True, f"Connected to PostgreSQL{(' — ' + short) if short else ''}")
|
|
65
|
+
except Exception as e: # noqa: BLE001
|
|
66
|
+
_out(False, f"PostgreSQL connection failed: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── MySQL / MariaDB (PyMySQL — pure-python, sync) ────────────────────────────
|
|
70
|
+
def probe_mysql(creds):
|
|
71
|
+
dsn = _pick(creds, "dsn", "MYSQL_DSN")
|
|
72
|
+
if not dsn:
|
|
73
|
+
_out(False, "Connection DSN required")
|
|
74
|
+
try:
|
|
75
|
+
import pymysql # type: ignore
|
|
76
|
+
except ImportError:
|
|
77
|
+
_out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
|
|
78
|
+
try:
|
|
79
|
+
p = urllib.parse.urlparse(dsn)
|
|
80
|
+
conn = pymysql.connect(
|
|
81
|
+
host=p.hostname,
|
|
82
|
+
port=p.port or 3306,
|
|
83
|
+
user=urllib.parse.unquote(p.username or ""),
|
|
84
|
+
password=urllib.parse.unquote(p.password or ""),
|
|
85
|
+
db=(p.path or "").lstrip("/") or None,
|
|
86
|
+
connect_timeout=10,
|
|
87
|
+
read_timeout=10,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
with conn.cursor() as cur:
|
|
91
|
+
cur.execute("SELECT VERSION()")
|
|
92
|
+
row = cur.fetchone()
|
|
93
|
+
ver = (row[0] if row else "") or ""
|
|
94
|
+
finally:
|
|
95
|
+
conn.close()
|
|
96
|
+
_out(True, f"Connected to MySQL{(' — ' + str(ver)) if ver else ''}")
|
|
97
|
+
except Exception as e: # noqa: BLE001
|
|
98
|
+
_out(False, f"MySQL connection failed: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
|
|
102
|
+
def probe_snowflake(creds):
|
|
103
|
+
account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
|
|
104
|
+
user = _pick(creds, "user", "SNOWFLAKE_USER")
|
|
105
|
+
pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
|
|
106
|
+
if not account or not user or not pk:
|
|
107
|
+
_out(False, "Account + Username + Private Key required")
|
|
108
|
+
try:
|
|
109
|
+
from cryptography.hazmat.primitives import serialization # type: ignore
|
|
110
|
+
import requests # type: ignore
|
|
111
|
+
except ImportError:
|
|
112
|
+
_out(False, "cryptography/requests not installed on this runner")
|
|
113
|
+
|
|
114
|
+
def b64url(b):
|
|
115
|
+
if isinstance(b, str):
|
|
116
|
+
b = b.encode()
|
|
117
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
import time
|
|
121
|
+
key = serialization.load_pem_private_key(pk.encode(), password=None)
|
|
122
|
+
der_spki = key.public_key().public_bytes(
|
|
123
|
+
serialization.Encoding.DER,
|
|
124
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
125
|
+
)
|
|
126
|
+
fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
|
|
127
|
+
acct_upper = account.upper()
|
|
128
|
+
if "." in acct_upper:
|
|
129
|
+
acct_upper = acct_upper.split(".")[0]
|
|
130
|
+
qual = f"{acct_upper}.{user.upper()}"
|
|
131
|
+
now = int(time.time())
|
|
132
|
+
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
|
|
133
|
+
payload = b64url(json.dumps(
|
|
134
|
+
{"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
|
|
135
|
+
signing_input = f"{header}.{payload}".encode()
|
|
136
|
+
from cryptography.hazmat.primitives import hashes # type: ignore
|
|
137
|
+
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
|
138
|
+
sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
139
|
+
jwt = f"{header}.{payload}.{b64url(sig)}"
|
|
140
|
+
host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
|
|
141
|
+
r = requests.post(
|
|
142
|
+
f"{host}/api/v2/statements",
|
|
143
|
+
headers={
|
|
144
|
+
"Authorization": f"Bearer {jwt}",
|
|
145
|
+
"X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"Accept": "application/json",
|
|
148
|
+
},
|
|
149
|
+
json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
|
|
150
|
+
timeout=15,
|
|
151
|
+
)
|
|
152
|
+
if r.ok:
|
|
153
|
+
v = ""
|
|
154
|
+
try:
|
|
155
|
+
v = (r.json().get("data") or [[None]])[0][0] or ""
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
pass
|
|
158
|
+
_out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
|
|
159
|
+
_out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
|
|
160
|
+
except Exception as e: # noqa: BLE001
|
|
161
|
+
_out(False, f"Snowflake connection failed: {e}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
165
|
+
def probe_databricks(creds):
|
|
166
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
167
|
+
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
168
|
+
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
169
|
+
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
170
|
+
if not host:
|
|
171
|
+
_out(False, "Workspace URL required")
|
|
172
|
+
try:
|
|
173
|
+
import requests # type: ignore
|
|
174
|
+
except ImportError:
|
|
175
|
+
_out(False, "requests not installed on this runner")
|
|
176
|
+
try:
|
|
177
|
+
bearer = pat
|
|
178
|
+
if not bearer:
|
|
179
|
+
if not cid or not sec:
|
|
180
|
+
_out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
|
|
181
|
+
tr = requests.post(
|
|
182
|
+
f"{host}/oidc/v1/token",
|
|
183
|
+
auth=(cid, sec),
|
|
184
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
185
|
+
timeout=12,
|
|
186
|
+
)
|
|
187
|
+
tj = {}
|
|
188
|
+
try:
|
|
189
|
+
tj = tr.json()
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
pass
|
|
192
|
+
if not tr.ok or not tj.get("access_token"):
|
|
193
|
+
_out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
|
|
194
|
+
bearer = tj["access_token"]
|
|
195
|
+
r = requests.get(
|
|
196
|
+
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
197
|
+
headers={"Authorization": f"Bearer {bearer}"},
|
|
198
|
+
timeout=12,
|
|
199
|
+
)
|
|
200
|
+
if r.ok:
|
|
201
|
+
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
202
|
+
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
203
|
+
except Exception as e: # noqa: BLE001
|
|
204
|
+
_out(False, f"Databricks connection failed: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── SQLite (local file on the runner) ────────────────────────────────────────
|
|
208
|
+
def probe_sqlite(creds):
|
|
209
|
+
import os
|
|
210
|
+
import sqlite3
|
|
211
|
+
path = _pick(creds, "dsn", "db_path", "path")
|
|
212
|
+
if not path:
|
|
213
|
+
_out(False, "Database file path required")
|
|
214
|
+
if not os.path.exists(path):
|
|
215
|
+
_out(False, f"No SQLite file at {path}")
|
|
216
|
+
try:
|
|
217
|
+
conn = sqlite3.connect(path, timeout=8)
|
|
218
|
+
try:
|
|
219
|
+
conn.execute("SELECT 1")
|
|
220
|
+
finally:
|
|
221
|
+
conn.close()
|
|
222
|
+
_out(True, f"Opened SQLite database ({os.path.basename(path)})")
|
|
223
|
+
except Exception as e: # noqa: BLE001
|
|
224
|
+
_out(False, f"SQLite open failed: {e}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_PROBES = {
|
|
228
|
+
"postgres": probe_postgres,
|
|
229
|
+
"mysql": probe_mysql,
|
|
230
|
+
"snowflake": probe_snowflake,
|
|
231
|
+
"databricks": probe_databricks,
|
|
232
|
+
"sqlite": probe_sqlite,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
try:
|
|
238
|
+
job = json.loads(sys.stdin.read() or "{}")
|
|
239
|
+
except Exception as e: # noqa: BLE001
|
|
240
|
+
_out(False, f"bad probe job: {e}")
|
|
241
|
+
return
|
|
242
|
+
service = (job.get("service") or "").strip()
|
|
243
|
+
creds = job.get("creds") or {}
|
|
244
|
+
fn = _PROBES.get(service)
|
|
245
|
+
if not fn:
|
|
246
|
+
_out(False, f"unsupported service: {service}")
|
|
247
|
+
return
|
|
248
|
+
fn(creds)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.40",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
"dist/**/*.py",
|
|
16
16
|
"localRagIngest.py",
|
|
17
17
|
"localRagRetrieve.py",
|
|
18
|
+
"localDbProbe.py",
|
|
18
19
|
"nltk_data/**",
|
|
19
20
|
"README.md"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
24
|
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
24
25
|
"prepublishOnly": "npm run build"
|
|
25
26
|
},
|