c8ctl-plugin-nano 1.32.0 → 1.33.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 +48 -0
- package/agentic.mjs +13 -0
- package/c8ctl-plugin.js +270 -39
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -212,6 +212,54 @@ c8ctl nano work reviewer --max-parallel 2 --recovery-window 300000
|
|
|
212
212
|
c8ctl nano work reviewer --name reviewer-eu # name this worker (else auto ‹host›-‹profile›-‹random›)
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
+
#### Zero-config enrolment: `--auto` (serve every deployed agent job type)
|
|
216
|
+
|
|
217
|
+
`--auto` is the **"Borland Delphi on your machine"** onboarding ramp: skip the
|
|
218
|
+
capability wiring entirely and subscribe the worker to **all deployed *agent*
|
|
219
|
+
job types**, read straight from the engine.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
c8ctl nano work coder --auto # serve every agent job type on the engine
|
|
223
|
+
c8ctl nano work coder --auto --auto-scope my-app # scope to one app/network (process-id prefix)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
How it works and why it needs no wiring:
|
|
227
|
+
|
|
228
|
+
- **Engine-read demand.** The worker already holds the engine (C8 REST) endpoint
|
|
229
|
+
from its c8ctl profile. `--auto` enumerates the deployed process definitions
|
|
230
|
+
(`process-definitions/search` → `/{key}/xml`) and scans their
|
|
231
|
+
`<zeebe:taskDefinition type>` leaves for the job types the engine matches. The
|
|
232
|
+
engine is the guaranteed shared rendezvous: if a worker can execute an app's
|
|
233
|
+
agent jobs at all, it and the app are already on the same engine, so *what
|
|
234
|
+
agent job types exist* is answerable from that engine alone — **no cross-machine
|
|
235
|
+
app discovery, no app enrol endpoint, no channel connection**.
|
|
236
|
+
- **Agent-task header filter.** Not every service task is an agent task —
|
|
237
|
+
connectors and record-keepers (e.g. `pr.record-plan`) are plain workers.
|
|
238
|
+
`--auto` keeps only leaves whose service task carries an
|
|
239
|
+
**`io.nanobpm.agentTask.`** task header (e.g. `senior:plan` carries
|
|
240
|
+
`io.nanobpm.agentTask.task.prompt`; a record-keeper does not).
|
|
241
|
+
- **One poller per agent job type, reconciled on change.** It opens one poller
|
|
242
|
+
per agent job type and re-reads the engine periodically, adding pollers for
|
|
243
|
+
newly deployed agent processes and draining pollers for undeployed ones — the
|
|
244
|
+
same in-place reconcile the profile watch uses, sourced from the engine instead
|
|
245
|
+
of the profile.
|
|
246
|
+
- **Raw job-type grammar.** The job type the engine matches (`senior:plan`) is
|
|
247
|
+
advertised **verbatim** — colon-named types are not forced through any
|
|
248
|
+
dot-grammar.
|
|
249
|
+
|
|
250
|
+
`--auto` is **mutually exclusive** with capability-resolved serving: it bypasses
|
|
251
|
+
the rank×capability matrix (any `--job-type` extras are still added). The prompt
|
|
252
|
+
a worker needs already rides the job header (`io.nanobpm.agentTask.task.prompt`)
|
|
253
|
+
plus per-instance context, so a generic `--auto` worker needs no baked
|
|
254
|
+
specialisation.
|
|
255
|
+
|
|
256
|
+
> **Trust.** Engine-read has **no capability gate** — an `--auto` worker will
|
|
257
|
+
> serve *any* deployed agent job on its engine. That is the accepted trade for the
|
|
258
|
+
> local/zero-config target; capability-gated serving is the specialised
|
|
259
|
+
> (capability-declared) enrolment path. Use `--auto-scope <process-id | prefix>`
|
|
260
|
+
> to narrow the blast radius to one app/network.
|
|
261
|
+
|
|
262
|
+
|
|
215
263
|
The optional `--name` sets **this worker's name** — the `workerName` it
|
|
216
264
|
registers under at the broker (`‹name›:‹jobType›`) and how it shows up in
|
|
217
265
|
supervisor status/logs. Omit it and a distinct `‹host›-‹profile›-‹random›`
|
package/agentic.mjs
CHANGED
|
@@ -74,6 +74,19 @@ export * as presence from '@nanobpm/agentic/presence';
|
|
|
74
74
|
export * as relay from '@nanobpm/agentic/relay';
|
|
75
75
|
export * as transcript from '@nanobpm/agentic/transcript';
|
|
76
76
|
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Demand read — @nanobpm/agentic/demand.
|
|
79
|
+
//
|
|
80
|
+
// The read-only C8 REST mirror of the engine's deployed `taskDefinition` leaves
|
|
81
|
+
// (ADR 0056 S4). `nano work --auto` (jwulf/c8ctl-plugin-nano#66) consumes its
|
|
82
|
+
// `httpC8RestReader` to enumerate deployed process definitions and read each
|
|
83
|
+
// one's BPMN XML straight from the engine the worker already talks to — the
|
|
84
|
+
// zero-config enrolment source. The header-filter that narrows those leaves to
|
|
85
|
+
// *agent* job types lives in the plugin (`scanAgentTaskLeaves`), extending the
|
|
86
|
+
// package's type/element/process-only scanner with a `zeebe:taskHeaders` read.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
export * as demand from '@nanobpm/agentic/demand';
|
|
89
|
+
|
|
77
90
|
// ---------------------------------------------------------------------------
|
|
78
91
|
// Worker-side channel client — @nanobpm/urban-agent-client.
|
|
79
92
|
//
|
package/c8ctl-plugin.js
CHANGED
|
@@ -125,6 +125,11 @@ const READINESS_TIMEOUT_MS = 60_000;
|
|
|
125
125
|
const READINESS_POLL_MS = 500;
|
|
126
126
|
const HEALTH_TIMEOUT_MS = 1_500;
|
|
127
127
|
const STOP_GRACE_MS = 8_000;
|
|
128
|
+
// Upper bound on one `--auto` engine-read reconcile (enumerate deployed
|
|
129
|
+
// definitions + fetch each BPMN). A read that stalls past this is treated as a
|
|
130
|
+
// transient failure so the running poller set is KEPT and, crucially, shutdown
|
|
131
|
+
// — which awaits the in-flight reconcile — can never hang on a wedged engine.
|
|
132
|
+
const AUTO_ENGINE_READ_TIMEOUT_MS = 15_000;
|
|
128
133
|
const PROCESSOS_STATE_FILE = 'processos.json';
|
|
129
134
|
const SUPERVISOR_STATE_FILE = 'supervisor.json';
|
|
130
135
|
const PROCESSOS_DEFAULT_PORT = 8090;
|
|
@@ -2390,6 +2395,127 @@ function resolveBrokerRestConfig(env = process.env) {
|
|
|
2390
2395
|
return { baseUrl, token };
|
|
2391
2396
|
}
|
|
2392
2397
|
|
|
2398
|
+
// ---------------------------------------------------------------------------
|
|
2399
|
+
// `nano work --auto`: zero-config engine-read enrolment (issue #66).
|
|
2400
|
+
//
|
|
2401
|
+
// Subscribe a generic worker to ALL deployed *agent* job types by reading the
|
|
2402
|
+
// demand straight from the engine (C8 REST) the worker already talks to — no
|
|
2403
|
+
// capability, no app enrol endpoint, no hub rendezvous. The engine is the
|
|
2404
|
+
// guaranteed shared rendezvous: if a worker can execute an app's agent jobs at
|
|
2405
|
+
// all, it and the app are already on the same engine, so "what agent job types
|
|
2406
|
+
// exist" is answerable from that engine alone.
|
|
2407
|
+
//
|
|
2408
|
+
// `@nanobpm/agentic/demand` already reads deployed `taskDefinition` leaves over
|
|
2409
|
+
// C8 REST (`process-definitions/search` → `/{key}/xml`), but its scanner reads
|
|
2410
|
+
// type/element/process only. Not every service task is an agent task — plain
|
|
2411
|
+
// connectors and record-keepers (e.g. `pr.record-plan`) are ordinary workers.
|
|
2412
|
+
// The demand scanner is therefore extended HERE to read `zeebe:taskHeaders` and
|
|
2413
|
+
// keep only leaves whose service task carries an `io.nanobpm.agentTask.` header
|
|
2414
|
+
// (e.g. `senior:plan` carries `io.nanobpm.agentTask.task.prompt`; a record-keeper
|
|
2415
|
+
// does not). Advertise the raw job-type string the engine matches (`senior:plan`)
|
|
2416
|
+
// verbatim — colon-named types are NOT forced through the agentic dot-grammar.
|
|
2417
|
+
// ---------------------------------------------------------------------------
|
|
2418
|
+
|
|
2419
|
+
// True iff a serviceTask body carries a `zeebe:taskHeader` under the agent-task
|
|
2420
|
+
// namespace — the marker that distinguishes an agent task from a plain
|
|
2421
|
+
// connector / record-keeper. Matches the exact `io.nanobpm.agentTask` key and
|
|
2422
|
+
// any flattened `io.nanobpm.agentTask.*` dotpath key (element templates emit the
|
|
2423
|
+
// latter, e.g. `io.nanobpm.agentTask.task.prompt`).
|
|
2424
|
+
function serviceTaskHasAgentHeader(body) {
|
|
2425
|
+
const headerRe = /<zeebe:header\b[^>]*\bkey\s*=\s*"([^"]*)"/g;
|
|
2426
|
+
let m;
|
|
2427
|
+
while ((m = headerRe.exec(body)) !== null) {
|
|
2428
|
+
const key = m[1];
|
|
2429
|
+
if (key === AGENT_TASK_NS || key.startsWith(`${AGENT_TASK_NS}.`)) return true;
|
|
2430
|
+
}
|
|
2431
|
+
return false;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// Scan one deployed BPMN document for its *agent* task-definition leaves: every
|
|
2435
|
+
// `<bpmn:serviceTask>` carrying BOTH a non-empty `<zeebe:taskDefinition type>`
|
|
2436
|
+
// AND an `io.nanobpm.agentTask.` task header. Returns `{ taskType, process }`
|
|
2437
|
+
// leaves in first-occurrence order. This is the header-aware extension of the
|
|
2438
|
+
// demand package's `scanTaskDefinitions` (which reads type/element/process only).
|
|
2439
|
+
function scanAgentTaskLeaves(xml) {
|
|
2440
|
+
const source = String(xml || '');
|
|
2441
|
+
const procMatch = source.match(/<bpmn:process\b[^>]*\bid\s*=\s*"([^"]*)"/);
|
|
2442
|
+
const proc = procMatch ? procMatch[1] : '';
|
|
2443
|
+
const out = [];
|
|
2444
|
+
const blockRe = /<bpmn:serviceTask\b[^>]*>([\s\S]*?)<\/bpmn:serviceTask>/g;
|
|
2445
|
+
let block;
|
|
2446
|
+
while ((block = blockRe.exec(source)) !== null) {
|
|
2447
|
+
const body = block[1];
|
|
2448
|
+
const tdMatch = body.match(/<zeebe:taskDefinition\b[^>]*>/);
|
|
2449
|
+
if (!tdMatch) continue;
|
|
2450
|
+
const typeMatch = tdMatch[0].match(/\btype\s*=\s*"([^"]*)"/);
|
|
2451
|
+
const taskType = typeMatch ? typeMatch[1] : '';
|
|
2452
|
+
if (!taskType) continue;
|
|
2453
|
+
if (!serviceTaskHasAgentHeader(body)) continue;
|
|
2454
|
+
out.push({ taskType, process: proc });
|
|
2455
|
+
}
|
|
2456
|
+
return out;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// Read the distinct deployed *agent* job types through a demand C8RestReader
|
|
2460
|
+
// seam: enumerate the deployed definitions, fetch each one's BPMN XML, scan the
|
|
2461
|
+
// agent leaves, and return the distinct job types in first-occurrence order. An
|
|
2462
|
+
// optional `scope` narrows to one app/network — kept only when the leaf's
|
|
2463
|
+
// `bpmn:process` id equals or is prefixed by the scope string.
|
|
2464
|
+
async function readDeployedAgentJobTypes(reader, { scope = '' } = {}) {
|
|
2465
|
+
const keys = await reader.searchProcessDefinitionKeys();
|
|
2466
|
+
const seen = new Set();
|
|
2467
|
+
const out = [];
|
|
2468
|
+
for (const key of keys) {
|
|
2469
|
+
const xml = await reader.getProcessDefinitionXml(key);
|
|
2470
|
+
for (const leaf of scanAgentTaskLeaves(xml)) {
|
|
2471
|
+
if (scope && !(leaf.process === scope || leaf.process.startsWith(scope))) continue;
|
|
2472
|
+
if (seen.has(leaf.taskType)) continue;
|
|
2473
|
+
seen.add(leaf.taskType);
|
|
2474
|
+
out.push(leaf.taskType);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
return out;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// Build the live C8 v2 REST reader from the broker REST config. `httpC8RestReader`
|
|
2481
|
+
// appends `/process-definitions/...` to its `restAddress`, and the C8 v2 API is
|
|
2482
|
+
// mounted under `/v2` on the broker (same base the linked-resource fetch uses),
|
|
2483
|
+
// so the reader's address is `<baseUrl>/v2`. The demand module is imported lazily
|
|
2484
|
+
// through the single agentic surface (`agentic.mjs`) so the whole agentic module
|
|
2485
|
+
// graph only loads when `--auto` is actually used.
|
|
2486
|
+
async function defaultC8RestReader(restConfig) {
|
|
2487
|
+
const { demand } = await import('./agentic.mjs');
|
|
2488
|
+
const base = String(restConfig?.baseUrl || DEFAULT_NANO_URL).replace(/\/+$/, '');
|
|
2489
|
+
return demand.httpC8RestReader({
|
|
2490
|
+
restAddress: `${base}/v2`,
|
|
2491
|
+
token: restConfig?.token ? restConfig.token : undefined,
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// Resolve the desired job-type set for `--auto`: all deployed agent job types
|
|
2496
|
+
// read from the engine, optionally scoped to one process-id/prefix. A test may
|
|
2497
|
+
// inject an in-memory `readerFactory` to drive it without a live engine. The
|
|
2498
|
+
// whole read is time-bounded (`timeoutMs`, 0 disables) and a timeout rejects
|
|
2499
|
+
// with a clear error, so a stalled engine read settles the awaited promise
|
|
2500
|
+
// (KEEP the running set) instead of wedging the reconcile — and, via shutdown's
|
|
2501
|
+
// `await inFlightReconcile`, wedging `Ctrl-C`/SIGTERM.
|
|
2502
|
+
async function resolveAutoJobTypes({ restConfig, scope = '', readerFactory, timeoutMs = AUTO_ENGINE_READ_TIMEOUT_MS } = {}) {
|
|
2503
|
+
const read = (async () => {
|
|
2504
|
+
const reader = readerFactory ? await readerFactory() : await defaultC8RestReader(restConfig);
|
|
2505
|
+
return readDeployedAgentJobTypes(reader, { scope });
|
|
2506
|
+
})();
|
|
2507
|
+
if (!(timeoutMs > 0)) return read;
|
|
2508
|
+
let timer;
|
|
2509
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
2510
|
+
timer = setTimeout(() => reject(new Error(`engine read timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
2511
|
+
});
|
|
2512
|
+
try {
|
|
2513
|
+
return await Promise.race([read, timeout]);
|
|
2514
|
+
} finally {
|
|
2515
|
+
clearTimeout(timer);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2393
2519
|
// Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
|
|
2394
2520
|
// `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
|
|
2395
2521
|
// worker always fetches `/content/binary`.
|
|
@@ -3830,19 +3956,59 @@ async function workAgent(req, flags) {
|
|
|
3830
3956
|
logger.error(jobTypeErrors.join('; '));
|
|
3831
3957
|
process.exit(1);
|
|
3832
3958
|
}
|
|
3833
|
-
|
|
3959
|
+
// Zero-config engine-read enrolment (issue #66). `--auto` subscribes this
|
|
3960
|
+
// worker to ALL deployed *agent* job types read straight from the engine —
|
|
3961
|
+
// no capability, no app enrol endpoint, no channel connection. It is the
|
|
3962
|
+
// mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
|
|
3963
|
+
// the rank×capability matrix is bypassed entirely (any deployed agent job is
|
|
3964
|
+
// served, gated only by the `io.nanobpm.agentTask.` task header), and the
|
|
3965
|
+
// desired set is reconciled by polling the engine rather than watching the
|
|
3966
|
+
// profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
|
|
3967
|
+
// app/network; without it, every agent job type on the engine is served.
|
|
3968
|
+
//
|
|
3969
|
+
// TRUST: engine-read has no capability gate — a `--auto` worker will serve any
|
|
3970
|
+
// deployed agent job on its engine. That is the accepted trade for the
|
|
3971
|
+
// local/zero-config target; capability-gated serving is the specialised path.
|
|
3972
|
+
const autoMode = coerceBool(flags?.auto, false);
|
|
3973
|
+
const autoScope = flags?.['auto-scope'] ? String(flags['auto-scope']).trim() : '';
|
|
3974
|
+
if (!autoMode && autoScope) {
|
|
3975
|
+
logger.error('--auto-scope requires --auto (it narrows the engine-read agent job types).');
|
|
3976
|
+
process.exit(1);
|
|
3977
|
+
}
|
|
3834
3978
|
const camunda = globalThis.c8ctl.createClient();
|
|
3835
3979
|
|
|
3836
3980
|
// Broker REST endpoint for live linked-resource prompts (issue #63) — the same
|
|
3837
|
-
// nano endpoint this worker already talks to. Resolved once at startup
|
|
3981
|
+
// nano endpoint this worker already talks to. Resolved once at startup, and
|
|
3982
|
+
// reused as the C8 REST source for `--auto`'s engine-read enrolment.
|
|
3838
3983
|
const restConfig = resolveBrokerRestConfig();
|
|
3839
3984
|
|
|
3985
|
+
// The desired job-type set. In `--auto` it is engine-read (∪ any --job-type
|
|
3986
|
+
// extras); otherwise it is the rank×capability matrix (∪ extras). The initial
|
|
3987
|
+
// engine read is best-effort — a transient failure starts the worker with no
|
|
3988
|
+
// auto pollers and the poll reconcile below fills them in on the next pass,
|
|
3989
|
+
// rather than refusing to start.
|
|
3990
|
+
let jobTypes;
|
|
3991
|
+
if (autoMode) {
|
|
3992
|
+
try {
|
|
3993
|
+
const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
|
|
3994
|
+
jobTypes = [...new Set([...autoTypes, ...extraJobTypes])];
|
|
3995
|
+
} catch (err) {
|
|
3996
|
+
logger.warn(`--auto: initial engine read failed (${err?.message || err}); starting with no auto pollers — will retry on the next poll.`);
|
|
3997
|
+
jobTypes = [...new Set(extraJobTypes)];
|
|
3998
|
+
}
|
|
3999
|
+
} else {
|
|
4000
|
+
jobTypes = [...new Set([...matrix, ...extraJobTypes])];
|
|
4001
|
+
}
|
|
4002
|
+
|
|
3840
4003
|
logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
3841
4004
|
logger.info(` worker: ${workerName}`);
|
|
3842
4005
|
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
3843
4006
|
logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
|
|
3844
4007
|
const profileEnvKeys = Object.keys(profileEnv);
|
|
3845
4008
|
if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
|
|
4009
|
+
if (autoMode) {
|
|
4010
|
+
logger.info(` enrolment: --auto (zero-config engine read${autoScope ? `, scope "${autoScope}"` : ', all agent job types'}) — no capability gate; serves any deployed agent job on this engine.`);
|
|
4011
|
+
}
|
|
3846
4012
|
const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
|
|
3847
4013
|
logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
|
|
3848
4014
|
logger.info(` max parallel: ${maxParallelJobs}; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
|
|
@@ -4248,12 +4414,20 @@ async function workAgent(req, flags) {
|
|
|
4248
4414
|
|
|
4249
4415
|
for (const jobType of jobTypes) spawnJobType(jobType);
|
|
4250
4416
|
|
|
4251
|
-
// ---- Live
|
|
4252
|
-
//
|
|
4253
|
-
//
|
|
4254
|
-
//
|
|
4417
|
+
// ---- Live reconcile: keep the poller set in step with the desired job-type
|
|
4418
|
+
// set — start pollers for added types, gracefully drain pollers for removed
|
|
4419
|
+
// types — without a restart and without disturbing unchanged types' in-flight
|
|
4420
|
+
// work. The DESIRED set comes from one of two sources depending on mode:
|
|
4421
|
+
// - default: the watched profile's rank×capability matrix (∪ --job-type),
|
|
4422
|
+
// reconciled when the on-disk profile changes (e.g. `nano assign`);
|
|
4423
|
+
// - --auto: the engine's deployed *agent* job types, reconciled by polling
|
|
4424
|
+
// the engine (the deployed set changes as apps deploy/undeploy). ----
|
|
4255
4425
|
const configFile = getConfigFile();
|
|
4256
4426
|
const WATCH_INTERVAL_MS = 1500;
|
|
4427
|
+
// How often `--auto` re-reads the engine's deployed agent job types to pick up
|
|
4428
|
+
// newly deployed / undeployed agent processes. Deploys are occasional, so a
|
|
4429
|
+
// few seconds of latency is fine; the read is a couple of cheap C8 REST calls.
|
|
4430
|
+
const AUTO_POLL_INTERVAL_MS = 5000;
|
|
4257
4431
|
let reconciling = false;
|
|
4258
4432
|
// Set when a profile change arrives while a reconcile is already in flight, so
|
|
4259
4433
|
// we run one more pass after the current drain completes instead of dropping
|
|
@@ -4263,10 +4437,21 @@ async function workAgent(req, flags) {
|
|
|
4263
4437
|
// before snapshotting `workers` (avoids double-stops / missed drains).
|
|
4264
4438
|
let inFlightReconcile = null;
|
|
4265
4439
|
|
|
4266
|
-
// Desired job types
|
|
4267
|
-
// extras)
|
|
4268
|
-
//
|
|
4269
|
-
|
|
4440
|
+
// Desired job types. In `--auto` this is the engine's deployed agent job types
|
|
4441
|
+
// (∪ --job-type extras), read fresh each pass; a transient engine-read failure
|
|
4442
|
+
// returns { skip } so the running set is KEPT, never torn down. Otherwise it is
|
|
4443
|
+
// the CURRENT on-disk profile's matrix (∪ extras), with { skip } for a
|
|
4444
|
+
// transient/torn read, a vanished profile, or an invalid edit — callers must
|
|
4445
|
+
// then KEEP the running set, never tear down.
|
|
4446
|
+
const desiredJobTypes = async () => {
|
|
4447
|
+
if (autoMode) {
|
|
4448
|
+
try {
|
|
4449
|
+
const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
|
|
4450
|
+
return { jobTypes: [...new Set([...autoTypes, ...extraJobTypes])] };
|
|
4451
|
+
} catch (err) {
|
|
4452
|
+
return { skip: `engine read failed: ${err?.message || err}` };
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4270
4455
|
let stored;
|
|
4271
4456
|
try {
|
|
4272
4457
|
stored = readHiresStrict()[name];
|
|
@@ -4309,9 +4494,11 @@ async function workAgent(req, flags) {
|
|
|
4309
4494
|
};
|
|
4310
4495
|
|
|
4311
4496
|
const runReconcilePass = async () => {
|
|
4312
|
-
const desired = desiredJobTypes();
|
|
4497
|
+
const desired = await desiredJobTypes();
|
|
4313
4498
|
if (desired.skip) {
|
|
4314
|
-
if (
|
|
4499
|
+
if (autoMode) {
|
|
4500
|
+
logger.warn(`--auto reconcile skipped — ${desired.skip}; keeping the current ${workers.size} worker(s) running.`);
|
|
4501
|
+
} else if (desired.skip === 'deleted') {
|
|
4315
4502
|
logger.warn(`Profile "${name}" is gone from config — keeping the current ${workers.size} worker(s) running.`);
|
|
4316
4503
|
} else {
|
|
4317
4504
|
logger.warn(`Profile "${name}" reload skipped — ${desired.skip}; keeping current workers.`);
|
|
@@ -4320,7 +4507,8 @@ async function workAgent(req, flags) {
|
|
|
4320
4507
|
}
|
|
4321
4508
|
const { added, removed } = diffJobTypes([...workers.keys()], desired.jobTypes);
|
|
4322
4509
|
if (added.length === 0 && removed.length === 0) return;
|
|
4323
|
-
|
|
4510
|
+
const source = autoMode ? 'engine deployed set' : `Profile "${name}"`;
|
|
4511
|
+
logger.info(`${source} changed — reconciling job types (+${added.length} / -${removed.length}).`);
|
|
4324
4512
|
for (const jt of added) {
|
|
4325
4513
|
spawnJobType(jt);
|
|
4326
4514
|
logger.info(` + now listening on ${jt}`);
|
|
@@ -4344,37 +4532,58 @@ async function workAgent(req, flags) {
|
|
|
4344
4532
|
logger.info(` now listening on ${workers.size} job type(s): ${[...workers.keys()].join(' ')}`);
|
|
4345
4533
|
};
|
|
4346
4534
|
|
|
4347
|
-
//
|
|
4348
|
-
//
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
//
|
|
4353
|
-
//
|
|
4354
|
-
//
|
|
4355
|
-
//
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4535
|
+
// Reconcile trigger. In `--auto` a periodic engine poll re-reads the deployed
|
|
4536
|
+
// agent job types; otherwise a profile-file watch fires on profile edits.
|
|
4537
|
+
let autoPollTimer = null;
|
|
4538
|
+
if (autoMode) {
|
|
4539
|
+
// Self-standing interval poll (not watchFile) since the desired set is
|
|
4540
|
+
// derived from the engine, not the on-disk profile. Skip a tick while a
|
|
4541
|
+
// reconcile is already in flight: calling reconcile() then would set
|
|
4542
|
+
// reconcileRequested and make the in-flight pass loop back-to-back, so an
|
|
4543
|
+
// engine read that consistently outlasts AUTO_POLL_INTERVAL_MS would run
|
|
4544
|
+
// reconciles as fast as the read completes and hammer the broker. Skipping
|
|
4545
|
+
// keeps polling rate-limited to the configured interval regardless of
|
|
4546
|
+
// engine-read latency; the next tick re-reads the latest engine state.
|
|
4547
|
+
autoPollTimer = setInterval(() => {
|
|
4548
|
+
if (inFlightReconcile) return;
|
|
4549
|
+
reconcile().catch((err) => logger.warn(`--auto reconcile failed: ${err?.message || err}`));
|
|
4550
|
+
}, AUTO_POLL_INTERVAL_MS);
|
|
4551
|
+
if (typeof autoPollTimer.unref === 'function') autoPollTimer.unref();
|
|
4552
|
+
} else {
|
|
4553
|
+
// `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
|
|
4554
|
+
// atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
|
|
4555
|
+
// inode and go silent), and it's uniform across platforms. Profile edits are
|
|
4556
|
+
// rare + manual, so a ~1.5s poll latency is fine.
|
|
4557
|
+
watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
|
|
4558
|
+
// Fires each interval; act only on real changes. Compare mtime, ctime and
|
|
4559
|
+
// size, not mtime alone: on filesystems with coarse mtime resolution (or two
|
|
4560
|
+
// edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
|
|
4561
|
+
// differ, and an mtime-only guard would skip a genuine profile update.
|
|
4562
|
+
if (
|
|
4563
|
+
curr.mtimeMs === prev.mtimeMs &&
|
|
4564
|
+
curr.ctimeMs === prev.ctimeMs &&
|
|
4565
|
+
curr.size === prev.size
|
|
4566
|
+
) return;
|
|
4567
|
+
// `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
|
|
4568
|
+
// a reconcile is already running coalesces into the current pass and returns
|
|
4569
|
+
// that same in-flight promise, so shutdown always waits for the real one.
|
|
4570
|
+
reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
|
|
4571
|
+
});
|
|
4572
|
+
}
|
|
4366
4573
|
|
|
4367
4574
|
// Keep the process alive until a stop signal, then drain gracefully.
|
|
4368
4575
|
await new Promise((resolve) => {
|
|
4369
4576
|
const stop = async (signal) => {
|
|
4370
4577
|
if (draining) return;
|
|
4371
4578
|
draining = true;
|
|
4372
|
-
// Stop
|
|
4373
|
-
// any in-flight reconcile to finish before snapshotting
|
|
4374
|
-
// prevents double-stops, missed drains, or a wrong worker
|
|
4375
|
-
|
|
4579
|
+
// Stop the reconcile trigger first so no new reconcile can be triggered,
|
|
4580
|
+
// then wait for any in-flight reconcile to finish before snapshotting
|
|
4581
|
+
// `workers` — this prevents double-stops, missed drains, or a wrong worker
|
|
4582
|
+
// count on exit.
|
|
4583
|
+
if (autoPollTimer) clearInterval(autoPollTimer);
|
|
4584
|
+
else unwatchFile(configFile);
|
|
4376
4585
|
if (inFlightReconcile) {
|
|
4377
|
-
logger.info('Waiting for in-flight
|
|
4586
|
+
logger.info('Waiting for in-flight reconcile to finish before shutdown…');
|
|
4378
4587
|
await inFlightReconcile;
|
|
4379
4588
|
}
|
|
4380
4589
|
const list = [...workers.values()];
|
|
@@ -4465,6 +4674,8 @@ const WORK_FORWARD_FLAGS = {
|
|
|
4465
4674
|
'clone-timeout': 'value',
|
|
4466
4675
|
'keep-runs': 'boolean',
|
|
4467
4676
|
stream: 'boolean',
|
|
4677
|
+
auto: 'boolean',
|
|
4678
|
+
'auto-scope': 'value',
|
|
4468
4679
|
arg: 'list',
|
|
4469
4680
|
env: 'list',
|
|
4470
4681
|
'job-type': 'list',
|
|
@@ -4477,11 +4688,21 @@ const WORK_FORWARD_FLAGS = {
|
|
|
4477
4688
|
function reconstructWorkArgs(flags) {
|
|
4478
4689
|
const out = [];
|
|
4479
4690
|
if (!flags || typeof flags !== 'object') return out;
|
|
4691
|
+
// `--auto-scope` is meaningless without `--auto` — `workAgent` exits fast with
|
|
4692
|
+
// "--auto-scope requires --auto". Forwarding the orphan flag to a supervised
|
|
4693
|
+
// worker would guarantee an immediate crash/restart loop, so drop it here at
|
|
4694
|
+
// the forwarding boundary when `--auto` is not truthy (mirrors that guard).
|
|
4695
|
+
// Parse booleans through `coerceBool()` so forwarding matches `workAgent`'s
|
|
4696
|
+
// parsing semantics — c8ctl may pass boolean flags as strings like `'1'`,
|
|
4697
|
+
// `'yes'` or `'on'`, and treating only `true`/`'true'` as enabled would
|
|
4698
|
+
// silently drop `--auto`/`--keep-runs`/`--stream` for supervised workers.
|
|
4699
|
+
const autoOn = coerceBool(flags.auto, false);
|
|
4480
4700
|
for (const [name, kind] of Object.entries(WORK_FORWARD_FLAGS)) {
|
|
4701
|
+
if (name === 'auto-scope' && !autoOn) continue;
|
|
4481
4702
|
const v = flags[name];
|
|
4482
4703
|
if (v === undefined || v === null) continue;
|
|
4483
4704
|
if (kind === 'boolean') {
|
|
4484
|
-
if (v
|
|
4705
|
+
if (coerceBool(v, false)) out.push(`--${name}`);
|
|
4485
4706
|
} else if (kind === 'list') {
|
|
4486
4707
|
const items = Array.isArray(v) ? v : [v];
|
|
4487
4708
|
for (const item of items) {
|
|
@@ -7301,6 +7522,10 @@ export {
|
|
|
7301
7522
|
jobTypeMatrix,
|
|
7302
7523
|
diffJobTypes,
|
|
7303
7524
|
parseJobTypeFlags,
|
|
7525
|
+
serviceTaskHasAgentHeader,
|
|
7526
|
+
scanAgentTaskLeaves,
|
|
7527
|
+
readDeployedAgentJobTypes,
|
|
7528
|
+
resolveAutoJobTypes,
|
|
7304
7529
|
derivePollTimeoutMs,
|
|
7305
7530
|
AGENT_TASK_NS,
|
|
7306
7531
|
AGENT_RESULT_KEY,
|
|
@@ -7382,6 +7607,8 @@ export const metadata = {
|
|
|
7382
7607
|
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --terminal pty', description: 'Opt this role into a full, steerable live terminal (PTY) streamed on the agentic relay lane (default: pipe)' },
|
|
7383
7608
|
{ command: 'c8ctl nano assign reviewer code-review,testing', description: 'Grant more capabilities (comma-separated, like hire) to an existing hire — additive; running workers hot-reload it' },
|
|
7384
7609
|
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
7610
|
+
{ command: 'c8ctl nano work coder --auto', description: 'Zero-config: serve every deployed agent job type read straight from the engine — no capability, no wiring (great for a local single-tenant plane)' },
|
|
7611
|
+
{ command: 'c8ctl nano work coder --auto --auto-scope my-app', description: 'Zero-config, scoped to one app: serve only agent job types deployed under process ids prefixed "my-app"' },
|
|
7385
7612
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
7386
7613
|
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_TOKEN=<identity-token> NANO_AGENTIC_CREDENTIAL=<capability-cred> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
7387
7614
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
@@ -7456,6 +7683,8 @@ export const commands = {
|
|
|
7456
7683
|
'lock-grace': { type: 'string', description: 'work: DEPRECATED and ignored — the broker lock is now auto-managed via --recovery-window.' },
|
|
7457
7684
|
'poll-timeout': { type: 'string', description: 'work: broker long-poll window in ms each activateJobs request is held open (fewer reconnects → fewer transient connect errors); default 30000, 0 = broker default, negative = return immediately' },
|
|
7458
7685
|
'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
|
|
7686
|
+
auto: { type: 'boolean', description: 'work: zero-config enrolment — serve ALL deployed agent job types read straight from the engine (no capability, no app enrol endpoint, no channel). Mutually exclusive with capability-resolved serving; has NO capability gate (serves any deployed agent job on the engine).' },
|
|
7687
|
+
'auto-scope': { type: 'string', description: 'work: with --auto, narrow the served agent job types to those whose bpmn:process id equals or is prefixed by this value (one app/network). Default: all agent job types on the engine.' },
|
|
7459
7688
|
worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
|
|
7460
7689
|
instances: { type: 'string', description: `supervisor add: spawn N distinct auto-named instances of the profile in one call (default 1, max ${MAX_ADD_INSTANCES}; cannot combine with --name)` },
|
|
7461
7690
|
attach: { type: 'boolean', description: 'supervisor start: attach the interactive console after starting the daemon' },
|
|
@@ -7613,7 +7842,7 @@ function printUsage() {
|
|
|
7613
7842
|
console.log(' c8ctl nano update [--check]');
|
|
7614
7843
|
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--env NAME=VALUE ...] [--list]');
|
|
7615
7844
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
7616
|
-
console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
|
|
7845
|
+
console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--max-parallel <n>] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
|
|
7617
7846
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
7618
7847
|
console.log('');
|
|
7619
7848
|
console.log('Subcommands:');
|
|
@@ -7660,6 +7889,8 @@ function printUsage() {
|
|
|
7660
7889
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
7661
7890
|
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
7662
7891
|
console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
|
|
7892
|
+
console.log(' --auto work: zero-config enrolment — serve ALL deployed agent job types read from the engine (no capability, no app enrol endpoint, no channel). NO capability gate: serves any deployed agent job on the engine.');
|
|
7893
|
+
console.log(' --auto-scope <p> work: with --auto, narrow to agent job types whose bpmn:process id equals or is prefixed by <p> (one app/network); default all');
|
|
7663
7894
|
console.log(' --recovery-window <ms> work: broker activation-lock window, auto-refreshed while the agent runs; also the node-loss reclaim time (default 300000)');
|
|
7664
7895
|
console.log(' --idle-timeout <ms> work: max silence (no agent stdout/stderr) before the harness is killed as wedged and the job reclaimed (default 300000)');
|
|
7665
7896
|
console.log(' --job-timeout <ms> work: OPTIONAL absolute hard cap on total harness runtime; killed past this (default 0 = unlimited)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.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",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.33.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.0",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.0"
|
|
67
67
|
}
|
|
68
68
|
}
|