@alfe.ai/integrations 0.2.7 → 0.2.9

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/dist/index.d.ts CHANGED
@@ -610,6 +610,15 @@ declare class IntegrationManager {
610
610
  clear(): void;
611
611
  private checkHealth;
612
612
  private err;
613
+ /**
614
+ * True when the manifest's `supported_agents` (if declared) intersects the
615
+ * registered runtime appliers. Hooks and MCP registration run once per
616
+ * integration (not per runtime), so they must use this aggregate check —
617
+ * the per-runtime loop's check only protects plugin/skill/config apply.
618
+ * The daemon registers exactly the agent's runtime, so checking the
619
+ * registered appliers' keys is sufficient.
620
+ */
621
+ private manifestSupportsRegisteredRuntime;
613
622
  }
614
623
  //#endregion
615
624
  //#region src/state.d.ts
@@ -699,6 +708,13 @@ interface HookEnvOptions {
699
708
  config?: Record<string, unknown>;
700
709
  /** Secret key-value pairs (in-memory only, injected as env vars) */
701
710
  secrets?: Map<string, unknown>;
711
+ /**
712
+ * Registered runtime name(s) on this agent (e.g. ["openclaw"]). Injected as
713
+ * ALFE_AGENT_RUNTIME so hooks can branch on runtime instead of sniffing for
714
+ * runtime binaries. The daemon registers exactly one runtime today; the
715
+ * value is comma-joined if that ever changes.
716
+ */
717
+ runtimes?: string[];
702
718
  }
703
719
  /**
704
720
  * Build the environment variables for a hook script execution.
@@ -706,6 +722,7 @@ interface HookEnvOptions {
706
722
  * Injects:
707
723
  * - ALFE_INTEGRATION_DIR=~/.alfe/integrations/{name}/
708
724
  * - ALFE_STATE_DIR=~/.alfe/state/{name}/ (creates dir if needed)
725
+ * - ALFE_AGENT_RUNTIME=openclaw (comma-joined registered runtime names)
709
726
  * - ALFE_<NAME_UPPER>_<KEY_UPPER>=value for each config entry
710
727
  * - ALFE_<NAME_UPPER>_<KEY_UPPER>=value for each secret entry
711
728
  */
