mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 10:30:52 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
383
openclaw/extensions/qa-lab/src/docker-harness.ts
Normal file
383
openclaw/extensions/qa-lab/src/docker-harness.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
|
||||
import {
|
||||
createQaChannelGatewayConfig,
|
||||
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
|
||||
} from "./qa-channel-transport.js";
|
||||
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
|
||||
|
||||
const QA_LAB_INTERNAL_PORT = 43123;
|
||||
const QA_LAB_UI_OVERLAY_DIR = "/opt/openclaw-qa-lab-ui";
|
||||
|
||||
function toPosixRelative(fromDir: string, toPath: string): string {
|
||||
return path.relative(fromDir, toPath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function renderImageBlock(params: {
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
imageName: string;
|
||||
usePrebuiltImage: boolean;
|
||||
}) {
|
||||
if (params.usePrebuiltImage) {
|
||||
return ` image: ${params.imageName}\n`;
|
||||
}
|
||||
const context = toPosixRelative(params.outputDir, params.repoRoot) || ".";
|
||||
return ` build:\n context: ${context}\n dockerfile: Dockerfile\n args:\n OPENCLAW_EXTENSIONS: "qa-channel qa-lab"\n`;
|
||||
}
|
||||
|
||||
function renderCompose(params: {
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
imageName: string;
|
||||
usePrebuiltImage: boolean;
|
||||
bindUiDist: boolean;
|
||||
gatewayPort: number;
|
||||
qaLabPort: number;
|
||||
gatewayToken: string;
|
||||
includeQaLabUi: boolean;
|
||||
}) {
|
||||
const imageBlock = renderImageBlock(params);
|
||||
const repoMount = toPosixRelative(params.outputDir, params.repoRoot) || ".";
|
||||
const qaLabUiMount = toPosixRelative(
|
||||
params.outputDir,
|
||||
path.join(params.repoRoot, "extensions", "qa-lab", "web", "dist"),
|
||||
);
|
||||
|
||||
return `services:
|
||||
qa-mock-openai:
|
||||
${imageBlock} pull_policy: never
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch("http://127.0.0.1:44080/healthz").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 3s
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- qa
|
||||
- mock-openai
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "44080"
|
||||
${
|
||||
params.includeQaLabUi
|
||||
? ` qa-lab:
|
||||
${imageBlock} pull_policy: never
|
||||
ports:
|
||||
- "${params.qaLabPort}:${QA_LAB_INTERNAL_PORT}"
|
||||
${params.bindUiDist ? ` volumes:\n - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` : ""} healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch("http://127.0.0.1:${QA_LAB_INTERNAL_PORT}/healthz").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 5s
|
||||
environment:
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1"
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1"
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1"
|
||||
OPENCLAW_PROFILE: ""
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- qa
|
||||
- ui
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "${QA_LAB_INTERNAL_PORT}"
|
||||
- --advertise-host
|
||||
- "127.0.0.1"
|
||||
- --advertise-port
|
||||
- "${params.qaLabPort}"
|
||||
- --control-ui-url
|
||||
- "http://127.0.0.1:${params.gatewayPort}/"
|
||||
- --control-ui-proxy-target
|
||||
- "http://openclaw-qa-gateway:18789/"
|
||||
- --control-ui-token
|
||||
- "${params.gatewayToken}"
|
||||
${params.bindUiDist ? ` - --ui-dist-dir\n - "${QA_LAB_UI_OVERLAY_DIR}"\n` : ""} - --auto-kickoff-target
|
||||
- direct
|
||||
- --send-kickoff-on-start
|
||||
- --embedded-gateway
|
||||
- disabled
|
||||
depends_on:
|
||||
qa-mock-openai:
|
||||
condition: service_healthy
|
||||
`
|
||||
: ""
|
||||
} openclaw-qa-gateway:
|
||||
${imageBlock} pull_policy: never
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${params.gatewayPort}:18789"
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw/openclaw.json
|
||||
OPENCLAW_STATE_DIR: /tmp/openclaw/state
|
||||
OPENCLAW_NO_RESPAWN: "1"
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1"
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1"
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1"
|
||||
OPENCLAW_PROFILE: ""
|
||||
volumes:
|
||||
- ./state:/opt/openclaw-scaffold:ro
|
||||
- ${repoMount}:/opt/openclaw-repo:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch("http://127.0.0.1:18789/healthz").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
${
|
||||
params.includeQaLabUi
|
||||
? ` qa-lab:
|
||||
condition: service_healthy
|
||||
`
|
||||
: ""
|
||||
} qa-mock-openai:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- sh
|
||||
- -lc
|
||||
- mkdir -p /tmp/openclaw/workspace /tmp/openclaw/state && cp /opt/openclaw-scaffold/openclaw.json /tmp/openclaw/openclaw.json && cp -R /opt/openclaw-scaffold/seed-workspace/. /tmp/openclaw/workspace/ && ln -snf /opt/openclaw-repo /tmp/openclaw/workspace/repo && exec node dist/index.js gateway run --port 18789 --bind lan --allow-unconfigured
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEnvExample(params: {
|
||||
gatewayPort: number;
|
||||
qaLabPort: number;
|
||||
gatewayToken: string;
|
||||
providerBaseUrl: string;
|
||||
qaBusBaseUrl: string;
|
||||
includeQaLabUi: boolean;
|
||||
}) {
|
||||
return `# QA Docker harness example env
|
||||
OPENCLAW_GATEWAY_TOKEN=${params.gatewayToken}
|
||||
QA_GATEWAY_PORT=${params.gatewayPort}
|
||||
QA_BUS_BASE_URL=${params.qaBusBaseUrl}
|
||||
QA_PROVIDER_BASE_URL=${params.providerBaseUrl}
|
||||
${params.includeQaLabUi ? `QA_LAB_URL=http://127.0.0.1:${params.qaLabPort}\n` : ""}`;
|
||||
}
|
||||
|
||||
function renderReadme(params: {
|
||||
gatewayPort: number;
|
||||
qaLabPort: number;
|
||||
usePrebuiltImage: boolean;
|
||||
bindUiDist: boolean;
|
||||
includeQaLabUi: boolean;
|
||||
}) {
|
||||
return `# QA Docker Harness
|
||||
|
||||
Generated scaffold for the Docker-backed QA lane.
|
||||
|
||||
Files:
|
||||
|
||||
- \`docker-compose.qa.yml\`
|
||||
- \`.env.example\`
|
||||
- \`state/openclaw.json\`
|
||||
|
||||
Suggested flow:
|
||||
|
||||
1. Build the prebaked image once:
|
||||
- \`docker build -t openclaw:qa-local-prebaked --build-arg OPENCLAW_EXTENSIONS="qa-channel qa-lab" -f Dockerfile .\`
|
||||
2. Start the stack:
|
||||
- \`docker compose -f docker-compose.qa.yml up${params.usePrebuiltImage ? "" : " --build"} -d\`
|
||||
3. Open the QA dashboard:
|
||||
- \`${params.includeQaLabUi ? `http://127.0.0.1:${params.qaLabPort}` : "not published in this scaffold"}\`
|
||||
4. The single QA site embeds both panes:
|
||||
- left: Control UI
|
||||
- right: Slack-ish QA lab
|
||||
5. The repo-backed kickoff task auto-injects on startup.
|
||||
|
||||
Fast UI refresh:
|
||||
|
||||
- Start once with a prebuilt image + bind-mounted QA Lab assets:
|
||||
- \`pnpm qa:lab:up --use-prebuilt-image --bind-ui-dist --skip-ui-build\`
|
||||
- In another shell, rebuild the QA Lab bundle on change:
|
||||
- \`pnpm qa:lab:watch\`
|
||||
- The browser auto-reloads when the QA Lab asset hash changes.
|
||||
|
||||
Gateway:
|
||||
|
||||
- health: \`http://127.0.0.1:${params.gatewayPort}/healthz\`
|
||||
- Control UI: \`http://127.0.0.1:${params.gatewayPort}/\`
|
||||
- Mock OpenAI: internal \`http://qa-mock-openai:44080/v1\`
|
||||
|
||||
This scaffold uses localhost Control UI insecure-auth compatibility for QA only.
|
||||
The gateway runs with in-process restarts inside Docker so restart actions do not
|
||||
kill the container by detaching a replacement child.
|
||||
`;
|
||||
}
|
||||
|
||||
export async function writeQaDockerHarnessFiles(params: {
|
||||
outputDir: string;
|
||||
repoRoot: string;
|
||||
gatewayPort?: number;
|
||||
qaLabPort?: number;
|
||||
gatewayToken?: string;
|
||||
providerBaseUrl?: string;
|
||||
qaBusBaseUrl?: string;
|
||||
imageName?: string;
|
||||
usePrebuiltImage?: boolean;
|
||||
bindUiDist?: boolean;
|
||||
includeQaLabUi?: boolean;
|
||||
}) {
|
||||
const gatewayPort = params.gatewayPort ?? 18789;
|
||||
const qaLabPort = params.qaLabPort ?? 43124;
|
||||
const gatewayToken = params.gatewayToken ?? `qa-token-${randomUUID()}`;
|
||||
const providerBaseUrl = params.providerBaseUrl ?? "http://qa-mock-openai:44080/v1";
|
||||
const qaBusBaseUrl = params.qaBusBaseUrl ?? "http://qa-lab:43123";
|
||||
const imageName = params.imageName ?? "openclaw:qa-local-prebaked";
|
||||
const usePrebuiltImage = params.usePrebuiltImage ?? false;
|
||||
const bindUiDist = params.bindUiDist ?? false;
|
||||
const includeQaLabUi = params.includeQaLabUi ?? true;
|
||||
|
||||
await fs.mkdir(path.join(params.outputDir, "state", "seed-workspace"), { recursive: true });
|
||||
await seedQaAgentWorkspace({
|
||||
workspaceDir: path.join(params.outputDir, "state", "seed-workspace"),
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
|
||||
const config = buildQaGatewayConfig({
|
||||
bind: "lan",
|
||||
gatewayPort: 18789,
|
||||
gatewayToken,
|
||||
providerBaseUrl,
|
||||
workspaceDir: "/tmp/openclaw/workspace",
|
||||
controlUiRoot: "/app/dist/control-ui",
|
||||
transportPluginIds: QA_CHANNEL_REQUIRED_PLUGIN_IDS,
|
||||
transportConfig: createQaChannelGatewayConfig({
|
||||
baseUrl: qaBusBaseUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
const files = [
|
||||
path.join(params.outputDir, "docker-compose.qa.yml"),
|
||||
path.join(params.outputDir, ".env.example"),
|
||||
path.join(params.outputDir, "README.md"),
|
||||
path.join(params.outputDir, "state", "openclaw.json"),
|
||||
];
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(params.outputDir, "docker-compose.qa.yml"),
|
||||
renderCompose({
|
||||
outputDir: params.outputDir,
|
||||
repoRoot: params.repoRoot,
|
||||
imageName,
|
||||
usePrebuiltImage,
|
||||
bindUiDist,
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
gatewayToken,
|
||||
includeQaLabUi,
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(params.outputDir, ".env.example"),
|
||||
renderEnvExample({
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
gatewayToken,
|
||||
providerBaseUrl,
|
||||
qaBusBaseUrl,
|
||||
includeQaLabUi,
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(params.outputDir, "README.md"),
|
||||
renderReadme({
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
usePrebuiltImage,
|
||||
bindUiDist,
|
||||
includeQaLabUi,
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(params.outputDir, "state", "openclaw.json"),
|
||||
`${JSON.stringify(config, null, 2)}\n`,
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
outputDir: params.outputDir,
|
||||
imageName,
|
||||
files: [
|
||||
...files,
|
||||
path.join(params.outputDir, "state", "seed-workspace", "IDENTITY.md"),
|
||||
path.join(params.outputDir, "state", "seed-workspace", "QA_KICKOFF_TASK.md"),
|
||||
path.join(params.outputDir, "state", "seed-workspace", "QA_SCENARIO_PLAN.md"),
|
||||
path.join(params.outputDir, "state", "seed-workspace", "QA_SCENARIOS.md"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildQaDockerHarnessImage(
|
||||
params: {
|
||||
repoRoot: string;
|
||||
imageName?: string;
|
||||
},
|
||||
deps?: {
|
||||
runCommand?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
},
|
||||
) {
|
||||
const imageName = params.imageName ?? "openclaw:qa-local-prebaked";
|
||||
const runCommand =
|
||||
deps?.runCommand ??
|
||||
(async (command: string, args: string[], cwd: string) => {
|
||||
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFile(command, args, { cwd }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await runCommand(
|
||||
"docker",
|
||||
[
|
||||
"build",
|
||||
"-t",
|
||||
imageName,
|
||||
"--build-arg",
|
||||
"OPENCLAW_EXTENSIONS=qa-channel qa-lab",
|
||||
"-f",
|
||||
"Dockerfile",
|
||||
".",
|
||||
],
|
||||
params.repoRoot,
|
||||
);
|
||||
|
||||
return { imageName };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue