mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Doctor: add health-check contract and --lint validation (#80055)
* feat(doctor): add --lint mode + structured HealthFinding shape
Adds the core machinery for `openclaw doctor --lint` per the
doctor-lint-and-oc-rules upstream proposal. PR-1 of the proposal:
no new top-level verb, no public plugin SDK; everything internal.
Files:
- src/flows/checks.ts ? HealthFinding / HealthCheck / HealthCheckContext
types. Findings carry severity per-finding; checks return
readonly HealthFinding[]. Mode tag (doctor/lint/fix) lets a check
distinguish the calling posture.
- src/flows/health-check-registry.ts ? module-level registry with
duplicate-id rejection + test reset helper.
- src/flows/doctor-lint-flow.ts ? runner over registered checks.
Catches throws into synthetic error findings (anchored at check id;
message scrubbed of control chars, capped at 256 bytes). Sorts
findings by severity desc, check id, path. Exports
exitCodeFromFindings (1 if any warning/error, 0 otherwise).
- src/flows/doctor-core-checks.ts ? 4 modern HealthChecks rewriting
logic from existing legacy run*Health functions:
core/doctor/gateway-config (warning)
core/doctor/command-owner (info)
core/doctor/workspace-status (info)
core/doctor/final-config-validation (error)
Each was audited safe per the proposal's adapter constraints
(no writes, no repair calls, no prompts, no probes incl. local-bind).
Legacy run*Health contributions in doctor-health-contributions.ts
are unchanged ? doctor mode (no --lint) still runs the existing 35.
- src/commands/doctor-lint.ts ? CLI dispatch for --lint. Reads config
snapshot, builds HealthCheckContext (mode: "lint"), runs the registry,
filters by --severity-min, emits human or JSON output, returns exit
code from unfiltered set so --severity-min hides info findings
without changing CI signal.
- src/cli/program/register.maintenance.ts ? adds --lint, --json,
--severity-min, --skip, --only flags to existing doctor command.
--lint branches to runDoctorLintCli; without --lint, doctor runs
unchanged.
LoC: 382 src across 6 files. Tests + doc + oc-path-side rule packs
follow as separate commits on this branch.
* fix: avoid string spread in doctor errors
* chore: refresh plugin SDK API baseline
* docs: clarify doctor lint usage
* feat(doctor): prepare repairs for dry-run reporting
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
d979b8c2721eeb83380a38853309e9ba0f2c28e040a9ad2ee1e7b2ab10c547db plugin-sdk-api-baseline.json
|
||||
4815f711fe2481483159137cfb97ce3d1c173e0b50a364a2353f49888f4d53df plugin-sdk-api-baseline.jsonl
|
||||
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
|
||||
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -15,13 +15,34 @@ Related:
|
||||
- Troubleshooting: [Troubleshooting](/gateway/troubleshooting)
|
||||
- Security audit: [Security](/gateway/security)
|
||||
|
||||
## Why Use It
|
||||
|
||||
`openclaw doctor` is the OpenClaw health surface. Use it when the gateway,
|
||||
channels, plugins, skills, model routing, local state, or config migrations are
|
||||
not behaving as expected and you want one command that can explain what is
|
||||
wrong.
|
||||
|
||||
Doctor has three postures:
|
||||
|
||||
| Posture | Command | Behavior |
|
||||
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
|
||||
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
|
||||
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
|
||||
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
|
||||
|
||||
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a
|
||||
human operator intentionally wants doctor to edit config or state.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
openclaw doctor
|
||||
openclaw doctor --repair
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --deep
|
||||
openclaw doctor --repair --non-interactive
|
||||
openclaw doctor --fix
|
||||
openclaw doctor --fix --non-interactive
|
||||
openclaw doctor --generate-gateway-token
|
||||
```
|
||||
|
||||
@@ -44,13 +65,134 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
|
||||
- `--generate-gateway-token`: generate and configure a gateway token
|
||||
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
|
||||
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output
|
||||
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
|
||||
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
|
||||
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
|
||||
|
||||
## Lint mode
|
||||
|
||||
`openclaw doctor --lint` is the read-only automation posture for doctor checks.
|
||||
It uses the structured health-check path, does not prompt, and does not repair
|
||||
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
|
||||
when you want machine-readable findings instead of guided repair prompts.
|
||||
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
|
||||
are only accepted with `--lint`.
|
||||
|
||||
```bash
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
```
|
||||
|
||||
Human output is compact:
|
||||
|
||||
```text
|
||||
doctor --lint: ran 5 check(s), 1 finding(s)
|
||||
[warning] core/doctor/gateway-config gateway.mode - gateway.mode is unset; gateway start will be blocked.
|
||||
fix: Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.
|
||||
```
|
||||
|
||||
JSON output is the scripting surface for lint runs:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"checksRun": 5,
|
||||
"checksSkipped": 0,
|
||||
"findings": [
|
||||
{
|
||||
"checkId": "core/doctor/gateway-config",
|
||||
"severity": "warning",
|
||||
"message": "gateway.mode is unset; gateway start will be blocked.",
|
||||
"path": "gateway.mode",
|
||||
"fixHint": "Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Exit behavior:
|
||||
|
||||
- `0`: no findings at or above the selected severity threshold
|
||||
- `1`: at least one finding meets the selected threshold
|
||||
- `2`: command/runtime failure before lint findings can be produced
|
||||
|
||||
`--severity-min` controls both visible findings and the exit threshold. For
|
||||
example, `openclaw doctor --lint --severity-min error` can print no findings and
|
||||
exit `0` even when lower-severity `info` or `warning` findings exist.
|
||||
|
||||
## Structured Health Checks
|
||||
|
||||
Modern doctor checks use a small structured contract:
|
||||
|
||||
```ts
|
||||
detect(ctx, scope?) -> HealthFinding[]
|
||||
repair?(ctx, findings) -> HealthRepairResult
|
||||
```
|
||||
|
||||
`detect()` powers `doctor --lint`. `repair()` is optional and is only considered
|
||||
by `doctor --fix` / `doctor --repair`. Checks that have not migrated to this
|
||||
shape continue to use the legacy doctor contribution flow.
|
||||
|
||||
The split is intentional: `detect()` owns diagnosis, while `repair()` owns
|
||||
reporting what it changed or would change. Repair contexts can carry
|
||||
`dryRun`/`diff` requests, and repair results can return structured `diffs` for
|
||||
config/file edits plus `effects` for service, process, package, state, or other
|
||||
side effects. That lets converted checks grow toward `doctor --fix --dry-run`
|
||||
and diff reporting without moving mutation planning into `detect()`.
|
||||
|
||||
`repair()` reports whether it attempted the requested repair with `status:
|
||||
"repaired" | "skipped" | "failed"`. Omitted status means `repaired`, so simple
|
||||
repair checks only need to return changes. When repair returns `skipped` or
|
||||
`failed`, doctor reports the reason and does not run validation for that check.
|
||||
|
||||
After a successful structured repair, doctor re-runs `detect()` with the
|
||||
repaired findings as scope. Checks can use selected findings, paths, or `ocPath`
|
||||
values for focused validation. If the finding is still present, doctor reports a
|
||||
repair warning instead of treating the change as silently complete.
|
||||
|
||||
A finding includes:
|
||||
|
||||
| Field | Purpose |
|
||||
| ----------------- | ------------------------------------------------------ |
|
||||
| `checkId` | Stable id for skip/only filters and CI allowlists. |
|
||||
| `severity` | `info`, `warning`, or `error`. |
|
||||
| `message` | Human-readable problem statement. |
|
||||
| `path` | Config, file, or logical path when available. |
|
||||
| `line` / `column` | Source location when available. |
|
||||
| `ocPath` | Precise `oc://` address when a check can point to one. |
|
||||
| `fixHint` | Suggested operator action or repair summary. |
|
||||
|
||||
This release registers the modernized core doctor checks on the structured
|
||||
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for bundled follow-up consumers, but plugin-backed checks only run
|
||||
after their owning package registers them in the active command path.
|
||||
|
||||
## Check Selection
|
||||
|
||||
Use `--only` and `--skip` when a workflow wants a focused gate:
|
||||
|
||||
```bash
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
openclaw doctor --lint --skip core/doctor/skills-readiness
|
||||
```
|
||||
|
||||
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`
|
||||
id is not registered, no check runs for that id; use the command's `checksRun`
|
||||
and `checksSkipped` fields to verify a focused gate is selecting the checks you
|
||||
expect.
|
||||
|
||||
Notes:
|
||||
|
||||
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
|
||||
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive doctor sessions still load the plugin surfaces needed by the legacy health and repair flow.
|
||||
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix` or `doctor --repair` when you want doctor to make changes.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
|
||||
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
|
||||
@@ -26,17 +26,28 @@ openclaw doctor
|
||||
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
|
||||
|
||||
</Tab>
|
||||
<Tab title="--repair">
|
||||
<Tab title="--fix">
|
||||
```bash
|
||||
openclaw doctor --repair
|
||||
openclaw doctor --fix
|
||||
```
|
||||
|
||||
Apply recommended repairs without prompting (repairs + restarts where safe).
|
||||
|
||||
</Tab>
|
||||
<Tab title="--repair --force">
|
||||
<Tab title="--lint">
|
||||
```bash
|
||||
openclaw doctor --repair --force
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --json
|
||||
```
|
||||
|
||||
Run structured health checks for CI or preflight automation. This mode is
|
||||
read-only: it does not prompt, repair, migrate config, restart services, or
|
||||
touch state.
|
||||
|
||||
</Tab>
|
||||
<Tab title="--fix --force">
|
||||
```bash
|
||||
openclaw doctor --fix --force
|
||||
```
|
||||
|
||||
Apply aggressive repairs too (overwrites custom supervisor configs).
|
||||
@@ -66,6 +77,57 @@ If you want to review changes before writing, open the config file first:
|
||||
cat ~/.openclaw/openclaw.json
|
||||
```
|
||||
|
||||
## Read-only lint mode
|
||||
|
||||
`openclaw doctor --lint` is the automation-friendly sibling of
|
||||
`openclaw doctor --fix`. Both use doctor health checks, but their posture is
|
||||
different:
|
||||
|
||||
| Mode | Prompts | Writes config/state | Output | Use it for |
|
||||
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
|
||||
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
|
||||
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
|
||||
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
|
||||
|
||||
Modernized health checks may provide an optional `repair()` implementation.
|
||||
`doctor --fix` applies those repairs when they exist and continues to use the
|
||||
existing doctor repair flow for checks that have not migrated yet.
|
||||
The structured repair contract also separates repair reporting from detection:
|
||||
`detect()` reports current findings, while `repair()` can report changes,
|
||||
config/file diffs, and non-file side effects. That keeps the migration path open
|
||||
for future `doctor --fix --dry-run` and diff output without making lint checks
|
||||
plan mutations.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
```
|
||||
|
||||
JSON output includes:
|
||||
|
||||
- `ok`: whether any visible finding met the selected severity threshold
|
||||
- `checksRun`: number of health checks executed
|
||||
- `checksSkipped`: checks skipped by `--only` or `--skip`
|
||||
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
|
||||
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0`: no findings at or above the selected threshold
|
||||
- `1`: one or more findings met the selected threshold
|
||||
- `2`: command/runtime failure before lint findings could be emitted
|
||||
|
||||
Use `--severity-min info|warning|error` to control both what is printed and what
|
||||
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
|
||||
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
|
||||
lint run active.
|
||||
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
|
||||
must be paired with `--lint`; regular doctor and repair runs reject them.
|
||||
|
||||
## What it does (summary)
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -471,8 +533,8 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
|
||||
- `openclaw doctor` prompts before rewriting supervisor config.
|
||||
- `openclaw doctor --yes` accepts the default repair prompts.
|
||||
- `openclaw doctor --repair` applies recommended fixes without prompts.
|
||||
- `openclaw doctor --repair --force` overwrites custom supervisor configs.
|
||||
- `openclaw doctor --fix` applies recommended fixes without prompts (`--repair` is an alias).
|
||||
- `openclaw doctor --fix --force` overwrites custom supervisor configs.
|
||||
- `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle.
|
||||
- On Linux, doctor does not rewrite command/entrypoint metadata while the matching systemd gateway unit is active. It also ignores inactive non-legacy extra gateway-like units during the duplicate-service scan so companion service files do not create cleanup noise.
|
||||
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
|
||||
|
||||
@@ -197,8 +197,7 @@ function subagentFanoutTaskForProvider(
|
||||
worker: "alpha" | "beta",
|
||||
) {
|
||||
const marker = worker === "alpha" ? "ALPHA-OK" : "BETA-OK";
|
||||
const scope =
|
||||
providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
|
||||
const scope = providerVariant === "anthropic" ? "the QA docs fixture" : "the QA workspace";
|
||||
return `Fanout worker ${worker}: inspect ${scope} and finish with exactly ${marker}.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
"types": "./dist/plugin-sdk/runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/runtime.js"
|
||||
},
|
||||
"./plugin-sdk/health": {
|
||||
"types": "./dist/plugin-sdk/health.d.ts",
|
||||
"default": "./dist/plugin-sdk/health.js"
|
||||
},
|
||||
"./plugin-sdk/runtime-doctor": {
|
||||
"types": "./dist/plugin-sdk/runtime-doctor.d.ts",
|
||||
"default": "./dist/plugin-sdk/runtime-doctor.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import {
|
||||
detectChangedLanesForPaths,
|
||||
listChangedPathsFromGit,
|
||||
@@ -66,12 +66,9 @@ function executableExistsOnPath(command, env = process.env) {
|
||||
export function shouldSkipAppLintForMissingSwiftlint(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const swiftlintAvailable =
|
||||
options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
|
||||
const swiftlintAvailable = options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
|
||||
return (
|
||||
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) &&
|
||||
platform !== "darwin" &&
|
||||
!swiftlintAvailable
|
||||
isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN) && platform !== "darwin" && !swiftlintAvailable
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export const pluginSdkDocMetadata = {
|
||||
core: {
|
||||
category: "core",
|
||||
},
|
||||
health: {
|
||||
category: "core",
|
||||
},
|
||||
"approval-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"self-hosted-provider-setup",
|
||||
"routing",
|
||||
"runtime",
|
||||
"health",
|
||||
"runtime-doctor",
|
||||
"runtime-env",
|
||||
"runtime-logger",
|
||||
|
||||
@@ -269,7 +269,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
{ commandPath: ["dashboard"], policy: { networkProxy: "bypass" } },
|
||||
{ commandPath: ["daemon"], policy: { networkProxy: "bypass" } },
|
||||
{ commandPath: ["devices"], policy: { networkProxy: "bypass" } },
|
||||
{ commandPath: ["doctor"], policy: { bypassConfigGuard: true } },
|
||||
{
|
||||
commandPath: ["doctor"],
|
||||
policy: {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
},
|
||||
},
|
||||
{ commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } },
|
||||
{ commandPath: ["hooks"], policy: { networkProxy: "bypass" } },
|
||||
{ commandPath: ["logs"], policy: { networkProxy: "bypass" } },
|
||||
|
||||
@@ -198,6 +198,10 @@ describe("command-path-policy", () => {
|
||||
loadPlugins: "never",
|
||||
networkProxy: "bypass",
|
||||
});
|
||||
expectResolvedPolicy(["doctor"], {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
});
|
||||
expectResolvedPolicy(["config", "validate"], {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("registerPreActionHooks", () => {
|
||||
.command("create")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
program.command("doctor").action(() => {});
|
||||
program.command("doctor").option("--lint").action(() => {});
|
||||
program.command("completion").action(() => {});
|
||||
program.command("secrets").action(() => {});
|
||||
program
|
||||
@@ -334,6 +334,16 @@ describe("registerPreActionHooks", () => {
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the config guard and plugin loading for doctor lint", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["doctor"],
|
||||
processArgv: ["node", "openclaw", "doctor", "--lint"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only allows invalid config for explicit official recovery reinstall requests", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["plugins", "install", "@openclaw/discord"],
|
||||
@@ -529,6 +539,7 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses config guard for config validate when root option values are present", async () => {
|
||||
@@ -538,6 +549,7 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses config guard for config schema", async () => {
|
||||
@@ -547,6 +559,7 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses config guard for backup create", async () => {
|
||||
@@ -556,6 +569,7 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
|
||||
@@ -595,7 +609,7 @@ describe("registerPreActionHooks", () => {
|
||||
preAction?: Array<(thisCommand: Command, actionCommand: Command) => Promise<void> | void>;
|
||||
};
|
||||
}
|
||||
)._lifeCycleHooks?.preAction;
|
||||
)["_lifeCycleHooks"]?.preAction;
|
||||
preActionHook = hooks?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
commandPath,
|
||||
startupPolicy,
|
||||
allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath),
|
||||
skipConfigGuard: shouldBypassConfigGuardForCommandPath(commandPath),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,9 +12,17 @@ const mocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
runDoctorLintCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const { doctorCommand, dashboardCommand, resetCommand, uninstallCommand, runtime } = mocks;
|
||||
const {
|
||||
doctorCommand,
|
||||
dashboardCommand,
|
||||
resetCommand,
|
||||
uninstallCommand,
|
||||
runtime,
|
||||
runDoctorLintCli,
|
||||
} = mocks;
|
||||
|
||||
vi.mock("../../commands/doctor.js", () => ({
|
||||
doctorCommand: mocks.doctorCommand,
|
||||
@@ -32,6 +40,10 @@ vi.mock("../../commands/uninstall.js", () => ({
|
||||
uninstallCommand: mocks.uninstallCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/doctor-lint.js", () => ({
|
||||
runDoctorLintCli: mocks.runDoctorLintCli,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
@@ -89,6 +101,51 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
expect(options.repair).toBe(true);
|
||||
});
|
||||
|
||||
it("runs doctor lint mode without invoking repair doctor", async () => {
|
||||
runDoctorLintCli.mockResolvedValue(1);
|
||||
|
||||
await runMaintenanceCli([
|
||||
"doctor",
|
||||
"--lint",
|
||||
"--json",
|
||||
"--severity-min",
|
||||
"error",
|
||||
"--skip",
|
||||
"a",
|
||||
"--only",
|
||||
"b",
|
||||
]);
|
||||
|
||||
expect(doctorCommand).not.toHaveBeenCalled();
|
||||
expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, {
|
||||
json: true,
|
||||
severityMin: "error",
|
||||
skipIds: ["a"],
|
||||
onlyIds: ["b"],
|
||||
});
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => {
|
||||
runDoctorLintCli.mockRejectedValue(new Error("lint failed"));
|
||||
|
||||
await runMaintenanceCli(["doctor", "--lint"]);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith("Error: lint failed");
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("rejects lint-only selectors outside lint mode", async () => {
|
||||
await runMaintenanceCli(["doctor", "--only", "core/example"]);
|
||||
|
||||
expect(doctorCommand).not.toHaveBeenCalled();
|
||||
expect(runDoctorLintCli).not.toHaveBeenCalled();
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("passes noOpen to dashboard command", async () => {
|
||||
dashboardCommand.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -25,7 +25,52 @@ export function registerMaintenanceCommands(program: Command) {
|
||||
.option("--non-interactive", "Run without prompts (safe migrations only)", false)
|
||||
.option("--generate-gateway-token", "Generate and configure a gateway token", false)
|
||||
.option("--deep", "Scan system services for extra gateway installs", false)
|
||||
.option("--lint", "Run read-only health checks and report findings", false)
|
||||
.option("--json", "With --lint: emit JSON findings instead of human output", false)
|
||||
.option(
|
||||
"--severity-min <level>",
|
||||
"With --lint: drop findings below this severity (info|warning|error)",
|
||||
)
|
||||
.option(
|
||||
"--skip <id>",
|
||||
"With --lint: skip a specific check id (repeatable)",
|
||||
(v: string, prev: string[]) => [...prev, v],
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--only <id>",
|
||||
"With --lint: run only the specified check id (repeatable)",
|
||||
(v: string, prev: string[]) => [...prev, v],
|
||||
[],
|
||||
)
|
||||
.action(async (opts) => {
|
||||
if (opts.lint === true) {
|
||||
await runCommandWithRuntime(
|
||||
defaultRuntime,
|
||||
async () => {
|
||||
const { runDoctorLintCli } = await import("../../commands/doctor-lint.js");
|
||||
const exitCode = await runDoctorLintCli(defaultRuntime, {
|
||||
json: Boolean(opts.json),
|
||||
severityMin: typeof opts.severityMin === "string" ? opts.severityMin : undefined,
|
||||
skipIds: Array.isArray(opts.skip) ? opts.skip : [],
|
||||
onlyIds: Array.isArray(opts.only) ? opts.only : [],
|
||||
});
|
||||
defaultRuntime.exit(exitCode);
|
||||
},
|
||||
(err) => {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(2);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (hasLintOnlyDoctorOptions(opts)) {
|
||||
defaultRuntime.error(
|
||||
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
|
||||
);
|
||||
defaultRuntime.exit(2);
|
||||
return;
|
||||
}
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await doctorCommand(defaultRuntime, {
|
||||
workspaceSuggestions: opts.workspaceSuggestions,
|
||||
@@ -113,3 +158,17 @@ export function registerMaintenanceCommands(program: Command) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasLintOnlyDoctorOptions(opts: {
|
||||
readonly json?: boolean;
|
||||
readonly severityMin?: unknown;
|
||||
readonly skip?: unknown;
|
||||
readonly only?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
opts.json === true ||
|
||||
typeof opts.severityMin === "string" ||
|
||||
(Array.isArray(opts.skip) && opts.skip.length > 0) ||
|
||||
(Array.isArray(opts.only) && opts.only.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2757,8 +2757,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
postCoreConfigSnapshot,
|
||||
preUpdateSourceConfig,
|
||||
);
|
||||
const parentPluginInstallRecords =
|
||||
await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath);
|
||||
const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile(
|
||||
postCoreInstallRecordsPath,
|
||||
);
|
||||
// The updated doctor may have repaired plugin installs before this fresh process resumed.
|
||||
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const pluginInstallRecords =
|
||||
|
||||
115
src/commands/doctor-lint.test.ts
Normal file
115
src/commands/doctor-lint.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetCoreHealthChecksForTest } from "../flows/doctor-core-checks.js";
|
||||
import { clearHealthChecksForTest } from "../flows/health-check-registry.js";
|
||||
import { runDoctorLintCli } from "./doctor-lint.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
describe("runDoctorLintCli", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearHealthChecksForTest();
|
||||
resetCoreHealthChecksForTest();
|
||||
});
|
||||
|
||||
it("bases exit code on the selected severity threshold", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
});
|
||||
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const exitCode = await runDoctorLintCli(runtime, {
|
||||
json: true,
|
||||
severityMin: "error",
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
|
||||
expect(String(stdout.mock.calls.at(-1)?.[0])).toContain('"findings":[]');
|
||||
} finally {
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the visible finding count in human output", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
});
|
||||
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
try {
|
||||
const exitCode = await runDoctorLintCli(runtime, {
|
||||
severityMin: "error",
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(String(stdout.mock.calls[0]?.[0])).toBe(
|
||||
"doctor --lint: ran 5 check(s), 0 finding(s)\n",
|
||||
);
|
||||
expect(String(stdout.mock.calls[1]?.[0])).toBe(" no findings\n");
|
||||
} finally {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsTTY });
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits structured JSON for invalid config snapshots", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: false,
|
||||
config: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [{ path: "gateway.mode", message: "Required" }],
|
||||
});
|
||||
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const exitCode = await runDoctorLintCli(runtime, { json: true });
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0]));
|
||||
expect(payload).toMatchObject({
|
||||
ok: false,
|
||||
checksRun: 1,
|
||||
findings: [
|
||||
{
|
||||
checkId: "core/doctor/final-config-validation",
|
||||
severity: "error",
|
||||
message: "Required",
|
||||
path: "gateway.mode",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid severity thresholds", async () => {
|
||||
await expect(runDoctorLintCli(runtime, { severityMin: "warnng" })).rejects.toThrow(
|
||||
"Invalid --severity-min value",
|
||||
);
|
||||
});
|
||||
});
|
||||
138
src/commands/doctor-lint.ts
Normal file
138
src/commands/doctor-lint.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import {
|
||||
configValidationIssuesToHealthFindings,
|
||||
registerCoreHealthChecks,
|
||||
} from "../flows/doctor-core-checks.js";
|
||||
import {
|
||||
exitCodeFromFindings,
|
||||
runDoctorLintChecks,
|
||||
type DoctorLintRunOptions,
|
||||
} from "../flows/doctor-lint-flow.js";
|
||||
import {
|
||||
healthFindingMeetsSeverity,
|
||||
parseHealthFindingSeverity,
|
||||
type HealthCheckContext,
|
||||
type HealthFinding,
|
||||
} from "../flows/health-checks.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export interface DoctorLintCliOptions {
|
||||
readonly json?: boolean;
|
||||
readonly severityMin?: string;
|
||||
readonly skipIds?: readonly string[];
|
||||
readonly onlyIds?: readonly string[];
|
||||
}
|
||||
|
||||
function detectMode(opts: DoctorLintCliOptions): "human" | "json" {
|
||||
if (opts.json === true) {
|
||||
return "json";
|
||||
}
|
||||
return process.stdout.isTTY ? "human" : "json";
|
||||
}
|
||||
|
||||
export async function runDoctorLintCli(
|
||||
runtime: RuntimeEnv,
|
||||
opts: DoctorLintCliOptions,
|
||||
): Promise<number> {
|
||||
registerCoreHealthChecks();
|
||||
|
||||
const sevMin =
|
||||
opts.severityMin === undefined ? "info" : parseHealthFindingSeverity(opts.severityMin);
|
||||
if (sevMin === null) {
|
||||
throw new Error("Invalid --severity-min value. Expected one of: info, warning, error.");
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot({ observe: false });
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
const findings = configValidationIssuesToHealthFindings(snapshot.issues);
|
||||
const visible = findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
|
||||
if (detectMode(opts) === "json") {
|
||||
writeJsonResult({
|
||||
ok: false,
|
||||
checksRun: 1,
|
||||
checksSkipped: 0,
|
||||
findings: visible,
|
||||
});
|
||||
} else {
|
||||
runtime.error("doctor --lint: config file exists but does not parse cleanly.");
|
||||
for (const issue of snapshot.issues) {
|
||||
const path = issue.path || "<root>";
|
||||
runtime.error(`- ${path}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
return exitCodeFromFindings(findings, sevMin);
|
||||
}
|
||||
|
||||
const ctx: HealthCheckContext = {
|
||||
mode: "lint",
|
||||
runtime,
|
||||
cfg: snapshot.config,
|
||||
cwd: resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config)),
|
||||
...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}),
|
||||
};
|
||||
|
||||
const runOpts: DoctorLintRunOptions = {
|
||||
...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}),
|
||||
...(opts.onlyIds && opts.onlyIds.length > 0 ? { onlyIds: opts.onlyIds } : {}),
|
||||
};
|
||||
const result = await runDoctorLintChecks(ctx, runOpts);
|
||||
const visible = result.findings.filter((finding) => healthFindingMeetsSeverity(finding, sevMin));
|
||||
|
||||
const mode = detectMode(opts);
|
||||
if (mode === "json") {
|
||||
writeJsonResult({
|
||||
ok: exitCodeFromFindings(result.findings, sevMin) === 0,
|
||||
checksRun: result.checksRun,
|
||||
checksSkipped: result.checksSkipped,
|
||||
findings: visible,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`doctor --lint: ran ${result.checksRun} check(s), ${visible.length} finding(s)\n`,
|
||||
);
|
||||
if (visible.length === 0) {
|
||||
process.stdout.write(" no findings\n");
|
||||
} else {
|
||||
for (const f of visible) {
|
||||
const where = f.path !== undefined ? ` ${f.path}` : "";
|
||||
const line = f.line !== undefined ? `:${f.line}` : "";
|
||||
process.stdout.write(` [${f.severity}] ${f.checkId}${where}${line} - ${f.message}\n`);
|
||||
if (f.fixHint !== undefined) {
|
||||
process.stdout.write(` fix: ${f.fixHint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exitCodeFromFindings(result.findings, sevMin);
|
||||
}
|
||||
|
||||
function writeJsonResult(result: {
|
||||
ok: boolean;
|
||||
checksRun: number;
|
||||
checksSkipped: number;
|
||||
findings: readonly HealthFinding[];
|
||||
}): void {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: result.ok,
|
||||
checksRun: result.checksRun,
|
||||
checksSkipped: result.checksSkipped,
|
||||
findings: result.findings.map(toJsonFinding),
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
function toJsonFinding(f: HealthFinding): Record<string, unknown> {
|
||||
return {
|
||||
checkId: f.checkId,
|
||||
severity: f.severity,
|
||||
message: f.message,
|
||||
...(f.source !== undefined ? { source: f.source } : {}),
|
||||
...(f.path !== undefined ? { path: f.path } : {}),
|
||||
...(f.line !== undefined ? { line: f.line } : {}),
|
||||
...(f.column !== undefined ? { column: f.column } : {}),
|
||||
...(f.ocPath !== undefined ? { ocPath: f.ocPath } : {}),
|
||||
...(f.fixHint !== undefined ? { fixHint: f.fixHint } : {}),
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export type {
|
||||
RuntimeConfigSnapshotMetadata,
|
||||
} from "./runtime-snapshot.js";
|
||||
export type {
|
||||
ConfigSnapshotReadOptions,
|
||||
ConfigWriteNotification,
|
||||
ConfigWriteResult,
|
||||
ReadConfigFileSnapshotWithPluginMetadataResult,
|
||||
|
||||
@@ -8,6 +8,23 @@ import {
|
||||
import { withTempHome, writeOpenClawConfig } from "./test-helpers.js";
|
||||
|
||||
describe("readBestEffortConfig", () => {
|
||||
it("can read snapshots without updating config observation state", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
|
||||
await readConfigFileSnapshot({ observe: false });
|
||||
|
||||
const healthPath = `${home}/.openclaw/logs/config-health.json`;
|
||||
await expect(fs.stat(healthPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
await readConfigFileSnapshot();
|
||||
|
||||
await expect(fs.stat(healthPath)).resolves.toMatchObject({ isFile: expect.any(Function) });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore suspicious direct edits from .bak during ordinary reads", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = await writeOpenClawConfig(home, {
|
||||
|
||||
@@ -887,6 +887,14 @@ export type ConfigIoDeps = {
|
||||
logger?: Pick<typeof console, "error" | "warn">;
|
||||
measure?: ConfigSnapshotReadMeasure;
|
||||
suppressFutureVersionWarning?: boolean;
|
||||
observe?: boolean;
|
||||
};
|
||||
|
||||
export type ConfigSnapshotReadOptions = {
|
||||
measure?: ConfigSnapshotReadMeasure;
|
||||
observe?: boolean;
|
||||
skipPluginValidation?: boolean;
|
||||
preservedLegacyRootKeys?: readonly string[];
|
||||
};
|
||||
|
||||
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
|
||||
@@ -946,6 +954,7 @@ function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
|
||||
logger: overrides.logger ?? console,
|
||||
measure: overrides.measure ?? (async (_name, run) => await run()),
|
||||
suppressFutureVersionWarning: overrides.suppressFutureVersionWarning ?? false,
|
||||
observe: overrides.observe ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1218,7 +1227,9 @@ async function finalizeReadConfigSnapshotInternalResult(
|
||||
deps: Required<ConfigIoDeps>,
|
||||
result: ReadConfigFileSnapshotInternalResult,
|
||||
): Promise<ReadConfigFileSnapshotInternalResult> {
|
||||
await observeConfigSnapshot(deps, result.snapshot);
|
||||
if (deps.observe) {
|
||||
await observeConfigSnapshot(deps, result.snapshot);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2382,15 +2393,14 @@ export async function readSourceConfigBestEffort(): Promise<OpenClawConfig> {
|
||||
return await createConfigIO().readSourceConfigBestEffort();
|
||||
}
|
||||
|
||||
export async function readConfigFileSnapshot(options?: {
|
||||
measure?: ConfigSnapshotReadMeasure;
|
||||
skipPluginValidation?: boolean;
|
||||
preservedLegacyRootKeys?: readonly string[];
|
||||
}): Promise<ConfigFileSnapshot> {
|
||||
export async function readConfigFileSnapshot(
|
||||
options: ConfigSnapshotReadOptions = {},
|
||||
): Promise<ConfigFileSnapshot> {
|
||||
return await createConfigIO({
|
||||
...(options?.measure ? { measure: options.measure } : {}),
|
||||
...(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}),
|
||||
...(options?.preservedLegacyRootKeys
|
||||
...(options.measure ? { measure: options.measure } : {}),
|
||||
...(options.observe === false ? { observe: false } : {}),
|
||||
...(options.skipPluginValidation ? { pluginValidation: "skip" } : {}),
|
||||
...(options.preservedLegacyRootKeys
|
||||
? { preservedLegacyRootKeys: options.preservedLegacyRootKeys }
|
||||
: {}),
|
||||
}).readConfigFileSnapshot();
|
||||
|
||||
150
src/flows/doctor-core-checks.test.ts
Normal file
150
src/flows/doctor-core-checks.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
CORE_HEALTH_CHECKS,
|
||||
registerCoreHealthChecks,
|
||||
resetCoreHealthChecksForTest,
|
||||
} from "./doctor-core-checks.js";
|
||||
import {
|
||||
clearHealthChecksForTest,
|
||||
listHealthChecks,
|
||||
registerHealthCheck,
|
||||
} from "./health-check-registry.js";
|
||||
|
||||
describe("registerCoreHealthChecks", () => {
|
||||
let tmp: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
clearHealthChecksForTest();
|
||||
resetCoreHealthChecksForTest();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmp !== undefined) {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
tmp = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the built-in health checks once", () => {
|
||||
registerCoreHealthChecks();
|
||||
registerCoreHealthChecks();
|
||||
|
||||
expect(listHealthChecks().map((check) => check.id)).toEqual([
|
||||
"core/doctor/gateway-config",
|
||||
"core/doctor/command-owner",
|
||||
"core/doctor/workspace-status",
|
||||
"core/doctor/skills-readiness",
|
||||
"core/doctor/final-config-validation",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can retry after a duplicate registration failure is cleared", () => {
|
||||
registerHealthCheck({
|
||||
id: "core/doctor/gateway-config",
|
||||
kind: "core",
|
||||
description: "duplicate",
|
||||
async detect() {
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => registerCoreHealthChecks()).toThrow("health check already registered");
|
||||
|
||||
clearHealthChecksForTest();
|
||||
registerCoreHealthChecks();
|
||||
|
||||
expect(listHealthChecks()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("shows the repair-capable health check shape with skills readiness", async () => {
|
||||
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-"));
|
||||
const skillDir = join(tmp, "skills", "missing-tool");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: missing-tool
|
||||
description: Missing tool
|
||||
metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}'
|
||||
---
|
||||
|
||||
# Missing tool
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmp,
|
||||
skills: ["missing-tool"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/skills-readiness");
|
||||
|
||||
expect(check?.repair).toBeTypeOf("function");
|
||||
|
||||
const findings = await check?.detect({
|
||||
mode: "lint",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(findings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/skills-readiness",
|
||||
severity: "warning",
|
||||
path: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
check?.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
{ paths: ["skills.entries.other-tool.enabled"] },
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
check?.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
{ paths: ["skills.entries.missing-tool.enabled"] },
|
||||
),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
|
||||
const repaired = await check?.repair?.(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
findings ?? [],
|
||||
);
|
||||
expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false });
|
||||
expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool.");
|
||||
expect(repaired?.effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "config",
|
||||
action: "disable-skill",
|
||||
target: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
239
src/flows/doctor-core-checks.ts
Normal file
239
src/flows/doctor-core-checks.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import { hasConfiguredCommandOwners } from "../commands/doctor-command-owner.js";
|
||||
import {
|
||||
collectUnavailableAgentSkills,
|
||||
disableUnavailableSkillsInConfig,
|
||||
} from "../commands/doctor-skills.js";
|
||||
import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||
import { registerHealthCheck } from "./health-check-registry.js";
|
||||
import type { HealthCheck, HealthFinding } from "./health-checks.js";
|
||||
|
||||
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
|
||||
|
||||
export function configValidationIssuesToHealthFindings(
|
||||
issues: readonly ConfigValidationIssue[],
|
||||
): readonly HealthFinding[] {
|
||||
return issues.map(
|
||||
(issue): HealthFinding => ({
|
||||
checkId: FINAL_CONFIG_VALIDATION_CHECK_ID,
|
||||
severity: "error",
|
||||
message: issue.message,
|
||||
path: issue.path || "<root>",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const gatewayConfigCheck: HealthCheck = {
|
||||
id: "core/doctor/gateway-config",
|
||||
kind: "core",
|
||||
description: "openclaw.jsonc gateway block is set and unambiguous.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const findings: HealthFinding[] = [];
|
||||
if (!ctx.cfg.gateway?.mode) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-config",
|
||||
severity: "warning",
|
||||
message: "gateway.mode is unset; gateway start will be blocked.",
|
||||
path: "gateway.mode",
|
||||
fixHint:
|
||||
"Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.",
|
||||
});
|
||||
}
|
||||
if (ctx.cfg.gateway?.mode !== "remote" && hasAmbiguousGatewayAuthModeConfig(ctx.cfg)) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-config",
|
||||
severity: "warning",
|
||||
message:
|
||||
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset; auth selection is ambiguous.",
|
||||
path: "gateway.auth.mode",
|
||||
fixHint:
|
||||
"Set an explicit mode: `openclaw config set gateway.auth.mode token` or `... password`.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
|
||||
const commandOwnerCheck: HealthCheck = {
|
||||
id: "core/doctor/command-owner",
|
||||
kind: "core",
|
||||
description: "An owner account is configured for owner-only commands.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
if (hasConfiguredCommandOwners(ctx.cfg)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "core/doctor/command-owner",
|
||||
severity: "info",
|
||||
message:
|
||||
"No command owner is configured. Owner-only commands (/diagnostics, /export-trajectory, /config, exec approvals) have no allowed sender.",
|
||||
path: "commands.ownerAllowFrom",
|
||||
fixHint:
|
||||
"Set commands.ownerAllowFrom to your channel user id, e.g. `openclaw config set commands.ownerAllowFrom '[\"telegram:123456789\"]'`.",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceStatusCheck: HealthCheck = {
|
||||
id: "core/doctor/workspace-status",
|
||||
kind: "core",
|
||||
description: "Workspace directory exists and has no legacy duplicates.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const { detectLegacyWorkspaceDirs } = await import("../commands/doctor-workspace.js");
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
const legacy = detectLegacyWorkspaceDirs({ workspaceDir });
|
||||
if (legacy.legacyDirs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "core/doctor/workspace-status",
|
||||
severity: "info",
|
||||
message: `Detected ${legacy.legacyDirs.length} legacy workspace director${
|
||||
legacy.legacyDirs.length === 1 ? "y" : "ies"
|
||||
} alongside the active workspace.`,
|
||||
path: workspaceDir,
|
||||
fixHint:
|
||||
"Inspect the legacy directories and migrate or remove them; see `openclaw doctor` for the detailed migration prompt.",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const skillsReadinessCheck: HealthCheck = {
|
||||
id: "core/doctor/skills-readiness",
|
||||
kind: "core",
|
||||
description: "Allowed skills are usable in the current runtime environment.",
|
||||
source: "doctor",
|
||||
async detect(ctx, scope) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
detectUnavailableSkills(ctx.cfg),
|
||||
scope?.paths,
|
||||
);
|
||||
return unavailable.map(unavailableSkillToFinding);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
detectUnavailableSkills(ctx.cfg),
|
||||
findings.map((finding) => finding.path),
|
||||
);
|
||||
if (unavailable.length === 0) {
|
||||
return { changes: [] };
|
||||
}
|
||||
const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable);
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`),
|
||||
effects: unavailable.map((skill) => ({
|
||||
kind: "config" as const,
|
||||
action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill",
|
||||
target: skillReadinessPath(skill),
|
||||
dryRunSafe: true,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function unavailableSkillToFinding(skill: SkillStatusEntry): HealthFinding {
|
||||
return {
|
||||
checkId: "core/doctor/skills-readiness",
|
||||
severity: "warning",
|
||||
message: `${skill.name} is allowed but unavailable: ${formatMissingSkillSummary(skill)}.`,
|
||||
path: skillReadinessPath(skill),
|
||||
fixHint:
|
||||
"Install/configure the missing requirement, or run `openclaw doctor --fix` to disable unused unavailable skills.",
|
||||
};
|
||||
}
|
||||
|
||||
function filterUnavailableSkillsForScope(
|
||||
unavailable: readonly SkillStatusEntry[],
|
||||
paths: readonly (string | undefined)[] | undefined,
|
||||
): SkillStatusEntry[] {
|
||||
const scopedPaths = new Set(paths?.filter((path): path is string => path !== undefined) ?? []);
|
||||
if (scopedPaths.size === 0) {
|
||||
return [...unavailable];
|
||||
}
|
||||
return unavailable.filter((skill) => scopedPaths.has(skillReadinessPath(skill)));
|
||||
}
|
||||
|
||||
function skillReadinessPath(skill: SkillStatusEntry): string {
|
||||
return `skills.entries.${skill.skillKey}.enabled`;
|
||||
}
|
||||
|
||||
const finalConfigValidationCheck: HealthCheck = {
|
||||
id: FINAL_CONFIG_VALIDATION_CHECK_ID,
|
||||
kind: "core",
|
||||
description: "Active openclaw.jsonc parses and conforms to the config schema.",
|
||||
source: "doctor",
|
||||
async detect() {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
if (!snap.exists || snap.valid) {
|
||||
return [];
|
||||
}
|
||||
return configValidationIssuesToHealthFindings(snap.issues);
|
||||
},
|
||||
};
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerCoreHealthChecks(): void {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
registerHealthCheck(gatewayConfigCheck);
|
||||
registerHealthCheck(commandOwnerCheck);
|
||||
registerHealthCheck(workspaceStatusCheck);
|
||||
registerHealthCheck(skillsReadinessCheck);
|
||||
registerHealthCheck(finalConfigValidationCheck);
|
||||
registered = true;
|
||||
}
|
||||
|
||||
export function resetCoreHealthChecksForTest(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = [
|
||||
gatewayConfigCheck,
|
||||
commandOwnerCheck,
|
||||
workspaceStatusCheck,
|
||||
skillsReadinessCheck,
|
||||
finalConfigValidationCheck,
|
||||
];
|
||||
|
||||
function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: cfg,
|
||||
agentId,
|
||||
});
|
||||
return collectUnavailableAgentSkills(report);
|
||||
}
|
||||
|
||||
function formatMissingSkillSummary(skill: SkillStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
if (skill.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.anyBins.length > 0) {
|
||||
missing.push(`any bins: ${skill.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.env.length > 0) {
|
||||
missing.push(`env: ${skill.missing.env.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.config.length > 0) {
|
||||
missing.push(`config: ${skill.missing.config.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.os.length > 0) {
|
||||
missing.push(`os: ${skill.missing.os.join(", ")}`);
|
||||
}
|
||||
return missing.join("; ") || "unknown requirement";
|
||||
}
|
||||
16
src/flows/doctor-error-message.ts
Normal file
16
src/flows/doctor-error-message.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const ERR_MESSAGE_MAX_LEN = 256;
|
||||
|
||||
export function scrubDoctorErrorMessage(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
let stripped = "";
|
||||
for (let index = 0; index < raw.length; index++) {
|
||||
const code = raw.charCodeAt(index);
|
||||
if (code > 0x1f && code !== 0x7f) {
|
||||
stripped += raw.charAt(index);
|
||||
}
|
||||
}
|
||||
if (stripped.length <= ERR_MESSAGE_MAX_LEN) {
|
||||
return stripped;
|
||||
}
|
||||
return `${stripped.slice(0, ERR_MESSAGE_MAX_LEN - 3)}...`;
|
||||
}
|
||||
@@ -170,6 +170,18 @@ describe("doctor health contributions", () => {
|
||||
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
|
||||
});
|
||||
|
||||
it("runs structured repairs before legacy skill repairs and config writes", () => {
|
||||
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
|
||||
|
||||
expect(ids.indexOf("doctor:structured-health-repairs")).toBeGreaterThan(-1);
|
||||
expect(ids.indexOf("doctor:structured-health-repairs")).toBeLessThan(
|
||||
ids.indexOf("doctor:skills"),
|
||||
);
|
||||
expect(ids.indexOf("doctor:structured-health-repairs")).toBeLessThan(
|
||||
ids.indexOf("doctor:write-config"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips doctor config writes under legacy update parents", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
|
||||
@@ -228,6 +228,34 @@ async function runCommandOwnerHealth(ctx: DoctorHealthFlowContext): Promise<void
|
||||
noteCommandOwnerHealth(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
if (!ctx.prompter.shouldRepair) {
|
||||
return;
|
||||
}
|
||||
const { registerCoreHealthChecks } = await import("./doctor-core-checks.js");
|
||||
const { runDoctorHealthRepairs } = await import("./doctor-repair-flow.js");
|
||||
const { resolveAgentWorkspaceDir, resolveDefaultAgentId } =
|
||||
await import("../agents/agent-scope.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
|
||||
registerCoreHealthChecks();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
const result = await runDoctorHealthRepairs({
|
||||
mode: "fix",
|
||||
runtime: ctx.runtime,
|
||||
cfg: ctx.cfg,
|
||||
cwd: workspaceDir,
|
||||
configPath: ctx.configPath,
|
||||
});
|
||||
ctx.cfg = result.config;
|
||||
if (result.changes.length > 0) {
|
||||
note(result.changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
note(result.warnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
}
|
||||
|
||||
async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js");
|
||||
noteClaudeCliHealth(ctx.cfg);
|
||||
@@ -709,6 +737,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
label: "Command owner",
|
||||
run: runCommandOwnerHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:structured-health-repairs",
|
||||
label: "Structured health repairs",
|
||||
run: runStructuredHealthRepairs,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:legacy-state",
|
||||
label: "Legacy state",
|
||||
|
||||
65
src/flows/doctor-lint-flow.test.ts
Normal file
65
src/flows/doctor-lint-flow.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { exitCodeFromFindings, runDoctorLintChecks } from "./doctor-lint-flow.js";
|
||||
import type { HealthCheck, HealthCheckContext } from "./health-checks.js";
|
||||
|
||||
const ctx: HealthCheckContext = {
|
||||
mode: "lint",
|
||||
runtime: {
|
||||
log() {},
|
||||
error() {},
|
||||
exit() {},
|
||||
},
|
||||
cfg: {},
|
||||
};
|
||||
|
||||
function check(id: string, detect: HealthCheck["detect"]): HealthCheck {
|
||||
return {
|
||||
id,
|
||||
kind: "core",
|
||||
description: id,
|
||||
detect,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runDoctorLintChecks", () => {
|
||||
it("filters selected checks and reports skipped count", async () => {
|
||||
const result = await runDoctorLintChecks(ctx, {
|
||||
checks: [
|
||||
check("a", async () => [{ checkId: "a", severity: "warning", message: "warn" }]),
|
||||
check("b", async () => [{ checkId: "b", severity: "error", message: "err" }]),
|
||||
],
|
||||
onlyIds: ["a"],
|
||||
});
|
||||
|
||||
expect(result.checksRun).toBe(1);
|
||||
expect(result.checksSkipped).toBe(1);
|
||||
expect(result.findings.map((finding) => finding.checkId)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("turns thrown checks into error findings", async () => {
|
||||
const result = await runDoctorLintChecks(ctx, {
|
||||
checks: [
|
||||
check("boom", async () => {
|
||||
throw new Error("nope");
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
{
|
||||
checkId: "boom",
|
||||
severity: "error",
|
||||
message: "health check threw: nope",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitCodeFromFindings", () => {
|
||||
it("uses the selected severity threshold", () => {
|
||||
const findings = [{ checkId: "a", severity: "warning" as const, message: "warn" }];
|
||||
|
||||
expect(exitCodeFromFindings(findings, "warning")).toBe(1);
|
||||
expect(exitCodeFromFindings(findings, "error")).toBe(0);
|
||||
});
|
||||
});
|
||||
85
src/flows/doctor-lint-flow.ts
Normal file
85
src/flows/doctor-lint-flow.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { listHealthChecks } from "./health-check-registry.js";
|
||||
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
|
||||
import {
|
||||
HEALTH_FINDING_SEVERITY_RANK,
|
||||
healthFindingMeetsSeverity,
|
||||
type HealthCheck,
|
||||
type HealthCheckContext,
|
||||
type HealthFinding,
|
||||
type HealthFindingSeverity,
|
||||
} from "./health-checks.js";
|
||||
|
||||
export interface DoctorLintRunOptions {
|
||||
readonly checks?: readonly HealthCheck[];
|
||||
readonly skipIds?: ReadonlySet<string> | readonly string[];
|
||||
readonly onlyIds?: ReadonlySet<string> | readonly string[];
|
||||
}
|
||||
|
||||
export interface DoctorLintRunResult {
|
||||
readonly findings: readonly HealthFinding[];
|
||||
readonly checksRun: number;
|
||||
readonly checksSkipped: number;
|
||||
}
|
||||
|
||||
export async function runDoctorLintChecks(
|
||||
ctx: HealthCheckContext,
|
||||
opts: DoctorLintRunOptions = {},
|
||||
): Promise<DoctorLintRunResult> {
|
||||
const all = opts.checks ?? listHealthChecks();
|
||||
const skip = opts.skipIds instanceof Set ? opts.skipIds : new Set(opts.skipIds ?? []);
|
||||
const only = opts.onlyIds instanceof Set ? opts.onlyIds : new Set(opts.onlyIds ?? []);
|
||||
|
||||
const selected = all.filter((c) => {
|
||||
if (only.size > 0 && !only.has(c.id)) {
|
||||
return false;
|
||||
}
|
||||
if (skip.has(c.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const findings: HealthFinding[] = [];
|
||||
for (const check of selected) {
|
||||
try {
|
||||
const out = await check.detect(ctx);
|
||||
for (const f of out) {
|
||||
findings.push(f);
|
||||
}
|
||||
} catch (err) {
|
||||
findings.push({
|
||||
checkId: check.id,
|
||||
severity: "error",
|
||||
message: `health check threw: ${scrubDoctorErrorMessage(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
findings.sort(compareFindings);
|
||||
|
||||
return {
|
||||
findings,
|
||||
checksRun: selected.length,
|
||||
checksSkipped: all.length - selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
function compareFindings(a: HealthFinding, b: HealthFinding): number {
|
||||
const sevDelta =
|
||||
HEALTH_FINDING_SEVERITY_RANK[b.severity] - HEALTH_FINDING_SEVERITY_RANK[a.severity];
|
||||
if (sevDelta !== 0) {
|
||||
return sevDelta;
|
||||
}
|
||||
const idDelta = a.checkId.localeCompare(b.checkId);
|
||||
if (idDelta !== 0) {
|
||||
return idDelta;
|
||||
}
|
||||
return (a.path ?? "").localeCompare(b.path ?? "");
|
||||
}
|
||||
|
||||
export function exitCodeFromFindings(
|
||||
findings: readonly HealthFinding[],
|
||||
severityMin: HealthFindingSeverity = "warning",
|
||||
): 0 | 1 {
|
||||
return findings.some((f) => healthFindingMeetsSeverity(f, severityMin)) ? 1 : 0;
|
||||
}
|
||||
223
src/flows/doctor-repair-flow.test.ts
Normal file
223
src/flows/doctor-repair-flow.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { runDoctorHealthRepairs } from "./doctor-repair-flow.js";
|
||||
import type { HealthCheck, HealthRepairContext } from "./health-checks.js";
|
||||
|
||||
function ctx(cfg: OpenClawConfig): HealthRepairContext {
|
||||
return {
|
||||
mode: "fix",
|
||||
runtime: {
|
||||
log() {},
|
||||
error() {},
|
||||
exit() {},
|
||||
},
|
||||
cfg,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runDoctorHealthRepairs", () => {
|
||||
it("repairs modern checks and threads updated config", async () => {
|
||||
const scopes: unknown[] = [];
|
||||
const checks: HealthCheck[] = [
|
||||
{
|
||||
id: "test/repairable",
|
||||
kind: "core",
|
||||
description: "repairable",
|
||||
async detect(ctx, scope) {
|
||||
if (scope !== undefined) {
|
||||
scopes.push(scope);
|
||||
}
|
||||
return ctx.cfg.gateway?.mode === "local"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
checkId: "test/repairable",
|
||||
severity: "warning",
|
||||
message: "gateway mode missing",
|
||||
path: "gateway.mode",
|
||||
},
|
||||
];
|
||||
},
|
||||
async repair(ctx) {
|
||||
return {
|
||||
config: { ...ctx.cfg, gateway: { ...ctx.cfg.gateway, mode: "local" } },
|
||||
changes: ["Set gateway.mode to local."],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runDoctorHealthRepairs(ctx({}), { checks });
|
||||
|
||||
expect(result.config.gateway?.mode).toBe("local");
|
||||
expect(result.changes).toEqual(["Set gateway.mode to local."]);
|
||||
expect(result.checksRepaired).toBe(1);
|
||||
expect(result.checksValidated).toBe(1);
|
||||
expect(result.remainingFindings).toEqual([]);
|
||||
expect(scopes).toMatchObject([{ paths: ["gateway.mode"] }]);
|
||||
});
|
||||
|
||||
it("leaves non-repairable checks for legacy doctor behavior", async () => {
|
||||
const checks: HealthCheck[] = [
|
||||
{
|
||||
id: "test/legacy-only",
|
||||
kind: "core",
|
||||
description: "legacy only",
|
||||
async detect() {
|
||||
return [
|
||||
{
|
||||
checkId: "test/legacy-only",
|
||||
severity: "warning",
|
||||
message: "legacy repair still owns this finding",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runDoctorHealthRepairs(ctx({}), { checks });
|
||||
|
||||
expect(result.config).toEqual({});
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.remainingFindings).toEqual([]);
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.checksRepaired).toBe(0);
|
||||
expect(result.checksValidated).toBe(0);
|
||||
});
|
||||
|
||||
it("reports repair validation findings that remain after repair", async () => {
|
||||
const checks: HealthCheck[] = [
|
||||
{
|
||||
id: "test/not-fixed",
|
||||
kind: "core",
|
||||
description: "not fixed",
|
||||
async detect() {
|
||||
return [
|
||||
{
|
||||
checkId: "test/not-fixed",
|
||||
severity: "warning",
|
||||
message: "still broken",
|
||||
ocPath: "oc://openclaw.json/gateway.mode",
|
||||
},
|
||||
];
|
||||
},
|
||||
async repair() {
|
||||
return {
|
||||
changes: ["Tried repair."],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runDoctorHealthRepairs(ctx({}), { checks });
|
||||
|
||||
expect(result.checksRepaired).toBe(1);
|
||||
expect(result.checksValidated).toBe(1);
|
||||
expect(result.remainingFindings).toMatchObject([
|
||||
{
|
||||
checkId: "test/not-fixed",
|
||||
ocPath: "oc://openclaw.json/gateway.mode",
|
||||
},
|
||||
]);
|
||||
expect(result.warnings).toEqual(["test/not-fixed repair left 1 finding(s)"]);
|
||||
});
|
||||
|
||||
it("does not validate skipped or failed repair results", async () => {
|
||||
let validationCalls = 0;
|
||||
const checks: HealthCheck[] = [
|
||||
{
|
||||
id: "test/skipped",
|
||||
kind: "core",
|
||||
description: "skipped",
|
||||
async detect() {
|
||||
validationCalls++;
|
||||
return [
|
||||
{
|
||||
checkId: "test/skipped",
|
||||
severity: "warning",
|
||||
message: "needs manual repair",
|
||||
},
|
||||
];
|
||||
},
|
||||
async repair() {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "manual confirmation required",
|
||||
changes: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runDoctorHealthRepairs(ctx({}), { checks });
|
||||
|
||||
expect(validationCalls).toBe(1);
|
||||
expect(result.checksRepaired).toBe(0);
|
||||
expect(result.checksValidated).toBe(0);
|
||||
expect(result.remainingFindings).toEqual([]);
|
||||
expect(result.warnings).toEqual(["test/skipped repair skipped: manual confirmation required"]);
|
||||
});
|
||||
|
||||
it("supports dry-run repairs without applying returned config or validating", async () => {
|
||||
const repairContexts: HealthRepairContext[] = [];
|
||||
let detectCalls = 0;
|
||||
const checks: HealthCheck[] = [
|
||||
{
|
||||
id: "test/dry-run",
|
||||
kind: "core",
|
||||
description: "dry run",
|
||||
async detect(ctx) {
|
||||
detectCalls++;
|
||||
return ctx.cfg.gateway?.mode === "local"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
checkId: "test/dry-run",
|
||||
severity: "warning",
|
||||
message: "gateway mode missing",
|
||||
path: "gateway.mode",
|
||||
},
|
||||
];
|
||||
},
|
||||
async repair(ctx) {
|
||||
repairContexts.push(ctx);
|
||||
return {
|
||||
config: { ...ctx.cfg, gateway: { ...ctx.cfg.gateway, mode: "local" } },
|
||||
changes: ["Would set gateway.mode to local."],
|
||||
diffs: [
|
||||
{
|
||||
kind: "config",
|
||||
path: "gateway.mode",
|
||||
before: undefined,
|
||||
after: "local",
|
||||
},
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
kind: "config",
|
||||
action: "would-set",
|
||||
target: "gateway.mode",
|
||||
dryRunSafe: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runDoctorHealthRepairs(ctx({}), {
|
||||
checks,
|
||||
dryRun: true,
|
||||
diff: true,
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({});
|
||||
expect(result.changes).toEqual(["Would set gateway.mode to local."]);
|
||||
expect(result.diffs).toMatchObject([{ kind: "config", path: "gateway.mode" }]);
|
||||
expect(result.effects).toMatchObject([{ kind: "config", action: "would-set" }]);
|
||||
expect(result.checksRepaired).toBe(1);
|
||||
expect(result.checksValidated).toBe(0);
|
||||
expect(detectCalls).toBe(1);
|
||||
expect(repairContexts[0]).toMatchObject({ dryRun: true, diff: true });
|
||||
});
|
||||
});
|
||||
123
src/flows/doctor-repair-flow.ts
Normal file
123
src/flows/doctor-repair-flow.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
|
||||
import { listHealthChecks } from "./health-check-registry.js";
|
||||
import type {
|
||||
HealthCheck,
|
||||
HealthFinding,
|
||||
HealthRepairContext,
|
||||
HealthRepairDiff,
|
||||
HealthRepairEffect,
|
||||
} from "./health-checks.js";
|
||||
|
||||
export interface DoctorRepairRunOptions {
|
||||
readonly checks?: readonly HealthCheck[];
|
||||
readonly dryRun?: boolean;
|
||||
readonly diff?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorRepairRunResult {
|
||||
readonly config: OpenClawConfig;
|
||||
readonly findings: readonly HealthFinding[];
|
||||
readonly remainingFindings: readonly HealthFinding[];
|
||||
readonly changes: readonly string[];
|
||||
readonly warnings: readonly string[];
|
||||
readonly diffs: readonly HealthRepairDiff[];
|
||||
readonly effects: readonly HealthRepairEffect[];
|
||||
readonly checksRun: number;
|
||||
readonly checksRepaired: number;
|
||||
readonly checksValidated: number;
|
||||
}
|
||||
|
||||
export async function runDoctorHealthRepairs(
|
||||
ctx: HealthRepairContext,
|
||||
opts: DoctorRepairRunOptions = {},
|
||||
): Promise<DoctorRepairRunResult> {
|
||||
const checks = opts.checks ?? listHealthChecks();
|
||||
const findings: HealthFinding[] = [];
|
||||
const remainingFindings: HealthFinding[] = [];
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const diffs: HealthRepairDiff[] = [];
|
||||
const effects: HealthRepairEffect[] = [];
|
||||
let cfg = ctx.cfg;
|
||||
let checksRepaired = 0;
|
||||
let checksValidated = 0;
|
||||
|
||||
for (const check of checks) {
|
||||
const detectCtx: HealthRepairContext = { ...ctx, cfg };
|
||||
let checkFindings: readonly HealthFinding[];
|
||||
try {
|
||||
checkFindings = await check.detect(detectCtx);
|
||||
} catch (err) {
|
||||
warnings.push(`${check.id} detect failed: ${scrubDoctorErrorMessage(err)}`);
|
||||
continue;
|
||||
}
|
||||
findings.push(...checkFindings);
|
||||
if (checkFindings.length === 0 || check.repair === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await check.repair(
|
||||
{ ...ctx, cfg, dryRun: opts.dryRun === true, diff: opts.diff === true },
|
||||
checkFindings,
|
||||
);
|
||||
warnings.push(...(result.warnings ?? []));
|
||||
diffs.push(...(result.diffs ?? []));
|
||||
effects.push(...(result.effects ?? []));
|
||||
const status = result.status ?? "repaired";
|
||||
if (status !== "repaired") {
|
||||
warnings.push(`${check.id} repair ${status}${result.reason ? `: ${result.reason}` : ""}`);
|
||||
continue;
|
||||
}
|
||||
if (result.config !== undefined && opts.dryRun !== true) {
|
||||
cfg = result.config;
|
||||
}
|
||||
changes.push(...result.changes);
|
||||
checksRepaired++;
|
||||
if (opts.dryRun === true) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const validationFindings = await check.detect(
|
||||
{ ...ctx, cfg },
|
||||
createValidationScope(checkFindings),
|
||||
);
|
||||
remainingFindings.push(...validationFindings);
|
||||
checksValidated++;
|
||||
if (validationFindings.length > 0) {
|
||||
warnings.push(`${check.id} repair left ${validationFindings.length} finding(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`${check.id} validation failed: ${scrubDoctorErrorMessage(err)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`${check.id} repair failed: ${scrubDoctorErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: cfg,
|
||||
findings,
|
||||
remainingFindings,
|
||||
changes,
|
||||
warnings,
|
||||
diffs,
|
||||
effects,
|
||||
checksRun: checks.length,
|
||||
checksRepaired,
|
||||
checksValidated,
|
||||
};
|
||||
}
|
||||
|
||||
function createValidationScope(findings: readonly HealthFinding[]) {
|
||||
return {
|
||||
findings,
|
||||
paths: uniqueDefined(findings.map((finding) => finding.path)),
|
||||
ocPaths: uniqueDefined(findings.map((finding) => finding.ocPath)),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueDefined(values: readonly (string | undefined)[]): readonly string[] {
|
||||
return [...new Set(values.filter((value): value is string => value !== undefined))];
|
||||
}
|
||||
30
src/flows/health-check-registry.ts
Normal file
30
src/flows/health-check-registry.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { HealthCheck } from "./health-checks.js";
|
||||
|
||||
const REGISTRY = new Map<string, HealthCheck>();
|
||||
|
||||
export class HealthCheckRegistrationError extends Error {
|
||||
readonly code = "OC_DOCTOR_DUPLICATE_CHECK";
|
||||
constructor(readonly checkId: string) {
|
||||
super(`health check already registered: ${checkId}`);
|
||||
this.name = "HealthCheckRegistrationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHealthCheck(check: HealthCheck): void {
|
||||
if (REGISTRY.has(check.id)) {
|
||||
throw new HealthCheckRegistrationError(check.id);
|
||||
}
|
||||
REGISTRY.set(check.id, check);
|
||||
}
|
||||
|
||||
export function listHealthChecks(): readonly HealthCheck[] {
|
||||
return [...REGISTRY.values()];
|
||||
}
|
||||
|
||||
export function getHealthCheck(id: string): HealthCheck | undefined {
|
||||
return REGISTRY.get(id);
|
||||
}
|
||||
|
||||
export function clearHealthChecksForTest(): void {
|
||||
REGISTRY.clear();
|
||||
}
|
||||
99
src/flows/health-checks.ts
Normal file
99
src/flows/health-checks.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export type HealthFindingSeverity = "info" | "warning" | "error";
|
||||
|
||||
export const HEALTH_FINDING_SEVERITY_RANK: Record<HealthFindingSeverity, number> = {
|
||||
info: 0,
|
||||
warning: 1,
|
||||
error: 2,
|
||||
};
|
||||
|
||||
export function parseHealthFindingSeverity(
|
||||
input: string | undefined,
|
||||
): HealthFindingSeverity | null {
|
||||
if (input === "info" || input === "warning" || input === "error") {
|
||||
return input;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function healthFindingMeetsSeverity(
|
||||
finding: Pick<HealthFinding, "severity">,
|
||||
severityMin: HealthFindingSeverity,
|
||||
): boolean {
|
||||
return (
|
||||
HEALTH_FINDING_SEVERITY_RANK[finding.severity] >= HEALTH_FINDING_SEVERITY_RANK[severityMin]
|
||||
);
|
||||
}
|
||||
|
||||
export interface HealthFinding {
|
||||
readonly checkId: string;
|
||||
readonly severity: HealthFindingSeverity;
|
||||
readonly message: string;
|
||||
readonly source?: string;
|
||||
readonly path?: string;
|
||||
readonly line?: number;
|
||||
readonly column?: number;
|
||||
readonly ocPath?: string;
|
||||
readonly fixHint?: string;
|
||||
}
|
||||
|
||||
export type HealthCheckMode = "doctor" | "lint" | "fix";
|
||||
|
||||
export interface HealthCheckContext {
|
||||
readonly mode: HealthCheckMode;
|
||||
readonly runtime: RuntimeEnv;
|
||||
readonly cfg: OpenClawConfig;
|
||||
readonly cwd?: string;
|
||||
readonly configPath?: string;
|
||||
}
|
||||
|
||||
export interface HealthRepairContext extends Omit<HealthCheckContext, "mode"> {
|
||||
readonly mode: "fix";
|
||||
readonly dryRun?: boolean;
|
||||
readonly diff?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthRepairDiff {
|
||||
readonly kind: "config" | "file";
|
||||
readonly path: string;
|
||||
readonly before?: string;
|
||||
readonly after?: string;
|
||||
readonly unifiedDiff?: string;
|
||||
}
|
||||
|
||||
export interface HealthRepairEffect {
|
||||
readonly kind: "config" | "file" | "service" | "process" | "package" | "state" | "other";
|
||||
readonly action: string;
|
||||
readonly target?: string;
|
||||
readonly dryRunSafe?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthRepairResult {
|
||||
readonly status?: "repaired" | "skipped" | "failed";
|
||||
readonly reason?: string;
|
||||
readonly config?: OpenClawConfig;
|
||||
readonly changes: readonly string[];
|
||||
readonly warnings?: readonly string[];
|
||||
readonly diffs?: readonly HealthRepairDiff[];
|
||||
readonly effects?: readonly HealthRepairEffect[];
|
||||
}
|
||||
|
||||
export interface HealthCheckScope {
|
||||
readonly findings?: readonly HealthFinding[];
|
||||
readonly paths?: readonly string[];
|
||||
readonly ocPaths?: readonly string[];
|
||||
}
|
||||
|
||||
export interface HealthCheck {
|
||||
readonly id: string;
|
||||
readonly kind: "core" | "plugin";
|
||||
readonly description: string;
|
||||
readonly source?: string;
|
||||
detect(ctx: HealthCheckContext, scope?: HealthCheckScope): Promise<readonly HealthFinding[]>;
|
||||
repair?(
|
||||
ctx: HealthRepairContext,
|
||||
findings: readonly HealthFinding[],
|
||||
): Promise<HealthRepairResult>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
|
||||
import { stripAnsi } from "../terminal/ansi.js";
|
||||
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
||||
30
src/plugin-sdk/health.ts
Normal file
30
src/plugin-sdk/health.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
export { readConfigFileSnapshot } from "../config/config.js";
|
||||
export type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
export {
|
||||
configValidationIssuesToHealthFindings,
|
||||
registerCoreHealthChecks,
|
||||
} from "../flows/doctor-core-checks.js";
|
||||
export {
|
||||
exitCodeFromFindings,
|
||||
runDoctorLintChecks,
|
||||
type DoctorLintRunOptions,
|
||||
} from "../flows/doctor-lint-flow.js";
|
||||
export {
|
||||
healthFindingMeetsSeverity,
|
||||
parseHealthFindingSeverity,
|
||||
type HealthCheck,
|
||||
type HealthCheckContext,
|
||||
type HealthCheckScope,
|
||||
type HealthFinding,
|
||||
type HealthFindingSeverity,
|
||||
type HealthRepairDiff,
|
||||
type HealthRepairEffect,
|
||||
type HealthRepairContext,
|
||||
type HealthRepairResult,
|
||||
} from "../flows/health-checks.js";
|
||||
export {
|
||||
getHealthCheck,
|
||||
listHealthChecks,
|
||||
registerHealthCheck,
|
||||
} from "../flows/health-check-registry.js";
|
||||
Reference in New Issue
Block a user