@alfe.ai/gateway 0.2.1 → 0.2.2

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 (2) hide show
  1. package/dist/health.js +151 -14
  2. package/package.json +2 -2
package/dist/health.js CHANGED
@@ -5257,6 +5257,79 @@ function isIPCResponse(msg) {
5257
5257
  /** Current IPC protocol version */
5258
5258
  const PROTOCOL_VERSION = 1;
5259
5259
  //#endregion
5260
+ //#region src/runtime-gate.ts
5261
+ const log$3 = logger$1.child({ component: "RuntimeGate" });
5262
+ var RuntimeGate = class {
5263
+ depth = 0;
5264
+ wasRunning = false;
5265
+ constructor(getRuntime) {
5266
+ this.getRuntime = getRuntime;
5267
+ }
5268
+ /**
5269
+ * Suspend the runtime child. Only the outermost call (depth 0 → 1) actually
5270
+ * stops it; nested calls just bump the refcount. Remembers whether the child
5271
+ * was running so `resume()` doesn't spuriously start a runtime that was never
5272
+ * up (e.g. autoStartRuntime=false).
5273
+ */
5274
+ async suspend() {
5275
+ if (this.depth++ > 0) return;
5276
+ const rp = this.getRuntime();
5277
+ this.wasRunning = rp?.isRunning ?? false;
5278
+ if (rp && this.wasRunning) {
5279
+ log$3.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
5280
+ await rp.stop();
5281
+ }
5282
+ }
5283
+ /**
5284
+ * Resume the runtime child. Only the outermost call (depth 1 → 0) actually
5285
+ * restarts it, and only if it was running when we suspended. This subsumes
5286
+ * the old post-reconcile "restart if plugins changed" step — bringing the
5287
+ * runtime back is exactly what a restart-after-reconcile did.
5288
+ */
5289
+ resume() {
5290
+ if (--this.depth > 0) return Promise.resolve();
5291
+ if (this.depth < 0) this.depth = 0;
5292
+ const rp = this.getRuntime();
5293
+ if (rp && this.wasRunning) {
5294
+ log$3.info("Resuming runtime after mutating reconcile");
5295
+ rp.resume();
5296
+ }
5297
+ this.wasRunning = false;
5298
+ return Promise.resolve();
5299
+ }
5300
+ /**
5301
+ * Out-of-band restart request (e.g. `alfe.config_set`, Hermes MCP sync).
5302
+ * Deferred while a suspend is active — the pending `resume()` will bring the
5303
+ * runtime back with fresh config, so restarting mid-suspend would just fight
5304
+ * the gate (and risk spawning the runtime while a CLI mutation is still
5305
+ * writing the DB).
5306
+ */
5307
+ requestRestart() {
5308
+ if (this.depth > 0) {
5309
+ log$3.info("Runtime restart requested while suspended — deferring to pending resume");
5310
+ return;
5311
+ }
5312
+ const rp = this.getRuntime();
5313
+ if (rp) rp.restart().catch((err) => {
5314
+ log$3.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
5315
+ });
5316
+ }
5317
+ };
5318
+ /**
5319
+ * A gate that never touches the runtime — for runtimes that don't share the
5320
+ * single-writer SQLite hazard, or for tests. `suspend`/`resume` are no-ops and
5321
+ * `requestRestart` does nothing.
5322
+ */
5323
+ var NoopRuntimeGate = class {
5324
+ suspend() {
5325
+ return Promise.resolve();
5326
+ }
5327
+ resume() {
5328
+ return Promise.resolve();
5329
+ }
5330
+ requestRestart() {}
5331
+ };
5332
+ //#endregion
5260
5333
  //#region src/reconciliation.ts
5261
5334
  const log$2 = logger$1.child({ component: "Reconciliation" });
5262
5335
  /**
@@ -5271,13 +5344,50 @@ const MAX_ERROR_ACTIVATE_ATTEMPTS = 3;
5271
5344
  var ReconciliationEngine = class {
5272
5345
  /** Consecutive re-activation attempts for an intact error-state integration, keyed by id. */
5273
5346
  activateAttempts = /* @__PURE__ */ new Map();
5274
- constructor(manager) {
5347
+ /**
5348
+ * Whether this reconcile pass has already suspended the runtime. Reset at the
5349
+ * start of every {@link reconcile} call. The runtime is suspended lazily on
5350
+ * the first mutating branch (install / reinstall / activate / removal-of-
5351
+ * existing) so an all-`up_to_date` pass never touches the runtime.
5352
+ */
5353
+ suspended = false;
5354
+ /**
5355
+ * @param manager The integration manager to converge.
5356
+ * @param gate Runtime gate used to suspend the runtime child around
5357
+ * mutating branches so `openclaw` CLI subprocesses don't write
5358
+ * `~/.openclaw/state/openclaw.sqlite` concurrently with a live
5359
+ * `openclaw gateway run` (see runtime-gate.ts). Defaults to a
5360
+ * no-op gate for runtimes/tests that don't need protection.
5361
+ */
5362
+ constructor(manager, gate = new NoopRuntimeGate()) {
5275
5363
  this.manager = manager;
5364
+ this.gate = gate;
5365
+ }
5366
+ /**
5367
+ * Suspend the runtime on the first mutating branch of a reconcile pass.
5368
+ * Idempotent within a pass — subsequent mutating branches are covered by the
5369
+ * single suspend held until the `finally` in {@link reconcile}.
5370
+ */
5371
+ async ensureSuspended() {
5372
+ if (!this.suspended) {
5373
+ this.suspended = true;
5374
+ await this.gate.suspend();
5375
+ }
5376
+ }
5377
+ /**
5378
+ * Resume the runtime if this pass suspended it. Called from `reconcile()`'s
5379
+ * `finally`. Kept as a method (rather than an inline `if (this.suspended)`)
5380
+ * so the mutation performed by {@link ensureSuspended} in a different method
5381
+ * isn't narrowed away by control-flow analysis at the callsite.
5382
+ */
5383
+ async resumeIfSuspended() {
5384
+ if (this.suspended) await this.gate.resume();
5276
5385
  }
5277
5386
  /**
5278
5387
  * Reconcile local state against desired state from cloud.
5279
5388
  */
5280
5389
  async reconcile(desiredIntegrations) {
5390
+ this.suspended = false;
5281
5391
  const report = {
5282
5392
  results: [],
5283
5393
  installed: [],
@@ -5296,15 +5406,20 @@ var ReconciliationEngine = class {
5296
5406
  }
5297
5407
  const localMap = new Map(localIntegrations.map((i) => [i.id, i]));
5298
5408
  const desiredMap = new Map(desiredIntegrations.map((d) => [d.integrationId, d]));
5299
- for (const desired of desiredIntegrations) if (desired.desiredStatus === "active") await this.reconcileActive(desired, localMap.get(desired.integrationId), report);
5300
- else await this.reconcileRemoved(desired.integrationId, localMap.get(desired.integrationId), report);
5301
- for (const local of localIntegrations) if (!desiredMap.has(local.id)) await this.reconcileRemoved(local.id, local, report);
5409
+ try {
5410
+ for (const desired of desiredIntegrations) if (desired.desiredStatus === "active") await this.reconcileActive(desired, localMap.get(desired.integrationId), report);
5411
+ else await this.reconcileRemoved(desired.integrationId, localMap.get(desired.integrationId), report);
5412
+ for (const local of localIntegrations) if (!desiredMap.has(local.id)) await this.reconcileRemoved(local.id, local, report);
5413
+ } finally {
5414
+ await this.resumeIfSuspended();
5415
+ }
5302
5416
  return report;
5303
5417
  }
5304
5418
  async reconcileActive(desired, local, report) {
5305
5419
  const id = desired.integrationId;
5306
5420
  try {
5307
5421
  if (!local) {
5422
+ await this.ensureSuspended();
5308
5423
  log$2.info(`Installing ${id}@${desired.version}`);
5309
5424
  await this.manager.install(id, desired.version, desired.config, desired.customSource);
5310
5425
  report.installed.push(id);
@@ -5319,6 +5434,7 @@ var ReconciliationEngine = class {
5319
5434
  return;
5320
5435
  }
5321
5436
  if (local.version !== desired.version && desired.version !== "" && local.version !== "unknown") {
5437
+ await this.ensureSuspended();
5322
5438
  log$2.info(`Upgrading ${id}: ${local.version} → ${desired.version}`);
5323
5439
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
5324
5440
  report.installed.push(id);
@@ -5332,6 +5448,7 @@ var ReconciliationEngine = class {
5332
5448
  }
5333
5449
  if (local.status === "error") {
5334
5450
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5451
+ await this.ensureSuspended();
5335
5452
  log$2.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5336
5453
  this.manager.resetReinstallAttempts(id);
5337
5454
  this.activateAttempts.delete(id);
@@ -5361,6 +5478,7 @@ var ReconciliationEngine = class {
5361
5478
  });
5362
5479
  return;
5363
5480
  }
5481
+ await this.ensureSuspended();
5364
5482
  log$2.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
5365
5483
  this.manager.incrementReinstallAttempts(id);
5366
5484
  try {
@@ -5393,6 +5511,7 @@ var ReconciliationEngine = class {
5393
5511
  const activateAttempts = this.activateAttempts.get(id) ?? 0;
5394
5512
  if (activateAttempts < MAX_ERROR_ACTIVATE_ATTEMPTS) {
5395
5513
  this.activateAttempts.set(id, activateAttempts + 1);
5514
+ await this.ensureSuspended();
5396
5515
  log$2.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
5397
5516
  try {
5398
5517
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
@@ -5434,6 +5553,7 @@ var ReconciliationEngine = class {
5434
5553
  return;
5435
5554
  }
5436
5555
  if (local.status !== "active") {
5556
+ await this.ensureSuspended();
5437
5557
  log$2.info(`Activating ${id}`);
5438
5558
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
5439
5559
  report.activated.push(id);
@@ -5445,6 +5565,7 @@ var ReconciliationEngine = class {
5445
5565
  return;
5446
5566
  }
5447
5567
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5568
+ await this.ensureSuspended();
5448
5569
  log$2.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5449
5570
  this.manager.resetReinstallAttempts(id);
5450
5571
  this.activateAttempts.delete(id);
@@ -5489,6 +5610,7 @@ var ReconciliationEngine = class {
5489
5610
  return;
5490
5611
  }
5491
5612
  try {
5613
+ await this.ensureSuspended();
5492
5614
  log$2.info(`Removing ${id}`);
5493
5615
  if (local.status === "active") {
5494
5616
  await this.manager.deactivate(id);
@@ -5576,8 +5698,16 @@ var CloudClient = class {
5576
5698
  setOnReconciliationComplete(handler) {
5577
5699
  this.onReconciliationComplete = handler;
5578
5700
  }
5579
- setIntegrationManager(manager) {
5580
- this.reconciliationEngine = new ReconciliationEngine(manager);
5701
+ /**
5702
+ * Set the integration manager (and optionally the runtime gate) for
5703
+ * reconciliation. When set, the cloud client handles DESIRED_STATE messages
5704
+ * automatically. The gate is passed to the ReconciliationEngine so mutating
5705
+ * reconcile branches suspend the runtime child around runtime-CLI mutations
5706
+ * (avoids concurrent SQLite writers — see runtime-gate.ts). Resuming the
5707
+ * runtime after a mutating pass subsumes the old post-reconcile restart.
5708
+ */
5709
+ setIntegrationManager(manager, gate) {
5710
+ this.reconciliationEngine = new ReconciliationEngine(manager, gate);
5581
5711
  }
5582
5712
  /**
5583
5713
  * Start the cloud connection with auto-reconnect.
@@ -5813,7 +5943,6 @@ var CloudClient = class {
5813
5943
  }, "Cloud: reconciliation complete");
5814
5944
  const reportMsg = createReconciliationReport(report.results);
5815
5945
  this.send(reportMsg);
5816
- if ((report.installed.length > 0 || report.uninstalled.length > 0 || report.configApplied) && this.onRuntimeRestartNeeded) this.onRuntimeRestartNeeded();
5817
5946
  this.onReconciliationComplete?.();
5818
5947
  }
5819
5948
  send(msg) {
@@ -21772,6 +21901,17 @@ var RuntimeProcess = class {
21772
21901
  });
21773
21902
  }
21774
21903
  /**
21904
+ * Resume after a suspend (stop). `start()` alone is a no-op while stopped
21905
+ * because `stop()` latches `this.stopped = true`; resume clears that latch,
21906
+ * resets the crash backoff, and re-spawns. Used by the RuntimeGate to bring
21907
+ * the runtime back after a mutating reconcile branch has finished.
21908
+ */
21909
+ resume() {
21910
+ this.stopped = false;
21911
+ this.backoffMs = BACKOFF_INITIAL_MS;
21912
+ this.start();
21913
+ }
21914
+ /**
21775
21915
  * Restart the runtime process (stop then start with fresh backoff).
21776
21916
  */
21777
21917
  async restart() {
@@ -22619,14 +22759,11 @@ async function startDaemon() {
22619
22759
  }
22620
22760
  });
22621
22761
  const integrationAdapter = new IntegrationManagerAdapter(integrationManager);
22622
- cloudClient.setIntegrationManager(integrationAdapter);
22762
+ const runtimeGate = new RuntimeGate(() => runtimeProcess);
22763
+ cloudClient.setIntegrationManager(integrationAdapter, runtimeGate);
22623
22764
  const requestRuntimeRestart = () => {
22624
- if (runtimeProcess) {
22625
- logger$1.info("Runtime state changed — restarting runtime");
22626
- runtimeProcess.restart().catch((err) => {
22627
- logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
22628
- });
22629
- }
22765
+ logger$1.info("Runtime state changed — requesting runtime restart");
22766
+ runtimeGate.requestRestart();
22630
22767
  };
22631
22768
  cloudClient.setRuntimeRestartNeededHandler(requestRuntimeRestart);
22632
22769
  if (config.runtime === "hermes") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "@alfe.ai/ai-proxy-local": "^0.0.12",
27
27
  "@alfe.ai/config": "^0.2.0",
28
28
  "@alfe.ai/integration-manifest": "^0.3.1",
29
- "@alfe.ai/integrations": "^0.2.4",
29
+ "@alfe.ai/integrations": "^0.2.5",
30
30
  "@alfe.ai/mcp-bundler": "^0.2.2"
31
31
  },
32
32
  "license": "UNLICENSED",