feat: gate claw runtime selection by availability

This commit is contained in:
kris
2026-04-03 02:11:41 +08:00
parent 6c999fb951
commit 8e2350e89d
19 changed files with 564 additions and 123 deletions

View File

@@ -51,7 +51,7 @@ function resolveBackendByProvider(provider: AiProvider): ExecutionBackendChoice
function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendSelectionInput) {
if (choice.backendId === CLAW_BACKEND.backendId) {
const requestKind = input.requestKind;
if (!input.claw?.enabled || !requestKind) {
if (!input.claw?.selectable || !requestKind) {
return false;
}
return isClawRequestKindSupported(requestKind);

View File

@@ -6,8 +6,10 @@ import type {
ExecutionRequestKind,
} from "@/lib/execution/types";
import {
getClawBackendAvailability,
getClawBackendConfig,
isClawBackendConfigured,
type ClawBackendAvailability,
type ClawBackendConfig,
} from "@/lib/execution/backends/claw-config";
import { runClawCommand } from "@/lib/execution/backends/claw-runner";
@@ -30,6 +32,8 @@ type ClawRunner = (input: ClawRunnerInput) => Promise<ExecutionImmediateResult>;
export interface ClawBackendSelectionState {
enabled: boolean;
selectable: boolean;
availability: ClawBackendAvailability;
supportsKinds: ExecutionRequestKind[];
}
@@ -45,11 +49,14 @@ export function isClawRequestKindSupported(kind: ExecutionRequestKind) {
return SUPPORTED_CLAW_KINDS.has(kind);
}
export function getClawBackendSelectionState(
export async function getClawBackendSelectionState(
config: ClawBackendConfig = getClawBackendConfig(),
): ClawBackendSelectionState {
): Promise<ClawBackendSelectionState> {
const availability = await getClawBackendAvailability(config);
return {
enabled: isClawBackendConfigured(config),
selectable: availability.selectable,
availability,
supportsKinds: [...SUPPORTED_CLAW_KINDS],
};
}

View File

@@ -1,3 +1,7 @@
import { access } from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
export interface ClawBackendConfig {
enabled: boolean;
command?: string;
@@ -7,6 +11,24 @@ export interface ClawBackendConfig {
defaultModel?: string;
}
export type ClawBackendAvailabilityStatus = "disabled" | "misconfigured" | "ready";
export interface ClawBackendAvailability {
status: ClawBackendAvailabilityStatus;
selectable: boolean;
configured: boolean;
reason:
| "disabled"
| "command_not_set"
| "command_not_found"
| "workdir_not_found"
| "script_not_found"
| "ready";
reasonLabel: string;
command?: string;
cwd?: string;
}
function parseBoolean(value: string | undefined) {
return value?.trim().toLowerCase() === "true";
}
@@ -38,5 +60,127 @@ export function isClawBackendConfigured(config: ClawBackendConfig) {
return config.enabled && Boolean(config.command);
}
function commandLooksLikePath(command: string) {
return command.includes("/") || command.includes("\\");
}
async function fileExists(filePath: string, mode = constants.F_OK) {
try {
await access(filePath, mode);
return true;
} catch {
return false;
}
}
function resolveScriptCandidate(config: ClawBackendConfig) {
if (!config.command || config.args.length === 0) {
return null;
}
const commandName = path.basename(config.command).toLowerCase();
const scriptRuntimes = new Set(["node", "node.exe", "tsx", "tsx.cmd", "bun", "bun.exe", "deno", "deno.exe"]);
if (!scriptRuntimes.has(commandName)) {
return null;
}
const candidate = config.args[0];
if (!candidate || candidate.startsWith("-")) {
return null;
}
return path.isAbsolute(candidate)
? candidate
: path.resolve(config.cwd?.trim() || process.cwd(), candidate);
}
async function isCommandReachable(command: string) {
if (commandLooksLikePath(command)) {
return fileExists(path.resolve(command), constants.X_OK);
}
const searchPaths = (process.env.PATH || "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
for (const entry of searchPaths) {
const candidate = path.join(entry, command);
if (await fileExists(candidate, constants.X_OK)) {
return true;
}
}
return false;
}
export async function getClawBackendAvailability(
config: ClawBackendConfig = getClawBackendConfig(),
): Promise<ClawBackendAvailability> {
const base = {
command: config.command,
cwd: config.cwd,
configured: isClawBackendConfigured(config),
};
if (!config.enabled) {
return {
...base,
status: "disabled",
selectable: false,
reason: "disabled",
reasonLabel: "Claw Runtime 当前未启用。",
};
}
if (!config.command) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_set",
reasonLabel: "Claw Runtime 缺少启动命令。",
};
}
if (!(await isCommandReachable(config.command))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_found",
reasonLabel: "未检测到可执行的 Claw 启动命令。",
};
}
if (config.cwd && !(await fileExists(config.cwd, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "workdir_not_found",
reasonLabel: "Claw Runtime 工作目录不存在。",
};
}
const scriptCandidate = resolveScriptCandidate(config);
if (scriptCandidate && !(await fileExists(scriptCandidate, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "script_not_found",
reasonLabel: "未检测到有效的 Claw 启动脚本,将自动回退到默认后端。",
};
}
return {
...base,
status: "ready",
selectable: true,
reason: "ready",
reasonLabel: "Claw Runtime 可用。",
};
}
export const getClawBackendConfigForTesting = getClawBackendConfig;
export const isClawBackendConfiguredForTesting = isClawBackendConfigured;
export const getClawBackendAvailabilityForTesting = getClawBackendAvailability;