feat: add claw backend adapter
This commit is contained in:
@@ -3,11 +3,17 @@ import {
|
||||
ALIYUN_QWEN_BACKEND,
|
||||
isReadyAliyunQwenBackend,
|
||||
} from "@/lib/execution/backends/aliyun-qwen-backend";
|
||||
import {
|
||||
CLAW_BACKEND,
|
||||
type ClawBackendSelectionState,
|
||||
isClawRequestKindSupported,
|
||||
} from "@/lib/execution/backends/claw-backend";
|
||||
import {
|
||||
MASTER_CODEX_NODE_BACKEND,
|
||||
isReadyMasterCodexNodeBackend,
|
||||
} from "@/lib/execution/backends/master-codex-node-backend";
|
||||
import { OPENAI_BACKEND, isReadyOpenAiBackend } from "@/lib/execution/backends/openai-backend";
|
||||
import type { ExecutionRequestKind } from "@/lib/execution/types";
|
||||
|
||||
export interface ExecutionBackendSelectionInput {
|
||||
primary: {
|
||||
@@ -18,9 +24,13 @@ export interface ExecutionBackendSelectionInput {
|
||||
provider: AiProvider;
|
||||
status: AiAccountStatus;
|
||||
}>;
|
||||
requestKind?: ExecutionRequestKind;
|
||||
requestedBackendId?: string;
|
||||
claw?: ClawBackendSelectionState;
|
||||
}
|
||||
|
||||
export type ExecutionBackendChoice =
|
||||
| typeof CLAW_BACKEND
|
||||
| typeof MASTER_CODEX_NODE_BACKEND
|
||||
| typeof OPENAI_BACKEND
|
||||
| typeof ALIYUN_QWEN_BACKEND;
|
||||
@@ -39,6 +49,14 @@ 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) {
|
||||
return false;
|
||||
}
|
||||
return isClawRequestKindSupported(requestKind);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...(input.primary.provider === choice.provider ? [input.primary] : []),
|
||||
...input.backups.filter((item) => item.provider === choice.provider),
|
||||
@@ -79,6 +97,13 @@ export function listExecutionBackendChoices(
|
||||
seen.add(backend.backendId);
|
||||
};
|
||||
|
||||
if (
|
||||
input.requestedBackendId === CLAW_BACKEND.backendId &&
|
||||
isReadyBackend(CLAW_BACKEND, input)
|
||||
) {
|
||||
pushBackend(CLAW_BACKEND);
|
||||
}
|
||||
|
||||
if (input.primary.status === "ready") {
|
||||
pushBackend(primaryBackend);
|
||||
}
|
||||
|
||||
103
src/lib/execution/backends/claw-backend.ts
Normal file
103
src/lib/execution/backends/claw-backend.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ExecutionBackend } from "@/lib/execution/execution-backend";
|
||||
import type {
|
||||
ExecutionBackendDescription,
|
||||
ExecutionImmediateResult,
|
||||
ExecutionRequest,
|
||||
ExecutionRequestKind,
|
||||
} from "@/lib/execution/types";
|
||||
import {
|
||||
getClawBackendConfig,
|
||||
isClawBackendConfigured,
|
||||
type ClawBackendConfig,
|
||||
} from "@/lib/execution/backends/claw-config";
|
||||
import { runClawCommand } from "@/lib/execution/backends/claw-runner";
|
||||
|
||||
export const CLAW_BACKEND_ID = "claw-runtime";
|
||||
|
||||
export const CLAW_BACKEND = {
|
||||
backendId: CLAW_BACKEND_ID,
|
||||
label: "Claw Runtime",
|
||||
mode: "local",
|
||||
} as const satisfies ExecutionBackendDescription;
|
||||
|
||||
const SUPPORTED_CLAW_KINDS = new Set<ExecutionRequestKind>([
|
||||
"master_agent_reply",
|
||||
"thread_reply",
|
||||
]);
|
||||
|
||||
type ClawRunnerInput = Parameters<typeof runClawCommand>[0];
|
||||
type ClawRunner = (input: ClawRunnerInput) => Promise<ExecutionImmediateResult>;
|
||||
|
||||
export interface ClawBackendSelectionState {
|
||||
enabled: boolean;
|
||||
supportsKinds: ExecutionRequestKind[];
|
||||
}
|
||||
|
||||
function createFailedResult(error: string): ExecutionImmediateResult {
|
||||
return {
|
||||
status: "failed",
|
||||
backendId: CLAW_BACKEND_ID,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function isClawRequestKindSupported(kind: ExecutionRequestKind) {
|
||||
return SUPPORTED_CLAW_KINDS.has(kind);
|
||||
}
|
||||
|
||||
export function getClawBackendSelectionState(
|
||||
config: ClawBackendConfig = getClawBackendConfig(),
|
||||
): ClawBackendSelectionState {
|
||||
return {
|
||||
enabled: isClawBackendConfigured(config),
|
||||
supportsKinds: [...SUPPORTED_CLAW_KINDS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildClawPayload(input: ExecutionRequest, config: ClawBackendConfig) {
|
||||
return {
|
||||
kind: input.kind,
|
||||
projectId: input.projectId,
|
||||
requestMessageId: input.requestMessageId,
|
||||
body: input.body,
|
||||
executionPrompt: input.executionPrompt ?? input.body,
|
||||
model: input.modelOverride ?? config.defaultModel,
|
||||
reasoningEffort: input.reasoningEffortOverride ?? "medium",
|
||||
...(input.targetProjectId ? { targetProjectId: input.targetProjectId } : {}),
|
||||
...(input.targetThreadId ? { targetThreadId: input.targetThreadId } : {}),
|
||||
...(input.requestedByAccount ? { requestedByAccount: input.requestedByAccount } : {}),
|
||||
...(input.requestedByLabel ? { requestedByLabel: input.requestedByLabel } : {}),
|
||||
...(input.taskId ? { taskId: input.taskId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createClawBackend(options?: {
|
||||
config?: ClawBackendConfig;
|
||||
runner?: ClawRunner;
|
||||
}): ExecutionBackend {
|
||||
const config = options?.config ?? getClawBackendConfig();
|
||||
const runner = options?.runner ?? runClawCommand;
|
||||
|
||||
return {
|
||||
backendId: CLAW_BACKEND_ID,
|
||||
async canHandle(input) {
|
||||
return isClawBackendConfigured(config) && isClawRequestKindSupported(input.kind);
|
||||
},
|
||||
async execute(input) {
|
||||
const canHandle = await this.canHandle(input);
|
||||
if (!canHandle) {
|
||||
return createFailedResult("CLAW_BACKEND_NOT_AVAILABLE");
|
||||
}
|
||||
return runner({
|
||||
config,
|
||||
payload: buildClawPayload(input, config),
|
||||
});
|
||||
},
|
||||
async describe() {
|
||||
return CLAW_BACKEND;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const CLAW_BACKEND_ADAPTER = createClawBackend();
|
||||
export const createClawBackendForTesting = createClawBackend;
|
||||
42
src/lib/execution/backends/claw-config.ts
Normal file
42
src/lib/execution/backends/claw-config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface ClawBackendConfig {
|
||||
enabled: boolean;
|
||||
command?: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined) {
|
||||
return value?.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function parseArgs(value: string | undefined) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value: string | undefined) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
|
||||
}
|
||||
|
||||
export function getClawBackendConfig(): ClawBackendConfig {
|
||||
return {
|
||||
enabled: parseBoolean(process.env.BOSS_CLAW_ENABLED),
|
||||
command: process.env.BOSS_CLAW_COMMAND?.trim() || undefined,
|
||||
args: parseArgs(process.env.BOSS_CLAW_ARGS),
|
||||
cwd: process.env.BOSS_CLAW_WORKDIR?.trim() || undefined,
|
||||
timeoutMs: parseTimeoutMs(process.env.BOSS_CLAW_TIMEOUT_MS),
|
||||
defaultModel: process.env.BOSS_CLAW_DEFAULT_MODEL?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isClawBackendConfigured(config: ClawBackendConfig) {
|
||||
return config.enabled && Boolean(config.command);
|
||||
}
|
||||
|
||||
export const getClawBackendConfigForTesting = getClawBackendConfig;
|
||||
export const isClawBackendConfiguredForTesting = isClawBackendConfigured;
|
||||
118
src/lib/execution/backends/claw-runner.ts
Normal file
118
src/lib/execution/backends/claw-runner.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
import type { ClawBackendConfig } from "@/lib/execution/backends/claw-config";
|
||||
import type { ExecutionImmediateResult } from "@/lib/execution/types";
|
||||
|
||||
const CLAW_BACKEND_ID = "claw-runtime";
|
||||
|
||||
function createFailedResult(error: string): ExecutionImmediateResult {
|
||||
return {
|
||||
status: "failed",
|
||||
backendId: CLAW_BACKEND_ID,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClawProcessResult(input: {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): ExecutionImmediateResult {
|
||||
if (input.exitCode !== 0) {
|
||||
return createFailedResult(input.stderr.trim() || `CLAW_EXIT_${input.exitCode}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.stdout);
|
||||
} catch {
|
||||
return createFailedResult("INVALID_CLAW_RESPONSE");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
(parsed as { status?: unknown }).status === "completed" &&
|
||||
typeof (parsed as { output?: unknown }).output === "string"
|
||||
) {
|
||||
return {
|
||||
status: "completed",
|
||||
backendId: CLAW_BACKEND_ID,
|
||||
output: (parsed as { output: string }).output,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
(parsed as { status?: unknown }).status === "failed" &&
|
||||
typeof (parsed as { error?: unknown }).error === "string"
|
||||
) {
|
||||
return createFailedResult((parsed as { error: string }).error);
|
||||
}
|
||||
|
||||
return createFailedResult("INVALID_CLAW_RESPONSE");
|
||||
}
|
||||
|
||||
export async function runClawCommand(input: {
|
||||
config: ClawBackendConfig;
|
||||
payload: unknown;
|
||||
}): Promise<ExecutionImmediateResult> {
|
||||
const command = input.config.command;
|
||||
if (!command) {
|
||||
return createFailedResult("CLAW_COMMAND_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child: ChildProcessWithoutNullStreams = spawn(command, input.config.args, {
|
||||
cwd: input.config.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"] as const,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: ExecutionImmediateResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
finish(createFailedResult("CLAW_TIMEOUT"));
|
||||
}, input.config.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
child.on("error", (error: Error) => {
|
||||
finish(createFailedResult(error.message));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
finish(
|
||||
normalizeClawProcessResult({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
child.stdin.end(JSON.stringify(input.payload ?? null));
|
||||
});
|
||||
}
|
||||
|
||||
export const runClawCommandForTesting = runClawCommand;
|
||||
export const createClawProcessResultForTesting = normalizeClawProcessResult;
|
||||
@@ -11,8 +11,10 @@ export interface ExecutionRequest {
|
||||
projectId: string;
|
||||
requestMessageId: string;
|
||||
body: string;
|
||||
executionPrompt?: string;
|
||||
requestedByAccount?: string;
|
||||
requestedByLabel?: string;
|
||||
requestedBackendId?: string;
|
||||
taskId?: string;
|
||||
targetThreadId?: string;
|
||||
targetProjectId?: string;
|
||||
@@ -55,8 +57,10 @@ export function createExecutionRequest(input: ExecutionRequest): ExecutionReques
|
||||
projectId: input.projectId,
|
||||
requestMessageId: input.requestMessageId,
|
||||
body: input.body,
|
||||
executionPrompt: input.executionPrompt ?? undefined,
|
||||
requestedByAccount: input.requestedByAccount ?? undefined,
|
||||
requestedByLabel: input.requestedByLabel ?? undefined,
|
||||
requestedBackendId: input.requestedBackendId ?? undefined,
|
||||
taskId: input.taskId ?? undefined,
|
||||
targetThreadId: input.targetThreadId ?? undefined,
|
||||
targetProjectId: input.targetProjectId ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user