@@ -870,6 +887,11 @@ declare class OpenClawApplier implements RuntimeApplier {
870
887
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
871
888
  */
872
889
  private cliLock;
890
+ /**
891
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
892
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
893
+ */
894
+ private lastHealAt;
873
895
  constructor(options: OpenClawApplierOptions);
874
896
  /**
875
897
  * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
@@ -913,6 +935,46 @@ declare class OpenClawApplier implements RuntimeApplier {
913
935
  * on the first success, false once all attempts are exhausted.
914
936
  */
915
937
  private verifyApplied;
938
+ /**
939
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
940
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
941
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
942
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
943
+ * error propagates unchanged — callers keep their existing failure semantics
944
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
945
+ *
946
+ * Assumes the shared CLI lock is already held by the calling public method: the
947
+ * heal runs INSIDE the same locked section as the failed command. NEVER
948
+ * re-acquires the non-re-entrant `cliLock`.
949
+ *
950
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
951
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
952
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
953
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
954
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
955
+ * its next restart — the state DB is disposable), and the recreated DB gets
956
+ * fresh -wal/-shm files the old process never attaches to.
957
+ */
958
+ private execOpenClawHealing;
959
+ /**
960
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
961
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
962
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
963
+ *
964
+ * Steps (human-verified against a live managed agent): rename
965
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
966
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
967
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
968
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
969
+ * execOpenClawHealing for the runtime-alive rename semantics).
970
+ *
971
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
972
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
973
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
974
+ * window would strand the box with the main DB quarantined and no recreate for
975
+ * 10 minutes.
976
+ */
977
+ private healMalformedStateDb;
916
978
  applyPlugin(spec: string, _installPath?: string, opts?: {
917
979
  force?: boolean;
918
980
  }): Promise<void>;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { homedir, platform, tmpdir } from "node:os";
5
- import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
8
  import { parseDocument } from "yaml";
@@ -671,11 +671,12 @@ const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
671
671
  * Injects:
672
672
  * - ALFE_INTEGRATION_DIR=~/.alfe/integrations/{name}/
673
673
  * - ALFE_STATE_DIR=~/.alfe/state/{name}/ (creates dir if needed)
674
+ * - ALFE_AGENT_RUNTIME=openclaw (comma-joined registered runtime names)
674
675
  * - ALFE_<NAME_UPPER>_<KEY_UPPER>=value for each config entry
675
676
  * - ALFE_<NAME_UPPER>_<KEY_UPPER>=value for each secret entry
676
677
  */
677
678
  function buildHookEnv(options, additionalEnv) {
678
- const { integrationName, config, secrets } = options;
679
+ const { integrationName, config, secrets, runtimes } = options;
679
680
  const nameUpper = integrationName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
680
681
  const integrationDir = join(INTEGRATIONS_BASE_DIR, integrationName);
681
682
  const stateDir = join(STATE_BASE_DIR, integrationName);
@@ -688,6 +689,7 @@ function buildHookEnv(options, additionalEnv) {
688
689
  ALFE_INTEGRATION_DIR: integrationDir,
689
690
  ALFE_STATE_DIR: stateDir
690
691
  };
692
+ if (runtimes && runtimes.length > 0) env.ALFE_AGENT_RUNTIME = runtimes.join(",");
691
693
  if (config) {
692
694
  for (const [key, value] of Object.entries(config)) if (value !== void 0 && value !== null) {
693
695
  const envKey = `ALFE_${nameUpper}_${key.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
@@ -1037,17 +1039,20 @@ var IntegrationManager = class {
1037
1039
  const depState = this.state.get(dep);
1038
1040
  if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
1039
1041
  }
1040
- if (manifest.hooks.pre_install) {
1042
+ const installHooksSupported = this.manifestSupportsRegisteredRuntime(manifest);
1043
+ if (!installHooksSupported && (manifest.hooks.pre_install || manifest.hooks.post_install)) this.log.warn(`Integration "${name}" install hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(manifest.supported_agents ?? []).join(", ")})`);
1044
+ if (installHooksSupported && manifest.hooks.pre_install) {
1041
1045
  this.log.info(`Running pre_install hook: ${manifest.hooks.pre_install}`);
1042
1046
  const hookResult = await runHook(installPath, manifest.hooks.pre_install);
1043
1047
  if (hookResult.exitCode !== 0) throw new Error(`pre_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1044
1048
  }
1045
- if (manifest.hooks.post_install) {
1049
+ if (installHooksSupported && manifest.hooks.post_install) {
1046
1050
  this.log.info(`Running post_install hook: ${manifest.hooks.post_install}`);
1047
1051
  const hookResult = await runHookWithContext(installPath, manifest.hooks.post_install, {
1048
1052
  integrationName: name,
1049
1053
  config: config ?? {},
1050
- secrets: this.secrets.get(name)
1054
+ secrets: this.secrets.get(name),
1055
+ runtimes: [...this.runtimeAppliers.keys()]
1051
1056
  });
1052
1057
  if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1053
1058
  }
@@ -1213,8 +1218,8 @@ var IntegrationManager = class {
1213
1218
  if (appliedPlugins.length > 0 || skills.length > 0 || runtimeConfigApplied) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, appliedPlugins, skills, installPath, { configApplied: runtimeConfigApplied });
1214
1219
  }
1215
1220
  const mcpServers = manifest.mcp_servers ?? [];
1216
- const runtimeSupportsMcp = !supportedAgents || supportedAgents.length === 0 || [...this.runtimeAppliers.keys()].some((r) => supportedAgents.includes(r));
1217
- if (mcpServers.length > 0 && !runtimeSupportsMcp) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${supportedAgents.join(", ")}) — skipping MCP registration`);
1221
+ const runtimeSupported = this.manifestSupportsRegisteredRuntime(manifest);
1222
+ if (mcpServers.length > 0 && !runtimeSupported) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(supportedAgents ?? []).join(", ")}) — skipping MCP registration`);
1218
1223
  else if (mcpServers.length > 0) if (!this.mcpApplier) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no mcpApplier is wired — skipping MCP registration`);
1219
1224
  else {
1220
1225
  const secretEntries = this.secrets.get(integrationId);
@@ -1226,24 +1231,28 @@ var IntegrationManager = class {
1226
1231
  await this.mcpApplier.applyForIntegration(integrationId, mcpServers, mergedConfig, { connectionId: entry.customConnectionId });
1227
1232
  }
1228
1233
  this.state.setStatus(integrationId, "active");
1229
- if (manifest.hooks.post_activate) {
1234
+ if (manifest.hooks.post_activate && !runtimeSupported) this.log.warn(`Integration "${integrationId}" post_activate hook skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(supportedAgents ?? []).join(", ")})`);
1235
+ else if (manifest.hooks.post_activate) {
1230
1236
  this.log.info(`Running post_activate hook: ${manifest.hooks.post_activate}`);
1231
1237
  const hookResult = await runHookWithContext(installPath, manifest.hooks.post_activate, {
1232
1238
  integrationName: integrationId,
1233
1239
  config: entry.config,
1234
- secrets: this.secrets.get(integrationId)
1240
+ secrets: this.secrets.get(integrationId),
1241
+ runtimes: [...this.runtimeAppliers.keys()]
1235
1242
  });
1236
1243
  if (hookResult.exitCode !== 0) {
1237
1244
  this.state.setStatus(integrationId, "error", `post_activate hook failed: ${hookResult.stderr || hookResult.stdout}`);
1238
1245
  return this.err("POST_ACTIVATE_FAILED", `post_activate hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1239
