@cotal-ai/cli 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/command.d.ts.map +1 -1
  2. package/dist/command.js +4 -0
  3. package/dist/command.js.map +1 -1
  4. package/dist/commands/describe.d.ts.map +1 -1
  5. package/dist/commands/describe.js +19 -11
  6. package/dist/commands/describe.js.map +1 -1
  7. package/dist/commands/down.d.ts +3 -0
  8. package/dist/commands/down.d.ts.map +1 -1
  9. package/dist/commands/down.js +24 -18
  10. package/dist/commands/down.js.map +1 -1
  11. package/dist/commands/meshes.d.ts +4 -0
  12. package/dist/commands/meshes.d.ts.map +1 -1
  13. package/dist/commands/meshes.js +45 -9
  14. package/dist/commands/meshes.js.map +1 -1
  15. package/dist/commands/setup.d.ts +1 -1
  16. package/dist/commands/setup.d.ts.map +1 -1
  17. package/dist/commands/setup.js +9 -2
  18. package/dist/commands/setup.js.map +1 -1
  19. package/dist/commands/spawn.d.ts +16 -1
  20. package/dist/commands/spawn.d.ts.map +1 -1
  21. package/dist/commands/spawn.js +206 -44
  22. package/dist/commands/spawn.js.map +1 -1
  23. package/dist/commands/status.d.ts.map +1 -1
  24. package/dist/commands/status.js +3 -2
  25. package/dist/commands/status.js.map +1 -1
  26. package/dist/commands/up.d.ts.map +1 -1
  27. package/dist/commands/up.js +84 -20
  28. package/dist/commands/up.js.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/lib/control.d.ts +13 -1
  32. package/dist/lib/control.d.ts.map +1 -1
  33. package/dist/lib/control.js +45 -9
  34. package/dist/lib/control.js.map +1 -1
  35. package/dist/lib/delivery-proc.d.ts +10 -1
  36. package/dist/lib/delivery-proc.d.ts.map +1 -1
  37. package/dist/lib/delivery-proc.js +19 -0
  38. package/dist/lib/delivery-proc.js.map +1 -1
  39. package/dist/lib/teardown-spare.d.ts +7 -3
  40. package/dist/lib/teardown-spare.d.ts.map +1 -1
  41. package/dist/lib/teardown-spare.js +22 -10
  42. package/dist/lib/teardown-spare.js.map +1 -1
  43. package/dist/managed-handoff.d.ts +10 -0
  44. package/dist/managed-handoff.d.ts.map +1 -0
  45. package/dist/managed-handoff.js +42 -0
  46. package/dist/managed-handoff.js.map +1 -0
  47. package/dist/seed/reconcile.d.ts.map +1 -1
  48. package/dist/seed/reconcile.js +4 -1
  49. package/dist/seed/reconcile.js.map +1 -1
  50. package/dist/seed/store.d.ts.map +1 -1
  51. package/dist/seed/store.js +5 -7
  52. package/dist/seed/store.js.map +1 -1
  53. package/package.json +4 -4
@@ -4,7 +4,7 @@ import { createConnection, createServer } from "node:net";
4
4
  import { hostname } from "node:os";
5
5
  import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, readdirSync, } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
- import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, ttlBuckets, standaloneConnectOpts, requireBrokerFloor, parseServerVersion, presenceBucket, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
7
+ import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, ttlBuckets, standaloneConnectOpts, requireBrokerFloor, parseServerVersion, presenceBucket, seedChannelRegistry, ensureDefaultDeliveryClass, LEASE_TTL_MS, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
8
8
  import { connect } from "@nats-io/transport-node";
9
9
  import { jetstreamManager, StorageType } from "@nats-io/jetstream";
10
10
  import { Kvm } from "@nats-io/kv";
@@ -17,7 +17,7 @@ import { resolveNatsServer } from "../lib/nats-bin.js";
17
17
  import { cotalPath, cotalRoot } from "../lib/paths.js";
18
18
  import { renderDetachedSummary } from "../lib/up-report.js";
19
19
  import { detachedSystemdSupervisionWarning } from "../lib/systemd-supervision.js";
20
- import { deliveryUp, ensureControlPlane, stopDelivery } from "../lib/delivery-proc.js";
20
+ import { deliveryStoppedByDown, deliveryUp, ensureControlPlane, ensureDelivery, stopDelivery } from "../lib/delivery-proc.js";
21
21
  import { RESPONDER_UNBOUND_CONSEQUENCE } from "../lib/delivery-responder.js";
