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.
@@ -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
- from core.parser import parse_node_code
80
- lines: list[str] = []
81
- for t in TEMPLATES:
82
- schema = parse_node_code(t["code"])
83
- data_ins = [i.name for i in schema.inputs if i.kind == "data"]
84
- field_ins = [f"{i.name}:{i.kind}={i.default}" for i in schema.inputs if i.kind != "data"]
85
- outs = [o.name for o in schema.outputs]
86
- lines.append(
87
- f"{t['id']} [{t['category']}]\n"
88
- f" inputs : {data_ins or '(none)'} fields: {field_ins or '(none)'}\n"
89
- f" outputs: {outs or '(none)'}"
90
- )
91
-
92
- if custom_components:
93
- lines.append("\n=== USER CUSTOM COMPONENTS (Preferred if applicable) ===")
94
- for c in custom_components:
95
- schema = c.get("schema", {})
96
- data_ins = [i["name"] for i in schema.get("inputs", []) if i.get("kind") == "data"]
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"{c.get('id')} [Custom] \"{c.get('label')}\"\n"
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
- return "\n".join(lines)
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
- async function isDockerAvailable() {
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: 5_000 });
24
- return exitCode === 0;
42
+ const { exitCode } = await execa('docker', ['info'], { reject: false, timeout: 4_000 });
43
+ _dockerAvailableCache = exitCode === 0;
25
44
  } catch {
26
- return false;
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 from 'path';
17
- import os from 'os';
18
- import fs from '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 } from './ports.js';
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} for the frontend`);
76
- if (be.changed) log.warn(`Port ${be.from} busy → using ${be.port} for the backend`);
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
- try { backendProc.kill(); } catch {}
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.0",
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": "node scripts/build.js",
37
- "dev": "node scripts/build.js && npm install -g . --force",
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": "node scripts/check-docker.js"
39
+ "postinstall": "node scripts/check-docker.js"
40
40
  }
41
41
  }