c8ctl-plugin-nano 1.8.0 → 1.10.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 +129 -0
- package/c8ctl-plugin.js +971 -1
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -130,6 +130,135 @@ 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
|
+
|
|
200
|
+
### Task envelope, sandboxes & disk hygiene
|
|
201
|
+
|
|
202
|
+
For **agentic** jobs (an agent that clones a repo, works a task, pushes a
|
|
203
|
+
branch) the job carries a structured **task envelope** under the reserved
|
|
204
|
+
`io.nanobpm.agentTask` namespace. It is assembled from the job's static
|
|
205
|
+
`customHeaders` (model-authored defaults) deep-merged with per-instance
|
|
206
|
+
`variables` (**overrides win**), then normalized to schema v1 and included in the
|
|
207
|
+
stdin payload as `task`:
|
|
208
|
+
|
|
209
|
+
```jsonc
|
|
210
|
+
{
|
|
211
|
+
"io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
|
|
212
|
+
"io.nanobpm.agentTask.repository.ref": "main",
|
|
213
|
+
"io.nanobpm.agentTask.branch.push": "true",
|
|
214
|
+
"io.nanobpm.agentTask.task.allowPr": "false"
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Element templates emit flat dotpath header keys (strings); the plugin expands
|
|
219
|
+
them into a nested object and coerces `"true"/"false"` → bool and numeric
|
|
220
|
+
strings → int. The normalized shape is
|
|
221
|
+
`{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
|
|
222
|
+
On completion the plugin writes an **output envelope** back under
|
|
223
|
+
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`).
|
|
224
|
+
|
|
225
|
+
**Sandbox.** By default the command runs on the host (`--sandbox none`). Pass
|
|
226
|
+
`--sandbox docker` (or `podman`) with an `--image` to run **each job in a
|
|
227
|
+
throwaway container** instead:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
c8ctl nano hire --name coder --rank senior --command "agent-harness" \
|
|
231
|
+
--sandbox docker --image ghcr.io/acme/agent:1
|
|
232
|
+
c8ctl nano work coder # uses the profile's sandbox/image
|
|
233
|
+
c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or override
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
|
|
237
|
+
`nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
|
|
238
|
+
a run that outlives `--job-timeout` is force-removed. The envelope is piped on
|
|
239
|
+
the container's stdin exactly as on the host.
|
|
240
|
+
|
|
241
|
+
**Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
|
|
242
|
+
(and the repo/PR credential when `task.allowPr` is set — defaulting to
|
|
243
|
+
`GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
|
|
244
|
+
(only `host`, reading `process.env`, is implemented) and forwarded into the
|
|
245
|
+
container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
|
|
246
|
+
A missing required secret fails the job with a clear provisioning message.
|
|
247
|
+
|
|
248
|
+
**Disk hygiene.** Container sandboxes get automatic cleanup so leaked
|
|
249
|
+
containers can't fill the disk: a **label-scoped** reaper runs at worker startup
|
|
250
|
+
and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
|
|
251
|
+
removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
|
|
252
|
+
default `3600000` = 1h) while **skipping any run still in flight** — it never
|
|
253
|
+
touches containers it didn't create and never `system prune`s. A **disk-budget
|
|
254
|
+
admission shed** fails (retryable) new jobs when the engine data root has less
|
|
255
|
+
than `--min-free-mb` MB free (default `1024`).
|
|
256
|
+
|
|
257
|
+
> Git provisioning (clone/branch/push), agent-opened PRs, and the
|
|
258
|
+
> Vercel/Sandcastle provider are **increment 2** — the envelope names above are
|
|
259
|
+
> frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
|
|
260
|
+
> can be built against this contract.
|
|
261
|
+
|
|
133
262
|
## Cleaning up disk
|
|
134
263
|
|
|
135
264
|
```bash
|
package/c8ctl-plugin.js
CHANGED
|
@@ -40,11 +40,14 @@ import {
|
|
|
40
40
|
chmodSync,
|
|
41
41
|
renameSync,
|
|
42
42
|
realpathSync,
|
|
43
|
+
statfsSync,
|
|
43
44
|
} from 'node:fs';
|
|
45
|
+
import { randomUUID } from 'node:crypto';
|
|
44
46
|
import { homedir, platform as osPlatform } from 'node:os';
|
|
45
47
|
import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
|
|
46
48
|
import { createRequire } from 'node:module';
|
|
47
49
|
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { createInterface } from 'node:readline/promises';
|
|
48
51
|
import { platformForHost } from './platforms.mjs';
|
|
49
52
|
|
|
50
53
|
const requireFromHere = createRequire(import.meta.url);
|
|
@@ -363,7 +366,7 @@ function launcherEnvMarkers(resolved) {
|
|
|
363
366
|
// Argument parsing
|
|
364
367
|
// ---------------------------------------------------------------------------
|
|
365
368
|
|
|
366
|
-
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update'];
|
|
369
|
+
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update', 'hire', 'work'];
|
|
367
370
|
|
|
368
371
|
/**
|
|
369
372
|
* Parse positional args + flags into a normalized request.
|
|
@@ -1293,6 +1296,908 @@ function showConfig() {
|
|
|
1293
1296
|
console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
|
|
1294
1297
|
}
|
|
1295
1298
|
|
|
1299
|
+
// ---------------------------------------------------------------------------
|
|
1300
|
+
// hire / work — CLI agent harness workers.
|
|
1301
|
+
//
|
|
1302
|
+
// A "hire" is a persisted agent profile (name, rank, CLI command, model,
|
|
1303
|
+
// capabilities). "work <name>" turns that profile into a set of Nano job
|
|
1304
|
+
// workers: one per job-type in the rank×capability matrix. When a job is
|
|
1305
|
+
// activated, the profile's CLI command is spawned fresh (one-shot), fed the job
|
|
1306
|
+
// as JSON on stdin, and its stdout is returned as the job's `output` variable.
|
|
1307
|
+
// ---------------------------------------------------------------------------
|
|
1308
|
+
|
|
1309
|
+
const RANKS = ['principal', 'senior', 'junior', 'decider'];
|
|
1310
|
+
|
|
1311
|
+
/** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
|
|
1312
|
+
function normalizeCapabilities(input) {
|
|
1313
|
+
const raw = Array.isArray(input)
|
|
1314
|
+
? input
|
|
1315
|
+
: String(input || '').split(',');
|
|
1316
|
+
return [...new Set(raw.map((c) => String(c).trim().toLowerCase()).filter(Boolean))].sort();
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** A profile name must be a safe, filesystem/token-friendly slug. */
|
|
1320
|
+
function isValidProfileName(name) {
|
|
1321
|
+
return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* The job-type matrix a worker subscribes to, from a profile's rank
|
|
1326
|
+
* and sorted capabilities [c1, c2, ...]:
|
|
1327
|
+
* - `rank` (rank alone)
|
|
1328
|
+
* - `rank:c1`, `rank:c2` (rank + a single capability, "spread")
|
|
1329
|
+
* - `rank:c1+c2+...` (rank + all capabilities combined; only when >1 cap)
|
|
1330
|
+
* Delimiters: `:` separates rank from capabilities, `+` joins combined caps.
|
|
1331
|
+
* Capabilities are sorted so the combined token is canonical/predictable.
|
|
1332
|
+
*/
|
|
1333
|
+
function jobTypeMatrix(rank, capabilities) {
|
|
1334
|
+
const caps = normalizeCapabilities(capabilities);
|
|
1335
|
+
const tokens = [rank];
|
|
1336
|
+
for (const c of caps) tokens.push(`${rank}:${c}`);
|
|
1337
|
+
if (caps.length > 1) tokens.push(`${rank}:${caps.join('+')}`);
|
|
1338
|
+
return [...new Set(tokens)];
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** All persisted hire profiles, keyed by name. */
|
|
1342
|
+
function readHires() {
|
|
1343
|
+
const cfg = readConfig();
|
|
1344
|
+
// A JSON array is `typeof === 'object'` but drops string-keyed writes on
|
|
1345
|
+
// JSON.stringify, so treat only plain objects as a valid hires map.
|
|
1346
|
+
return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Persist a single hire profile into config.json under `hires`. */
|
|
1350
|
+
function writeHire(profile) {
|
|
1351
|
+
const cfg = readConfig();
|
|
1352
|
+
if (!cfg.hires || typeof cfg.hires !== 'object' || Array.isArray(cfg.hires)) cfg.hires = {};
|
|
1353
|
+
cfg.hires[profile.name] = profile;
|
|
1354
|
+
writeConfig(cfg);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Validate and normalize a stored profile before use so a hand-edited or
|
|
1359
|
+
* version-skewed config.json can't produce undefined job types or an invalid
|
|
1360
|
+
* spawn. Returns the normalized profile, or a { error } describing the problem.
|
|
1361
|
+
*/
|
|
1362
|
+
function normalizeStoredProfile(name, profile) {
|
|
1363
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
1364
|
+
return { error: `profile "${name}" is not an object` };
|
|
1365
|
+
}
|
|
1366
|
+
const rank = String(profile.rank || '').trim().toLowerCase();
|
|
1367
|
+
if (!RANKS.includes(rank)) {
|
|
1368
|
+
return { error: `profile "${name}" has an invalid rank "${profile.rank}" (expected one of: ${RANKS.join(', ')})` };
|
|
1369
|
+
}
|
|
1370
|
+
const command = String(profile.command || '').trim();
|
|
1371
|
+
if (!command) {
|
|
1372
|
+
return { error: `profile "${name}" has no command to run` };
|
|
1373
|
+
}
|
|
1374
|
+
const sandbox = String(profile.sandbox || 'none').trim().toLowerCase();
|
|
1375
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
1376
|
+
return { error: `profile "${name}" has an invalid sandbox "${profile.sandbox}" (expected one of: ${SANDBOXES.join(', ')})` };
|
|
1377
|
+
}
|
|
1378
|
+
const image = typeof profile.image === 'string' ? profile.image.trim() : '';
|
|
1379
|
+
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1380
|
+
return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
profile: {
|
|
1384
|
+
name,
|
|
1385
|
+
rank,
|
|
1386
|
+
command,
|
|
1387
|
+
model: typeof profile.model === 'string' ? profile.model.trim() : '',
|
|
1388
|
+
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1389
|
+
sandbox,
|
|
1390
|
+
image,
|
|
1391
|
+
},
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* hire — create (or overwrite) an agent profile. Interactive by default; every
|
|
1397
|
+
* field can also be supplied via a flag (--name/--rank/--command/--model/
|
|
1398
|
+
* --capabilities) for scripting. Prompts only for the fields still missing.
|
|
1399
|
+
* `--list` prints existing profiles instead.
|
|
1400
|
+
*/
|
|
1401
|
+
async function hireWorker(req, flags) {
|
|
1402
|
+
const logger = getLogger();
|
|
1403
|
+
|
|
1404
|
+
if (flags?.list) {
|
|
1405
|
+
const hires = readHires();
|
|
1406
|
+
const names = Object.keys(hires);
|
|
1407
|
+
if (names.length === 0) {
|
|
1408
|
+
logger.info('No hires yet. Create one with: c8ctl nano hire');
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
logger.info('Hired agent profiles:');
|
|
1412
|
+
for (const name of names.sort()) {
|
|
1413
|
+
const p = hires[name];
|
|
1414
|
+
logger.info(` ${name} [${p.rank}] ${p.command} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
|
|
1415
|
+
}
|
|
1416
|
+
logger.info('');
|
|
1417
|
+
logger.info('Put one to work with: c8ctl nano work <name>');
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Seed from flags; prompt for anything still missing. Trim string flags so a
|
|
1422
|
+
// stray space can't be persisted into config.json or the spawned command.
|
|
1423
|
+
let name = flags?.name ? String(flags.name).trim() : req.positional[0];
|
|
1424
|
+
let rank = flags?.rank ? String(flags.rank).trim().toLowerCase() : undefined;
|
|
1425
|
+
let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
|
|
1426
|
+
let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
|
|
1427
|
+
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1428
|
+
let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
|
|
1429
|
+
let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
|
|
1430
|
+
|
|
1431
|
+
const missingRequired = !name || !rank || !command;
|
|
1432
|
+
const missingOptional = model === undefined || capabilities === undefined;
|
|
1433
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
1434
|
+
|
|
1435
|
+
// Non-interactively only name/rank/command are required; model and
|
|
1436
|
+
// capabilities are optional (they default to empty), matching how the
|
|
1437
|
+
// interactive prompts label them.
|
|
1438
|
+
if (missingRequired && !interactive) {
|
|
1439
|
+
logger.error('Non-interactive: provide at least --name, --rank and --command.');
|
|
1440
|
+
logger.info('Example: c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing');
|
|
1441
|
+
process.exit(1);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (interactive && (missingRequired || missingOptional)) {
|
|
1445
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1446
|
+
try {
|
|
1447
|
+
console.log('Hire a CLI agent worker. Press Ctrl-C to cancel.');
|
|
1448
|
+
console.log('');
|
|
1449
|
+
while (!name) {
|
|
1450
|
+
const ans = (await rl.question('Profile name: ')).trim();
|
|
1451
|
+
if (isValidProfileName(ans)) { name = ans; break; }
|
|
1452
|
+
console.log(' Please use letters, digits, dot, dash or underscore.');
|
|
1453
|
+
}
|
|
1454
|
+
while (!rank) {
|
|
1455
|
+
const ans = (await rl.question(`Rank (${RANKS.join('|')}): `)).trim().toLowerCase();
|
|
1456
|
+
if (RANKS.includes(ans)) { rank = ans; break; }
|
|
1457
|
+
console.log(` Rank must be one of: ${RANKS.join(', ')}`);
|
|
1458
|
+
}
|
|
1459
|
+
while (!command) {
|
|
1460
|
+
const ans = (await rl.question('CLI command (e.g. copilot, claude, pi): ')).trim();
|
|
1461
|
+
if (ans) { command = ans; break; }
|
|
1462
|
+
console.log(' A command is required.');
|
|
1463
|
+
}
|
|
1464
|
+
if (model === undefined) {
|
|
1465
|
+
model = (await rl.question('Model name (optional): ')).trim();
|
|
1466
|
+
}
|
|
1467
|
+
if (capabilities === undefined) {
|
|
1468
|
+
capabilities = (await rl.question('Capabilities (comma-separated, optional): ')).trim();
|
|
1469
|
+
}
|
|
1470
|
+
} finally {
|
|
1471
|
+
rl.close();
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Optional fields default to empty when omitted (e.g. scripted invocations).
|
|
1476
|
+
if (model === undefined) model = '';
|
|
1477
|
+
if (capabilities === undefined) capabilities = '';
|
|
1478
|
+
if (sandbox === undefined || sandbox === '') sandbox = 'none';
|
|
1479
|
+
if (image === undefined) image = '';
|
|
1480
|
+
|
|
1481
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
1482
|
+
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1486
|
+
logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
|
|
1487
|
+
process.exit(1);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (!isValidProfileName(name)) {
|
|
1491
|
+
logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
if (!RANKS.includes(rank)) {
|
|
1495
|
+
logger.error(`Invalid rank "${rank}". Must be one of: ${RANKS.join(', ')}`);
|
|
1496
|
+
process.exit(1);
|
|
1497
|
+
}
|
|
1498
|
+
if (!command) {
|
|
1499
|
+
logger.error('A CLI command is required.');
|
|
1500
|
+
process.exit(1);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const existed = Boolean(readHires()[name]);
|
|
1504
|
+
const profile = {
|
|
1505
|
+
name,
|
|
1506
|
+
rank,
|
|
1507
|
+
command,
|
|
1508
|
+
model: model || '',
|
|
1509
|
+
capabilities: normalizeCapabilities(capabilities),
|
|
1510
|
+
sandbox,
|
|
1511
|
+
image: image || '',
|
|
1512
|
+
createdAt: new Date().toISOString(),
|
|
1513
|
+
};
|
|
1514
|
+
writeHire(profile);
|
|
1515
|
+
|
|
1516
|
+
const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
|
|
1517
|
+
logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
|
|
1518
|
+
logger.info(` model: ${profile.model || '(none)'}`);
|
|
1519
|
+
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1520
|
+
logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
|
|
1521
|
+
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
1522
|
+
logger.info(`Put it to work with: c8ctl nano work ${name}`);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Concatenate captured Buffer chunks into a UTF-8 string, dropping a trailing
|
|
1527
|
+
* incomplete multibyte sequence (which the byte cap may have split) so decoding
|
|
1528
|
+
* never emits a replacement char or pushes the string over the byte cap.
|
|
1529
|
+
*/
|
|
1530
|
+
function joinCapped(chunks) {
|
|
1531
|
+
if (!chunks.length) return '';
|
|
1532
|
+
let buf = Buffer.concat(chunks);
|
|
1533
|
+
let i = buf.length - 1;
|
|
1534
|
+
let cont = 0;
|
|
1535
|
+
while (i >= 0 && (buf[i] & 0xc0) === 0x80 && cont < 3) { i -= 1; cont += 1; }
|
|
1536
|
+
if (i >= 0) {
|
|
1537
|
+
const lead = buf[i];
|
|
1538
|
+
let needed;
|
|
1539
|
+
if ((lead & 0x80) === 0x00) needed = 0;
|
|
1540
|
+
else if ((lead & 0xe0) === 0xc0) needed = 1;
|
|
1541
|
+
else if ((lead & 0xf0) === 0xe0) needed = 2;
|
|
1542
|
+
else if ((lead & 0xf8) === 0xf0) needed = 3;
|
|
1543
|
+
else needed = -1;
|
|
1544
|
+
if (needed > 0 && cont < needed) buf = buf.subarray(0, i);
|
|
1545
|
+
}
|
|
1546
|
+
return buf.toString('utf8');
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Kill a spawned child and its whole process tree. With `detached: true` on
|
|
1551
|
+
* POSIX the child leads its own process group, so a negative PID signals every
|
|
1552
|
+
* process in that group (shell wrapper + the actual harness command). Falls
|
|
1553
|
+
* back to a plain child.kill() on Windows or if the group signal fails.
|
|
1554
|
+
*/
|
|
1555
|
+
function killTree(child) {
|
|
1556
|
+
const pid = child.pid;
|
|
1557
|
+
if (process.platform !== 'win32' && typeof pid === 'number') {
|
|
1558
|
+
try { process.kill(-pid, 'SIGKILL'); return; } catch { /* fall through */ }
|
|
1559
|
+
}
|
|
1560
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ===========================================================================
|
|
1564
|
+
// Agent task envelope + sandboxed execution (issue #8, increment 1)
|
|
1565
|
+
// ===========================================================================
|
|
1566
|
+
|
|
1567
|
+
// Reserved namespaces. The INPUT envelope is assembled from the job's static
|
|
1568
|
+
// customHeaders (model-authored defaults) deep-merged with per-instance
|
|
1569
|
+
// variables (overrides win), then normalized/coerced to schema v1. The OUTPUT
|
|
1570
|
+
// envelope is written back on the job's completion variables.
|
|
1571
|
+
const AGENT_TASK_NS = 'io.nanobpm.agentTask';
|
|
1572
|
+
const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
|
|
1573
|
+
const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
1574
|
+
// The result-envelope version is intentionally independent of the task-envelope
|
|
1575
|
+
// version so the two contracts can evolve separately without silently coupling.
|
|
1576
|
+
const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
|
|
1577
|
+
const SANDBOXES = ['none', 'docker', 'podman'];
|
|
1578
|
+
// Only container-based sandboxes need an image / disk hygiene / a runtime bin.
|
|
1579
|
+
const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
|
|
1580
|
+
|
|
1581
|
+
function coerceBool(v, dflt = false) {
|
|
1582
|
+
if (typeof v === 'boolean') return v;
|
|
1583
|
+
if (v == null) return dflt;
|
|
1584
|
+
const s = String(v).trim().toLowerCase();
|
|
1585
|
+
if (['true', '1', 'yes', 'on'].includes(s)) return true;
|
|
1586
|
+
if (['false', '0', 'no', 'off', ''].includes(s)) return false;
|
|
1587
|
+
return dflt;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function coerceInt(v, dflt) {
|
|
1591
|
+
if (v == null || v === '') return dflt;
|
|
1592
|
+
const n = Number.parseInt(String(v), 10);
|
|
1593
|
+
return Number.isFinite(n) ? n : dflt;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function isPlainObject(v) {
|
|
1597
|
+
return v != null && typeof v === 'object' && !Array.isArray(v);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function deepMerge(base, over) {
|
|
1601
|
+
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
1602
|
+
const out = { ...base };
|
|
1603
|
+
if (!isPlainObject(over)) return out;
|
|
1604
|
+
for (const [k, v] of Object.entries(over)) {
|
|
1605
|
+
if (v === undefined) continue;
|
|
1606
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
1607
|
+
}
|
|
1608
|
+
return out;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function setPath(obj, path, value) {
|
|
1612
|
+
let cur = obj;
|
|
1613
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1614
|
+
const k = path[i];
|
|
1615
|
+
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
1616
|
+
cur = cur[k];
|
|
1617
|
+
}
|
|
1618
|
+
cur[path[path.length - 1]] = value;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Collect the reserved namespace out of a flat key→value map (customHeaders or
|
|
1622
|
+
// variables). Supports both a single `io.nanobpm.agentTask` key whose value is
|
|
1623
|
+
// a JSON string/object, AND flattened dotpath keys like
|
|
1624
|
+
// `io.nanobpm.agentTask.repository.ref` (element templates emit the latter).
|
|
1625
|
+
function collectEnvelopeFrom(source) {
|
|
1626
|
+
const out = {};
|
|
1627
|
+
if (!isPlainObject(source)) return out;
|
|
1628
|
+
const whole = source[AGENT_TASK_NS];
|
|
1629
|
+
if (whole != null) {
|
|
1630
|
+
let val = whole;
|
|
1631
|
+
if (typeof whole === 'string') {
|
|
1632
|
+
try { val = JSON.parse(whole); } catch { val = undefined; }
|
|
1633
|
+
}
|
|
1634
|
+
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
1635
|
+
}
|
|
1636
|
+
const prefix = `${AGENT_TASK_NS}.`;
|
|
1637
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1638
|
+
if (!key.startsWith(prefix)) continue;
|
|
1639
|
+
const rest = key.slice(prefix.length);
|
|
1640
|
+
if (!rest) continue;
|
|
1641
|
+
setPath(out, rest.split('.'), value);
|
|
1642
|
+
}
|
|
1643
|
+
return out;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Normalize the assembled envelope to schema v1, coercing header string values
|
|
1647
|
+
// (element templates write everything as strings) into bool/int as needed.
|
|
1648
|
+
function normalizeTaskEnvelope(customHeaders, variables) {
|
|
1649
|
+
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
1650
|
+
const str = (v) => (v == null ? undefined : String(v));
|
|
1651
|
+
const env = {
|
|
1652
|
+
// Normalization always emits the v1 shape, so the version is forced to v1
|
|
1653
|
+
// (the raw input version is only a hint about how the author authored it).
|
|
1654
|
+
schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
const repo = raw.repository;
|
|
1658
|
+
if (isPlainObject(repo) && str(repo.url)) {
|
|
1659
|
+
env.repository = {
|
|
1660
|
+
provider: str(repo.provider) || 'github',
|
|
1661
|
+
url: str(repo.url),
|
|
1662
|
+
ref: str(repo.ref),
|
|
1663
|
+
depth: coerceInt(repo.depth, undefined),
|
|
1664
|
+
submodules: coerceBool(repo.submodules, false),
|
|
1665
|
+
authRef: str(repo.authRef),
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
1670
|
+
env.branch = {
|
|
1671
|
+
base: str(branch.base),
|
|
1672
|
+
create: str(branch.create),
|
|
1673
|
+
push: coerceBool(branch.push, true),
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const setup = isPlainObject(raw.setup) ? raw.setup : {};
|
|
1677
|
+
env.setup = {
|
|
1678
|
+
commands: Array.isArray(setup.commands) ? setup.commands.map(String) : [],
|
|
1679
|
+
env: isPlainObject(setup.env) ? setup.env : {},
|
|
1680
|
+
secretRefs: Array.isArray(setup.secretRefs) ? setup.secretRefs.map(String) : [],
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
const task = isPlainObject(raw.task) ? raw.task : {};
|
|
1684
|
+
env.task = {
|
|
1685
|
+
prompt: str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task),
|
|
1686
|
+
promptFile: str(task.promptFile),
|
|
1687
|
+
maxIterations: coerceInt(task.maxIterations, undefined),
|
|
1688
|
+
timeoutMs: coerceInt(task.timeoutMs, undefined),
|
|
1689
|
+
allowPr: coerceBool(task.allowPr, false),
|
|
1690
|
+
prBase: str(task.prBase),
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
return env;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ---- Secret resolution (pluggable; only host-env implemented for now) ------
|
|
1697
|
+
// Secrets are referenced by NAME, never by value, in the model. A resolver maps
|
|
1698
|
+
// a name → value at run time. The wrapper injects them into the child ENV, so
|
|
1699
|
+
// values never appear in argv or `docker inspect`.
|
|
1700
|
+
const hostEnvSecretResolver = {
|
|
1701
|
+
kind: 'host',
|
|
1702
|
+
resolve(name) {
|
|
1703
|
+
const v = process.env[name];
|
|
1704
|
+
return v == null || v === '' ? undefined : v;
|
|
1705
|
+
},
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
function makeSecretResolver(kind) {
|
|
1709
|
+
const k = (kind || 'host').trim().toLowerCase();
|
|
1710
|
+
if (k === 'host' || k === '') return hostEnvSecretResolver;
|
|
1711
|
+
return null; // unknown → caller reports a clear error
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Resolve the names a job needs (setup.secretRefs, plus the repo/PR credential
|
|
1715
|
+
// when allowPr). Returns resolved values + a list of names that were missing so
|
|
1716
|
+
// the caller can fail the job with a clear provisioning error.
|
|
1717
|
+
function resolveJobSecrets(resolver, envelope) {
|
|
1718
|
+
const names = new Set();
|
|
1719
|
+
for (const n of envelope.setup?.secretRefs || []) if (n) names.add(n);
|
|
1720
|
+
if (envelope.task?.allowPr) {
|
|
1721
|
+
const provider = envelope.repository?.provider || 'github';
|
|
1722
|
+
const authRef = envelope.repository?.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : undefined);
|
|
1723
|
+
if (authRef) names.add(authRef);
|
|
1724
|
+
}
|
|
1725
|
+
const resolved = {};
|
|
1726
|
+
const missing = [];
|
|
1727
|
+
for (const name of names) {
|
|
1728
|
+
const v = resolver.resolve(name);
|
|
1729
|
+
if (v === undefined) missing.push(name);
|
|
1730
|
+
else resolved[name] = v;
|
|
1731
|
+
}
|
|
1732
|
+
return { resolved, missing, names: [...names] };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// ---- Disk hygiene (container sandboxes only) -------------------------------
|
|
1736
|
+
const CONTAINER_LABEL = 'nano.managed=1';
|
|
1737
|
+
|
|
1738
|
+
function containerEngineAvailable(engine) {
|
|
1739
|
+
try {
|
|
1740
|
+
const r = spawnSync(engine, ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
1741
|
+
return r.status === 0;
|
|
1742
|
+
} catch {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Resolve the container engine's data root. Returns null when it can't be
|
|
1748
|
+
// determined so the caller can fail OPEN (never fall back to an unrelated path
|
|
1749
|
+
// like the OS temp dir, which would shed on the wrong filesystem's free space).
|
|
1750
|
+
function dockerRootDir(engine) {
|
|
1751
|
+
try {
|
|
1752
|
+
const r = spawnSync(engine, ['info', '-f', '{{.DockerRootDir}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
1753
|
+
const dir = (r.stdout || '').trim();
|
|
1754
|
+
if (r.status === 0 && dir) return dir;
|
|
1755
|
+
} catch { /* fall through */ }
|
|
1756
|
+
return null;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Fail-open disk-budget check: shed work when free space on the engine's data
|
|
1760
|
+
// root drops below the configured floor (mirrors nano's admission-shed pattern).
|
|
1761
|
+
function diskBudgetOk(engine, minFreeBytes) {
|
|
1762
|
+
if (!minFreeBytes || minFreeBytes <= 0) return { ok: true, free: null };
|
|
1763
|
+
try {
|
|
1764
|
+
if (typeof statfsSync !== 'function') return { ok: true, free: null };
|
|
1765
|
+
const root = dockerRootDir(engine);
|
|
1766
|
+
if (!root) return { ok: true, free: null }; // can't resolve the real root → fail open
|
|
1767
|
+
const st = statfsSync(root);
|
|
1768
|
+
const free = st.bavail * st.bsize;
|
|
1769
|
+
return { ok: free >= minFreeBytes, free };
|
|
1770
|
+
} catch {
|
|
1771
|
+
return { ok: true, free: null }; // never block work on a stat failure
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Reap our own leaked/finished containers. Label-scoped (never touches anything
|
|
1776
|
+
// we didn't create — safe on shared hosts, NEVER `system prune -a`), age-gated,
|
|
1777
|
+
// and skips any run id still in flight.
|
|
1778
|
+
function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } = {}) {
|
|
1779
|
+
let reaped = 0;
|
|
1780
|
+
try {
|
|
1781
|
+
const fmt = '{{.ID}}\t{{.Label "nano.run"}}\t{{.State}}\t{{.CreatedAt}}';
|
|
1782
|
+
const r = spawnSync(engine, ['ps', '-a', '--filter', `label=${CONTAINER_LABEL}`, '--format', fmt], { encoding: 'utf8', timeout: 15_000 });
|
|
1783
|
+
if (r.status !== 0) return { reaped, error: (r.stderr || '').trim() || 'ps failed' };
|
|
1784
|
+
const now = Date.now();
|
|
1785
|
+
for (const line of (r.stdout || '').split('\n')) {
|
|
1786
|
+
if (!line.trim()) continue;
|
|
1787
|
+
const [id, run, state, created] = line.split('\t');
|
|
1788
|
+
if (run && liveRunIds.has(run)) continue; // in-flight; leave it
|
|
1789
|
+
if (!/exited|dead|created/i.test(state || '')) continue; // only finished/stuck
|
|
1790
|
+
if (maxAgeMs > 0) {
|
|
1791
|
+
const createdMs = Date.parse(created || '');
|
|
1792
|
+
if (Number.isFinite(createdMs) && now - createdMs < maxAgeMs) continue;
|
|
1793
|
+
}
|
|
1794
|
+
const rm = spawnSync(engine, ['rm', '-f', id], { timeout: 15_000 });
|
|
1795
|
+
if (rm.status === 0) reaped++;
|
|
1796
|
+
}
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
return { reaped, error: err.message };
|
|
1799
|
+
}
|
|
1800
|
+
return { reaped };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// ---- One-shot capture (shared by host + container executors) ---------------
|
|
1804
|
+
const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
1805
|
+
|
|
1806
|
+
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
1807
|
+
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
1808
|
+
// uniform result. Used by both the host and container executors.
|
|
1809
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
|
|
1810
|
+
return new Promise((resolve) => {
|
|
1811
|
+
let child;
|
|
1812
|
+
const stdoutChunks = [];
|
|
1813
|
+
const stderrChunks = [];
|
|
1814
|
+
let stdoutBytes = 0;
|
|
1815
|
+
let stderrBytes = 0;
|
|
1816
|
+
let stdoutTruncated = false;
|
|
1817
|
+
let stderrTruncated = false;
|
|
1818
|
+
let settled = false;
|
|
1819
|
+
let timer = null;
|
|
1820
|
+
|
|
1821
|
+
const finish = (result) => {
|
|
1822
|
+
if (settled) return;
|
|
1823
|
+
settled = true;
|
|
1824
|
+
if (timer) clearTimeout(timer);
|
|
1825
|
+
resolve(result);
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
try {
|
|
1829
|
+
child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
timer = timeoutMs && timeoutMs > 0
|
|
1836
|
+
? setTimeout(() => {
|
|
1837
|
+
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
1838
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: stdoutTruncated, stderrTruncated });
|
|
1839
|
+
}, timeoutMs)
|
|
1840
|
+
: null;
|
|
1841
|
+
|
|
1842
|
+
child.stdout.on('data', (d) => {
|
|
1843
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1844
|
+
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
1845
|
+
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
1846
|
+
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
1847
|
+
else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
|
|
1848
|
+
});
|
|
1849
|
+
child.stderr.on('data', (d) => {
|
|
1850
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
1851
|
+
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
1852
|
+
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
1853
|
+
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
1854
|
+
else { stderrChunks.push(buf); stderrBytes += buf.length; }
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
child.on('error', (err) => {
|
|
1858
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: err.message, truncated: stdoutTruncated, stderrTruncated });
|
|
1859
|
+
});
|
|
1860
|
+
child.on('close', (code, signal) => {
|
|
1861
|
+
finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
child.stdin.on('error', () => {});
|
|
1865
|
+
try {
|
|
1866
|
+
if (stdinData != null) child.stdin.write(stdinData);
|
|
1867
|
+
child.stdin.end();
|
|
1868
|
+
} catch { /* 'error' handler resolves on failure */ }
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function buildAgentPayload(profile, job, envelope) {
|
|
1873
|
+
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
1874
|
+
return {
|
|
1875
|
+
jobKey: job.jobKey,
|
|
1876
|
+
jobType: job.type,
|
|
1877
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
1878
|
+
elementInstanceKey: job.elementInstanceKey ?? null,
|
|
1879
|
+
elementId: job.elementId ?? null,
|
|
1880
|
+
bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
|
|
1881
|
+
prompt: envelope?.task?.prompt ?? variables.prompt ?? variables.task ?? null,
|
|
1882
|
+
task: envelope || null,
|
|
1883
|
+
variables,
|
|
1884
|
+
customHeaders: job.customHeaders ?? {},
|
|
1885
|
+
profile: {
|
|
1886
|
+
name: profile.name,
|
|
1887
|
+
rank: profile.rank,
|
|
1888
|
+
model: profile.model,
|
|
1889
|
+
capabilities: profile.capabilities,
|
|
1890
|
+
},
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function baseAgentEnv(profile, job) {
|
|
1895
|
+
return {
|
|
1896
|
+
AGENT_PROFILE: profile.name,
|
|
1897
|
+
AGENT_RANK: profile.rank,
|
|
1898
|
+
AGENT_MODEL: profile.model || '',
|
|
1899
|
+
AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
|
|
1900
|
+
AGENT_JOB_TYPE: String(job.type ?? ''),
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Run a single activated job through the profile's CLI command (one-shot),
|
|
1906
|
+
* dispatching on the profile's sandbox:
|
|
1907
|
+
* - none → spawn the command on the host (legacy behaviour).
|
|
1908
|
+
* - docker | podman → `run --rm` a labelled, log-capped container, piping the
|
|
1909
|
+
* task envelope on stdin; a run that outlives the timeout
|
|
1910
|
+
* is force-removed so it never leaks a slot or disk.
|
|
1911
|
+
* Both paths resolve to the same result contract.
|
|
1912
|
+
*/
|
|
1913
|
+
function runAgentJob(profile, job, opts = {}) {
|
|
1914
|
+
const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
|
|
1915
|
+
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
1916
|
+
const agentEnv = baseAgentEnv(profile, job);
|
|
1917
|
+
|
|
1918
|
+
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
1919
|
+
return spawnCaptureOneShot({
|
|
1920
|
+
command: profile.command,
|
|
1921
|
+
shell: true,
|
|
1922
|
+
// Own process group so the timeout handler can kill the whole tree.
|
|
1923
|
+
detached: process.platform !== 'win32',
|
|
1924
|
+
env: { ...process.env, ...agentEnv, ...secretEnv },
|
|
1925
|
+
stdinData: payload,
|
|
1926
|
+
timeoutMs,
|
|
1927
|
+
onTimeout: (child) => killTree(child),
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const engine = sandbox;
|
|
1932
|
+
const containerName = `nano-${runId}`;
|
|
1933
|
+
// Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
|
|
1934
|
+
// `docker inspect`; docker reads the value from our child's environment.
|
|
1935
|
+
const envArgs = [];
|
|
1936
|
+
for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
|
|
1937
|
+
for (const n of passThroughSecretNames) envArgs.push('-e', n);
|
|
1938
|
+
const setupEnv = isPlainObject(envelope?.setup?.env) ? envelope.setup.env : {};
|
|
1939
|
+
const setupEnvValues = {};
|
|
1940
|
+
for (const [k, v] of Object.entries(setupEnv)) { envArgs.push('-e', k); setupEnvValues[k] = String(v); }
|
|
1941
|
+
|
|
1942
|
+
const args = [
|
|
1943
|
+
'run', '--rm', '-i',
|
|
1944
|
+
'--name', containerName,
|
|
1945
|
+
'--label', CONTAINER_LABEL,
|
|
1946
|
+
'--label', `nano.worker=${profile.name}`,
|
|
1947
|
+
'--label', `nano.jobKey=${job.jobKey}`,
|
|
1948
|
+
'--label', `nano.run=${runId}`,
|
|
1949
|
+
'--log-opt', 'max-size=10m',
|
|
1950
|
+
'--log-opt', 'max-file=3',
|
|
1951
|
+
...envArgs,
|
|
1952
|
+
image,
|
|
1953
|
+
'sh', '-c', profile.command,
|
|
1954
|
+
];
|
|
1955
|
+
|
|
1956
|
+
return spawnCaptureOneShot({
|
|
1957
|
+
command: engine,
|
|
1958
|
+
args,
|
|
1959
|
+
shell: false,
|
|
1960
|
+
env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
|
|
1961
|
+
stdinData: payload,
|
|
1962
|
+
timeoutMs,
|
|
1963
|
+
onTimeout: (child) => {
|
|
1964
|
+
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
1965
|
+
try { killTree(child); } catch { /* best effort */ }
|
|
1966
|
+
},
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// Shape the io.nanobpm.agentResult output envelope. branch/commits/pr are
|
|
1971
|
+
// reserved for increment 2 (git provisioning); increment 1 reports execution.
|
|
1972
|
+
function buildResultEnvelope(result, { sandbox, image }) {
|
|
1973
|
+
const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
|
|
1974
|
+
return {
|
|
1975
|
+
schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
|
|
1976
|
+
status,
|
|
1977
|
+
sandbox,
|
|
1978
|
+
image: image || null,
|
|
1979
|
+
output: result.stdout ?? '',
|
|
1980
|
+
truncated: !!result.truncated,
|
|
1981
|
+
stderrTruncated: !!result.stderrTruncated,
|
|
1982
|
+
exitCode: result.exitCode ?? null,
|
|
1983
|
+
signal: result.signal ?? null,
|
|
1984
|
+
error: result.error ?? null,
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
1990
|
+
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
1991
|
+
* Uses the c8ctl-provided SDK client (globalThis.c8ctl.createClient()).
|
|
1992
|
+
*/
|
|
1993
|
+
async function workAgent(req, flags) {
|
|
1994
|
+
const logger = getLogger();
|
|
1995
|
+
const name = flags?.name ? String(flags.name).trim() : req.positional[0];
|
|
1996
|
+
|
|
1997
|
+
if (!name) {
|
|
1998
|
+
const hires = readHires();
|
|
1999
|
+
const names = Object.keys(hires).sort();
|
|
2000
|
+
logger.error('Usage: c8ctl nano work <profileName>');
|
|
2001
|
+
if (names.length > 0) logger.info(`Profiles: ${names.join(', ')}`);
|
|
2002
|
+
else logger.info('No hires yet. Create one with: c8ctl nano hire');
|
|
2003
|
+
process.exit(1);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const stored = readHires()[name];
|
|
2007
|
+
if (!stored) {
|
|
2008
|
+
logger.error(`No hire named "${name}". List profiles with: c8ctl nano hire --list`);
|
|
2009
|
+
process.exit(1);
|
|
2010
|
+
}
|
|
2011
|
+
const normalized = normalizeStoredProfile(name, stored);
|
|
2012
|
+
if (normalized.error) {
|
|
2013
|
+
logger.error(`Cannot work "${name}": ${normalized.error}. Re-create it with: c8ctl nano hire`);
|
|
2014
|
+
process.exit(1);
|
|
2015
|
+
}
|
|
2016
|
+
const profile = normalized.profile;
|
|
2017
|
+
|
|
2018
|
+
if (!globalThis.c8ctl || typeof globalThis.c8ctl.createClient !== 'function') {
|
|
2019
|
+
logger.error('work requires the c8ctl runtime (createClient). Run it via the c8ctl CLI.');
|
|
2020
|
+
process.exit(1);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
const intFlag = (v, dflt) => {
|
|
2024
|
+
const n = Number.parseInt(String(v ?? ''), 10);
|
|
2025
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
2026
|
+
};
|
|
2027
|
+
const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
|
|
2028
|
+
const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
|
|
2029
|
+
|
|
2030
|
+
// Sandbox: flag overrides the stored profile default. `none` runs on the host
|
|
2031
|
+
// (legacy); `docker`/`podman` run each job in a throwaway labelled container.
|
|
2032
|
+
const sandbox = String(flags?.sandbox ?? profile.sandbox ?? 'none').trim().toLowerCase();
|
|
2033
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
2034
|
+
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
2035
|
+
process.exit(1);
|
|
2036
|
+
}
|
|
2037
|
+
const image = flags?.image ? String(flags.image).trim() : (profile.image || '');
|
|
2038
|
+
const isContainer = CONTAINER_SANDBOXES.has(sandbox);
|
|
2039
|
+
if (isContainer && !image) {
|
|
2040
|
+
logger.error(`--sandbox ${sandbox} requires an --image (or hire the profile with --image).`);
|
|
2041
|
+
process.exit(1);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const secretResolver = makeSecretResolver(flags?.['secret-resolver']);
|
|
2045
|
+
if (!secretResolver) {
|
|
2046
|
+
logger.error(`Unknown --secret-resolver "${flags?.['secret-resolver']}". Only "host" is supported.`);
|
|
2047
|
+
process.exit(1);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// Disk-hygiene knobs (container sandboxes only). Reaper age + interval and the
|
|
2051
|
+
// free-space admission floor mirror nano's own disk-budget/reaper patterns.
|
|
2052
|
+
const reapAgeMs = intFlag(flags?.['reap-age'], 60 * 60_000); // 1h
|
|
2053
|
+
const reapIntervalMs = intFlag(flags?.['reap-interval'], 5 * 60_000); // 5m
|
|
2054
|
+
const minFreeBytes = flags?.['min-free-mb'] != null
|
|
2055
|
+
? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
|
|
2056
|
+
: 1_073_741_824; // 1 GiB default floor
|
|
2057
|
+
|
|
2058
|
+
// Tracks run ids currently executing so the reaper never removes a live
|
|
2059
|
+
// container out from under an in-flight job.
|
|
2060
|
+
const liveRunIds = new Set();
|
|
2061
|
+
let reaperTimer = null;
|
|
2062
|
+
|
|
2063
|
+
if (isContainer) {
|
|
2064
|
+
if (!containerEngineAvailable(sandbox)) {
|
|
2065
|
+
logger.error(`--sandbox ${sandbox} selected but "${sandbox}" is not available/running on this host.`);
|
|
2066
|
+
process.exit(1);
|
|
2067
|
+
}
|
|
2068
|
+
// Age-gate the startup sweep too (not maxAgeMs:0): on a shared host other
|
|
2069
|
+
// worker processes may have just-created containers not yet in liveRunIds,
|
|
2070
|
+
// so only reap ones older than --reap-age, matching the interval reaper.
|
|
2071
|
+
const initial = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
|
|
2072
|
+
if (initial.reaped > 0) logger.info(`Reaped ${initial.reaped} leftover agent container(s) at startup.`);
|
|
2073
|
+
if (initial.error) logger.warn(`Startup reap warning: ${initial.error}`);
|
|
2074
|
+
reaperTimer = setInterval(() => {
|
|
2075
|
+
const r = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
|
|
2076
|
+
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished agent container(s).`);
|
|
2077
|
+
}, reapIntervalMs);
|
|
2078
|
+
if (typeof reaperTimer.unref === 'function') reaperTimer.unref();
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
|
|
2082
|
+
const camunda = globalThis.c8ctl.createClient();
|
|
2083
|
+
|
|
2084
|
+
logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
|
|
2085
|
+
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
2086
|
+
logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
|
|
2087
|
+
logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
|
|
2088
|
+
logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
|
|
2089
|
+
logger.info('Polling for work — press Ctrl-C to stop.');
|
|
2090
|
+
|
|
2091
|
+
const workers = matrix.map((jobType) =>
|
|
2092
|
+
camunda.createJobWorker({
|
|
2093
|
+
jobType,
|
|
2094
|
+
workerName: `${name}:${jobType}`,
|
|
2095
|
+
maxParallelJobs,
|
|
2096
|
+
jobTimeoutMs,
|
|
2097
|
+
jobHandler: async (job) => {
|
|
2098
|
+
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
|
|
2099
|
+
|
|
2100
|
+
// Disk-budget admission shed: if the engine data root is below the free
|
|
2101
|
+
// floor, don't start a container — fail (retryable) so work sheds until
|
|
2102
|
+
// the reaper/host frees space.
|
|
2103
|
+
if (isContainer) {
|
|
2104
|
+
const budget = diskBudgetOk(sandbox, minFreeBytes);
|
|
2105
|
+
if (!budget.ok) {
|
|
2106
|
+
const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
|
|
2107
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
2108
|
+
logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
|
|
2109
|
+
return job.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Assemble + normalize the task envelope from headers (defaults) and
|
|
2114
|
+
// variables (overrides), then resolve any secrets it references.
|
|
2115
|
+
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
|
|
2116
|
+
const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
|
|
2117
|
+
if (missing.length > 0) {
|
|
2118
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
2119
|
+
const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
|
|
2120
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
2121
|
+
return job.fail({ errorMessage: msg, retries });
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const runId = randomUUID();
|
|
2125
|
+
if (isContainer) liveRunIds.add(runId);
|
|
2126
|
+
let result;
|
|
2127
|
+
try {
|
|
2128
|
+
result = await runAgentJob(profile, job, {
|
|
2129
|
+
timeoutMs: jobTimeoutMs,
|
|
2130
|
+
envelope,
|
|
2131
|
+
sandbox,
|
|
2132
|
+
image,
|
|
2133
|
+
runId,
|
|
2134
|
+
secretEnv: resolved,
|
|
2135
|
+
passThroughSecretNames: names,
|
|
2136
|
+
});
|
|
2137
|
+
} finally {
|
|
2138
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
|
|
2142
|
+
if (result.ok) {
|
|
2143
|
+
logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
|
|
2144
|
+
return job.complete({
|
|
2145
|
+
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
2146
|
+
output: result.stdout,
|
|
2147
|
+
exitCode: 0,
|
|
2148
|
+
agent: profile.name,
|
|
2149
|
+
truncated: Boolean(result.truncated),
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
2153
|
+
const detail = result.error
|
|
2154
|
+
|| (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
|
|
2155
|
+
|| (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
|
|
2156
|
+
logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
|
|
2157
|
+
return job.fail({
|
|
2158
|
+
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
2159
|
+
retries,
|
|
2160
|
+
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
2161
|
+
});
|
|
2162
|
+
},
|
|
2163
|
+
}),
|
|
2164
|
+
);
|
|
2165
|
+
|
|
2166
|
+
// Keep the process alive until a stop signal, then drain gracefully.
|
|
2167
|
+
await new Promise((resolve) => {
|
|
2168
|
+
let stopping = false;
|
|
2169
|
+
const stop = async (signal) => {
|
|
2170
|
+
if (stopping) return;
|
|
2171
|
+
stopping = true;
|
|
2172
|
+
logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
|
|
2173
|
+
if (reaperTimer) clearInterval(reaperTimer);
|
|
2174
|
+
let stopFailures = 0;
|
|
2175
|
+
await Promise.all(
|
|
2176
|
+
workers.map(async (w) => {
|
|
2177
|
+
try {
|
|
2178
|
+
if (typeof w.stopGracefully === 'function') {
|
|
2179
|
+
await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
|
|
2180
|
+
} else if (typeof w.stop === 'function') {
|
|
2181
|
+
await w.stop();
|
|
2182
|
+
}
|
|
2183
|
+
} catch {
|
|
2184
|
+
// best-effort: never let one worker's stop failure hang shutdown
|
|
2185
|
+
stopFailures += 1;
|
|
2186
|
+
}
|
|
2187
|
+
}),
|
|
2188
|
+
);
|
|
2189
|
+
if (stopFailures > 0) {
|
|
2190
|
+
logger.warn(`${stopFailures} of ${workers.length} worker(s) did not stop cleanly; some connections may still be open.`);
|
|
2191
|
+
} else {
|
|
2192
|
+
logger.info('All workers stopped.');
|
|
2193
|
+
}
|
|
2194
|
+
resolve();
|
|
2195
|
+
};
|
|
2196
|
+
process.once('SIGINT', () => { stop('SIGINT'); });
|
|
2197
|
+
process.once('SIGTERM', () => { stop('SIGTERM'); });
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
1296
2201
|
// ---------------------------------------------------------------------------
|
|
1297
2202
|
// update — pull a new nanobpmn release onto a machine with an existing install.
|
|
1298
2203
|
// The plugin (and the bundled server binary, shipped via the matching platform
|
|
@@ -2517,6 +3422,27 @@ function parseProcessosRequest(args, flags) {
|
|
|
2517
3422
|
// Internal helpers exported for tests/tooling only. c8ctl consumes just
|
|
2518
3423
|
// `metadata` and `commands`; these named exports are inert to it.
|
|
2519
3424
|
export { resolveBinary, findBinary, launcherEnvMarkers };
|
|
3425
|
+
export {
|
|
3426
|
+
normalizeTaskEnvelope,
|
|
3427
|
+
collectEnvelopeFrom,
|
|
3428
|
+
coerceBool,
|
|
3429
|
+
coerceInt,
|
|
3430
|
+
deepMerge,
|
|
3431
|
+
resolveJobSecrets,
|
|
3432
|
+
makeSecretResolver,
|
|
3433
|
+
hostEnvSecretResolver,
|
|
3434
|
+
buildAgentPayload,
|
|
3435
|
+
buildResultEnvelope,
|
|
3436
|
+
reapAgentContainers,
|
|
3437
|
+
diskBudgetOk,
|
|
3438
|
+
containerEngineAvailable,
|
|
3439
|
+
runAgentJob,
|
|
3440
|
+
normalizeStoredProfile,
|
|
3441
|
+
jobTypeMatrix,
|
|
3442
|
+
AGENT_TASK_NS,
|
|
3443
|
+
AGENT_RESULT_KEY,
|
|
3444
|
+
SANDBOXES,
|
|
3445
|
+
};
|
|
2520
3446
|
|
|
2521
3447
|
export const metadata = {
|
|
2522
3448
|
name: 'c8ctl-plugin-nano',
|
|
@@ -2548,6 +3474,12 @@ export const metadata = {
|
|
|
2548
3474
|
{ command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
|
|
2549
3475
|
{ command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
|
|
2550
3476
|
{ command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
|
|
3477
|
+
{ command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
|
|
3478
|
+
{ command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
|
|
3479
|
+
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
3480
|
+
{ command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
|
|
3481
|
+
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
3482
|
+
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
2551
3483
|
],
|
|
2552
3484
|
},
|
|
2553
3485
|
processos: {
|
|
@@ -2589,6 +3521,20 @@ export const commands = {
|
|
|
2589
3521
|
workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
|
|
2590
3522
|
check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
|
|
2591
3523
|
binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
|
|
3524
|
+
name: { type: 'string', description: 'hire/work: agent profile name (alt to positional arg)' },
|
|
3525
|
+
rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
|
|
3526
|
+
command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
|
|
3527
|
+
model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
|
|
3528
|
+
capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
|
|
3529
|
+
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
3530
|
+
image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
|
|
3531
|
+
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
3532
|
+
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container is reaped (default 3600000)' },
|
|
3533
|
+
'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers in ms (default 300000)' },
|
|
3534
|
+
'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
|
|
3535
|
+
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
3536
|
+
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
3537
|
+
'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
|
|
2592
3538
|
},
|
|
2593
3539
|
handler: async (args, flags) => {
|
|
2594
3540
|
const logger = getLogger();
|
|
@@ -2637,6 +3583,12 @@ export const commands = {
|
|
|
2637
3583
|
case 'update':
|
|
2638
3584
|
updatePlugin(req);
|
|
2639
3585
|
break;
|
|
3586
|
+
case 'hire':
|
|
3587
|
+
await hireWorker(req, flags);
|
|
3588
|
+
break;
|
|
3589
|
+
case 'work':
|
|
3590
|
+
await workAgent(req, flags);
|
|
3591
|
+
break;
|
|
2640
3592
|
}
|
|
2641
3593
|
} catch (error) {
|
|
2642
3594
|
logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
|
|
@@ -2725,6 +3677,8 @@ function printUsage() {
|
|
|
2725
3677
|
console.log(' c8ctl nano set <bin|model-dir> <path>');
|
|
2726
3678
|
console.log(' c8ctl nano config');
|
|
2727
3679
|
console.log(' c8ctl nano update [--check]');
|
|
3680
|
+
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--list]');
|
|
3681
|
+
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--secret-resolver host] [--min-free-mb <n>]');
|
|
2728
3682
|
console.log('');
|
|
2729
3683
|
console.log('Subcommands:');
|
|
2730
3684
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
@@ -2738,6 +3692,8 @@ function printUsage() {
|
|
|
2738
3692
|
console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
|
|
2739
3693
|
console.log(' config Show current configuration and on-disk locations');
|
|
2740
3694
|
console.log(' update Pull the latest published nano release (--check to only report)');
|
|
3695
|
+
console.log(' hire Create a CLI agent worker profile (rank + capabilities → job-type matrix)');
|
|
3696
|
+
console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
|
|
2741
3697
|
console.log('');
|
|
2742
3698
|
console.log('Options:');
|
|
2743
3699
|
console.log(' <nodes> Number of nodes to start (default 1)');
|
|
@@ -2753,6 +3709,20 @@ function printUsage() {
|
|
|
2753
3709
|
console.log(' --purge stop: also delete per-node engine data');
|
|
2754
3710
|
console.log(' --force start: stop any existing cluster first');
|
|
2755
3711
|
console.log(' --workspace clean: also delete the workspace (models + workers)');
|
|
3712
|
+
console.log(' --name <n> hire/work: agent profile name (alt to positional arg)');
|
|
3713
|
+
console.log(' --rank <r> hire: agent rank (principal|senior|junior|decider)');
|
|
3714
|
+
console.log(' --command <c> hire: CLI command that runs the agent harness');
|
|
3715
|
+
console.log(' --model <m> hire: model name passed to the harness (AGENT_MODEL)');
|
|
3716
|
+
console.log(' --capabilities <a,b> hire: comma-separated capability list');
|
|
3717
|
+
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
3718
|
+
console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
|
|
3719
|
+
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
3720
|
+
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
3721
|
+
console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
|
|
3722
|
+
console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
|
|
3723
|
+
console.log(' --reap-age <ms> work: age before a finished agent container is reaped (default 3600000)');
|
|
3724
|
+
console.log(' --reap-interval <ms> work: how often to sweep finished agent containers (default 300000)');
|
|
3725
|
+
console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
|
|
2756
3726
|
console.log('');
|
|
2757
3727
|
console.log('Persistent assets:');
|
|
2758
3728
|
console.log(' Models and workers live in the workspace dir (NANOBPMN_WORKSPACE_DIR),');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"lint": "node --check c8ctl-plugin.js",
|
|
30
|
-
"test": "node --check c8ctl-plugin.js"
|
|
30
|
+
"test": "node --check c8ctl-plugin.js && node --test"
|
|
31
31
|
},
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"engines": {
|
|
@@ -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.10.0",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.10.0",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.10.0",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.10.0",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.10.0",
|
|
57
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.10.0",
|
|
58
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.10.0"
|
|
59
59
|
}
|
|
60
60
|
}
|