c8ctl-plugin-nano 1.35.3 → 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 +224 -32
  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,
@@ -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) {
@@ -7480,6 +7501,158 @@ async function downloadProcessosBinary(url, dest) {
7480
7501
  return dest;
7481
7502
  }
7482
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
+
7483
7656
  /**
7484
7657
  * Resolve the ProcessOS binary to run, downloading it on demand when the user
7485
7658
  * has a PROCESSOS_DOWNLOAD_URL but no local copy yet. Resolution:
@@ -7511,6 +7684,19 @@ async function resolveProcessosBinary(req) {
7511
7684
  if (haveCached && remoteVer) {
7512
7685
  logger.info(`Updating ProcessOS ${haveVer ?? '?'} -> ${remoteVer} ...`);
7513
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
+ }
7514
7700
  await downloadProcessosBinary(processosBinaryUrl(dlUrl), cached);
7515
7701
  // Record what we fetched so the update notifier/status can compare later.
7516
7702
  try {
@@ -8149,6 +8335,12 @@ function parseProcessosRequest(args, flags) {
8149
8335
  // Internal helpers exported for tests/tooling only. c8ctl consumes just
8150
8336
  // `metadata` and `commands`; these named exports are inert to it.
8151
8337
  export { resolveBinary, findBinary, launcherEnvMarkers };
8338
+ export {
8339
+ backupReadModelsBeforeUpgrade,
8340
+ pruneReadModelBackups,
8341
+ sanitizeVersionTag,
8342
+ READ_MODEL_BACKUP_RING,
8343
+ };
8152
8344
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
8153
8345
  export { buildNpmInvocation };
8154
8346
  export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
@@ -8308,7 +8500,7 @@ export const metadata = {
8308
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)' },
8309
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"' },
8310
8502
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
8311
- { 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' },
8312
8504
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
8313
8505
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
8314
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",
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.3",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.3",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.3",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.3",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.3",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.3",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.3"
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
  }