From fe46bfcaaaa31df2b60ad04089c3a30f86d075a6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 03:17:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(hwpod):=20=E4=BD=BF=E7=94=A8=20YAML=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=92=8C=E5=81=A5=E5=BA=B7=E8=B6=85?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/hwpod/spec-registry.ts | 7 +++++-- tools/src/hwpod-native-service.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/hwpod/spec-registry.ts b/internal/hwpod/spec-registry.ts index 08af92bb..a6b4073f 100644 --- a/internal/hwpod/spec-registry.ts +++ b/internal/hwpod/spec-registry.ts @@ -28,8 +28,9 @@ export function hwpodSpecRegistryFromEnv(env: Record const databaseUrl = requiredEnv(env, "HWPOD_SPEC_DATABASE_URL"); const schema = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_SCHEMA"), "HWPOD_SPEC_DATABASE_SCHEMA"); const table = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_TABLE"), "HWPOD_SPEC_DATABASE_TABLE"); + const connectionTimeoutMs = positiveIntegerEnv(env, "HWPOD_SPEC_DATABASE_CONNECTION_TIMEOUT_MS"); const builtIns = parseBuiltIns(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON); - return createHwpodSpecRegistry({ store: createPostgresHwpodRuntimeSpecStore({ databaseUrl, schema, table }), builtIns }); + return createHwpodSpecRegistry({ store: createPostgresHwpodRuntimeSpecStore({ databaseUrl, schema, table, connectionTimeoutMs }), builtIns }); } export function createHwpodSpecRegistry(options: { store: HwpodRuntimeSpecStore; builtIns?: BuiltInInput[]; now?: () => string }) { @@ -85,13 +86,14 @@ export function createHwpodSpecRegistry(options: { store: HwpodRuntimeSpecStore; return { list, get, create, update, delete: remove, close: async () => store.close?.() }; } -export function createPostgresHwpodRuntimeSpecStore(options: { databaseUrl: string; schema: string; table: string }): HwpodRuntimeSpecStore { +export function createPostgresHwpodRuntimeSpecStore(options: { databaseUrl: string; schema: string; table: string; connectionTimeoutMs: number }): HwpodRuntimeSpecStore { const schema = postgresIdentifier(options.schema, "schema"); const table = postgresIdentifier(options.table, "table"); const qualifiedTable = `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`; const pool = new Pool(buildPostgresPoolConfig({ dbUrl: options.databaseUrl, sslMode: postgresSslModeFromConnectionString(options.databaseUrl) ?? "disable", + timeoutMs: options.connectionTimeoutMs, })); let ready: Promise | undefined; @@ -187,6 +189,7 @@ function normalizeBuiltIns(inputs: BuiltInInput[]) { } function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("hwpod_spec_database_config_required", `${key} is required`); return value; } +function positiveIntegerEnv(env: Record, key: string) { const value = Number(requiredEnv(env, key)); if (!Number.isSafeInteger(value) || value <= 0) throw codedError("invalid_hwpod_spec_database_config", `${key} must be a positive integer`); return value; } function postgresIdentifier(value: string, name: string) { if (!/^[a-z_][a-z0-9_]*$/u.test(value)) throw codedError("invalid_hwpod_spec_database_identifier", `${name} must be a lowercase PostgreSQL identifier`); return value; } function quoteIdentifier(value: string) { return `"${postgresIdentifier(value, "identifier")}"`; } function isoTimestamp(value: unknown) { const date = value instanceof Date ? value : new Date(String(value ?? "")); if (!Number.isFinite(date.getTime())) throw codedError("invalid_hwpod_runtime_spec", "runtime HWPOD spec timestamp is invalid"); return date.toISOString(); } diff --git a/tools/src/hwpod-native-service.ts b/tools/src/hwpod-native-service.ts index b033caf0..899f041b 100644 --- a/tools/src/hwpod-native-service.ts +++ b/tools/src/hwpod-native-service.ts @@ -29,11 +29,12 @@ async function start(service: ServiceName, stateDir: string, stateFile: string, await mkdir(stateDir, { recursive: true }); const endpoints = serviceEndpoints(service, env); const command = serviceCommand(service); + const healthTimeoutMs = positiveIntegerEnv(env, "HWPOD_HEALTH_TIMEOUT_MS"); 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() }; + const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, healthUrl: endpoints.probe.url, healthTimeoutMs, 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` }; } @@ -49,7 +50,9 @@ async function stop(service: ServiceName, stateFile: string, logFile: string) { 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 health = processRunning && state?.healthUrl && state?.healthTimeoutMs + ? await probeHealth(state.healthUrl, state.healthTimeoutMs) + : { ok: false, status: null, error: processRunning ? "health-config-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` }; } @@ -83,8 +86,9 @@ function endpoint(host: string, port: string, pathname: string) { const numericP 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, 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 positiveIntegerEnv(env: Record, key: string) { const value = Number(requiredEnv(env, key)); if (!Number.isSafeInteger(value) || value <= 0) throw codedError("native_exposure_config_invalid", `${key} must be a positive integer`); 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) }; } } +async function probeHealth(url: string, timeoutMs: number) { try { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); 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 }); }