@alfe.ai/integrations 0.2.7 → 0.2.8

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
@@ -870,6 +870,11 @@ declare class OpenClawApplier implements RuntimeApplier {
870
870
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
871
871
  */
872
872
  private cliLock;
873
+ /**
874
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
875
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
876
+ */
877
+ private lastHealAt;
873
878
  constructor(options: OpenClawApplierOptions);
874
879
  /**
875
880
  * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
@@ -913,6 +918,46 @@ declare class OpenClawApplier implements RuntimeApplier {
913
918
  * on the first success, false once all attempts are exhausted.
914
919
  */
915
920
  private verifyApplied;
921
+ /**
922
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
923
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
924
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
925
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
926
+ * error propagates unchanged — callers keep their existing failure semantics
927
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
928
+ *
929
+ * Assumes the shared CLI lock is already held by the calling public method: the
930
+ * heal runs INSIDE the same locked section as the failed command. NEVER
931
+ * re-acquires the non-re-entrant `cliLock`.
932
+ *
933
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
934
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
935
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
936
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
937
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
938
+ * its next restart — the state DB is disposable), and the recreated DB gets
939
+ * fresh -wal/-shm files the old process never attaches to.
940
+ */
941
+ private execOpenClawHealing;
942
+ /**
943
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
944
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
945
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
946
+ *
947
+ * Steps (human-verified against a live managed agent): rename
948
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
949
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
950
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
951
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
952
+ * execOpenClawHealing for the runtime-alive rename semantics).
953
+ *
954
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
955
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
956
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
957
+ * window would strand the box with the main DB quarantined and no recreate for
958
+ * 10 minutes.
959
+ */
960
+ private healMalformedStateDb;
916
961
  applyPlugin(spec: string, _installPath?: string, opts?: {
917
962
  force?: boolean;
918
963
  }): 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";
@@ -1760,6 +1760,44 @@ const VERIFY_GET_CONCURRENCY = 4;
1760
1760
  * `configValueMatches`).
1761
1761
  */
1762
1762
  const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
1763
+ /**
1764
+ * OpenClaw's gateway runtime deterministically truncates its own WAL-mode state
1765
+ * DB (`<home>/state/openclaw.sqlite`) ~50s after boot: the page_count header
1766
+ * shrinks while btree/freelist entries still reference pages past the new end.
1767
+ * Once corrupt, EVERY `openclaw` CLI invocation that walks the freelist dies at
1768
+ * startup with this exact phrase (printed to STDOUT, exit 1), which kills every
1769
+ * `plugins install` during activation. Upstream (no fix, suggests exactly this
1770
+ * quarantine-and-recreate recovery): https://github.com/openclaw/openclaw/issues/71689.
1771
+ * `doctor --fix` does NOT detect it. The state DB is disposable — plugin installs
1772
+ * live in `<home>/npm/node_modules` + the on-disk registry, so quarantine + any
1773
+ * CLI command rebuilds it via migrations with no meaningful loss.
1774
+ */
1775
+ const MALFORMED_STATE_DB_PHRASE = "database disk image is malformed";
1776
+ /**
1777
+ * Rate-limit the state-DB self-heal to ONE attempt per applier instance per
1778
+ * window so a heal→corrupt→heal loop (the runtime re-corrupts the fresh DB ~50s
1779
+ * later) can't thrash the box. Wall-clock `Date.now()` is fine here — this is
1780
+ * runtime daemon code, not a workflow script.
1781
+ */
1782
+ const HEAL_MIN_INTERVAL_MS = 600 * 1e3;
1783
+ /**
1784
+ * Cooldown after a FAILED heal attempt. A failed heal produced no fresh DB, so
1785
+ * the full HEAL_MIN_INTERVAL_MS lockout is unjustified (it would strand the box
1786
+ * with the main DB quarantined and no recreate); a short cooldown just stops a
1787
+ * tight retry loop from hammering rename/recreate.
1788
+ */
1789
+ const FAILED_HEAL_COOLDOWN_MS = 60 * 1e3;
1790
+ /**
1791
+ * True when an execFile rejection carries the malformed-state-DB signature.
1792
+ * OpenClaw prints the reason to STDOUT (not stderr), and Node's execFile error
1793
+ * embeds the argv in `message`, so all three streams are checked. The phrase is
1794
+ * specific enough to match on directly (case-insensitive).
1795
+ */
1796
+ function isMalformedStateDbError(err) {
1797
+ if (!(err instanceof Error)) return false;
1798
+ const e = err;
1799
+ return `${e.message}\n${e.stderr ?? ""}\n${e.stdout ?? ""}`.toLowerCase().includes(MALFORMED_STATE_DB_PHRASE);
1800
+ }
1763
1801
  const delay$1 = (ms) => new Promise((resolve) => {
1764
1802
  setTimeout(resolve, ms);
1765
1803
  });
