m8flow 1.1.0 → 1.1.1
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/bundled/backend/services/llm_service.py +41 -24
- package/lib/backend.js +34 -5
- package/lib/ports.js +42 -0
- package/lib/run.js +24 -8
- package/lib/setup.js +24 -0
- package/package.json +4 -4
|
@@ -74,35 +74,52 @@ def _mark_rate_limited(model: str) -> None:
|
|
|
74
74
|
|
|
75
75
|
# ── Catalogue helpers ──────────────────────────────────────────────────────────
|
|
76
76
|
|
|
77
|
+
_CATALOGUE_CACHE: str | None = None # built-in templates, parsed once
|
|
78
|
+
|
|
77
79
|
def _template_catalogue(custom_components: list[dict] | None = None) -> str:
|
|
78
|
-
"""Detailed catalogue: id, category, inputs, outputs.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
field_ins = [f"{i['name']}:{i.get('kind')}={i.get('default')}" for i in schema.get("inputs", []) if i.get("kind") != "data"]
|
|
98
|
-
outs = [o["name"] for o in schema.get("outputs", [])]
|
|
80
|
+
"""Detailed catalogue: id, category, inputs, outputs.
|
|
81
|
+
|
|
82
|
+
Built-in templates are parsed ONCE and cached — they never change at runtime.
|
|
83
|
+
Custom components are appended fresh each call because they can vary per session.
|
|
84
|
+
"""
|
|
85
|
+
global _CATALOGUE_CACHE
|
|
86
|
+
|
|
87
|
+
# ── Built-ins: parse once, then serve from cache ──────────────────────────
|
|
88
|
+
if _CATALOGUE_CACHE is None:
|
|
89
|
+
from core.parser import parse_node_code
|
|
90
|
+
lines: list[str] = []
|
|
91
|
+
for t in TEMPLATES:
|
|
92
|
+
try:
|
|
93
|
+
schema = parse_node_code(t["code"])
|
|
94
|
+
data_ins = [i.name for i in schema.inputs if i.kind == "data"]
|
|
95
|
+
field_ins = [f"{i.name}:{i.kind}={i.default}" for i in schema.inputs if i.kind != "data"]
|
|
96
|
+
outs = [o.name for o in schema.outputs]
|
|
97
|
+
except Exception:
|
|
98
|
+
data_ins, field_ins, outs = [], [], []
|
|
99
99
|
lines.append(
|
|
100
|
-
f"{
|
|
100
|
+
f"{t['id']} [{t['category']}]\n"
|
|
101
101
|
f" inputs : {data_ins or '(none)'} fields: {field_ins or '(none)'}\n"
|
|
102
102
|
f" outputs: {outs or '(none)'}"
|
|
103
103
|
)
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
_CATALOGUE_CACHE = "\n".join(lines)
|
|
105
|
+
logger.debug("Template catalogue cached (%d templates)", len(TEMPLATES))
|
|
106
|
+
|
|
107
|
+
if not custom_components:
|
|
108
|
+
return _CATALOGUE_CACHE
|
|
109
|
+
|
|
110
|
+
# ── Custom nodes: always fresh ────────────────────────────────────────────
|
|
111
|
+
custom_lines = ["\n=== USER CUSTOM COMPONENTS (Preferred if applicable) ==="]
|
|
112
|
+
for c in custom_components:
|
|
113
|
+
schema = c.get("schema", {})
|
|
114
|
+
data_ins = [i["name"] for i in schema.get("inputs", []) if i.get("kind") == "data"]
|
|
115
|
+
field_ins = [f"{i['name']}:{i.get('kind')}={i.get('default')}" for i in schema.get("inputs", []) if i.get("kind") != "data"]
|
|
116
|
+
outs = [o["name"] for o in schema.get("outputs", [])]
|
|
117
|
+
custom_lines.append(
|
|
118
|
+
f"{c.get('id')} [Custom] \"{c.get('label')}\"\n"
|
|
119
|
+
f" inputs : {data_ins or '(none)'} fields: {field_ins or '(none)'}\n"
|
|
120
|
+
f" outputs: {outs or '(none)'}"
|
|
121
|
+
)
|
|
122
|
+
return _CATALOGUE_CACHE + "\n".join(custom_lines)
|
|
106
123
|
|
|
107
124
|
|
|
108
125
|
def _allowed_type_ids() -> set[str]:
|
package/lib/backend.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { execa } from 'execa';
|
|
10
10
|
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
11
12
|
import { log } from './logger.js';
|
|
12
13
|
import { fileURLToPath } from 'url';
|
|
13
14
|
|
|
@@ -18,13 +19,39 @@ const SUPPRESS_VENV = /^\s*(INFO|DEBUG):\s*(Started|Waiting|Application startup
|
|
|
18
19
|
|
|
19
20
|
// ── Docker helpers ────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
let _dockerAvailableCache = null; // null = not checked yet
|
|
23
|
+
|
|
24
|
+
async function isDockerAvailable(m8flowHome) {
|
|
25
|
+
// Cache in memory for the process lifetime — `docker info` takes 1-2 s
|
|
26
|
+
if (_dockerAvailableCache !== null) return _dockerAvailableCache;
|
|
27
|
+
|
|
28
|
+
// Also cache to disk so repeated CLI invocations skip the check for 24 h
|
|
29
|
+
if (m8flowHome) {
|
|
30
|
+
const flagFile = path.join(m8flowHome, '.docker_available');
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.statSync(flagFile);
|
|
33
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
34
|
+
if (ageMs < 86_400_000) { // 24-hour TTL
|
|
35
|
+
_dockerAvailableCache = fs.readFileSync(flagFile, 'utf8').trim() === '1';
|
|
36
|
+
return _dockerAvailableCache;
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
try {
|
|
23
|
-
const { exitCode } = await execa('docker', ['info'], { reject: false, timeout:
|
|
24
|
-
|
|
42
|
+
const { exitCode } = await execa('docker', ['info'], { reject: false, timeout: 4_000 });
|
|
43
|
+
_dockerAvailableCache = exitCode === 0;
|
|
25
44
|
} catch {
|
|
26
|
-
|
|
45
|
+
_dockerAvailableCache = false;
|
|
27
46
|
}
|
|
47
|
+
|
|
48
|
+
if (m8flowHome) {
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(path.join(m8flowHome, '.docker_available'), _dockerAvailableCache ? '1' : '0');
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return _dockerAvailableCache;
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
async function isImageBuilt() {
|
|
@@ -123,6 +150,8 @@ function spawnVenvBackend(venvPython, backendDir, port, m8flowHome, verbose) {
|
|
|
123
150
|
const env = {
|
|
124
151
|
...process.env,
|
|
125
152
|
PYTHONUNBUFFERED: '1',
|
|
153
|
+
// Use the pre-compiled .pyc cache built by warmup.py — cuts import time by ~70%
|
|
154
|
+
PYTHONPYCACHEPREFIX: path.join(m8flowHome, 'pycache'),
|
|
126
155
|
M8FLOW_UPLOAD_DIR: path.join(m8flowHome, 'uploads'),
|
|
127
156
|
M8FLOW_MODELS_DIR: path.join(m8flowHome, 'models'),
|
|
128
157
|
M8FLOW_PIPELINE_DIR: path.join(m8flowHome, 'pipelines'),
|
|
@@ -171,7 +200,7 @@ function spawnVenvBackend(venvPython, backendDir, port, m8flowHome, verbose) {
|
|
|
171
200
|
*/
|
|
172
201
|
export async function spawnBackend(venvPython, backendDir, port, m8flowHome, verbose) {
|
|
173
202
|
// ── Try Docker first ──────────────────────────────────────────────────────
|
|
174
|
-
const dockerAvailable = await isDockerAvailable();
|
|
203
|
+
const dockerAvailable = await isDockerAvailable(m8flowHome);
|
|
175
204
|
|
|
176
205
|
if (dockerAvailable) {
|
|
177
206
|
// Stop any leftover container from a previous run
|
package/lib/ports.js
CHANGED
|
@@ -3,6 +3,48 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import detectPort from 'detect-port';
|
|
5
5
|
import net from 'net';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Kill whatever process is listening on `port`.
|
|
10
|
+
* Used to clean up stale m8flow backends from previous sessions that
|
|
11
|
+
* didn't get properly terminated (common on Windows after a forced close).
|
|
12
|
+
*
|
|
13
|
+
* Cross-platform:
|
|
14
|
+
* Windows → netstat -ano + taskkill /F /T
|
|
15
|
+
* Mac/Linux → lsof + kill -9
|
|
16
|
+
*/
|
|
17
|
+
export async function killProcessOnPort(port) {
|
|
18
|
+
try {
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
// netstat output: TCP 127.0.0.1:8000 0.0.0.0:0 LISTENING 12345
|
|
21
|
+
const { stdout = '' } = await execa('netstat', ['-ano'], {
|
|
22
|
+
reject: false, timeout: 4_000,
|
|
23
|
+
});
|
|
24
|
+
for (const line of stdout.split('\n')) {
|
|
25
|
+
// Match lines with our port in LISTENING state
|
|
26
|
+
if (!line.includes(`:${port}`) || !line.toUpperCase().includes('LISTEN')) continue;
|
|
27
|
+
const parts = line.trim().split(/\s+/);
|
|
28
|
+
const pid = parts[parts.length - 1];
|
|
29
|
+
if (!pid || pid === '0' || !/^\d+$/.test(pid)) continue;
|
|
30
|
+
// /T = kill entire process tree, /F = force
|
|
31
|
+
await execa('taskkill', ['/PID', pid, '/F', '/T'], { reject: false });
|
|
32
|
+
await sleep(600); // let the OS release the port
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// lsof -ti:<port> lists PIDs listening on that port
|
|
37
|
+
await execa('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null; true`], {
|
|
38
|
+
reject: false, timeout: 4_000,
|
|
39
|
+
});
|
|
40
|
+
await sleep(400);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Non-fatal — if we can't kill it, detect-port will find a different port
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
6
48
|
|
|
7
49
|
/**
|
|
8
50
|
* Returns a free port starting from `preferred`.
|
package/lib/run.js
CHANGED
|
@@ -13,16 +13,17 @@
|
|
|
13
13
|
* 9. Open browser
|
|
14
14
|
* 10. Keep alive until Ctrl+C
|
|
15
15
|
*/
|
|
16
|
-
import path
|
|
17
|
-
import os
|
|
18
|
-
import fs
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import { execSync } from 'child_process';
|
|
19
20
|
import { createRequire } from 'module';
|
|
20
21
|
import { fileURLToPath } from 'url';
|
|
21
22
|
|
|
22
23
|
import { checkPython, ensureVenv, installDeps } from './setup.js';
|
|
23
24
|
import { spawnBackend } from './backend.js';
|
|
24
25
|
import { serveStatic } from './server.js';
|
|
25
|
-
import { resolvePort, waitForPort }
|
|
26
|
+
import { resolvePort, waitForPort, killProcessOnPort } from './ports.js';
|
|
26
27
|
import { banner, log, spin, readyBox } from './logger.js';
|
|
27
28
|
|
|
28
29
|
const require = createRequire(import.meta.url);
|
|
@@ -67,13 +68,19 @@ export async function run({
|
|
|
67
68
|
// ── 4. pip install ────────────────────────────────────────────────────────
|
|
68
69
|
await installDeps(venvPython, BACKEND_DIR, M8FLOW_HOME);
|
|
69
70
|
|
|
70
|
-
// ── 5. Ports
|
|
71
|
+
// ── 5. Ports — kill stale backends first ────────────────────────────────────
|
|
71
72
|
log.section('Network');
|
|
73
|
+
|
|
74
|
+
// Kill any leftover process from a previous session BEFORE checking port availability.
|
|
75
|
+
// On Windows, Ctrl+C sometimes leaves the Python/uvicorn process running.
|
|
76
|
+
await killProcessOnPort(backendPort);
|
|
77
|
+
await killProcessOnPort(frontendPort);
|
|
78
|
+
|
|
72
79
|
const fe = await resolvePort(frontendPort, 'frontend');
|
|
73
80
|
const be = await resolvePort(backendPort, 'backend');
|
|
74
81
|
|
|
75
|
-
if (fe.changed) log.warn(`Port ${fe.from} busy → using ${fe.port}
|
|
76
|
-
if (be.changed) log.warn(`Port ${be.from} busy → using ${be.port}
|
|
82
|
+
if (fe.changed) log.warn(`Port ${fe.from} still busy after cleanup → using ${fe.port}`);
|
|
83
|
+
if (be.changed) log.warn(`Port ${be.from} still busy after cleanup → using ${be.port}`);
|
|
77
84
|
|
|
78
85
|
log.ok(`Frontend → http://localhost:${fe.port}`);
|
|
79
86
|
log.ok(`Backend → http://localhost:${be.port}`);
|
|
@@ -127,7 +134,16 @@ export async function run({
|
|
|
127
134
|
const shutdown = (signal) => {
|
|
128
135
|
log.blank();
|
|
129
136
|
log.info(`Received ${signal} — shutting down…`);
|
|
130
|
-
|
|
137
|
+
|
|
138
|
+
// On Windows, .kill() only kills the execa wrapper — the Python subprocess
|
|
139
|
+
// often survives, leaving the port busy on the next run.
|
|
140
|
+
// Use taskkill /T to kill the entire process tree.
|
|
141
|
+
if (process.platform === 'win32' && backendProc.pid) {
|
|
142
|
+
try { execSync(`taskkill /PID ${backendProc.pid} /F /T`, { stdio: 'ignore' }); } catch {}
|
|
143
|
+
} else {
|
|
144
|
+
try { backendProc.kill('SIGTERM'); } catch {}
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
try { httpServer.close(); } catch {}
|
|
132
148
|
log.ok('Goodbye!\n');
|
|
133
149
|
process.exit(0);
|
package/lib/setup.js
CHANGED
|
@@ -209,6 +209,30 @@ export async function installDeps(venvPython, backendDir, m8flowHome) {
|
|
|
209
209
|
|
|
210
210
|
fs.writeFileSync(hashFile, hash);
|
|
211
211
|
console.log(` ${chalk.green('✔')} ${chalk.bold('ML engine ready')} — all ${total} packages installed`);
|
|
212
|
+
|
|
213
|
+
// ── Pre-compile bytecode so first server startup is instant ───────────────
|
|
214
|
+
// warmup.py imports every heavy library once; Python writes .pyc to
|
|
215
|
+
// ~/.m8flow/pycache/ (via PYTHONPYCACHEPREFIX). Subsequent uvicorn starts
|
|
216
|
+
// read compiled bytecode instead of parsing source — cuts cold start by 70%.
|
|
217
|
+
const warmupScript = path.join(backendDir, 'warmup.py');
|
|
218
|
+
if (fs.existsSync(warmupScript)) {
|
|
219
|
+
process.stdout.write(` ⏳ Pre-compiling ML bytecode (one-time)… `);
|
|
220
|
+
try {
|
|
221
|
+
await execa(venvPython, [warmupScript], {
|
|
222
|
+
cwd: backendDir,
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
PYTHONPYCACHEPREFIX: path.join(m8flowHome, 'pycache'),
|
|
226
|
+
PYTHONDONTWRITEBYTECODE: '0',
|
|
227
|
+
},
|
|
228
|
+
timeout: 120_000,
|
|
229
|
+
reject: false,
|
|
230
|
+
});
|
|
231
|
+
process.stdout.write(chalk.green('done\n'));
|
|
232
|
+
} catch {
|
|
233
|
+
process.stdout.write(chalk.dim('skipped\n')); // non-fatal
|
|
234
|
+
}
|
|
235
|
+
}
|
|
212
236
|
console.log();
|
|
213
237
|
}
|
|
214
238
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "m8flow",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Visual ML Pipeline Builder — run locally with one command",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"machine-learning",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"ora": "^8.1.1"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"build":
|
|
37
|
-
"dev":
|
|
36
|
+
"build": "node scripts/build.js",
|
|
37
|
+
"dev": "node scripts/build.js && npm install -g . --force",
|
|
38
38
|
"prepublishOnly": "node scripts/build.js",
|
|
39
|
-
"postinstall":
|
|
39
|
+
"postinstall": "node scripts/check-docker.js"
|
|
40
40
|
}
|
|
41
41
|
}
|