@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 +62 -0
- package/dist/index.js +192 -21
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1217
|
-
if (mcpServers.length > 0 && !
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2395
|
+
const pkg = stripPluginVersion(spec);
|
|
2396
|
+
await this.execOpenClawHealing([
|
|
2226
2397
|
"plugins",
|
|
2227
2398
|
"uninstall",
|
|
2228
2399
|
"--force",
|
|
2229
|
-
|
|
2400
|
+
pkg
|
|
2230
2401
|
], { timeout: 3e4 });
|
|
2231
2402
|
}
|
|
2232
2403
|
applySkill(name, srcPath) {
|
package/package.json
CHANGED