@@ -1925,6 +1963,11 @@ var OpenClawApplier = class {
1925
1963
  * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
1926
1964
  */
1927
1965
  cliLock;
1966
+ /**
1967
+ * Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
1968
+ * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
1969
+ */
1970
+ lastHealAt = 0;
1928
1971
  constructor(options) {
1929
1972
  const home = options.home ?? options.workspace;
1930
1973
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1963,7 +2006,7 @@ var OpenClawApplier = class {
1963
2006
  const timeout = opts.timeout ?? 1e4;
1964
2007
  let lastErr;
1965
2008
  for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1966
- await execFileAsync$1("openclaw", ["config", ...args], { timeout });
2009
+ await this.execOpenClawHealing(["config", ...args], { timeout });
1967
2010
  return;
1968
2011
  } catch (err) {
1969
2012
  lastErr = err;
@@ -2010,6 +2053,99 @@ var OpenClawApplier = class {
2010
2053
  }
2011
2054
  return false;
2012
2055
  }
2056
+ /**
2057
+ * `openclaw <args>` with automatic malformed-state-DB self-heal. On a
2058
+ * malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
2059
+ * state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
2060
+ * once. If the heal is rate-limited/failed, or the single retry also fails, the
2061
+ * error propagates unchanged — callers keep their existing failure semantics
2062
+ * (retry loops, install-tolerance checks, log.warn) as if healing never ran.
2063
+ *
2064
+ * Assumes the shared CLI lock is already held by the calling public method: the
2065
+ * heal runs INSIDE the same locked section as the failed command. NEVER
2066
+ * re-acquires the non-re-entrant `cliLock`.
2067
+ *
2068
+ * Runtime-alive safety: reconcile-path callers run under the gateway's
2069
+ * RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
2070
+ * `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
2071
+ * That is safe by fd/inode decoupling, not by exclusivity: renameSync is
2072
+ * inode-level, the live runtime keeps writing its orphaned inode (discarded on
2073
+ * its next restart — the state DB is disposable), and the recreated DB gets
2074
+ * fresh -wal/-shm files the old process never attaches to.
2075
+ */
2076
+ async execOpenClawHealing(args, opts = {}) {
2077
+ try {
2078
+ await execFileAsync$1("openclaw", args, opts);
2079
+ return;
2080
+ } catch (err) {
2081
+ if (!isMalformedStateDbError(err)) throw err;
2082
+ log$3.warn({ args: args.slice(0, 2) }, "openclaw CLI failed with a malformed state DB — attempting self-heal then one retry");
2083
+ if (!await this.healMalformedStateDb()) throw err;
2084
+ await execFileAsync$1("openclaw", args, opts);
2085
+ }
2086
+ }
2087
+ /**
2088
+ * Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
2089
+ * (logs + returns whether the heal succeeded). Rate-limited to one attempt per
2090
+ * HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
2091
+ *
2092
+ * Steps (human-verified against a live managed agent): rename
2093
+ * `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
2094
+ * (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
2095
+ * list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
2096
+ * box — 90s budget). Assumes the caller holds `cliLock` (see
2097
+ * execOpenClawHealing for the runtime-alive rename semantics).
2098
+ *
2099
+ * A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
2100
+ * the runtime to re-corrupt — that loop is what the window prevents). A FAILED
2101
+ * heal only gets the short cooldown: no fresh DB was produced, and a full
2102
+ * window would strand the box with the main DB quarantined and no recreate for
2103
+ * 10 minutes.
2104
+ */
2105
+ async healMalformedStateDb() {
2106
+ const now = Date.now();
2107
+ if (now - this.lastHealAt < HEAL_MIN_INTERVAL_MS) {
2108
+ 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");
2109
+ return false;
2110
+ }
2111
+ this.lastHealAt = now;
2112
+ const stateDir = join(this.home, "state");
2113
+ const stamp = new Date(now).toISOString().replaceAll(":", "-");
2114
+ const quarantined = [];
2115
+ try {
2116
+ for (const name of [
2117
+ "openclaw.sqlite",
2118
+ "openclaw.sqlite-wal",
2119
+ "openclaw.sqlite-shm"
2120
+ ]) {
2121
+ const src = join(stateDir, name);
2122
+ if (!existsSync(src)) continue;
2123
+ const dest = `${src}.quarantined-${stamp}`;
2124
+ renameSync(src, dest);
2125
+ quarantined.push(dest);
2126
+ }
2127
+ if (quarantined.length === 0) {
2128
+ log$3.warn({ stateDir }, "malformed openclaw state DB reported but no state/openclaw.sqlite* files found to quarantine");
2129
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2130
+ return false;
2131
+ }
2132
+ log$3.warn({
2133
+ stateDir,
2134
+ quarantined
2135
+ }, "quarantined malformed openclaw state DB — recreating via `openclaw plugins list` (upstream openclaw/openclaw#71689)");
2136
+ await execFileAsync$1("openclaw", ["plugins", "list"], { timeout: 9e4 });
2137
+ log$3.warn({ stateDir }, "recreated openclaw state DB after quarantine");
2138
+ return true;
2139
+ } catch (err) {
2140
+ log$3.warn({
2141
+ stateDir,
2142
+ quarantined,
2143
+ err: err instanceof Error ? err.message : String(err)
2144
+ }, "failed to heal malformed openclaw state DB — leaving quarantined files for forensics");
2145
+ this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
2146
+ return false;
2147
+ }
2148
+ }
2013
2149
  applyPlugin(spec, _installPath, opts) {
2014
2150
  return this.cliLock.run(() => this.applyPluginLocked(spec, opts));
2015
2151
  }
@@ -2055,12 +2191,12 @@ var OpenClawApplier = class {
2055
2191
  const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
2056
2192
  try {
2057
2193
  try {
2058
- await execFileAsync$1("openclaw", args, { timeout: 6e4 });
2194
+ await this.execOpenClawHealing(args, { timeout: 6e4 });
2059
2195
  } catch (err) {
2060
2196
  const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
2061
2197
  if (useUnsafeFlag && errText.includes("unknown option")) {
2062
2198
  log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
2063
- await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
2199
+ await this.execOpenClawHealing(baseArgs, { timeout: 6e4 });
2064
2200
  } else throw err;
2065
2201
  }
2066
2202
  } catch (err) {
@@ -2222,11 +2358,12 @@ var OpenClawApplier = class {
2222
2358
  * path). Never call outside a locked section.
2223
2359
  */
2224
2360
  async removePluginUnlocked(spec) {
2225
- await execFileAsync$1("openclaw", [
2361
+ const pkg = stripPluginVersion(spec);
2362
+ await this.execOpenClawHealing([
2226
2363
  "plugins",
2227
2364
  "uninstall",
2228
2365
  "--force",
2229
- stripPluginVersion(spec)
2366
+ pkg
2230
2367
  ], { timeout: 3e4 });
2231
2368
  }
2232
2369
  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.8",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",