22
22
  import { displayCmd } from "../lib/self-exec.js";
23
23
  import { liveManagerWouldApplyMaxSessions, managerHasDeliveryMarker, managerLogDisplayPath, managerRecordState, managerUp, stopManager } from "../lib/manager-proc.js";
@@ -26,6 +26,7 @@ import { loadManifest } from "../lib/manifest/index.js";
26
26
  import { buildLaunchSpec, genRunId, manifestToChannels, preflightConnectors, writeLaunchSpec } from "../lib/manifest/apply.js";
27
27
  import { renderUpPlan, renderInherited, renderWarnings } from "../lib/manifest/render.js";
28
28
  import { failManifest } from "./topology.js";
29
+ import { reserveStop } from "./down.js";
29
30
  import { extensionNames, preflightRuntime } from "../ext-loader.js";
30
31
  import { completingFlagValue } from "../lib/completion.js";
31
32
  import { askManager } from "../lib/control.js";
@@ -808,7 +809,7 @@ async function runUp(args, inheritedLock, onAdopt) {
808
809
  // failure copy says "restart it with `cotal up`"), so a refresh must re-ensure the service —
809
810
  // never just reprint "already running" over a dead callout. No broker config is (re)written
810
811
  // here, so healing on a bare `cotal up` is safe: the mode can't drift, only the daemon heals.
811
- let userAuth = held.userAuth;
812
+ let userAuth;
812
813
  if (held.mode === "user") {
813
814
  const auth = await getSpaceAuth(workspaceSecretStore(root), held.space);
814
815
  if (!auth) {
@@ -868,15 +869,11 @@ async function runUp(args, inheritedLock, onAdopt) {
868
869
  if (!controlPlane)
869
870
  process.exitCode = 1;
870
871
  }
871
- const heldAttachHost = attachHostFor(held.space, values.host);
872
- const heldMaxSessions = maxSessionsFor(held.space, maxSessions);
873
872
  // A broker was already answering here — this branch starts nothing, so it must not claim the
874
- // record as ours (see `Provenance`).
875
- // `tlsRequired` is CARRIED FORWARD, not re-derived. This branch starts no listener, so it has
876
- // no transport decision to record — and `recordOurMesh` writes the entry whole, so omitting
877
- // the field here would erase the requirement on every bare refresh, exactly the way dropping
878
- // `attachHost` would silently demote the mesh to loopback.
879
- recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(held.tlsRequired !== undefined ? { tlsRequired: held.tlsRequired } : {}), ...(userAuth ? { userAuth } : {}), ...(heldAttachHost ? { attachHost: heldAttachHost } : {}), ...(heldMaxSessions !== undefined ? { maxSessions: heldMaxSessions } : {}), ...(held.maxFileStore !== undefined ? { maxFileStore: held.maxFileStore } : {}), ...(held.storeDir !== undefined ? { storeDir: held.storeDir } : {}), ts: new Date().toISOString() }, "refresh");
873
+ // record as ours (see `Provenance`), and it passes only what this invocation decided.
874
+ // `recordOurMesh` carries everything else (`tlsRequired`, the recorded host and session
875
+ // ceiling, the store) from the record as it stands at write time, not from `held`.
876
+ recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(userAuth ? { userAuth } : {}), ...(values.host ? { attachHost: values.host } : {}), ...(maxSessions !== undefined ? { maxSessions } : {}), ts: new Date().toISOString() }, "refresh");
880
877
  return;
881
878
  }
882
879
  const who = held ? `mesh "${held.space}" (${held.root})` : "a broker not started here";
