c8ctl-plugin-nano 1.35.2 → 1.35.4
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 +75 -16
- package/c8ctl-plugin.js +415 -44
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -274,13 +274,14 @@ its agents' terminals to an operator's cockpit. This rides the app's agentic
|
|
|
274
274
|
channel (ADR 0056), served **same-port** on the app's own HTTP base URL at path
|
|
275
275
|
**`/agentic`** — not a sidecar, so there's no extra port to open.
|
|
276
276
|
|
|
277
|
-
**Connecting — on by default (
|
|
278
|
-
visibility is **on by default**. Run a worker against a
|
|
279
|
-
live with **zero configuration** — it joins the channel with a well-known
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
277
|
+
**Connecting — on by default (LAN-first).** Nano is designed to run on a trusted
|
|
278
|
+
network, so visibility is **on by default**. Run a worker against a Nano app and it
|
|
279
|
+
appears live with **zero configuration** — it joins the channel with a well-known
|
|
280
|
+
token in **LOCAL mode** (no secret). The worker presents this token to whatever
|
|
281
|
+
`NANO_AGENTIC_URL` you point it at; the hub honours the well-known LOCAL token from
|
|
282
|
+
**any origin** (matching the open trusted-LAN posture of the engine itself), so a
|
|
283
|
+
worker on another box on the LAN appears live too — exposure is governed by the
|
|
284
|
+
server's bind address, not by a shared secret:
|
|
284
285
|
|
|
285
286
|
```bash
|
|
286
287
|
# LOCAL mode (default): appears live with no secrets
|
|
@@ -318,24 +319,35 @@ used **verbatim** — so an explicit target always wins, and it's also how you
|
|
|
318
319
|
disambiguate when several apps are running. `NANO_AGENTIC=off` disables the
|
|
319
320
|
channel entirely and attempts **no** discovery.
|
|
320
321
|
|
|
321
|
-
**Secure mode (opt-in).** For a
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
**Secure mode (opt-in).** For a deployment where you want the visibility channel
|
|
323
|
+
authenticated (rather than open on the LAN), start the server **and** every worker
|
|
324
|
+
box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
|
|
325
|
+
sides (Tab A → Slot A). The worker presents it as its identity token and the hub
|
|
326
|
+
verifies it against its own `NANO_AGENTIC_SECRET`. Setting it switches the worker
|
|
327
|
+
into SECURE mode:
|
|
325
328
|
|
|
326
329
|
```bash
|
|
327
|
-
# SECURE mode:
|
|
330
|
+
# SECURE mode: same NANO_AGENTIC_SECRET on the server and every worker box
|
|
328
331
|
export NANO_AGENTIC_URL=http://localhost:8080
|
|
329
|
-
export
|
|
330
|
-
export NANO_AGENTIC_CREDENTIAL=<capability-cred> # capability credential
|
|
332
|
+
export NANO_AGENTIC_SECRET=<shared-secret> # must equal the server's NANO_AGENTIC_SECRET
|
|
331
333
|
c8ctl nano work reviewer
|
|
332
334
|
# agentic channel (secure): announcing presence as ‹worker› on ws://localhost:8080/agentic
|
|
333
335
|
```
|
|
334
336
|
|
|
337
|
+
The shared secret can also be **persisted** (via config) as `agenticSecret`, so it
|
|
338
|
+
need not be exported into the environment on every run; env still wins over the
|
|
339
|
+
persisted value.
|
|
340
|
+
|
|
341
|
+
The legacy `NANO_AGENTIC_TOKEN` env var (and persisted `agenticToken`) is still
|
|
342
|
+
accepted as a **deprecated alias** for the shared secret. The **capability
|
|
343
|
+
credential** (`NANO_AGENTIC_CREDENTIAL`) is no longer required — it was removed from
|
|
344
|
+
the hub contract (it was accept-any, pure friction). It remains **optional** and is
|
|
345
|
+
forwarded only if still configured, for forward-compatibility with a future per-peer
|
|
346
|
+
capability verifier.
|
|
347
|
+
|
|
335
348
|
To opt out entirely, set `NANO_AGENTIC=off` (or persisted `agentic: false`) — the
|
|
336
349
|
worker then runs with **no visibility, no relay, nothing else changed**. In secure
|
|
337
|
-
mode a
|
|
338
|
-
(unauthorized) and a missing capability is rejected (forbidden).
|
|
350
|
+
mode a matching secret connects; a wrong secret is rejected (unauthorized).
|
|
339
351
|
|
|
340
352
|
**How presence appears.** On connect the worker **announces** its identity, its
|
|
341
353
|
`host`, and the set of `jobKeys` it is currently running, then **heartbeats** to
|
|
@@ -1098,6 +1110,53 @@ non-interactive shells, in CI, and when `NO_UPDATE_NOTIFIER` /
|
|
|
1098
1110
|
`NANO_NO_UPDATE_NOTIFIER` is set. To update, stop and start ProcessOS again — a
|
|
1099
1111
|
downloaded binary re-fetches the latest build; a `set bin` binary updates itself.
|
|
1100
1112
|
|
|
1113
|
+
### Pre-upgrade read-model backup & restore
|
|
1114
|
+
|
|
1115
|
+
A schema-changing gateway release can reproject the SQLite read model and, in
|
|
1116
|
+
the worst case, silently drop completed process-instance history (root cause and
|
|
1117
|
+
durable fix tracked in `nano-bpm#831`). As a safety net, whenever `start`
|
|
1118
|
+
downloads a **different** ProcessOS version over an existing cached binary — a
|
|
1119
|
+
true upgrade — the launcher first snapshots each per-node read model **before**
|
|
1120
|
+
swapping the binary. First installs (no cached copy yet) are not upgrades, so
|
|
1121
|
+
they skip the backup.
|
|
1122
|
+
|
|
1123
|
+
For every `…/data/node-<i>/` that has a `read-model.sqlite`, the launcher copies
|
|
1124
|
+
it — together with its `-wal` sidecar (WAL mode keeps uncheckpointed pages there)
|
|
1125
|
+
and `-shm` index, plus the coherent point-in-time set `journal.head` and
|
|
1126
|
+
`snapshot.*.bin` — into a `read-model-backups/` subdir under that node, named
|
|
1127
|
+
`read-model.pre-upgrade-<oldver>-<timestamp>-<rand>.sqlite` (the `<rand>` token
|
|
1128
|
+
keeps two backups that land in the same millisecond from colliding). The backup
|
|
1129
|
+
path is logged
|
|
1130
|
+
at INFO, and a bounded ring (the last **5** upgrades per node) is retained;
|
|
1131
|
+
older sets are pruned. The backup is best-effort: a failure is logged and never
|
|
1132
|
+
blocks the upgrade.
|
|
1133
|
+
|
|
1134
|
+
To restore a node's read model from a backup (do this while the node is
|
|
1135
|
+
stopped):
|
|
1136
|
+
|
|
1137
|
+
```bash
|
|
1138
|
+
# 1. Stop the cluster so nothing is writing the read model.
|
|
1139
|
+
c8ctl nano stop
|
|
1140
|
+
|
|
1141
|
+
# 2. Pick the pre-upgrade backup you want to restore (newest shown first).
|
|
1142
|
+
NODE=~/Library/Application\ Support/c8ctl-nano/data/node-0 # adjust per platform/node
|
|
1143
|
+
ls -t "$NODE/read-model-backups"/read-model.pre-upgrade-*.sqlite
|
|
1144
|
+
|
|
1145
|
+
# 3. Replace the live read-model files with the chosen backup set. Remove the
|
|
1146
|
+
# stale WAL/SHM first so SQLite does not replay them over the restored DB.
|
|
1147
|
+
STEM="$NODE/read-model-backups/read-model.pre-upgrade-<oldver>-<timestamp>-<rand>"
|
|
1148
|
+
rm -f "$NODE/read-model.sqlite" "$NODE/read-model.sqlite-wal" "$NODE/read-model.sqlite-shm"
|
|
1149
|
+
cp "$STEM.sqlite" "$NODE/read-model.sqlite"
|
|
1150
|
+
[ -f "$STEM.sqlite-wal" ] && cp "$STEM.sqlite-wal" "$NODE/read-model.sqlite-wal"
|
|
1151
|
+
|
|
1152
|
+
# 4. Start the cluster again.
|
|
1153
|
+
c8ctl nano start
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
> On Linux the data dir defaults to `~/.local/share/c8ctl-nano/data`, on Windows
|
|
1157
|
+
> to `%LOCALAPPDATA%\c8ctl-nano\data` (override the root with `C8CTL_NANO_HOME`).
|
|
1158
|
+
|
|
1159
|
+
|
|
1101
1160
|
On a successful `start` the summary leads with the landing page:
|
|
1102
1161
|
|
|
1103
1162
|
```
|
package/c8ctl-plugin.js
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
existsSync,
|
|
34
34
|
mkdirSync,
|
|
35
35
|
openSync,
|
|
36
|
+
copyFileSync,
|
|
37
|
+
statSync,
|
|
36
38
|
readFileSync,
|
|
37
39
|
writeFileSync,
|
|
38
40
|
appendFileSync,
|
|
@@ -55,7 +57,7 @@ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from
|
|
|
55
57
|
import { createRequire } from 'node:module';
|
|
56
58
|
import { fileURLToPath } from 'node:url';
|
|
57
59
|
import { createInterface } from 'node:readline/promises';
|
|
58
|
-
import { createInterface as createReadline } from 'node:readline';
|
|
60
|
+
import { createInterface as createReadline, cursorTo as rlCursorTo, moveCursor as rlMoveCursor, clearScreenDown as rlClearScreenDown } from 'node:readline';
|
|
59
61
|
import { platformForHost } from './platforms.mjs';
|
|
60
62
|
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
61
63
|
import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
@@ -136,13 +138,13 @@ const PROCESSOS_DEFAULT_PORT = 8090;
|
|
|
136
138
|
const DEFAULT_NANO_URL = 'http://localhost:8080';
|
|
137
139
|
|
|
138
140
|
// The well-known identity token used for LOCAL agentic visibility (security opt-in). Nano is
|
|
139
|
-
// local-first:
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
141
|
+
// local-first: a `nano work` worker joins the visibility channel with zero configuration, so it
|
|
142
|
+
// presents this constant, well-known LOCAL token — NOT a secret. The worker does not enforce any
|
|
143
|
+
// loopback restriction itself (it presents this token to whatever NANO_AGENTIC_URL is configured);
|
|
144
|
+
// the hub honours this well-known token from any origin (matching the open trusted-LAN posture of
|
|
145
|
+
// the engine itself). Kept in lock-step with the hub constant in nanobpm/nano-workforce
|
|
146
|
+
// (`app/agentic/channel.ts` LOCAL_AGENTIC_TOKEN). In secure mode (a real NANO_AGENTIC_SECRET) this
|
|
147
|
+
// is never used.
|
|
146
148
|
const LOCAL_AGENTIC_TOKEN = 'nano-local';
|
|
147
149
|
|
|
148
150
|
// Passive update notifier (npm-style): refresh the latest published version
|
|
@@ -2397,14 +2399,18 @@ function resolveBrokerRestConfig(env = process.env) {
|
|
|
2397
2399
|
env.NANO_BASE_URL ||
|
|
2398
2400
|
cfg.nanoUrl ||
|
|
2399
2401
|
DEFAULT_NANO_URL;
|
|
2400
|
-
// An explicit REST token always wins. The agentic identity
|
|
2402
|
+
// An explicit REST token always wins. The agentic identity secret is only a
|
|
2401
2403
|
// fallback for single-token deployments where the broker REST endpoint IS the
|
|
2402
2404
|
// agentic endpoint — so only forward it when the REST base URL is same-origin
|
|
2403
|
-
// as the agentic URL. This prevents leaking the identity
|
|
2405
|
+
// as the agentic URL. This prevents leaking the identity secret to a different
|
|
2404
2406
|
// NANO_REST_URL host when no REST token is set (see resolveAgenticConfig).
|
|
2405
2407
|
let token = env.NANO_REST_TOKEN || '';
|
|
2406
2408
|
if (!token) {
|
|
2407
|
-
const agenticToken = env.
|
|
2409
|
+
const agenticToken = env.NANO_AGENTIC_SECRET
|
|
2410
|
+
|| cfg.agenticSecret
|
|
2411
|
+
|| env.NANO_AGENTIC_TOKEN
|
|
2412
|
+
|| cfg.agenticToken
|
|
2413
|
+
|| '';
|
|
2408
2414
|
const agenticUrl =
|
|
2409
2415
|
env.NANO_AGENTIC_URL ||
|
|
2410
2416
|
cfg.agenticUrl ||
|
|
@@ -3860,19 +3866,24 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
|
|
|
3860
3866
|
* Local-first (security opt-in). Nano is designed for local use, so visibility is
|
|
3861
3867
|
* ON BY DEFAULT:
|
|
3862
3868
|
* - LOCAL mode (default): no credentials configured — the worker connects with
|
|
3863
|
-
* the well-known
|
|
3864
|
-
*
|
|
3865
|
-
*
|
|
3866
|
-
* - SECURE mode: set
|
|
3867
|
-
*
|
|
3868
|
-
*
|
|
3869
|
-
*
|
|
3869
|
+
* the well-known LOCAL token ({@link LOCAL_AGENTIC_TOKEN}) and no capability
|
|
3870
|
+
* credential, so it appears live with zero configuration (the hub honours this
|
|
3871
|
+
* well-known token from any origin on a trusted LAN).
|
|
3872
|
+
* - SECURE mode: set NANO_AGENTIC_SECRET — the SAME env var name and value the
|
|
3873
|
+
* server is started with (Tab A → Slot A). The worker presents it as its
|
|
3874
|
+
* identity token and the hub verifies it against its own NANO_AGENTIC_SECRET.
|
|
3875
|
+
* The legacy NANO_AGENTIC_TOKEN name (and persisted `agenticToken`) is still
|
|
3876
|
+
* accepted as a deprecated alias. The capability credential was removed from
|
|
3877
|
+
* the hub contract (it was accept-any, pure friction), so NANO_AGENTIC_CREDENTIAL
|
|
3878
|
+
* is OPTIONAL and forwarded only if still configured; a credential set WITHOUT a
|
|
3879
|
+
* secret is ignored (LOCAL mode).
|
|
3870
3880
|
* - OFF: NANO_AGENTIC=off (or 0/false/no), or persisted `agentic:false`.
|
|
3871
3881
|
*
|
|
3872
3882
|
* Env wins over persisted config; the base URL falls back to the configured nano
|
|
3873
|
-
* URL (the app's own port)
|
|
3883
|
+
* URL (the app's own port) and ultimately DEFAULT_NANO_URL, so it is never empty.
|
|
3884
|
+
* Returns `null` only when disabled (the off-switch).
|
|
3874
3885
|
*
|
|
3875
|
-
* @returns {{ url: string, token: string, credential: string, bufferCapacity: number, secure: boolean } | null}
|
|
3886
|
+
* @returns {{ url: string, token: string, credential: string, bufferCapacity: number, secure: boolean, explicitUrl: boolean } | null}
|
|
3876
3887
|
*/
|
|
3877
3888
|
function resolveAgenticConfig() {
|
|
3878
3889
|
const cfg = readConfig();
|
|
@@ -3891,8 +3902,16 @@ function resolveAgenticConfig() {
|
|
|
3891
3902
|
|| cfg.nanoUrl
|
|
3892
3903
|
|| process.env.NANO_BASE_URL
|
|
3893
3904
|
|| DEFAULT_NANO_URL;
|
|
3894
|
-
|
|
3895
|
-
|
|
3905
|
+
// SECURE-mode shared secret. Named NANO_AGENTIC_SECRET to match the server's env
|
|
3906
|
+
// var EXACTLY (Tab A → Slot A): set the same name + value on the server and every
|
|
3907
|
+
// worker box. The worker presents it as its identity token; the hub verifies it
|
|
3908
|
+
// against its own NANO_AGENTIC_SECRET. NANO_AGENTIC_TOKEN / `agenticToken` remain
|
|
3909
|
+
// as a deprecated alias.
|
|
3910
|
+
const secret = process.env.NANO_AGENTIC_SECRET
|
|
3911
|
+
|| cfg.agenticSecret
|
|
3912
|
+
|| process.env.NANO_AGENTIC_TOKEN
|
|
3913
|
+
|| cfg.agenticToken
|
|
3914
|
+
|| '';
|
|
3896
3915
|
const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
|
|
3897
3916
|
// Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
|
|
3898
3917
|
// long expected outage can be given more headroom; resolveBufferCapacity
|
|
@@ -3901,14 +3920,16 @@ function resolveAgenticConfig() {
|
|
|
3901
3920
|
process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
|
|
3902
3921
|
);
|
|
3903
3922
|
|
|
3904
|
-
// SECURE mode:
|
|
3905
|
-
//
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3923
|
+
// SECURE mode: an explicit shared secret means the operator opted into a real
|
|
3924
|
+
// per-peer secret. The capability credential was removed from the hub contract
|
|
3925
|
+
// (accept-any → pure friction), so it is OPTIONAL — forwarded only if still
|
|
3926
|
+
// configured. A credential set without a secret is meaningless and falls through
|
|
3927
|
+
// to LOCAL mode.
|
|
3928
|
+
if (secret) {
|
|
3929
|
+
return { url, token: secret, credential, bufferCapacity, secure: true, explicitUrl };
|
|
3909
3930
|
}
|
|
3910
3931
|
|
|
3911
|
-
// LOCAL mode (default): well-known
|
|
3932
|
+
// LOCAL mode (default): well-known token, no capability credential.
|
|
3912
3933
|
return { url, token: LOCAL_AGENTIC_TOKEN, credential: '', bufferCapacity, secure: false, explicitUrl };
|
|
3913
3934
|
}
|
|
3914
3935
|
|
|
@@ -4462,9 +4483,9 @@ async function workAgent(req, flags) {
|
|
|
4462
4483
|
// lifecycle events) rather than opening their own connection.
|
|
4463
4484
|
//
|
|
4464
4485
|
// Local-first (security opt-in): visibility is ON BY DEFAULT. In LOCAL mode the
|
|
4465
|
-
// worker joins with the well-known
|
|
4466
|
-
//
|
|
4467
|
-
//
|
|
4486
|
+
// worker joins with the well-known LOCAL token and no credential; SECURE mode
|
|
4487
|
+
// (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
|
|
4488
|
+
// NANO_AGENTIC=off disables it (see resolveAgenticConfig).
|
|
4468
4489
|
const agenticTarget = await resolveAgenticTarget({ logger });
|
|
4469
4490
|
let agenticCfg = null;
|
|
4470
4491
|
switch (agenticTarget.status) {
|
|
@@ -4482,7 +4503,7 @@ async function workAgent(req, flags) {
|
|
|
4482
4503
|
break;
|
|
4483
4504
|
case 'off':
|
|
4484
4505
|
default:
|
|
4485
|
-
logger.info(' agentic channel: disabled —
|
|
4506
|
+
logger.info(' agentic channel: disabled — the off-switch is set (NANO_AGENTIC=off or persisted agentic:false). Clear it to use default LOCAL visibility.');
|
|
4486
4507
|
break;
|
|
4487
4508
|
}
|
|
4488
4509
|
if (agenticCfg) {
|
|
@@ -5081,6 +5102,12 @@ const SUPERVISOR_MAX_FRAME_BYTES = 1 << 20; // 1 MiB
|
|
|
5081
5102
|
// live refresh (falling back to the attach-time snapshot + lifecycle events).
|
|
5082
5103
|
const SUPERVISOR_MONITOR_INTERVAL_MS = 1_000;
|
|
5083
5104
|
|
|
5105
|
+
// How often an *attached* console re-ages and repaints its pinned status block
|
|
5106
|
+
// locally, so UPTIME / job-age advance even while the fleet is quiet and the
|
|
5107
|
+
// daemon's change-gated push stays silent (issue #83). Pure client-side; no
|
|
5108
|
+
// extra daemon traffic.
|
|
5109
|
+
const SUPERVISOR_LIVE_TICK_MS = 5_000;
|
|
5110
|
+
|
|
5084
5111
|
// The `nano work` flags forwarded verbatim to each spawned child.
|
|
5085
5112
|
// kind: 'value' → `--flag v`; 'boolean' → `--flag`; 'list' → repeated `--flag v`.
|
|
5086
5113
|
const WORK_FORWARD_FLAGS = {
|
|
@@ -5343,10 +5370,16 @@ function formatDuration(ms) {
|
|
|
5343
5370
|
/** Project a live/stored worker record to a status row (pure w.r.t. `now`). */
|
|
5344
5371
|
function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
5345
5372
|
const alive = isPidAlive(w.pid);
|
|
5346
|
-
|
|
5373
|
+
// Absolute base epoch (ms) the worker started, so an attached console can
|
|
5374
|
+
// re-age `uptimeMs` locally on each tick (see reageSupervisorStatus) without
|
|
5375
|
+
// the daemon re-broadcasting. `uptimeMs` is the value at snapshot time; it and
|
|
5376
|
+
// `startedAtMs` are kept in lock-step here so a consumer can use either.
|
|
5377
|
+
const startedAtEpoch = alive && w.startedAt ? new Date(w.startedAt).getTime() : null;
|
|
5378
|
+
const startedAtMs = Number.isFinite(startedAtEpoch) ? startedAtEpoch : null;
|
|
5379
|
+
const uptimeMs = startedAtMs != null ? Math.max(0, now - startedAtMs) : 0;
|
|
5347
5380
|
// Per-job activity (supervised workers only). Guard on pid so a stale marker
|
|
5348
5381
|
// left by a previous incarnation can't show a dead job as in-flight.
|
|
5349
|
-
let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs }] }
|
|
5382
|
+
let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs, sinceEpochMs }] }
|
|
5350
5383
|
if (alive) {
|
|
5351
5384
|
const act = readWorkerActivity(w.id);
|
|
5352
5385
|
if (act && act.pid === w.pid) {
|
|
@@ -5354,7 +5387,10 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
5354
5387
|
? act.jobs.map((j) => ({
|
|
5355
5388
|
key: String(j.key),
|
|
5356
5389
|
type: j.type ?? null,
|
|
5390
|
+
// Both the snapshot-time duration and its absolute base, so the
|
|
5391
|
+
// console can re-age the job cell locally (mirrors uptimeMs above).
|
|
5357
5392
|
sinceMs: Number.isFinite(j.since) ? Math.max(0, now - j.since) : null,
|
|
5393
|
+
sinceEpochMs: Number.isFinite(j.since) ? j.since : null,
|
|
5358
5394
|
}))
|
|
5359
5395
|
: [];
|
|
5360
5396
|
activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
|
|
@@ -5368,6 +5404,7 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
5368
5404
|
state: w.stopping ? 'stopping' : alive ? 'running' : 'down',
|
|
5369
5405
|
restarts: Number(w.restarts) || 0,
|
|
5370
5406
|
uptimeMs,
|
|
5407
|
+
startedAtMs,
|
|
5371
5408
|
lastExit: w.lastExit ?? null,
|
|
5372
5409
|
args: Array.isArray(w.args) ? w.args : [],
|
|
5373
5410
|
activity,
|
|
@@ -5413,6 +5450,132 @@ function supervisorJobCell(w) {
|
|
|
5413
5450
|
return `${first.key}${more}${dur}`;
|
|
5414
5451
|
}
|
|
5415
5452
|
|
|
5453
|
+
/**
|
|
5454
|
+
* Re-age a supervisor status snapshot to `now`, recomputing the ticking
|
|
5455
|
+
* durations (`uptimeMs`, per-job `sinceMs`) from the absolute base epochs the
|
|
5456
|
+
* daemon includes (`startedAtMs`, `sinceEpochMs`). This lets an attached
|
|
5457
|
+
* console tick UPTIME / job-age locally on its own timer — no re-broadcast —
|
|
5458
|
+
* so a quiet-but-busy fleet's clocks still advance. Pure: returns a new object,
|
|
5459
|
+
* never mutates its input. Workers whose base epoch is absent (an older daemon,
|
|
5460
|
+
* or a down worker with no start) keep their snapshot-time value. Non-array /
|
|
5461
|
+
* non-object shapes pass through untouched, so it is safe on any frame.
|
|
5462
|
+
*/
|
|
5463
|
+
function reageSupervisorStatus(status, now = Date.now()) {
|
|
5464
|
+
if (!status || typeof status !== 'object') return status;
|
|
5465
|
+
const workers = Array.isArray(status.workers) ? status.workers : null;
|
|
5466
|
+
if (!workers) return status;
|
|
5467
|
+
return {
|
|
5468
|
+
...status,
|
|
5469
|
+
workers: workers.map((w) => {
|
|
5470
|
+
if (!w || typeof w !== 'object') return w;
|
|
5471
|
+
const uptimeMs =
|
|
5472
|
+
Number.isFinite(w.startedAtMs) ? Math.max(0, now - w.startedAtMs) : w.uptimeMs;
|
|
5473
|
+
let activity = w.activity;
|
|
5474
|
+
if (activity && Array.isArray(activity.jobs)) {
|
|
5475
|
+
activity = {
|
|
5476
|
+
...activity,
|
|
5477
|
+
jobs: activity.jobs.map((j) =>
|
|
5478
|
+
j && typeof j === 'object' && Number.isFinite(j.sinceEpochMs)
|
|
5479
|
+
? { ...j, sinceMs: Math.max(0, now - j.sinceEpochMs) }
|
|
5480
|
+
: j,
|
|
5481
|
+
),
|
|
5482
|
+
};
|
|
5483
|
+
}
|
|
5484
|
+
return { ...w, uptimeMs, activity };
|
|
5485
|
+
}),
|
|
5486
|
+
};
|
|
5487
|
+
}
|
|
5488
|
+
|
|
5489
|
+
/**
|
|
5490
|
+
* Clamp a line to `width` display columns, appending `…` when it is truncated,
|
|
5491
|
+
* so a pinned in-place status block keeps one logical line per terminal row —
|
|
5492
|
+
* the invariant the console's redraw cursor-math depends on (a wrapped row
|
|
5493
|
+
* would desync the up-count). `width < 1` or a non-finite width is treated as
|
|
5494
|
+
* "no clamp". Pure.
|
|
5495
|
+
*/
|
|
5496
|
+
function clampToWidth(line, width) {
|
|
5497
|
+
const s = String(line ?? '');
|
|
5498
|
+
if (!Number.isFinite(width) || width < 1 || s.length <= width) return s;
|
|
5499
|
+
if (width === 1) return '…';
|
|
5500
|
+
return s.slice(0, width - 1) + '…';
|
|
5501
|
+
}
|
|
5502
|
+
|
|
5503
|
+
/**
|
|
5504
|
+
* A pinned in-place status "block" for the attached `supervisor>` console
|
|
5505
|
+
* (issue #83). Instead of appending a fresh table on every change, it keeps a
|
|
5506
|
+
* single block just above the readline prompt and *mutates it in place*:
|
|
5507
|
+
* `status()` / `repaint()` erase the previously-painted rows (cursor up N +
|
|
5508
|
+
* clear-to-end) and redraw, so the table updates without scrolling a new copy
|
|
5509
|
+
* into the backlog. `write()` emits scrolling history (events, command replies)
|
|
5510
|
+
* above the block. Durations are re-aged to `now()` on every paint (see
|
|
5511
|
+
* reageSupervisorStatus) so a ~5s tick advances UPTIME / job-age with no
|
|
5512
|
+
* re-broadcast. On a non-TTY (`isTty === false`) there is no cursor addressing:
|
|
5513
|
+
* `status()` appends the table and `write()` is a plain line — the classic
|
|
5514
|
+
* behavior. Each block line is clamped to the terminal width so one logical
|
|
5515
|
+
* line is exactly one terminal row, keeping the erase cursor-math exact.
|
|
5516
|
+
*
|
|
5517
|
+
* Dependencies are injected (stream, columns getter, prompt refresh, clock) so
|
|
5518
|
+
* the render orchestration is unit-testable against a fake capturing stream.
|
|
5519
|
+
*/
|
|
5520
|
+
function createSupervisorLiveView({
|
|
5521
|
+
stream,
|
|
5522
|
+
isTty,
|
|
5523
|
+
columns = () => 80,
|
|
5524
|
+
refreshPrompt = () => {},
|
|
5525
|
+
now = () => Date.now(),
|
|
5526
|
+
}) {
|
|
5527
|
+
let lastStatus = null;
|
|
5528
|
+
let blockLines = 0; // terminal rows the block currently occupies
|
|
5529
|
+
|
|
5530
|
+
const blockText = () => {
|
|
5531
|
+
if (!lastStatus) return '';
|
|
5532
|
+
const cols = columns();
|
|
5533
|
+
return formatSupervisorStatus(reageSupervisorStatus(lastStatus, now()))
|
|
5534
|
+
.split('\n')
|
|
5535
|
+
.map((l) => clampToWidth(l, cols))
|
|
5536
|
+
.join('\n');
|
|
5537
|
+
};
|
|
5538
|
+
|
|
5539
|
+
// Erase the painted block (if any) + the prompt line, leaving the cursor at
|
|
5540
|
+
// column 0 on the row where the block should restart.
|
|
5541
|
+
const erase = () => {
|
|
5542
|
+
rlCursorTo(stream, 0);
|
|
5543
|
+
if (blockLines > 0) rlMoveCursor(stream, 0, -blockLines);
|
|
5544
|
+
rlClearScreenDown(stream);
|
|
5545
|
+
};
|
|
5546
|
+
|
|
5547
|
+
// Draw the (re-aged) block, then re-render the prompt below it.
|
|
5548
|
+
const draw = () => {
|
|
5549
|
+
const text = blockText();
|
|
5550
|
+
if (text) { stream.write(text + '\n'); blockLines = text.split('\n').length; }
|
|
5551
|
+
else blockLines = 0;
|
|
5552
|
+
refreshPrompt();
|
|
5553
|
+
};
|
|
5554
|
+
|
|
5555
|
+
return {
|
|
5556
|
+
/** New snapshot → mutate the block in place (TTY) or append it (non-TTY). */
|
|
5557
|
+
status(frame) {
|
|
5558
|
+
lastStatus = frame;
|
|
5559
|
+
if (isTty) { erase(); draw(); }
|
|
5560
|
+
else { stream.write('\n'); stream.write(formatSupervisorStatus(reageSupervisorStatus(frame, now())) + '\n'); }
|
|
5561
|
+
},
|
|
5562
|
+
/** Repaint the current snapshot re-aged to now (the ~5s tick / resize). */
|
|
5563
|
+
repaint() { if (isTty && lastStatus) { erase(); draw(); } },
|
|
5564
|
+
/** Scrolling history above the block; a plain append on a non-TTY. */
|
|
5565
|
+
write(text) {
|
|
5566
|
+
const s = String(text);
|
|
5567
|
+
if (!isTty) { stream.write(s + '\n'); return; }
|
|
5568
|
+
erase();
|
|
5569
|
+
stream.write(s + '\n');
|
|
5570
|
+
draw();
|
|
5571
|
+
},
|
|
5572
|
+
/** Rows the block currently occupies (test/inspection). */
|
|
5573
|
+
blockRows() { return blockLines; },
|
|
5574
|
+
/** Whether a snapshot has been received (test/inspection). */
|
|
5575
|
+
hasStatus() { return lastStatus != null; },
|
|
5576
|
+
};
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5416
5579
|
/** Render a supervisor status object as an aligned text table. */
|
|
5417
5580
|
function formatSupervisorStatus(status) {
|
|
5418
5581
|
const lines = [];
|
|
@@ -6324,13 +6487,27 @@ async function attachSupervisorConsole(state) {
|
|
|
6324
6487
|
process.exit(1);
|
|
6325
6488
|
}
|
|
6326
6489
|
|
|
6327
|
-
const
|
|
6490
|
+
const outStream = process.stdout;
|
|
6491
|
+
// The pinned in-place block needs cursor addressing; on a non-TTY / dumb
|
|
6492
|
+
// terminal we fall back to the classic append-and-scroll behavior.
|
|
6493
|
+
const isTty = !!outStream.isTTY && process.env.TERM !== 'dumb';
|
|
6494
|
+
|
|
6495
|
+
let rl = null;
|
|
6496
|
+
const termCols = () => (Number.isFinite(outStream.columns) ? outStream.columns : 80);
|
|
6497
|
+
const view = createSupervisorLiveView({
|
|
6498
|
+
stream: outStream,
|
|
6499
|
+
isTty,
|
|
6500
|
+
columns: termCols,
|
|
6501
|
+
refreshPrompt: () => { if (rl) { try { rl.prompt(true); } catch { /* ignore */ } } },
|
|
6502
|
+
});
|
|
6503
|
+
// All scrolling output (intro, events, command replies) goes ABOVE the block.
|
|
6504
|
+
const out = (s) => view.write(s);
|
|
6505
|
+
|
|
6328
6506
|
out('Attached to nano worker supervisor. Type "help" for commands.');
|
|
6329
6507
|
out('Detach (leave it running) with "detach" or Ctrl-D; tear it down with "stop".');
|
|
6330
6508
|
sock.write(encodeFrame({ op: 'attach' }));
|
|
6331
6509
|
|
|
6332
6510
|
let buf = '';
|
|
6333
|
-
let rl = null;
|
|
6334
6511
|
sock.on('data', (chunk) => {
|
|
6335
6512
|
buf += chunk;
|
|
6336
6513
|
const { frames, rest } = decodeFrames(buf);
|
|
@@ -6338,8 +6515,9 @@ async function attachSupervisorConsole(state) {
|
|
|
6338
6515
|
if (frames.length === 0) return;
|
|
6339
6516
|
for (const frame of frames) {
|
|
6340
6517
|
if (frame.type === 'status') {
|
|
6341
|
-
|
|
6342
|
-
|
|
6518
|
+
// Mutate the pinned block in place (TTY) or append (non-TTY) — never a
|
|
6519
|
+
// reprinted table stacking up in the scrollback.
|
|
6520
|
+
view.status(frame);
|
|
6343
6521
|
} else if (frame.type === 'event') {
|
|
6344
6522
|
const w = frame.worker;
|
|
6345
6523
|
if (frame.event === 'worker-start') out(`• worker ${w.id} started (pid ${w.pid}).`);
|
|
@@ -6356,18 +6534,37 @@ async function attachSupervisorConsole(state) {
|
|
|
6356
6534
|
out(`! ${frame.error}`);
|
|
6357
6535
|
}
|
|
6358
6536
|
}
|
|
6359
|
-
//
|
|
6360
|
-
//
|
|
6361
|
-
//
|
|
6362
|
-
|
|
6537
|
+
// Every frame above routes through view.status() or view.write(), each of
|
|
6538
|
+
// which already erases+redraws the pinned block and refreshes the prompt in
|
|
6539
|
+
// TTY mode — so an extra repaint here would just duplicate that work (and
|
|
6540
|
+
// flicker at the ~1s monitor cadence). On a non-TTY there is no block to
|
|
6541
|
+
// redraw, so only nudge the prompt so an async push doesn't leave the input
|
|
6542
|
+
// line half-rendered.
|
|
6543
|
+
if (!isTty && rl) { try { rl.prompt(true); } catch { /* ignore */ } }
|
|
6363
6544
|
});
|
|
6364
6545
|
|
|
6365
6546
|
rl = createReadline({ input: process.stdin, output: process.stdout, prompt: 'supervisor> ' });
|
|
6547
|
+
|
|
6548
|
+
// Local ~5s tick: re-age the block so UPTIME / job-age advance even when the
|
|
6549
|
+
// fleet is quiet (the daemon's change-gated push stays silent). And reflow the
|
|
6550
|
+
// block on terminal resize so a narrower window re-clamps cleanly.
|
|
6551
|
+
let tickTimer = null;
|
|
6552
|
+
let onResize = null;
|
|
6553
|
+
if (isTty) {
|
|
6554
|
+
tickTimer = setInterval(() => view.repaint(), SUPERVISOR_LIVE_TICK_MS);
|
|
6555
|
+
if (typeof tickTimer.unref === 'function') tickTimer.unref();
|
|
6556
|
+
onResize = () => view.repaint();
|
|
6557
|
+
outStream.on('resize', onResize);
|
|
6558
|
+
}
|
|
6366
6559
|
rl.prompt();
|
|
6367
6560
|
|
|
6368
6561
|
await new Promise((resolve) => {
|
|
6369
6562
|
let stopping = false;
|
|
6370
|
-
const finish = () => {
|
|
6563
|
+
const finish = () => {
|
|
6564
|
+
if (tickTimer) { try { clearInterval(tickTimer); } catch { /* ignore */ } tickTimer = null; }
|
|
6565
|
+
if (onResize) { try { outStream.off('resize', onResize); } catch { /* ignore */ } onResize = null; }
|
|
6566
|
+
try { rl.close(); } catch { /* ignore */ } try { sock.end(); } catch { /* ignore */ } resolve();
|
|
6567
|
+
};
|
|
6371
6568
|
|
|
6372
6569
|
sock.on('close', () => { if (!stopping) out('\nSupervisor connection closed.'); finish(); });
|
|
6373
6570
|
|
|
@@ -7304,6 +7501,158 @@ async function downloadProcessosBinary(url, dest) {
|
|
|
7304
7501
|
return dest;
|
|
7305
7502
|
}
|
|
7306
7503
|
|
|
7504
|
+
// --- Pre-upgrade read-model backup -----------------------------------------
|
|
7505
|
+
// A schema-changing gateway release can reproject the SQLite read model and
|
|
7506
|
+
// silently drop completed process-instance history (see nano-bpm#831). Until
|
|
7507
|
+
// the engine ships non-destructive migrations, the launcher is the last line of
|
|
7508
|
+
// defence: before we swap the binary for a different version, snapshot each
|
|
7509
|
+
// per-node read model so the pre-upgrade state is always recoverable.
|
|
7510
|
+
|
|
7511
|
+
/** How many prior pre-upgrade backups to keep per node (bounds disk use). */
|
|
7512
|
+
const READ_MODEL_BACKUP_RING = 5;
|
|
7513
|
+
|
|
7514
|
+
/** Filesystem-safe tag for the version being replaced (for the backup name). */
|
|
7515
|
+
function sanitizeVersionTag(v) {
|
|
7516
|
+
const s = String(v ?? '').trim() || 'unknown';
|
|
7517
|
+
return s.replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
7518
|
+
}
|
|
7519
|
+
|
|
7520
|
+
/**
|
|
7521
|
+
* Copy `src` to `dest` when it exists; best-effort (sidecars may be absent).
|
|
7522
|
+
* A genuinely absent sidecar is fine and stays silent, but a sidecar that
|
|
7523
|
+
* exists yet cannot be copied (permissions/lock) is logged as a warning so an
|
|
7524
|
+
* incomplete backup never passes unnoticed — a missing/locked sidecar must
|
|
7525
|
+
* still never fail the backup itself.
|
|
7526
|
+
*/
|
|
7527
|
+
function copyIfExists(src, dest, logger) {
|
|
7528
|
+
if (!existsSync(src)) return false;
|
|
7529
|
+
try {
|
|
7530
|
+
copyFileSync(src, dest);
|
|
7531
|
+
return true;
|
|
7532
|
+
} catch (err) {
|
|
7533
|
+
logger?.warn?.(
|
|
7534
|
+
`Read-model backup: could not copy ${src} (continuing): ${err?.message ?? err}`,
|
|
7535
|
+
);
|
|
7536
|
+
}
|
|
7537
|
+
return false;
|
|
7538
|
+
}
|
|
7539
|
+
|
|
7540
|
+
/**
|
|
7541
|
+
* Snapshot every per-node read model before an upgrade swaps the gateway
|
|
7542
|
+
* binary. For each `<dataDir>/node-*` that has a `read-model.sqlite`, copy it —
|
|
7543
|
+
* plus its `-wal` sidecar (WAL mode keeps uncheckpointed pages there) and the
|
|
7544
|
+
* `-shm` shared-memory index — to a timestamped file under a
|
|
7545
|
+
* `read-model-backups/` subdir. We also grab the coherent point-in-time set
|
|
7546
|
+
* (`snapshot.*.bin` + `journal.head`) when present, and prune to a bounded ring
|
|
7547
|
+
* of prior backups. Best-effort: any failure is logged and swallowed so it can
|
|
7548
|
+
* never block the upgrade itself.
|
|
7549
|
+
*
|
|
7550
|
+
* @param {string|null} oldVersion version being replaced (used in the filename)
|
|
7551
|
+
* @param {number} ring prior backups to keep per node
|
|
7552
|
+
* @returns {string[]} paths of the primary `.sqlite` copies written
|
|
7553
|
+
*/
|
|
7554
|
+
function backupReadModelsBeforeUpgrade(oldVersion, ring = READ_MODEL_BACKUP_RING) {
|
|
7555
|
+
const logger = getLogger();
|
|
7556
|
+
const dataDir = getDataDir();
|
|
7557
|
+
const written = [];
|
|
7558
|
+
|
|
7559
|
+
let nodeDirs;
|
|
7560
|
+
try {
|
|
7561
|
+
nodeDirs = readdirSync(dataDir, { withFileTypes: true })
|
|
7562
|
+
.filter((d) => d.isDirectory() && d.name.startsWith('node-'))
|
|
7563
|
+
.map((d) => d.name);
|
|
7564
|
+
} catch {
|
|
7565
|
+
return written; // no data dir yet — nothing to back up
|
|
7566
|
+
}
|
|
7567
|
+
|
|
7568
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
7569
|
+
// Append a short random token so two backup runs landing in the same
|
|
7570
|
+
// millisecond (parallel starts, or a retry loop) can't derive an identical
|
|
7571
|
+
// stem and clobber each other's pre-upgrade set.
|
|
7572
|
+
const uniq = randomBytes(3).toString('hex');
|
|
7573
|
+
const verTag = sanitizeVersionTag(oldVersion);
|
|
7574
|
+
|
|
7575
|
+
for (const node of nodeDirs) {
|
|
7576
|
+
const nodeDir = join(dataDir, node);
|
|
7577
|
+
const readModel = join(nodeDir, 'read-model.sqlite');
|
|
7578
|
+
if (!existsSync(readModel)) continue; // in-memory node, or never projected
|
|
7579
|
+
|
|
7580
|
+
const backupDir = join(nodeDir, 'read-model-backups');
|
|
7581
|
+
const stem = `read-model.pre-upgrade-${verTag}-${ts}-${uniq}`;
|
|
7582
|
+
try {
|
|
7583
|
+
mkdirSync(backupDir, { recursive: true });
|
|
7584
|
+
|
|
7585
|
+
// Primary DB + its WAL/SHM sidecars (uncheckpointed pages live in -wal).
|
|
7586
|
+
const destSqlite = join(backupDir, `${stem}.sqlite`);
|
|
7587
|
+
copyFileSync(readModel, destSqlite);
|
|
7588
|
+
written.push(destSqlite);
|
|
7589
|
+
copyIfExists(join(nodeDir, 'read-model.sqlite-wal'), join(backupDir, `${stem}.sqlite-wal`), logger);
|
|
7590
|
+
copyIfExists(join(nodeDir, 'read-model.sqlite-shm'), join(backupDir, `${stem}.sqlite-shm`), logger);
|
|
7591
|
+
|
|
7592
|
+
// Coherent point-in-time set: journal head + all snapshot bins.
|
|
7593
|
+
copyIfExists(join(nodeDir, 'journal.head'), join(backupDir, `${stem}.journal.head`), logger);
|
|
7594
|
+
for (const f of readdirSync(nodeDir)) {
|
|
7595
|
+
if (/^snapshot\..*\.bin$/.test(f)) {
|
|
7596
|
+
copyIfExists(join(nodeDir, f), join(backupDir, `${stem}.${f}`), logger);
|
|
7597
|
+
}
|
|
7598
|
+
}
|
|
7599
|
+
|
|
7600
|
+
logger.info(`Backed up read model before upgrade: ${destSqlite}`);
|
|
7601
|
+
pruneReadModelBackups(backupDir, ring, logger);
|
|
7602
|
+
} catch (err) {
|
|
7603
|
+
logger.warn(
|
|
7604
|
+
`Read-model backup for ${node} failed (continuing upgrade): ${err?.message ?? err}`,
|
|
7605
|
+
);
|
|
7606
|
+
}
|
|
7607
|
+
}
|
|
7608
|
+
return written;
|
|
7609
|
+
}
|
|
7610
|
+
|
|
7611
|
+
/**
|
|
7612
|
+
* Keep only the newest `ring` pre-upgrade backup sets in `backupDir`, deleting
|
|
7613
|
+
* the oldest. A "set" is all files sharing a `read-model.pre-upgrade-*` stem
|
|
7614
|
+
* (the `.sqlite` plus its `-wal`/`-shm`/`journal.head`/`snapshot.*` siblings),
|
|
7615
|
+
* identified by the primary `.sqlite` file and ordered by its mtime.
|
|
7616
|
+
*/
|
|
7617
|
+
function pruneReadModelBackups(backupDir, ring = READ_MODEL_BACKUP_RING, logger = getLogger()) {
|
|
7618
|
+
if (!(ring > 0)) return;
|
|
7619
|
+
let entries;
|
|
7620
|
+
try {
|
|
7621
|
+
entries = readdirSync(backupDir);
|
|
7622
|
+
} catch {
|
|
7623
|
+
return;
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
const stems = entries
|
|
7627
|
+
.filter((f) => f.startsWith('read-model.pre-upgrade-') && f.endsWith('.sqlite'))
|
|
7628
|
+
.map((f) => f.slice(0, -'.sqlite'.length));
|
|
7629
|
+
if (stems.length <= ring) return;
|
|
7630
|
+
|
|
7631
|
+
const withTime = stems.map((stem) => {
|
|
7632
|
+
let mtime = 0;
|
|
7633
|
+
try {
|
|
7634
|
+
mtime = statSync(join(backupDir, `${stem}.sqlite`)).mtimeMs;
|
|
7635
|
+
} catch {
|
|
7636
|
+
/* fall back to 0 so an unreadable set sorts oldest and is dropped first */
|
|
7637
|
+
}
|
|
7638
|
+
return { stem, mtime };
|
|
7639
|
+
});
|
|
7640
|
+
withTime.sort((a, b) => a.mtime - b.mtime);
|
|
7641
|
+
|
|
7642
|
+
for (const { stem } of withTime.slice(0, withTime.length - ring)) {
|
|
7643
|
+
for (const f of entries) {
|
|
7644
|
+
if (f === `${stem}.sqlite` || f.startsWith(`${stem}.`)) {
|
|
7645
|
+
try {
|
|
7646
|
+
rmSync(join(backupDir, f), { force: true });
|
|
7647
|
+
} catch {
|
|
7648
|
+
/* best-effort prune */
|
|
7649
|
+
}
|
|
7650
|
+
}
|
|
7651
|
+
}
|
|
7652
|
+
logger.info(`Pruned old read-model backup set: ${join(backupDir, stem)}.*`);
|
|
7653
|
+
}
|
|
7654
|
+
}
|
|
7655
|
+
|
|
7307
7656
|
/**
|
|
7308
7657
|
* Resolve the ProcessOS binary to run, downloading it on demand when the user
|
|
7309
7658
|
* has a PROCESSOS_DOWNLOAD_URL but no local copy yet. Resolution:
|
|
@@ -7335,6 +7684,19 @@ async function resolveProcessosBinary(req) {
|
|
|
7335
7684
|
if (haveCached && remoteVer) {
|
|
7336
7685
|
logger.info(`Updating ProcessOS ${haveVer ?? '?'} -> ${remoteVer} ...`);
|
|
7337
7686
|
}
|
|
7687
|
+
// Before swapping the binary for a different version, snapshot each
|
|
7688
|
+
// node's read model so a schema-changing release can never silently
|
|
7689
|
+
// destroy completed-instance history (issue #85). First download of a
|
|
7690
|
+
// fresh install (no cached copy) is not an upgrade, so it is skipped.
|
|
7691
|
+
if (haveCached) {
|
|
7692
|
+
try {
|
|
7693
|
+
backupReadModelsBeforeUpgrade(haveVer);
|
|
7694
|
+
} catch (err) {
|
|
7695
|
+
logger.warn(
|
|
7696
|
+
`Pre-upgrade read-model backup failed (continuing upgrade): ${err?.message ?? err}`,
|
|
7697
|
+
);
|
|
7698
|
+
}
|
|
7699
|
+
}
|
|
7338
7700
|
await downloadProcessosBinary(processosBinaryUrl(dlUrl), cached);
|
|
7339
7701
|
// Record what we fetched so the update notifier/status can compare later.
|
|
7340
7702
|
try {
|
|
@@ -7973,6 +8335,12 @@ function parseProcessosRequest(args, flags) {
|
|
|
7973
8335
|
// Internal helpers exported for tests/tooling only. c8ctl consumes just
|
|
7974
8336
|
// `metadata` and `commands`; these named exports are inert to it.
|
|
7975
8337
|
export { resolveBinary, findBinary, launcherEnvMarkers };
|
|
8338
|
+
export {
|
|
8339
|
+
backupReadModelsBeforeUpgrade,
|
|
8340
|
+
pruneReadModelBackups,
|
|
8341
|
+
sanitizeVersionTag,
|
|
8342
|
+
READ_MODEL_BACKUP_RING,
|
|
8343
|
+
};
|
|
7976
8344
|
export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
|
|
7977
8345
|
export { buildNpmInvocation };
|
|
7978
8346
|
export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
|
|
@@ -8069,6 +8437,9 @@ export {
|
|
|
8069
8437
|
formatDuration,
|
|
8070
8438
|
summarizeSupervisorWorker,
|
|
8071
8439
|
formatSupervisorStatus,
|
|
8440
|
+
reageSupervisorStatus,
|
|
8441
|
+
clampToWidth,
|
|
8442
|
+
createSupervisorLiveView,
|
|
8072
8443
|
printSupervisorStatus,
|
|
8073
8444
|
supervisorStatusSignature,
|
|
8074
8445
|
supervisorJobCell,
|
|
@@ -8129,7 +8500,7 @@ export const metadata = {
|
|
|
8129
8500
|
{ command: 'c8ctl nano work coder --auto', description: 'Zero-config: serve every deployed agent job type read straight from the engine — no capability, no wiring (great for a local single-tenant plane)' },
|
|
8130
8501
|
{ command: 'c8ctl nano work coder --auto --auto-scope my-app', description: 'Zero-config, scoped to one app: serve only agent job types deployed under process ids prefixed "my-app"' },
|
|
8131
8502
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
8132
|
-
{ command: 'NANO_AGENTIC_URL=http://localhost:8080
|
|
8503
|
+
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
8133
8504
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
8134
8505
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
8135
8506
|
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.4",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.4",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.4",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.4",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.4",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.4",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.4"
|
|
67
67
|
}
|
|
68
68
|
}
|