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.
Files changed (3) hide show
  1. package/README.md +75 -16
  2. package/c8ctl-plugin.js +415 -44
  3. 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 (local-first).** Nano is designed for local use, so
278
- visibility is **on by default**. Run a worker against a local app and it appears
279
- live with **zero configuration** — it joins the channel with a well-known
280
- localhost token in **LOCAL mode** (no credential). The worker presents this
281
- token to whatever `NANO_AGENTIC_URL` you point it at; the same-machine
282
- restriction is enforced by the **hub**, which only honours the well-known LOCAL
283
- token for local/loopback connections:
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 shared/remote deployment, enrol the worker with an
322
- ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
323
- pattern the blackboard uses). Setting either switches the worker into SECURE mode,
324
- which requires **both** (fail closed if only one is set):
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: real enrolment
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 NANO_AGENTIC_TOKEN=<identity-token> # ADR 0028 identity
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 valid identity + capability connects; an invalid identity is rejected
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: on the operator's own machine a `nano work` worker joins the visibility channel with
140
- // zero configuration, so it presents this constant, well-known localhost token — NOT a secret. The
141
- // worker does not enforce any loopback restriction itself (it presents this token to whatever
142
- // NANO_AGENTIC_URL is configured); same-machine gating is enforced by the hub, which only honours
143
- // this well-known token for local/loopback connections. Kept in lock-step with the hub constant in
144
- // nanobpm/nano-workforce (`app/agentic/channel.ts` LOCAL_AGENTIC_TOKEN). In secure mode (a real
145
- // NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL) this is never used.
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 token is only a
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 token to a different
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.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
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 localhost token ({@link LOCAL_AGENTIC_TOKEN}) and no
3864
- * capability credential, so it appears live with zero configuration (the hub's
3865
- * matching LOCAL mode accepts it).
3866
- * - SECURE mode: set NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL (or the
3867
- * persisted `agenticToken`/`agenticCredential`) an ADR 0028 identity token
3868
- * AND a capability credential are then sent (enrolment). If only one is set the
3869
- * config is incomplete and we stay off (fail closed), returning `null`.
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). Returns `null` only when disabled or half-configured.
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
- if (!url) return null;
3895
- const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
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: any explicit credential configured means the operator opted into
3905
- // enrolment require BOTH halves, fail closed if only one is present.
3906
- if (token || credential) {
3907
- if (!token || !credential) return null;
3908
- return { url, token, credential, bufferCapacity, secure: true, explicitUrl };
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 localhost token, no capability credential.
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 localhost token and no credential; SECURE
4466
- // mode (NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL) sends a real ADR 0028
4467
- // identity + capability; NANO_AGENTIC=off disables it (see resolveAgenticConfig).
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 — either the off-switch is set (NANO_AGENTIC=off or persisted agentic:false), or SECURE mode is half-configured (set BOTH NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL). Clear the off-switch to use default LOCAL visibility.');
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
- const uptimeMs = alive && w.startedAt ? Math.max(0, now - new Date(w.startedAt).getTime()) : 0;
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 out = (s) => process.stdout.write(s + '\n');
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
- out('');
6342
- out(formatSupervisorStatus(frame));
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
- // A pushed frame writes straight to stdout, stepping on the readline prompt
6360
- // and any half-typed command. Re-render the prompt (preserving the input
6361
- // buffer) so an async live-view refresh doesn't corrupt what the user typed.
6362
- if (rl) { try { rl.prompt(true); } catch { /* ignore */ } }
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 = () => { try { rl.close(); } catch { /* ignore */ } try { sock.end(); } catch { /* ignore */ } resolve(); };
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 NANO_AGENTIC_TOKEN=<identity-token> NANO_AGENTIC_CREDENTIAL=<capability-cred> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel so it appears live (presence + relay terminals) on the Workforce visibility page' },
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.2",
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.2",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.2",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.2",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.2",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.2",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.2",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.2"
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
  }