@@ -1047,8 +1044,11 @@ async function runUp(args, inheritedLock, onAdopt) {
1047
1044
  void (async () => {
1048
1045
  const managerContext = { root: cotalRoot(), space };
1049
1046
  let spared;
1047
+ let spareSeats;
1050
1048
  let legacyManagerSpareUnverified = false;
1051
1049
  const managerPidPath = localProcessPath(MANAGER_PIDFILE, managerContext);
1050
+ const stopMarker = `${managerPidPath}.stopping`;
1051
+ let reserved = false;
1052
1052
  if (existsSync(managerPidPath)) {
1053
1053
  const pin = verifyIdentityPin(managerPidPath);
1054
1054
  if (pin.kind === "legacy")
@@ -1056,7 +1056,12 @@ async function runUp(args, inheritedLock, onAdopt) {
1056
1056
  else if (pin.kind === "match") {
1057
1057
  spared = await listManagerSeatsForSpare(managerContext);
1058
1058
  try {
1059
- assertManagerCanSpare(managerContext, undefined, pin.record);
1059
+ // Taken before the capability read and held until the manager is gone, as bare `down`
1060
+ // holds it, so a concurrent `cotal down` can neither stop this manager nor arm a reap
1061
+ // while this stop is in flight.
1062
+ reserveStop("manager", stopMarker);
1063
+ reserved = true;
1064
+ spareSeats = assertManagerCanSpare(managerContext, undefined, pin.record);
1060
1065
  }
1061
1066
  catch (e) {
1062
1067
  // THE CAPABILITY ASSERT IS THE SIGNAL GATE, the same rule bare `down` enforces inside
@@ -1065,6 +1070,8 @@ async function runUp(args, inheritedLock, onAdopt) {
1065
1070
  // release the latch so the stack keeps running in the foreground; the operator ends it
1066
1071
  // with `cotal down --with-agents` from another terminal, and the broker-exit handler
1067
1072
  // below already ends `up` when the broker goes.
1073
+ if (reserved)
1074
+ rmSync(stopMarker, { force: true });
1068
1075
  console.error(c.red(`! teardown: ${e.message}`));
1069
1076
  console.error(c.red(`the stack is still running; to take managed agents with it, run: cotal down --with-agents`));
1070
1077
  stopping = false;
@@ -1078,11 +1085,15 @@ async function runUp(args, inheritedLock, onAdopt) {
1078
1085
  if (legacyManagerSpareUnverified)
1079
1086
  printLegacyManagerSpareUncertainty();
1080
1087
  else if (spared)
1081
- printSparedAgents(spared);
1088
+ printSparedAgents(spared, spareSeats);
1082
1089
  }
1083
1090
  catch (e) {
1084
1091
  console.error(`! manager teardown: ${e.message}`);
1085
1092
  }
1093
+ finally {
1094
+ if (reserved)
1095
+ rmSync(stopMarker, { force: true });
1096
+ }
1086
1097
  await stopAuthService(space).catch((e) => console.error(`! auth teardown: ${e.message}`));
1087
1098
  child.kill("SIGTERM");
1088
1099
  })();
@@ -1186,11 +1197,62 @@ async function runUp(args, inheritedLock, onAdopt) {
1186
1197
  storeDir,
1187
1198
  ts: new Date().toISOString(),
1188
1199
  }, "started");
1200
+ // A DAEMON THAT DIES UNDER THIS RUNNING BROKER IS RESTARTED HERE (#2469). It exits on its own once
1201
+ // it cannot reach the broker, and a starved host makes a running broker look unreachable, so it can
1202
+ // end while this broker serves on. Nothing else brings it back, and every static retirement then
1203
+ // fails on the ctl.delivery-admin rail and holds its name until an operator re-runs `cotal up`. A
1204
+ // clean exit or SIGTERM/SIGINT is a deliberate stop (`cotal down delivery`) and stays stopped, and so
1205
+ // is the SIGKILL `down` escalates to when a starved daemon outlives its grace, also when either ends a
1206
+ // replacement this loop is still waiting on: that attempt then fails, and retrying it would undo the
1207
+ // stop. A failed attempt waits out the lease TTL, the longest a dead holder's lease blocks its
1208
+ // replacement. No daemon is alive during that wait for `down` to signal, so a stop there shows only as
1209
+ // the dead daemon's record going away. Recovery is announced only once the responder is bound.
1210
+ const meshServing = () => !stopping && child.exitCode === null && child.signalCode === null;
1211
+ let restartingDelivery = false;
1212
+ let deliveryStopped = false;
1213
+ const onDeliveryExit = (code, signal) => {
1214
+ if (code === 0 || signal === "SIGTERM" || signal === "SIGINT" || (signal !== null && deliveryStoppedByDown(space))) {
1215
+ deliveryStopped = true;
1216
+ return;
1217
+ }
1218
+ if (restartingDelivery || !meshServing())
1219
+ return;
1220
+ restartingDelivery = true;
1221
+ console.error(c.yellow(`! delivery daemon exited (${signal ? `signal ${signal}` : `code ${code}`}) while nats-server is running - restarting it`));
1222
+ void (async () => {
1223
+ while (meshServing()) {
1224
+ let failure;
1225
+ try {
1226
+ const ensured = await ensureDelivery({ space, server, tls: transport.kind === "tls-required", onDeliveryExit });
1227
+ // An abnormal exit during this attempt was dropped above, so the record decides.
1228
+ if (deliveryUp(space)) {
1229
+ // An unbound responder was reported by ensureDelivery, and this daemon's exit re-arms the restart.
1230
+ if (ensured.responderBound)
1231
+ console.error(c.green(`✓ delivery daemon running again${ensured.pid !== undefined ? ` (pid ${ensured.pid})` : ""}`));
1232
+ break;
1233
+ }
1234
+ failure = "no delivery daemon is running after the attempt";
1235
+ }
1236
+ catch (e) {
1237
+ failure = e.message;
1238
+ }
1239
+ if (deliveryStopped)
1240
+ break;
1241
+ console.error(c.yellow(`! delivery restart failed, retrying in ${LEASE_TTL_MS / 1000}s: ${failure}`));
1242
+ const recorded = !deliveryStoppedByDown(space);
1243
+ await new Promise((r) => setTimeout(r, LEASE_TTL_MS));
1244
+ if (deliveryStopped || (recorded && deliveryStoppedByDown(space)))
1245
+ break;
1246
+ }
1247
+ restartingDelivery = false;
1248
+ })();
1249
+ };
1189
1250
  // Bring up the delivery daemon WITH the server (auth mode only — it self-gates on `.cotal/auth`).
1190
1251
  // It is part of the server, so `cotal up` starts it by default; open dev mode has no daemon.
1191
1252
  // Class-2 credential renewal is NOT wired here: the MANAGER is the renewal owner (it is resident
1192
1253
  // in every mesh mode — foreground, --detach, refresh — where this foreground process is not).
1193
1254
  const controlPlane = await startDeliveryWithBroker(space, server, transport.kind === "tls-required", {
1255
+ onDeliveryExit,
1194
1256
  runtime: values.runtime,
1195
1257
  // The address the broker was bound to. This is what lets `cotal attach` reach this manager
1196
1258
  // from another machine; without it the attach face stays loopback-only, so exposing terminals
@@ -2441,13 +2503,15 @@ function recordOurMesh(m, provenance) {
2441
2503
  const usableCurrent = cur && findMesh(cur) ? cur : undefined; // compute before recording m
2442
2504
  const prior = findMesh(m.space);
2443
2505
  const origin = provenance === "refresh" && (prior?.origin === "manual" || prior?.origin === "catalog") ? prior.origin : "up";
2444
- // A REFRESH starts nothing: it concluded the mesh is up from reachability alone, and rebuilds `m`
2445
- // from what THIS launch knows, which is never the operator's past decisions. `origin` was already
2446
- // carried across for that reason; the overlay acceptance is the same class and was not, so a
2447
- // no-op refresh silently erased a consent the operator had given. A `started` takeover may
2448
- // replace it (that launch really is the mesh now); a refresh may not quietly drop it.
2449
- const unencryptedOverlay = provenance === "refresh" && m.unencryptedOverlay === undefined ? prior?.unencryptedOverlay : m.unencryptedOverlay;
2450
- recordMesh({ ...m, origin, ...(unencryptedOverlay !== undefined ? { unencryptedOverlay } : {}) });
2506
+ // A REFRESH starts nothing: it concluded the mesh is up from reachability alone, so `m` holds only
2507
+ // what THIS launch decided, and every other field is the record as it stands now. That covers the
2508
+ // operator's past decisions (origin, overlay consent, TLS requirement) and anything another writer
2509
+ // committed while the refresh ensured the control plane; rebuilding from the refresh's earlier
2510
+ // read reverted those and still exited 0. A `started` takeover may replace the record outright
2511
+ // (that launch really is the mesh now).
2512
+ if (provenance === "refresh" && !prior)
2513
+ throw new Error(`mesh "${m.space}" was removed from the registry while \`cotal up\` refreshed it - not re-recording it from an earlier read`);
2514
+ recordMesh(provenance === "refresh" ? { ...prior, ...m, origin } : { ...m, origin });
2451
2515
  if (!usableCurrent) {
2452
2516
  setCurrent(m.space);
2453
2517
  return;