c8ctl-plugin-nano 1.35.3 → 1.35.5
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 +224 -32
- package/nanobpmn-binary.json +3 -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 (
|
|
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,
|
|
@@ -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) {
|
|
@@ -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
|
|
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/nanobpmn-binary.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.5",
|
|
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.5",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.5",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.5",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.5",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.5",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.5",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.5"
|
|
67
67
|
}
|
|
68
68
|
}
|