1246
  }
1240
1247
  }
1241
- if (manifest.hooks.health_check) {
1248
+ if (manifest.hooks.health_check && !runtimeSupported) this.log.warn(`Integration "${integrationId}" health_check hook skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(supportedAgents ?? []).join(", ")})`);
1249
+ else if (manifest.hooks.health_check) {
1242
1250
  this.log.info(`Running health check: ${manifest.hooks.health_check}`);
1243
1251
  const hookResult = await runHookWithContext(installPath, manifest.hooks.health_check, {
1244
1252
  integrationName: integrationId,
1245
1253
  config: entry.config,
1246
- secrets: this.secrets.get(integrationId)
1254
+ secrets: this.secrets.get(integrationId),
1255
+ runtimes: [...this.runtimeAppliers.keys()]
1247
1256
  });
1248
1257
  if (hookResult.exitCode !== 0) {
1249
1258
  this.state.setStatus(integrationId, "error", `Health check failed: ${hookResult.stderr || hookResult.stdout}`);
@@ -1386,21 +1395,25 @@ var IntegrationManager = class {
1386
1395
  } catch {
1387
1396
  this.log.warn(`Could not parse manifest for "${name}" — proceeding with basic cleanup`);
1388
1397
  }
1389
- if (manifest?.hooks.pre_uninstall) {
1398
+ const uninstallHooksSupported = manifest ? this.manifestSupportsRegisteredRuntime(manifest) : true;
1399
+ if (manifest && !uninstallHooksSupported && (manifest.hooks.pre_uninstall || manifest.hooks.post_uninstall)) this.log.warn(`Integration "${name}" uninstall hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(manifest.supported_agents ?? []).join(", ")})`);
1400
+ if (uninstallHooksSupported && manifest?.hooks.pre_uninstall) {
1390
1401
  this.log.info(`Running pre_uninstall hook: ${manifest.hooks.pre_uninstall}`);
1391
1402
  const hookResult = await runHookWithContext(installPath, manifest.hooks.pre_uninstall, {
1392
1403
  integrationName: name,
1393
1404
  config: entry.config,
1394
- secrets: this.secrets.get(name)
1405
+ secrets: this.secrets.get(name),
1406
+ runtimes: [...this.runtimeAppliers.keys()]
1395
1407
  });
1396
1408
  if (hookResult.exitCode !== 0) this.log.warn(`pre_uninstall hook failed (continuing): ${hookResult.stderr}`);
1397
1409
  }
1398
- if (manifest?.hooks.post_uninstall) {
1410
+ if (uninstallHooksSupported && manifest?.hooks.post_uninstall) {
1399
1411
  this.log.info(`Running post_uninstall hook: ${manifest.hooks.post_uninstall}`);
1400
1412
  const hookResult = await runHookWithContext(installPath, manifest.hooks.post_uninstall, {
1401
1413
  integrationName: name,
1402
1414
  config: entry.config,
1403
- secrets: this.secrets.get(name)
1415
+ secrets: this.secrets.get(name),
1416
+ runtimes: [...this.runtimeAppliers.keys()]
1404
1417
  });
1405
1418
  if (hookResult.exitCode !== 0) this.log.warn(`post_uninstall hook failed (non-fatal): ${hookResult.stderr}`);
1406
1419
  }
@@ -1597,10 +1610,19 @@ var IntegrationManager = class {
1597
1610
  version: manifest.version,
1598
1611
  message: "No health check hook defined"
1599
1612
  };
1613
+ if (!this.manifestSupportsRegisteredRuntime(manifest)) return {
1614
+ id,
1615
+ name: manifest.name,
1616
+ healthy: entry.status === "active" || entry.status === "configured" || entry.status === "installed",
1617
+ status: entry.status,
1618
+ version: manifest.version,
1619
+ message: "Health check hook skipped — integration does not support this agent runtime"
1620
+ };
1600
1621
  const hookResult = await runHookWithContext(installPath, manifest.hooks.health_check, {
1601
1622
  integrationName: id,
1602
1623
  config: entry.config,
1603
- secrets: this.secrets.get(id)
1624
+ secrets: this.secrets.get(id),
1625
+ runtimes: [...this.runtimeAppliers.keys()]
1604
1626
  });
1605
1627
  return {
1606
1628
  id,
@@ -1631,6 +1653,18 @@ var IntegrationManager = class {
1631
1653
  }
1632
1654
  };
1633
1655
  }
1656
+ /**
1657
+ * True when the manifest's `supported_agents` (if declared) intersects the
1658
+ * registered runtime appliers. Hooks and MCP registration run once per
1659
+ * integration (not per runtime), so they must use this aggregate check —
1660
+ * the per-runtime loop's check only protects plugin/skill/config apply.
1661
+ * The daemon registers exactly the agent's runtime, so checking the
1662
+ * registered appliers' keys is sufficient.
1663
+ */
1664
+ manifestSupportsRegisteredRuntime(manifest) {
1665
+ const supported = manifest.supported_agents;
1666
+ return !supported || supported.length === 0 || [...this.runtimeAppliers.keys()].some((r) => supported.includes(r));
1667
+ }
1634
1668
  };
1635
1669
  //#endregion
1636
1670
  //#region src/openclaw-cli-lock.ts
@@ -1760,6 +1794,44 @@ const VERIFY_GET_CONCURRENCY = 4;
1760
1794
  * `configValueMatches`).
1761
1795
  */
1762
1796
  const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
1797
+ /**
1798
+ * OpenClaw's gateway runtime deterministically truncates its own WAL-mode state
1799
+ * DB (`<home>/state/openclaw.sqlite`) ~50s after boot: the page_count header
1800
+ * shrinks while btree/freelist entries still reference pages past the new end.
1801
+ * Once corrupt, EVERY `openclaw` CLI invocation that walks the freelist dies at
1802
+ * startup with this exact phrase (printed to STDOUT, exit 1), which kills every
1803
+ * `plugins install` during activation. Upstream (no fix, suggests exactly this
1804
+ * quarantine-and-recreate recovery): https://github.com/openclaw/openclaw/issues/71689.
1805
+ * `doctor --fix` does NOT detect it. The state DB is disposable — plugin installs
1806
+ * live in `<home>/npm/node_modules` + the on-disk registry, so quarantine + any
1807
+ * CLI command rebuilds it via migrations with no meaningful loss.
1808
+ */
1809
+ const MALFORMED_STATE_DB_PHRASE = "database disk image is malformed";
1810
+ /**
1811
+ * Rate-limit the state-DB self-heal to ONE attempt per applier instance per
1812
+ * window so a heal→corrupt→heal loop (the runtime re-corrupts the fresh DB ~50s
1813
+ * later) can't thrash the box. Wall-clock `Date.now()` is fine here — this is
1814
+ * runtime daemon code, not a workflow script.
1815
+ */
1816
+ const HEAL_MIN_INTERVAL_MS = 600 * 1e3;
1817
+ /**
1818
+ * Cooldown after a FAILED heal attempt. A failed heal produced no fresh DB, so
1819
+ * the full HEAL_MIN_INTERVAL_MS lockout is unjustified (it would strand the box
1820
+ * with the main DB quarantined and no recreate); a short cooldown just stops a
1821
+ * tight retry loop from hammering rename/recreate.
1822
+ */
1823
+ const FAILED_HEAL_COOLDOWN_MS = 60 * 1e3;
1824
+ /**
1825
+ * True when an execFile rejection carries the malformed-state-DB signature.
1826
+ * OpenClaw prints the reason to STDOUT (not stderr), and Node's execFile error
1827
+ * embeds the argv in `message`, so all three streams are checked. The phrase is
1828
+ * specific enough to match on directly (case-insensitive).
1829
+ */
1830
+ function isMalformedStateDbError(err) {
1831
+ if (!(err instanceof Error)) return false;
1832
+ const e = err;
1833
+ return `${e.message}\n${e.stderr ?? ""}\n${e.stdout ?? ""}`.toLowerCase().includes(MALFORMED_STATE_DB_PHRASE);
1834
+ }
1763
1835
  const delay$1 = (ms) => new Promise((resolve) => {
1764
1836
  setTimeout(resolve, ms);
1765
1837
  });
@@ -1925,6 +1997,11 @@ var OpenClawApplier = class {
1925
1997
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
1926
1998
  */
1927
1999
  cliLock;
2000
+ /**
2001
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
2002
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
2003
+ */
2004
+ lastHealAt = 0;
1928
2005
  constructor(options) {
1929
2006
  const home = options.home ?? options.workspace;
1930
2007
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1963,7 +2040,7 @@ var OpenClawApplier = class {
1963
2040
  const timeout = opts.timeout ?? 1e4;
1964
2041
  let lastErr;
1965
2042
  for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1966
- await execFileAsync$1("openclaw", ["config", ...args], { timeout });
2043
+ await this.execOpenClawHealing(["config", ...args], { timeout });
1967
2044
  return;
1968
2045
  } catch (err) {
1969
2046
  lastErr = err;
@@ -2010,6 +2087,99 @@ var OpenClawApplier = class {
2010
2087
  }
2011
2088
  return false;
2012
2089
  }
2090
+ /**
2091
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
2092
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
2093
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
2094
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
2095
+ * error propagates unchanged — callers keep their existing failure semantics
2096
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
2097
+ *
2098
+ * Assumes the shared CLI lock is already held by the calling public method: the
2099
+ * heal runs INSIDE the same locked section as the failed command. NEVER
2100
+ * re-acquires the non-re-entrant `cliLock`.
2101
+ *
2102
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
2103
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
2104
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
2105
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
2106
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
2107
+ * its next restart — the state DB is disposable), and the recreated DB gets
2108
+ * fresh -wal/-shm files the old process never attaches to.
2109
+ */
2110
+ async execOpenClawHealing(args, opts = {}) {
2111
+ try {
2112
+ await execFileAsync$1("openclaw", args, opts);
2113
+ return;
2114
+ } catch (err) {
2115
+ if (!isMalformedStateDbError(err)) throw err;
2116
+ log$3.warn({ args: args.slice(0, 2) }, "openclaw CLI failed with a malformed state DB — attempting self-heal then one retry");
2117
+ if (!await this.healMalformedStateDb()) throw err;
2118
+ await execFileAsync$1("openclaw", args, opts);
2119
+ }
2120
+ }
2121
+ /**
2122
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
2123
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
2124
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
2125
+ *
2126
+ * Steps (human-verified against a live managed agent): rename
2127
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
2128
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
2129
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
2130
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
2131
+ * execOpenClawHealing for the runtime-alive rename semantics).
2132
+ *
2133
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
2134
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
2135
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
2136
+ * window would strand the box with the main DB quarantined and no recreate for
2137
+ * 10 minutes.
2138
+ */
2139
+ async healMalformedStateDb() {
2140
+ const now = Date.now();
2141
+ if (now - this.lastHealAt < HEAL_MIN_INTERVAL_MS) {
2142
+ log$3.warn({ sinceLastHealMs: now - this.lastHealAt }, "openclaw state DB malformed but a heal ran within the rate-limit window — skipping to avoid a heal→corrupt→heal loop");
2143
+ return false;
2144
+ }
2145
+ this.lastHealAt = now;
2146
+ const stateDir = join(this.home, "state");
2147
+ const stamp = new Date(now).toISOString().replaceAll(":", "-");
2148
+ const quarantined = [];
2149
+ try {
2150
+ for (const name of [
2151
+ "openclaw.sqlite",
2152
+ "openclaw.sqlite-wal",
2153
+ "openclaw.sqlite-shm"
2154
+ ]) {
2155
+ const src = join(stateDir, name);
2156
+ if (!existsSync(src)) continue;
2157
+ const dest = `${src}.quarantined-${stamp}`;
2158
+ renameSync(src, dest);
2159
+ quarantined.push(dest);
2160
+ }
2161
+ if (quarantined.length === 0) {
2162
+ log$3.warn({ stateDir }, "malformed openclaw state DB reported but no state/openclaw.sqlite* files found to quarantine");
2163
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2164
+ return false;
2165
+ }
2166
+ log$3.warn({
2167
+ stateDir,
2168
+ quarantined
2169
+ }, "quarantined malformed openclaw state DB — recreating via `openclaw plugins list` (upstream openclaw/openclaw#71689)");
2170
+ await execFileAsync$1("openclaw", ["plugins", "list"], { timeout: 9e4 });
2171
+ log$3.warn({ stateDir }, "recreated openclaw state DB after quarantine");
2172
+ return true;
2173
+ } catch (err) {
2174
+ log$3.warn({
2175
+ stateDir,
2176
+ quarantined,
2177
+ err: err instanceof Error ? err.message : String(err)
2178
+ }, "failed to heal malformed openclaw state DB — leaving quarantined files for forensics");
2179
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2180
+ return false;
2181
+ }
2182
+ }
2013
2183
  applyPlugin(spec, _installPath, opts) {
2014
2184
  return this.cliLock.run(() => this.applyPluginLocked(spec, opts));
2015
2185
  }
@@ -2055,12 +2225,12 @@ var OpenClawApplier = class {
2055
2225
  const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
2056
2226
  try {
2057
2227
  try {
2058
- await execFileAsync$1("openclaw", args, { timeout: 6e4 });
2228
+ await this.execOpenClawHealing(args, { timeout: 6e4 });
2059
2229
  } catch (err) {
2060
2230
  const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
2061
2231
  if (useUnsafeFlag && errText.includes("unknown option")) {
2062
2232
  log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
2063
- await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
2233
+ await this.execOpenClawHealing(baseArgs, { timeout: 6e4 });
2064
2234
  } else throw err;
2065
2235
  }
2066
2236
  } catch (err) {
@@ -2222,11 +2392,12 @@ var OpenClawApplier = class {
2222
2392
  * path). Never call outside a locked section.
2223
2393
  */
2224
2394
  async removePluginUnlocked(spec) {
2225
- await execFileAsync$1("openclaw", [
2395
+ const pkg = stripPluginVersion(spec);
2396
+ await this.execOpenClawHealing([
2226
2397
  "plugins",
2227
2398
  "uninstall",
2228
2399
  "--force",
2229
- stripPluginVersion(spec)
2400
+ pkg
2230
2401
  ], { timeout: 3e4 });
2231
2402
  }
2232
2403
  applySkill(name, srcPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",