c8ctl-plugin-nano 1.8.0 → 1.9.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 +67 -0
- package/c8ctl-plugin.js +479 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -130,6 +130,73 @@ c8ctl nano set model-dir ~/bpmn-workspace
|
|
|
130
130
|
This creates `~/bpmn-workspace/models/` and `~/bpmn-workspace/workers/`. Restart a
|
|
131
131
|
running cluster for a workspace change to take effect.
|
|
132
132
|
|
|
133
|
+
## CLI agent workers: `hire` / `work`
|
|
134
|
+
|
|
135
|
+
Beyond BPMN service-task workers (code in the workspace `workers/` dir), the
|
|
136
|
+
plugin can turn an interactive **CLI agent harness** (Copilot CLI, Claude CLI,
|
|
137
|
+
`pi`, "little coder", …) into a Nano job worker.
|
|
138
|
+
|
|
139
|
+
**`hire`** persists an agent *profile* — a name, a **rank**
|
|
140
|
+
(`principal|senior|junior|decider`), the **command** that starts the CLI, a
|
|
141
|
+
**model** name, and a list of **capabilities**:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Interactive
|
|
145
|
+
c8ctl nano hire
|
|
146
|
+
|
|
147
|
+
# Or non-interactively
|
|
148
|
+
c8ctl nano hire --name reviewer --rank senior --command copilot \
|
|
149
|
+
--model gpt-5 --capabilities code-review,testing
|
|
150
|
+
|
|
151
|
+
# List profiles
|
|
152
|
+
c8ctl nano hire --list
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**`work <name>`** loads the profile, connects with the c8ctl SDK client, and
|
|
156
|
+
registers one job worker per token in the **rank × capability matrix**, then
|
|
157
|
+
polls for work in the foreground until Ctrl-C. For rank `senior` and
|
|
158
|
+
capabilities `code-review, testing` the matrix is:
|
|
159
|
+
|
|
160
|
+
| Token | Meaning |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `senior` | rank alone |
|
|
163
|
+
| `senior:code-review` | rank + one capability (spread) |
|
|
164
|
+
| `senior:testing` | rank + one capability (spread) |
|
|
165
|
+
| `senior:code-review+testing` | rank + all capabilities, sorted (combined) |
|
|
166
|
+
|
|
167
|
+
so a BPMN service task can target a worker at any granularity by setting its job
|
|
168
|
+
type to the matching token.
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
c8ctl nano work reviewer # poll for work until Ctrl-C
|
|
172
|
+
c8ctl nano work reviewer --max-parallel 2 --job-timeout 600000
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Each activated job runs the profile's command **once** (one-shot): the job is
|
|
176
|
+
serialized to JSON and piped to the CLI's **stdin** —
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{ "jobKey": "...", "jobType": "senior:code-review", "processInstanceKey": "...",
|
|
180
|
+
"prompt": "<variables.prompt ?? variables.task>", "variables": {},
|
|
181
|
+
"profile": { "name": "reviewer", "rank": "senior", "model": "gpt-5",
|
|
182
|
+
"capabilities": ["code-review", "testing"] } }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
and the profile/model are also exported as `AGENT_PROFILE`, `AGENT_RANK`,
|
|
186
|
+
`AGENT_MODEL`, `AGENT_CAPABILITIES`, `AGENT_JOB_TYPE` env vars. On exit `0` the
|
|
187
|
+
job is **completed** with `{ output: <stdout>, exitCode: 0 }` (captured output is
|
|
188
|
+
capped at 1 MiB, with a `truncated` flag when exceeded); any other exit **fails**
|
|
189
|
+
the job with a decremented retry count, and a job that outlives `--job-timeout`
|
|
190
|
+
is killed. Profiles are stored in the plugin's `config.json` (see `c8ctl nano
|
|
191
|
+
config`).
|
|
192
|
+
|
|
193
|
+
> **Trust boundary.** The profile `command` is run through a shell so you can
|
|
194
|
+
> write a full invocation (args, pipes, multi-word commands). It is
|
|
195
|
+
> **operator-authored** — only what you put in your own `config.json` is
|
|
196
|
+
> shell-interpreted. Untrusted job data reaches the harness solely as stdin JSON
|
|
197
|
+
> and `AGENT_*` env vars, never interpolated into the command line, so process
|
|
198
|
+
> variables cannot inject shell commands.
|
|
199
|
+
|
|
133
200
|
## Cleaning up disk
|
|
134
201
|
|
|
135
202
|
```bash
|
package/c8ctl-plugin.js
CHANGED
|
@@ -45,6 +45,7 @@ import { homedir, platform as osPlatform } from 'node:os';
|
|
|
45
45
|
import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
|
|
46
46
|
import { createRequire } from 'node:module';
|
|
47
47
|
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { createInterface } from 'node:readline/promises';
|
|
48
49
|
import { platformForHost } from './platforms.mjs';
|
|
49
50
|
|
|
50
51
|
const requireFromHere = createRequire(import.meta.url);
|
|
@@ -363,7 +364,7 @@ function launcherEnvMarkers(resolved) {
|
|
|
363
364
|
// Argument parsing
|
|
364
365
|
// ---------------------------------------------------------------------------
|
|
365
366
|
|
|
366
|
-
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update'];
|
|
367
|
+
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update', 'hire', 'work'];
|
|
367
368
|
|
|
368
369
|
/**
|
|
369
370
|
* Parse positional args + flags into a normalized request.
|
|
@@ -1293,6 +1294,461 @@ function showConfig() {
|
|
|
1293
1294
|
console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
|
|
1294
1295
|
}
|
|
1295
1296
|
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
// hire / work — CLI agent harness workers.
|
|
1299
|
+
//
|
|
1300
|
+
// A "hire" is a persisted agent profile (name, rank, CLI command, model,
|
|
1301
|
+
// capabilities). "work <name>" turns that profile into a set of Nano job
|
|
1302
|
+
// workers: one per job-type in the rank×capability matrix. When a job is
|
|
1303
|
+
// activated, the profile's CLI command is spawned fresh (one-shot), fed the job
|
|
1304
|
+
// as JSON on stdin, and its stdout is returned as the job's `output` variable.
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
const RANKS = ['principal', 'senior', 'junior', 'decider'];
|
|
1308
|
+
|
|
1309
|
+
/** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
|
|
1310
|
+
function normalizeCapabilities(input) {
|
|
1311
|
+
const raw = Array.isArray(input)
|
|
1312
|
+
? input
|
|
1313
|
+
: String(input || '').split(',');
|
|
1314
|
+
return [...new Set(raw.map((c) => String(c).trim().toLowerCase()).filter(Boolean))].sort();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** A profile name must be a safe, filesystem/token-friendly slug. */
|
|
1318
|
+
function isValidProfileName(name) {
|
|
1319
|
+
return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* The job-type matrix a worker subscribes to, from a profile's rank
|
|
1324
|
+
* and sorted capabilities [c1, c2, ...]:
|
|
1325
|
+
* - `rank` (rank alone)
|
|
1326
|
+
* - `rank:c1`, `rank:c2` (rank + a single capability, "spread")
|
|
1327
|
+
* - `rank:c1+c2+...` (rank + all capabilities combined; only when >1 cap)
|
|
1328
|
+
* Delimiters: `:` separates rank from capabilities, `+` joins combined caps.
|
|
1329
|
+
* Capabilities are sorted so the combined token is canonical/predictable.
|
|
1330
|
+
*/
|
|
1331
|
+
function jobTypeMatrix(rank, capabilities) {
|
|
1332
|
+
const caps = normalizeCapabilities(capabilities);
|
|
1333
|
+
const tokens = [rank];
|
|
1334
|
+
for (const c of caps) tokens.push(`${rank}:${c}`);
|
|
1335
|
+
if (caps.length > 1) tokens.push(`${rank}:${caps.join('+')}`);
|
|
1336
|
+
return [...new Set(tokens)];
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** All persisted hire profiles, keyed by name. */
|
|
1340
|
+
function readHires() {
|
|
1341
|
+
const cfg = readConfig();
|
|
1342
|
+
// A JSON array is `typeof === 'object'` but drops string-keyed writes on
|
|
1343
|
+
// JSON.stringify, so treat only plain objects as a valid hires map.
|
|
1344
|
+
return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Persist a single hire profile into config.json under `hires`. */
|
|
1348
|
+
function writeHire(profile) {
|
|
1349
|
+
const cfg = readConfig();
|
|
1350
|
+
if (!cfg.hires || typeof cfg.hires !== 'object' || Array.isArray(cfg.hires)) cfg.hires = {};
|
|
1351
|
+
cfg.hires[profile.name] = profile;
|
|
1352
|
+
writeConfig(cfg);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Validate and normalize a stored profile before use so a hand-edited or
|
|
1357
|
+
* version-skewed config.json can't produce undefined job types or an invalid
|
|
1358
|
+
* spawn. Returns the normalized profile, or a { error } describing the problem.
|
|
1359
|
+
*/
|
|
1360
|
+
function normalizeStoredProfile(name, profile) {
|
|
1361
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
1362
|
+
return { error: `profile "${name}" is not an object` };
|
|
1363
|
+
}
|
|
1364
|
+
const rank = String(profile.rank || '').trim().toLowerCase();
|
|
1365
|
+
if (!RANKS.includes(rank)) {
|
|
1366
|
+
return { error: `profile "${name}" has an invalid rank "${profile.rank}" (expected one of: ${RANKS.join(', ')})` };
|
|
1367
|
+
}
|
|
1368
|
+
const command = String(profile.command || '').trim();
|
|
1369
|
+
if (!command) {
|
|
1370
|
+
return { error: `profile "${name}" has no command to run` };
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
profile: {
|
|
1374
|
+
name,
|
|
1375
|
+
rank,
|
|
1376
|
+
command,
|
|
1377
|
+
model: typeof profile.model === 'string' ? profile.model.trim() : '',
|
|
1378
|
+
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1379
|
+
},
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* hire — create (or overwrite) an agent profile. Interactive by default; every
|
|
1385
|
+
* field can also be supplied via a flag (--name/--rank/--command/--model/
|
|
1386
|
+
* --capabilities) for scripting. Prompts only for the fields still missing.
|
|
1387
|
+
* `--list` prints existing profiles instead.
|
|
1388
|
+
*/
|
|
1389
|
+
async function hireWorker(req, flags) {
|
|
1390
|
+
const logger = getLogger();
|
|
1391
|
+
|
|
1392
|
+
if (flags?.list) {
|
|
1393
|
+
const hires = readHires();
|
|
1394
|
+
const names = Object.keys(hires);
|
|
1395
|
+
if (names.length === 0) {
|
|
1396
|
+
logger.info('No hires yet. Create one with: c8ctl nano hire');
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
logger.info('Hired agent profiles:');
|
|
1400
|
+
for (const name of names.sort()) {
|
|
1401
|
+
const p = hires[name];
|
|
1402
|
+
logger.info(` ${name} [${p.rank}] ${p.command} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
|
|
1403
|
+
}
|
|
1404
|
+
logger.info('');
|
|
1405
|
+
logger.info('Put one to work with: c8ctl nano work <name>');
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Seed from flags; prompt for anything still missing. Trim string flags so a
|
|
1410
|
+
// stray space can't be persisted into config.json or the spawned command.
|
|
1411
|
+
let name = flags?.name ? String(flags.name).trim() : req.positional[0];
|
|
1412
|
+
let rank = flags?.rank ? String(flags.rank).trim().toLowerCase() : undefined;
|
|
1413
|
+
let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
|
|
1414
|
+
let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
|
|
1415
|
+
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1416
|
+
|
|
1417
|
+
const missingRequired = !name || !rank || !command;
|
|
1418
|
+
const missingOptional = model === undefined || capabilities === undefined;
|
|
1419
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
1420
|
+
|
|
1421
|
+
// Non-interactively only name/rank/command are required; model and
|
|
1422
|
+
// capabilities are optional (they default to empty), matching how the
|
|
1423
|
+
// interactive prompts label them.
|
|
1424
|
+
if (missingRequired && !interactive) {
|
|
1425
|
+
logger.error('Non-interactive: provide at least --name, --rank and --command.');
|
|
1426
|
+
logger.info('Example: c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing');
|
|
1427
|
+
process.exit(1);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (interactive && (missingRequired || missingOptional)) {
|
|
1431
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1432
|
+
try {
|
|
1433
|
+
console.log('Hire a CLI agent worker. Press Ctrl-C to cancel.');
|
|
1434
|
+
console.log('');
|
|
1435
|
+
while (!name) {
|
|
1436
|
+
const ans = (await rl.question('Profile name: ')).trim();
|
|
1437
|
+
if (isValidProfileName(ans)) { name = ans; break; }
|
|
1438
|
+
console.log(' Please use letters, digits, dot, dash or underscore.');
|
|
1439
|
+
}
|
|
1440
|
+
while (!rank) {
|
|
1441
|
+
const ans = (await rl.question(`Rank (${RANKS.join('|')}): `)).trim().toLowerCase();
|
|
1442
|
+
if (RANKS.includes(ans)) { rank = ans; break; }
|
|
1443
|
+
console.log(` Rank must be one of: ${RANKS.join(', ')}`);
|
|
1444
|
+
}
|
|
1445
|
+
while (!command) {
|
|
1446
|
+
const ans = (await rl.question('CLI command (e.g. copilot, claude, pi): ')).trim();
|
|
1447
|
+
if (ans) { command = ans; break; }
|
|
1448
|
+
console.log(' A command is required.');
|
|
1449
|
+
}
|
|
1450
|
+
if (model === undefined) {
|
|
1451
|
+
model = (await rl.question('Model name (optional): ')).trim();
|
|
1452
|
+
}
|
|
1453
|
+
if (capabilities === undefined) {
|
|
1454
|
+
capabilities = (await rl.question('Capabilities (comma-separated, optional): ')).trim();
|
|
1455
|
+
}
|
|
1456
|
+
} finally {
|
|
1457
|
+
rl.close();
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Optional fields default to empty when omitted (e.g. scripted invocations).
|
|
1462
|
+
if (model === undefined) model = '';
|
|
1463
|
+
if (capabilities === undefined) capabilities = '';
|
|
1464
|
+
|
|
1465
|
+
if (!isValidProfileName(name)) {
|
|
1466
|
+
logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
|
|
1467
|
+
process.exit(1);
|
|
1468
|
+
}
|
|
1469
|
+
if (!RANKS.includes(rank)) {
|
|
1470
|
+
logger.error(`Invalid rank "${rank}". Must be one of: ${RANKS.join(', ')}`);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
if (!command) {
|
|
1474
|
+
logger.error('A CLI command is required.');
|
|
1475
|
+
process.exit(1);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const existed = Boolean(readHires()[name]);
|
|
1479
|
+
const profile = {
|
|
1480
|
+
name,
|
|
1481
|
+
rank,
|
|
1482
|
+
command,
|
|
1483
|
+
model: model || '',
|
|
1484
|
+
capabilities: normalizeCapabilities(capabilities),
|
|
1485
|
+
createdAt: new Date().toISOString(),
|
|
1486
|
+
};
|
|
1487
|
+
writeHire(profile);
|
|
1488
|
+
|
|
1489
|
+
const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
|
|
1490
|
+
logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
|
|
1491
|
+
logger.info(` model: ${profile.model || '(none)'}`);
|
|
1492
|
+
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1493
|
+
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
1494
|
+
logger.info(`Put it to work with: c8ctl nano work ${name}`);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Concatenate captured Buffer chunks into a UTF-8 string, dropping a trailing
|
|
1499
|
+
* incomplete multibyte sequence (which the byte cap may have split) so decoding
|
|
1500
|
+
* never emits a replacement char or pushes the string over the byte cap.
|
|
1501
|
+
*/
|
|
1502
|
+
function joinCapped(chunks) {
|
|
1503
|
+
if (!chunks.length) return '';
|
|
1504
|
+
let buf = Buffer.concat(chunks);
|
|
1505
|
+
let i = buf.length - 1;
|
|
1506
|
+
let cont = 0;
|
|
1507
|
+
while (i >= 0 && (buf[i] & 0xc0) === 0x80 && cont < 3) { i -= 1; cont += 1; }
|
|
1508
|
+
if (i >= 0) {
|
|
1509
|
+
const lead = buf[i];
|
|
1510
|
+
let needed;
|
|
1511
|
+
if ((lead & 0x80) === 0x00) needed = 0;
|
|
1512
|
+
else if ((lead & 0xe0) === 0xc0) needed = 1;
|
|
1513
|
+
else if ((lead & 0xf0) === 0xe0) needed = 2;
|
|
1514
|
+
else if ((lead & 0xf8) === 0xf0) needed = 3;
|
|
1515
|
+
else needed = -1;
|
|
1516
|
+
if (needed > 0 && cont < needed) buf = buf.subarray(0, i);
|
|
1517
|
+
}
|
|
1518
|
+
return buf.toString('utf8');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Kill a spawned child and its whole process tree. With `detached: true` on
|
|
1523
|
+
* POSIX the child leads its own process group, so a negative PID signals every
|
|
1524
|
+
* process in that group (shell wrapper + the actual harness command). Falls
|
|
1525
|
+
* back to a plain child.kill() on Windows or if the group signal fails.
|
|
1526
|
+
*/
|
|
1527
|
+
function killTree(child) {
|
|
1528
|
+
const pid = child.pid;
|
|
1529
|
+
if (process.platform !== 'win32' && typeof pid === 'number') {
|
|
1530
|
+
try { process.kill(-pid, 'SIGKILL'); return; } catch { /* fall through */ }
|
|
1531
|
+
}
|
|
1532
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Run a single activated job through the profile's CLI command (one-shot):
|
|
1537
|
+
* spawn the command fresh, pipe the job as JSON on stdin, capture stdout, and
|
|
1538
|
+
* resolve to a job action. Exit 0 → complete with { output, exitCode }; any
|
|
1539
|
+
* other exit (or spawn failure) → fail with a decremented retry count.
|
|
1540
|
+
* A child that outlives `timeoutMs` is killed and reported as a failure so it
|
|
1541
|
+
* never leaks a worker slot.
|
|
1542
|
+
*/
|
|
1543
|
+
function runAgentJob(profile, job, timeoutMs) {
|
|
1544
|
+
return new Promise((resolve) => {
|
|
1545
|
+
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
1546
|
+
const payload = {
|
|
1547
|
+
jobKey: job.jobKey,
|
|
1548
|
+
jobType: job.type,
|
|
1549
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
1550
|
+
elementInstanceKey: job.elementInstanceKey ?? null,
|
|
1551
|
+
elementId: job.elementId ?? null,
|
|
1552
|
+
bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
|
|
1553
|
+
prompt: variables.prompt ?? variables.task ?? null,
|
|
1554
|
+
variables,
|
|
1555
|
+
customHeaders: job.customHeaders ?? {},
|
|
1556
|
+
profile: {
|
|
1557
|
+
name: profile.name,
|
|
1558
|
+
rank: profile.rank,
|
|
1559
|
+
model: profile.model,
|
|
1560
|
+
capabilities: profile.capabilities,
|
|
1561
|
+
},
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
const child = spawn(profile.command, {
|
|
1565
|
+
shell: true,
|
|
1566
|
+
// Run the shell in its own process group so the timeout handler can kill
|
|
1567
|
+
// the whole tree (shell + harness), not just the shell wrapper PID.
|
|
1568
|
+
detached: process.platform !== 'win32',
|
|
1569
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1570
|
+
env: {
|
|
1571
|
+
...process.env,
|
|
1572
|
+
AGENT_PROFILE: profile.name,
|
|
1573
|
+
AGENT_RANK: profile.rank,
|
|
1574
|
+
AGENT_MODEL: profile.model || '',
|
|
1575
|
+
AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
|
|
1576
|
+
AGENT_JOB_TYPE: String(job.type ?? ''),
|
|
1577
|
+
},
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
const stdoutChunks = [];
|
|
1581
|
+
const stderrChunks = [];
|
|
1582
|
+
let stdoutBytes = 0;
|
|
1583
|
+
let stderrBytes = 0;
|
|
1584
|
+
let stdoutTruncated = false;
|
|
1585
|
+
let stderrTruncated = false;
|
|
1586
|
+
let settled = false;
|
|
1587
|
+
// Bound captured output (by BYTES, not string length) so a noisy/runaway
|
|
1588
|
+
// harness can't grow memory without limit and crash the worker.
|
|
1589
|
+
const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
1590
|
+
const finish = (result) => {
|
|
1591
|
+
if (settled) return;
|
|
1592
|
+
settled = true;
|
|
1593
|
+
if (timer) clearTimeout(timer);
|
|
1594
|
+
resolve(result);
|
|
1595
|
+
};
|
|
1596
|
+
|
|
1597
|
+
// Kill (and fail) a child that runs longer than the job's timeout so it can
|
|
1598
|
+
// never permanently hold a worker slot or leak the process.
|
|
1599
|
+
const timer = timeoutMs && timeoutMs > 0
|
|
1600
|
+
? setTimeout(() => {
|
|
1601
|
+
killTree(child);
|
|
1602
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, truncated: stdoutTruncated, stderrTruncated });
|
|
1603
|
+
}, timeoutMs)
|
|
1604
|
+
: null;
|
|
1605
|
+
|
|
1606
|
+
child.stdout.on('data', (d) => {
|
|
1607
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1608
|
+
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
1609
|
+
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
1610
|
+
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
1611
|
+
else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
|
|
1612
|
+
});
|
|
1613
|
+
child.stderr.on('data', (d) => {
|
|
1614
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1615
|
+
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
1616
|
+
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
1617
|
+
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
1618
|
+
else { stderrChunks.push(buf); stderrBytes += buf.length; }
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
child.on('error', (err) => {
|
|
1622
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: err.message, truncated: stdoutTruncated, stderrTruncated });
|
|
1623
|
+
});
|
|
1624
|
+
child.on('close', (code, signal) => {
|
|
1625
|
+
finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
// The child may exit before reading stdin; swallow the async EPIPE so it
|
|
1629
|
+
// doesn't crash the whole worker process (only the `child` close/error
|
|
1630
|
+
// handlers above decide the job outcome).
|
|
1631
|
+
child.stdin.on('error', () => {});
|
|
1632
|
+
try {
|
|
1633
|
+
child.stdin.write(JSON.stringify(payload));
|
|
1634
|
+
child.stdin.end();
|
|
1635
|
+
} catch {
|
|
1636
|
+
// 'error' handler above resolves the promise on spawn failure.
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
1643
|
+
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
1644
|
+
* Uses the c8ctl-provided SDK client (globalThis.c8ctl.createClient()).
|
|
1645
|
+
*/
|
|
1646
|
+
async function workAgent(req, flags) {
|
|
1647
|
+
const logger = getLogger();
|
|
1648
|
+
const name = flags?.name ? String(flags.name).trim() : req.positional[0];
|
|
1649
|
+
|
|
1650
|
+
if (!name) {
|
|
1651
|
+
const hires = readHires();
|
|
1652
|
+
const names = Object.keys(hires).sort();
|
|
1653
|
+
logger.error('Usage: c8ctl nano work <profileName>');
|
|
1654
|
+
if (names.length > 0) logger.info(`Profiles: ${names.join(', ')}`);
|
|
1655
|
+
else logger.info('No hires yet. Create one with: c8ctl nano hire');
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const stored = readHires()[name];
|
|
1660
|
+
if (!stored) {
|
|
1661
|
+
logger.error(`No hire named "${name}". List profiles with: c8ctl nano hire --list`);
|
|
1662
|
+
process.exit(1);
|
|
1663
|
+
}
|
|
1664
|
+
const normalized = normalizeStoredProfile(name, stored);
|
|
1665
|
+
if (normalized.error) {
|
|
1666
|
+
logger.error(`Cannot work "${name}": ${normalized.error}. Re-create it with: c8ctl nano hire`);
|
|
1667
|
+
process.exit(1);
|
|
1668
|
+
}
|
|
1669
|
+
const profile = normalized.profile;
|
|
1670
|
+
|
|
1671
|
+
if (!globalThis.c8ctl || typeof globalThis.c8ctl.createClient !== 'function') {
|
|
1672
|
+
logger.error('work requires the c8ctl runtime (createClient). Run it via the c8ctl CLI.');
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const intFlag = (v, dflt) => {
|
|
1677
|
+
const n = Number.parseInt(String(v ?? ''), 10);
|
|
1678
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
1679
|
+
};
|
|
1680
|
+
const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
|
|
1681
|
+
const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
|
|
1682
|
+
|
|
1683
|
+
const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
|
|
1684
|
+
const camunda = globalThis.c8ctl.createClient();
|
|
1685
|
+
|
|
1686
|
+
logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
|
|
1687
|
+
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1688
|
+
logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
|
|
1689
|
+
logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
|
|
1690
|
+
logger.info('Polling for work — press Ctrl-C to stop.');
|
|
1691
|
+
|
|
1692
|
+
const workers = matrix.map((jobType) =>
|
|
1693
|
+
camunda.createJobWorker({
|
|
1694
|
+
jobType,
|
|
1695
|
+
workerName: `${name}:${jobType}`,
|
|
1696
|
+
maxParallelJobs,
|
|
1697
|
+
jobTimeoutMs,
|
|
1698
|
+
jobHandler: async (job) => {
|
|
1699
|
+
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
|
|
1700
|
+
const result = await runAgentJob(profile, job, jobTimeoutMs);
|
|
1701
|
+
if (result.ok) {
|
|
1702
|
+
logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
|
|
1703
|
+
return job.complete({ output: result.stdout, exitCode: 0, agent: profile.name, truncated: Boolean(result.truncated) });
|
|
1704
|
+
}
|
|
1705
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
1706
|
+
const detail = result.error
|
|
1707
|
+
|| (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
|
|
1708
|
+
|| (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
|
|
1709
|
+
logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
|
|
1710
|
+
return job.fail({
|
|
1711
|
+
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
1712
|
+
retries,
|
|
1713
|
+
});
|
|
1714
|
+
},
|
|
1715
|
+
}),
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
// Keep the process alive until a stop signal, then drain gracefully.
|
|
1719
|
+
await new Promise((resolve) => {
|
|
1720
|
+
let stopping = false;
|
|
1721
|
+
const stop = async (signal) => {
|
|
1722
|
+
if (stopping) return;
|
|
1723
|
+
stopping = true;
|
|
1724
|
+
logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
|
|
1725
|
+
let stopFailures = 0;
|
|
1726
|
+
await Promise.all(
|
|
1727
|
+
workers.map(async (w) => {
|
|
1728
|
+
try {
|
|
1729
|
+
if (typeof w.stopGracefully === 'function') {
|
|
1730
|
+
await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
|
|
1731
|
+
} else if (typeof w.stop === 'function') {
|
|
1732
|
+
await w.stop();
|
|
1733
|
+
}
|
|
1734
|
+
} catch {
|
|
1735
|
+
// best-effort: never let one worker's stop failure hang shutdown
|
|
1736
|
+
stopFailures += 1;
|
|
1737
|
+
}
|
|
1738
|
+
}),
|
|
1739
|
+
);
|
|
1740
|
+
if (stopFailures > 0) {
|
|
1741
|
+
logger.warn(`${stopFailures} of ${workers.length} worker(s) did not stop cleanly; some connections may still be open.`);
|
|
1742
|
+
} else {
|
|
1743
|
+
logger.info('All workers stopped.');
|
|
1744
|
+
}
|
|
1745
|
+
resolve();
|
|
1746
|
+
};
|
|
1747
|
+
process.once('SIGINT', () => { stop('SIGINT'); });
|
|
1748
|
+
process.once('SIGTERM', () => { stop('SIGTERM'); });
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1296
1752
|
// ---------------------------------------------------------------------------
|
|
1297
1753
|
// update — pull a new nanobpmn release onto a machine with an existing install.
|
|
1298
1754
|
// The plugin (and the bundled server binary, shipped via the matching platform
|
|
@@ -2548,6 +3004,10 @@ export const metadata = {
|
|
|
2548
3004
|
{ command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
|
|
2549
3005
|
{ command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
|
|
2550
3006
|
{ command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
|
|
3007
|
+
{ command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
|
|
3008
|
+
{ command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
|
|
3009
|
+
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
3010
|
+
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
2551
3011
|
],
|
|
2552
3012
|
},
|
|
2553
3013
|
processos: {
|
|
@@ -2589,6 +3049,14 @@ export const commands = {
|
|
|
2589
3049
|
workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
|
|
2590
3050
|
check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
|
|
2591
3051
|
binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
|
|
3052
|
+
name: { type: 'string', description: 'hire/work: agent profile name (alt to positional arg)' },
|
|
3053
|
+
rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
|
|
3054
|
+
command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
|
|
3055
|
+
model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
|
|
3056
|
+
capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
|
|
3057
|
+
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
3058
|
+
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
3059
|
+
'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
|
|
2592
3060
|
},
|
|
2593
3061
|
handler: async (args, flags) => {
|
|
2594
3062
|
const logger = getLogger();
|
|
@@ -2637,6 +3105,12 @@ export const commands = {
|
|
|
2637
3105
|
case 'update':
|
|
2638
3106
|
updatePlugin(req);
|
|
2639
3107
|
break;
|
|
3108
|
+
case 'hire':
|
|
3109
|
+
await hireWorker(req, flags);
|
|
3110
|
+
break;
|
|
3111
|
+
case 'work':
|
|
3112
|
+
await workAgent(req, flags);
|
|
3113
|
+
break;
|
|
2640
3114
|
}
|
|
2641
3115
|
} catch (error) {
|
|
2642
3116
|
logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
|
|
@@ -2725,6 +3199,8 @@ function printUsage() {
|
|
|
2725
3199
|
console.log(' c8ctl nano set <bin|model-dir> <path>');
|
|
2726
3200
|
console.log(' c8ctl nano config');
|
|
2727
3201
|
console.log(' c8ctl nano update [--check]');
|
|
3202
|
+
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--list]');
|
|
3203
|
+
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>]');
|
|
2728
3204
|
console.log('');
|
|
2729
3205
|
console.log('Subcommands:');
|
|
2730
3206
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
@@ -2738,6 +3214,8 @@ function printUsage() {
|
|
|
2738
3214
|
console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
|
|
2739
3215
|
console.log(' config Show current configuration and on-disk locations');
|
|
2740
3216
|
console.log(' update Pull the latest published nano release (--check to only report)');
|
|
3217
|
+
console.log(' hire Create a CLI agent worker profile (rank + capabilities → job-type matrix)');
|
|
3218
|
+
console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
|
|
2741
3219
|
console.log('');
|
|
2742
3220
|
console.log('Options:');
|
|
2743
3221
|
console.log(' <nodes> Number of nodes to start (default 1)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"semantic-release": "^25.0.3"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.9.0",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.9.0",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.9.0",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.9.0",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.9.0",
|
|
57
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.9.0",
|
|
58
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.9.0"
|
|
59
59
|
}
|
|
60
60
|
}
|