@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 +45 -0
- package/dist/index.js +143 -6
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2361
|
+
const pkg = stripPluginVersion(spec);
|
|
2362
|
+
await this.execOpenClawHealing([
|
|
2226
2363
|
"plugins",
|
|
2227
2364
|
"uninstall",
|
|
2228
2365
|
"--force",
|
|
2229
|
-
|
|
2366
|
+
pkg
|
|
2230
2367
|
], { timeout: 3e4 });
|
|
2231
2368
|
}
|
|
2232
2369
|
applySkill(name, srcPath) {
|
package/package.json
CHANGED