c8ctl-plugin-nano 1.11.0 → 1.12.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 +8 -0
- package/c8ctl-plugin.js +217 -12
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -281,6 +281,14 @@ a run that outlives `--job-timeout` is force-removed. The envelope is piped on
|
|
|
281
281
|
the container's stdin exactly as on the host. (Container-side git provisioning —
|
|
282
282
|
strong isolation — is a later increment; container jobs don't clone yet.)
|
|
283
283
|
|
|
284
|
+
**Host workers inherit your credentials.** A host worker (`--sandbox none`, the
|
|
285
|
+
default) runs as your user and inherits your full environment and `$HOME`, so
|
|
286
|
+
your existing `gh` CLI login (from `gh auth login`) or a `GH_TOKEN`/`GITHUB_TOKEN`
|
|
287
|
+
env var is available to the harness with **no extra setup** — handy when the
|
|
288
|
+
agent command shells out to `gh`. A **container** sandbox is isolated and does
|
|
289
|
+
**not** inherit that host login; provide the token explicitly via
|
|
290
|
+
`setup.secretRefs` / `--secret-resolver host` (see **Secrets** below) instead.
|
|
291
|
+
|
|
284
292
|
**Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
|
|
285
293
|
(and the repo/PR credential when `task.allowPr` is set — defaulting to
|
|
286
294
|
`GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
|
package/c8ctl-plugin.js
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
} from 'node:fs';
|
|
47
47
|
import { randomUUID } from 'node:crypto';
|
|
48
48
|
import { homedir, platform as osPlatform, devNull } from 'node:os';
|
|
49
|
-
import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
|
|
49
|
+
import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
|
|
50
50
|
import { createRequire } from 'node:module';
|
|
51
51
|
import { fileURLToPath } from 'node:url';
|
|
52
52
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -1639,6 +1639,95 @@ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
|
1639
1639
|
// The result-envelope version is intentionally independent of the task-envelope
|
|
1640
1640
|
// version so the two contracts can evolve separately without silently coupling.
|
|
1641
1641
|
const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
|
|
1642
|
+
|
|
1643
|
+
// Structured result channel (agent → harness). A coding CLI streams a lot of
|
|
1644
|
+
// noisy prose/tool output on stdout, so scraping it for the job's structured
|
|
1645
|
+
// result is fragile. Instead the harness hands the agent a private file path in
|
|
1646
|
+
// `AGENT_RESULT_FILE`; the agent writes a JSON object of *job result variables*
|
|
1647
|
+
// there (e.g. `{ "status": "needs_input", "question": "…" }`). The harness reads
|
|
1648
|
+
// it after the run and merges those variables into the job's completion, so the
|
|
1649
|
+
// model sees them as first-class outputs. A `::nano:result:: {json}` stdout
|
|
1650
|
+
// sentinel (or a trailing ```json fence) is honoured as a fallback for agents
|
|
1651
|
+
// that cannot write the file. The harness stays app-agnostic: it merges whatever
|
|
1652
|
+
// object the agent returns; the *app's prompt* owns the field vocabulary.
|
|
1653
|
+
const AGENT_RESULT_FILE_ENV = 'AGENT_RESULT_FILE';
|
|
1654
|
+
const RESULT_SENTINEL = '::nano:result::';
|
|
1655
|
+
// Completion keys the harness owns — an agent's returned result can never
|
|
1656
|
+
// overwrite these (nor anything in the reserved `io.nanobpm.*` namespace), so a
|
|
1657
|
+
// stray `output`/`exitCode`/git field in the agent's JSON can't corrupt the
|
|
1658
|
+
// audit envelope or process bookkeeping.
|
|
1659
|
+
const RESERVED_RESULT_KEYS = new Set([
|
|
1660
|
+
AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
|
|
1661
|
+
'branch', 'commits', 'pushed', 'pullRequest',
|
|
1662
|
+
]);
|
|
1663
|
+
|
|
1664
|
+
// Parse `text` as a JSON object, returning it only when it is a plain object.
|
|
1665
|
+
// Never throws — malformed agent output degrades to `null`.
|
|
1666
|
+
function parseAgentResultObject(text) {
|
|
1667
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
1668
|
+
try {
|
|
1669
|
+
const v = JSON.parse(text);
|
|
1670
|
+
return isPlainObject(v) ? v : null;
|
|
1671
|
+
} catch { return null; }
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Read + parse the agent's result file, if it wrote one. Best-effort; a missing
|
|
1675
|
+
// or malformed file is treated as "no structured result". The file is
|
|
1676
|
+
// agent-controlled, so guard against a symlink or an oversized payload (DoS):
|
|
1677
|
+
// only a regular file no larger than the cap is read. A well-formed result is a
|
|
1678
|
+
// tiny JSON object, so the cap is generous.
|
|
1679
|
+
const MAX_RESULT_FILE_BYTES = 1_048_576; // 1 MiB
|
|
1680
|
+
function readAgentResultFile(path) {
|
|
1681
|
+
if (!path) return null;
|
|
1682
|
+
try {
|
|
1683
|
+
const st = lstatSync(path); // lstat: never follow a symlink the agent planted
|
|
1684
|
+
if (!st.isFile() || st.size > MAX_RESULT_FILE_BYTES) return null;
|
|
1685
|
+
return parseAgentResultObject(readFileSync(path, 'utf8'));
|
|
1686
|
+
} catch { return null; }
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Fallback extraction from stdout, robust to the surrounding transcript: prefer
|
|
1690
|
+
// the LAST `::nano:result:: {json}` sentinel line (cheapest + most explicit),
|
|
1691
|
+
// else the LAST ```json fenced block. "Last wins" so a re-stated result
|
|
1692
|
+
// supersedes an earlier draft.
|
|
1693
|
+
function parseResultFromStdout(stdout) {
|
|
1694
|
+
if (typeof stdout !== 'string' || !stdout) return null;
|
|
1695
|
+
const lines = stdout.split(/\r?\n/);
|
|
1696
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1697
|
+
const idx = lines[i].indexOf(RESULT_SENTINEL);
|
|
1698
|
+
if (idx === -1) continue;
|
|
1699
|
+
const obj = parseAgentResultObject(lines[i].slice(idx + RESULT_SENTINEL.length).trim());
|
|
1700
|
+
if (obj) return obj;
|
|
1701
|
+
}
|
|
1702
|
+
// Match the opening fence tolerantly (optional language tag, CRLF or LF) so a
|
|
1703
|
+
// Windows agent's `\r\n` output still parses.
|
|
1704
|
+
const fences = [...stdout.matchAll(/```[^\n]*\r?\n([\s\S]*?)```/g)];
|
|
1705
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
1706
|
+
const obj = parseAgentResultObject(fences[i][1].trim());
|
|
1707
|
+
if (obj) return obj;
|
|
1708
|
+
}
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// The domain result variables an agent may return: the parsed object with the
|
|
1713
|
+
// harness-reserved keys (and the `io.nanobpm.*` namespace) stripped, so it can
|
|
1714
|
+
// never clobber the audit envelope, transcript, or git facts. The agent's output
|
|
1715
|
+
// is untrusted, so build the result on a null-prototype object and drop the
|
|
1716
|
+
// prototype-pollution keys — a merged `__proto__`/`constructor`/`prototype`
|
|
1717
|
+
// must never mutate object prototypes when spread into the job completion.
|
|
1718
|
+
const PROTO_POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
1719
|
+
function sanitizeResultVars(obj) {
|
|
1720
|
+
if (!isPlainObject(obj)) return {};
|
|
1721
|
+
const out = Object.create(null);
|
|
1722
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1723
|
+
if (RESERVED_RESULT_KEYS.has(k)) continue;
|
|
1724
|
+
if (PROTO_POLLUTION_KEYS.has(k)) continue;
|
|
1725
|
+
if (k.startsWith('io.nanobpm.')) continue;
|
|
1726
|
+
out[k] = v;
|
|
1727
|
+
}
|
|
1728
|
+
return out;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1642
1731
|
const SANDBOXES = ['none', 'docker', 'podman'];
|
|
1643
1732
|
// Only container-based sandboxes need an image / disk hygiene / a runtime bin.
|
|
1644
1733
|
const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
|
|
@@ -2102,10 +2191,11 @@ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
|
|
|
2102
2191
|
if (!existsSync(root)) return { reaped };
|
|
2103
2192
|
const now = Date.now();
|
|
2104
2193
|
for (const name of readdirSync(root)) {
|
|
2105
|
-
// Only reap the
|
|
2106
|
-
// `
|
|
2107
|
-
//
|
|
2108
|
-
|
|
2194
|
+
// Only reap the throwaway dirs this worker creates under agent-runs: the
|
|
2195
|
+
// `run-*` job workspaces and the `res-*` structured-result dirs (see the
|
|
2196
|
+
// `mkdtempSync(...)` calls in workAgent). Never touch unrelated files/dirs
|
|
2197
|
+
// an operator may have placed under agent-runs.
|
|
2198
|
+
if (!name.startsWith('run-') && !name.startsWith('res-')) continue;
|
|
2109
2199
|
const p = join(root, name);
|
|
2110
2200
|
if (liveRunDirs.has(p)) continue;
|
|
2111
2201
|
try {
|
|
@@ -2128,7 +2218,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2128
2218
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2129
2219
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2130
2220
|
// uniform result. Used by both the host and container executors.
|
|
2131
|
-
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout }) {
|
|
2221
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
|
|
2132
2222
|
return new Promise((resolve) => {
|
|
2133
2223
|
let child;
|
|
2134
2224
|
const stdoutChunks = [];
|
|
@@ -2140,10 +2230,43 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2140
2230
|
let settled = false;
|
|
2141
2231
|
let timer = null;
|
|
2142
2232
|
|
|
2233
|
+
// Live "spy" tee (--stream): mirror the child's output line-by-line to a
|
|
2234
|
+
// caller-supplied emitter (the worker routes these through c8ctl's
|
|
2235
|
+
// output-mode-aware logger so streaming never corrupts a structured/JSON
|
|
2236
|
+
// output mode; falling back to a raw console write only when none is given).
|
|
2237
|
+
// Each complete line is tagged with the job prefix so interleaved jobs stay
|
|
2238
|
+
// legible. A per-stream buffer holds partial lines across chunk boundaries;
|
|
2239
|
+
// it is force-flushed once it exceeds STREAM_TEE_LINE_CAP so a newline-less
|
|
2240
|
+
// torrent (progress bars, binary output) can't grow it without bound.
|
|
2241
|
+
const STREAM_TEE_LINE_CAP = 64 * 1024;
|
|
2242
|
+
const makeTee = (emit) => {
|
|
2243
|
+
if (!stream) return null;
|
|
2244
|
+
const sink = emit || ((line) => process.stdout.write(`${line}\n`));
|
|
2245
|
+
let partial = '';
|
|
2246
|
+
const flush = (text, final) => {
|
|
2247
|
+
partial += text;
|
|
2248
|
+
let nl;
|
|
2249
|
+
while ((nl = partial.indexOf('\n')) !== -1) {
|
|
2250
|
+
sink(`${streamPrefix}${partial.slice(0, nl)}`);
|
|
2251
|
+
partial = partial.slice(nl + 1);
|
|
2252
|
+
}
|
|
2253
|
+
while (partial.length >= STREAM_TEE_LINE_CAP) {
|
|
2254
|
+
sink(`${streamPrefix}${partial.slice(0, STREAM_TEE_LINE_CAP)}`);
|
|
2255
|
+
partial = partial.slice(STREAM_TEE_LINE_CAP);
|
|
2256
|
+
}
|
|
2257
|
+
if (final && partial) { sink(`${streamPrefix}${partial}`); partial = ''; }
|
|
2258
|
+
};
|
|
2259
|
+
return flush;
|
|
2260
|
+
};
|
|
2261
|
+
const teeOut = makeTee(onStreamOut);
|
|
2262
|
+
const teeErr = makeTee(onStreamErr || onStreamOut);
|
|
2263
|
+
|
|
2143
2264
|
const finish = (result) => {
|
|
2144
2265
|
if (settled) return;
|
|
2145
2266
|
settled = true;
|
|
2146
2267
|
if (timer) clearTimeout(timer);
|
|
2268
|
+
if (teeOut) teeOut('', true);
|
|
2269
|
+
if (teeErr) teeErr('', true);
|
|
2147
2270
|
resolve(result);
|
|
2148
2271
|
};
|
|
2149
2272
|
|
|
@@ -2163,6 +2286,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2163
2286
|
|
|
2164
2287
|
child.stdout.on('data', (d) => {
|
|
2165
2288
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2289
|
+
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
2166
2290
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
2167
2291
|
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
2168
2292
|
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
@@ -2170,6 +2294,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2170
2294
|
});
|
|
2171
2295
|
child.stderr.on('data', (d) => {
|
|
2172
2296
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2297
|
+
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
2173
2298
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
2174
2299
|
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
2175
2300
|
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
@@ -2233,7 +2358,7 @@ function baseAgentEnv(profile, job) {
|
|
|
2233
2358
|
* Both paths resolve to the same result contract.
|
|
2234
2359
|
*/
|
|
2235
2360
|
function runAgentJob(profile, job, opts = {}) {
|
|
2236
|
-
const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {} } = opts;
|
|
2361
|
+
const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr } = opts;
|
|
2237
2362
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
2238
2363
|
const agentEnv = baseAgentEnv(profile, job);
|
|
2239
2364
|
// Static, non-secret env for the harness: the worker/profile's env (e.g. a
|
|
@@ -2243,6 +2368,8 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2243
2368
|
const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
|
|
2244
2369
|
|
|
2245
2370
|
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
2371
|
+
// Host: hand the agent the result file by its real path.
|
|
2372
|
+
const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
|
|
2246
2373
|
return spawnCaptureOneShot({
|
|
2247
2374
|
command: profile.command,
|
|
2248
2375
|
shell: true,
|
|
@@ -2250,20 +2377,38 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2250
2377
|
detached: process.platform !== 'win32',
|
|
2251
2378
|
// When a repository was provisioned, run the harness IN the workspace.
|
|
2252
2379
|
cwd,
|
|
2253
|
-
|
|
2380
|
+
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
2381
|
+
// resolved secrets so a task-supplied secret NAME can never shadow it.
|
|
2382
|
+
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2254
2383
|
stdinData: payload,
|
|
2255
2384
|
timeoutMs,
|
|
2256
2385
|
onTimeout: (child) => killTree(child),
|
|
2386
|
+
stream,
|
|
2387
|
+
streamPrefix,
|
|
2388
|
+
onStreamOut,
|
|
2389
|
+
onStreamErr,
|
|
2257
2390
|
});
|
|
2258
2391
|
}
|
|
2259
2392
|
|
|
2260
2393
|
const engine = sandbox;
|
|
2261
2394
|
const containerName = `nano-${runId}`;
|
|
2395
|
+
// Container: bind-mount the result file's directory read-write at a fixed
|
|
2396
|
+
// in-container path and point AGENT_RESULT_FILE at the mounted file, so the
|
|
2397
|
+
// agent writes it inside the sandbox and the harness reads it back on the host.
|
|
2398
|
+
let resultEnv = {};
|
|
2399
|
+
const mountArgs = [];
|
|
2400
|
+
if (resultFile) {
|
|
2401
|
+
const hostDir = dirname(resultFile);
|
|
2402
|
+
const containerPath = `/nano-agent/${basename(resultFile)}`;
|
|
2403
|
+
mountArgs.push('-v', `${hostDir}:/nano-agent`);
|
|
2404
|
+
resultEnv = { [AGENT_RESULT_FILE_ENV]: containerPath };
|
|
2405
|
+
}
|
|
2262
2406
|
// Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
|
|
2263
2407
|
// `docker inspect`; docker reads the value from our child's environment.
|
|
2264
2408
|
const envArgs = [];
|
|
2265
2409
|
for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
|
|
2266
2410
|
for (const k of Object.keys(extraEnv)) envArgs.push('-e', k);
|
|
2411
|
+
for (const k of Object.keys(resultEnv)) envArgs.push('-e', k);
|
|
2267
2412
|
for (const n of passThroughSecretNames) envArgs.push('-e', n);
|
|
2268
2413
|
for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
|
|
2269
2414
|
|
|
@@ -2276,6 +2421,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2276
2421
|
'--label', `nano.run=${runId}`,
|
|
2277
2422
|
'--log-opt', 'max-size=10m',
|
|
2278
2423
|
'--log-opt', 'max-file=3',
|
|
2424
|
+
...mountArgs,
|
|
2279
2425
|
...envArgs,
|
|
2280
2426
|
image,
|
|
2281
2427
|
'sh', '-c', profile.command,
|
|
@@ -2285,9 +2431,16 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2285
2431
|
command: engine,
|
|
2286
2432
|
args,
|
|
2287
2433
|
shell: false,
|
|
2288
|
-
|
|
2434
|
+
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
2435
|
+
// resolved secrets so a task-supplied secret NAME can never shadow it. In
|
|
2436
|
+
// container mode docker reads these values from our child env by NAME.
|
|
2437
|
+
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2289
2438
|
stdinData: payload,
|
|
2290
2439
|
timeoutMs,
|
|
2440
|
+
stream,
|
|
2441
|
+
streamPrefix,
|
|
2442
|
+
onStreamOut,
|
|
2443
|
+
onStreamErr,
|
|
2291
2444
|
onTimeout: (child) => {
|
|
2292
2445
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
2293
2446
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -2297,7 +2450,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2297
2450
|
|
|
2298
2451
|
// Shape the io.nanobpm.agentResult output envelope. When a repository was
|
|
2299
2452
|
// provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
|
|
2300
|
-
function buildResultEnvelope(result, { sandbox, image, git } = {}) {
|
|
2453
|
+
function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
|
|
2301
2454
|
const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
|
|
2302
2455
|
const env = {
|
|
2303
2456
|
schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
|
|
@@ -2311,6 +2464,9 @@ function buildResultEnvelope(result, { sandbox, image, git } = {}) {
|
|
|
2311
2464
|
signal: result.signal ?? null,
|
|
2312
2465
|
error: result.error ?? null,
|
|
2313
2466
|
};
|
|
2467
|
+
// The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
|
|
2468
|
+
// preserved verbatim for auditability even when merged into the completion vars.
|
|
2469
|
+
if (isPlainObject(agentResult)) env.result = agentResult;
|
|
2314
2470
|
if (git) {
|
|
2315
2471
|
env.repository = git.remote ?? null;
|
|
2316
2472
|
env.branch = git.branch ?? null;
|
|
@@ -2408,6 +2564,9 @@ async function workAgent(req, flags) {
|
|
|
2408
2564
|
// Git provisioning knobs (increment 2a — host harness with a repository).
|
|
2409
2565
|
const cloneTimeoutMs = intFlag(flags?.['clone-timeout'], 120_000);
|
|
2410
2566
|
const keepRuns = coerceBool(flags?.['keep-runs'], false);
|
|
2567
|
+
// --stream: tee each agent job's live stdout/stderr to this console (spy/debug),
|
|
2568
|
+
// in addition to the existing byte-capped capture used for the result envelope.
|
|
2569
|
+
const stream = coerceBool(flags?.stream, false);
|
|
2411
2570
|
|
|
2412
2571
|
// Tracks run ids currently executing so the reaper never removes a live
|
|
2413
2572
|
// container out from under an in-flight job.
|
|
@@ -2534,7 +2693,20 @@ async function workAgent(req, flags) {
|
|
|
2534
2693
|
|
|
2535
2694
|
let result;
|
|
2536
2695
|
let gitResult = null;
|
|
2696
|
+
// Private structured-result channel: hand the agent a file (outside any
|
|
2697
|
+
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
2698
|
+
let resultDir = null;
|
|
2699
|
+
let resultFile = null;
|
|
2537
2700
|
try {
|
|
2701
|
+
try {
|
|
2702
|
+
mkdirSync(agentRunsRoot(), { recursive: true });
|
|
2703
|
+
resultDir = mkdtempSync(join(agentRunsRoot(), 'res-'));
|
|
2704
|
+
resultFile = join(resultDir, 'result.json');
|
|
2705
|
+
// Track it so the run-dir reaper skips it while in-flight and reaps it
|
|
2706
|
+
// (as a `res-*` dir) if this worker crashes before the cleanup below.
|
|
2707
|
+
liveRunDirs.add(resultDir);
|
|
2708
|
+
} catch { resultDir = null; resultFile = null; }
|
|
2709
|
+
|
|
2538
2710
|
result = await runAgentJob(profile, job, {
|
|
2539
2711
|
timeoutMs: jobTimeoutMs,
|
|
2540
2712
|
envelope,
|
|
@@ -2546,6 +2718,13 @@ async function workAgent(req, flags) {
|
|
|
2546
2718
|
cwd,
|
|
2547
2719
|
extraEnv,
|
|
2548
2720
|
profileEnv,
|
|
2721
|
+
resultFile,
|
|
2722
|
+
stream,
|
|
2723
|
+
streamPrefix: `[${jobType} ${job.jobKey}] `,
|
|
2724
|
+
// Route the --stream tee through c8ctl's output-mode-aware logger so
|
|
2725
|
+
// spying never corrupts a structured/JSON output mode.
|
|
2726
|
+
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
2727
|
+
onStreamErr: stream ? (line) => logger.warn(line) : undefined,
|
|
2549
2728
|
});
|
|
2550
2729
|
|
|
2551
2730
|
// Finalize git only when the harness succeeded — never push a
|
|
@@ -2572,14 +2751,33 @@ async function workAgent(req, flags) {
|
|
|
2572
2751
|
if (runDir) liveRunDirs.delete(runDir);
|
|
2573
2752
|
}
|
|
2574
2753
|
|
|
2575
|
-
|
|
2754
|
+
// Read the agent's structured result: the file it wrote, else a stdout
|
|
2755
|
+
// sentinel/`json fence fallback. The raw object is attached to the audit
|
|
2756
|
+
// envelope; the sanitized (reserved-key-stripped) vars are merged into the
|
|
2757
|
+
// job completion so the model sees `status`/`summary`/… as first-class
|
|
2758
|
+
// outputs. Read before deleting the temp dir.
|
|
2759
|
+
const rawResult = readAgentResultFile(resultFile) ?? parseResultFromStdout(result.stdout);
|
|
2760
|
+
if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
|
|
2761
|
+
const resultVars = sanitizeResultVars(rawResult);
|
|
2762
|
+
|
|
2763
|
+
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
|
|
2576
2764
|
if (result.ok) {
|
|
2577
2765
|
const gitNote = gitResult
|
|
2578
2766
|
? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
|
|
2579
2767
|
: '';
|
|
2580
2768
|
logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
|
|
2581
2769
|
if (gitResult?.pushError) logger.warn(`[${jobType}] job ${job.jobKey}: branch push failed — ${gitResult.pushError}`);
|
|
2770
|
+
// Guard the operator against silent empty escalations: a success that
|
|
2771
|
+
// yields no *effective* result vars (no file/sentinel at all, an empty
|
|
2772
|
+
// `{}`, or only reserved keys that were sanitized away) means the
|
|
2773
|
+
// model's status/decision vars stay unset and any status gateway will
|
|
2774
|
+
// fall through to its default. Warn on the merged-vars emptiness, not
|
|
2775
|
+
// just a missing rawResult.
|
|
2776
|
+
const resultKeys = Object.keys(resultVars);
|
|
2777
|
+
if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
|
|
2778
|
+
else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
|
|
2582
2779
|
return job.complete({
|
|
2780
|
+
...resultVars,
|
|
2583
2781
|
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
2584
2782
|
output: result.stdout,
|
|
2585
2783
|
exitCode: 0,
|
|
@@ -3875,6 +4073,10 @@ export {
|
|
|
3875
4073
|
hostEnvSecretResolver,
|
|
3876
4074
|
buildAgentPayload,
|
|
3877
4075
|
buildResultEnvelope,
|
|
4076
|
+
parseAgentResultObject,
|
|
4077
|
+
readAgentResultFile,
|
|
4078
|
+
parseResultFromStdout,
|
|
4079
|
+
sanitizeResultVars,
|
|
3878
4080
|
parseEnvPairs,
|
|
3879
4081
|
normalizeEnvMap,
|
|
3880
4082
|
reapAgentContainers,
|
|
@@ -3893,6 +4095,8 @@ export {
|
|
|
3893
4095
|
jobTypeMatrix,
|
|
3894
4096
|
AGENT_TASK_NS,
|
|
3895
4097
|
AGENT_RESULT_KEY,
|
|
4098
|
+
RESULT_SENTINEL,
|
|
4099
|
+
RESERVED_RESULT_KEYS,
|
|
3896
4100
|
SANDBOXES,
|
|
3897
4101
|
};
|
|
3898
4102
|
|
|
@@ -3987,6 +4191,7 @@ export const commands = {
|
|
|
3987
4191
|
'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
|
|
3988
4192
|
'clone-timeout': { type: 'string', description: 'work: max time in ms for cloning a task repository on the host (default 120000)' },
|
|
3989
4193
|
'keep-runs': { type: 'boolean', description: 'work: keep per-job workspaces under <state>/agent-runs instead of deleting them after each job (debug)' },
|
|
4194
|
+
stream: { type: 'boolean', description: 'work: tee each agent job\'s live stdout/stderr to this console, prefixed with the job type + key (spy/debug)' },
|
|
3990
4195
|
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
3991
4196
|
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
3992
4197
|
'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
|
|
@@ -4133,7 +4338,7 @@ function printUsage() {
|
|
|
4133
4338
|
console.log(' c8ctl nano config');
|
|
4134
4339
|
console.log(' c8ctl nano update [--check]');
|
|
4135
4340
|
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
|
|
4136
|
-
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs]');
|
|
4341
|
+
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
|
|
4137
4342
|
console.log('');
|
|
4138
4343
|
console.log('Subcommands:');
|
|
4139
4344
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"semantic-release": "^25.0.3"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.12.0",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.12.0",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.12.0",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.12.0",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.12.0",
|
|
57
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.12.0",
|
|
58
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.12.0"
|
|
59
59
|
}
|
|
60
60
|
}
|