c8ctl-plugin-nano 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +493 -0
- package/c8ctl-plugin.js +1866 -0
- package/nanobpmn-binary.json +5 -0
- package/package.json +58 -0
- package/platforms.mjs +66 -0
package/c8ctl-plugin.js
ADDED
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c8ctl-plugin-nano
|
|
3
|
+
*
|
|
4
|
+
* Start, inspect, and stop a local Nano BPM (nanobpmn) cluster.
|
|
5
|
+
*
|
|
6
|
+
* A nanobpmn deployment is one or more node *processes*. Each node is a single
|
|
7
|
+
* binary configured entirely through environment variables:
|
|
8
|
+
*
|
|
9
|
+
* PORT this node's HTTP listen port
|
|
10
|
+
* NANOBPMN_NODE_ID this node's id (index into NANOBPMN_NODES)
|
|
11
|
+
* NANOBPMN_NODES comma-separated peer base URLs, index = node id
|
|
12
|
+
* NANOBPMN_PARTITIONS total partitions across the cluster
|
|
13
|
+
* NANOBPMN_RF replication factor (1 = single-homed, no Raft)
|
|
14
|
+
* NANOBPMN_RAFT set when RF > 1 to enable per-partition Raft
|
|
15
|
+
* NANOBPMN_DATA_DIR this node's engine data directory
|
|
16
|
+
*
|
|
17
|
+
* This plugin spawns N detached node processes wired to talk to each other on
|
|
18
|
+
* localhost, tracks them in a state file, and stops them on request.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* c8ctl nano start [<nodes>] [--port <basePort>] [--partitions <n>] [--rf <n>]
|
|
22
|
+
* c8ctl nano status
|
|
23
|
+
* c8ctl nano stop [--purge]
|
|
24
|
+
* c8ctl nano logs [<nodeId>] [--follow]
|
|
25
|
+
* c8ctl nano restart [<nodes>] ...
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
openSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
rmSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { homedir, platform as osPlatform } from 'node:os';
|
|
39
|
+
import { join, isAbsolute, resolve as resolvePath, dirname } from 'node:path';
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import { platformForHost } from './platforms.mjs';
|
|
43
|
+
|
|
44
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
45
|
+
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the bundled-binary marker (nanobpmn-binary.json) written by the upstream
|
|
49
|
+
* release pipeline. Records which nanobpmn build the shipped binaries came from.
|
|
50
|
+
* Best-effort: returns undefined when absent or unset (e.g. local dev checkout).
|
|
51
|
+
*/
|
|
52
|
+
function readBundledBinaryInfo() {
|
|
53
|
+
try {
|
|
54
|
+
const p = join(pluginDir, 'nanobpmn-binary.json');
|
|
55
|
+
if (!existsSync(p)) return undefined;
|
|
56
|
+
const info = JSON.parse(readFileSync(p, 'utf8'));
|
|
57
|
+
if (!info || !info.version || info.version === '0.0.0-dev') return undefined;
|
|
58
|
+
return info;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Locate the nanobpmn binary shipped by the matching platform package
|
|
66
|
+
* (an optionalDependency such as c8ctl-plugin-nano-darwin-arm64). Returns the
|
|
67
|
+
* absolute path, or undefined if the package isn't installed for this host.
|
|
68
|
+
*/
|
|
69
|
+
function findPlatformPackageBinary() {
|
|
70
|
+
const p = platformForHost();
|
|
71
|
+
if (!p) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const manifest = requireFromHere.resolve(`${p.pkg}/package.json`);
|
|
74
|
+
const bin = join(dirname(manifest), p.bin);
|
|
75
|
+
return existsSync(bin) ? bin : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Configuration & paths
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const STATE_FILE = 'cluster.json';
|
|
86
|
+
const CONFIG_FILE = 'config.json';
|
|
87
|
+
const DEFAULT_BASE_PORT = 8080;
|
|
88
|
+
const READINESS_TIMEOUT_MS = 60_000;
|
|
89
|
+
const READINESS_POLL_MS = 500;
|
|
90
|
+
const HEALTH_TIMEOUT_MS = 1_500;
|
|
91
|
+
const STOP_GRACE_MS = 8_000;
|
|
92
|
+
const PROCESSOS_STATE_FILE = 'processos.json';
|
|
93
|
+
const PROCESSOS_DEFAULT_PORT = 8090;
|
|
94
|
+
const DEFAULT_NANO_URL = 'http://localhost:8080';
|
|
95
|
+
|
|
96
|
+
function getLogger() {
|
|
97
|
+
if (globalThis.c8ctl) {
|
|
98
|
+
return globalThis.c8ctl.getLogger();
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
info: console.log,
|
|
102
|
+
warn: console.warn,
|
|
103
|
+
error: console.error,
|
|
104
|
+
debug: () => {},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Expand a leading `~` to the user's home directory. */
|
|
109
|
+
function expandHome(p) {
|
|
110
|
+
if (!p) return p;
|
|
111
|
+
if (p === '~') return homedir();
|
|
112
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Per-user home for this plugin: holds the cluster state file, per-node data
|
|
118
|
+
* directories, and log files. Override with C8CTL_NANO_HOME.
|
|
119
|
+
*/
|
|
120
|
+
function getStateHome() {
|
|
121
|
+
const env = process.env.C8CTL_NANO_HOME;
|
|
122
|
+
if (env) return expandHome(env);
|
|
123
|
+
|
|
124
|
+
const home = homedir();
|
|
125
|
+
switch (osPlatform()) {
|
|
126
|
+
case 'darwin':
|
|
127
|
+
return join(home, 'Library', 'Application Support', 'c8ctl-nano');
|
|
128
|
+
case 'win32':
|
|
129
|
+
return join(
|
|
130
|
+
process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'),
|
|
131
|
+
'c8ctl-nano',
|
|
132
|
+
);
|
|
133
|
+
default:
|
|
134
|
+
return join(process.env.XDG_DATA_HOME || join(home, '.local', 'share'), 'c8ctl-nano');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getStateFile() {
|
|
139
|
+
return join(getStateHome(), STATE_FILE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getDataDir() {
|
|
143
|
+
return join(getStateHome(), 'data');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getLogDir() {
|
|
147
|
+
return join(getStateHome(), 'logs');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Persistent plugin config (config.json) — user settings that survive across
|
|
152
|
+
// clusters: the binary path and the workspace (models/workers) location.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function getConfigFile() {
|
|
156
|
+
return join(getStateHome(), CONFIG_FILE);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readConfig() {
|
|
160
|
+
const file = getConfigFile();
|
|
161
|
+
if (!existsSync(file)) return {};
|
|
162
|
+
try {
|
|
163
|
+
const cfg = JSON.parse(readFileSync(file, 'utf-8'));
|
|
164
|
+
return cfg && typeof cfg === 'object' ? cfg : {};
|
|
165
|
+
} catch {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writeConfig(cfg) {
|
|
171
|
+
mkdirSync(getStateHome(), { recursive: true });
|
|
172
|
+
writeFileSync(getConfigFile(), JSON.stringify(cfg, null, 2));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The workspace root (NANOBPMN_WORKSPACE_DIR): the persistent authoring source
|
|
177
|
+
* of truth holding `models/` and `workers/`. Deliberately separate from the
|
|
178
|
+
* per-node engine data dir so "nano clean" never touches it.
|
|
179
|
+
*
|
|
180
|
+
* Resolution: configured `workspaceDir` → `<stateHome>/workspace` default.
|
|
181
|
+
*/
|
|
182
|
+
function getWorkspaceDir() {
|
|
183
|
+
const cfg = readConfig();
|
|
184
|
+
if (cfg.workspaceDir) {
|
|
185
|
+
const p = expandHome(String(cfg.workspaceDir));
|
|
186
|
+
return isAbsolute(p) ? p : resolvePath(process.cwd(), p);
|
|
187
|
+
}
|
|
188
|
+
return join(getStateHome(), 'workspace');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Ensure the workspace and its models/ and workers/ subdirectories exist. */
|
|
192
|
+
function ensureWorkspace() {
|
|
193
|
+
const root = getWorkspaceDir();
|
|
194
|
+
mkdirSync(join(root, 'models'), { recursive: true });
|
|
195
|
+
mkdirSync(join(root, 'workers'), { recursive: true });
|
|
196
|
+
return root;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The nanobpmn source/checkout root used to locate a built binary. */
|
|
200
|
+
function getRepoRoot() {
|
|
201
|
+
return expandHome(process.env.NANOBPMN_REPO || join(homedir(), 'workspace', 'nanobpmn'));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Locate the nanobpmn server binary. Resolution order:
|
|
206
|
+
* 1. --binary flag
|
|
207
|
+
* 2. configured binary path ("nano set bin <path>")
|
|
208
|
+
* 3. NANOBPMN_BINARY env var
|
|
209
|
+
* 4. matching platform package (optionalDependency), when installed via npm
|
|
210
|
+
* 5. release build under the nanobpmn repo
|
|
211
|
+
* 6. debug build under the nanobpmn repo
|
|
212
|
+
*/
|
|
213
|
+
function findBinary(flags) {
|
|
214
|
+
const cfg = readConfig();
|
|
215
|
+
const sources = [
|
|
216
|
+
{ val: flags?.binary && String(flags.binary), from: '--binary' },
|
|
217
|
+
{ val: cfg.binary && String(cfg.binary), from: 'configured bin ("nano set bin")' },
|
|
218
|
+
{ val: process.env.NANOBPMN_BINARY, from: 'NANOBPMN_BINARY' },
|
|
219
|
+
];
|
|
220
|
+
for (const { val, from } of sources) {
|
|
221
|
+
if (!val) continue;
|
|
222
|
+
const p = expandHome(val);
|
|
223
|
+
const abs = isAbsolute(p) ? p : resolvePath(process.cwd(), p);
|
|
224
|
+
if (!existsSync(abs)) {
|
|
225
|
+
throw new Error(`Binary not found at ${abs} (from ${from})`);
|
|
226
|
+
}
|
|
227
|
+
return abs;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fromPackage = findPlatformPackageBinary();
|
|
231
|
+
if (fromPackage) return fromPackage;
|
|
232
|
+
|
|
233
|
+
const repo = getRepoRoot();
|
|
234
|
+
const name = 'nanobpm-gateway-rest-server';
|
|
235
|
+
const candidates = [
|
|
236
|
+
join(repo, 'server', 'target', 'release', name),
|
|
237
|
+
join(repo, 'server', 'target', 'debug', name),
|
|
238
|
+
];
|
|
239
|
+
for (const c of candidates) {
|
|
240
|
+
if (existsSync(c)) return c;
|
|
241
|
+
}
|
|
242
|
+
const host = `${process.platform}/${process.arch}`;
|
|
243
|
+
const expectedPkg = platformForHost()?.pkg;
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Could not find the nanobpmn server binary.\n` +
|
|
246
|
+
(expectedPkg
|
|
247
|
+
? `No platform package installed for ${host} (expected "${expectedPkg}").\n` +
|
|
248
|
+
`Reinstall the plugin so npm can fetch it, or build from source below.\n`
|
|
249
|
+
: `No prebuilt binary is published for this platform (${host}).\n`) +
|
|
250
|
+
`Looked for a local build in:\n ${candidates.join('\n ')}\n` +
|
|
251
|
+
`Build it with: (cd ${repo} && make release-gateway)\n` +
|
|
252
|
+
`Or set one with "c8ctl nano set bin <path>", --binary <path>, or NANOBPMN_BINARY=<path>.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Argument parsing
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config'];
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse positional args + flags into a normalized request.
|
|
264
|
+
* Positional 0 = subcommand, positional 1 = node count (start/restart) or
|
|
265
|
+
* node id (logs).
|
|
266
|
+
*/
|
|
267
|
+
function parseRequest(args, flags) {
|
|
268
|
+
const subcommand = args[0];
|
|
269
|
+
const positional = args.slice(1).filter((a) => !a.startsWith('-'));
|
|
270
|
+
|
|
271
|
+
const intFlag = (name) => {
|
|
272
|
+
const v = flags?.[name];
|
|
273
|
+
if (v === undefined || v === null || v === '') return undefined;
|
|
274
|
+
const n = Number.parseInt(String(v), 10);
|
|
275
|
+
return Number.isFinite(n) ? n : undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
subcommand,
|
|
280
|
+
positional,
|
|
281
|
+
nodes: intFlag('nodes'),
|
|
282
|
+
basePort: intFlag('port'),
|
|
283
|
+
partitions: intFlag('partitions'),
|
|
284
|
+
rf: intFlag('rf'),
|
|
285
|
+
raft: flags?.raft,
|
|
286
|
+
follow: Boolean(flags?.follow),
|
|
287
|
+
purge: Boolean(flags?.purge),
|
|
288
|
+
force: Boolean(flags?.force),
|
|
289
|
+
capture: Boolean(flags?.capture),
|
|
290
|
+
workspace: Boolean(flags?.workspace),
|
|
291
|
+
binary: flags?.binary,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Process / state helpers
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/** True if a process with `pid` is currently alive. */
|
|
300
|
+
function isPidAlive(pid) {
|
|
301
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, 0);
|
|
304
|
+
return true;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
// EPERM means the process exists but we can't signal it — still alive.
|
|
307
|
+
return err && err.code === 'EPERM';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readState() {
|
|
312
|
+
const file = getStateFile();
|
|
313
|
+
if (!existsSync(file)) return null;
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function writeState(state) {
|
|
322
|
+
mkdirSync(getStateHome(), { recursive: true });
|
|
323
|
+
writeFileSync(getStateFile(), JSON.stringify(state, null, 2));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function clearState() {
|
|
327
|
+
const file = getStateFile();
|
|
328
|
+
if (existsSync(file)) rmSync(file);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** How many of a cluster's recorded nodes are still alive. */
|
|
332
|
+
function liveNodeCount(state) {
|
|
333
|
+
if (!state || !Array.isArray(state.nodes)) return 0;
|
|
334
|
+
return state.nodes.filter((n) => isPidAlive(n.pid)).length;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Probe a node's always-on GET /v2/topology endpoint for reachability. */
|
|
338
|
+
async function probeHealthy(url) {
|
|
339
|
+
const controller = new AbortController();
|
|
340
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(`${url}/v2/topology`, { signal: controller.signal });
|
|
343
|
+
return res.ok;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
} finally {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Probe whether `path` on `url` answers with a 2xx. Used to detect whether this
|
|
353
|
+
* binary was built with the web console (which serves the landing page `/`,
|
|
354
|
+
* `/console`, and the `/docs` user guide); the API-only gateway 404s them.
|
|
355
|
+
*/
|
|
356
|
+
async function probePath(url, path) {
|
|
357
|
+
const controller = new AbortController();
|
|
358
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(`${url}${path}`, { signal: controller.signal });
|
|
361
|
+
return res.ok;
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
} finally {
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Fetch and parse a node's GET /v2/topology, or null if unreachable / not a
|
|
371
|
+
* Nano BPM endpoint. The topology is the authoritative cluster view, so this
|
|
372
|
+
* lets `nano status` report on a cluster that c8ctl did not start.
|
|
373
|
+
*/
|
|
374
|
+
async function fetchTopology(url) {
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
377
|
+
try {
|
|
378
|
+
const res = await fetch(`${url}/v2/topology`, { signal: controller.signal });
|
|
379
|
+
if (!res.ok) return null;
|
|
380
|
+
const body = await res.json();
|
|
381
|
+
return body && Array.isArray(body.brokers) ? body : null;
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
} finally {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Classify a parsed /v2/topology body as Nano BPM vs stock Camunda.
|
|
391
|
+
*
|
|
392
|
+
* Nano advertises itself with a `nano` object (`engine: "nanobpmn"`) in its
|
|
393
|
+
* topology — a superset of the Camunda Orchestration Cluster API. A stock
|
|
394
|
+
* Camunda gateway answers the same /v2/topology shape but without that object,
|
|
395
|
+
* so its absence is the discriminator.
|
|
396
|
+
*/
|
|
397
|
+
function classifyTopology(topo) {
|
|
398
|
+
const nano = topo && topo.nano;
|
|
399
|
+
if (nano && nano.engine) {
|
|
400
|
+
return {
|
|
401
|
+
product: 'nano',
|
|
402
|
+
label: 'Nano BPM',
|
|
403
|
+
engine: nano.engine,
|
|
404
|
+
version: nano.version ?? topo.gatewayVersion ?? null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
product: 'camunda',
|
|
409
|
+
label: 'Camunda',
|
|
410
|
+
engine: null,
|
|
411
|
+
version: (topo && topo.gatewayVersion) ?? null,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Probe `url` and identify what is answering: returns the classification plus
|
|
417
|
+
* the raw topology, or null if nothing Camunda-compatible is listening.
|
|
418
|
+
*/
|
|
419
|
+
async function identifyEndpoint(url) {
|
|
420
|
+
const topo = await fetchTopology(url);
|
|
421
|
+
if (!topo) return null;
|
|
422
|
+
return { ...classifyTopology(topo), topo };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function waitForHealthy(url, timeoutMs = READINESS_TIMEOUT_MS) { const start = Date.now();
|
|
426
|
+
while (Date.now() - start < timeoutMs) {
|
|
427
|
+
if (await probeHealthy(url)) return true;
|
|
428
|
+
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// start
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
async function startCluster(req) {
|
|
438
|
+
const logger = getLogger();
|
|
439
|
+
|
|
440
|
+
// Refuse to start over a live cluster unless forced.
|
|
441
|
+
const existing = readState();
|
|
442
|
+
if (existing && liveNodeCount(existing) > 0) {
|
|
443
|
+
if (!req.force) {
|
|
444
|
+
logger.warn(`A nano cluster is already running (${liveNodeCount(existing)} node(s) alive).`);
|
|
445
|
+
logger.info('Use "c8ctl nano status" to inspect it, "c8ctl nano stop" to stop it,');
|
|
446
|
+
logger.info('or "c8ctl nano start ... --force" to stop it and start fresh.');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
logger.info('Stopping existing cluster before starting a new one (--force)...');
|
|
450
|
+
await stopCluster({ purge: false });
|
|
451
|
+
} else if (existing) {
|
|
452
|
+
// Stale state — no live processes. Clean it up silently.
|
|
453
|
+
clearState();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const nodeCount = Math.max(1, req.nodes ?? (Number.parseInt(req.positional[0] ?? '1', 10) || 1));
|
|
457
|
+
const basePort = req.basePort ?? DEFAULT_BASE_PORT;
|
|
458
|
+
const partitions = req.partitions ?? nodeCount;
|
|
459
|
+
const rf = Math.min(Math.max(1, req.rf ?? 1), nodeCount);
|
|
460
|
+
// Raft is required for replication; auto-enable when RF > 1, allow override.
|
|
461
|
+
const raft = req.raft === undefined ? rf > 1 : Boolean(req.raft);
|
|
462
|
+
const capture = Boolean(req.capture);
|
|
463
|
+
|
|
464
|
+
if (partitions < nodeCount) {
|
|
465
|
+
logger.warn(
|
|
466
|
+
`partitions (${partitions}) < nodes (${nodeCount}): some nodes will own no partitions ` +
|
|
467
|
+
`and act as gateways only. Pass --partitions >= ${nodeCount} to spread ownership.`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (req.rf && req.rf > nodeCount) {
|
|
471
|
+
logger.warn(`--rf ${req.rf} clamped to node count (${nodeCount}).`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const binary = findBinary(req);
|
|
475
|
+
|
|
476
|
+
// Pre-flight: make sure the chosen ports are free, and tell the user exactly
|
|
477
|
+
// what is in the way (Camunda vs Nano vs some other HTTP server). We refuse to
|
|
478
|
+
// start on top of an existing gateway — pass a different --port to coexist
|
|
479
|
+
// (e.g. run Nano alongside a local Camunda on 8080).
|
|
480
|
+
const ports = Array.from({ length: nodeCount }, (_, i) => basePort + i);
|
|
481
|
+
for (const port of ports) {
|
|
482
|
+
const url = `http://127.0.0.1:${port}`;
|
|
483
|
+
const found = await identifyEndpoint(url);
|
|
484
|
+
if (found) {
|
|
485
|
+
logger.error(
|
|
486
|
+
`Port ${port} is already serving a ${found.label} gateway` +
|
|
487
|
+
`${found.version ? ` (v${found.version})` : ''}.`,
|
|
488
|
+
);
|
|
489
|
+
if (found.product === 'camunda') {
|
|
490
|
+
logger.error('Refusing to start Nano on top of a running Camunda instance.');
|
|
491
|
+
} else {
|
|
492
|
+
logger.error('A Nano node appears to already be bound to this port.');
|
|
493
|
+
}
|
|
494
|
+
logger.info(
|
|
495
|
+
`Start on a free base port instead, e.g. ` +
|
|
496
|
+
`"c8ctl nano start ${nodeCount} --port ${basePort + 100}".`,
|
|
497
|
+
);
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
if (await probeHealthy(url)) {
|
|
501
|
+
logger.error(`Port ${port} is already serving an HTTP endpoint. Choose another --port base.`);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const peers = ports.map((p) => `http://127.0.0.1:${p}`);
|
|
507
|
+
const nodesEnv = peers.join(',');
|
|
508
|
+
|
|
509
|
+
mkdirSync(getDataDir(), { recursive: true });
|
|
510
|
+
mkdirSync(getLogDir(), { recursive: true });
|
|
511
|
+
const workspaceDir = ensureWorkspace();
|
|
512
|
+
|
|
513
|
+
logger.info(
|
|
514
|
+
`Starting Nano BPM cluster: ${nodeCount} node(s), ${partitions} partition(s), ` +
|
|
515
|
+
`RF=${rf}${raft ? ', Raft on' : ''}${capture ? ', trace capture on' : ''}`,
|
|
516
|
+
);
|
|
517
|
+
logger.info(`Binary: ${binary}`);
|
|
518
|
+
logger.info(`Workspace: ${workspaceDir} (models/, workers/)`);
|
|
519
|
+
|
|
520
|
+
const nodes = [];
|
|
521
|
+
for (let id = 0; id < nodeCount; id++) {
|
|
522
|
+
const port = ports[id];
|
|
523
|
+
const dataDir = join(getDataDir(), `node-${id}`);
|
|
524
|
+
const logFile = join(getLogDir(), `node-${id}.log`);
|
|
525
|
+
mkdirSync(dataDir, { recursive: true });
|
|
526
|
+
|
|
527
|
+
const env = {
|
|
528
|
+
...process.env,
|
|
529
|
+
PORT: String(port),
|
|
530
|
+
NANOBPMN_NODE_ID: String(id),
|
|
531
|
+
NANOBPMN_NODES: nodesEnv,
|
|
532
|
+
NANOBPMN_PARTITIONS: String(partitions),
|
|
533
|
+
NANOBPMN_RF: String(rf),
|
|
534
|
+
NANOBPMN_DATA_DIR: dataDir,
|
|
535
|
+
// Default to async durability (group-commit) for throughput; the user can
|
|
536
|
+
// override per the spread of process.env above by exporting
|
|
537
|
+
// NANOBPMN_DURABILITY (e.g. "sync") before running.
|
|
538
|
+
NANOBPMN_DURABILITY: process.env.NANOBPMN_DURABILITY ?? 'async',
|
|
539
|
+
// Replicate job activation as a digest by default so activated-job state
|
|
540
|
+
// is observable across the cluster; override by exporting
|
|
541
|
+
// NANOBPMN_REPLICATE_ACTIVATION (e.g. "off"/"full") before running.
|
|
542
|
+
NANOBPMN_REPLICATE_ACTIVATION:
|
|
543
|
+
process.env.NANOBPMN_REPLICATE_ACTIVATION ?? 'digest',
|
|
544
|
+
// Acknowledge writes once durable on the leader by default; override by
|
|
545
|
+
// exporting NANOBPMN_REPLICATION before running.
|
|
546
|
+
NANOBPMN_REPLICATION: process.env.NANOBPMN_REPLICATION ?? 'leader-durable',
|
|
547
|
+
// Shared, persistent authoring workspace (models + workers). Lives
|
|
548
|
+
// outside the per-node data dir so "nano clean" never wipes it.
|
|
549
|
+
NANOBPMN_WORKSPACE_DIR: workspaceDir,
|
|
550
|
+
};
|
|
551
|
+
if (raft) env.NANOBPMN_RAFT = '1';
|
|
552
|
+
// Trace capture: a single flag enables the Tier 2 recorded-input (stimuli)
|
|
553
|
+
// log AND auto-enables Tier 1 variable capture, so historical replay /
|
|
554
|
+
// analysis can reconstruct each instance. Must be set on every node — each
|
|
555
|
+
// node's TraceStore only sees instances on its own partitions. Optional
|
|
556
|
+
// tuning vars (NANOBPMN_TRACE_VARIABLES_MAX_BYTES / _STIMULI_MAX /
|
|
557
|
+
// _CAPACITY) pass through automatically from the environment if set.
|
|
558
|
+
if (capture) env.NANOBPMN_TRACE_STIMULI = '1';
|
|
559
|
+
|
|
560
|
+
const out = openSync(logFile, 'a');
|
|
561
|
+
const child = spawn(binary, [], {
|
|
562
|
+
env,
|
|
563
|
+
stdio: ['ignore', out, out],
|
|
564
|
+
detached: true,
|
|
565
|
+
});
|
|
566
|
+
child.unref();
|
|
567
|
+
|
|
568
|
+
if (typeof child.pid !== 'number') {
|
|
569
|
+
logger.error(`Failed to spawn node ${id}.`);
|
|
570
|
+
// Best-effort cleanup of anything already started.
|
|
571
|
+
for (const n of nodes) {
|
|
572
|
+
try {
|
|
573
|
+
process.kill(n.pid, 'SIGTERM');
|
|
574
|
+
} catch {
|
|
575
|
+
/* ignore */
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
nodes.push({ id, port, pid: child.pid, url: peers[id], dataDir, logFile });
|
|
582
|
+
logger.info(` node ${id}: pid ${child.pid} → ${peers[id]} (log: ${logFile})`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const state = {
|
|
586
|
+
version: 1,
|
|
587
|
+
startedAt: new Date().toISOString(),
|
|
588
|
+
binary,
|
|
589
|
+
workspaceDir,
|
|
590
|
+
partitions,
|
|
591
|
+
rf,
|
|
592
|
+
raft,
|
|
593
|
+
capture,
|
|
594
|
+
basePort,
|
|
595
|
+
nodes,
|
|
596
|
+
};
|
|
597
|
+
writeState(state);
|
|
598
|
+
|
|
599
|
+
// Wait for every node to report reachable on /v2/topology.
|
|
600
|
+
logger.info('Waiting for nodes to become reachable...');
|
|
601
|
+
let allHealthy = true;
|
|
602
|
+
for (const n of nodes) {
|
|
603
|
+
// A crashed process won't ever become healthy — bail early with its log.
|
|
604
|
+
if (!isPidAlive(n.pid)) {
|
|
605
|
+
logger.error(`Node ${n.id} (pid ${n.pid}) exited during startup. Check ${n.logFile}`);
|
|
606
|
+
allHealthy = false;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const ok = await waitForHealthy(n.url);
|
|
610
|
+
if (ok) {
|
|
611
|
+
logger.info(` node ${n.id} ready at ${n.url}`);
|
|
612
|
+
} else {
|
|
613
|
+
allHealthy = false;
|
|
614
|
+
logger.error(` node ${n.id} did not become ready within timeout (see ${n.logFile})`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!allHealthy) {
|
|
619
|
+
logger.error('Cluster did not fully start. Inspect logs above, then "c8ctl nano stop".');
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
await printSummary(state);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function printSummary(state) {
|
|
627
|
+
console.log('');
|
|
628
|
+
console.log(
|
|
629
|
+
`Nano BPM cluster is up: ${state.nodes.length} node(s), ${state.partitions} partition(s), ` +
|
|
630
|
+
`RF=${state.rf}${state.raft ? ', Raft on' : ''}`,
|
|
631
|
+
);
|
|
632
|
+
console.log('');
|
|
633
|
+
for (const n of state.nodes) {
|
|
634
|
+
console.log(` node ${n.id} ${n.url} (pid ${n.pid})`);
|
|
635
|
+
}
|
|
636
|
+
console.log('');
|
|
637
|
+
const entry = state.nodes[0];
|
|
638
|
+
// The landing page (and the /docs user guide + /console) only exist in builds
|
|
639
|
+
// compiled with the web console; probe so we advertise the right entry point.
|
|
640
|
+
const hasConsole = await probePath(entry.url, '/');
|
|
641
|
+
if (hasConsole) {
|
|
642
|
+
console.log(` Start here ${entry.url}/ (landing: console, user guide & API docs)`);
|
|
643
|
+
}
|
|
644
|
+
console.log(` REST API ${entry.url}/v2`);
|
|
645
|
+
console.log(` Topology ${entry.url}/v2/topology`);
|
|
646
|
+
if (hasConsole) {
|
|
647
|
+
console.log(` Web console ${entry.url}/console`);
|
|
648
|
+
console.log(` User guide ${entry.url}/docs`);
|
|
649
|
+
}
|
|
650
|
+
if (state.workspaceDir) {
|
|
651
|
+
console.log(` Workspace ${state.workspaceDir} (models/, workers/)`);
|
|
652
|
+
}
|
|
653
|
+
console.log('');
|
|
654
|
+
console.log(' Inspect with: c8ctl nano status');
|
|
655
|
+
console.log(' Stop with: c8ctl nano stop');
|
|
656
|
+
console.log('');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// stop
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
async function stopCluster(req) {
|
|
664
|
+
const logger = getLogger();
|
|
665
|
+
const state = readState();
|
|
666
|
+
|
|
667
|
+
if (!state || !Array.isArray(state.nodes) || state.nodes.length === 0) {
|
|
668
|
+
logger.warn('No nano cluster state found — nothing to stop.');
|
|
669
|
+
if (req.purge) purgeData();
|
|
670
|
+
clearState();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const alive = state.nodes.filter((n) => isPidAlive(n.pid));
|
|
675
|
+
if (alive.length === 0) {
|
|
676
|
+
logger.warn('No running nano nodes found (stale state). Cleaning up.');
|
|
677
|
+
clearState();
|
|
678
|
+
if (req.purge) purgeData();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
logger.info(`Stopping ${alive.length} nano node(s)...`);
|
|
683
|
+
|
|
684
|
+
// Phase 1: polite SIGTERM. Continue any paused (SIGSTOP'd) node first, else
|
|
685
|
+
// the SIGTERM stays pending and the node can only be force-killed.
|
|
686
|
+
for (const n of alive) {
|
|
687
|
+
try {
|
|
688
|
+
if (n.paused) process.kill(n.pid, 'SIGCONT');
|
|
689
|
+
process.kill(n.pid, 'SIGTERM');
|
|
690
|
+
} catch {
|
|
691
|
+
/* already gone */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Phase 2: wait for graceful exit.
|
|
696
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
697
|
+
let remaining = alive;
|
|
698
|
+
while (Date.now() < deadline) {
|
|
699
|
+
remaining = remaining.filter((n) => isPidAlive(n.pid));
|
|
700
|
+
if (remaining.length === 0) break;
|
|
701
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Phase 3: force-kill stragglers.
|
|
705
|
+
remaining = remaining.filter((n) => isPidAlive(n.pid));
|
|
706
|
+
for (const n of remaining) {
|
|
707
|
+
logger.warn(` node ${n.id} (pid ${n.pid}) did not exit gracefully — sending SIGKILL.`);
|
|
708
|
+
try {
|
|
709
|
+
process.kill(n.pid, 'SIGKILL');
|
|
710
|
+
} catch {
|
|
711
|
+
/* ignore */
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
clearState();
|
|
716
|
+
if (req.purge) purgeData();
|
|
717
|
+
|
|
718
|
+
logger.info('Nano cluster stopped.');
|
|
719
|
+
if (!req.purge) {
|
|
720
|
+
logger.info(
|
|
721
|
+
`Engine data retained under ${getDataDir()} (run "c8ctl nano clean" to delete it now that the server is stopped).`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function purgeData() {
|
|
727
|
+
const logger = getLogger();
|
|
728
|
+
const dir = getDataDir();
|
|
729
|
+
if (existsSync(dir)) {
|
|
730
|
+
rmSync(dir, { recursive: true, force: true });
|
|
731
|
+
logger.info(`Purged engine data: ${dir}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// status
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Render a cluster's live topology (brokers + partition roles) as reported by
|
|
741
|
+
* GET /v2/topology. Works for any reachable Nano BPM gateway, whether or not
|
|
742
|
+
* c8ctl started it.
|
|
743
|
+
*/
|
|
744
|
+
function printTopology(topo, endpoint) {
|
|
745
|
+
const id = classifyTopology(topo);
|
|
746
|
+
console.log(
|
|
747
|
+
` product: ${id.label}${id.engine ? ` (${id.engine})` : ''}` +
|
|
748
|
+
`${id.version ? ` ${id.version}` : ''}`,
|
|
749
|
+
);
|
|
750
|
+
console.log(
|
|
751
|
+
` cluster size: ${topo.clusterSize ?? topo.brokers.length}` +
|
|
752
|
+
` partitions: ${topo.partitionsCount ?? '?'}` +
|
|
753
|
+
` RF: ${topo.replicationFactor ?? '?'}` +
|
|
754
|
+
`${topo.gatewayVersion ? ` gateway: ${topo.gatewayVersion}` : ''}`,
|
|
755
|
+
);
|
|
756
|
+
console.log(` endpoint: ${endpoint}/v2/topology`);
|
|
757
|
+
console.log('');
|
|
758
|
+
console.log(' NODE ADDRESS PARTITIONS (role) VERSION');
|
|
759
|
+
const sorted = [...topo.brokers].sort((a, b) => (a.nodeId ?? 0) - (b.nodeId ?? 0));
|
|
760
|
+
for (const b of sorted) {
|
|
761
|
+
const addr = `${b.host}:${b.port}`;
|
|
762
|
+
const parts = Array.isArray(b.partitions)
|
|
763
|
+
? b.partitions.map((p) => `${p.partitionId}:${p.role ?? '?'}`).join(' ')
|
|
764
|
+
: '';
|
|
765
|
+
console.log(
|
|
766
|
+
` ${String(b.nodeId ?? '?').padEnd(4)} ${addr.padEnd(20)} ${parts.padEnd(27)} ${b.version ?? ''}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
console.log('');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function statusCluster(req) {
|
|
773
|
+
const state = readState();
|
|
774
|
+
|
|
775
|
+
// Where to look for a live topology: the recorded cluster's nodes if we have
|
|
776
|
+
// them, otherwise a default localhost endpoint (overridable with --port).
|
|
777
|
+
const probePort = req?.basePort ?? state?.basePort ?? DEFAULT_BASE_PORT;
|
|
778
|
+
const probeUrls =
|
|
779
|
+
state && Array.isArray(state.nodes) && state.nodes.length > 0
|
|
780
|
+
? state.nodes.map((n) => n.url)
|
|
781
|
+
: [`http://127.0.0.1:${probePort}`];
|
|
782
|
+
|
|
783
|
+
// Find the first node that answers /v2/topology — the authoritative view.
|
|
784
|
+
let topo = null;
|
|
785
|
+
let topoUrl = null;
|
|
786
|
+
for (const url of probeUrls) {
|
|
787
|
+
topo = await fetchTopology(url);
|
|
788
|
+
if (topo) {
|
|
789
|
+
topoUrl = url;
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// No cluster recorded by c8ctl: fall back entirely to the topology probe so
|
|
795
|
+
// status still works for an externally started cluster.
|
|
796
|
+
if (!state || !Array.isArray(state.nodes) || state.nodes.length === 0) {
|
|
797
|
+
if (!topo) {
|
|
798
|
+
console.log(
|
|
799
|
+
`Nano cluster status: stopped (no cluster recorded by c8ctl; nothing answering at ` +
|
|
800
|
+
`http://127.0.0.1:${probePort}/v2/topology)`,
|
|
801
|
+
);
|
|
802
|
+
console.log(' Tip: point at a different port with "c8ctl nano status --port <port>".');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const id = classifyTopology(topo);
|
|
806
|
+
if (id.product === 'camunda') {
|
|
807
|
+
console.log(`Detected a Camunda gateway (not Nano) at ${topoUrl}.`);
|
|
808
|
+
console.log(' This was not started by c8ctl nano; manage it with Camunda tooling.');
|
|
809
|
+
printTopology(topo, topoUrl);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
console.log('Nano cluster status: running (external — not started by c8ctl)');
|
|
813
|
+
printTopology(topo, topoUrl);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// c8ctl-managed cluster: report process liveness + per-node health.
|
|
818
|
+
const checks = await Promise.all(
|
|
819
|
+
state.nodes.map(async (n) => ({
|
|
820
|
+
...n,
|
|
821
|
+
alive: isPidAlive(n.pid),
|
|
822
|
+
healthy: await probeHealthy(n.url),
|
|
823
|
+
})),
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const liveCount = checks.filter((c) => c.alive).length;
|
|
827
|
+
const healthyCount = checks.filter((c) => c.healthy).length;
|
|
828
|
+
const overall =
|
|
829
|
+
healthyCount === checks.length ? 'running' : liveCount > 0 ? 'degraded' : 'stopped';
|
|
830
|
+
|
|
831
|
+
console.log(`Nano cluster status: ${overall}`);
|
|
832
|
+
console.log(
|
|
833
|
+
` started: ${state.startedAt} partitions: ${state.partitions} RF: ${state.rf}` +
|
|
834
|
+
`${state.raft ? ' raft: on' : ''}${state.capture ? ' trace capture: on' : ''}`,
|
|
835
|
+
);
|
|
836
|
+
console.log(` binary: ${state.binary}`);
|
|
837
|
+
console.log(` workspace: ${state.workspaceDir || getWorkspaceDir()}`);
|
|
838
|
+
console.log(` data: ${getDataDir()}`);
|
|
839
|
+
console.log('');
|
|
840
|
+
console.log(' NODE PORT PID PROCESS HEALTH URL');
|
|
841
|
+
for (const c of checks) {
|
|
842
|
+
const proc = c.alive ? (c.paused ? 'paused' : 'alive') : 'dead';
|
|
843
|
+
const health = c.healthy ? 'healthy' : c.paused ? 'paused' : c.alive ? 'unreachable' : '-';
|
|
844
|
+
console.log(
|
|
845
|
+
` ${String(c.id).padEnd(4)} ${String(c.port).padEnd(5)} ${String(c.pid).padEnd(8)} ` +
|
|
846
|
+
`${proc.padEnd(8)} ${health.padEnd(8)} ${c.url}`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
console.log('');
|
|
850
|
+
|
|
851
|
+
// Enrich with the live topology when reachable — the authoritative view of
|
|
852
|
+
// partition leadership across the cluster.
|
|
853
|
+
if (topo) {
|
|
854
|
+
console.log(' Live topology:');
|
|
855
|
+
printTopology(topo, topoUrl);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (overall === 'stopped') {
|
|
859
|
+
console.log(' All recorded nodes are dead. Run "c8ctl nano stop" to clear stale state.');
|
|
860
|
+
} else if (overall === 'degraded') {
|
|
861
|
+
console.log(' Some nodes are not healthy. Check logs in ' + getLogDir());
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const paused = checks.filter((c) => c.paused && c.alive);
|
|
865
|
+
if (paused.length > 0) {
|
|
866
|
+
console.log(
|
|
867
|
+
` Paused (SIGSTOP): node(s) ${paused.map((c) => c.id).join(', ')} — ` +
|
|
868
|
+
`resume with "c8ctl nano resume <nodeId>".`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (state.capture) {
|
|
873
|
+
console.log(
|
|
874
|
+
' Trace capture is ON (recorded-input replay). Read a trace with ' +
|
|
875
|
+
'GET /console/api/traces/{instanceKey} (creationVariables + stimuli[]).',
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
// logs
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
function logsCluster(req) {
|
|
885
|
+
const logger = getLogger();
|
|
886
|
+
const state = readState();
|
|
887
|
+
|
|
888
|
+
let files;
|
|
889
|
+
const idArg = req.positional[0];
|
|
890
|
+
if (idArg !== undefined) {
|
|
891
|
+
const id = Number.parseInt(idArg, 10);
|
|
892
|
+
const file = join(getLogDir(), `node-${id}.log`);
|
|
893
|
+
if (!existsSync(file)) {
|
|
894
|
+
logger.error(`No log file for node ${id} at ${file}`);
|
|
895
|
+
process.exit(1);
|
|
896
|
+
}
|
|
897
|
+
files = [file];
|
|
898
|
+
} else if (state && Array.isArray(state.nodes) && state.nodes.length > 0) {
|
|
899
|
+
files = state.nodes.map((n) => n.logFile).filter((f) => existsSync(f));
|
|
900
|
+
} else if (existsSync(getLogDir())) {
|
|
901
|
+
files = readdirSync(getLogDir())
|
|
902
|
+
.filter((f) => f.endsWith('.log'))
|
|
903
|
+
.map((f) => join(getLogDir(), f));
|
|
904
|
+
} else {
|
|
905
|
+
files = [];
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (files.length === 0) {
|
|
909
|
+
logger.warn('No nano log files found.');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const tailArgs = req.follow ? ['-n', '+1', '-F', ...files] : ['-n', '200', ...files];
|
|
914
|
+
const proc = spawn('tail', tailArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
915
|
+
proc.on('error', (err) => {
|
|
916
|
+
logger.error(`Failed to read logs: ${err.message}`);
|
|
917
|
+
logger.info(`Log files:\n ${files.join('\n ')}`);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// pause / resume — freeze or resume a node to simulate a node failing and
|
|
923
|
+
// coming back online. SIGSTOP halts the process (uncatchable, like a hang or
|
|
924
|
+
// network partition); SIGCONT resumes it. The node keeps its PID and on-disk
|
|
925
|
+
// state, so this exercises Raft failover/recovery without a real restart.
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
function controlNode(req, { signal, verb, paused }) {
|
|
929
|
+
const logger = getLogger();
|
|
930
|
+
const state = readState();
|
|
931
|
+
|
|
932
|
+
if (!state || !Array.isArray(state.nodes) || state.nodes.length === 0) {
|
|
933
|
+
logger.error('No c8ctl-managed cluster is running. Start one with "c8ctl nano start <nodes>".');
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const nodeIds = state.nodes.map((n) => n.id).join(', ');
|
|
938
|
+
const idArg = req.positional[0];
|
|
939
|
+
if (idArg === undefined) {
|
|
940
|
+
logger.error(`Specify a node id, e.g. "c8ctl nano ${verb} 1". Nodes: ${nodeIds}`);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const id = Number.parseInt(idArg, 10);
|
|
945
|
+
const node = Number.isFinite(id) ? state.nodes.find((n) => n.id === id) : undefined;
|
|
946
|
+
if (!node) {
|
|
947
|
+
logger.error(`No node "${idArg}" in the running cluster. Nodes: ${nodeIds}`);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!isPidAlive(node.pid)) {
|
|
952
|
+
logger.error(`Node ${id} (pid ${node.pid}) is not running — cannot ${verb} it.`);
|
|
953
|
+
process.exit(1);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (paused && node.paused) {
|
|
957
|
+
logger.warn(`Node ${id} is already paused.`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!paused && !node.paused) {
|
|
961
|
+
logger.warn(`Node ${id} is not paused — nothing to resume.`);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
process.kill(node.pid, signal);
|
|
967
|
+
} catch (err) {
|
|
968
|
+
logger.error(`Failed to ${verb} node ${id} (pid ${node.pid}): ${err.message}`);
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
node.paused = paused;
|
|
973
|
+
writeState(state);
|
|
974
|
+
|
|
975
|
+
if (paused) {
|
|
976
|
+
logger.info(
|
|
977
|
+
`Paused node ${id} (pid ${node.pid}, ${node.url}) — sent SIGSTOP. ` +
|
|
978
|
+
`The process is frozen; resume it with "c8ctl nano resume ${id}".`,
|
|
979
|
+
);
|
|
980
|
+
} else {
|
|
981
|
+
logger.info(`Resumed node ${id} (pid ${node.pid}, ${node.url}) — sent SIGCONT.`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
// clean — wipe engine data (journal/snapshots/spill) + logs from disk. The
|
|
987
|
+
// persistent workspace (models/workers) is deliberately preserved.
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
function cleanCluster(req) {
|
|
991
|
+
const logger = getLogger();
|
|
992
|
+
const state = readState();
|
|
993
|
+
|
|
994
|
+
if (state && liveNodeCount(state) > 0) {
|
|
995
|
+
logger.error(
|
|
996
|
+
`Refusing to clean while ${liveNodeCount(state)} node(s) are running. ` +
|
|
997
|
+
`Stop the cluster first: c8ctl nano stop`,
|
|
998
|
+
);
|
|
999
|
+
process.exit(1);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Stopped cluster with leftover state — clear the stale marker too.
|
|
1003
|
+
if (state) clearState();
|
|
1004
|
+
|
|
1005
|
+
const dataDir = getDataDir();
|
|
1006
|
+
const logDir = getLogDir();
|
|
1007
|
+
let removed = 0;
|
|
1008
|
+
|
|
1009
|
+
if (existsSync(dataDir)) {
|
|
1010
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
1011
|
+
logger.info(`Removed engine data: ${dataDir}`);
|
|
1012
|
+
removed++;
|
|
1013
|
+
}
|
|
1014
|
+
if (existsSync(logDir)) {
|
|
1015
|
+
rmSync(logDir, { recursive: true, force: true });
|
|
1016
|
+
logger.info(`Removed logs: ${logDir}`);
|
|
1017
|
+
removed++;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (removed === 0) {
|
|
1021
|
+
logger.info('Nothing to clean — no engine data or logs on disk.');
|
|
1022
|
+
} else {
|
|
1023
|
+
logger.info(`Workspace preserved: ${getWorkspaceDir()} (models/, workers/)`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (req.workspace) {
|
|
1027
|
+
const ws = getWorkspaceDir();
|
|
1028
|
+
if (existsSync(ws)) {
|
|
1029
|
+
rmSync(ws, { recursive: true, force: true });
|
|
1030
|
+
logger.warn(`Removed workspace (models + workers): ${ws}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---------------------------------------------------------------------------
|
|
1036
|
+
// set / config — persistent user settings (binary path, workspace location)
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
const SETTING_ALIASES = {
|
|
1040
|
+
bin: 'binary',
|
|
1041
|
+
binary: 'binary',
|
|
1042
|
+
'model-dir': 'workspaceDir',
|
|
1043
|
+
'models-dir': 'workspaceDir',
|
|
1044
|
+
workspace: 'workspaceDir',
|
|
1045
|
+
'workspace-dir': 'workspaceDir',
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
function setConfig(req) {
|
|
1049
|
+
const logger = getLogger();
|
|
1050
|
+
const key = req.positional[0];
|
|
1051
|
+
const value = req.positional[1];
|
|
1052
|
+
|
|
1053
|
+
if (!key || !(key in SETTING_ALIASES)) {
|
|
1054
|
+
logger.error('Usage: c8ctl nano set <bin|model-dir> <path>');
|
|
1055
|
+
logger.info('Settings:');
|
|
1056
|
+
logger.info(' bin <path> Path to the nanobpmn server binary');
|
|
1057
|
+
logger.info(' model-dir <path> Workspace root holding models/ and workers/');
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
1060
|
+
if (!value) {
|
|
1061
|
+
logger.error(`Please provide a value: c8ctl nano set ${key} <path>`);
|
|
1062
|
+
process.exit(1);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const field = SETTING_ALIASES[key];
|
|
1066
|
+
const expanded = expandHome(value);
|
|
1067
|
+
const abs = isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
|
|
1068
|
+
|
|
1069
|
+
if (field === 'binary' && !existsSync(abs)) {
|
|
1070
|
+
logger.error(`Binary not found at ${abs}`);
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const cfg = readConfig();
|
|
1075
|
+
cfg[field] = abs;
|
|
1076
|
+
writeConfig(cfg);
|
|
1077
|
+
|
|
1078
|
+
logger.info(`Set ${field} = ${abs}`);
|
|
1079
|
+
if (field === 'workspaceDir') {
|
|
1080
|
+
ensureWorkspace();
|
|
1081
|
+
logger.info('Created models/ and workers/ subdirectories.');
|
|
1082
|
+
const running = readState();
|
|
1083
|
+
if (running && liveNodeCount(running) > 0) {
|
|
1084
|
+
logger.warn('A cluster is running — restart it for the new workspace to take effect.');
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function showConfig() {
|
|
1090
|
+
const cfg = readConfig();
|
|
1091
|
+
console.log('Nano plugin configuration:');
|
|
1092
|
+
console.log('');
|
|
1093
|
+
console.log(` state home ${getStateHome()}`);
|
|
1094
|
+
console.log(` binary ${cfg.binary || '(auto-detect: $NANOBPMN_BINARY or repo build)'}`);
|
|
1095
|
+
const bundled = readBundledBinaryInfo();
|
|
1096
|
+
if (bundled) {
|
|
1097
|
+
const at = bundled.commit && bundled.commit !== 'unknown' ? ` (${bundled.commit})` : '';
|
|
1098
|
+
console.log(` bundled nano ${bundled.version}${at}`);
|
|
1099
|
+
}
|
|
1100
|
+
console.log(` workspace ${getWorkspaceDir()}${cfg.workspaceDir ? '' : ' (default)'}`);
|
|
1101
|
+
console.log(` data dir ${getDataDir()}`);
|
|
1102
|
+
console.log(` log dir ${getLogDir()}`);
|
|
1103
|
+
console.log('');
|
|
1104
|
+
console.log(` config file ${getConfigFile()}`);
|
|
1105
|
+
console.log('');
|
|
1106
|
+
console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ---------------------------------------------------------------------------
|
|
1110
|
+
// processos — manage a single local ProcessOS instance (the optimization-plane
|
|
1111
|
+
// server that analyses a running Nano BPM engine). Unlike nano, the ProcessOS
|
|
1112
|
+
// binary is not distributed via npm: the user downloads it and points the
|
|
1113
|
+
// plugin at it with "c8ctl processos set bin <path>".
|
|
1114
|
+
// ---------------------------------------------------------------------------
|
|
1115
|
+
|
|
1116
|
+
const PROCESSOS_VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'set', 'config'];
|
|
1117
|
+
|
|
1118
|
+
function getProcessosStateFile() {
|
|
1119
|
+
return join(getStateHome(), PROCESSOS_STATE_FILE);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function getProcessosLogFile() {
|
|
1123
|
+
return join(getLogDir(), 'processos.log');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function readProcessosConfig() {
|
|
1127
|
+
const cfg = readConfig();
|
|
1128
|
+
return cfg.processos && typeof cfg.processos === 'object' ? cfg.processos : {};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function writeProcessosConfig(pcfg) {
|
|
1132
|
+
const cfg = readConfig();
|
|
1133
|
+
cfg.processos = pcfg;
|
|
1134
|
+
writeConfig(cfg);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Resolve a user-supplied path to an absolute path, expanding a leading `~`. */
|
|
1138
|
+
function toAbsPath(p) {
|
|
1139
|
+
const expanded = expandHome(String(p));
|
|
1140
|
+
return isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Engine data dir for ProcessOS (PROCESSOS_DATA_DIR). */
|
|
1144
|
+
function getProcessosDataDir() {
|
|
1145
|
+
const cfg = readProcessosConfig();
|
|
1146
|
+
if (cfg.dataDir) return toAbsPath(cfg.dataDir);
|
|
1147
|
+
return join(getStateHome(), 'processos-data');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** The target Nano BPM URL ProcessOS analyses (NANO_BASE_URL). */
|
|
1151
|
+
function getProcessosNanoUrl() {
|
|
1152
|
+
const cfg = readProcessosConfig();
|
|
1153
|
+
return cfg.nanoUrl || process.env.NANO_BASE_URL || DEFAULT_NANO_URL;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** The listen port (flag overrides configured value, which overrides default). */
|
|
1157
|
+
function getProcessosPort(req) {
|
|
1158
|
+
const cfg = readProcessosConfig();
|
|
1159
|
+
if (Number.isFinite(req?.port)) return req.port;
|
|
1160
|
+
if (Number.isFinite(cfg.port)) return cfg.port;
|
|
1161
|
+
return PROCESSOS_DEFAULT_PORT;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function readProcessosState() {
|
|
1165
|
+
const file = getProcessosStateFile();
|
|
1166
|
+
if (!existsSync(file)) return null;
|
|
1167
|
+
try {
|
|
1168
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
1169
|
+
} catch {
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function writeProcessosState(state) {
|
|
1175
|
+
mkdirSync(getStateHome(), { recursive: true });
|
|
1176
|
+
writeFileSync(getProcessosStateFile(), JSON.stringify(state, null, 2));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function clearProcessosState() {
|
|
1180
|
+
const file = getProcessosStateFile();
|
|
1181
|
+
if (existsSync(file)) rmSync(file);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Locate the ProcessOS binary. Resolution order:
|
|
1186
|
+
* 1. --binary flag
|
|
1187
|
+
* 2. configured path ("processos set bin <path>")
|
|
1188
|
+
* 3. PROCESSOS_BINARY env var
|
|
1189
|
+
* 4. release build under the nanobpmn repo
|
|
1190
|
+
* 5. debug build under the nanobpmn repo
|
|
1191
|
+
* The binary is not shipped via npm — it is downloaded manually.
|
|
1192
|
+
*/
|
|
1193
|
+
function findProcessosBinary(req) {
|
|
1194
|
+
const cfg = readProcessosConfig();
|
|
1195
|
+
const sources = [
|
|
1196
|
+
{ val: req?.binary && String(req.binary), from: '--binary' },
|
|
1197
|
+
{ val: cfg.binary && String(cfg.binary), from: 'configured bin ("processos set bin")' },
|
|
1198
|
+
{ val: process.env.PROCESSOS_BINARY, from: 'PROCESSOS_BINARY' },
|
|
1199
|
+
];
|
|
1200
|
+
for (const { val, from } of sources) {
|
|
1201
|
+
if (!val) continue;
|
|
1202
|
+
const abs = toAbsPath(val);
|
|
1203
|
+
if (!existsSync(abs)) {
|
|
1204
|
+
throw new Error(`ProcessOS binary not found at ${abs} (from ${from})`);
|
|
1205
|
+
}
|
|
1206
|
+
return abs;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const repo = getRepoRoot();
|
|
1210
|
+
const name = 'processos';
|
|
1211
|
+
const candidates = [
|
|
1212
|
+
join(repo, 'processos', 'target', 'release', name),
|
|
1213
|
+
join(repo, 'processos', 'target', 'debug', name),
|
|
1214
|
+
];
|
|
1215
|
+
for (const c of candidates) {
|
|
1216
|
+
if (existsSync(c)) return c;
|
|
1217
|
+
}
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
`Could not find the ProcessOS binary.\n` +
|
|
1220
|
+
`ProcessOS is not distributed via npm — download the binary for your platform, then point the plugin at it:\n` +
|
|
1221
|
+
` c8ctl processos set bin <path> (or --binary <path>, or export PROCESSOS_BINARY)\n` +
|
|
1222
|
+
`Alternatively build from source: (cd ${repo} && make processos-build-release)\n` +
|
|
1223
|
+
`Looked for a local build in:\n ${candidates.join('\n ')}`,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Probe ProcessOS's GET /health endpoint for reachability. */
|
|
1228
|
+
async function probeProcessosHealthy(url) {
|
|
1229
|
+
return probePath(url, '/health');
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
async function waitForProcessosHealthy(url, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
1233
|
+
const start = Date.now();
|
|
1234
|
+
while (Date.now() - start < timeoutMs) {
|
|
1235
|
+
if (await probeProcessosHealthy(url)) return true;
|
|
1236
|
+
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
|
1237
|
+
}
|
|
1238
|
+
return false;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function startProcessos(req) {
|
|
1242
|
+
const logger = getLogger();
|
|
1243
|
+
|
|
1244
|
+
const existing = readProcessosState();
|
|
1245
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
1246
|
+
if (!req.force) {
|
|
1247
|
+
logger.error(
|
|
1248
|
+
`ProcessOS is already running (pid ${existing.pid}) at ${existing.url}. ` +
|
|
1249
|
+
`Use --force to restart, or "c8ctl processos stop".`,
|
|
1250
|
+
);
|
|
1251
|
+
process.exit(1);
|
|
1252
|
+
}
|
|
1253
|
+
await stopProcessos({});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const binary = findProcessosBinary(req);
|
|
1257
|
+
const port = getProcessosPort(req);
|
|
1258
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1259
|
+
const nanoUrl = req.nanoUrl || getProcessosNanoUrl();
|
|
1260
|
+
const dataDir = getProcessosDataDir();
|
|
1261
|
+
|
|
1262
|
+
// Pre-flight: refuse if something is already serving this port.
|
|
1263
|
+
if (await probeProcessosHealthy(url)) {
|
|
1264
|
+
logger.error(`Port ${port} is already serving a ProcessOS health endpoint. Choose another --port.`);
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1269
|
+
mkdirSync(getLogDir(), { recursive: true });
|
|
1270
|
+
|
|
1271
|
+
if (!(await probeHealthy(nanoUrl))) {
|
|
1272
|
+
logger.warn(
|
|
1273
|
+
`Target Nano BPM at ${nanoUrl} is not reachable — ProcessOS will start but cannot analyse ` +
|
|
1274
|
+
`an engine until one is up (set with "c8ctl processos set nano-url <url>").`,
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const cfg = readProcessosConfig();
|
|
1279
|
+
const env = {
|
|
1280
|
+
...process.env,
|
|
1281
|
+
// Generic passthrough first so typed settings below always win.
|
|
1282
|
+
...(cfg.env && typeof cfg.env === 'object' ? cfg.env : {}),
|
|
1283
|
+
PROCESSOS_PORT: String(port),
|
|
1284
|
+
NANO_BASE_URL: nanoUrl,
|
|
1285
|
+
PROCESSOS_DATA_DIR: dataDir,
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// ProcessOS runs its own internal "pilot" Nano engine, which it spawns as a
|
|
1289
|
+
// child process from a console-enabled gateway binary in PROCESSOS_NANO_BIN.
|
|
1290
|
+
// The plugin already knows where the nano binary lives, so auto-wire it.
|
|
1291
|
+
// Spawning the pilot engine is the DEFAULT; resolve the binary best-effort.
|
|
1292
|
+
let nanoBin;
|
|
1293
|
+
try {
|
|
1294
|
+
nanoBin = findBinary({});
|
|
1295
|
+
} catch {
|
|
1296
|
+
nanoBin = undefined;
|
|
1297
|
+
}
|
|
1298
|
+
if (nanoBin && !env.PROCESSOS_NANO_BIN) {
|
|
1299
|
+
env.PROCESSOS_NANO_BIN = nanoBin;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Decide whether to spawn the pilot engine. Precedence:
|
|
1303
|
+
// --no-spawn-nano flag -> off (explicit)
|
|
1304
|
+
// --spawn-nano flag -> on (explicit; hard-fail if no binary)
|
|
1305
|
+
// PROCESSOS_SPAWN_NANO env/config -> honor it (explicit)
|
|
1306
|
+
// otherwise -> on by default (soft; fall back to URL
|
|
1307
|
+
// mode with a warning if no binary)
|
|
1308
|
+
let spawnNano;
|
|
1309
|
+
if (req.noSpawnNano) {
|
|
1310
|
+
spawnNano = false;
|
|
1311
|
+
} else if (req.spawnNano) {
|
|
1312
|
+
if (!env.PROCESSOS_NANO_BIN) findBinary({}); // surface the resolver's guidance
|
|
1313
|
+
spawnNano = true;
|
|
1314
|
+
} else if (env.PROCESSOS_SPAWN_NANO !== undefined && env.PROCESSOS_SPAWN_NANO !== '') {
|
|
1315
|
+
spawnNano = ['1', 'true', 'yes', 'on'].includes(String(env.PROCESSOS_SPAWN_NANO).toLowerCase());
|
|
1316
|
+
if (spawnNano && !env.PROCESSOS_NANO_BIN) findBinary({});
|
|
1317
|
+
} else if (env.PROCESSOS_NANO_BIN) {
|
|
1318
|
+
spawnNano = true; // default
|
|
1319
|
+
} else {
|
|
1320
|
+
spawnNano = false; // default intent, but no nano binary available
|
|
1321
|
+
logger.warn(
|
|
1322
|
+
'No nano binary found, so ProcessOS will not spawn its own pilot engine; it will use the ' +
|
|
1323
|
+
`target engine (${nanoUrl}) for its pilot instead. Point the plugin at a nano binary ` +
|
|
1324
|
+
'("c8ctl nano set bin <path>") to enable a dedicated pilot engine.',
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
env.PROCESSOS_SPAWN_NANO = spawnNano ? 'true' : 'false';
|
|
1328
|
+
|
|
1329
|
+
logger.info('Starting ProcessOS...');
|
|
1330
|
+
logger.info(`Binary: ${binary}`);
|
|
1331
|
+
logger.info(`Target: ${nanoUrl}`);
|
|
1332
|
+
if (spawnNano) {
|
|
1333
|
+
logger.info(`Own Nano: spawning pilot engine from ${env.PROCESSOS_NANO_BIN}`);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const logFile = getProcessosLogFile();
|
|
1337
|
+
const out = openSync(logFile, 'a');
|
|
1338
|
+
const child = spawn(binary, [], { env, stdio: ['ignore', out, out], detached: true });
|
|
1339
|
+
child.unref();
|
|
1340
|
+
|
|
1341
|
+
if (typeof child.pid !== 'number') {
|
|
1342
|
+
logger.error('Failed to spawn ProcessOS.');
|
|
1343
|
+
process.exit(1);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const state = {
|
|
1347
|
+
pid: child.pid,
|
|
1348
|
+
port,
|
|
1349
|
+
url,
|
|
1350
|
+
binary,
|
|
1351
|
+
dataDir,
|
|
1352
|
+
logFile,
|
|
1353
|
+
nanoUrl,
|
|
1354
|
+
spawnNano,
|
|
1355
|
+
nanoBin: spawnNano ? env.PROCESSOS_NANO_BIN : undefined,
|
|
1356
|
+
startedAt: new Date().toISOString(),
|
|
1357
|
+
};
|
|
1358
|
+
writeProcessosState(state);
|
|
1359
|
+
|
|
1360
|
+
logger.info(` pid ${child.pid} — waiting for ${url}/health ...`);
|
|
1361
|
+
const ok = await waitForProcessosHealthy(url);
|
|
1362
|
+
if (!ok) {
|
|
1363
|
+
logger.error(
|
|
1364
|
+
`ProcessOS did not become healthy at ${url}/health. Inspect logs with "c8ctl processos logs", ` +
|
|
1365
|
+
`then "c8ctl processos stop".`,
|
|
1366
|
+
);
|
|
1367
|
+
process.exit(1);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
printProcessosSummary(state);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function stopProcessos(req) {
|
|
1374
|
+
const logger = getLogger();
|
|
1375
|
+
const state = readProcessosState();
|
|
1376
|
+
|
|
1377
|
+
if (!state) {
|
|
1378
|
+
logger.warn('No ProcessOS instance state found — nothing to stop.');
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (!isPidAlive(state.pid)) {
|
|
1382
|
+
logger.warn('ProcessOS is not running (stale state). Cleaning up.');
|
|
1383
|
+
clearProcessosState();
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
logger.info(`Stopping ProcessOS (pid ${state.pid})...`);
|
|
1388
|
+
try {
|
|
1389
|
+
process.kill(state.pid, 'SIGTERM');
|
|
1390
|
+
} catch {
|
|
1391
|
+
/* already gone */
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
1395
|
+
while (Date.now() < deadline) {
|
|
1396
|
+
if (!isPidAlive(state.pid)) break;
|
|
1397
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (isPidAlive(state.pid)) {
|
|
1401
|
+
logger.warn(` ProcessOS (pid ${state.pid}) did not exit gracefully — sending SIGKILL.`);
|
|
1402
|
+
try {
|
|
1403
|
+
process.kill(state.pid, 'SIGKILL');
|
|
1404
|
+
} catch {
|
|
1405
|
+
/* ignore */
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
clearProcessosState();
|
|
1410
|
+
logger.info('ProcessOS stopped.');
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async function statusProcessos() {
|
|
1414
|
+
const state = readProcessosState();
|
|
1415
|
+
if (!state) {
|
|
1416
|
+
console.log('ProcessOS: not running (no managed instance).');
|
|
1417
|
+
console.log(' Start one with: c8ctl processos start');
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const alive = isPidAlive(state.pid);
|
|
1422
|
+
const healthy = alive ? await probeProcessosHealthy(state.url) : false;
|
|
1423
|
+
|
|
1424
|
+
console.log('ProcessOS status:');
|
|
1425
|
+
console.log('');
|
|
1426
|
+
console.log(` pid: ${state.pid} ${alive ? '(alive)' : '(dead — stale state)'}`);
|
|
1427
|
+
console.log(` url: ${state.url}`);
|
|
1428
|
+
console.log(` health: ${healthy ? 'ok' : 'unreachable'} (${state.url}/health)`);
|
|
1429
|
+
console.log(` target: ${state.nanoUrl}`);
|
|
1430
|
+
console.log(` data dir: ${state.dataDir}`);
|
|
1431
|
+
console.log(` binary: ${state.binary}`);
|
|
1432
|
+
if (state.spawnNano) {
|
|
1433
|
+
console.log(` own nano: spawned from ${state.nanoBin}`);
|
|
1434
|
+
}
|
|
1435
|
+
console.log(` started: ${state.startedAt}`);
|
|
1436
|
+
if (!alive) {
|
|
1437
|
+
console.log('');
|
|
1438
|
+
console.log(' The recorded process is gone. Run "c8ctl processos start" to start a fresh instance.');
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function logsProcessos(req) {
|
|
1443
|
+
const logger = getLogger();
|
|
1444
|
+
const file = getProcessosLogFile();
|
|
1445
|
+
if (!existsSync(file)) {
|
|
1446
|
+
logger.warn(`No ProcessOS log file found at ${file}`);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const tailArgs = req.follow ? ['-n', '+1', '-F', file] : ['-n', '200', file];
|
|
1450
|
+
const proc = spawn('tail', tailArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
1451
|
+
proc.on('error', (err) => {
|
|
1452
|
+
logger.error(`Failed to read logs: ${err.message}`);
|
|
1453
|
+
logger.info(`Log file: ${file}`);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function printProcessosSummary(state) {
|
|
1458
|
+
console.log('');
|
|
1459
|
+
console.log(`ProcessOS is up (pid ${state.pid}).`);
|
|
1460
|
+
console.log('');
|
|
1461
|
+
console.log(` Start here ${state.url}/ (landing)`);
|
|
1462
|
+
console.log(` Cockpit ${state.url}/cockpit`);
|
|
1463
|
+
console.log(` Health ${state.url}/health`);
|
|
1464
|
+
console.log(` Target Nano ${state.nanoUrl}`);
|
|
1465
|
+
console.log('');
|
|
1466
|
+
console.log(' Inspect with: c8ctl processos status');
|
|
1467
|
+
console.log(' Stop with: c8ctl processos stop');
|
|
1468
|
+
console.log('');
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const PROCESSOS_SET_FIELDS = {
|
|
1472
|
+
bin: 'binary',
|
|
1473
|
+
binary: 'binary',
|
|
1474
|
+
port: 'port',
|
|
1475
|
+
'nano-url': 'nanoUrl',
|
|
1476
|
+
nanourl: 'nanoUrl',
|
|
1477
|
+
'data-dir': 'dataDir',
|
|
1478
|
+
datadir: 'dataDir',
|
|
1479
|
+
env: 'env',
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
function printProcessosSetUsage() {
|
|
1483
|
+
const logger = getLogger();
|
|
1484
|
+
logger.info('Usage: c8ctl processos set <field> <value>');
|
|
1485
|
+
logger.info(' bin <path> Path to the downloaded ProcessOS binary');
|
|
1486
|
+
logger.info(' port <n> Listen port (default 8090)');
|
|
1487
|
+
logger.info(' nano-url <url> Target Nano BPM engine URL (default http://localhost:8080)');
|
|
1488
|
+
logger.info(' data-dir <path> ProcessOS data directory');
|
|
1489
|
+
logger.info(' env KEY=VALUE Set a passthrough env var (e.g. PROCESSOS_LLM_MODEL); KEY= unsets it');
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function setProcessosConfig(req) {
|
|
1493
|
+
const logger = getLogger();
|
|
1494
|
+
const rawField = req.positional[0];
|
|
1495
|
+
if (!rawField) {
|
|
1496
|
+
printProcessosSetUsage();
|
|
1497
|
+
process.exit(1);
|
|
1498
|
+
}
|
|
1499
|
+
const field = PROCESSOS_SET_FIELDS[String(rawField).toLowerCase()];
|
|
1500
|
+
if (!field) {
|
|
1501
|
+
logger.error(`Unknown ProcessOS setting "${rawField}".`);
|
|
1502
|
+
printProcessosSetUsage();
|
|
1503
|
+
process.exit(1);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const cfg = readProcessosConfig();
|
|
1507
|
+
|
|
1508
|
+
if (field === 'env') {
|
|
1509
|
+
const arg = req.positional[1];
|
|
1510
|
+
if (!arg || !arg.includes('=')) {
|
|
1511
|
+
logger.error('Usage: c8ctl processos set env KEY=VALUE (use "KEY=" to unset)');
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
const idx = arg.indexOf('=');
|
|
1515
|
+
const key = arg.slice(0, idx);
|
|
1516
|
+
const val = arg.slice(idx + 1);
|
|
1517
|
+
if (!key) {
|
|
1518
|
+
logger.error('Missing env var name. Usage: c8ctl processos set env KEY=VALUE');
|
|
1519
|
+
process.exit(1);
|
|
1520
|
+
}
|
|
1521
|
+
cfg.env = cfg.env && typeof cfg.env === 'object' ? cfg.env : {};
|
|
1522
|
+
if (val === '') {
|
|
1523
|
+
delete cfg.env[key];
|
|
1524
|
+
logger.info(`Unset env ${key}`);
|
|
1525
|
+
} else {
|
|
1526
|
+
cfg.env[key] = val;
|
|
1527
|
+
logger.info(`Set env ${key}=${val}`);
|
|
1528
|
+
}
|
|
1529
|
+
} else if (field === 'binary') {
|
|
1530
|
+
const val = req.positional[1];
|
|
1531
|
+
if (!val) {
|
|
1532
|
+
logger.error('Usage: c8ctl processos set bin <path>');
|
|
1533
|
+
process.exit(1);
|
|
1534
|
+
}
|
|
1535
|
+
const abs = toAbsPath(val);
|
|
1536
|
+
if (!existsSync(abs)) {
|
|
1537
|
+
logger.error(`Binary not found at ${abs}`);
|
|
1538
|
+
process.exit(1);
|
|
1539
|
+
}
|
|
1540
|
+
cfg.binary = abs;
|
|
1541
|
+
logger.info(`Set binary = ${abs}`);
|
|
1542
|
+
} else if (field === 'port') {
|
|
1543
|
+
const n = Number.parseInt(String(req.positional[1]), 10);
|
|
1544
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1545
|
+
logger.error('Usage: c8ctl processos set port <n>');
|
|
1546
|
+
process.exit(1);
|
|
1547
|
+
}
|
|
1548
|
+
cfg.port = n;
|
|
1549
|
+
logger.info(`Set port = ${n}`);
|
|
1550
|
+
} else if (field === 'nanoUrl') {
|
|
1551
|
+
const val = req.positional[1];
|
|
1552
|
+
if (!val) {
|
|
1553
|
+
logger.error('Usage: c8ctl processos set nano-url <url>');
|
|
1554
|
+
process.exit(1);
|
|
1555
|
+
}
|
|
1556
|
+
cfg.nanoUrl = val;
|
|
1557
|
+
logger.info(`Set nano-url = ${val}`);
|
|
1558
|
+
} else if (field === 'dataDir') {
|
|
1559
|
+
const val = req.positional[1];
|
|
1560
|
+
if (!val) {
|
|
1561
|
+
logger.error('Usage: c8ctl processos set data-dir <path>');
|
|
1562
|
+
process.exit(1);
|
|
1563
|
+
}
|
|
1564
|
+
cfg.dataDir = toAbsPath(val);
|
|
1565
|
+
logger.info(`Set data-dir = ${cfg.dataDir}`);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
writeProcessosConfig(cfg);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function showProcessosConfig() {
|
|
1572
|
+
const cfg = readProcessosConfig();
|
|
1573
|
+
const nanoUrl = cfg.nanoUrl || process.env.NANO_BASE_URL || DEFAULT_NANO_URL;
|
|
1574
|
+
console.log('ProcessOS configuration:');
|
|
1575
|
+
console.log('');
|
|
1576
|
+
console.log(` binary ${cfg.binary || '(not set — "processos set bin <path>", $PROCESSOS_BINARY, or repo build)'}`);
|
|
1577
|
+
console.log(` port ${Number.isFinite(cfg.port) ? cfg.port : PROCESSOS_DEFAULT_PORT}${Number.isFinite(cfg.port) ? '' : ' (default)'}`);
|
|
1578
|
+
console.log(` nano-url ${nanoUrl}${cfg.nanoUrl ? '' : ' (default)'}`);
|
|
1579
|
+
console.log(` data dir ${getProcessosDataDir()}${cfg.dataDir ? '' : ' (default)'}`);
|
|
1580
|
+
const env = cfg.env && typeof cfg.env === 'object' ? cfg.env : {};
|
|
1581
|
+
const keys = Object.keys(env);
|
|
1582
|
+
if (keys.length > 0) {
|
|
1583
|
+
console.log('');
|
|
1584
|
+
console.log(' env (passthrough):');
|
|
1585
|
+
for (const k of keys.sort()) {
|
|
1586
|
+
console.log(` ${k}=${env[k]}`);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
console.log('');
|
|
1590
|
+
console.log(` state file ${getProcessosStateFile()}`);
|
|
1591
|
+
console.log(` log file ${getProcessosLogFile()}`);
|
|
1592
|
+
console.log('');
|
|
1593
|
+
console.log(' Change with: c8ctl processos set bin <path> | set port <n> | set nano-url <url> | set data-dir <path> | set env KEY=VALUE');
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function printProcessosUsage() {
|
|
1597
|
+
console.log('Manage a local ProcessOS instance (optimization-plane server for Nano BPM).');
|
|
1598
|
+
console.log('');
|
|
1599
|
+
console.log('Usage:');
|
|
1600
|
+
console.log(' c8ctl processos start [--port <n>] [--nano-url <url>] [--binary <path>] [--no-spawn-nano] [--force]');
|
|
1601
|
+
console.log(' c8ctl processos status');
|
|
1602
|
+
console.log(' c8ctl processos stop');
|
|
1603
|
+
console.log(' c8ctl processos restart [...]');
|
|
1604
|
+
console.log(' c8ctl processos logs [--follow]');
|
|
1605
|
+
console.log(' c8ctl processos set bin <path> | port <n> | nano-url <url> | data-dir <path> | env KEY=VALUE');
|
|
1606
|
+
console.log(' c8ctl processos config');
|
|
1607
|
+
console.log('');
|
|
1608
|
+
console.log('ProcessOS is downloaded manually; point the plugin at it with "c8ctl processos set bin <path>".');
|
|
1609
|
+
console.log('By default ProcessOS spawns its own internal pilot Nano engine (the plugin auto-wires the nano');
|
|
1610
|
+
console.log('binary into PROCESSOS_NANO_BIN). Use --no-spawn-nano to instead use the --nano-url engine for');
|
|
1611
|
+
console.log('the pilot too. If no nano binary is available, it falls back to --no-spawn-nano automatically.');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function parseProcessosRequest(args, flags) {
|
|
1615
|
+
const subcommand = args[0];
|
|
1616
|
+
const positional = args.slice(1).filter((a) => !String(a).startsWith('-'));
|
|
1617
|
+
const portRaw = flags?.port;
|
|
1618
|
+
const port =
|
|
1619
|
+
portRaw === undefined || portRaw === null || portRaw === ''
|
|
1620
|
+
? undefined
|
|
1621
|
+
: Number.parseInt(String(portRaw), 10);
|
|
1622
|
+
return {
|
|
1623
|
+
subcommand,
|
|
1624
|
+
positional,
|
|
1625
|
+
port: Number.isFinite(port) ? port : undefined,
|
|
1626
|
+
nanoUrl: flags?.['nano-url'] || flags?.nanoUrl,
|
|
1627
|
+
binary: flags?.binary,
|
|
1628
|
+
spawnNano: Boolean(flags?.['spawn-nano'] || flags?.spawnNano),
|
|
1629
|
+
noSpawnNano: Boolean(flags?.['no-spawn-nano'] || flags?.noSpawnNano),
|
|
1630
|
+
follow: Boolean(flags?.follow),
|
|
1631
|
+
force: Boolean(flags?.force),
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ---------------------------------------------------------------------------
|
|
1636
|
+
// metadata + commands
|
|
1637
|
+
// ---------------------------------------------------------------------------
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
export const metadata = {
|
|
1641
|
+
name: 'c8ctl-plugin-nano',
|
|
1642
|
+
description: 'Start, inspect, and stop a local Nano BPM (nanobpmn) cluster',
|
|
1643
|
+
commands: {
|
|
1644
|
+
nano: {
|
|
1645
|
+
description: 'Manage a local Nano BPM cluster — start, status, stop, logs',
|
|
1646
|
+
examples: [
|
|
1647
|
+
{ command: 'c8ctl nano start', description: 'Start a single-node Nano BPM cluster' },
|
|
1648
|
+
{ command: 'c8ctl nano start 3', description: 'Start a 3-node local cluster' },
|
|
1649
|
+
{
|
|
1650
|
+
command: 'c8ctl nano start 3 --rf 3',
|
|
1651
|
+
description: 'Start a 3-node Raft-replicated cluster (RF=3)',
|
|
1652
|
+
},
|
|
1653
|
+
{ command: 'c8ctl nano start 3 --port 9000', description: 'Start 3 nodes on ports 9000..9002' },
|
|
1654
|
+
{ command: 'c8ctl nano start --capture', description: 'Start with trace capture for historical replay/analysis' },
|
|
1655
|
+
{ command: 'c8ctl nano status', description: 'Show cluster status and per-node health' },
|
|
1656
|
+
{ command: 'c8ctl nano pause 1', description: 'Freeze node 1 (SIGSTOP) to simulate a node failure' },
|
|
1657
|
+
{ command: 'c8ctl nano resume 1', description: 'Resume node 1 (SIGCONT) to bring it back online' },
|
|
1658
|
+
{ command: 'c8ctl nano logs 1 --follow', description: "Stream node 1's log" },
|
|
1659
|
+
{ command: 'c8ctl nano stop', description: 'Stop the running cluster (keep data)' },
|
|
1660
|
+
{ command: 'c8ctl nano stop --purge', description: 'Stop the cluster and delete engine data' },
|
|
1661
|
+
{ command: 'c8ctl nano clean', description: 'Wipe journal/data + logs on disk (keeps models/workers)' },
|
|
1662
|
+
{ command: 'c8ctl nano set bin <path>', description: 'Set the nanobpmn server binary path' },
|
|
1663
|
+
{ command: 'c8ctl nano set model-dir <path>', description: 'Set the workspace dir (models + workers)' },
|
|
1664
|
+
{ command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
|
|
1665
|
+
],
|
|
1666
|
+
},
|
|
1667
|
+
processos: {
|
|
1668
|
+
description: 'Manage a local ProcessOS instance — start, status, stop, logs, config',
|
|
1669
|
+
examples: [
|
|
1670
|
+
{ command: 'c8ctl processos set bin <path>', description: 'Point the plugin at the downloaded ProcessOS binary' },
|
|
1671
|
+
{ command: 'c8ctl processos start', description: 'Start ProcessOS against the local Nano BPM engine' },
|
|
1672
|
+
{ command: 'c8ctl processos start --nano-url http://localhost:8080', description: 'Start against a specific engine' },
|
|
1673
|
+
{ command: 'c8ctl processos status', description: 'Show ProcessOS status and health' },
|
|
1674
|
+
{ command: 'c8ctl processos logs --follow', description: "Stream ProcessOS's log" },
|
|
1675
|
+
{ command: 'c8ctl processos stop', description: 'Stop the running ProcessOS instance' },
|
|
1676
|
+
{ command: 'c8ctl processos set port 8090', description: 'Set the listen port' },
|
|
1677
|
+
{ command: 'c8ctl processos set nano-url <url>', description: 'Set the target Nano BPM engine URL' },
|
|
1678
|
+
{ command: 'c8ctl processos set env PROCESSOS_LLM_MODEL=...', description: 'Set a passthrough env var' },
|
|
1679
|
+
{ command: 'c8ctl processos config', description: 'Show current ProcessOS configuration and paths' },
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
export const commands = {
|
|
1686
|
+
nano: {
|
|
1687
|
+
flags: {
|
|
1688
|
+
nodes: { type: 'string', description: 'Number of nodes to start (alt to positional arg)' },
|
|
1689
|
+
port: { type: 'string', description: 'start: base port (node i = basePort+i); status: endpoint port to probe (default 8080)' },
|
|
1690
|
+
partitions: { type: 'string', description: 'Total partitions across the cluster (default = node count)' },
|
|
1691
|
+
rf: { type: 'string', description: 'Replication factor; >1 enables Raft (default 1)' },
|
|
1692
|
+
raft: { type: 'boolean', description: 'Force per-partition Raft on/off (default: on when rf>1)' },
|
|
1693
|
+
capture: { type: 'boolean', description: 'start: enable trace capture (recorded-input replay) on every node' },
|
|
1694
|
+
follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
|
|
1695
|
+
purge: { type: 'boolean', description: 'stop: also delete per-node engine data' },
|
|
1696
|
+
force: { type: 'boolean', description: 'start: stop any existing cluster first' },
|
|
1697
|
+
workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
|
|
1698
|
+
binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
|
|
1699
|
+
},
|
|
1700
|
+
handler: async (args, flags) => {
|
|
1701
|
+
const logger = getLogger();
|
|
1702
|
+
const req = parseRequest(args, flags);
|
|
1703
|
+
|
|
1704
|
+
if (!req.subcommand || !VALID_SUBCOMMANDS.includes(req.subcommand)) {
|
|
1705
|
+
printUsage();
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
try {
|
|
1710
|
+
switch (req.subcommand) {
|
|
1711
|
+
case 'start':
|
|
1712
|
+
await startCluster(req);
|
|
1713
|
+
break;
|
|
1714
|
+
case 'stop':
|
|
1715
|
+
await stopCluster(req);
|
|
1716
|
+
break;
|
|
1717
|
+
case 'status':
|
|
1718
|
+
await statusCluster(req);
|
|
1719
|
+
break;
|
|
1720
|
+
case 'log':
|
|
1721
|
+
case 'logs':
|
|
1722
|
+
logsCluster(req);
|
|
1723
|
+
break;
|
|
1724
|
+
case 'restart':
|
|
1725
|
+
await stopCluster({ purge: false });
|
|
1726
|
+
await startCluster({ ...req, force: true });
|
|
1727
|
+
break;
|
|
1728
|
+
case 'pause':
|
|
1729
|
+
controlNode(req, { signal: 'SIGSTOP', verb: 'pause', paused: true });
|
|
1730
|
+
break;
|
|
1731
|
+
case 'resume':
|
|
1732
|
+
controlNode(req, { signal: 'SIGCONT', verb: 'resume', paused: false });
|
|
1733
|
+
break;
|
|
1734
|
+
case 'clean':
|
|
1735
|
+
cleanCluster(req);
|
|
1736
|
+
break;
|
|
1737
|
+
case 'set':
|
|
1738
|
+
setConfig(req);
|
|
1739
|
+
break;
|
|
1740
|
+
case 'config':
|
|
1741
|
+
showConfig();
|
|
1742
|
+
break;
|
|
1743
|
+
}
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
|
|
1746
|
+
process.exit(1);
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
},
|
|
1750
|
+
processos: {
|
|
1751
|
+
flags: {
|
|
1752
|
+
port: { type: 'string', description: 'start: listen port (default 8090)' },
|
|
1753
|
+
'nano-url': { type: 'string', description: 'start: target Nano BPM engine URL (default http://localhost:8080)' },
|
|
1754
|
+
binary: { type: 'string', description: 'Path to the ProcessOS binary' },
|
|
1755
|
+
'spawn-nano': { type: 'boolean', description: 'start: force ProcessOS to spawn its own pilot Nano engine (default on when a nano binary is available)' },
|
|
1756
|
+
'no-spawn-nano': { type: 'boolean', description: 'start: do NOT spawn a pilot engine; use the --nano-url engine for the pilot too' },
|
|
1757
|
+
follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
|
|
1758
|
+
force: { type: 'boolean', description: 'start: stop any existing instance first' },
|
|
1759
|
+
},
|
|
1760
|
+
handler: async (args, flags) => {
|
|
1761
|
+
const logger = getLogger();
|
|
1762
|
+
const req = parseProcessosRequest(args, flags);
|
|
1763
|
+
|
|
1764
|
+
if (!req.subcommand || !PROCESSOS_VALID_SUBCOMMANDS.includes(req.subcommand)) {
|
|
1765
|
+
printProcessosUsage();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
try {
|
|
1770
|
+
switch (req.subcommand) {
|
|
1771
|
+
case 'start':
|
|
1772
|
+
await startProcessos(req);
|
|
1773
|
+
break;
|
|
1774
|
+
case 'stop':
|
|
1775
|
+
await stopProcessos(req);
|
|
1776
|
+
break;
|
|
1777
|
+
case 'status':
|
|
1778
|
+
await statusProcessos();
|
|
1779
|
+
break;
|
|
1780
|
+
case 'log':
|
|
1781
|
+
case 'logs':
|
|
1782
|
+
logsProcessos(req);
|
|
1783
|
+
break;
|
|
1784
|
+
case 'restart':
|
|
1785
|
+
await stopProcessos({});
|
|
1786
|
+
await startProcessos({ ...req, force: true });
|
|
1787
|
+
break;
|
|
1788
|
+
case 'set':
|
|
1789
|
+
setProcessosConfig(req);
|
|
1790
|
+
break;
|
|
1791
|
+
case 'config':
|
|
1792
|
+
showProcessosConfig();
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
logger.error(`processos ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
|
|
1797
|
+
process.exit(1);
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
},
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
function printUsage() {
|
|
1804
|
+
console.log('Usage:');
|
|
1805
|
+
console.log(' c8ctl nano start [<nodes>] [--port <basePort>] [--partitions <n>] [--rf <n>] [--raft] [--capture] [--binary <path>]');
|
|
1806
|
+
console.log(' c8ctl nano status [--port <port>]');
|
|
1807
|
+
console.log(' c8ctl nano stop [--purge]');
|
|
1808
|
+
console.log(' c8ctl nano logs [<nodeId>] [--follow]');
|
|
1809
|
+
console.log(' c8ctl nano pause <nodeId>');
|
|
1810
|
+
console.log(' c8ctl nano resume <nodeId>');
|
|
1811
|
+
console.log(' c8ctl nano restart [<nodes>] ...');
|
|
1812
|
+
console.log(' c8ctl nano clean [--workspace]');
|
|
1813
|
+
console.log(' c8ctl nano set <bin|model-dir> <path>');
|
|
1814
|
+
console.log(' c8ctl nano config');
|
|
1815
|
+
console.log('');
|
|
1816
|
+
console.log('Subcommands:');
|
|
1817
|
+
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
1818
|
+
console.log(' status Show cluster status; queries /v2/topology (works for any running node)');
|
|
1819
|
+
console.log(' stop Stop all nodes (add --purge to also delete engine data)');
|
|
1820
|
+
console.log(' logs Show or follow node logs');
|
|
1821
|
+
console.log(' pause Freeze a node (SIGSTOP) to simulate it failing');
|
|
1822
|
+
console.log(' resume Resume a frozen node (SIGCONT) to bring it back online');
|
|
1823
|
+
console.log(' restart Stop then start');
|
|
1824
|
+
console.log(' clean Wipe journal/data + logs on disk (keeps models/workers)');
|
|
1825
|
+
console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
|
|
1826
|
+
console.log(' config Show current configuration and on-disk locations');
|
|
1827
|
+
console.log('');
|
|
1828
|
+
console.log('Options:');
|
|
1829
|
+
console.log(' <nodes> Number of nodes to start (default 1)');
|
|
1830
|
+
console.log(' --port <basePort> start: base port (node i = basePort+i); status: port to probe (default 8080)');
|
|
1831
|
+
console.log(' --partitions <n> Total partitions across the cluster (default = node count)');
|
|
1832
|
+
console.log(' --rf <n> Replication factor; >1 enables Raft (default 1)');
|
|
1833
|
+
console.log(' --raft Force Raft on (default: on iff rf>1)');
|
|
1834
|
+
console.log(' --capture start: enable trace capture (recorded-input replay) on every node');
|
|
1835
|
+
console.log(' --binary <path> Path to the nanobpmn server binary (overrides "set bin")');
|
|
1836
|
+
console.log(' --purge stop: also delete per-node engine data');
|
|
1837
|
+
console.log(' --force start: stop any existing cluster first');
|
|
1838
|
+
console.log(' --workspace clean: also delete the workspace (models + workers)');
|
|
1839
|
+
console.log('');
|
|
1840
|
+
console.log('Persistent assets:');
|
|
1841
|
+
console.log(' Models and workers live in the workspace dir (NANOBPMN_WORKSPACE_DIR),');
|
|
1842
|
+
console.log(' shared by all nodes and never touched by "stop" or "clean". Engine data');
|
|
1843
|
+
console.log(' (journal/snapshots/spill) is per-node and ephemeral. Set the workspace');
|
|
1844
|
+
console.log(' location with "c8ctl nano set model-dir <path>"; see "c8ctl nano config".');
|
|
1845
|
+
console.log('');
|
|
1846
|
+
console.log('Trace capture (--capture):');
|
|
1847
|
+
console.log(' Sets NANOBPMN_TRACE_STIMULI=1 on every node, enabling the recorded-input');
|
|
1848
|
+
console.log(' (stimuli) log plus variable capture for historical replay/analysis. Read a');
|
|
1849
|
+
console.log(' trace with GET /console/api/traces/{instanceKey} (creationVariables +');
|
|
1850
|
+
console.log(' stimuli[] + per-incident variables). Tune via env vars passed through from');
|
|
1851
|
+
console.log(' your shell: NANOBPMN_TRACE_VARIABLES_MAX_BYTES (16384), NANOBPMN_TRACE_STIMULI_MAX');
|
|
1852
|
+
console.log(' (1024), NANOBPMN_TRACE_CAPACITY (2000).');
|
|
1853
|
+
console.log('');
|
|
1854
|
+
console.log('Examples:');
|
|
1855
|
+
console.log(' c8ctl nano start 3 # 3-node cluster on ports 8080..8082');
|
|
1856
|
+
console.log(' c8ctl nano start 3 --rf 3 # 3-node Raft-replicated cluster');
|
|
1857
|
+
console.log(' c8ctl nano start --capture # single node with trace capture for replay');
|
|
1858
|
+
console.log(' c8ctl nano status');
|
|
1859
|
+
console.log(' c8ctl nano pause 1 # freeze node 1 to simulate a failure');
|
|
1860
|
+
console.log(' c8ctl nano resume 1 # bring node 1 back online');
|
|
1861
|
+
console.log(' c8ctl nano logs 1 --follow');
|
|
1862
|
+
console.log(' c8ctl nano stop --purge');
|
|
1863
|
+
console.log(' c8ctl nano clean # free disk after stopping, keep models/workers');
|
|
1864
|
+
console.log(' c8ctl nano set bin ~/workspace/nanobpmn/server/target/release/nanobpm-gateway-rest-server');
|
|
1865
|
+
console.log(' c8ctl nano set model-dir ~/bpmn-workspace');
|
|
1866
|
+
}
|