feat: 拆分 HWPOD Temporal 服务与独立 Web
This commit is contained in:
@@ -26,6 +26,16 @@ if (argv[0] === "tasktree") {
|
||||
console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else if (argv[0] === "hwpod" && argv[1] === "service") {
|
||||
const { hwpodNativeServiceCommand } = await import("../../src/hwpod-native-service.ts");
|
||||
try {
|
||||
const result = await hwpodNativeServiceCommand({ service: argv[2], action: argv[3] ?? "status", cwd: process.cwd(), env: process.env });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exitCode = result?.ok === false ? 1 : 0;
|
||||
} catch (error: any) {
|
||||
console.log(JSON.stringify({ ok: false, operation: "hwpod", error: { code: error?.code ?? "hwpod_cli_error", message: error?.message ?? String(error) } }, null, 2));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else if (argv[0] === "workbench") {
|
||||
const { runWorkbenchCli } = await import("../../src/workbench-cli.ts");
|
||||
try {
|
||||
|
||||
+1
-1
@@ -872,9 +872,9 @@ class WebSocketNodeClient:
|
||||
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||||
host = parsed.hostname or ""
|
||||
raw = socket.create_connection((host, port), timeout=10)
|
||||
raw.settimeout(1.0)
|
||||
self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
|
||||
self._handshake(parsed, host, port)
|
||||
self.sock.settimeout(1.0)
|
||||
self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
|
||||
self._send_json({
|
||||
"type": "register",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010103 HWPOD 服务。
|
||||
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
|
||||
* 责任: 通过一个受控入口独立管理 HWPOD L1 API、worker 和 Web。
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type ServiceName = "api" | "worker" | "web";
|
||||
|
||||
export async function hwpodNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record<string, string | undefined> }) {
|
||||
const service = validService(input.service);
|
||||
const stateDir = path.resolve(input.cwd, requiredEnv(input.env, "HWPOD_NATIVE_SERVICE_STATE_DIR"));
|
||||
const stateFile = path.join(stateDir, `${service}.json`);
|
||||
const logFile = path.join(stateDir, `${service}.log`);
|
||||
if (input.action === "status") return status(service, stateFile, logFile);
|
||||
if (input.action === "logs") return logs(service, stateFile, logFile);
|
||||
if (input.action === "stop") return stop(service, stateFile, logFile);
|
||||
if (input.action === "restart") { await stop(service, stateFile, logFile); return start(service, stateDir, stateFile, logFile, input.cwd, input.env); }
|
||||
if (input.action === "start") return start(service, stateDir, stateFile, logFile, input.cwd, input.env);
|
||||
throw codedError("unsupported_service_action", `unsupported HWPOD service action: ${input.action}`);
|
||||
}
|
||||
|
||||
async function start(service: ServiceName, stateDir: string, stateFile: string, logFile: string, cwd: string, env: Record<string, string | undefined>) {
|
||||
const current = await readState(stateFile);
|
||||
if (current?.pid && alive(current.pid)) throw codedError("service_already_running", `${service} is already running`, { pid: current.pid, correctionCommand: `hwlab-cli hwpod service ${service} stop` });
|
||||
await mkdir(stateDir, { recursive: true });
|
||||
const endpoints = serviceEndpoints(service, env);
|
||||
const command = serviceCommand(service);
|
||||
const logFd = openSync(logFile, "w");
|
||||
const child = spawn(command[0], command.slice(1), { cwd: service === "web" ? path.join(cwd, "web/hwlab-cloud-web") : cwd, env: serviceEnv(service, env), detached: true, stdio: ["ignore", logFd, logFd] });
|
||||
closeSync(logFd);
|
||||
child.unref();
|
||||
const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, healthUrl: endpoints.probe.url, startedAt: new Date().toISOString() };
|
||||
await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
return { ok: true, operation: `hwpod.service.${service}.start`, ...state, nextCommand: `hwlab-cli hwpod service ${service} status` };
|
||||
}
|
||||
|
||||
async function stop(service: ServiceName, stateFile: string, logFile: string) {
|
||||
const state = await readState(stateFile);
|
||||
if (!state?.pid || !alive(state.pid)) { await rm(stateFile, { force: true }); return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", alreadyStopped: true, logFile }; }
|
||||
try { process.kill(-state.pid, "SIGTERM"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; }
|
||||
await rm(stateFile, { force: true });
|
||||
return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", pid: state.pid, logFile };
|
||||
}
|
||||
|
||||
async function status(service: ServiceName, stateFile: string, logFile: string) {
|
||||
const state = await readState(stateFile);
|
||||
const processRunning = Boolean(state?.pid && alive(state.pid));
|
||||
const health = processRunning && state?.healthUrl ? await probeHealth(state.healthUrl) : { ok: false, status: null, error: processRunning ? "health-url-not-declared" : "process-not-running" };
|
||||
const running = processRunning && health.ok;
|
||||
return { ok: running, operation: `hwpod.service.${service}.status`, service, status: running ? "running" : processRunning ? "degraded" : "stopped", pid: processRunning ? state.pid : null, endpoints: state?.endpoints ?? null, health, stateFile, logFile, nextCommand: `hwlab-cli hwpod service ${service} logs` };
|
||||
}
|
||||
|
||||
async function logs(service: ServiceName, stateFile: string, logFile: string) {
|
||||
const current = await status(service, stateFile, logFile);
|
||||
const lines = (await readFile(logFile, "utf8").catch(() => "")).split("\n").filter(Boolean).slice(-80);
|
||||
return { ...current, operation: `hwpod.service.${service}.logs`, lines, truncated: lines.length === 80 };
|
||||
}
|
||||
|
||||
function serviceCommand(service: ServiceName) {
|
||||
if (service === "api") return ["bun", "cmd/hwlab-hwpod-api/main.ts"];
|
||||
if (service === "worker") return ["bun", "cmd/hwlab-hwpod-worker/main.ts"];
|
||||
return ["bun", "run", "dev:native-hwpod"];
|
||||
}
|
||||
function serviceEnv(service: ServiceName, env: Record<string, string | undefined>) {
|
||||
if (service === "api") return { ...process.env, ...env, HWPOD_API_HOST: requiredEnv(env, "HWPOD_API_BIND_HOST"), HWPOD_API_PORT: requiredEnv(env, "HWPOD_API_PORT") };
|
||||
if (service === "worker") return { ...process.env, ...env, HWPOD_WORKER_HEALTH_HOST: requiredEnv(env, "HWPOD_WORKER_BIND_HOST"), HWPOD_WORKER_HEALTH_PORT: requiredEnv(env, "HWPOD_WORKER_HEALTH_PORT") };
|
||||
return { ...process.env, ...env, HWPOD_WEB_HOST: requiredEnv(env, "HWPOD_WEB_BIND_HOST"), HWPOD_WEB_PORT: requiredEnv(env, "HWPOD_WEB_PORT"), HWPOD_NATIVE_API_URL: requiredEnv(env, "HWPOD_NATIVE_API_URL") };
|
||||
}
|
||||
function serviceEndpoints(service: ServiceName, env: Record<string, string | undefined>) {
|
||||
const prefix = service === "worker" ? "HWPOD_WORKER" : service === "api" ? "HWPOD_API" : "HWPOD_WEB";
|
||||
const bindHost = requiredEnv(env, `${prefix}_BIND_HOST`);
|
||||
const probeHost = requiredEnv(env, `${prefix}_PROBE_HOST`);
|
||||
const port = requiredEnv(env, service === "worker" ? "HWPOD_WORKER_HEALTH_PORT" : `${prefix}_PORT`);
|
||||
const publicBaseUrl = fixedHttpsOrigin(requiredEnv(env, "HWPOD_PUBLIC_BASE_URL"), "HWPOD_PUBLIC_BASE_URL");
|
||||
const healthPath = service === "web" ? "/health/live" : "/health/ready";
|
||||
return { bind: { host: bindHost, port: validPort(port) }, probe: endpoint(probeHost, port, healthPath), public: service === "worker" ? null : { url: publicBaseUrl } };
|
||||
}
|
||||
function endpoint(host: string, port: string, pathname: string) { const numericPort = validPort(port); return { host, port: numericPort, url: `http://${host}:${numericPort}${pathname}` }; }
|
||||
function validPort(value: string) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) throw codedError("invalid_service_port", `invalid HWPOD service port: ${value}`); return port; }
|
||||
function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "HWPOD service must be api, worker, or web"); }
|
||||
function requiredEnv(env: Record<string, string | undefined>, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first HWPOD native development config`); return value; }
|
||||
function fixedHttpsOrigin(value: string, key: string) { try { const parsed = new URL(value); if (parsed.protocol === "https:" && parsed.pathname === "/" && !parsed.search && !parsed.hash && !parsed.port) return parsed.origin; } catch {} throw codedError("native_exposure_config_invalid", `${key} must be a pathless HTTPS origin`); }
|
||||
async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); }
|
||||
function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } }
|
||||
async function probeHealth(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return { ok: response.ok, status: response.status, url }; } catch (error) { return { ok: false, status: null, url, error: error instanceof Error ? error.message : String(error) }; } }
|
||||
function codedError(code: string, message: string, details?: unknown) { return Object.assign(new Error(message), { code, details }); }
|
||||
Reference in New Issue
Block a user