import { resolve } from "node:path"; import { runCommand } from "./command"; import { readEnvSourceFile, requiredEnvValue } from "./secrets"; import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeNativeDevelopmentSpec } from "./hwlab-node-lanes"; type Application = "workbench" | "caserun"; type Service = "api" | "worker" | "web"; type Action = "start" | "stop" | "restart" | "status" | "logs"; export function hwlabNativeDevelopmentHelp(): Record { return { ok: true, command: "hwlab nodes native-development workbench", usage: [ "bun scripts/cli.ts hwlab nodes native-development workbench api|worker|web start|stop|restart|status|logs --node --lane ", "bun scripts/cli.ts hwlab nodes native-development workbench cli --node --lane ", "bun scripts/cli.ts hwlab nodes native-development caserun api|web start|stop|restart|status|logs --node --lane ", ], contract: "L1 API/Web 用户入口只输出 owning YAML 解析出的固定 HTTPS 域名;IP、port 和 bind/probe 地址不作为用户入口。", configPath: hwlabRuntimeLaneConfigPath(), }; } export function runHwlabNativeDevelopmentCommand(args: string[]): Record { if (args.length === 0 || args.includes("--help") || args.includes("-h")) return hwlabNativeDevelopmentHelp(); const [applicationRaw, serviceRaw, actionRaw] = args; const application = parseApplication(applicationRaw); const node = requiredOption(args, "--node"); const lane = requiredOption(args, "--lane"); if (!isHwlabRuntimeLane(lane)) throw new Error(`--lane must be declared in ${hwlabRuntimeLaneConfigPath()}`); const spec = hwlabRuntimeLaneSpecForNode(lane, node); const native = spec.nativeDevelopment?.[application]; if (native === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.${application} is required`); const env = application === "workbench" ? workbenchEnvironment(spec, native as HwlabRuntimeNativeDevelopmentSpec["workbench"]) : hwlabCaseRunNativeDevelopmentEnvironment(native as NonNullable); if (application === "workbench" && serviceRaw === "cli") { const commandArgs = withoutOptions(args.slice(2), ["--node", "--lane"]); if (commandArgs.length === 0) throw new Error("native-development workbench cli requires a Workbench command"); const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "workbench", ...commandArgs]; const result = runCommand(command, spec.workspace, { timeoutMs: cliTimeoutMs(commandArgs), env: { ...process.env, ...env, WORKBENCH_API_URL: env.WORKBENCH_NATIVE_API_URL }, }); return { ok: result.exitCode === 0, command: "hwlab nodes native-development workbench cli", node, lane, transport: commandArgs.includes("--over-api") ? "native-api" : "local-dispatcher", configSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.workbench`, workbenchResult: parsePayload(result.stdout), ...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_cli_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }), }; } const service = parseService(serviceRaw, application); const action = parseAction(actionRaw); const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", application, "service", service, action]; const result = runCommand(command, spec.workspace, { timeoutMs: 15_000, env: { ...process.env, ...env } }); const payload = parsePayload(result.stdout); const publicEndpoint = service === "api" ? { url: native.publicExposure.publicBaseUrl } : service === "web" ? { url: `${native.publicExposure.publicBaseUrl}/${application === "workbench" ? "workbench" : "caserun"}` } : null; return { ok: result.exitCode === 0 && payload.ok !== false, command: `hwlab nodes native-development ${application} ${service} ${action}`, node, lane, service, action, configSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.${application}`, publicExposureSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.${application}.publicExposure`, userEndpoint: publicEndpoint, serviceResult: payload, ...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_service_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }), }; } function workbenchEnvironment(spec: HwlabRuntimeLaneSpec, native: HwlabRuntimeNativeDevelopmentSpec["workbench"]): NodeJS.ProcessEnv { const sourceCommitResult = runCommand(["git", "rev-parse", "HEAD"], spec.workspace, { timeoutMs: 5_000 }); if (sourceCommitResult.exitCode !== 0 || !/^[0-9a-f]{40}$/iu.test(sourceCommitResult.stdout.trim())) throw new Error(`cannot resolve current HWLAB source commit from ${spec.workspace}`); return hwlabNativeDevelopmentEnvironment(native, spec, sourceCommitResult.stdout.trim()); } export function hwlabNativeDevelopmentEnvironment(native: HwlabRuntimeNativeDevelopmentSpec["workbench"], spec: HwlabRuntimeLaneSpec, sourceCommit: string): NodeJS.ProcessEnv { const codeAgent = spec.codeAgentRuntime; if (codeAgent === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.codeAgentRuntime is required`); const kafkaBridge = codeAgent.kafkaEventBridge; if (kafkaBridge === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.${native.kafka.configRef} is required`); if (native.kafka.hwlabEventConsumerGroupId === kafkaBridge.hwlabEventConsumerGroupId) throw new Error("native Workbench Kafka consumer group must be distinct from the Cloud API live SSE consumer group"); if (!/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error("sourceCommit must be the current HWLAB workspace 40-hex git SHA"); const providerSecretNames = codeAgent.providerSecretNames; if (providerSecretNames === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.codeAgentRuntime.providerSecretNames is required`); const defaultProviderKey = codeAgent.defaultProviderProfile.toLowerCase().replace(/[^a-z0-9]+/gu, "-"); if (!providerSecretNames[defaultProviderKey]) throw new Error(`codeAgentRuntime.providerSecretNames.${defaultProviderKey} is required for defaultProviderProfile=${codeAgent.defaultProviderProfile}`); const authorizationSource = readEnvSourceFile({ root: "/root/.unidesk/.state/secrets", sourceRef: codeAgent.apiKeySourceRef, }); const agentRunApiKey = requiredEnvValue(authorizationSource.values, codeAgent.apiKeySourceKey, codeAgent.apiKeySourceRef); const sessionIndex = native.kafka.refreshReplay.sessionIndex; return { WORKBENCH_MODE: native.mode, WORKBENCH_TEMPORAL_ADDRESS: native.temporal.address, WORKBENCH_TEMPORAL_NAMESPACE: native.temporal.namespace, WORKBENCH_TEMPORAL_TASK_QUEUE: native.temporal.taskQueue, WORKBENCH_ACTIVITY_START_TO_CLOSE_TIMEOUT_MS: String(native.temporal.activity.startToCloseTimeoutMs), WORKBENCH_ACTIVITY_RETRY_INITIAL_INTERVAL_MS: String(native.temporal.activity.retryInitialIntervalMs), WORKBENCH_ACTIVITY_RETRY_MAXIMUM_INTERVAL_MS: String(native.temporal.activity.retryMaximumIntervalMs), WORKBENCH_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS: String(native.temporal.activity.retryMaximumAttempts), AGENTRUN_MGR_URL: native.agentRun.managerUrl, AGENTRUN_API_KEY: agentRunApiKey, HWLAB_CODE_AGENT_ADAPTER: codeAgent.adapter, HWLAB_CODE_AGENT_AGENTRUN_ALLOW_PRIVATE_URL: "1", HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: spec.nodeId, HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: codeAgent.defaultProviderProfile, HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: codeAgent.runnerNamespace, HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: codeAgent.secretNamespace, ...providerSecretNameEnvironment(providerSecretNames), HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: codeAgent.toolSecretNames.githubPr, HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: codeAgent.toolSecretNames.unideskSsh, HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: spec.gitReadUrl, HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: sourceCommit, HWLAB_RUNTIME_API_URL: native.agentRun.runtimeApiUrl, HWLAB_RUNTIME_WEB_URL: native.agentRun.runtimeWebUrl, HWLAB_RUNTIME_NAMESPACE: spec.runtimeNamespace, HWLAB_RUNTIME_LANE: spec.lane, HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1", HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false", HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true", HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false", HWLAB_KAFKA_BOOTSTRAP_SERVERS: native.kafka.bootstrapServers, HWLAB_KAFKA_DNS_SERVERS: native.kafka.dnsServers.join(","), HWLAB_KAFKA_DNS_SEARCH_DOMAINS: native.kafka.dnsSearchDomains.join(","), HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC: kafkaBridge.agentRunEventTopic, HWLAB_KAFKA_EVENT_TOPIC: kafkaBridge.hwlabEventTopic, HWLAB_KAFKA_CLIENT_ID: native.kafka.clientId, HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: native.kafka.hwlabEventConsumerGroupId, HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX: native.kafka.refreshReplay.groupIdPrefix, HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS: String(native.kafka.refreshReplay.timeoutMs), HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT: String(native.kafka.refreshReplay.scanLimit), HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT: String(native.kafka.refreshReplay.matchedEventLimit), HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT: String(native.kafka.refreshReplay.liveBufferLimit), ...(sessionIndex === undefined ? {} : { HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_ENABLED: String(sessionIndex.enabled), HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_MAX_EVENTS: String(sessionIndex.maxEvents), HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_MAX_EVENTS_PER_SESSION: String(sessionIndex.maxEventsPerSession), HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_BOOTSTRAP_TIMEOUT_MS: String(sessionIndex.bootstrapTimeoutMs), HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_REBUILD_INTERVAL_MS: String(sessionIndex.rebuildIntervalMs), HWLAB_WORKBENCH_KAFKA_REFRESH_INDEX_REBUILD_COOLDOWN_MS: String(sessionIndex.rebuildCooldownMs), }), WORKBENCH_NATIVE_SERVICE_STATE_DIR: native.stateDir, WORKBENCH_NATIVE_STATE_FILE: resolve(spec.workspace, native.stateFile), WORKBENCH_API_BIND_HOST: native.api.bindHost, WORKBENCH_API_PROBE_HOST: native.api.probeHost, WORKBENCH_PUBLIC_BASE_URL: native.publicExposure.publicBaseUrl, WORKBENCH_API_PORT: String(native.api.port), WORKBENCH_API_IDLE_TIMEOUT_SECONDS: String(native.api.idleTimeoutSeconds), WORKBENCH_WORKER_BIND_HOST: native.worker.bindHost, WORKBENCH_WORKER_PROBE_HOST: native.worker.probeHost, WORKBENCH_WORKER_HEALTH_PORT: String(native.worker.healthPort), WORKBENCH_WEB_BIND_HOST: native.web.bindHost, WORKBENCH_WEB_PROBE_HOST: native.web.probeHost, WORKBENCH_WEB_PORT: String(native.web.port), WORKBENCH_NATIVE_API_URL: `http://${native.api.probeHost}:${native.api.port}`, WORKBENCH_WEB_RUNTIME_CONFIG: JSON.stringify(native.web.runtimeConfig), }; } function providerSecretNameEnvironment(providerSecretNames: Readonly>): NodeJS.ProcessEnv { return Object.fromEntries(Object.entries(providerSecretNames).map(([profile, secretName]) => { const profileKey = profile.toUpperCase().replace(/[^A-Z0-9]+/gu, "_"); return [`HWLAB_CODE_AGENT_AGENTRUN_${profileKey}_SECRET_NAME`, secretName]; })); } function parsePayload(stdout: string): Record { const parsed = JSON.parse(stdout) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("HWLAB Workbench CLI returned invalid JSON"); return parsed as Record; } function parseApplication(value: string | undefined): Application { if (value === "workbench" || value === "caserun") return value; throw new Error("native-development application must be workbench or caserun"); } function parseService(value: string | undefined, application: Application): Service { if (value === "api" || value === "web" || (application === "workbench" && value === "worker")) return value; throw new Error(`native-development ${application} service must be ${application === "workbench" ? "api, worker, or web" : "api or web"}`); } function parseAction(value: string | undefined): Action { if (value === "start" || value === "stop" || value === "restart" || value === "status" || value === "logs") return value; throw new Error("native-development workbench action must be start, stop, restart, status, or logs"); } function requiredOption(args: string[], option: string): string { const index = args.indexOf(option); const value = index < 0 ? undefined : args[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); return value; } function withoutOptions(args: string[], options: string[]): string[] { const result: string[] = []; for (let index = 0; index < args.length; index += 1) { if (options.includes(args[index])) { index += 1; continue; } result.push(args[index]); } return result; } function cliTimeoutMs(args: string[]): number { const index = args.indexOf("--timeout-ms"); const requested = index >= 0 ? Number.parseInt(args[index + 1] ?? "", 10) : 0; return Number.isSafeInteger(requested) && requested > 0 ? requested + 5_000 : 30_000; } function hwlabCaseRunNativeDevelopmentEnvironment(native: NonNullable): NodeJS.ProcessEnv { return { CASERUN_NATIVE_SERVICE_STATE_DIR: native.stateDir, CASERUN_PUBLIC_BASE_URL: native.publicExposure.publicBaseUrl, CASERUN_PROBE_HOST: native.api.probeHost, CASERUN_API_BIND_HOST: native.api.bindHost, CASERUN_API_PORT: String(native.api.port), CASERUN_WEB_BIND_HOST: native.web.bindHost, CASERUN_WEB_PORT: String(native.web.port), }; }