c8ctl-plugin-nano 1.42.0 → 1.43.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 +139 -2
- package/c8ctl-plugin.js +1119 -29
- package/nanobpmn-binary.json +3 -3
- package/package.json +8 -8
package/c8ctl-plugin.js
CHANGED
|
@@ -482,7 +482,7 @@ function launcherEnvMarkers(resolved) {
|
|
|
482
482
|
// Argument parsing
|
|
483
483
|
// ---------------------------------------------------------------------------
|
|
484
484
|
|
|
485
|
-
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'unset', 'config', 'update', 'hire', 'assign', 'work', 'supervisor'];
|
|
485
|
+
const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'unset', 'config', 'update', 'hire', 'assign', 'work', 'supervisor', 'workforce'];
|
|
486
486
|
|
|
487
487
|
/**
|
|
488
488
|
* Parse positional args + flags into a normalized request.
|
|
@@ -1629,6 +1629,10 @@ function normalizeArgList(input) {
|
|
|
1629
1629
|
const out = [];
|
|
1630
1630
|
for (const item of list) {
|
|
1631
1631
|
if (item == null) continue;
|
|
1632
|
+
// A value-less string flag (e.g. a bare `--arg` with no following value) is
|
|
1633
|
+
// coerced to boolean `true` by the flag layer. That is never a real arg, so
|
|
1634
|
+
// drop it rather than persist the misleading literal token "true".
|
|
1635
|
+
if (typeof item === 'boolean') continue;
|
|
1632
1636
|
const s = String(item);
|
|
1633
1637
|
if (s.length === 0) continue;
|
|
1634
1638
|
out.push(s);
|
|
@@ -1636,6 +1640,17 @@ function normalizeArgList(input) {
|
|
|
1636
1640
|
return out;
|
|
1637
1641
|
}
|
|
1638
1642
|
|
|
1643
|
+
// True when a repeatable `--arg` flag contains a value-less occurrence: a bare
|
|
1644
|
+
// `--arg` with no following value arrives as boolean `true` from the flag layer.
|
|
1645
|
+
// Such an invocation is malformed — normalizeArgList would silently drop it,
|
|
1646
|
+
// making it look like the intended arg was accepted — so callers reject it up
|
|
1647
|
+
// front rather than let it be a silent no-op.
|
|
1648
|
+
function hasValuelessArg(argFlag) {
|
|
1649
|
+
if (argFlag === undefined) return false;
|
|
1650
|
+
const items = Array.isArray(argFlag) ? argFlag : [argFlag];
|
|
1651
|
+
return items.some((a) => a === true);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1639
1654
|
// POSIX single-quote a string so it survives `sh -c`/shell:true as one literal
|
|
1640
1655
|
// argv token, no matter what it contains (spaces, $, quotes, globs). Empty
|
|
1641
1656
|
// string → ''. This is what keeps structured `--arg` values injection-safe even
|
|
@@ -1968,6 +1983,12 @@ async function hireWorker(req, flags) {
|
|
|
1968
1983
|
let permission = flags?.permission !== undefined ? String(flags.permission).trim().toLowerCase() : undefined;
|
|
1969
1984
|
// Structured command-line switches appended to the command when spawned, e.g.
|
|
1970
1985
|
// `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
|
|
1986
|
+
// A value-less `--arg` (bare flag, boolean `true`) is a malformed invocation —
|
|
1987
|
+
// reject it up front rather than silently drop it (mirrors workforce add).
|
|
1988
|
+
if (hasValuelessArg(flags?.arg)) {
|
|
1989
|
+
logger.error('--arg requires a value (e.g. --arg "--allow-all").');
|
|
1990
|
+
process.exit(1);
|
|
1991
|
+
}
|
|
1971
1992
|
const commandArgs = normalizeArgList(flags?.arg);
|
|
1972
1993
|
const envFromFlags = flags?.env !== undefined;
|
|
1973
1994
|
const { env: profileEnv, errors: envErrors } = parseEnvPairs(flags?.env);
|
|
@@ -3961,12 +3982,12 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
3961
3982
|
}
|
|
3962
3983
|
|
|
3963
3984
|
// ---- ACP capture (C3 #110 — the third harness path, "minimal mode") ---------
|
|
3964
|
-
// Some ACP agents are native (`copilot --acp`, `
|
|
3965
|
-
// adapter (`claude-
|
|
3966
|
-
// `--arg`s already assemble the ACP invocation; we
|
|
3967
|
-
// switch when the assembled line doesn't already
|
|
3968
|
-
// invocation is never doubled. This mirrors how
|
|
3969
|
-
// under a shell.
|
|
3985
|
+
// Some ACP agents are native (`copilot --acp`, `qwen --experimental-acp`,
|
|
3986
|
+
// `opencode acp`), some ride an adapter (`claude-code-acp`, `pi-acp`). In every
|
|
3987
|
+
// case the profile's command + `--arg`s already assemble the ACP invocation; we
|
|
3988
|
+
// only append a default `--acp` switch when the assembled line doesn't already
|
|
3989
|
+
// select ACP, so a native/adapter invocation is never doubled. This mirrors how
|
|
3990
|
+
// the pipe path spawns the line under a shell.
|
|
3970
3991
|
function ensureAcpFlag(commandLine) {
|
|
3971
3992
|
// Detection must survive buildAgentCommandLine()'s POSIX single-quoting: a
|
|
3972
3993
|
// structured `--arg acp` (or `--arg --acp`) lands here as the quoted token
|
|
@@ -3977,21 +3998,64 @@ function ensureAcpFlag(commandLine) {
|
|
|
3977
3998
|
// - a native ACP selector `acp`/`-acp`/`--acp` (subcommand or switch) as a
|
|
3978
3999
|
// WHOLE token, in ANY position (it may be the command or an argument), or
|
|
3979
4000
|
// - an adapter command whose basename ends in `-acp` (claude-agent-acp,
|
|
3980
|
-
// pi-acp) — but ONLY the command token (first token
|
|
4001
|
+
// pi-acp) — but ONLY the command token (the first token past any leading
|
|
4002
|
+
// env-assignment prefix, see commandIndex below), since an *argument*
|
|
3981
4003
|
// that merely ends in `-acp` (e.g. `--model foo-acp`) is not an ACP
|
|
3982
4004
|
// selector. Matching whole tokens/basenames (not a substring) also avoids
|
|
3983
4005
|
// the false positive of a path that merely contains `/acp/`.
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
4006
|
+
// A shell WORD is a contiguous run of quoted and/or unquoted segments with no
|
|
4007
|
+
// whitespace between them, so a `\S+` token boundary is wrong: an env prefix
|
|
4008
|
+
// whose value is quoted and contains spaces (e.g. `FOO='a b' claude-code-acp`)
|
|
4009
|
+
// is a SINGLE word `FOO=a b`, but `\S+` would split it into `FOO='a` and `b'`,
|
|
4010
|
+
// corrupting commandIndex so the real `*-acp` command token is never checked
|
|
4011
|
+
// (and `--acp` wrongly appended). Glue adjacent segments into one word.
|
|
4012
|
+
const tokens = commandLine.match(/(?:'(?:[^']|'\\'')*'|"(?:[^"\\]|\\.)*"|[^\s'"]+)+/g) || [];
|
|
4013
|
+
// Strip shell quoting across ALL of a word's segments (a word may mix quoted
|
|
4014
|
+
// and unquoted runs, e.g. `FOO='a b'`), yielding the logical value.
|
|
4015
|
+
const unquote = (tok) => {
|
|
4016
|
+
let out = '';
|
|
4017
|
+
const seg = /'((?:[^']|'\\'')*)'|"((?:[^"\\]|\\.)*)"|([^\s'"]+)/g;
|
|
4018
|
+
let m;
|
|
4019
|
+
while ((m = seg.exec(tok)) !== null) {
|
|
4020
|
+
if (m[1] !== undefined) out += m[1].replace(/'\\''/g, "'");
|
|
4021
|
+
else if (m[2] !== undefined) out += m[2].replace(/\\(["\\$`])/g, '$1');
|
|
4022
|
+
else out += m[3];
|
|
3991
4023
|
}
|
|
3992
|
-
|
|
4024
|
+
return out;
|
|
4025
|
+
};
|
|
4026
|
+
// The `*-acp` adapter suffix identifies the COMMAND token, but the command is
|
|
4027
|
+
// not always token 0: a shell command line may be prefixed with a contiguous
|
|
4028
|
+
// run of env assignments (`NAME=value`, e.g. the `ACP=true` in
|
|
4029
|
+
// `ACP=true claude-code-acp`). Skip that leading assignment prefix so the
|
|
4030
|
+
// adapter check lands on the real command token. Only a *leading* run counts —
|
|
4031
|
+
// once a real command token appears, later `FOO=bar` tokens are arguments.
|
|
4032
|
+
let commandIndex = 0;
|
|
4033
|
+
while (commandIndex < tokens.length &&
|
|
4034
|
+
/^[A-Za-z_][A-Za-z0-9_]*=/.test(unquote(tokens[commandIndex]))) {
|
|
4035
|
+
commandIndex++;
|
|
4036
|
+
}
|
|
4037
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
4038
|
+
let tok = unquote(tokens[i]);
|
|
4039
|
+
// Normalise a GNU-style `--opt=value` selector to its option-NAME part, so
|
|
4040
|
+
// `--acp=true` / `--experimental-acp=true` are detected as ACP selectors and
|
|
4041
|
+
// never doubled. Only the option NAME selects ACP — a `--model=foo-acp`
|
|
4042
|
+
// VALUE must not trigger (its name `--model` doesn't end in `-acp`).
|
|
4043
|
+
// Strip `=value` ONLY for switch tokens (those starting with `-`): a
|
|
4044
|
+
// non-switch token that happens to contain `=` — a leading env assignment
|
|
4045
|
+
// like `ACP=true copilot` or a bare value `acp=true` — must NOT be truncated
|
|
4046
|
+
// to `acp` and mis-detected as an ACP selector (a false positive that would
|
|
4047
|
+
// wrongly skip appending `--acp`).
|
|
4048
|
+
const name = tok.startsWith('-') ? tok.replace(/=.*$/s, '') : tok;
|
|
4049
|
+
const base = name.replace(/^.*[\\/]/, ''); // basename, for path-form commands
|
|
3993
4050
|
if (/^-{0,2}acp$/i.test(base)) return commandLine;
|
|
3994
|
-
|
|
4051
|
+
// A switch that NAMES acp, e.g. qwen's hidden `--experimental-acp` (present
|
|
4052
|
+
// in the shipped cli.js but not in `qwen --help`) — a long/short option
|
|
4053
|
+
// whose flag name ends in `-acp`. Matched in ANY position, since it is an
|
|
4054
|
+
// argument to the harness command (not the command token). A bare value that
|
|
4055
|
+
// merely ends in `-acp` (e.g. `--model foo-acp`) does NOT start with `-`, so
|
|
4056
|
+
// it is not a switch and still (correctly) triggers the append below.
|
|
4057
|
+
if (/^--?[a-z0-9][a-z0-9-]*-acp$/i.test(name)) return commandLine;
|
|
4058
|
+
if (i === commandIndex && /-acp$/i.test(base)) return commandLine;
|
|
3995
4059
|
}
|
|
3996
4060
|
return `${commandLine} --acp`;
|
|
3997
4061
|
}
|
|
@@ -5383,7 +5447,12 @@ async function workAgent(req, flags) {
|
|
|
5383
5447
|
|
|
5384
5448
|
// Structured command-line switches: the profile's persisted `--arg`s, extended
|
|
5385
5449
|
// by any work-time `--arg` (appended). Lets an operator add switches (e.g.
|
|
5386
|
-
// `--allow-all`) at dispatch time without re-hiring.
|
|
5450
|
+
// `--allow-all`) at dispatch time without re-hiring. A value-less `--arg`
|
|
5451
|
+
// (bare flag, boolean `true`) is malformed — reject it rather than drop it.
|
|
5452
|
+
if (hasValuelessArg(flags?.arg)) {
|
|
5453
|
+
logger.error('--arg requires a value (e.g. --arg "--allow-all").');
|
|
5454
|
+
process.exit(1);
|
|
5455
|
+
}
|
|
5387
5456
|
const effectiveArgs = [...profile.args, ...normalizeArgList(flags?.arg)];
|
|
5388
5457
|
|
|
5389
5458
|
const intFlag = (v, dflt) => {
|
|
@@ -6529,13 +6598,15 @@ function extractNameFlag(parts) {
|
|
|
6529
6598
|
const MAX_ADD_INSTANCES = 64;
|
|
6530
6599
|
|
|
6531
6600
|
/**
|
|
6532
|
-
* Parse the `--instances N` count for `supervisor add
|
|
6533
|
-
* (
|
|
6534
|
-
*
|
|
6535
|
-
*
|
|
6536
|
-
*
|
|
6601
|
+
* Parse the `--instances N` count for `supervisor add` / `workforce add`
|
|
6602
|
+
* (the caller passes its own `cmdLabel`, which appears in the over-cap error).
|
|
6603
|
+
* Accepts undefined/blank (defaults to 1), and a whole number in
|
|
6604
|
+
* `[1, MAX_ADD_INSTANCES]`. Rejects non-integers, zero/negatives, and anything
|
|
6605
|
+
* over the cap with a clear message. When a flag is repeated the last
|
|
6606
|
+
* occurrence wins (arrays are tolerated). Returns `{ count }` on success or
|
|
6607
|
+
* `{ error }` on rejection. Pure.
|
|
6537
6608
|
*/
|
|
6538
|
-
function parseInstancesCount(raw) {
|
|
6609
|
+
function parseInstancesCount(raw, cmdLabel = 'supervisor add') {
|
|
6539
6610
|
const v = Array.isArray(raw) ? raw[raw.length - 1] : raw;
|
|
6540
6611
|
if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) {
|
|
6541
6612
|
return { count: 1 };
|
|
@@ -6544,7 +6615,16 @@ function parseInstancesCount(raw) {
|
|
|
6544
6615
|
if (!/^\d+$/.test(s)) return { error: `Invalid --instances "${v}": use a whole number between 1 and ${MAX_ADD_INSTANCES}.` };
|
|
6545
6616
|
const n = Number.parseInt(s, 10);
|
|
6546
6617
|
if (n < 1) return { error: `Invalid --instances "${v}": use a whole number between 1 and ${MAX_ADD_INSTANCES}.` };
|
|
6547
|
-
if (n > MAX_ADD_INSTANCES)
|
|
6618
|
+
if (n > MAX_ADD_INSTANCES) {
|
|
6619
|
+
// `supervisor add` accumulates workers, so exceeding the cap can be worked
|
|
6620
|
+
// around by re-running the command. Workforce entries are updated in place,
|
|
6621
|
+
// so the cap is a hard per-entry maximum — the rerun hint would mislead.
|
|
6622
|
+
return {
|
|
6623
|
+
error: cmdLabel === 'supervisor add'
|
|
6624
|
+
? `--instances ${n} exceeds the ${MAX_ADD_INSTANCES}-per-command cap; run "${cmdLabel}" again to add more.`
|
|
6625
|
+
: `--instances ${n} exceeds the ${MAX_ADD_INSTANCES}-per-entry maximum.`,
|
|
6626
|
+
};
|
|
6627
|
+
}
|
|
6548
6628
|
return { count: n };
|
|
6549
6629
|
}
|
|
6550
6630
|
|
|
@@ -8039,6 +8119,969 @@ async function supervisorCommand(req, flags) {
|
|
|
8039
8119
|
}
|
|
8040
8120
|
}
|
|
8041
8121
|
|
|
8122
|
+
// ---------------------------------------------------------------------------
|
|
8123
|
+
// workforce — declarative workforce manifests (issue #117). A manifest is a
|
|
8124
|
+
// named, user-curated document (`<stateHome>/workforce/<name>.json`) describing
|
|
8125
|
+
// a fleet of supervised workers you compose once (`workforce add`) and bring up
|
|
8126
|
+
// convergently with one command (`workforce start`). It is deliberately a
|
|
8127
|
+
// SEPARATE file per manifest — a portable artifact meant to be read, edited,
|
|
8128
|
+
// diffed and copied between machines — not a key in `config.json` (plugin
|
|
8129
|
+
// state). `workforce start` reconciles the running supervisor to the manifest
|
|
8130
|
+
// using deterministic `wf-<manifest>-<profile>-<index>` worker names, so a
|
|
8131
|
+
// second start with an unchanged manifest starts/stops/restarts nothing.
|
|
8132
|
+
// ---------------------------------------------------------------------------
|
|
8133
|
+
|
|
8134
|
+
const WORKFORCE_MANIFEST_VERSION = 1;
|
|
8135
|
+
const DEFAULT_WORKFORCE_MANIFEST = 'default';
|
|
8136
|
+
|
|
8137
|
+
/** The directory holding per-name workforce manifests. */
|
|
8138
|
+
function getWorkforceDir() {
|
|
8139
|
+
return join(getStateHome(), 'workforce');
|
|
8140
|
+
}
|
|
8141
|
+
|
|
8142
|
+
/** The manifest file for a given manifest name (`<stateHome>/workforce/<name>.json`). */
|
|
8143
|
+
function getWorkforceManifestFile(name) {
|
|
8144
|
+
return join(getWorkforceDir(), `${name}.json`);
|
|
8145
|
+
}
|
|
8146
|
+
|
|
8147
|
+
/**
|
|
8148
|
+
* A manifest name is valid iff it is a non-empty string of `[A-Za-z0-9._-]`
|
|
8149
|
+
* whose first character is alphanumeric (`[A-Za-z0-9]`; same charset and rule
|
|
8150
|
+
* as a profile name). It rides in both a filename and the deterministic
|
|
8151
|
+
* `wf-<name>-` worker-name prefix, so this charset keeps it safe on disk and as
|
|
8152
|
+
* a broker/supervisor worker id. Pure.
|
|
8153
|
+
*/
|
|
8154
|
+
function isValidManifestName(name) {
|
|
8155
|
+
return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
|
|
8156
|
+
}
|
|
8157
|
+
|
|
8158
|
+
/** An empty v1 manifest with the given name. Pure. */
|
|
8159
|
+
function emptyWorkforceManifest(name) {
|
|
8160
|
+
return { version: WORKFORCE_MANIFEST_VERSION, name: String(name), workers: [] };
|
|
8161
|
+
}
|
|
8162
|
+
|
|
8163
|
+
// A workforce role name (a capability token): starts with a letter/digit, then
|
|
8164
|
+
// letters/digits/`. _ + -`. No `:` (that delimits rank↔role in the mapped job
|
|
8165
|
+
// type). Roles are lowercased to match `normalizeCapabilities`.
|
|
8166
|
+
const WORKFORCE_ROLE_RE = /^[a-z0-9][a-z0-9._+-]*$/i;
|
|
8167
|
+
|
|
8168
|
+
/**
|
|
8169
|
+
* Parse a `--roles a,b,c` value (string | string[]) into a deduped, validated,
|
|
8170
|
+
* lowercased list of role names. Returns `{ roles, errors }`. Pure.
|
|
8171
|
+
*/
|
|
8172
|
+
function parseRolesList(raw) {
|
|
8173
|
+
const fromArray = Array.isArray(raw);
|
|
8174
|
+
const rawItems = fromArray ? raw : [raw];
|
|
8175
|
+
const seen = new Set();
|
|
8176
|
+
const roles = [];
|
|
8177
|
+
const errors = [];
|
|
8178
|
+
for (const rawItem of rawItems) {
|
|
8179
|
+
if (typeof rawItem !== 'string') {
|
|
8180
|
+
// Array elements must all be strings. For a lone scalar, a nullish value
|
|
8181
|
+
// means "no roles supplied" (empty result, no error), but any other
|
|
8182
|
+
// non-string (e.g. boolean `true` from a value-less `--roles` flag, or a
|
|
8183
|
+
// torn JSON value) is a malformed value, not a role — reject it rather
|
|
8184
|
+
// than coerce it into a phantom role like "true".
|
|
8185
|
+
if (fromArray || rawItem != null) {
|
|
8186
|
+
errors.push(`invalid role ${JSON.stringify(rawItem)} (expected a role-name string)`);
|
|
8187
|
+
}
|
|
8188
|
+
continue;
|
|
8189
|
+
}
|
|
8190
|
+
// A single value may itself be comma-separated (`--roles a,b`); repeated
|
|
8191
|
+
// flags arrive as an array whose elements may also be comma-separated
|
|
8192
|
+
// (`--roles a,b --roles c`), so split every element on commas.
|
|
8193
|
+
for (const item of rawItem.split(',')) {
|
|
8194
|
+
const r = item.trim().toLowerCase();
|
|
8195
|
+
if (!r) continue;
|
|
8196
|
+
if (!WORKFORCE_ROLE_RE.test(r)) { errors.push(`invalid role "${item.trim()}" (use letters, digits, and . _ + -)`); continue; }
|
|
8197
|
+
if (seen.has(r)) continue;
|
|
8198
|
+
seen.add(r);
|
|
8199
|
+
roles.push(r);
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
8202
|
+
return { roles, errors };
|
|
8203
|
+
}
|
|
8204
|
+
|
|
8205
|
+
/**
|
|
8206
|
+
* Map an explicit role list to repeatable `--job-type <rank>:<role>` tokens,
|
|
8207
|
+
* resolved AT START TIME from the manifest and the hired profile's rank —
|
|
8208
|
+
* independent of what the profile was hired with (the install script hires with
|
|
8209
|
+
* `--capabilities ""`). `["pr-review"]` × rank `senior` → `["senior:pr-review"]`.
|
|
8210
|
+
* Pure and unit-tested.
|
|
8211
|
+
*/
|
|
8212
|
+
function rolesToJobTypes(roles, rank) {
|
|
8213
|
+
if (!Array.isArray(roles)) return [];
|
|
8214
|
+
const r = String(rank || '').trim();
|
|
8215
|
+
return roles.map((role) => `${r}:${String(role).trim()}`);
|
|
8216
|
+
}
|
|
8217
|
+
|
|
8218
|
+
/**
|
|
8219
|
+
* Translate one manifest entry (+ the resolved hired-profile rank) into the
|
|
8220
|
+
* `nano work` argv tail a supervised worker runs with:
|
|
8221
|
+
* - `roles: "auto"` → `--auto [--auto-scope <s>]` (no capability gate; serves
|
|
8222
|
+
* every deployed agent job type — what the install script sets).
|
|
8223
|
+
* - `roles: [...]` → repeatable `--job-type <rank>:<role>` per role.
|
|
8224
|
+
* Then any verbatim `entry.args` escape-hatch flags are appended. Neither form
|
|
8225
|
+
* mutates the hired profile. Pure and unit-tested.
|
|
8226
|
+
*/
|
|
8227
|
+
function manifestEntryToWorkArgs(entry, rank) {
|
|
8228
|
+
const out = [];
|
|
8229
|
+
const roles = entry?.roles;
|
|
8230
|
+
if (roles === 'auto' || roles == null) {
|
|
8231
|
+
out.push('--auto');
|
|
8232
|
+
const scope = typeof entry?.autoScope === 'string' ? entry.autoScope.trim() : '';
|
|
8233
|
+
if (scope) out.push('--auto-scope', scope);
|
|
8234
|
+
} else if (Array.isArray(roles)) {
|
|
8235
|
+
for (const jt of rolesToJobTypes(roles, rank)) out.push('--job-type', jt);
|
|
8236
|
+
}
|
|
8237
|
+
for (const a of normalizeArgList(entry?.args)) out.push(a);
|
|
8238
|
+
return out;
|
|
8239
|
+
}
|
|
8240
|
+
|
|
8241
|
+
/** The ownership prefix for a manifest's workforce-owned workers. Pure. */
|
|
8242
|
+
function workforceOwnerPrefix(manifest) {
|
|
8243
|
+
return `wf-${manifest}-`;
|
|
8244
|
+
}
|
|
8245
|
+
|
|
8246
|
+
/** The deterministic worker name for the Nth instance of a profile. Pure. */
|
|
8247
|
+
function workforceWorkerName(manifest, profile, index) {
|
|
8248
|
+
return `${workforceOwnerPrefix(manifest)}${profile}-${index}`;
|
|
8249
|
+
}
|
|
8250
|
+
|
|
8251
|
+
/**
|
|
8252
|
+
* Is worker `id` owned by `manifest`? A worker name is
|
|
8253
|
+
* `wf-<manifest>-<profile>-<index>`, and BOTH the manifest name and the profile
|
|
8254
|
+
* may contain `-` (see `isValidManifestName`), so a bare `wf-<manifest>-` prefix
|
|
8255
|
+
* test is ambiguous: manifest `a` would otherwise claim `wf-a-b-...` workers that
|
|
8256
|
+
* actually belong to manifest `a-b`, letting `workforce start/stop/status`
|
|
8257
|
+
* stop or report another manifest's workers. We disambiguate by LONGEST-prefix
|
|
8258
|
+
* ownership against the manifest names that exist on this machine
|
|
8259
|
+
* (`manifestNames`): `id` belongs to `manifest` only when no OTHER existing
|
|
8260
|
+
* manifest name is a longer `wf-<name>-` prefix of `id`. When `manifestNames` is
|
|
8261
|
+
* absent/empty this degrades to the plain prefix test (unchanged behaviour), so
|
|
8262
|
+
* a worker whose more-specific owner no longer exists on disk stays claimable as
|
|
8263
|
+
* an orphan under its prefix. Pure.
|
|
8264
|
+
*/
|
|
8265
|
+
function isWorkforceOwnedWorker(id, manifest, manifestNames) {
|
|
8266
|
+
if (typeof id !== 'string' || !id.startsWith(workforceOwnerPrefix(manifest))) return false;
|
|
8267
|
+
const names = Array.isArray(manifestNames) ? manifestNames : [];
|
|
8268
|
+
for (const other of names) {
|
|
8269
|
+
if (other === manifest) continue;
|
|
8270
|
+
if (other.length > manifest.length && id.startsWith(workforceOwnerPrefix(other))) return false;
|
|
8271
|
+
}
|
|
8272
|
+
return true;
|
|
8273
|
+
}
|
|
8274
|
+
|
|
8275
|
+
/**
|
|
8276
|
+
* Parse the `<profile>` embedded in a deterministic
|
|
8277
|
+
* `wf-<manifest>-<profile>-<index>` worker id, given the owning manifest.
|
|
8278
|
+
* Returns the profile string, or `null` when `id` does not carry this manifest's
|
|
8279
|
+
* `wf-<manifest>-` prefix or does not end in a `-<index>` counter (a hand-added
|
|
8280
|
+
* id that merely shares the prefix). A profile name may itself contain dashes,
|
|
8281
|
+
* so we peel the trailing `-<digits>` index and treat the remainder as the
|
|
8282
|
+
* profile. Pure — mirrors `workforceWorkerName`'s construction.
|
|
8283
|
+
*/
|
|
8284
|
+
function workforceProfileFromWorkerName(manifest, id) {
|
|
8285
|
+
const prefix = workforceOwnerPrefix(manifest);
|
|
8286
|
+
if (typeof id !== 'string' || !id.startsWith(prefix)) return null;
|
|
8287
|
+
const rest = id.slice(prefix.length);
|
|
8288
|
+
const m = rest.match(/^(.+)-(\d+)$/);
|
|
8289
|
+
return m ? m[1] : null;
|
|
8290
|
+
}
|
|
8291
|
+
|
|
8292
|
+
/**
|
|
8293
|
+
* Expand a manifest into the flat list of desired workers it describes:
|
|
8294
|
+
* `[{ name, profile, index, entry }]`, one per instance. Pure.
|
|
8295
|
+
*/
|
|
8296
|
+
function expandWorkforceDesired(manifest) {
|
|
8297
|
+
const name = manifest?.name;
|
|
8298
|
+
const entries = Array.isArray(manifest?.workers) ? manifest.workers : [];
|
|
8299
|
+
const out = [];
|
|
8300
|
+
for (const entry of entries) {
|
|
8301
|
+
const profile = entry?.profile;
|
|
8302
|
+
const instances = Number(entry?.instances) || 0;
|
|
8303
|
+
for (let i = 1; i <= instances; i++) {
|
|
8304
|
+
out.push({ name: workforceWorkerName(name, profile, i), profile, index: i, entry });
|
|
8305
|
+
}
|
|
8306
|
+
}
|
|
8307
|
+
return out;
|
|
8308
|
+
}
|
|
8309
|
+
|
|
8310
|
+
/**
|
|
8311
|
+
* The convergent reconcile diff for `workforce start`, factored as a PURE
|
|
8312
|
+
* function over (desired workers, live supervisor workers, manifest name) so it
|
|
8313
|
+
* is unit-testable directly (mirroring how `diffJobTypes` is factored):
|
|
8314
|
+
* - `toStart` — desired workers not currently running (by exact name).
|
|
8315
|
+
* - `toStop` — live workers OWNED by this manifest (the `wf-<manifest>-`
|
|
8316
|
+
* name prefix) that are no longer desired (entry removed or
|
|
8317
|
+
* `instances` reduced).
|
|
8318
|
+
* - `toRestart` — desired workers present in supervisor status under the same
|
|
8319
|
+
* profile but NOT actually running (e.g. `state: "down"` while
|
|
8320
|
+
* crashed / mid-backoff). Restarting them lets `workforce
|
|
8321
|
+
* start` converge back to the desired *running* fleet instead
|
|
8322
|
+
* of counting a down worker as "unchanged". A live worker with
|
|
8323
|
+
* no `state` field (older status payloads) is assumed running.
|
|
8324
|
+
* - `unchanged` — desired workers already running under the same profile.
|
|
8325
|
+
* - `collisions`— a desired name is already taken by a worker running a
|
|
8326
|
+
* DIFFERENT profile (a hand-added worker that clashes): it is
|
|
8327
|
+
* neither started (don't clobber) nor stopped (not ours).
|
|
8328
|
+
* Workers NOT owned by this manifest (added by hand with `supervisor add`, or
|
|
8329
|
+
* owned by another manifest) are never in `toStop`. `desired` is
|
|
8330
|
+
* `[{ name, profile, ... }]`; `live` is `[{ id, profile }]`.
|
|
8331
|
+
*
|
|
8332
|
+
* `skippedProfiles` names manifest entries whose profile could not be resolved
|
|
8333
|
+
* this run (a local config error, e.g. a deleted hire). Such an entry produces
|
|
8334
|
+
* NO desired workers, so its already-running `wf-<manifest>-<profile>-*` workers
|
|
8335
|
+
* would otherwise be swept into `toStop` — turning a validation error into a
|
|
8336
|
+
* destructive teardown. We PROTECT those workers instead: they are left running
|
|
8337
|
+
* (reported under `protected`) so a config problem never tears down part of the
|
|
8338
|
+
* live fleet. Pure.
|
|
8339
|
+
*/
|
|
8340
|
+
function reconcileWorkforce({ desired, live, manifest, skippedProfiles, manifestNames }) {
|
|
8341
|
+
const liveList = Array.isArray(live) ? live : [];
|
|
8342
|
+
const liveById = new Map();
|
|
8343
|
+
for (const w of liveList) { if (w && typeof w.id === 'string') liveById.set(w.id, w); }
|
|
8344
|
+
const desiredList = Array.isArray(desired) ? desired : [];
|
|
8345
|
+
const desiredNames = new Set(desiredList.map((d) => d.name));
|
|
8346
|
+
const protectedProfiles = new Set(Array.isArray(skippedProfiles) ? skippedProfiles : []);
|
|
8347
|
+
// Compare the worker id's EMBEDDED profile exactly against skippedProfiles.
|
|
8348
|
+
// A prefix `startsWith` check would over-match when profile names contain '-'
|
|
8349
|
+
// (e.g. skipping "a" must not protect "a-b"'s `wf-<manifest>-a-b-*` workers).
|
|
8350
|
+
const isProtected = (id) => protectedProfiles.has(workforceProfileFromWorkerName(manifest, id));
|
|
8351
|
+
const toStart = [];
|
|
8352
|
+
const toRestart = [];
|
|
8353
|
+
const unchanged = [];
|
|
8354
|
+
const collisions = [];
|
|
8355
|
+
for (const d of desiredList) {
|
|
8356
|
+
const existing = liveById.get(d.name);
|
|
8357
|
+
if (!existing) { toStart.push(d); continue; }
|
|
8358
|
+
if (existing.profile != null && d.profile != null && existing.profile !== d.profile) {
|
|
8359
|
+
collisions.push({ name: d.name, wantProfile: d.profile, haveProfile: existing.profile });
|
|
8360
|
+
continue;
|
|
8361
|
+
}
|
|
8362
|
+
// A worker known to the supervisor but NOT running (state present and not
|
|
8363
|
+
// "running", e.g. "down" while crashed/mid-backoff) is restarted so `start`
|
|
8364
|
+
// converges to the desired *running* fleet. Missing state ⇒ assume running.
|
|
8365
|
+
if (existing.state != null && existing.state !== 'running') { toRestart.push(d); continue; }
|
|
8366
|
+
unchanged.push(d);
|
|
8367
|
+
}
|
|
8368
|
+
const toStop = [];
|
|
8369
|
+
const protectedWorkers = [];
|
|
8370
|
+
for (const w of liveList) {
|
|
8371
|
+
if (!w || typeof w.id !== 'string') continue;
|
|
8372
|
+
if (!isWorkforceOwnedWorker(w.id, manifest, manifestNames) || desiredNames.has(w.id)) continue;
|
|
8373
|
+
if (isProtected(w.id)) { protectedWorkers.push(w.id); continue; }
|
|
8374
|
+
toStop.push(w.id);
|
|
8375
|
+
}
|
|
8376
|
+
return { toStart, toRestart, toStop, unchanged, collisions, protected: protectedWorkers };
|
|
8377
|
+
}
|
|
8378
|
+
|
|
8379
|
+
/**
|
|
8380
|
+
* Validate + normalize one stored manifest entry, returning `{ entry }` or
|
|
8381
|
+
* `{ error }`. Enforces: a profile string, `instances` in `[1, MAX_ADD_INSTANCES]`,
|
|
8382
|
+
* and `roles` being either `"auto"` (with an optional `autoScope`) or a
|
|
8383
|
+
* non-empty array of valid role names. Pure — no config/hire I/O (the profile's
|
|
8384
|
+
* EXISTENCE is checked at add/start against `hires`, not here). Pure.
|
|
8385
|
+
*/
|
|
8386
|
+
function normalizeManifestEntry(entry) {
|
|
8387
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { error: 'entry is not an object' };
|
|
8388
|
+
const profile = String(entry.profile || '').trim();
|
|
8389
|
+
if (!profile) return { error: 'entry is missing a profile' };
|
|
8390
|
+
// A manifest is hand-editable, so enforce the same safe charset as a hired
|
|
8391
|
+
// profile name here: the profile rides into the deterministic
|
|
8392
|
+
// `wf-<manifest>-<profile>-<index>` worker id, and a torn value like "bad
|
|
8393
|
+
// name" would otherwise pass and fail later in a less obvious place.
|
|
8394
|
+
if (!isValidProfileName(profile)) {
|
|
8395
|
+
return { error: `entry "${profile}": invalid profile name (use letters, digits, dot, dash or underscore)` };
|
|
8396
|
+
}
|
|
8397
|
+
const { count, error } = parseInstancesCount(entry.instances, 'workforce add');
|
|
8398
|
+
if (error) return { error: `entry "${profile}": ${error}` };
|
|
8399
|
+
let roles;
|
|
8400
|
+
let autoScope;
|
|
8401
|
+
// Distinguish an ABSENT `roles` field (a legitimate "default to auto") from a
|
|
8402
|
+
// field that is PRESENT but malformed — including an explicit `null`, which is
|
|
8403
|
+
// a torn value, not "auto". Keying on presence (mirrors the strict `workers:
|
|
8404
|
+
// null` rejection above) surfaces the corruption instead of silently
|
|
8405
|
+
// broadening the worker to `--auto` serving.
|
|
8406
|
+
const rolesAbsent = !('roles' in entry) || entry.roles === undefined;
|
|
8407
|
+
if (entry.roles === 'auto' || rolesAbsent) {
|
|
8408
|
+
roles = 'auto';
|
|
8409
|
+
const scope = typeof entry.autoScope === 'string' ? entry.autoScope.trim() : '';
|
|
8410
|
+
if (scope) autoScope = scope;
|
|
8411
|
+
} else if (Array.isArray(entry.roles)) {
|
|
8412
|
+
const { roles: rs, errors } = parseRolesList(entry.roles);
|
|
8413
|
+
if (errors.length) return { error: `entry "${profile}": ${errors.join('; ')}` };
|
|
8414
|
+
if (rs.length === 0) return { error: `entry "${profile}": roles array is empty` };
|
|
8415
|
+
roles = rs;
|
|
8416
|
+
} else {
|
|
8417
|
+
return { error: `entry "${profile}": roles must be "auto" or an array of role names` };
|
|
8418
|
+
}
|
|
8419
|
+
// `args` is documented as an array of extra verbatim `work` flags. Like
|
|
8420
|
+
// roles/workers above, distinguish an ABSENT `args` (legitimately "no extra
|
|
8421
|
+
// flags") from one that is PRESENT but malformed. Left to normalizeArgList, a
|
|
8422
|
+
// torn/hand-edited value such as { "args": [true] } (or an empty string) would
|
|
8423
|
+
// be silently dropped/coerced, so the flag looks accepted when it was actually
|
|
8424
|
+
// discarded — contradicting the "malformed manifests are refused" contract and
|
|
8425
|
+
// making debugging hard. Reject non-string / empty values here, naming the
|
|
8426
|
+
// entry, rather than normalize them away.
|
|
8427
|
+
const argsAbsent = !('args' in entry) || entry.args === undefined;
|
|
8428
|
+
let args = [];
|
|
8429
|
+
if (!argsAbsent) {
|
|
8430
|
+
if (!Array.isArray(entry.args)) {
|
|
8431
|
+
return { error: `entry "${profile}": args must be an array of flag strings` };
|
|
8432
|
+
}
|
|
8433
|
+
for (const a of entry.args) {
|
|
8434
|
+
if (typeof a !== 'string') {
|
|
8435
|
+
return { error: `entry "${profile}": invalid arg ${JSON.stringify(a)} (expected a flag string)` };
|
|
8436
|
+
}
|
|
8437
|
+
if (a.length === 0) {
|
|
8438
|
+
return { error: `entry "${profile}": args contains an empty string (expected a flag string)` };
|
|
8439
|
+
}
|
|
8440
|
+
}
|
|
8441
|
+
args = normalizeArgList(entry.args);
|
|
8442
|
+
}
|
|
8443
|
+
const out = { profile, instances: count, roles };
|
|
8444
|
+
if (autoScope) out.autoScope = autoScope;
|
|
8445
|
+
if (args.length) out.args = args;
|
|
8446
|
+
return { entry: out };
|
|
8447
|
+
}
|
|
8448
|
+
|
|
8449
|
+
/**
|
|
8450
|
+
* Validate a parsed manifest object against the v1 schema, THROWING a clear
|
|
8451
|
+
* error naming the file path on any problem (mirroring `readConfigStrict()`'s
|
|
8452
|
+
* "absent vs unreadable" distinction — a torn/unknown manifest is never silently
|
|
8453
|
+
* treated as empty). Refuses an unknown `version` rather than best-effort
|
|
8454
|
+
* parsing. Returns the normalized manifest.
|
|
8455
|
+
*/
|
|
8456
|
+
function validateWorkforceManifest(parsed, file, expectedName) {
|
|
8457
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
8458
|
+
throw new Error(`workforce manifest ${file} is malformed (expected a JSON object).`);
|
|
8459
|
+
}
|
|
8460
|
+
if (parsed.version !== WORKFORCE_MANIFEST_VERSION) {
|
|
8461
|
+
throw new Error(`workforce manifest ${file} has unsupported version ${JSON.stringify(parsed.version)} (this build understands version ${WORKFORCE_MANIFEST_VERSION}).`);
|
|
8462
|
+
}
|
|
8463
|
+
// Distinguish an ABSENT `workers` field (a legitimately empty manifest) from
|
|
8464
|
+
// one that is PRESENT but not an array — including an explicit `null`, which
|
|
8465
|
+
// is a malformed value, not "empty". `!= null` used to let `null` slip
|
|
8466
|
+
// through as empty; keying on presence surfaces the corruption instead.
|
|
8467
|
+
if ('workers' in parsed && parsed.workers !== undefined && !Array.isArray(parsed.workers)) {
|
|
8468
|
+
throw new Error(`workforce manifest ${file} has a malformed "workers" field (expected an array, got ${parsed.workers === null ? 'null' : typeof parsed.workers}).`);
|
|
8469
|
+
}
|
|
8470
|
+
const workersRaw = Array.isArray(parsed.workers) ? parsed.workers : [];
|
|
8471
|
+
const workers = [];
|
|
8472
|
+
const seenProfiles = new Set();
|
|
8473
|
+
for (const raw of workersRaw) {
|
|
8474
|
+
const norm = normalizeManifestEntry(raw);
|
|
8475
|
+
if (norm.error) throw new Error(`workforce manifest ${file}: ${norm.error}.`);
|
|
8476
|
+
// Reject duplicate profiles: two entries for the same profile expand to the
|
|
8477
|
+
// same deterministic `wf-<manifest>-<profile>-<index>` worker names, so
|
|
8478
|
+
// `workforce start` would try `supervisor add` for the same worker id twice
|
|
8479
|
+
// and fail non-deterministically. Surface the corruption here (naming the
|
|
8480
|
+
// path) — use "instances" to run multiple copies of one profile.
|
|
8481
|
+
if (seenProfiles.has(norm.entry.profile)) {
|
|
8482
|
+
throw new Error(`workforce manifest ${file} has a duplicate profile ${JSON.stringify(norm.entry.profile)} — each profile may appear at most once (use "instances" to run more than one).`);
|
|
8483
|
+
}
|
|
8484
|
+
seenProfiles.add(norm.entry.profile);
|
|
8485
|
+
workers.push(norm.entry);
|
|
8486
|
+
}
|
|
8487
|
+
const onDiskName = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : '';
|
|
8488
|
+
// When loaded by name, the file's identity (its path) is authoritative: a
|
|
8489
|
+
// hand-edited `name` that disagrees would make writeWorkforceManifest() target
|
|
8490
|
+
// a *different* file, silently corrupting an unrelated manifest. Refuse it
|
|
8491
|
+
// rather than trust the field (mirrors the strict "surface corruption" ethos).
|
|
8492
|
+
const expected = String(expectedName || '').trim();
|
|
8493
|
+
if (expected && onDiskName && onDiskName !== expected) {
|
|
8494
|
+
throw new Error(`workforce manifest ${file} declares name ${JSON.stringify(onDiskName)} but was loaded as ${JSON.stringify(expected)} — the manifest name must match its filename.`);
|
|
8495
|
+
}
|
|
8496
|
+
const name = onDiskName || expected;
|
|
8497
|
+
return { version: WORKFORCE_MANIFEST_VERSION, name, workers };
|
|
8498
|
+
}
|
|
8499
|
+
|
|
8500
|
+
/**
|
|
8501
|
+
* Read + validate a manifest by name. Returns the normalized manifest, or
|
|
8502
|
+
* `null` when the file is absent. THROWS (naming the path) on an unreadable
|
|
8503
|
+
* file, torn/non-JSON content, or a schema/version violation — so callers never
|
|
8504
|
+
* mistake "unreadable" for "empty".
|
|
8505
|
+
*/
|
|
8506
|
+
function readWorkforceManifestStrict(name) {
|
|
8507
|
+
const file = getWorkforceManifestFile(name);
|
|
8508
|
+
if (!existsSync(file)) return null;
|
|
8509
|
+
let raw;
|
|
8510
|
+
try { raw = readFileSync(file, 'utf-8'); }
|
|
8511
|
+
catch (err) { throw new Error(`could not read workforce manifest ${file}: ${err.message}`); }
|
|
8512
|
+
let parsed;
|
|
8513
|
+
try { parsed = JSON.parse(raw); }
|
|
8514
|
+
catch (err) { throw new Error(`workforce manifest ${file} is not valid JSON: ${err.message}`); }
|
|
8515
|
+
return validateWorkforceManifest(parsed, file, name);
|
|
8516
|
+
}
|
|
8517
|
+
|
|
8518
|
+
/** Atomically persist a manifest to `<stateHome>/workforce/<name>.json`. */
|
|
8519
|
+
function writeWorkforceManifest(manifest) {
|
|
8520
|
+
mkdirSync(getWorkforceDir(), { recursive: true });
|
|
8521
|
+
const target = getWorkforceManifestFile(manifest.name);
|
|
8522
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
8523
|
+
// Owner-only (0600): manifests record forwarded `work` flags via `args`
|
|
8524
|
+
// (e.g. `--env NAME=VALUE`), so keep them out of world-readable view on
|
|
8525
|
+
// multi-user machines, consistent with supervisor.json.
|
|
8526
|
+
writeFileSync(tmp, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
8527
|
+
try { renameSync(tmp, target); }
|
|
8528
|
+
catch (err) { try { rmSync(tmp, { force: true }); } catch { /* best effort */ } throw err; }
|
|
8529
|
+
}
|
|
8530
|
+
|
|
8531
|
+
/** All manifest names that exist on disk (sorted). Best-effort IO. */
|
|
8532
|
+
function listWorkforceManifestNames() {
|
|
8533
|
+
let entries;
|
|
8534
|
+
try { entries = readdirSync(getWorkforceDir()); }
|
|
8535
|
+
catch { return []; }
|
|
8536
|
+
return entries
|
|
8537
|
+
.filter((f) => f.endsWith('.json'))
|
|
8538
|
+
.map((f) => f.slice(0, -'.json'.length))
|
|
8539
|
+
.filter((n) => n && isValidManifestName(n))
|
|
8540
|
+
.sort();
|
|
8541
|
+
}
|
|
8542
|
+
|
|
8543
|
+
/**
|
|
8544
|
+
* Append or update-in-place a manifest entry, matched by `profile` (idempotent,
|
|
8545
|
+
* matching `hire`'s update-in-place semantics). Returns a new manifest. Pure.
|
|
8546
|
+
*/
|
|
8547
|
+
function upsertManifestEntry(manifest, entry) {
|
|
8548
|
+
const workers = Array.isArray(manifest.workers) ? manifest.workers.slice() : [];
|
|
8549
|
+
const idx = workers.findIndex((w) => w && w.profile === entry.profile);
|
|
8550
|
+
if (idx >= 0) workers[idx] = entry; else workers.push(entry);
|
|
8551
|
+
return { ...manifest, workers };
|
|
8552
|
+
}
|
|
8553
|
+
|
|
8554
|
+
/**
|
|
8555
|
+
* Drop a manifest entry by profile, or clear ALL entries when `profile` is
|
|
8556
|
+
* `"all"`. Returns `{ manifest, removed }` where `removed` lists the dropped
|
|
8557
|
+
* profiles. Pure.
|
|
8558
|
+
*/
|
|
8559
|
+
function removeManifestEntry(manifest, profile) {
|
|
8560
|
+
const workers = Array.isArray(manifest.workers) ? manifest.workers : [];
|
|
8561
|
+
if (profile === 'all') {
|
|
8562
|
+
return { manifest: { ...manifest, workers: [] }, removed: workers.map((w) => w && w.profile).filter(Boolean) };
|
|
8563
|
+
}
|
|
8564
|
+
const kept = [];
|
|
8565
|
+
let removed = null;
|
|
8566
|
+
for (const w of workers) {
|
|
8567
|
+
if (w && w.profile === profile) removed = w.profile;
|
|
8568
|
+
else kept.push(w);
|
|
8569
|
+
}
|
|
8570
|
+
return { manifest: { ...manifest, workers: kept }, removed: removed ? [removed] : [] };
|
|
8571
|
+
}
|
|
8572
|
+
|
|
8573
|
+
/** Human-readable one-line summary of an entry's roles. Pure. */
|
|
8574
|
+
function describeEntryRoles(entry) {
|
|
8575
|
+
if (Array.isArray(entry?.roles)) return entry.roles.join(', ');
|
|
8576
|
+
const scope = typeof entry?.autoScope === 'string' && entry.autoScope ? ` (scope ${entry.autoScope})` : '';
|
|
8577
|
+
return `auto${scope}`;
|
|
8578
|
+
}
|
|
8579
|
+
|
|
8580
|
+
/** Render a manifest as an aligned text table for `workforce list`. Pure. */
|
|
8581
|
+
function formatWorkforceManifest(manifest) {
|
|
8582
|
+
const lines = [];
|
|
8583
|
+
lines.push(`Workforce "${manifest.name}" (v${manifest.version ?? WORKFORCE_MANIFEST_VERSION}):`);
|
|
8584
|
+
const entries = Array.isArray(manifest.workers) ? manifest.workers : [];
|
|
8585
|
+
if (entries.length === 0) {
|
|
8586
|
+
lines.push(' (empty — add workers with: c8ctl nano workforce add <profile> --instances N [--auto|--roles a,b])');
|
|
8587
|
+
return lines.join('\n');
|
|
8588
|
+
}
|
|
8589
|
+
const rows = entries.map((e) => ({
|
|
8590
|
+
profile: String(e.profile),
|
|
8591
|
+
instances: String(e.instances ?? 1),
|
|
8592
|
+
roles: describeEntryRoles(e),
|
|
8593
|
+
args: Array.isArray(e.args) && e.args.length ? e.args.join(' ') : '-',
|
|
8594
|
+
}));
|
|
8595
|
+
const head = { profile: 'PROFILE', instances: 'INSTANCES', roles: 'ROLES', args: 'ARGS' };
|
|
8596
|
+
const cols = ['profile', 'instances', 'roles', 'args'];
|
|
8597
|
+
const width = {};
|
|
8598
|
+
for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
|
|
8599
|
+
const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
|
|
8600
|
+
lines.push(fmt(head));
|
|
8601
|
+
for (const r of rows) lines.push(fmt(r));
|
|
8602
|
+
return lines.join('\n');
|
|
8603
|
+
}
|
|
8604
|
+
|
|
8605
|
+
/**
|
|
8606
|
+
* Build the `workforce status` report — the manifest's entries joined against
|
|
8607
|
+
* the live supervisor worker set — as a plain data object (also the `--json`
|
|
8608
|
+
* shape). For each entry it reports desired vs actual instance counts and the
|
|
8609
|
+
* per-instance worker (present/state/pid/uptime/restarts); plus `extra` workers
|
|
8610
|
+
* owned by this manifest (the `wf-<name>-` prefix) that no entry desires. Pure
|
|
8611
|
+
* over its inputs. `live` is an array of summarized supervisor workers.
|
|
8612
|
+
*/
|
|
8613
|
+
function buildWorkforceStatus(manifest, name, live, supervisorRunning, manifestNames) {
|
|
8614
|
+
const liveList = Array.isArray(live) ? live : [];
|
|
8615
|
+
const liveById = new Map();
|
|
8616
|
+
for (const w of liveList) { if (w && typeof w.id === 'string') liveById.set(w.id, w); }
|
|
8617
|
+
const entriesRaw = Array.isArray(manifest?.workers) ? manifest.workers : [];
|
|
8618
|
+
const desiredNames = new Set();
|
|
8619
|
+
const entries = entriesRaw.map((e) => {
|
|
8620
|
+
const workers = [];
|
|
8621
|
+
let running = 0;
|
|
8622
|
+
for (let i = 1; i <= (Number(e.instances) || 0); i++) {
|
|
8623
|
+
const wname = workforceWorkerName(name, e.profile, i);
|
|
8624
|
+
desiredNames.add(wname);
|
|
8625
|
+
const w = liveById.get(wname) || null;
|
|
8626
|
+
// Mirror reconcile's collision rule: a live worker occupying the desired
|
|
8627
|
+
// NAME but running a DIFFERENT profile does NOT satisfy this instance
|
|
8628
|
+
// (workforce start skips such a name collision rather than clobbering it).
|
|
8629
|
+
// Treat it as a collision — the desired instance is effectively absent —
|
|
8630
|
+
// and surface the intruding profile so status doesn't mask the clash by
|
|
8631
|
+
// reporting the instance as "running".
|
|
8632
|
+
const collision = w && w.profile != null && e.profile != null && String(w.profile) !== String(e.profile)
|
|
8633
|
+
? String(w.profile)
|
|
8634
|
+
: null;
|
|
8635
|
+
const eff = collision ? null : w;
|
|
8636
|
+
// Mirror reconcile: a live worker with no `state` (older supervisor
|
|
8637
|
+
// status payloads) is assumed running, not "undefined".
|
|
8638
|
+
const state = eff ? (eff.state != null ? String(eff.state) : 'running') : 'absent';
|
|
8639
|
+
if (state === 'running') running++;
|
|
8640
|
+
workers.push({
|
|
8641
|
+
name: wname,
|
|
8642
|
+
present: Boolean(eff),
|
|
8643
|
+
state,
|
|
8644
|
+
pid: eff && eff.pid != null ? eff.pid : null,
|
|
8645
|
+
uptimeMs: eff && Number.isFinite(eff.uptimeMs) ? eff.uptimeMs : null,
|
|
8646
|
+
restarts: eff ? Number(eff.restarts) || 0 : 0,
|
|
8647
|
+
collision,
|
|
8648
|
+
});
|
|
8649
|
+
}
|
|
8650
|
+
return {
|
|
8651
|
+
profile: e.profile,
|
|
8652
|
+
roles: e.roles,
|
|
8653
|
+
autoScope: e.autoScope ?? null,
|
|
8654
|
+
desired: Number(e.instances) || 0,
|
|
8655
|
+
running,
|
|
8656
|
+
workers,
|
|
8657
|
+
};
|
|
8658
|
+
});
|
|
8659
|
+
const extra = liveList
|
|
8660
|
+
.filter((w) => w && typeof w.id === 'string' && isWorkforceOwnedWorker(w.id, name, manifestNames) && !desiredNames.has(w.id))
|
|
8661
|
+
.map((w) => ({ name: w.id, profile: w.profile ?? null, state: w.state != null ? String(w.state) : 'running', pid: w.pid ?? null }));
|
|
8662
|
+
return {
|
|
8663
|
+
name,
|
|
8664
|
+
exists: Boolean(manifest),
|
|
8665
|
+
supervisorRunning: Boolean(supervisorRunning),
|
|
8666
|
+
entries,
|
|
8667
|
+
extra,
|
|
8668
|
+
};
|
|
8669
|
+
}
|
|
8670
|
+
|
|
8671
|
+
/** Render a `buildWorkforceStatus` report as an aligned text table. Pure. */
|
|
8672
|
+
function formatWorkforceStatus(report) {
|
|
8673
|
+
const lines = [];
|
|
8674
|
+
lines.push(`Workforce "${report.name}":`);
|
|
8675
|
+
if (!report.exists) {
|
|
8676
|
+
lines.push(' (no manifest — create one with: c8ctl nano workforce add <profile> ...)');
|
|
8677
|
+
return lines.join('\n');
|
|
8678
|
+
}
|
|
8679
|
+
lines.push(` supervisor: ${report.supervisorRunning ? 'running' : 'not running'}`);
|
|
8680
|
+
const entries = Array.isArray(report.entries) ? report.entries : [];
|
|
8681
|
+
if (entries.length === 0) {
|
|
8682
|
+
lines.push(' (empty manifest)');
|
|
8683
|
+
return lines.join('\n');
|
|
8684
|
+
}
|
|
8685
|
+
const rows = [];
|
|
8686
|
+
for (const e of entries) {
|
|
8687
|
+
for (const w of e.workers) {
|
|
8688
|
+
rows.push({
|
|
8689
|
+
worker: w.name,
|
|
8690
|
+
profile: String(e.profile),
|
|
8691
|
+
desired: `${e.running}/${e.desired}`,
|
|
8692
|
+
state: w.collision ? `collision(${w.collision})` : w.state,
|
|
8693
|
+
pid: w.pid ? String(w.pid) : '-',
|
|
8694
|
+
restarts: String(w.restarts),
|
|
8695
|
+
uptime: w.state === 'running' && w.uptimeMs != null ? formatDuration(w.uptimeMs) : '-',
|
|
8696
|
+
});
|
|
8697
|
+
}
|
|
8698
|
+
}
|
|
8699
|
+
if (rows.length > 0) {
|
|
8700
|
+
const head = { worker: 'WORKER', profile: 'PROFILE', desired: 'RUN/WANT', state: 'STATE', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME' };
|
|
8701
|
+
const cols = ['worker', 'profile', 'desired', 'state', 'pid', 'restarts', 'uptime'];
|
|
8702
|
+
const width = {};
|
|
8703
|
+
for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
|
|
8704
|
+
const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
|
|
8705
|
+
lines.push('');
|
|
8706
|
+
lines.push(fmt(head));
|
|
8707
|
+
for (const r of rows) lines.push(fmt(r));
|
|
8708
|
+
}
|
|
8709
|
+
const missing = entries.filter((e) => e.running < e.desired);
|
|
8710
|
+
if (missing.length > 0) {
|
|
8711
|
+
lines.push('');
|
|
8712
|
+
lines.push(` Missing: ${missing.map((e) => `${e.profile} (${e.running}/${e.desired})`).join(', ')} — run: c8ctl nano workforce start${report.name === DEFAULT_WORKFORCE_MANIFEST ? '' : ` --profile ${report.name}`}`);
|
|
8713
|
+
}
|
|
8714
|
+
if (Array.isArray(report.extra) && report.extra.length > 0) {
|
|
8715
|
+
lines.push('');
|
|
8716
|
+
lines.push(` Extra (owned by this workforce, not desired): ${report.extra.map((w) => w.name).join(', ')} — will be stopped on next start.`);
|
|
8717
|
+
}
|
|
8718
|
+
return lines.join('\n');
|
|
8719
|
+
}
|
|
8720
|
+
|
|
8721
|
+
/** Resolve the manifest name from `--profile` (default `default`). Pure. */
|
|
8722
|
+
function lastProfileValue(flags) {
|
|
8723
|
+
// A repeated `--profile` flag arrives as a string[]; honor the last value.
|
|
8724
|
+
let profile = flags?.profile;
|
|
8725
|
+
if (Array.isArray(profile)) profile = profile.length ? profile[profile.length - 1] : '';
|
|
8726
|
+
return typeof profile === 'string' ? profile.trim() : '';
|
|
8727
|
+
}
|
|
8728
|
+
|
|
8729
|
+
function workforceManifestName(flags) {
|
|
8730
|
+
return lastProfileValue(flags) || DEFAULT_WORKFORCE_MANIFEST;
|
|
8731
|
+
}
|
|
8732
|
+
|
|
8733
|
+
/** Fetch the live supervisor worker set, or `[]` when no daemon is running. */
|
|
8734
|
+
async function fetchSupervisorWorkers() {
|
|
8735
|
+
const running = await liveSupervisor();
|
|
8736
|
+
if (!running) return { running: false, reachable: false, workers: [] };
|
|
8737
|
+
try {
|
|
8738
|
+
const res = await supervisorRequest({ op: 'status' });
|
|
8739
|
+
if (res && res.ok) return { running: true, reachable: true, workers: Array.isArray(res.workers) ? res.workers : [] };
|
|
8740
|
+
} catch { /* socket unreachable */ }
|
|
8741
|
+
return { running: true, reachable: false, workers: [] };
|
|
8742
|
+
}
|
|
8743
|
+
|
|
8744
|
+
async function workforceAddCmd(req, flags, manifestName) {
|
|
8745
|
+
const logger = getLogger();
|
|
8746
|
+
const profile = req.positional[1];
|
|
8747
|
+
if (!profile) {
|
|
8748
|
+
logger.error('Usage: c8ctl nano workforce add <profile> [--instances <n>] [--auto [--auto-scope <s>] | --roles a,b,c] [--arg <flag> ...] [--profile <manifest>]');
|
|
8749
|
+
process.exit(1);
|
|
8750
|
+
}
|
|
8751
|
+
if (!isValidProfileName(profile)) {
|
|
8752
|
+
logger.error(`Invalid profile name "${profile}". Use letters, digits, dot, dash or underscore.`);
|
|
8753
|
+
process.exit(1);
|
|
8754
|
+
}
|
|
8755
|
+
// The profile must be a hired profile — validated here at `add` and again at
|
|
8756
|
+
// `start`. readHiresStrict throws on a torn config (surfaced by the handler).
|
|
8757
|
+
const hires = readHiresStrict();
|
|
8758
|
+
if (!hires[profile]) {
|
|
8759
|
+
logger.error(`No hired profile "${profile}". Create one first with: c8ctl nano hire --name ${profile} --rank <r> --command <cmd>`);
|
|
8760
|
+
process.exit(1);
|
|
8761
|
+
}
|
|
8762
|
+
const { count, error } = parseInstancesCount(flags?.instances, 'workforce add');
|
|
8763
|
+
if (error) { logger.error(error); process.exit(1); }
|
|
8764
|
+
const auto = coerceBool(flags?.auto, false);
|
|
8765
|
+
const rolesFlag = flags?.roles;
|
|
8766
|
+
// A bare `--roles` (no value) is parsed as boolean `true` by the flag layer;
|
|
8767
|
+
// reject it rather than let `String(true)` create a phantom role named "true"
|
|
8768
|
+
// (mirrors how `--instances` rejects a non-numeric value).
|
|
8769
|
+
if (rolesFlag === true) {
|
|
8770
|
+
logger.error('--roles requires a comma-separated list of role names (e.g. --roles pr-review,fix).');
|
|
8771
|
+
process.exit(1);
|
|
8772
|
+
}
|
|
8773
|
+
// An explicitly provided but empty `--roles` (e.g. `--roles ""`, or an empty
|
|
8774
|
+
// array from the flag layer) is a user error, not an implicit "auto": reject
|
|
8775
|
+
// it rather than silently defaulting the entry to `roles: "auto"`.
|
|
8776
|
+
if (rolesFlag != null && String(rolesFlag).trim() === '') {
|
|
8777
|
+
logger.error('--roles requires a comma-separated list of role names (e.g. --roles pr-review,fix).');
|
|
8778
|
+
process.exit(1);
|
|
8779
|
+
}
|
|
8780
|
+
const hasRoles = rolesFlag != null;
|
|
8781
|
+
if (flags?.['auto-scope'] === true) {
|
|
8782
|
+
logger.error('--auto-scope requires a value.');
|
|
8783
|
+
process.exit(1);
|
|
8784
|
+
}
|
|
8785
|
+
const autoScope = flags?.['auto-scope'] != null ? String(flags['auto-scope']).trim() : '';
|
|
8786
|
+
if (auto && hasRoles) {
|
|
8787
|
+
logger.error('--auto and --roles are mutually exclusive: an entry is either "auto" (serve all deployed job types) or an explicit role list.');
|
|
8788
|
+
process.exit(1);
|
|
8789
|
+
}
|
|
8790
|
+
if (autoScope && !auto) {
|
|
8791
|
+
logger.error('--auto-scope requires --auto.');
|
|
8792
|
+
process.exit(1);
|
|
8793
|
+
}
|
|
8794
|
+
let entry;
|
|
8795
|
+
if (hasRoles) {
|
|
8796
|
+
const { roles, errors } = parseRolesList(rolesFlag);
|
|
8797
|
+
if (errors.length) { logger.error(errors.join('; ')); process.exit(1); }
|
|
8798
|
+
if (roles.length === 0) { logger.error('--roles must name at least one role.'); process.exit(1); }
|
|
8799
|
+
entry = { profile, instances: count, roles };
|
|
8800
|
+
} else {
|
|
8801
|
+
// Neither --auto nor --roles → default to "auto" (serve every deployed agent
|
|
8802
|
+
// job type), the onboarding/install-script happy path.
|
|
8803
|
+
entry = { profile, instances: count, roles: 'auto' };
|
|
8804
|
+
if (autoScope) entry.autoScope = autoScope;
|
|
8805
|
+
}
|
|
8806
|
+
// A value-less `--arg` (a bare flag with no following value) arrives as
|
|
8807
|
+
// boolean `true` from the flag layer; normalizeArgList would silently drop it,
|
|
8808
|
+
// making a malformed invocation look like it accepted the intended arg. Reject
|
|
8809
|
+
// it up front (mirrors the `--roles`/`--auto-scope` value-less validation).
|
|
8810
|
+
if (hasValuelessArg(flags?.arg)) {
|
|
8811
|
+
logger.error('--arg requires a value (e.g. --arg "--allow-all-tools").');
|
|
8812
|
+
process.exit(1);
|
|
8813
|
+
}
|
|
8814
|
+
const extraArgs = normalizeArgList(flags?.arg);
|
|
8815
|
+
if (extraArgs.length) entry.args = extraArgs;
|
|
8816
|
+
|
|
8817
|
+
let manifest = readWorkforceManifestStrict(manifestName) || emptyWorkforceManifest(manifestName);
|
|
8818
|
+
const existed = (manifest.workers || []).some((w) => w && w.profile === profile);
|
|
8819
|
+
manifest = upsertManifestEntry(manifest, entry);
|
|
8820
|
+
writeWorkforceManifest(manifest);
|
|
8821
|
+
logger.info(`${existed ? 'Updated' : 'Added'} "${profile}" in workforce "${manifestName}": instances ${count}, roles ${describeEntryRoles(entry)}${extraArgs.length ? `, args ${redactWorkArgs(extraArgs).join(' ')}` : ''}.`);
|
|
8822
|
+
logger.info(`Bring it up with: c8ctl nano workforce start${manifestName === DEFAULT_WORKFORCE_MANIFEST ? '' : ` --profile ${manifestName}`}`);
|
|
8823
|
+
}
|
|
8824
|
+
|
|
8825
|
+
async function workforceRemoveCmd(req, flags, manifestName) {
|
|
8826
|
+
const logger = getLogger();
|
|
8827
|
+
const profile = req.positional[1];
|
|
8828
|
+
if (!profile) {
|
|
8829
|
+
logger.error('Usage: c8ctl nano workforce remove <profile|all> [--profile <manifest>]');
|
|
8830
|
+
process.exit(1);
|
|
8831
|
+
}
|
|
8832
|
+
const manifest = readWorkforceManifestStrict(manifestName);
|
|
8833
|
+
if (!manifest) { logger.warn(`Workforce "${manifestName}" does not exist — nothing to remove.`); return; }
|
|
8834
|
+
const { manifest: next, removed } = removeManifestEntry(manifest, profile);
|
|
8835
|
+
if (removed.length === 0) { logger.warn(`No entry for "${profile}" in workforce "${manifestName}".`); return; }
|
|
8836
|
+
writeWorkforceManifest(next);
|
|
8837
|
+
if (profile === 'all') logger.info(`Cleared workforce "${manifestName}" (${removed.length} entr${removed.length === 1 ? 'y' : 'ies'} removed).`);
|
|
8838
|
+
else logger.info(`Removed "${profile}" from workforce "${manifestName}".`);
|
|
8839
|
+
}
|
|
8840
|
+
|
|
8841
|
+
async function workforceListCmd(req, flags, manifestName) {
|
|
8842
|
+
const logger = getLogger();
|
|
8843
|
+
const json = coerceBool(flags?.json, false);
|
|
8844
|
+
const explicitProfile = lastProfileValue(flags) !== '';
|
|
8845
|
+
const manifest = readWorkforceManifestStrict(manifestName);
|
|
8846
|
+
const others = explicitProfile ? null : listWorkforceManifestNames();
|
|
8847
|
+
if (json) {
|
|
8848
|
+
const payload = { manifest: manifest || null };
|
|
8849
|
+
if (others) payload.manifests = others;
|
|
8850
|
+
logger.output(JSON.stringify(payload, null, 2));
|
|
8851
|
+
return;
|
|
8852
|
+
}
|
|
8853
|
+
if (!manifest) {
|
|
8854
|
+
logger.info(`Workforce "${manifestName}" does not exist. Create it with: c8ctl nano workforce add <profile> --instances N [--auto|--roles a,b]`);
|
|
8855
|
+
} else {
|
|
8856
|
+
logger.output(formatWorkforceManifest(manifest));
|
|
8857
|
+
}
|
|
8858
|
+
if (others && others.length > 0) {
|
|
8859
|
+
logger.info('');
|
|
8860
|
+
logger.info(`Manifests on this machine: ${others.join(', ')}`);
|
|
8861
|
+
}
|
|
8862
|
+
}
|
|
8863
|
+
|
|
8864
|
+
async function workforceStartCmd(req, flags, manifestName) {
|
|
8865
|
+
const logger = getLogger();
|
|
8866
|
+
const manifest = readWorkforceManifestStrict(manifestName);
|
|
8867
|
+
if (!manifest || !Array.isArray(manifest.workers) || manifest.workers.length === 0) {
|
|
8868
|
+
logger.info(`Workforce "${manifestName}" is empty — nothing to start. Add workers with: c8ctl nano workforce add <profile> --instances N [--auto|--roles a,b]`);
|
|
8869
|
+
return; // friendly, exit 0
|
|
8870
|
+
}
|
|
8871
|
+
// Resolve each entry's profile → rank and its work args. A profile deleted
|
|
8872
|
+
// since it was added yields a clear error, is skipped, and forces a non-zero
|
|
8873
|
+
// exit at the end — a partial start never leaves a half-reconciled fleet
|
|
8874
|
+
// silently.
|
|
8875
|
+
const hires = readHiresStrict();
|
|
8876
|
+
const desired = [];
|
|
8877
|
+
const skippedProfiles = [];
|
|
8878
|
+
let hadError = false;
|
|
8879
|
+
for (const entry of manifest.workers) {
|
|
8880
|
+
const stored = hires[entry.profile];
|
|
8881
|
+
if (!stored) {
|
|
8882
|
+
logger.error(`Skipping "${entry.profile}": no such hired profile (create it with c8ctl nano hire --name ${entry.profile} ...).`);
|
|
8883
|
+
skippedProfiles.push(entry.profile);
|
|
8884
|
+
hadError = true;
|
|
8885
|
+
continue;
|
|
8886
|
+
}
|
|
8887
|
+
const norm = normalizeStoredProfile(entry.profile, stored);
|
|
8888
|
+
if (norm.error) {
|
|
8889
|
+
logger.error(`Skipping "${entry.profile}": ${norm.error}.`);
|
|
8890
|
+
skippedProfiles.push(entry.profile);
|
|
8891
|
+
hadError = true;
|
|
8892
|
+
continue;
|
|
8893
|
+
}
|
|
8894
|
+
const args = manifestEntryToWorkArgs(entry, norm.profile.rank);
|
|
8895
|
+
for (let i = 1; i <= entry.instances; i++) {
|
|
8896
|
+
desired.push({ name: workforceWorkerName(manifestName, entry.profile, i), profile: entry.profile, args });
|
|
8897
|
+
}
|
|
8898
|
+
}
|
|
8899
|
+
|
|
8900
|
+
// If every manifest entry was skipped (missing/invalid profiles), `desired` is
|
|
8901
|
+
// empty and there is nothing to reconcile. Starting the supervisor daemon here
|
|
8902
|
+
// would spin up (and leave behind) an empty daemon purely as a side effect of
|
|
8903
|
+
// an all-error run. Report the failure, render current status without starting
|
|
8904
|
+
// anything, and exit non-zero.
|
|
8905
|
+
if (desired.length === 0) {
|
|
8906
|
+
logger.error(`Workforce "${manifestName}": every entry was skipped (${skippedProfiles.join(', ')}) — nothing to start. Not starting a supervisor daemon.`);
|
|
8907
|
+
await workforceStatusCmd(req, { ...flags, json: false }, manifestName);
|
|
8908
|
+
process.exit(1);
|
|
8909
|
+
}
|
|
8910
|
+
|
|
8911
|
+
const state = await startSupervisorDaemon();
|
|
8912
|
+
logger.info(`Supervisor daemon running (pid ${state.pid}).`);
|
|
8913
|
+
const { reachable, workers: live } = await fetchSupervisorWorkers();
|
|
8914
|
+
// A running daemon with an unreachable status socket reports `live: []`, which
|
|
8915
|
+
// would make reconcile think the fleet is empty — duplicating workers it can't
|
|
8916
|
+
// see or "stopping" surplus it can't enumerate. Refuse to reconcile blindly
|
|
8917
|
+
// and exit non-zero (mirrors `workforce stop`).
|
|
8918
|
+
if (!reachable) {
|
|
8919
|
+
logger.error('Supervisor daemon is running but its status socket is unreachable — cannot enumerate live workers to reconcile against. Refusing to reconcile blindly; try again once the socket responds.');
|
|
8920
|
+
process.exit(1);
|
|
8921
|
+
}
|
|
8922
|
+
const { toStart, toRestart, toStop, unchanged, collisions, protected: protectedWorkers } = reconcileWorkforce({ desired, live, manifest: manifestName, skippedProfiles, manifestNames: listWorkforceManifestNames() });
|
|
8923
|
+
|
|
8924
|
+
for (const c of collisions) {
|
|
8925
|
+
logger.warn(`Skipping "${c.name}": a worker with that name already runs profile "${c.haveProfile}" (manifest wants "${c.wantProfile}") — not clobbering a hand-added worker.`);
|
|
8926
|
+
hadError = true;
|
|
8927
|
+
}
|
|
8928
|
+
for (const id of protectedWorkers) {
|
|
8929
|
+
logger.warn(` = kept "${id}" running (its profile could not be resolved this run — not tearing it down over a config error).`);
|
|
8930
|
+
}
|
|
8931
|
+
for (const id of toStop) {
|
|
8932
|
+
const res = await supervisorRequest({ op: 'remove', target: id });
|
|
8933
|
+
if (res && res.ok) logger.info(` - stopped "${id}" (no longer desired)`);
|
|
8934
|
+
else { logger.error(` ! could not stop "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
8935
|
+
}
|
|
8936
|
+
for (const d of toStart) {
|
|
8937
|
+
const res = await supervisorRequest({ op: 'add', profile: d.profile, name: d.name, args: d.args });
|
|
8938
|
+
if (res && res.ok) logger.info(` + started "${d.name}" (profile ${d.profile})`);
|
|
8939
|
+
else { logger.error(` ! could not start "${d.name}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
8940
|
+
}
|
|
8941
|
+
for (const d of toRestart) {
|
|
8942
|
+
const res = await supervisorRequest({ op: 'restart', target: d.name });
|
|
8943
|
+
if (res && res.ok) logger.info(` ↻ restarted "${d.name}" (was not running)`);
|
|
8944
|
+
else { logger.error(` ! could not restart "${d.name}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
8945
|
+
}
|
|
8946
|
+
logger.info(`Workforce "${manifestName}" reconciled: ${toStart.length} started, ${toRestart.length} restarted, ${toStop.length} stopped, ${unchanged.length} unchanged${protectedWorkers.length ? `, ${protectedWorkers.length} kept (profile unresolved)` : ''}.`);
|
|
8947
|
+
|
|
8948
|
+
// `--json` is documented for `workforce list/status` only; forcing it off here
|
|
8949
|
+
// keeps `workforce start --json` from appending a stray JSON blob to start's
|
|
8950
|
+
// human-readable log (which would be neither pure text nor machine-readable).
|
|
8951
|
+
await workforceStatusCmd(req, { ...flags, json: false }, manifestName);
|
|
8952
|
+
if (hadError) process.exit(1);
|
|
8953
|
+
}
|
|
8954
|
+
|
|
8955
|
+
async function workforceStatusCmd(req, flags, manifestName) {
|
|
8956
|
+
const logger = getLogger();
|
|
8957
|
+
const json = coerceBool(flags?.json, false);
|
|
8958
|
+
const manifest = readWorkforceManifestStrict(manifestName);
|
|
8959
|
+
const { running, reachable, workers: live } = await fetchSupervisorWorkers();
|
|
8960
|
+
// When the daemon is up but its status socket can't be reached, `live` is
|
|
8961
|
+
// empty and the report would falsely show every worker as absent — misleading
|
|
8962
|
+
// a human and any automation consuming `--json`. Fail non-zero instead of
|
|
8963
|
+
// rendering a phantom "everything down" status (mirrors `workforce stop`).
|
|
8964
|
+
if (running && !reachable) {
|
|
8965
|
+
logger.error('Supervisor is running but its status socket is unreachable — cannot report worker status. Try again once the socket responds.');
|
|
8966
|
+
process.exit(1);
|
|
8967
|
+
}
|
|
8968
|
+
const report = buildWorkforceStatus(manifest, manifestName, live, running, listWorkforceManifestNames());
|
|
8969
|
+
if (json) { logger.output(JSON.stringify(report, null, 2)); return; }
|
|
8970
|
+
logger.output(formatWorkforceStatus(report));
|
|
8971
|
+
}
|
|
8972
|
+
|
|
8973
|
+
async function workforceStopCmd(req, flags, manifestName) {
|
|
8974
|
+
const logger = getLogger();
|
|
8975
|
+
const running = await liveSupervisor();
|
|
8976
|
+
if (!running) { logger.warn('Supervisor is not running — nothing to stop.'); return; }
|
|
8977
|
+
const manifestNames = listWorkforceManifestNames();
|
|
8978
|
+
const { running: stillRunning, reachable, workers: live } = await fetchSupervisorWorkers();
|
|
8979
|
+
// The daemon can exit between the liveSupervisor() check above and this call;
|
|
8980
|
+
// fetchSupervisorWorkers() then reports running:false (not merely
|
|
8981
|
+
// unreachable). Treat that as "nothing to stop" rather than erroring out with
|
|
8982
|
+
// a misleading "socket unreachable" message.
|
|
8983
|
+
if (!stillRunning) { logger.warn('Supervisor is not running — nothing to stop.'); return; }
|
|
8984
|
+
if (!reachable) {
|
|
8985
|
+
logger.error('Supervisor is running but its status socket is unreachable — cannot enumerate workers. Leaving the daemon and its workers untouched.');
|
|
8986
|
+
process.exit(1);
|
|
8987
|
+
}
|
|
8988
|
+
const owned = live
|
|
8989
|
+
// Ownership is decided by longest-prefix match against the manifests that
|
|
8990
|
+
// exist on this machine, so manifest `a` never claims manifest `a-b`'s
|
|
8991
|
+
// `wf-a-b-...` workers even though its `wf-a-` prefix technically matches.
|
|
8992
|
+
.filter((w) => w && typeof w.id === 'string' && isWorkforceOwnedWorker(w.id, manifestName, manifestNames))
|
|
8993
|
+
// A worker id that carries our prefix but whose LIVE profile disagrees with
|
|
8994
|
+
// the profile embedded in its deterministic name is a hand-added worker that
|
|
8995
|
+
// merely collides on the name (the same case `workforce start` skips): it is
|
|
8996
|
+
// NOT ours, so never remove it. Skip only on a positive disagreement — an
|
|
8997
|
+
// id we can't parse a profile from, or a live worker with no reported
|
|
8998
|
+
// profile, stays owned (unchanged from prior behaviour).
|
|
8999
|
+
.filter((w) => {
|
|
9000
|
+
const embedded = workforceProfileFromWorkerName(manifestName, w.id);
|
|
9001
|
+
if (embedded != null && w.profile != null && w.profile !== embedded) {
|
|
9002
|
+
logger.info(`Skipping "${w.id}" — it runs profile "${w.profile}", not the "${embedded}" this manifest owns (name collision); not removing.`);
|
|
9003
|
+
return false;
|
|
9004
|
+
}
|
|
9005
|
+
return true;
|
|
9006
|
+
})
|
|
9007
|
+
.map((w) => w.id);
|
|
9008
|
+
let hadError = false;
|
|
9009
|
+
if (owned.length === 0) {
|
|
9010
|
+
logger.info(`No workers from workforce "${manifestName}" are running.`);
|
|
9011
|
+
} else {
|
|
9012
|
+
for (const id of owned) {
|
|
9013
|
+
const res = await supervisorRequest({ op: 'remove', target: id });
|
|
9014
|
+
if (res && res.ok) logger.info(`Removed worker "${id}".`);
|
|
9015
|
+
else { logger.error(`Could not remove "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
9016
|
+
}
|
|
9017
|
+
}
|
|
9018
|
+
// If no supervised workers remain, stop the daemon too — but only when the
|
|
9019
|
+
// status socket actually answered. A `{ workers: [] }` from an *unreachable*
|
|
9020
|
+
// socket is ambiguous, and treating it as "empty" would wrongly kill the
|
|
9021
|
+
// daemon (and any foreign workers it still supervises).
|
|
9022
|
+
const { running: daemonStillRunning, reachable: stillReachable, workers: remaining } = await fetchSupervisorWorkers();
|
|
9023
|
+
if (!daemonStillRunning) {
|
|
9024
|
+
// The daemon exited/crashed between the removals and this re-check (its pid
|
|
9025
|
+
// is no longer live), so there is nothing left to stop and reporting an
|
|
9026
|
+
// unreachable *socket* would misdescribe a dead daemon as merely unanswered.
|
|
9027
|
+
logger.warn('Supervisor daemon is no longer running.');
|
|
9028
|
+
} else if (!stillReachable) {
|
|
9029
|
+
logger.warn('Supervisor status socket became unreachable; leaving the daemon running.');
|
|
9030
|
+
} else if (remaining.length === 0) {
|
|
9031
|
+
logger.info('No supervised workers remain — stopping the supervisor daemon.');
|
|
9032
|
+
await supervisorStopCmd();
|
|
9033
|
+
} else {
|
|
9034
|
+
logger.info(`${remaining.length} other supervised worker(s) remain; leaving the daemon running.`);
|
|
9035
|
+
}
|
|
9036
|
+
// A worker that could not be removed means the workforce is NOT fully stopped:
|
|
9037
|
+
// exit non-zero so automation doesn't mistake a partial stop for success
|
|
9038
|
+
// (consistent with `supervisor remove`, which also exits non-zero on failure).
|
|
9039
|
+
if (hadError) process.exit(1);
|
|
9040
|
+
}
|
|
9041
|
+
|
|
9042
|
+
async function workforceCommand(req, flags) {
|
|
9043
|
+
const logger = getLogger();
|
|
9044
|
+
const action = (req.positional[0] || '').toLowerCase();
|
|
9045
|
+
// A bare `--profile` (no value) is parsed as boolean `true`; reject it so we
|
|
9046
|
+
// fail fast instead of silently operating on the default manifest.
|
|
9047
|
+
if (flags?.profile === true) {
|
|
9048
|
+
logger.error('--profile requires a manifest name.');
|
|
9049
|
+
process.exit(1);
|
|
9050
|
+
}
|
|
9051
|
+
const manifestName = workforceManifestName(flags);
|
|
9052
|
+
if (!isValidManifestName(manifestName)) {
|
|
9053
|
+
logger.error(`Invalid --profile "${manifestName}". Use letters, digits, dot, dash or underscore.`);
|
|
9054
|
+
process.exit(1);
|
|
9055
|
+
}
|
|
9056
|
+
switch (action) {
|
|
9057
|
+
case 'add':
|
|
9058
|
+
await workforceAddCmd(req, flags, manifestName);
|
|
9059
|
+
return;
|
|
9060
|
+
case 'remove':
|
|
9061
|
+
case 'rm':
|
|
9062
|
+
await workforceRemoveCmd(req, flags, manifestName);
|
|
9063
|
+
return;
|
|
9064
|
+
case 'list':
|
|
9065
|
+
case 'ls':
|
|
9066
|
+
await workforceListCmd(req, flags, manifestName);
|
|
9067
|
+
return;
|
|
9068
|
+
case 'start':
|
|
9069
|
+
case 'up':
|
|
9070
|
+
await workforceStartCmd(req, flags, manifestName);
|
|
9071
|
+
return;
|
|
9072
|
+
case 'status':
|
|
9073
|
+
await workforceStatusCmd(req, flags, manifestName);
|
|
9074
|
+
return;
|
|
9075
|
+
case 'stop':
|
|
9076
|
+
case 'down':
|
|
9077
|
+
await workforceStopCmd(req, flags, manifestName);
|
|
9078
|
+
return;
|
|
9079
|
+
default:
|
|
9080
|
+
logger.error(`Unknown workforce action "${action}". Use: add|remove|list|start|status|stop`);
|
|
9081
|
+
process.exit(1);
|
|
9082
|
+
}
|
|
9083
|
+
}
|
|
9084
|
+
|
|
8042
9085
|
// ---------------------------------------------------------------------------
|
|
8043
9086
|
// update — pull a new nanobpmn release onto a machine with an existing install.
|
|
8044
9087
|
// The plugin (and the bundled server binary, shipped via the matching platform
|
|
@@ -9742,6 +10785,7 @@ export {
|
|
|
9742
10785
|
parseEnvPairs,
|
|
9743
10786
|
normalizeEnvMap,
|
|
9744
10787
|
normalizeArgList,
|
|
10788
|
+
hasValuelessArg,
|
|
9745
10789
|
shQuote,
|
|
9746
10790
|
buildAgentCommandLine,
|
|
9747
10791
|
reapAgentContainers,
|
|
@@ -9831,6 +10875,37 @@ export {
|
|
|
9831
10875
|
getSupervisorStateFile,
|
|
9832
10876
|
};
|
|
9833
10877
|
|
|
10878
|
+
export {
|
|
10879
|
+
WORKFORCE_MANIFEST_VERSION,
|
|
10880
|
+
DEFAULT_WORKFORCE_MANIFEST,
|
|
10881
|
+
isValidManifestName,
|
|
10882
|
+
emptyWorkforceManifest,
|
|
10883
|
+
parseRolesList,
|
|
10884
|
+
rolesToJobTypes,
|
|
10885
|
+
manifestEntryToWorkArgs,
|
|
10886
|
+
workforceOwnerPrefix,
|
|
10887
|
+
workforceWorkerName,
|
|
10888
|
+
workforceProfileFromWorkerName,
|
|
10889
|
+
isWorkforceOwnedWorker,
|
|
10890
|
+
expandWorkforceDesired,
|
|
10891
|
+
reconcileWorkforce,
|
|
10892
|
+
normalizeManifestEntry,
|
|
10893
|
+
validateWorkforceManifest,
|
|
10894
|
+
readWorkforceManifestStrict,
|
|
10895
|
+
writeWorkforceManifest,
|
|
10896
|
+
listWorkforceManifestNames,
|
|
10897
|
+
getWorkforceDir,
|
|
10898
|
+
getWorkforceManifestFile,
|
|
10899
|
+
upsertManifestEntry,
|
|
10900
|
+
removeManifestEntry,
|
|
10901
|
+
describeEntryRoles,
|
|
10902
|
+
formatWorkforceManifest,
|
|
10903
|
+
buildWorkforceStatus,
|
|
10904
|
+
formatWorkforceStatus,
|
|
10905
|
+
workforceManifestName,
|
|
10906
|
+
lastProfileValue,
|
|
10907
|
+
};
|
|
10908
|
+
|
|
9834
10909
|
export const metadata = {
|
|
9835
10910
|
name: 'c8ctl-plugin-nano',
|
|
9836
10911
|
description: 'Start, inspect, and stop a local Nano BPM (nanobpmn) cluster',
|
|
@@ -9883,6 +10958,13 @@ export const metadata = {
|
|
|
9883
10958
|
{ command: 'c8ctl nano supervisor add reviewer --instances 3', description: 'Add 3 distinct auto-named instances of a profile in one call' },
|
|
9884
10959
|
{ command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
|
|
9885
10960
|
{ command: 'c8ctl nano supervisor stop', description: 'Stop the supervisor daemon and all its workers' },
|
|
10961
|
+
{ command: 'c8ctl nano workforce add copilot --instances 5 --auto', description: 'Compose a reusable fleet: 5 copilot workers serving every deployed agent job type (--auto)' },
|
|
10962
|
+
{ command: 'c8ctl nano workforce add qwen --instances 2 --roles pr-review,feature', description: "Add an entry mapped to explicit job types (<rank>:pr-review, <rank>:feature, where <rank> is the qwen hire's rank at start) — does not mutate the hired profile" },
|
|
10963
|
+
{ command: 'c8ctl nano workforce start', description: "Ensure the daemon is up, then reconcile running workers to the 'default' manifest (idempotent — a second run changes nothing)" },
|
|
10964
|
+
{ command: 'c8ctl nano workforce start --profile review-only', description: 'Bring up a named manifest (<stateHome>/workforce/review-only.json)' },
|
|
10965
|
+
{ command: 'c8ctl nano workforce status --json', description: 'Manifest entries joined against live supervisor status (desired vs actual), machine-readable for the install script / CI' },
|
|
10966
|
+
{ command: 'c8ctl nano workforce list', description: 'Print the default manifest and list the manifests that exist on this machine' },
|
|
10967
|
+
{ command: 'c8ctl nano workforce stop', description: "Remove this manifest's workers; stop the daemon too if no supervised workers remain" },
|
|
9886
10968
|
],
|
|
9887
10969
|
},
|
|
9888
10970
|
processos: {
|
|
@@ -9926,7 +11008,7 @@ export const commands = {
|
|
|
9926
11008
|
name: { type: 'string', description: 'work/supervisor add: worker name (auto ‹host›-‹profile›-‹random› if omitted); hire/assign: agent profile name' },
|
|
9927
11009
|
rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
|
|
9928
11010
|
command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
|
|
9929
|
-
arg: { type: 'string', multiple: true, description: 'hire/work: command-line switch/arg appended to the harness command (repeatable), e.g. --arg --allow-all. Persisted on hire; work appends more.' },
|
|
11011
|
+
arg: { type: 'string', multiple: true, description: 'hire/work/workforce add: command-line switch/arg appended to the harness command (repeatable), e.g. --arg --allow-all. Persisted on hire; work appends more; workforce add sets the entry args.' },
|
|
9930
11012
|
model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
|
|
9931
11013
|
capabilities: { type: 'string', description: 'hire/assign: comma-separated capability list' },
|
|
9932
11014
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
@@ -9949,11 +11031,14 @@ export const commands = {
|
|
|
9949
11031
|
'lock-grace': { type: 'string', description: 'work: DEPRECATED and ignored — the broker lock is now auto-managed via --recovery-window.' },
|
|
9950
11032
|
'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' },
|
|
9951
11033
|
'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
|
|
9952
|
-
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).' },
|
|
9953
|
-
'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.' },
|
|
11034
|
+
auto: { type: 'boolean', description: 'work / workforce add: 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 (--roles); has NO capability gate (serves any deployed agent job on the engine). For workforce add it sets roles: "auto" in the manifest.' },
|
|
11035
|
+
'auto-scope': { type: 'string', description: 'work / workforce add: 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. For workforce add it sets autoScope in the manifest.' },
|
|
9954
11036
|
worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
|
|
9955
|
-
instances: { type: 'string', description: `supervisor add: spawn N distinct
|
|
11037
|
+
instances: { type: 'string', description: `supervisor add / workforce add: spawn/compose N distinct instances of the profile in one call (default 1, max ${MAX_ADD_INSTANCES}; for supervisor add cannot combine with --name)` },
|
|
9956
11038
|
attach: { type: 'boolean', description: 'supervisor start: attach the interactive console after starting the daemon' },
|
|
11039
|
+
profile: { type: 'string', description: `workforce: manifest name to operate on (default ${DEFAULT_WORKFORCE_MANIFEST}); each subcommand reads/writes <stateHome>/workforce/<name>.json` },
|
|
11040
|
+
roles: { type: 'string', description: 'workforce add: comma-separated role list for the entry (→ --job-type <rank>:<role> at start); mutually exclusive with --auto' },
|
|
11041
|
+
json: { type: 'boolean', description: 'workforce list/status: emit machine-readable JSON (for the install script / CI)' },
|
|
9957
11042
|
},
|
|
9958
11043
|
handler: async (args, flags) => {
|
|
9959
11044
|
const logger = getLogger();
|
|
@@ -10017,6 +11102,9 @@ export const commands = {
|
|
|
10017
11102
|
case 'supervisor':
|
|
10018
11103
|
await supervisorCommand(req, flags);
|
|
10019
11104
|
break;
|
|
11105
|
+
case 'workforce':
|
|
11106
|
+
await workforceCommand(req, flags);
|
|
11107
|
+
break;
|
|
10020
11108
|
}
|
|
10021
11109
|
} catch (error) {
|
|
10022
11110
|
logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
|
|
@@ -10110,6 +11198,7 @@ function printUsage() {
|
|
|
10110
11198
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
10111
11199
|
console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--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]');
|
|
10112
11200
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
11201
|
+
console.log(' c8ctl nano workforce [add|remove|list|start|status|stop] ... [--profile <manifest>] (declarative, reusable fleet manifests)');
|
|
10113
11202
|
console.log('');
|
|
10114
11203
|
console.log('Subcommands:');
|
|
10115
11204
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
@@ -10128,6 +11217,7 @@ function printUsage() {
|
|
|
10128
11217
|
console.log(' assign Grant new capabilities (roles) to an existing hire (additive; comma-separated; workers hot-reload)');
|
|
10129
11218
|
console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
|
|
10130
11219
|
console.log(' supervisor Run/manage a fleet of workers from one terminal (detachable console + non-interactive control)');
|
|
11220
|
+
console.log(' workforce Compose a reusable, declarative fleet manifest and reconcile it up/down (add|remove|list|start|status|stop)');
|
|
10131
11221
|
console.log('');
|
|
10132
11222
|
console.log('Options:');
|
|
10133
11223
|
console.log(' <nodes> Number of nodes to start (default 1)');
|