Files
pikasTech-unidesk/scripts/src/hwlab-node/web-probe.ts
T
pikastech c6e41380a1 feat: 支持 L1 native Web observer
为 web-probe 增加 YAML-first native origin,并同步 Workbench SPEC、WebDev skill、帮助与 L0 测试。
2026-07-21 05:30:34 +02:00

2704 lines
152 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. web-probe module for scripts/src/hwlab-node-impl.ts.
// Moved mechanically from scripts/src/hwlab-node-impl.ts:6032-7426 for #903.
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure, shellQuote } from "../ssh";
import { CliInputError } from "../output";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneCiGitWorkspaceSecret, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeConsoleVerifyScript } from "../hwlab-node-web-probe-console-verify";
import { nodeWebProbeNativeReadinessScript } from "../hwlab-node-web-probe-native-readiness";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";
import type { BootstrapAdminPasswordMaterial, BootstrapAdminSecretMaterial, NodeRuntimeGitMirrorEgressProxySpec, NodeRuntimeGitMirrorGithubTransportSpec, NodeRuntimeGitMirrorTargetSpec, NodeRuntimeRenderResult, NodeWebProbeOptions, RuntimeSecretSpec } from "./entry";
import { isCommandSuccess, nodeRuntimeGitopsRoot, runNodeK3sArgs, runNodeK3sScript } from "./cleanup";
import { nodeRuntimeCicdWaitWarning } from "./git-mirror";
import { parseNodeScopedDelegatedOptions } from "./plan";
import { nodeRuntimePipelineFailureSummary, nodeRuntimePipelineRunDiagnostics } from "./render";
import { compactRuntimeCommand, runNodeHostScript, runNodeHostScriptAsync } from "./runtime-common";
import { assertLane, assertNodeId, keyValueLinesFromText, numericField, optionValue, optionalStringValue, positiveIntegerOption, positiveIntegerValue, record, requiredOption, shellQuote, statusText, stringValue, stripOptions } from "./utils";
import { discoverWebObserveIndexEntry, readWebObserveIndexEntry } from "./web-observe-render";
import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, normalizeNodeWebProbeObserveArgs, parseNodeWebProbeObserveOptions, parseNodeWebProbeSentinelOptions, parseWebProbeBrowserProxyMode } from "./web-probe-observe";
import { resolveNodeWebProbeCliOrigin } from "./web-probe-origin";
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { resolveEgressProxySourceRef } from "../egress-proxy-sources";
import { hwlabRuntimePipelineProvenanceAnnotations } from "./pipeline-provenance";
export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const gitSshProxy = httpProxyEndpoint(spec.networkProfile.proxy.http);
const gitMirror = nodeRuntimeGitMirrorTarget(spec);
const activeExternalPostgres = hwlabRuntimeActiveExternalPostgres(spec);
const renderGitMirror = {
...gitMirror,
egressProxy: gitMirror.egressProxy.mode === "direct" ? {
mode: "direct",
required: false,
} : gitMirror.egressProxy.mode === "host-route" ? {
...gitMirror.egressProxy,
mode: "host-route",
required: true,
} : {
...gitMirror.egressProxy,
mode: "node-global",
required: true,
},
};
const deployYamlGitMirror = {
...renderGitMirror,
egressProxy: renderGitMirror.egressProxy.mode !== "host-route" ? renderGitMirror.egressProxy : {
mode: "node-global",
required: true,
clientName: renderGitMirror.egressProxy.clientName,
namespace: "platform-infra",
serviceName: renderGitMirror.egressProxy.clientName,
port: httpProxyEndpoint(renderGitMirror.egressProxy.proxyUrl)?.port ?? 10808,
proxyUrl: renderGitMirror.egressProxy.proxyUrl,
noProxy: renderGitMirror.egressProxy.noProxy,
},
};
return {
pipelineProvenanceAnnotations: hwlabRuntimePipelineProvenanceAnnotations(spec),
nodeId: spec.nodeId,
lane: spec.lane,
environment: spec.environment,
sourceBranch: spec.sourceBranch,
gitopsBranch: spec.gitopsBranch,
gitopsRoot: nodeRuntimeGitopsRoot(spec),
runtimePath: spec.runtimePath,
runtimeRenderDir: spec.runtimeRenderDir,
runtimeNamespace: spec.runtimeNamespace,
catalogPath: spec.catalogPath,
tektonDir: spec.tektonDir,
pipelineName: spec.pipeline,
argoApplicationFile: spec.argoApplicationFile,
argoRepoUrl: spec.argoRepoUrl,
gitUrl: spec.gitUrl,
gitReadUrl: spec.gitReadUrl,
gitWriteUrl: spec.gitWriteUrl,
toolsImage: gitMirror.toolsImage,
toolsImagePullPolicy: gitMirror.toolsImagePullPolicy,
gitMirror: renderGitMirror,
deployYamlGitMirror,
networkProfileId: spec.networkProfileId,
downloadProfileId: spec.downloadProfileId,
gitSshProxyHost: gitSshProxy?.host,
gitSshProxyPort: gitSshProxy?.port,
proxyHttp: spec.networkProfile.proxy.http,
proxyHttps: spec.networkProfile.proxy.https,
proxyAll: spec.networkProfile.proxy.all,
noProxy: spec.networkProfile.proxy.noProxy.join(","),
dockerProxyHttp: spec.networkProfile.dockerBuildProxy.http,
dockerProxyHttps: spec.networkProfile.dockerBuildProxy.https,
dockerProxyAll: spec.networkProfile.dockerBuildProxy.all,
dockerNoProxy: spec.networkProfile.dockerBuildProxy.noProxy.join(","),
dockerNoProxyList: spec.networkProfile.dockerBuildProxy.noProxy,
npmRegistry: spec.downloadProfile.npm.registry,
npmFetchTimeoutMs: spec.downloadProfile.npm.fetchTimeoutSeconds * 1000,
npmRetries: spec.downloadProfile.npm.retries,
stepEnv: spec.stepEnv,
ciResourceGovernance: spec.ciResourceGovernance === undefined ? undefined : {
configRef: `${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.ciResourceGovernance`,
...spec.ciResourceGovernance,
},
observability: spec.observability,
runtimeStore: spec.runtimeStore,
codeAgentRuntime: spec.codeAgentRuntime === undefined ? undefined : {
enabled: spec.codeAgentRuntime.enabled,
adapter: spec.codeAgentRuntime.adapter,
managerUrl: spec.codeAgentRuntime.managerUrl,
apiKeySecretName: spec.codeAgentRuntime.apiKeySecretName,
apiKeySecretKey: spec.codeAgentRuntime.apiKeySecretKey,
runnerNamespace: spec.codeAgentRuntime.runnerNamespace,
secretNamespace: spec.codeAgentRuntime.secretNamespace,
...(spec.codeAgentRuntime.providerSecretNames === undefined ? {} : { providerSecretNames: spec.codeAgentRuntime.providerSecretNames }),
toolSecretNames: spec.codeAgentRuntime.toolSecretNames,
repoUrlFrom: spec.codeAgentRuntime.repoUrlFrom,
repoUrl: spec.gitReadUrl,
providerIdFrom: spec.codeAgentRuntime.providerIdFrom,
providerId: spec.nodeId,
defaultProviderProfile: spec.codeAgentRuntime.defaultProviderProfile,
codexStdioSupervisor: spec.codeAgentRuntime.codexStdioSupervisor,
kafkaShadowProducer: spec.codeAgentRuntime.kafkaShadowProducer ?? null,
kafkaEventBridge: spec.codeAgentRuntime.kafkaEventBridge ?? null,
valuesPrinted: false,
},
runtimeImageRewrites: spec.runtimeImageRewrites,
runtimeImageBuilds: spec.runtimeImageBuilds,
registryPrefix: spec.registryPrefix,
buildkitSidecarImage: spec.buildkit?.sidecarImage,
publicWebUrl: spec.publicWebUrl,
publicApiUrl: spec.publicApiUrl,
publicExposure: spec.publicExposure === null ? undefined : spec.publicExposure.mode === "node-port-shared-edge" ? {
enabled: spec.publicExposure.enabled,
mode: spec.publicExposure.mode,
publicBaseUrl: spec.publicExposure.publicBaseUrl,
hostname: spec.publicExposure.hostname,
expectedA: spec.publicExposure.expectedA,
upstream: spec.publicExposure.upstream,
healthPath: spec.publicExposure.healthPath,
serviceName: spec.publicExposure.serviceName,
selector: spec.publicExposure.selector,
port: spec.publicExposure.port,
targetPort: spec.publicExposure.targetPort,
nodePort: spec.publicExposure.nodePort,
} : {
enabled: spec.publicExposure.enabled,
mode: spec.publicExposure.mode,
publicBaseUrl: spec.publicExposure.publicBaseUrl,
hostname: spec.publicExposure.hostname,
expectedA: spec.publicExposure.expectedA,
serverAddr: spec.publicExposure.serverAddr,
serverPort: spec.publicExposure.serverPort,
secretName: spec.publicExposure.secretName,
secretKey: spec.publicExposure.secretKey,
tokenKey: spec.publicExposure.tokenKey,
webProxy: spec.publicExposure.webProxy,
apiProxy: spec.publicExposure.apiProxy,
extraProxies: spec.publicExposure.extraProxies,
},
externalPostgres: activeExternalPostgres === undefined ? undefined : {
enabled: true,
serviceName: activeExternalPostgres.serviceName,
endpointAddress: activeExternalPostgres.endpointAddress,
port: activeExternalPostgres.port,
runtimeAccess: activeExternalPostgres.runtimeAccess ?? null,
sslmode: activeExternalPostgres.sslmode,
...(activeExternalPostgres.cloudApi.migrationSecretKey === undefined ? {} : {
migrationSecretName: activeExternalPostgres.cloudApi.secretName,
migrationSecretKey: activeExternalPostgres.cloudApi.migrationSecretKey,
}),
},
};
}
export function httpProxyEndpoint(value: string): { host: string; port: number } | null {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
const port = parsed.port === "" ? parsed.protocol === "https:" ? 443 : 80 : Number.parseInt(parsed.port, 10);
if (!Number.isInteger(port) || port <= 0) return null;
return { host: parsed.hostname, port };
}
export function nodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string): string[] {
return [
`${renderDir}/${spec.runtimeRenderDir}/namespace.yaml`,
`${renderDir}/${spec.tektonDir}/rbac.yaml`,
`${renderDir}/${spec.tektonDir}/runtime-gitops-scripts.yaml`,
`${renderDir}/${spec.tektonDir}/pipeline.yaml`,
`${renderDir}/argocd/project.yaml`,
`${renderDir}/argocd/${spec.argoApplicationFile}`,
];
}
export function applyNodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandResult {
const files = nodeRuntimeControlPlaneFiles(spec, renderDir);
const args = [
"kubectl",
"apply",
...(dryRun ? ["--dry-run=client"] : ["--server-side", "--force-conflicts", `--field-manager=${spec.controlPlaneFieldManager}`]),
...files.flatMap((file) => ["-f", file]),
];
return runNodeK3sArgs(spec, args, timeoutSeconds);
}
export function applyLocalNodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandResult {
const manifest = nodeRuntimeControlPlaneFiles(spec, renderDir)
.map((file) => `---\n# Source: ${file}\n${readFileSync(file, "utf8").trimEnd()}\n`)
.join("");
const args = [
"kubectl",
"apply",
...(dryRun ? ["--dry-run=client"] : ["--server-side", "--force-conflicts", `--field-manager=${spec.controlPlaneFieldManager}`]),
"-f",
"-",
];
return runNodeK3sScript(spec, args.map(shellQuote).join(" "), timeoutSeconds, manifest);
}
export function cleanupNodeRuntimeRenderDir(spec: HwlabRuntimeLaneSpec, renderDir: string): CommandResult {
const prefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-control-plane-`;
const script = [
"set -eu",
`render_dir=${shellQuote(renderDir)}`,
`prefix=${shellQuote(prefix)}`,
"case \"$render_dir\" in",
" \"$prefix\"*) rm -rf \"$render_dir\" ;;",
" *) echo \"refusing to remove unexpected render dir: $render_dir\" >&2; exit 44 ;;",
"esac",
].join("\n");
return runNodeHostScript(spec, script, 60);
}
export function cleanupLocalNodeRuntimeRenderDir(spec: HwlabRuntimeLaneSpec, render: NodeRuntimeRenderResult): CommandResult {
const renderPrefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-control-plane-`;
const worktreePrefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-source-`;
if (!render.renderDir.startsWith(renderPrefix) || !render.worktreeDir.startsWith(worktreePrefix)) {
return {
command: ["rm", "-rf", "<refused>"],
cwd: repoRoot,
exitCode: 44,
stdout: "",
stderr: `refusing to remove unexpected local render paths: ${render.renderDir} ${render.worktreeDir}`,
signal: null,
timedOut: false,
};
}
return runCommand(["rm", "-rf", render.renderDir, render.worktreeDir], repoRoot, { timeoutMs: 60_000 });
}
export function nodeRuntimePipelineRunManifest(spec: HwlabRuntimeLaneSpec, sourceCommit: string, pipelineRun: string): Record<string, unknown> {
const gitWorkspaceSecret = hwlabNodeControlPlaneCiGitWorkspaceSecret(spec.nodeId, spec.lane);
return {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
metadata: {
name: pipelineRun,
namespace: gitWorkspaceSecret.namespace,
labels: {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/gitops-target": spec.lane,
"hwlab.pikastech.local/source-commit": sourceCommit,
"hwlab.pikastech.local/trigger": "unidesk-node-cli",
},
annotations: {
"hwlab.pikastech.local/node": spec.nodeId,
"hwlab.pikastech.local/source-branch": spec.sourceBranch,
"hwlab.pikastech.local/gitops-branch": spec.gitopsBranch,
"hwlab.pikastech.local/runtime-path": spec.runtimePath,
"hwlab.pikastech.local/network-profile": spec.networkProfileId,
"hwlab.pikastech.local/download-profile": spec.downloadProfileId,
},
},
spec: {
pipelineRef: { name: spec.pipeline },
taskRunTemplate: {
serviceAccountName: spec.serviceAccountName,
podTemplate: {
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet",
securityContext: { fsGroup: 1000 },
},
},
params: [
{ name: "git-url", value: spec.gitUrl },
{ name: "git-read-url", value: spec.gitReadUrl },
{ name: "git-write-url", value: spec.gitWriteUrl },
{ name: "source-branch", value: spec.sourceBranch },
{ name: "gitops-branch", value: spec.gitopsBranch },
{ name: "lane", value: spec.lane },
{ name: "catalog-path", value: spec.catalogPath },
{ name: "image-tag-mode", value: "full" },
{ name: "runtime-path", value: spec.runtimePath },
{ name: "source-commit", value: sourceCommit },
{ name: "revision", value: sourceCommit },
{ name: "registry-prefix", value: spec.registryPrefix },
{ name: "services", value: spec.serviceIds.join(",") },
{ name: "base-image", value: spec.baseImage },
],
workspaces: [
{ name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } },
{ name: "git-ssh", secret: { secretName: gitWorkspaceSecret.name } },
],
},
};
}
export function createNodeRuntimePipelineRun(spec: HwlabRuntimeLaneSpec, sourceCommit: string, pipelineRun: string, timeoutSeconds: number, rerun: boolean): CommandResult {
const gitWorkspaceSecret = hwlabNodeControlPlaneCiGitWorkspaceSecret(spec.nodeId, spec.lane);
const manifestB64 = Buffer.from(JSON.stringify(nodeRuntimePipelineRunManifest(spec, sourceCommit, pipelineRun)), "utf8").toString("base64");
const script = [
"set -eu",
`manifest_b64=${shellQuote(manifestB64)}`,
`pipeline_run=${shellQuote(pipelineRun)}`,
`ci_namespace=${shellQuote(gitWorkspaceSecret.namespace)}`,
`git_workspace_secret=${shellQuote(gitWorkspaceSecret.name)}`,
`rerun=${rerun ? "true" : "false"}`,
"manifest_path=\"/tmp/$pipeline_run.json\"",
"if ! kubectl -n \"$ci_namespace\" get secret \"$git_workspace_secret\" >/dev/null 2>&1; then",
" printf 'missing Tekton git workspace Secret %s/%s; run: bun scripts/cli.ts hwlab nodes control-plane infra apply --node %s --lane %s --confirm\\n' \"$ci_namespace\" \"$git_workspace_secret\" " + shellQuote(spec.nodeId) + " " + shellQuote(spec.lane) + " >&2",
" exit 44",
"fi",
"printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest_path\"",
"existing_status=$(kubectl get pipelinerun -n \"$ci_namespace\" \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)",
"if [ \"$rerun\" = true ] && [ -n \"$existing_status\" ]; then",
" kubectl delete pipelinerun -n \"$ci_namespace\" \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete taskrun -n \"$ci_namespace\" -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete pod -n \"$ci_namespace\" -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl -n \"$ci_namespace\" get pvc -o name | grep '^persistentvolumeclaim/pvc-' | xargs -r kubectl -n \"$ci_namespace\" delete --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
"elif [ \"$existing_status\" = False ]; then",
" kubectl delete pipelinerun -n \"$ci_namespace\" \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete taskrun -n \"$ci_namespace\" -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete pod -n \"$ci_namespace\" -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl -n \"$ci_namespace\" get pvc -o name | grep '^persistentvolumeclaim/pvc-' | xargs -r kubectl -n \"$ci_namespace\" delete --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
"fi",
"if kubectl create -f \"$manifest_path\"; then",
" :",
"else",
" code=$?",
" if kubectl get pipelinerun -n \"$ci_namespace\" \"$pipeline_run\" >/dev/null 2>&1; then",
" printf 'PipelineRun %s already exists; reusing existing object\\n' \"$pipeline_run\" >&2",
" else",
" exit \"$code\"",
" fi",
"fi",
"kubectl get pipelinerun -n \"$ci_namespace\" \"$pipeline_run\" -o jsonpath='{.metadata.name}{\"\\n\"}{.metadata.labels.hwlab\\.pikastech\\.local/source-commit}{\"\\n\"}{.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}'",
].join("\n");
return runNodeK3sScript(spec, script, timeoutSeconds);
}
export function waitForNodeRuntimePipelineRunTerminal(
spec: HwlabRuntimeLaneSpec,
pipelineRun: string,
timeoutSeconds: number,
options: { opportunisticPostFlush?: () => Record<string, unknown> | null; opportunisticPostSync?: () => Record<string, unknown> | null } = {},
): Record<string, unknown> {
const severeWarningThresholdMs = 120_000;
const effectiveTimeoutSeconds = Math.max(0, timeoutSeconds);
const startedAt = Date.now();
const deadline = startedAt + effectiveTimeoutSeconds * 1000;
let polls = 0;
let last: Record<string, unknown> = { exists: false, name: pipelineRun };
let lastOpportunisticPostFlushAt = 0;
let severeWarningEmitted = false;
let opportunisticPostSyncAttempted = false;
const opportunisticPostFlushes: Record<string, unknown>[] = [];
const opportunisticPostSyncs: Record<string, unknown>[] = [];
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "started", pipelineRun, timeoutSeconds: effectiveTimeoutSeconds });
if (effectiveTimeoutSeconds <= 0) {
const warning = nodeRuntimeCicdWaitWarning(spec, pipelineRun, { polls, elapsedMs: 0 });
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "warning", pipelineRun, polls, elapsedMs: 0, waitLimitSeconds: effectiveTimeoutSeconds });
return {
ok: false,
status: "pending",
pipelineRun: last,
polls,
elapsedMs: 0,
waitLimitSeconds: effectiveTimeoutSeconds,
warning,
next: { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun}` },
};
}
while (Date.now() <= deadline) {
polls += 1;
last = getNodeRuntimePipelineRun(spec, pipelineRun);
const status = typeof last.status === "string" ? last.status : null;
const reason = typeof last.reason === "string" ? last.reason : null;
const elapsedMs = Date.now() - startedAt;
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "poll", pipelineRun, pipelineStatus: status, reason, polls, elapsedMs });
if (!severeWarningEmitted && elapsedMs >= severeWarningThresholdMs && status !== "True" && status !== "False") {
severeWarningEmitted = true;
printNodeRuntimeTriggerProgress(spec, {
stage: "pipelinerun-wait",
status: "warning",
warning: "pipelinerun-wait-severe-timeout",
severity: "warning",
pipelineRun,
pipelineStatus: status,
reason,
polls,
elapsedMs,
thresholdMs: severeWarningThresholdMs,
message: "PipelineRun has been non-terminal for more than 120s; this is a severe timeout and requires further investigation of Tekton taskRuns/pods instead of treating it as normal wait time.",
next: {
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun} --full`,
},
});
}
if (status === "True" || status === "False") {
const ok = status === "True";
const diagnostics = ok ? null : nodeRuntimePipelineRunDiagnostics(spec, pipelineRun);
const failureSummary = nodeRuntimePipelineFailureSummary(diagnostics);
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: ok ? "succeeded" : "failed", pipelineRun, pipelineStatus: status, reason, polls, elapsedMs: Date.now() - startedAt });
return {
ok,
status: ok ? "succeeded" : "failed",
pipelineRun: last,
diagnostics,
failureSummary,
polls,
elapsedMs: Date.now() - startedAt,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
degradedReason: ok ? undefined : "node-runtime-pipelinerun-failed",
};
}
if (
options.opportunisticPostSync !== undefined
&& !opportunisticPostSyncAttempted
&& nodeRuntimePipelineRunSourceCloneCompleted(spec, pipelineRun)
) {
opportunisticPostSyncAttempted = true;
const sync = options.opportunisticPostSync();
if (sync !== null) opportunisticPostSyncs.push(sync);
}
if (options.opportunisticPostFlush !== undefined && Date.now() - lastOpportunisticPostFlushAt >= 15_000) {
lastOpportunisticPostFlushAt = Date.now();
const flush = options.opportunisticPostFlush();
if (flush !== null) opportunisticPostFlushes.push(flush);
last = getNodeRuntimePipelineRun(spec, pipelineRun);
const refreshedStatus = typeof last.status === "string" ? last.status : null;
const refreshedReason = typeof last.reason === "string" ? last.reason : null;
const refreshedElapsedMs = Date.now() - startedAt;
if (refreshedStatus === "True" || refreshedStatus === "False") {
const ok = refreshedStatus === "True";
const diagnostics = ok ? null : nodeRuntimePipelineRunDiagnostics(spec, pipelineRun);
const failureSummary = nodeRuntimePipelineFailureSummary(diagnostics);
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: ok ? "succeeded" : "failed", pipelineRun, pipelineStatus: refreshedStatus, reason: refreshedReason, polls, elapsedMs: refreshedElapsedMs, observedAfterPostFlush: true });
return {
ok,
status: ok ? "succeeded" : "failed",
pipelineRun: last,
diagnostics,
failureSummary,
polls,
elapsedMs: refreshedElapsedMs,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
observedAfterPostFlush: true,
degradedReason: ok ? undefined : "node-runtime-pipelinerun-failed",
};
}
}
sleepSync(Math.min(10_000, Math.max(1000, deadline - Date.now())));
}
const elapsedMs = Date.now() - startedAt;
const warning = nodeRuntimeCicdWaitWarning(spec, pipelineRun, { polls, elapsedMs });
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "warning", pipelineRun, polls, elapsedMs, waitLimitSeconds: timeoutSeconds });
return {
ok: false,
status: "pending",
pipelineRun: last,
polls,
elapsedMs,
waitLimitSeconds: timeoutSeconds,
warning,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
next: { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun}` },
};
}
export function nodeRuntimePipelineRunSourceCloneCompleted(spec: HwlabRuntimeLaneSpec, pipelineRun: string): boolean {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", "hwlab-ci", "get", "taskrun", "-l", `tekton.dev/pipelineRun=${pipelineRun}`, "-o", "json"], 60);
if (result.exitCode !== 0) return false;
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch {
return false;
}
const items = Array.isArray(record(parsed).items) ? record(parsed).items as unknown[] : [];
return items.some((item) => {
const itemRecord = record(item);
const metadata = record(itemRecord.metadata);
const labels = record(metadata.labels);
const name = typeof metadata.name === "string" ? metadata.name : "";
const pipelineTask = typeof labels["tekton.dev/pipelineTask"] === "string" ? labels["tekton.dev/pipelineTask"] : "";
const taskName = `${pipelineTask} ${name}`;
if (!/(clone|checkout|local-git|source[-_ ]*worktree)/iu.test(taskName)) return false;
const status = record(itemRecord.status);
const conditions = Array.isArray(status.conditions) ? status.conditions as unknown[] : [];
return conditions.some((condition) => {
const conditionRecord = record(condition);
return conditionRecord.type === "Succeeded" && conditionRecord.status === "True";
});
});
}
export function printNodeRuntimeTriggerProgress(spec: HwlabRuntimeLaneSpec, data: Record<string, unknown> = {}): void {
process.stderr.write(`${JSON.stringify({ event: "hwlab.runtime-lane.trigger.progress", at: new Date().toISOString(), lane: spec.lane, node: spec.nodeId, ...data })}\n`);
}
export function getNodeRuntimePipelineRun(spec: HwlabRuntimeLaneSpec, pipelineRun: string): Record<string, unknown> {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", "hwlab-ci", "get", "pipelinerun", pipelineRun, "-o", "jsonpath={.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}{.status.conditions[0].message}{\"\\n\"}{.metadata.creationTimestamp}{\"\\n\"}"], 60);
if (result.exitCode !== 0) return { exists: false, name: pipelineRun, result: compactRuntimeCommand(result) };
const [status = "", reason = "", message = "", createdAt = ""] = result.stdout.split(/\r?\n/u);
return {
exists: true,
name: pipelineRun,
status: status || null,
reason: reason || null,
message: message || null,
createdAt: createdAt || null,
result: compactRuntimeCommand(result),
};
}
export function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, Math.max(0, ms));
}
export function syncNodeExternalPostgresSecrets(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return null;
const secretRoot = externalPostgresSecretSourceRoot(spec);
const cloudApi = readSecretSourceValue(secretRoot, pg.cloudApi.sourceRef, pg.cloudApi.envKey);
const migration = pg.cloudApi.migrationEnvKey === undefined
? null
: readSecretSourceValue(secretRoot, pg.cloudApi.sourceRef, pg.cloudApi.migrationEnvKey);
const openfga = readSecretSourceValue(secretRoot, pg.openfga.sourceRef, pg.openfga.envKey);
const missing = [
...(cloudApi.ok ? [] : [`${pg.cloudApi.sourceRef}:${pg.cloudApi.envKey}`]),
...(migration === null || migration.ok ? [] : [`${pg.cloudApi.sourceRef}:${pg.cloudApi.migrationEnvKey}`]),
...(openfga.ok ? [] : [`${pg.openfga.sourceRef}:${pg.openfga.envKey}`]),
];
if (missing.length > 0) {
if (dryRun) {
return {
ok: true,
mode: "dry-run",
mutation: false,
skipped: true,
reason: "external-postgres-secret-source-missing",
missing,
sourceRoot: secretRoot,
valuesPrinted: false,
next: { platformDbApply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configRef} --confirm` },
};
}
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
degradedReason: "external-postgres-secret-source-missing",
missing,
sourceRoot: secretRoot,
valuesPrinted: false,
next: { platformDbApply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configRef} --confirm` },
};
}
const setup = runNodeK3sScript(spec, externalPostgresSecretSetupScript(spec, dryRun), timeoutSeconds);
if (!isCommandSuccess(setup)) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
phase: "namespace-authn-setup",
setup: compactRuntimeCommand(setup),
valuesPrinted: false,
};
}
const manifest = {
apiVersion: "v1",
kind: "List",
items: [
{
apiVersion: "v1",
kind: "Secret",
metadata: { name: pg.cloudApi.secretName, namespace: spec.runtimeNamespace },
type: "Opaque",
stringData: {
[pg.cloudApi.secretKey]: cloudApi.value,
...(migration === null || pg.cloudApi.migrationSecretKey === undefined ? {} : { [pg.cloudApi.migrationSecretKey]: migration.value }),
},
},
{
apiVersion: "v1",
kind: "Secret",
metadata: { name: pg.openfga.secretName, namespace: spec.runtimeNamespace },
type: "Opaque",
stringData: { [pg.openfga.secretKey]: openfga.value },
},
],
};
const applyScript = [
"set -eu",
[
"kubectl",
"apply",
"--server-side",
"--force-conflicts",
"--field-manager=unidesk-hwlab-node-external-postgres-secret",
...(dryRun ? ["--dry-run=server"] : []),
"-f",
"-",
].map(shellQuote).join(" "),
].join("\n");
const apply = runNodeK3sScript(spec, applyScript, timeoutSeconds, JSON.stringify(manifest));
return {
ok: isCommandSuccess(apply),
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: !dryRun && isCommandSuccess(apply),
namespace: spec.runtimeNamespace,
sourceRoot: secretRoot,
secrets: [
{ name: pg.cloudApi.secretName, key: pg.cloudApi.secretKey, sourceRef: pg.cloudApi.sourceRef, envKey: pg.cloudApi.envKey },
...(migration === null || pg.cloudApi.migrationSecretKey === undefined ? [] : [{ name: pg.cloudApi.secretName, key: pg.cloudApi.migrationSecretKey, sourceRef: pg.cloudApi.sourceRef, envKey: pg.cloudApi.migrationEnvKey }]),
{ name: pg.openfga.secretName, key: pg.openfga.secretKey, sourceRef: pg.openfga.sourceRef, envKey: pg.openfga.envKey, authnKey: pg.openfga.authnKey ?? null },
],
setup: compactRuntimeCommand(setup),
apply: compactRuntimeCommand(apply),
valuesPrinted: false,
};
}
export function syncNodeLocalPostgresBootstrapSecret(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
const pg = spec.runtimeStore?.postgres;
if (pg?.mode !== "local-k3s") return null;
const secretName = pg.secretName ?? `${spec.runtimeNamespace}-postgres`;
const sourceRef = pg.adminPasswordSourceRef ?? "";
const sourceKey = pg.adminPasswordSourceKey ?? "";
if (sourceRef.length === 0 || sourceKey.length === 0) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
degradedReason: "local-postgres-secret-source-not-configured",
valuesPrinted: false,
next: { fixYaml: `set runtimeStore.postgres.adminPasswordSourceRef/adminPasswordSourceKey for node=${spec.nodeId} lane=${spec.lane}` },
};
}
const material = readLocalPostgresPasswordMaterial({ sourceRef, sourceKey, dryRun });
if (material.ok !== true) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
source: material,
degradedReason: material.error ?? "local-postgres-secret-source-missing",
valuesPrinted: false,
next: { createSource: `create ${material.sourcePath ?? join(".state", "secrets", sourceRef)} with ${sourceKey}=<redacted>` },
};
}
const result = runNodeK3sScript(spec, localPostgresBootstrapSecretScript(spec, secretName, sourceRef, sourceKey, dryRun, material.fingerprint), timeoutSeconds, material.value ?? "");
const fields = keyValueLinesFromText(statusText(result));
const afterBytes = numericField(fields.afterPasswordBytes) ?? 0;
const ok = isCommandSuccess(result) && fields.afterSecretExists === "yes" && afterBytes > 0;
return {
ok,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: !dryRun && fields.mutation === "true",
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
source: {
ok: material.ok,
sourceRef,
sourceKey,
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
generated: material.generated,
fingerprint: material.fingerprint,
valueBytes: material.value?.length ?? 0,
valuesPrinted: false,
},
before: {
namespaceExists: fields.beforeNamespaceExists === "yes",
secretExists: fields.beforeSecretExists === "yes",
passwordBytes: numericField(fields.beforePasswordBytes),
fingerprint: fields.beforePasswordFingerprint || null,
},
after: {
namespaceExists: fields.afterNamespaceExists === "yes",
secretExists: fields.afterSecretExists === "yes",
passwordBytes: numericField(fields.afterPasswordBytes),
fingerprint: fields.afterPasswordFingerprint || null,
},
action: fields.action || null,
applyExitCode: numericField(fields.applyExitCode),
namespaceCreateExitCode: numericField(fields.namespaceCreateExitCode),
degradedReason: ok ? undefined : fields.action === "source-mismatch" ? "local-postgres-secret-source-mismatch" : "local-postgres-secret-apply-failed",
result: compactRuntimeCommand(result),
valuesPrinted: false,
};
}
export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; sourceKey: string; dryRun: boolean }): {
ok: boolean;
sourceRef: string;
sourceKey: string;
sourcePath: string | null;
checkedPaths: string[];
value: string | null;
fingerprint: string | null;
generated: boolean;
error?: string;
} {
const checkedPaths = localSecretSourcePaths(input.sourceRef);
const existingPath = checkedPaths.find((candidate) => existsSync(candidate)) ?? null;
const sourcePath = existingPath ?? checkedPaths[0] ?? null;
if (sourcePath === null) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: null, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-path-unresolved" };
}
if (existingPath === null && input.dryRun) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-missing" };
}
if (existingPath === null) {
const value = randomBytes(32).toString("base64url");
mkdirSync(dirname(sourcePath), { recursive: true });
writeFileSync(sourcePath, `${input.sourceKey}=${value}\n`, { mode: 0o600 });
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath, checkedPaths, value, fingerprint: shortSecretFingerprint(value), generated: true };
}
const text = readFileSync(existingPath, "utf8");
const values = parseEnvFile(text);
const existingValue = values[input.sourceKey];
if (existingValue !== undefined && existingValue.length > 0) {
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value: existingValue, fingerprint: shortSecretFingerprint(existingValue), generated: false };
}
if (input.dryRun) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-key-missing" };
}
const value = randomBytes(32).toString("base64url");
const prefix = text.length === 0 || text.endsWith("\n") ? "" : "\n";
writeFileSync(existingPath, `${text}${prefix}${input.sourceKey}=${value}\n`, { mode: 0o600 });
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value, fingerprint: shortSecretFingerprint(value), generated: true };
}
export function localSecretSourcePaths(sourceRef: string): string[] {
if (isAbsolute(sourceRef)) return [sourceRef];
return [rootPath(".state", "secrets", sourceRef)];
}
export function shortSecretFingerprint(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
}
export function localPostgresBootstrapSecretScript(spec: HwlabRuntimeLaneSpec, secretName: string, sourceRef: string, sourceKey: string, dryRun: boolean, sourceFingerprint: string | null): string {
return [
"set +e",
`namespace=${shellQuote(spec.runtimeNamespace)}`,
`secret=${shellQuote(secretName)}`,
"secret_key=POSTGRES_PASSWORD",
`source_ref=${shellQuote(sourceRef)}`,
`source_key=${shellQuote(sourceKey)}`,
`source_fingerprint=${shellQuote(sourceFingerprint ?? "")}`,
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
"field_manager=unidesk-hwlab-node-local-postgres-secret",
"password=$(cat)",
"password_bytes=$(printf '%s' \"$password\" | wc -c | tr -d ' ')",
"exists_flag() { kubectl -n \"$namespace\" get \"$1\" \"$2\" >/dev/null 2>&1 && printf yes || printf no; }",
"namespace_exists_flag() { kubectl get namespace \"$namespace\" >/dev/null 2>&1 && printf yes || printf no; }",
"secret_b64_key() { kubectl -n \"$namespace\" get secret \"$secret\" -o \"go-template={{ index .data \\\"$secret_key\\\" }}\" 2>/dev/null || true; }",
"decoded_length() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | wc -c | tr -d ' '; else printf '0'; fi; }",
"decoded_fingerprint() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | sha256sum | awk '{print \"sha256:\" substr($1,1,16)}'; fi; }",
"before_namespace_exists=$(namespace_exists_flag)",
"before_secret_exists=no",
"before_b64=",
"if [ \"$before_namespace_exists\" = yes ]; then before_secret_exists=$(exists_flag secret \"$secret\"); before_b64=$(secret_b64_key); fi",
"before_password_bytes=$(decoded_length \"$before_b64\")",
"before_password_fingerprint=$(decoded_fingerprint \"$before_b64\")",
"after_namespace_exists=$before_namespace_exists",
"after_secret_exists=$before_secret_exists",
"after_password_bytes=$before_password_bytes",
"after_password_fingerprint=$before_password_fingerprint",
"action=observed",
"mutation=false",
"namespace_create_exit=",
"apply_exit=",
"if [ \"$dry_run\" = true ]; then",
" if [ \"$before_secret_exists\" != yes ] || [ \"$before_password_bytes\" -le 0 ]; then action=would-ensure; else action=kept; fi",
"elif [ \"$password_bytes\" -le 0 ]; then",
" action=source-empty",
" apply_exit=42",
"elif [ \"$before_password_bytes\" -gt 0 ] && [ -n \"$source_fingerprint\" ] && [ \"$before_password_fingerprint\" != \"$source_fingerprint\" ]; then",
" action=source-mismatch",
" apply_exit=47",
"else",
" if [ \"$before_namespace_exists\" != yes ]; then",
" kubectl create namespace \"$namespace\" >/tmp/hwlab-local-postgres-namespace.out 2>/tmp/hwlab-local-postgres-namespace.err",
" namespace_create_exit=$?",
" else",
" namespace_create_exit=0",
" fi",
" if [ \"$namespace_create_exit\" = 0 ]; then",
" tmp=$(mktemp /tmp/hwlab-local-postgres-secret.XXXXXX.yaml)",
" password_b64=$(printf '%s' \"$password\" | base64 | tr -d '\\n')",
" cat >\"$tmp\" <<EOF_SECRET",
"apiVersion: v1",
"kind: Secret",
"metadata:",
" name: $secret",
" namespace: $namespace",
" labels:",
" app.kubernetes.io/part-of: hwlab",
" app.kubernetes.io/managed-by: unidesk",
" annotations:",
" hwlab.pikastech.local/secret-source-ref: $source_ref",
" hwlab.pikastech.local/secret-source-key: $source_key",
" hwlab.pikastech.local/secret-source-fingerprint: $source_fingerprint",
"type: Opaque",
"data:",
" $secret_key: $password_b64",
"EOF_SECRET",
" kubectl apply --server-side --force-conflicts --field-manager=\"$field_manager\" -f \"$tmp\" >/tmp/hwlab-local-postgres-secret.apply.out 2>/tmp/hwlab-local-postgres-secret.apply.err",
" apply_exit=$?",
" rm -f \"$tmp\"",
" if [ \"$apply_exit\" = 0 ]; then action=ensured; mutation=true; else action=apply-failed; fi",
" else",
" action=namespace-create-failed",
" apply_exit=$namespace_create_exit",
" fi",
"fi",
"after_namespace_exists=$(namespace_exists_flag)",
"if [ \"$after_namespace_exists\" = yes ]; then after_secret_exists=$(exists_flag secret \"$secret\"); after_b64=$(secret_b64_key); else after_secret_exists=no; after_b64=; fi",
"after_password_bytes=$(decoded_length \"$after_b64\")",
"after_password_fingerprint=$(decoded_fingerprint \"$after_b64\")",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$secret\"",
"printf 'key\\t%s\\n' \"$secret_key\"",
"printf 'sourceRef\\t%s\\n' \"$source_ref\"",
"printf 'sourceKey\\t%s\\n' \"$source_key\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeNamespaceExists\\t%s\\n' \"$before_namespace_exists\"",
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
"printf 'beforePasswordBytes\\t%s\\n' \"$before_password_bytes\"",
"printf 'beforePasswordFingerprint\\t%s\\n' \"$before_password_fingerprint\"",
"printf 'afterNamespaceExists\\t%s\\n' \"$after_namespace_exists\"",
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
"printf 'afterPasswordBytes\\t%s\\n' \"$after_password_bytes\"",
"printf 'afterPasswordFingerprint\\t%s\\n' \"$after_password_fingerprint\"",
"printf 'namespaceCreateExitCode\\t%s\\n' \"$namespace_create_exit\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"password=",
"if [ -n \"$apply_exit\" ] && [ \"$apply_exit\" != 0 ]; then exit \"$apply_exit\"; fi",
"if [ \"$after_secret_exists\" != yes ] || [ \"$after_password_bytes\" -le 0 ]; then exit 48; fi",
].join("\n");
}
interface NodeRuntimeImageDependency {
id: string;
target: string;
source: string;
mode: "pull" | "build-moonbridge" | "build-opencode-git";
sourceRepo?: string;
sourceRef?: string;
builderImage?: string;
goProxy?: string;
dockerNetworkMode?: "default" | "host";
}
function nodeRuntimeImageDependencies(spec: HwlabRuntimeLaneSpec): NodeRuntimeImageDependency[] {
const images: NodeRuntimeImageDependency[] = [
{ id: "base", target: spec.baseImage, source: spec.baseImageSource ?? "", mode: "pull" },
];
if (spec.buildkit !== undefined) {
images.push({ id: "buildkit", target: spec.buildkit.sidecarImage, source: spec.buildkit.sourceImage, mode: "pull" });
}
spec.runtimeImageRewrites.forEach((rewrite, index) => {
images.push({ id: `rewrite-${index + 1}`, target: rewrite.target, source: rewrite.source, mode: "pull" });
});
spec.runtimeImageBuilds.forEach((build) => {
if (build.kind === "moonbridge") {
images.push({
id: build.id,
target: build.target,
source: `${build.sourceRepo}#${build.sourceRef}`,
mode: "build-moonbridge",
sourceRepo: build.sourceRepo,
sourceRef: build.sourceRef,
builderImage: build.builderImage,
goProxy: build.goProxy,
dockerNetworkMode: build.dockerNetworkMode,
});
return;
}
images.push({
id: build.id,
target: build.target,
source: build.sourceImage ?? "",
mode: "build-opencode-git",
dockerNetworkMode: build.dockerNetworkMode,
});
});
return images;
}
function nodeRuntimeImageCalls(spec: HwlabRuntimeLaneSpec, functionName: string): string {
return nodeRuntimeImageDependencies(spec)
.map((image) => [
functionName,
image.id,
image.target,
image.source,
image.mode,
image.sourceRepo ?? "",
image.sourceRef ?? "",
image.builderImage ?? "",
image.goProxy ?? "",
image.dockerNetworkMode ?? "",
].map(shellQuote).join(" "))
.join("\n");
}
function parseNodeRuntimeImageRows(text: string): Record<string, unknown>[] {
return text.split(/\r?\n/u).flatMap((line) => {
if (!line.startsWith("imageJson\t")) return [];
try {
return [JSON.parse(line.slice("imageJson\t".length)) as Record<string, unknown>];
} catch {
return [];
}
});
}
export function nodeRuntimeBaseImageStatus(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): Record<string, unknown> {
const script = [
"set -eu",
"check_image() {",
" id=\"$1\"; target=\"$2\"; source=\"$3\"; mode=\"$4\"",
" repo_tag=${target#*/}",
" repo=${repo_tag%:*}",
" tag=${repo_tag##*:}",
" if [ \"$repo\" = \"$repo_tag\" ]; then tag=latest; fi",
" registry_url=\"http://127.0.0.1:5000/v2/$repo/tags/list\"",
" registry_present=false",
" if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then registry_present=true; fi",
" target_present=false",
" if docker image inspect \"$target\" >/dev/null 2>&1; then target_present=true; fi",
" source_present=false",
" if [ \"$mode\" = build-moonbridge ]; then source_present=true; elif [ -n \"$source\" ] && docker image inspect \"$source\" >/dev/null 2>&1; then source_present=true; fi",
" source_configured=false",
" if [ -n \"$source\" ]; then source_configured=true; fi",
" python3 - \"$id\" \"$target\" \"$source\" \"$mode\" \"$source_configured\" \"$registry_url\" \"$registry_present\" \"$target_present\" \"$source_present\" <<'PY'",
"import json, sys",
"keys=['id','target','source','mode','sourceConfigured','registryUrl','registryTagPresent','targetImagePresent','sourceImagePresent']",
"data=dict(zip(keys, sys.argv[1:]))",
"for key in ['sourceConfigured','registryTagPresent','targetImagePresent','sourceImagePresent']:",
" data[key] = data[key] == 'true'",
"print('imageJson\\t' + json.dumps(data, separators=(',', ':')))",
"PY",
"}",
nodeRuntimeImageCalls(spec, "check_image"),
].join("\n");
const result = runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300));
const imageRows = parseNodeRuntimeImageRows(statusText(result));
const base = imageRows.find((image) => image.id === "base") ?? {};
return {
ok: isCommandSuccess(result) && imageRows.length === nodeRuntimeImageDependencies(spec).length && imageRows.every((image) => image.sourceConfigured === true && image.registryTagPresent === true),
target: base.target ?? spec.baseImage,
source: base.source || null,
sourceConfigured: base.sourceConfigured === true,
registryUrl: base.registryUrl ?? null,
registryTagPresent: base.registryTagPresent === true,
targetImagePresent: base.targetImagePresent === true,
sourceImagePresent: base.sourceImagePresent === true,
images: imageRows,
result: compactRuntimeCommand(result),
};
}
export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
if (spec.baseImageSource === undefined) return null;
const script = [
"set -eu",
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
`pull_retries=${shellQuote(String(Math.max(1, spec.downloadProfile.docker.pullRetries)))}`,
`build_http_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.http)}`,
`build_https_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.https)}`,
`build_all_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.all)}`,
`build_no_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.noProxy.join(","))}`,
"resolve_host_proxy_url() {",
" raw_url=\"$1\"",
" if [ -z \"$raw_url\" ]; then printf '\\n'; return 0; fi",
" info=$(python3 - \"$raw_url\" <<'PY'",
"from urllib.parse import urlparse",
"import sys",
"url = sys.argv[1]",
"parsed = urlparse(url)",
"host = parsed.hostname or ''",
"port = parsed.port",
"scheme = parsed.scheme or 'http'",
"parts = host.split('.')",
"if len(parts) >= 4 and parts[2] == 'svc':",
" print('\\t'.join(['service', scheme, parts[0], parts[1], str(port or 80)]))",
"else:",
" print('\\t'.join(['literal', url]))",
"PY",
" )",
" kind=$(printf '%s' \"$info\" | cut -f1)",
" service_cluster_ip() {",
" service_namespace=\"$1\"; service_name=\"$2\"",
" cluster_ip=\"\"",
" if command -v kubectl >/dev/null 2>&1; then",
" cluster_ip=$(kubectl -n \"$service_namespace\" get svc \"$service_name\" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)",
" fi",
" if { [ -z \"$cluster_ip\" ] || [ \"$cluster_ip\" = None ]; } && command -v k3s >/dev/null 2>&1; then",
" cluster_ip=$(k3s kubectl -n \"$service_namespace\" get svc \"$service_name\" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)",
" fi",
" printf '%s\\n' \"$cluster_ip\"",
" }",
" if [ \"$kind\" = service ]; then",
" scheme=$(printf '%s' \"$info\" | cut -f2)",
" service_name=$(printf '%s' \"$info\" | cut -f3)",
" service_namespace=$(printf '%s' \"$info\" | cut -f4)",
" service_port=$(printf '%s' \"$info\" | cut -f5)",
" cluster_ip=$(service_cluster_ip \"$service_namespace\" \"$service_name\")",
" if [ -n \"$cluster_ip\" ] && [ \"$cluster_ip\" != None ]; then printf '%s://%s:%s\\n' \"$scheme\" \"$cluster_ip\" \"$service_port\"; else printf '%s\\n' \"$raw_url\"; fi",
" else",
" printf '%s\\n' \"$raw_url\"",
" fi",
"}",
"build_http_proxy=$(resolve_host_proxy_url \"$build_http_proxy\")",
"build_https_proxy=$(resolve_host_proxy_url \"$build_https_proxy\")",
"build_all_proxy=$(resolve_host_proxy_url \"$build_all_proxy\")",
"build_container_proxy_url() {",
" raw_url=\"$1\"",
" if [ -z \"$raw_url\" ]; then printf '\\n'; return 0; fi",
" python3 - \"$raw_url\" <<'PY'",
"from urllib.parse import urlparse, urlunparse",
"import sys",
"url = sys.argv[1]",
"parsed = urlparse(url)",
"host = parsed.hostname or ''",
"if host in {'127.0.0.1', 'localhost'}:",
" netloc = 'host.docker.internal'",
" if parsed.port is not None:",
" netloc = f'{netloc}:{parsed.port}'",
" print(urlunparse(parsed._replace(netloc=netloc)))",
"else:",
" print(url)",
"PY",
"}",
"build_container_http_proxy=$(build_container_proxy_url \"$build_http_proxy\")",
"build_container_https_proxy=$(build_container_proxy_url \"$build_https_proxy\")",
"build_container_all_proxy=$(build_container_proxy_url \"$build_all_proxy\")",
"docker_build_add_host_args=\"\"",
"case \"$build_container_http_proxy $build_container_https_proxy $build_container_all_proxy\" in",
" *host.docker.internal*) docker_build_add_host_args=\"--add-host host.docker.internal:host-gateway\" ;;",
"esac",
"failed=false",
"ensure_image() {",
" id=\"$1\"; target=\"$2\"; source=\"$3\"; mode=\"$4\"; source_repo=\"$5\"; source_ref=\"$6\"; builder_image=\"$7\"; go_proxy=\"$8\"; docker_network_mode=\"$9\"",
" repo_tag=${target#*/}",
" repo=${repo_tag%:*}",
" tag=${repo_tag##*:}",
" if [ \"$repo\" = \"$repo_tag\" ]; then tag=latest; fi",
" registry_url=\"http://127.0.0.1:5000/v2/$repo/tags/list\"",
" present=false",
" if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then present=true; fi",
" action=none",
" source_present_before=unknown",
" pulled_source=false",
" pull_attempts=0",
" if [ \"$present\" = false ]; then",
" action=seed",
" if [ \"$dry_run\" = false ]; then",
" if [ \"$mode\" = build-moonbridge ]; then",
" action=build",
" source_present_before=build-source",
" effective_build_container_http_proxy=\"$build_container_http_proxy\"",
" effective_build_container_https_proxy=\"$build_container_https_proxy\"",
" effective_build_container_all_proxy=\"$build_container_all_proxy\"",
" effective_docker_build_add_host_args=\"$docker_build_add_host_args\"",
" if [ \"$docker_network_mode\" = host ]; then",
" effective_build_container_http_proxy=\"$build_http_proxy\"",
" effective_build_container_https_proxy=\"$build_https_proxy\"",
" effective_build_container_all_proxy=\"$build_all_proxy\"",
" effective_docker_build_add_host_args=\"\"",
" fi",
" tmpdir=$(mktemp -d /tmp/hwlab-node-runtime-image-$id.XXXXXX)",
" dockerfile=\"$tmpdir/Dockerfile\"",
" cat > \"$dockerfile\" <<'DOCKERFILE'",
"ARG BUILDER_IMAGE",
"FROM ${BUILDER_IMAGE} AS builder",
"ARG MOONBRIDGE_REPO",
"ARG MOONBRIDGE_REF",
"ARG GOPROXY_VALUE",
"ARG HTTP_PROXY",
"ARG HTTPS_PROXY",
"ARG ALL_PROXY",
"ARG NO_PROXY",
"ENV GOPROXY=${GOPROXY_VALUE}",
"ENV HTTP_PROXY=${HTTP_PROXY}",
"ENV HTTPS_PROXY=${HTTPS_PROXY}",
"ENV ALL_PROXY=${ALL_PROXY}",
"ENV NO_PROXY=${NO_PROXY}",
"ENV http_proxy=${HTTP_PROXY}",
"ENV https_proxy=${HTTPS_PROXY}",
"ENV all_proxy=${ALL_PROXY}",
"ENV no_proxy=${NO_PROXY}",
"WORKDIR /src",
"RUN git clone --filter=blob:none \"${MOONBRIDGE_REPO}\" moon-bridge",
"WORKDIR /src/moon-bridge",
"RUN git checkout \"${MOONBRIDGE_REF}\" \\",
" && go mod download \\",
" && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o /out/moonbridge ./cmd/moonbridge",
"FROM scratch",
"WORKDIR /app",
"COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt",
"COPY --from=builder /out/moonbridge /app/moonbridge",
"EXPOSE 4001",
"USER 65532:65532",
"ENTRYPOINT [\"/app/moonbridge\"]",
"DOCKERFILE",
" if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker build $effective_docker_build_add_host_args --network \"$docker_network_mode\" --build-arg BUILDER_IMAGE=\"$builder_image\" --build-arg MOONBRIDGE_REPO=\"$source_repo\" --build-arg MOONBRIDGE_REF=\"$source_ref\" --build-arg GOPROXY_VALUE=\"$go_proxy\" --build-arg HTTP_PROXY=\"$effective_build_container_http_proxy\" --build-arg HTTPS_PROXY=\"$effective_build_container_https_proxy\" --build-arg ALL_PROXY=\"$effective_build_container_all_proxy\" --build-arg NO_PROXY=\"$build_no_proxy\" --build-arg http_proxy=\"$effective_build_container_http_proxy\" --build-arg https_proxy=\"$effective_build_container_https_proxy\" --build-arg all_proxy=\"$effective_build_container_all_proxy\" --build-arg no_proxy=\"$build_no_proxy\" -t \"$target\" -f \"$dockerfile\" \"$tmpdir\" >/tmp/hwlab-node-runtime-image-$id-build.out 2>&1; then",
" docker push \"$target\" >/tmp/hwlab-node-runtime-image-$id-push.out 2>&1 || { cat /tmp/hwlab-node-runtime-image-$id-push.out >&2 2>/dev/null || true; failed=true; }",
" else",
" cat /tmp/hwlab-node-runtime-image-$id-build.out >&2 2>/dev/null || true",
" failed=true",
" fi",
" rm -rf \"$tmpdir\"",
" elif [ \"$mode\" = build-opencode-git ]; then",
" action=build",
" source_present_before=false",
" if [ -n \"$source\" ] && docker image inspect \"$source\" >/dev/null 2>&1; then source_present_before=true; fi",
" effective_build_container_http_proxy=\"$build_container_http_proxy\"",
" effective_build_container_https_proxy=\"$build_container_https_proxy\"",
" effective_build_container_all_proxy=\"$build_container_all_proxy\"",
" effective_docker_build_add_host_args=\"$docker_build_add_host_args\"",
" if [ \"$docker_network_mode\" = host ]; then",
" effective_build_container_http_proxy=\"$build_http_proxy\"",
" effective_build_container_https_proxy=\"$build_https_proxy\"",
" effective_build_container_all_proxy=\"$build_all_proxy\"",
" effective_docker_build_add_host_args=\"\"",
" fi",
" tmpdir=$(mktemp -d /tmp/hwlab-node-runtime-image-$id.XXXXXX)",
" dockerfile=\"$tmpdir/Dockerfile\"",
" cat > \"$dockerfile\" <<'DOCKERFILE'",
"ARG SOURCE_IMAGE",
"FROM ${SOURCE_IMAGE}",
"ARG HTTP_PROXY",
"ARG HTTPS_PROXY",
"ARG ALL_PROXY",
"ARG NO_PROXY",
"ENV HTTP_PROXY=${HTTP_PROXY}",
"ENV HTTPS_PROXY=${HTTPS_PROXY}",
"ENV ALL_PROXY=${ALL_PROXY}",
"ENV NO_PROXY=${NO_PROXY}",
"ENV http_proxy=${HTTP_PROXY}",
"ENV https_proxy=${HTTPS_PROXY}",
"ENV all_proxy=${ALL_PROXY}",
"ENV no_proxy=${NO_PROXY}",
"RUN apk add --no-cache git",
"DOCKERFILE",
" if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker build $effective_docker_build_add_host_args --network \"$docker_network_mode\" --build-arg SOURCE_IMAGE=\"$source\" --build-arg HTTP_PROXY=\"$effective_build_container_http_proxy\" --build-arg HTTPS_PROXY=\"$effective_build_container_https_proxy\" --build-arg ALL_PROXY=\"$effective_build_container_all_proxy\" --build-arg NO_PROXY=\"$build_no_proxy\" --build-arg http_proxy=\"$effective_build_container_http_proxy\" --build-arg https_proxy=\"$effective_build_container_https_proxy\" --build-arg all_proxy=\"$effective_build_container_all_proxy\" --build-arg no_proxy=\"$build_no_proxy\" -t \"$target\" -f \"$dockerfile\" \"$tmpdir\" >/tmp/hwlab-node-runtime-image-$id-build.out 2>&1; then",
" docker push \"$target\" >/tmp/hwlab-node-runtime-image-$id-push.out 2>&1 || { cat /tmp/hwlab-node-runtime-image-$id-push.out >&2 2>/dev/null || true; failed=true; }",
" else",
" cat /tmp/hwlab-node-runtime-image-$id-build.out >&2 2>/dev/null || true",
" failed=true",
" fi",
" rm -rf \"$tmpdir\"",
" else",
" source_present_before=true",
" if ! docker image inspect \"$source\" >/dev/null 2>&1; then",
" source_present_before=false",
" attempt=1",
" while [ \"$attempt\" -le \"$pull_retries\" ]; do",
" pull_attempts=$attempt",
" if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker pull \"$source\" >/tmp/hwlab-node-runtime-image-$id-pull.out 2>&1; then pulled_source=true; break; fi",
" attempt=$((attempt + 1))",
" sleep 2",
" done",
" if [ \"$pulled_source\" != true ]; then cat /tmp/hwlab-node-runtime-image-$id-pull.out >&2 2>/dev/null || true; failed=true; fi",
" fi",
" if [ \"$failed\" != true ]; then",
" if docker image inspect \"$source\" >/dev/null 2>&1; then",
" docker tag \"$source\" \"$target\"",
" docker push \"$target\" >/tmp/hwlab-node-runtime-image-$id-push.out 2>&1 || failed=true",
" else",
" failed=true",
" fi",
" fi",
" fi",
" fi",
" fi",
" after=false",
" if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then after=true; fi",
" python3 - \"$id\" \"$target\" \"$source\" \"$mode\" \"$dry_run\" \"$present\" \"$after\" \"$action\" \"$pull_retries\" \"$source_present_before\" \"$pulled_source\" \"$pull_attempts\" <<'PY'",
"import json, sys",
"keys=['id','target','source','mode','dryRun','presentBefore','presentAfter','action','pullRetries','sourcePresentBefore','pulledSource','pullAttempts']",
"data=dict(zip(keys, sys.argv[1:]))",
"for key in ['dryRun','presentBefore','presentAfter','pulledSource']:",
" data[key] = data[key] == 'true'",
"for key in ['pullRetries','pullAttempts']:",
" data[key] = int(data[key]) if str(data[key]).isdigit() else None",
"print('imageJson\\t' + json.dumps(data, separators=(',', ':')))",
"PY",
" if [ \"$dry_run\" = false ] && [ \"$after\" != true ]; then failed=true; fi",
"}",
nodeRuntimeImageCalls(spec, "ensure_image"),
"if [ \"$failed\" = true ]; then exit 1; fi",
].join("\n");
const hasBuild = nodeRuntimeImageDependencies(spec).some((image) => image.mode === "build-moonbridge" || image.mode === "build-opencode-git");
const result = hasBuild && !dryRun
? runNodeHostScriptAsync(spec, script, Math.min(timeoutSeconds, 600), "runtime-image-build")
: runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300));
const imageRows = parseNodeRuntimeImageRows(statusText(result));
const dependencyCount = nodeRuntimeImageDependencies(spec).length;
const imageRowsComplete = imageRows.length === dependencyCount
&& imageRows.every((image) => image.presentAfter === true || image.registryTagPresent === true);
const base = imageRows.find((image) => image.id === "base") ?? {};
return {
ok: isCommandSuccess(result) || imageRowsComplete,
dryRun,
target: base.target ?? spec.baseImage,
source: base.source ?? spec.baseImageSource,
presentBefore: base.presentBefore === true,
presentAfter: base.presentAfter === true,
action: base.action ?? null,
pullRetries: numericField(typeof base.pullRetries === "number" ? String(base.pullRetries) : undefined),
sourcePresentBefore: typeof base.sourcePresentBefore === "string" ? base.sourcePresentBefore : null,
pulledSource: base.pulledSource === true,
pullAttempts: numericField(typeof base.pullAttempts === "number" ? String(base.pullAttempts) : undefined),
images: imageRows,
result: compactRuntimeCommand(result),
};
}
export function externalPostgresSecretSetupScript(spec: HwlabRuntimeLaneSpec, dryRun: boolean): string {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return "true";
const authnKey = pg.openfga.authnKey ?? "authn-preshared-key";
return [
"set -eu",
`namespace=${shellQuote(spec.runtimeNamespace)}`,
`secret=${shellQuote(pg.openfga.secretName)}`,
`authn_key=${shellQuote(authnKey)}`,
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
"namespace_apply_args=\"--server-side --field-manager=unidesk-hwlab-node-runtime-namespace\"",
"if [ \"$dry_run\" = true ]; then namespace_apply_args=\"$namespace_apply_args --dry-run=server\"; fi",
"kubectl create namespace \"$namespace\" --dry-run=client -o yaml | kubectl apply $namespace_apply_args -f -",
"authn_present=$(kubectl -n \"$namespace\" get secret \"$secret\" -o \"go-template={{ index .data \\\"$authn_key\\\" }}\" 2>/dev/null | wc -c | tr -d ' ')",
"if [ \"${authn_present:-0}\" -gt 0 ]; then",
" action=kept",
"elif [ \"$dry_run\" = true ]; then",
" action=would-generate-authn-key",
"else",
" if command -v openssl >/dev/null 2>&1; then authn_value=$(openssl rand -base64 48); else authn_value=$(head -c 48 /dev/urandom | base64); fi",
" kubectl -n \"$namespace\" create secret generic \"$secret\" --from-literal=\"$authn_key=$authn_value\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=unidesk-hwlab-node-openfga-authn -f - >/dev/null",
" authn_value=",
" action=generated-authn-key",
"fi",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$secret\"",
"printf 'authnKey\\t%s\\n' \"$authn_key\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'valuesPrinted\\tfalse\\n'",
].join("\n");
}
export function externalPostgresSecretSourceRoot(spec: HwlabRuntimeLaneSpec): string {
const configRef = spec.externalPostgres?.configRef;
if (configRef === undefined) return rootPath(".state", "secrets");
const configPath = rootPath(configRef);
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configRef} must be a YAML object`);
const secrets = (parsed as Record<string, unknown>).secrets;
if (typeof secrets !== "object" || secrets === null || Array.isArray(secrets)) throw new Error(`${configRef}.secrets must be an object`);
const root = (secrets as Record<string, unknown>).root;
if (typeof root !== "string" || root.length === 0) throw new Error(`${configRef}.secrets.root must be a non-empty string`);
return root.startsWith("/") ? root : rootPath(root);
}
export function readSecretSourceValue(secretRoot: string, sourceRef: string, key: string): { ok: true; value: string; sourcePath: string } | { ok: false; sourcePath: string } {
const sourcePath = isAbsolute(sourceRef) ? sourceRef : join(secretRoot, sourceRef);
if (!existsSync(sourcePath)) return { ok: false, sourcePath };
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const value = values[key];
if (value === undefined || value.length === 0) return { ok: false, sourcePath };
return { ok: true, value, sourcePath };
}
export function secretSourcePaths(sourceRef: string): string[] {
if (isAbsolute(sourceRef)) return [sourceRef];
return [rootPath(".state", "secrets", sourceRef)];
}
export function displayRepoPath(path: string): string {
const normalizedRoot = repoRoot.replace(/\/+$/u, "");
if (path === normalizedRoot) return ".";
if (path.startsWith(`${normalizedRoot}/`)) return path.slice(normalizedRoot.length + 1);
const marker = "/.worktree/";
const index = normalizedRoot.indexOf(marker);
if (index >= 0) {
const mainRoot = normalizedRoot.slice(0, index);
if (path === mainRoot) return ".";
if (path.startsWith(`${mainRoot}/`)) return path.slice(mainRoot.length + 1);
}
return path;
}
export function hwlabPasswordHash(password: string): string {
const salt = randomBytes(16).toString("hex");
return `sha256:${salt}:${createHash("sha256").update(`${salt}:${password}`).digest("hex")}`;
}
export function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): BootstrapAdminSecretMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
const sourceLine = spec.bootstrapAdminPasswordSourceLine ?? null;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return bootstrapAdminSecretMaterialError(spec, sourceRef ?? null, sourceKey ?? null, sourceLine, null, null, "bootstrap-admin-yaml-source-missing");
}
const password = readSecretSourceScalar(sourceRef, sourceKey, sourceLine);
if (!password.ok) return bootstrapAdminSecretMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, password.error);
const username = readBootstrapAdminUsername(spec);
if (!username.ok) return bootstrapAdminSecretMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, username.error);
return {
ok: true,
username: username.value,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: shortSecretFingerprint(username.value),
sourceRef,
sourceKey,
sourceLine,
sourcePath: password.sourcePath,
sourcePresent: password.sourcePresent,
sourceFingerprint: shortSecretFingerprint(password.value),
passwordHash: hwlabPasswordHash(password.value),
error: null,
};
}
export function readBootstrapAdminPasswordMaterial(spec: RuntimeSecretSpec): BootstrapAdminPasswordMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
const sourceLine = spec.bootstrapAdminPasswordSourceLine ?? null;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return bootstrapAdminPasswordMaterialError(spec, sourceRef ?? null, sourceKey ?? null, sourceLine, null, null, "bootstrap-admin-yaml-source-missing");
}
const password = readSecretSourceScalar(sourceRef, sourceKey, sourceLine);
if (!password.ok) return bootstrapAdminPasswordMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, password.error);
const username = readBootstrapAdminUsername(spec);
if (!username.ok) return bootstrapAdminPasswordMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, username.error);
return {
ok: true,
username: username.value,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: shortSecretFingerprint(username.value),
sourceRef,
sourceKey,
sourceLine,
sourcePath: password.sourcePath,
sourcePresent: password.sourcePresent,
sourceFingerprint: shortSecretFingerprint(password.value),
password: password.value,
error: null,
};
}
function readBootstrapAdminUsername(spec: RuntimeSecretSpec): { ok: true; value: string } | { ok: false; error: string } {
const ref = spec.bootstrapAdminUsernameSourceRef;
if (ref === undefined) return { ok: true, value: spec.bootstrapAdminUsername };
const key = spec.bootstrapAdminUsernameSourceKey ?? "username";
const material = readSecretSourceScalar(ref, key, spec.bootstrapAdminUsernameSourceLine ?? null);
if (!material.ok && material.error === "secret-source-missing" && spec.bootstrapAdminUsername.length > 0) return { ok: true, value: spec.bootstrapAdminUsername };
if (!material.ok) return { ok: false, error: `username-${material.error}` };
return { ok: true, value: material.value };
}
function readSecretSourceScalar(sourceRef: string, sourceKey: string, sourceLine: number | null): { ok: true; value: string; sourcePath: string; sourcePresent: boolean } | { ok: false; sourcePath: string; sourcePresent: boolean; error: string } {
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? rootPath(".state", "secrets", sourceRef);
const runtimeValue = process.env[sourceKey];
if (!existsSync(sourcePath)) {
if (runtimeValue !== undefined && runtimeValue.length > 0) return { ok: true, value: runtimeValue, sourcePath, sourcePresent: false };
return { ok: false, sourcePath, sourcePresent: false, error: "secret-source-missing" };
}
const text = readFileSync(sourcePath, "utf8");
if (sourceLine !== null) {
const line = text.split(/\r?\n/u)[sourceLine - 1]?.replace(/\r$/u, "") ?? "";
if (line.length === 0 && runtimeValue !== undefined && runtimeValue.length > 0) return { ok: true, value: runtimeValue, sourcePath, sourcePresent: true };
if (line.length === 0) return { ok: false, sourcePath, sourcePresent: true, error: "secret-line-missing" };
return { ok: true, value: line, sourcePath, sourcePresent: true };
}
const values = parseEnvFile(text);
const value = values[sourceKey] ?? runtimeValue;
if (value === undefined || value.length === 0) return { ok: false, sourcePath, sourcePresent: true, error: "secret-key-missing" };
return { ok: true, value, sourcePath, sourcePresent: true };
}
function bootstrapAdminSecretMaterialError(spec: RuntimeSecretSpec, sourceRef: string | null, sourceKey: string | null, sourceLine: number | null, sourcePath: string | null, sourcePresent: boolean | null, error: string): BootstrapAdminSecretMaterial {
return {
ok: false,
username: null,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: null,
sourceRef,
sourceKey,
sourceLine,
sourcePath,
sourcePresent: sourcePresent === true,
sourceFingerprint: null,
passwordHash: null,
error,
};
}
function bootstrapAdminPasswordMaterialError(spec: RuntimeSecretSpec, sourceRef: string | null, sourceKey: string | null, sourceLine: number | null, sourcePath: string | null, sourcePresent: boolean | null, error: string): BootstrapAdminPasswordMaterial {
return {
ok: false,
username: null,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: null,
sourceRef,
sourceKey,
sourceLine,
sourcePath,
sourcePresent: sourcePresent === true,
sourceFingerprint: null,
password: null,
error,
};
}
export function parseEnvFile(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const rawValue = line.slice(eq + 1).trim();
values[key] = rawValue.startsWith("'") && rawValue.endsWith("'")
? rawValue.slice(1, -1).replace(/'"'"'/gu, "'")
: rawValue.startsWith("\"") && rawValue.endsWith("\"")
? rawValue.slice(1, -1)
: rawValue;
}
return values;
}
export function isLocalPostgresObject(name: string, spec: HwlabRuntimeLaneSpec): boolean {
if (!/postgres|pgsql|pgdata/iu.test(name)) return false;
const externalServiceName = spec.externalPostgres?.serviceName;
if (externalServiceName !== undefined && (name === `service/${externalServiceName}` || name === `svc/${externalServiceName}`)) return false;
return true;
}
export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, namespaceExists: boolean): Record<string, unknown> {
const runtime = spec.codeAgentRuntime;
if (runtime === undefined || runtime.enabled === false) return { required: false, ready: true, enabled: runtime?.enabled ?? false };
if (!namespaceExists) return { required: true, ready: false, enabled: true, degradedReason: "runtime-namespace-missing" };
const managerTarget = nodeRuntimeCodeAgentManagerTarget(runtime.managerUrl, runtime.runnerNamespace);
const runnerNamespace = runNodeK3sArgs(spec, ["kubectl", "get", "ns", runtime.runnerNamespace, "-o", "name"], 60);
const secretNamespace = runNodeK3sArgs(spec, ["kubectl", "get", "ns", runtime.secretNamespace, "-o", "name"], 60);
const managerNamespace = runNodeK3sArgs(spec, ["kubectl", "get", "ns", managerTarget.namespace, "-o", "name"], 60);
const managerService = runNodeK3sArgs(spec, ["kubectl", "-n", managerTarget.namespace, "get", "service", managerTarget.serviceName, "-o", "name"], 60);
const managerDeployment = runNodeK3sArgs(spec, ["kubectl", "-n", managerTarget.namespace, "get", "deployment", managerTarget.serviceName, "-o", "jsonpath={.status.readyReplicas}{\"\\n\"}{.spec.replicas}{\"\\n\"}"], 60);
const [managerReadyReplicasRaw = "", managerDesiredReplicasRaw = ""] = managerDeployment.stdout.split(/\r?\n/u);
const managerReadyReplicas = numericField(managerReadyReplicasRaw);
const managerDesiredReplicas = numericField(managerDesiredReplicasRaw);
const managerDeploymentReady = managerDeployment.exitCode === 0 && (managerReadyReplicas ?? 0) >= Math.max(1, managerDesiredReplicas ?? 1);
const apiKey = secretKeyStatus(spec, runtime.apiKeySecretName, runtime.apiKeySecretKey);
const cloudApiEnv = nodeRuntimeCodeAgentCloudApiEnvStatus(spec, runtime);
const cloudWebEnv = nodeRuntimeCodeAgentCloudWebEnvStatus(spec, runtime);
const ready = runnerNamespace.exitCode === 0
&& secretNamespace.exitCode === 0
&& managerNamespace.exitCode === 0
&& managerService.exitCode === 0
&& managerDeploymentReady
&& apiKey.present === true
&& cloudApiEnv.ready === true
&& cloudWebEnv.ready === true;
const degradedReason = ready
? undefined
: runnerNamespace.exitCode !== 0
? "agentrun-runner-namespace-missing"
: secretNamespace.exitCode !== 0
? "agentrun-secret-namespace-missing"
: managerNamespace.exitCode !== 0
? "agentrun-manager-namespace-missing"
: managerService.exitCode !== 0
? "agentrun-manager-service-missing"
: !managerDeploymentReady
? "agentrun-manager-deployment-not-ready"
: apiKey.present !== true
? "agentrun-api-key-secret-missing"
: cloudApiEnv.ready !== true
? "hwlab-cloud-api-code-agent-env-mismatch"
: "hwlab-cloud-web-realtime-env-mismatch";
return {
required: true,
enabled: true,
ready,
degradedReason,
adapter: runtime.adapter,
managerUrl: runtime.managerUrl,
manager: {
namespace: managerTarget.namespace,
serviceName: managerTarget.serviceName,
serviceExists: managerService.exitCode === 0,
deploymentReady: managerDeploymentReady,
readyReplicas: managerReadyReplicas,
desiredReplicas: managerDesiredReplicas,
namespaceResult: compactRuntimeCommand(managerNamespace),
serviceResult: compactRuntimeCommand(managerService),
deploymentResult: compactRuntimeCommand(managerDeployment),
},
runnerNamespace: {
name: runtime.runnerNamespace,
exists: runnerNamespace.exitCode === 0,
result: compactRuntimeCommand(runnerNamespace),
},
secretNamespace: {
name: runtime.secretNamespace,
exists: secretNamespace.exitCode === 0,
result: compactRuntimeCommand(secretNamespace),
},
apiKey,
cloudApiEnv,
cloudWebEnv,
repoUrlFrom: runtime.repoUrlFrom,
providerIdFrom: runtime.providerIdFrom,
valuesPrinted: false,
};
}
function nodeRuntimeCodeAgentManagerTarget(managerUrl: string, fallbackNamespace: string): { namespace: string; serviceName: string } {
let parsed: URL | null = null;
try {
parsed = new URL(managerUrl);
} catch {
parsed = null;
}
const host = parsed?.hostname ?? "";
const parts = host.split(".").filter(Boolean);
if (parts.length >= 3 && parts[2] === "svc") return { serviceName: parts[0], namespace: parts[1] };
return { serviceName: parts[0] || "agentrun-mgr", namespace: parts[1] || fallbackNamespace };
}
function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
const envProbe = runNodeK3sArgs(spec, [
"kubectl",
"-n",
spec.runtimeNamespace,
"get",
"deployment",
"hwlab-cloud-api",
"-o",
'jsonpath={range .spec.template.spec.containers[?(@.name=="hwlab-cloud-api")].env[*]}{.name}{"\\t"}{.value}{"\\t"}{.valueFrom.secretKeyRef.name}{"\\t"}{.valueFrom.secretKeyRef.key}{"\\n"}{end}',
], 60);
if (envProbe.exitCode !== 0) {
return {
ready: false,
deploymentExists: false,
mismatches: [{ name: "hwlab-cloud-api", reason: "deployment-missing" }],
result: compactCodeAgentDeploymentProbeResult(envProbe),
valuesPrinted: false,
};
}
const envByName = parseCodeAgentEnvProbe(envProbe.stdout);
if (envByName.size === 0) {
return {
ready: false,
deploymentExists: true,
deploymentName: "hwlab-cloud-api",
containerName: "hwlab-cloud-api",
mismatches: [{ name: "hwlab-cloud-api", reason: "container-env-not-found" }],
result: compactCodeAgentDeploymentProbeResult(envProbe),
valuesPrinted: false,
};
}
const mismatches: Array<Record<string, unknown>> = [];
const expectValue = (name: string, expected: string): void => {
const item = envByName.get(name);
if (item === undefined) {
mismatches.push({ name, expected, present: false, kind: "value" });
return;
}
if (item.value !== expected) mismatches.push({ name, expected, value: item.value, present: true, kind: "value" });
};
const expectAbsent = (name: string): void => {
const item = envByName.get(name);
if (item !== undefined) mismatches.push({ name, expected: null, value: item.value, present: true, kind: "unexpected-value" });
};
const expectSecret = (name: string, expectedSecret: string, expectedKey: string): void => {
const item = envByName.get(name);
if (item === undefined) {
mismatches.push({ name, expectedSecret, expectedKey, present: false, kind: "secretKeyRef" });
return;
}
if (item.secretName !== expectedSecret || item.secretKey !== expectedKey) {
mismatches.push({ name, expectedSecret, expectedKey, secret: item.secretName, key: item.secretKey, present: true, kind: "secretKeyRef" });
}
};
expectValue("HWLAB_CODE_AGENT_ADAPTER", runtime.adapter);
expectValue("AGENTRUN_MGR_URL", runtime.managerUrl);
expectSecret("AGENTRUN_API_KEY", runtime.apiKeySecretName, runtime.apiKeySecretKey);
expectValue("HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE", runtime.runnerNamespace);
expectValue("HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE", runtime.secretNamespace);
for (const [profile, secretName] of Object.entries(runtime.providerSecretNames ?? {})) {
const profileKey = profile.toUpperCase().replace(/[^A-Z0-9]+/gu, "_");
expectValue(`HWLAB_CODE_AGENT_AGENTRUN_${profileKey}_SECRET_NAME`, secretName);
}
expectValue("HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME", runtime.toolSecretNames.githubPr);
expectValue("HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME", runtime.toolSecretNames.unideskSsh);
expectValue("HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", spec.gitReadUrl);
expectValue("HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", spec.nodeId);
expectValue("HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", runtime.defaultProviderProfile);
expectValue("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", runtime.codexStdioSupervisor);
const kafkaShadowProducer = runtime.kafkaShadowProducer;
if (kafkaShadowProducer !== undefined) {
expectValue("HWLAB_KAFKA_SHADOW_PRODUCE_ENABLED", String(kafkaShadowProducer.enabled));
expectValue("HWLAB_KAFKA_SHADOW_CONSUME_ENABLED", String(kafkaShadowProducer.consumeEnabled));
expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaShadowProducer.bootstrapServers);
expectValue("HWLAB_KAFKA_COMMAND_TOPIC", kafkaShadowProducer.commandTopic);
expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaShadowProducer.clientId);
}
const kafkaEventBridge = runtime.kafkaEventBridge;
if (kafkaEventBridge !== undefined) {
expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled));
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector)));
expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers);
expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic);
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic);
expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic);
expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId);
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", kafkaEventBridge.directPublishConsumerGroupId);
expectValue("HWLAB_KAFKA_PROJECTOR_GROUP_ID", kafkaEventBridge.transactionalProjectorConsumerGroupId);
expectValue("HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", kafkaEventBridge.hwlabEventConsumerGroupId);
expectValue("HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.directPublish));
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
const refreshReplayEnabled = kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay;
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(refreshReplayEnabled));
const refreshReplayEnvNames = [
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT",
];
if (refreshReplayEnabled) {
const refreshReplay = kafkaEventBridge.refreshReplay;
if (refreshReplay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.refreshReplay is required when Kafka refresh replay is enabled");
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", refreshReplay.groupIdPrefix);
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", String(refreshReplay.timeoutMs));
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", String(refreshReplay.scanLimit));
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", String(refreshReplay.matchedEventLimit));
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", String(refreshReplay.liveBufferLimit));
} else {
for (const name of refreshReplayEnvNames) expectAbsent(name);
}
expectValue("HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.transactionalProjector));
expectValue("HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionOutboxRelay));
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
}
return {
ready: mismatches.length === 0,
deploymentExists: true,
deploymentName: "hwlab-cloud-api",
containerName: "hwlab-cloud-api",
checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 21),
envCount: envByName.size,
mismatches,
result: compactCodeAgentDeploymentProbeResult(envProbe),
valuesPrinted: false,
};
}
function nodeRuntimeCodeAgentCloudWebEnvStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
const kafkaEventBridge = runtime.kafkaEventBridge;
if (kafkaEventBridge === undefined) return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false };
const envProbe = runNodeK3sArgs(spec, [
"kubectl",
"-n",
spec.runtimeNamespace,
"get",
"deployment",
"hwlab-cloud-web",
"-o",
'jsonpath={range .spec.template.spec.containers[?(@.name=="hwlab-cloud-web")].env[*]}{.name}{"\\t"}{.value}{"\\t"}{.valueFrom.secretKeyRef.name}{"\\t"}{.valueFrom.secretKeyRef.key}{"\\n"}{end}',
], 60);
if (envProbe.exitCode !== 0) {
return {
required: true,
ready: false,
deploymentExists: false,
mismatches: [{ name: "hwlab-cloud-web", reason: "deployment-missing" }],
result: compactCodeAgentDeploymentProbeResult(envProbe),
valuesPrinted: false,
};
}
const envByName = parseCodeAgentEnvProbe(envProbe.stdout);
const mismatches: Array<Record<string, unknown>> = [];
const expectValue = (name: string, expected: string): void => {
const item = envByName.get(name);
if (item === undefined) {
mismatches.push({ name, expected, present: false, kind: "value" });
return;
}
if (item.value !== expected) mismatches.push({ name, expected, value: item.value, present: true, kind: "value" });
};
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay));
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
return {
required: true,
ready: mismatches.length === 0,
deploymentExists: true,
deploymentName: "hwlab-cloud-web",
containerName: "hwlab-cloud-web",
checkedEnvCount: 3,
envCount: envByName.size,
mismatches,
result: compactCodeAgentDeploymentProbeResult(envProbe),
valuesPrinted: false,
};
}
type CodeAgentEnvProbeRow = {
value: string | null;
secretName: string | null;
secretKey: string | null;
};
function parseCodeAgentEnvProbe(stdout: string): Map<string, CodeAgentEnvProbeRow> {
const rows = new Map<string, CodeAgentEnvProbeRow>();
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trimEnd();
if (trimmed.length === 0) continue;
const [name = "", value = "", secretName = "", secretKey = ""] = trimmed.split("\t");
if (name.length === 0) continue;
rows.set(name, {
value: value.length > 0 ? value : null,
secretName: secretName.length > 0 ? secretName : null,
secretKey: secretKey.length > 0 ? secretKey : null,
});
}
return rows;
}
function compactCodeAgentDeploymentProbeResult(result: CommandResult): Record<string, unknown> {
return {
exitCode: result.exitCode,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stderrTail: result.stderr.slice(-600),
timedOut: result.timedOut === true,
};
}
export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespaceExists: boolean): Record<string, unknown> {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return { required: false, ready: true };
if (!namespaceExists) return { required: true, ready: false, degradedReason: "runtime-namespace-missing" };
const runtimeAccess = pg.runtimeAccess ?? { endpointAddress: pg.endpointAddress, port: pg.port };
const endpointIsIpv4 = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/u.test(runtimeAccess.endpointAddress);
const service = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "service", pg.serviceName, "-o", "jsonpath={.spec.type}{\"\\n\"}{.spec.externalName}{\"\\n\"}{.spec.ports[0].port}{\"\\n\"}"], 60);
const [serviceType = "", externalName = "", servicePort = ""] = service.stdout.split(/\r?\n/u);
if (!endpointIsIpv4) {
const ready = service.exitCode === 0 && serviceType === "ExternalName" && externalName === runtimeAccess.endpointAddress && Number(servicePort) === runtimeAccess.port;
return {
required: true,
ready,
serviceName: pg.serviceName,
serviceType: serviceType || null,
externalName: externalName || null,
expectedExternalName: runtimeAccess.endpointAddress,
port: numericField(servicePort),
expectedPort: runtimeAccess.port,
providerEndpointAddress: pg.endpointAddress,
providerPort: pg.port,
runtimeAccess: pg.runtimeAccess ?? null,
service: compactRuntimeCommand(service),
};
}
const endpoints = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpoints", pg.serviceName, "-o", "jsonpath={.subsets[0].addresses[0].ip}{\"\\n\"}{.subsets[0].ports[0].port}{\"\\n\"}"], 60);
const [endpointsAddress = "", endpointsPort = ""] = endpoints.stdout.split(/\r?\n/u);
const endpointSlice = endpoints.exitCode === 0 && endpointsAddress.length > 0
? { exitCode: 1, stdout: "", stderr: "", timedOut: false }
: runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
const [addressType = "", endpointSliceAddress = "", endpointSlicePort = ""] = endpointSlice.stdout.split(/\r?\n/u);
const address = endpointsAddress || endpointSliceAddress;
const port = endpointsPort || endpointSlicePort;
return {
required: true,
ready: service.exitCode === 0 && (endpoints.exitCode === 0 || endpointSlice.exitCode === 0) && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
serviceName: pg.serviceName,
serviceType: serviceType || null,
addressType: endpoints.exitCode === 0 ? "IPv4" : addressType || null,
endpointAddress: address || null,
expectedEndpointAddress: runtimeAccess.endpointAddress,
port: numericField(port),
expectedPort: runtimeAccess.port,
providerEndpointAddress: pg.endpointAddress,
providerPort: pg.port,
runtimeAccess: pg.runtimeAccess ?? null,
service: compactRuntimeCommand(service),
endpoints: compactRuntimeCommand(endpoints),
endpointSlice: compactRuntimeCommand(endpointSlice),
};
}
export function externalPostgresSecretStatus(spec: HwlabRuntimeLaneSpec, namespaceExists: boolean): Record<string, unknown> {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return { required: false, ready: true };
if (!namespaceExists) return { required: true, ready: false, degradedReason: "runtime-namespace-missing" };
const cloudApi = secretKeyStatus(spec, pg.cloudApi.secretName, pg.cloudApi.secretKey);
const openfgaUri = secretKeyStatus(spec, pg.openfga.secretName, pg.openfga.secretKey);
const openfgaAuthn = secretKeyStatus(spec, pg.openfga.secretName, pg.openfga.authnKey ?? "authn-preshared-key");
return {
required: true,
ready: cloudApi.present && openfgaUri.present && openfgaAuthn.present,
valuesPrinted: false,
cloudApi,
openfga: {
datastoreUri: openfgaUri,
authnPresharedKey: openfgaAuthn,
},
};
}
export function nodeRuntimeGitMirrorTarget(spec: HwlabRuntimeLaneSpec): NodeRuntimeGitMirrorTargetSpec {
const configPath = rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH);
const parsed = record(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown);
const nodes = record(parsed.nodes);
const node = record(nodes[spec.nodeId]);
const targets = Array.isArray(parsed.targets) ? parsed.targets : [];
const target = targets.map((item) => record(item)).find((item) => item.node === spec.nodeId && item.lane === spec.lane);
if (target === undefined) throw new Error(`no gitMirror target for node=${spec.nodeId} lane=${spec.lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`);
const gitMirror = record(target.gitMirror);
const gitMirrorEgressProxy = record(gitMirror.egressProxy);
const gitMirrorEgressMode = stringValue(gitMirrorEgressProxy.mode, "gitMirror.egressProxy.mode");
if (gitMirrorEgressMode !== "node-global" && gitMirrorEgressMode !== "host-route" && gitMirrorEgressMode !== "direct") throw new Error(`gitMirror.egressProxy.mode must be node-global, host-route, or direct for node=${spec.nodeId} lane=${spec.lane}`);
const nodeEgressProxy = gitMirrorEgressMode === "direct"
? { mode: "direct" as const, required: false as const }
: nodeRuntimeGitMirrorEgressProxySpec(record(node.egressProxy), `nodes.${spec.nodeId}.egressProxy`);
if ((gitMirrorEgressMode === "node-global" || gitMirrorEgressMode === "host-route") && gitMirrorEgressProxy.required !== true) throw new Error(`gitMirror.egressProxy.required must be true for node=${spec.nodeId} lane=${spec.lane}`);
if (gitMirrorEgressMode === "node-global" && nodeEgressProxy.mode !== "k8s-service-cluster-ip") throw new Error(`gitMirror.egressProxy.mode=node-global requires nodes.${spec.nodeId}.egressProxy.mode=k8s-service-cluster-ip`);
if (gitMirrorEgressMode === "host-route" && nodeEgressProxy.mode !== "host-route") throw new Error(`gitMirror.egressProxy.mode=host-route requires nodes.${spec.nodeId}.egressProxy.mode=host-route`);
const githubTransport = nodeRuntimeGitMirrorGithubTransportSpec(record(gitMirror.githubTransport), "gitMirror.githubTransport");
const source = record(target.source);
const gitops = record(target.gitops);
const tekton = record(target.tekton);
const toolsImage = record(tekton.toolsImage);
const toolsImagePullPolicy = optionalStringValue(toolsImage.imagePullPolicy, "tekton.toolsImage.imagePullPolicy") ?? "IfNotPresent";
if (toolsImagePullPolicy !== "Always" && toolsImagePullPolicy !== "IfNotPresent" && toolsImagePullPolicy !== "Never") throw new Error("tekton.toolsImage.imagePullPolicy must be Always, IfNotPresent, or Never");
return {
id: stringValue(target.id, "target.id"),
node: stringValue(target.node, "target.node"),
lane: stringValue(target.lane, "target.lane"),
namespace: stringValue(gitMirror.namespace, "gitMirror.namespace"),
serviceReadName: stringValue(gitMirror.serviceReadName, "gitMirror.serviceReadName"),
serviceWriteName: stringValue(gitMirror.serviceWriteName, "gitMirror.serviceWriteName"),
cachePvcName: stringValue(gitMirror.cachePvcName, "gitMirror.cachePvcName"),
cachePvcStorage: stringValue(gitMirror.cachePvcStorage, "gitMirror.cachePvcStorage"),
cacheHostPath: optionalStringValue(gitMirror.cacheHostPath, "gitMirror.cacheHostPath"),
servicePort: positiveIntegerValue(gitMirror.servicePort, "gitMirror.servicePort"),
secretName: stringValue(gitMirror.secretName, "gitMirror.secretName"),
syncConfigMapName: stringValue(gitMirror.syncConfigMapName, "gitMirror.syncConfigMapName"),
syncJobPrefix: stringValue(gitMirror.syncJobPrefix, "gitMirror.syncJobPrefix"),
flushJobPrefix: stringValue(gitMirror.flushJobPrefix, "gitMirror.flushJobPrefix"),
toolsImage: stringValue(toolsImage.output, "tekton.toolsImage.output"),
toolsImagePullPolicy,
sourceRepository: stringValue(source.repository, "source.repository"),
sourceBranch: stringValue(source.branch, "source.branch"),
gitopsBranch: stringValue(gitops.branch, "gitops.branch"),
egressProxy: nodeEgressProxy,
githubTransport,
};
}
export function nodeRuntimeGitMirrorGithubTransportSpec(raw: Record<string, unknown>, path: string): NodeRuntimeGitMirrorGithubTransportSpec {
const mode = stringValue(raw.mode, `${path}.mode`);
if (mode === "ssh") {
const knownHostsSecretKey = optionalStringValue(raw.knownHostsSecretKey, `${path}.knownHostsSecretKey`);
const knownHostsSourceRef = optionalStringValue(raw.knownHostsSourceRef, `${path}.knownHostsSourceRef`);
const knownHostsSourceKey = optionalStringValue(raw.knownHostsSourceKey, `${path}.knownHostsSourceKey`);
const knownHostsSourceEncoding = raw.knownHostsSourceEncoding === undefined ? null : gitMirrorSecretSourceEncoding(raw.knownHostsSourceEncoding, `${path}.knownHostsSourceEncoding`);
const knownHostsValues = [knownHostsSecretKey, knownHostsSourceRef, knownHostsSourceKey, knownHostsSourceEncoding];
if (knownHostsValues.some((value) => value !== null) && knownHostsValues.some((value) => value === null)) {
throw new Error(`${path}.knownHostsSecretKey/sourceRef/sourceKey/sourceEncoding must be declared together`);
}
return {
mode,
privateKeySecretKey: stringValue(raw.privateKeySecretKey, `${path}.privateKeySecretKey`),
privateKeySourceRef: stringValue(raw.privateKeySourceRef, `${path}.privateKeySourceRef`),
privateKeySourceKey: stringValue(raw.privateKeySourceKey, `${path}.privateKeySourceKey`),
privateKeySourceEncoding: gitMirrorSecretSourceEncoding(raw.privateKeySourceEncoding, `${path}.privateKeySourceEncoding`),
knownHostsSecretKey,
knownHostsSourceRef,
knownHostsSourceKey,
knownHostsSourceEncoding,
};
}
if (mode !== "https") throw new Error(`${path}.mode must be ssh or https`);
return {
mode,
username: stringValue(raw.username, `${path}.username`),
tokenSecretName: stringValue(raw.tokenSecretName, `${path}.tokenSecretName`),
tokenSecretKey: stringValue(raw.tokenSecretKey, `${path}.tokenSecretKey`),
tokenSourceRef: stringValue(raw.tokenSourceRef, `${path}.tokenSourceRef`),
tokenSourceKey: stringValue(raw.tokenSourceKey, `${path}.tokenSourceKey`),
};
}
function gitMirrorSecretSourceEncoding(raw: unknown, path: string): "plain" | "base64" {
const value = stringValue(raw, path);
if (value !== "plain" && value !== "base64") throw new Error(`${path} must be plain or base64`);
return value;
}
export function nodeRuntimeGitMirrorEgressProxySpec(raw: Record<string, unknown>, path: string): NodeRuntimeGitMirrorEgressProxySpec {
const mode = stringValue(raw.mode, `${path}.mode`);
if (mode === "host-route") {
const noProxyRaw = raw.noProxy;
if (!Array.isArray(noProxyRaw)) throw new Error(`${path}.noProxy must be an array`);
return {
mode,
clientName: stringValue(raw.clientName, `${path}.clientName`),
hostProxyConfigRef: stringValue(raw.hostProxyConfigRef, `${path}.hostProxyConfigRef`),
proxyEnvPath: stringValue(raw.proxyEnvPath, `${path}.proxyEnvPath`),
proxyUrl: stringValue(raw.proxyUrl, `${path}.proxyUrl`),
noProxy: noProxyRaw.map((item, index) => stringValue(item, `${path}.noProxy[${index}]`)),
};
}
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip or host-route`);
const port = Number(raw.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${path}.port must be a TCP port`);
const sourceConfigRef = optionalStringValue(raw.sourceConfigRef, `${path}.sourceConfigRef`) ?? null;
const source = sourceConfigRef === null ? null : resolveEgressProxySourceRef(sourceConfigRef, `${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}.${path}.sourceConfigRef`);
const sourceType = source?.sourceType ?? stringValue(raw.sourceType, `${path}.sourceType`);
if (sourceType !== "subscription-url" && sourceType !== "master-shadowsocks") throw new Error(`${path}.sourceType must be subscription-url or master-shadowsocks`);
if (raw.sourceType !== undefined && raw.sourceType !== sourceType) throw new Error(`${path}.sourceType must match sourceConfigRef value ${sourceType}`);
const sourceRef = source?.sourceRef ?? stringValue(raw.sourceRef, `${path}.sourceRef`);
const sourceKey = source?.sourceKey ?? stringValue(raw.sourceKey, `${path}.sourceKey`);
if (source !== null) {
if (raw.sourceRef !== undefined && raw.sourceRef !== source.sourceRef) throw new Error(`${path}.sourceRef must match sourceConfigRef value ${source.sourceRef}`);
if (raw.sourceKey !== undefined && raw.sourceKey !== source.sourceKey) throw new Error(`${path}.sourceKey must match sourceConfigRef value ${source.sourceKey}`);
}
const preferredOutbound = source?.preferredOutbound ?? (raw.preferredOutbound === undefined ? null : stringValue(raw.preferredOutbound, `${path}.preferredOutbound`));
if (preferredOutbound !== null && preferredOutbound !== "vless-reality" && preferredOutbound !== "hysteria2") throw new Error(`${path}.preferredOutbound must be vless-reality or hysteria2`);
if (source !== null && source.preferredOutbound !== null && raw.preferredOutbound !== undefined && raw.preferredOutbound !== source.preferredOutbound) {
throw new Error(`${path}.preferredOutbound must match sourceConfigRef value ${source.preferredOutbound}`);
}
const noProxyRaw = raw.noProxy;
if (!Array.isArray(noProxyRaw)) throw new Error(`${path}.noProxy must be an array`);
const noProxy = noProxyRaw.map((item, index) => stringValue(item, `${path}.noProxy[${index}]`));
return {
mode,
clientName: stringValue(raw.clientName, `${path}.clientName`),
namespace: stringValue(raw.namespace, `${path}.namespace`),
serviceName: stringValue(raw.serviceName, `${path}.serviceName`),
port,
sourceConfigRef,
sourceRef,
sourceKey,
sourceType,
preferredOutbound,
sourceFingerprint: source?.fingerprint ?? null,
noProxy,
};
}
export function secretKeyStatus(spec: HwlabRuntimeLaneSpec, secret: string, key: string): Record<string, unknown> {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "secret", secret, "-o", `go-template={{ index .data ${JSON.stringify(key)} }}`], 60);
return {
secret,
key,
present: result.exitCode === 0 && result.stdout.trim().length > 0,
valueBytesBase64: result.stdout.trim().length,
exitCode: result.exitCode,
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 500),
valuesPrinted: false,
};
}
export function startNodeDelegatedJob(options: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const commandArgs = [
"hwlab",
"nodes",
options.domain,
...stripOptions(options.originalArgs, ["--node", "--lane", "--confirm", "--dry-run", "--wait", "--timeout-seconds"]),
"--node",
options.node,
"--lane",
options.lane,
"--confirm",
"--timeout-seconds",
String(options.timeoutSeconds),
"--wait",
];
const command = ["bun", "scripts/cli.ts", ...commandArgs];
const job = startJob(
`hwlab_nodes_${options.lane}_${options.domain}_${options.action}`,
command,
`Run HWLAB ${options.lane} ${options.domain} ${options.action} for node ${options.node}`,
);
return {
ok: true,
command: `hwlab nodes ${options.domain} ${options.action} --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
mode: "async-job",
reason: "confirmed control-plane/mirror actions can spend tens of seconds on remote work; default is fire-and-forget to avoid silent blocking",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
waitCommand: command.join(" "),
};
}
export function rewriteDelegatedNodeResult(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const rewritten = rewriteDelegatedNodeValue(value, scoped);
const result = typeof rewritten === "object" && rewritten !== null && !Array.isArray(rewritten) ? rewritten as Record<string, unknown> : { value: rewritten };
return {
...result,
node: scoped.node,
commandFamily: "hwlab nodes",
};
}
export function rewriteDelegatedNodeValue(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): unknown {
if (typeof value === "string") return rewriteDelegatedNodeString(value, scoped);
if (Array.isArray(value)) return value.map((item) => rewriteDelegatedNodeValue(item, scoped));
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDelegatedNodeValue(item, scoped)]));
}
export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
const replaceCommand = (text: string, domain: DelegatedNodeDomain) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return text
.replace(new RegExp(`bun scripts/cli\\.ts hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `bun scripts/cli.ts hwlab nodes ${domain} $1 --node ${scoped.node}`)
.replace(new RegExp(`hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `hwlab nodes ${domain} $1 --node ${scoped.node}`);
};
return replaceCommand(replaceCommand(value, "control-plane"), "git-mirror");
}
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify" && actionRaw !== "native-readiness") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify|native-readiness --node NODE --lane vNN [--origin internal|public|native]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
const explicitNode = optionValue(args, "--node");
const explicitLane = optionValue(args, "--lane");
const requestedId = normalized.id ?? optionValue(normalized.args, "--job-id") ?? null;
const indexed = requestedId === null
? null
: readWebObserveIndexEntry(requestedId) ?? (explicitNode === undefined || explicitLane === undefined ? discoverWebObserveIndexEntry(requestedId) : null);
const node = explicitNode ?? indexed?.node;
const lane = explicitLane ?? indexed?.lane;
if (node === undefined || lane === undefined) {
if (requestedId !== null) {
throw new Error(`web-probe observe observer-id-unknown: ${requestedId}; run observe start first, or pass --node/--lane with --job-id/--state-dir to re-index this observer`);
}
throw new Error("web-probe observe node-lane-required-for-start: observe start requires --node/--lane; status|command|stop|collect|analyze should pass an observer id");
}
assertNodeId(node);
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
return parseNodeWebProbeObserveOptions(normalized.args, node, lane, spec, normalized.id, indexed);
}
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
const resolveOrigin = () => {
const browserProxyModeRaw = optionValue(args, "--browser-proxy-mode");
const browserProxyMode = browserProxyModeRaw === undefined ? undefined : parseWebProbeBrowserProxyMode(browserProxyModeRaw);
return resolveNodeWebProbeCliOrigin(spec, optionValue(args, "--origin"), optionValue(args, "--url"), browserProxyMode);
};
if (actionRaw === "native-readiness") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--profile",
"--timeout-ms",
"--command-timeout-seconds",
]), new Set([]));
const profiles = spec.webProbe?.nativeReadinessProfiles;
const profileName = optionValue(args, "--profile") ?? spec.webProbe?.defaultNativeReadinessProfile;
if (profiles === undefined || profileName === undefined) {
throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare a default native readiness profile`);
}
const profile = profiles[profileName];
if (profile === undefined) {
throw new Error(`web-probe native-readiness unknown --profile ${profileName}; configured profiles: ${Object.keys(profiles).sort().join(", ")}`);
}
const native = spec.nativeDevelopment?.[profile.application];
if (native === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare nativeDevelopment.${profile.application}`);
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", profile.navigationTimeoutMs, 120_000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", profile.commandTimeoutSeconds, 3600);
const configRef = `${hwlabRuntimeLaneConfigPath()}#templates.*.webProbeWorkbench.nativeReadinessProfiles.${profileName}`;
const scriptText = nodeWebProbeNativeReadinessScript(profileName, profile, configRef);
const url = native.publicExposure.publicBaseUrl;
const selectedOrigin = resolveNodeWebProbeCliOrigin(spec, undefined, url, "direct");
const commandLabel = `web-probe native-readiness --node ${node} --lane ${lane} --profile ${profileName}`;
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.${profile.application}`,
timeoutMs,
viewport: `${profile.viewport.width}x${profile.viewport.height}`,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
authentication: profile.authentication ?? "required",
commandLabel,
suppressAdHocWarning: true,
generatedHints: [
"L1 native Web readiness is a repo-owned typed command; fixed HTTPS origin, path, DOM, interaction and failure rules come from owning YAML.",
"Pass requires mounted DOM, a completed interaction, no browser errors, no critical failed responses and a screenshot artifact.",
],
generatedPreferredCommands: { rerun: commandLabel },
scriptSource: {
kind: "generated",
path: `builtin:native-readiness/${profileName}`,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
if (actionRaw === "console-verify") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--profile",
"--timeout-ms",
"--browser-proxy-mode",
"--command-timeout-seconds",
]), new Set([]));
const profiles = spec.webProbe?.consoleVerificationProfiles;
const profileName = optionValue(args, "--profile") ?? spec.webProbe?.defaultConsoleVerificationProfile;
if (profiles === undefined || profileName === undefined) {
throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare a default Cloud Console verification profile`);
}
const profile = profiles[profileName];
if (profile === undefined) {
throw new Error(`web-probe console-verify unknown --profile ${profileName}; configured profiles: ${Object.keys(profiles).sort().join(", ")}`);
}
const firstViewport = profile.viewports[0];
if (firstViewport === undefined) throw new Error(`web-probe console-verify profile ${profileName} has no viewport`);
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", profile.navigationTimeoutMs, 120_000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", profile.commandTimeoutSeconds, 3600);
const configRef = `${hwlabRuntimeLaneConfigPath()}#templates.*.webProbeWorkbench.consoleVerificationProfiles.${profileName}`;
const scriptText = nodeWebProbeConsoleVerifyScript(profileName, profile, configRef);
const selectedOrigin = resolveOrigin();
const commandLabel = `web-probe console-verify --node ${node} --lane ${lane}${selectedOrigin.originName === "custom" ? "" : ` --origin ${selectedOrigin.originName}`} --profile ${profileName}`;
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
timeoutMs,
viewport: `${firstViewport.width}x${firstViewport.height}`,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
commandLabel,
suppressAdHocWarning: true,
generatedHints: [
"Cloud Console verification is a repo-owned typed command; route, viewport, selector, workflow and evidence bounds come from the selected owning YAML profile.",
"The command reuses the managed semantic origin, authentication, remote browser, physical MemAvailable guard, report recovery and screenshot contracts.",
],
generatedPreferredCommands: {
rerun: commandLabel,
},
scriptSource: {
kind: "generated",
path: `builtin:console-verify/${profileName}`,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
if (actionRaw === "screenshot") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--path",
"--viewport",
"--browser-proxy-mode",
"--local-dir",
"--name",
"--timeout-ms",
"--wait-until",
"--selector",
"--wait-timeout-ms",
"--command-timeout-seconds",
]), new Set([
"--full-page",
"--no-full-page",
"--keep-remote",
]));
const url = optionValue(args, "--url");
const targetPath = optionValue(args, "--path") ?? null;
if (url !== undefined && targetPath !== null) {
const correctionCommand = `bun scripts/cli.ts web-probe ${removeOptionWithValue(args, "--path").map(shellQuote).join(" ")}`;
throw new CliInputError("web-probe screenshot 的 --url 与 --path 互斥;custom/local URL 已包含完整路径,请移除 --path", {
code: "web-probe-screenshot-url-path-mutually-exclusive",
reason: "custom-url-already-includes-full-path-remove-path",
mutation: false,
argument: "--url/--path",
usage: correctionCommand,
hint: "保留 --url 中的完整路径并移除 --pathsemantic origin 调用则保留 --origin 与 --path,并移除 --url。",
correctionCommands: [correctionCommand],
});
}
const selectedOrigin = resolveOrigin();
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000);
const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.ceil(waitTimeoutMs / 1000) + 45, 900);
return {
action: "screenshot",
node,
lane,
url: url ?? resolveWebProbeScreenshotUrl(selectedOrigin.url, targetPath ?? "/"),
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
path: targetPath,
viewport: parseWebProbeScreenshotViewport(optionValue(args, "--viewport") ?? "1440x900"),
localDir: optionValue(args, "--local-dir") ?? "/tmp",
name: parseWebProbeScreenshotName(optionValue(args, "--name") ?? `web-probe-${node.toLowerCase()}-${lane}.png`),
timeoutMs,
waitUntil: parseWebProbeScreenshotWaitUntil(optionValue(args, "--wait-until") ?? "networkidle"),
fullPage: args.includes("--full-page") && !args.includes("--no-full-page"),
selector: optionValue(args, "--selector") ?? null,
keepRemote: args.includes("--keep-remote"),
waitTimeoutMs,
commandTimeoutSeconds,
};
}
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--timeout-ms",
"--viewport",
"--browser-proxy-mode",
"--script-file",
"--command-timeout-seconds",
]), new Set([]));
const scriptFile = optionValue(args, "--script-file");
if (scriptFile === undefined && process.stdin.isTTY) {
throw new Error("web-probe script requires a stdin heredoc or --script-file <path>");
}
const scriptText = scriptFile === undefined ? readFileSync(0, "utf8") : readFileSync(scriptFile, "utf8");
if (scriptText.trim().length === 0) throw new Error("web-probe script received an empty script");
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.max(60, Math.ceil(timeoutMs / 1000) + 30), 3600);
const selectedOrigin = resolveOrigin();
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
timeoutMs,
viewport: optionValue(args, "--viewport") ?? "1440x900",
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
scriptSource: {
kind: scriptFile === undefined ? "stdin" : "file",
path: scriptFile ?? null,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
if (actionRaw === "opencode-smoke") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--path",
"--message",
"--expect-text",
"--timeout-ms",
"--response-timeout-ms",
"--viewport",
"--browser-proxy-mode",
"--command-timeout-seconds",
]), new Set([]));
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 40000, 300000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", 55, 3600);
const requestedResponseTimeoutMs = positiveIntegerOption(args, "--response-timeout-ms", Math.min(30000, timeoutMs), timeoutMs);
const responseTimeoutMs = Math.min(requestedResponseTimeoutMs, Math.max(5000, (commandTimeoutSeconds * 1000) - 20000));
const path = optionValue(args, "--path") ?? "/opencode";
if (!path.startsWith("/") || path.includes("\0") || path.length > 200) throw new Error("web-probe opencode-smoke --path must be an absolute path up to 200 chars");
const message = optionValue(args, "--message") ?? "hi";
if (message.includes("\0") || message.length > 500) throw new Error("web-probe opencode-smoke --message must be 0-500 non-NUL chars");
const expectText = optionValue(args, "--expect-text") ?? null;
if (expectText !== null && (expectText.includes("\0") || expectText.length > 500)) throw new Error("web-probe opencode-smoke --expect-text must be 0-500 non-NUL chars");
const scriptText = nodeWebProbeOpencodeSmokeScript({
path,
message,
expectText,
responseTimeoutMs,
});
const selectedOrigin = resolveOrigin();
const commandLabel = `web-probe opencode-smoke --node ${node} --lane ${lane}${selectedOrigin.originName === "custom" ? "" : ` --origin ${selectedOrigin.originName}`}`;
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
timeoutMs,
viewport: optionValue(args, "--viewport") ?? "1440x900",
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
commandLabel,
suppressAdHocWarning: true,
generatedHints: [
"OpenCode smoke is a repo-owned typed web-probe command; it uses the same managed auth, remote browser, report recovery and artifact contract as web-probe script.",
"Passing means the browser DOM reached assistant text and the OpenCode EventSource produced update, finish and idle evidence.",
],
generatedPreferredCommands: {
withExpectedText: `${commandLabel} --expect-text <assistant-substring>`,
},
scriptSource: {
kind: "generated",
path: "builtin:opencode-smoke",
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--origin",
"--url",
"--browser-proxy-mode",
"--timeout-ms",
"--wait-after-submit-ms",
"--wait-messages-ms",
"--wait-agent-terminal-ms",
"--trace-sample-count",
"--trace-sample-interval-ms",
"--message",
"--conversation-id",
"--command-timeout-seconds",
]), new Set([
"--fresh-session",
"--no-cancel-running",
]));
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const waitAfterSubmitMs = positiveIntegerOption(args, "--wait-after-submit-ms", 1500, 60000);
const waitMessagesMs = positiveIntegerOption(args, "--wait-messages-ms", 2500, 60000);
const waitAgentTerminalMs = positiveIntegerOption(args, "--wait-agent-terminal-ms", 0, 600000);
const message = optionValue(args, "--message") ?? null;
const hasMessage = message !== null;
const traceSampleCountDefault = hasMessage ? 2 : 0;
const traceSampleIntervalDefault = hasMessage ? 1500 : 0;
const traceSampleCount = positiveIntegerOption(args, "--trace-sample-count", traceSampleCountDefault, 200);
const traceSampleIntervalMs = positiveIntegerOption(args, "--trace-sample-interval-ms", traceSampleIntervalDefault, 60000);
const commandTimeoutAutoSeconds = nodeWebProbeAutoCommandTimeoutSeconds({
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
freshSession: args.includes("--fresh-session"),
hasMessage,
});
const commandTimeoutRaw = optionValue(args, "--command-timeout-seconds");
const commandTimeoutUserProvided = commandTimeoutRaw !== undefined;
const commandTimeoutSeconds = commandTimeoutUserProvided
? Math.max(positiveIntegerOption(args, "--command-timeout-seconds", commandTimeoutAutoSeconds, 3600), commandTimeoutAutoSeconds)
: commandTimeoutAutoSeconds;
const selectedOrigin = resolveOrigin();
return {
action: "run",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: selectedOrigin.originConfigPath,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
message,
conversationId: optionValue(args, "--conversation-id") ?? null,
freshSession: args.includes("--fresh-session"),
cancelRunning: !args.includes("--no-cancel-running"),
commandTimeoutSeconds,
commandTimeoutAutoSeconds,
commandTimeoutUserProvided,
};
}
function removeOptionWithValue(args: string[], option: string): string[] {
const result: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const item = args[index] ?? "";
if (item === option) {
index += 1;
continue;
}
if (item.startsWith(`${option}=`)) continue;
result.push(item);
}
return result;
}
function nodeWebProbeOpencodeSmokeScript(input: { path: string; message: string; expectText: string | null; responseTimeoutMs: number }): string {
const config = {
path: input.path,
message: input.message,
expectText: input.expectText,
responseTimeoutMs: input.responseTimeoutMs,
};
return patchOpenCodeSmokeScript(`const config = ${JSON.stringify(config)};\n\nexport default async function opencodeSmoke({ page, baseUrl, recordStep, wait, screenshot, jsonArtifact }) {\n await page.addInitScript(() => {\n const key = \"__unideskOpenCodeEvents\";\n window[key] = Array.isArray(window[key]) ? window[key] : [];\n if (window.__unideskOpenCodeEventSourceWrapped) return;\n window.__unideskOpenCodeEventSourceWrapped = true;\n const OriginalEventSource = window.EventSource;\n if (!OriginalEventSource) return;\n const push = (source, type, event) => {\n let parsedType = null;\n let parsedStatus = null;\n let dataBytes = 0;\n const rawData = typeof event?.data === \"string\" ? event.data : \"\";\n dataBytes = rawData.length;\n if (rawData) {\n try {\n const parsed = JSON.parse(rawData);\n parsedType = typeof parsed?.type === \"string\" ? parsed.type : typeof parsed?.event === \"string\" ? parsed.event : typeof parsed?.name === \"string\" ? parsed.name : null;\n parsedStatus = typeof parsed?.status === \"string\" ? parsed.status : typeof parsed?.part?.status === \"string\" ? parsed.part.status : null;\n } catch {\n parsedType = null;\n }\n }\n const label = parsedType || (parsedStatus ? type + \":\" + parsedStatus : type);\n window[key].push({ at: Date.now(), source, type, parsedType, status: parsedStatus, label, dataBytes });\n if (window[key].length > 240) window[key].splice(0, window[key].length - 240);\n };\n const originalAddEventListener = OriginalEventSource.prototype.addEventListener;\n OriginalEventSource.prototype.addEventListener = function wrappedAddEventListener(type, listener, options) {\n if (typeof listener !== \"function\") return originalAddEventListener.call(this, type, listener, options);\n const wrapped = function wrappedOpenCodeEvent(event) {\n push(String(this?.url || \"eventsource\"), String(type || \"message\"), event);\n return listener.call(this, event);\n };\n return originalAddEventListener.call(this, type, wrapped, options);\n };\n function WrappedEventSource(url, options) {\n const instance = new OriginalEventSource(url, options);\n originalAddEventListener.call(instance, \"message\", (event) => push(String(url || \"eventsource\"), \"message\", event));\n return instance;\n }\n WrappedEventSource.prototype = OriginalEventSource.prototype;\n WrappedEventSource.CONNECTING = OriginalEventSource.CONNECTING;\n WrappedEventSource.OPEN = OriginalEventSource.OPEN;\n WrappedEventSource.CLOSED = OriginalEventSource.CLOSED;\n window.EventSource = WrappedEventSource;\n });\n\n const targetUrl = new URL(config.path, baseUrl).toString();\n await page.goto(targetUrl, { waitUntil: \"domcontentloaded\", timeout: Math.min(Math.max(config.responseTimeoutMs, 1000), 120000) });\n recordStep(\"opencode-navigate\", { ok: true, targetPath: config.path, finalUrl: page.url() });\n\n const frame = await findOpenCodeFrame(page, config.responseTimeoutMs);\n const before = await collectDomState(frame, [], config);\n const beforeLines = before.lines;\n recordStep(\"opencode-frame\", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit });\n\n const prompt = await fillOpenCodePrompt(frame, config.message);\n recordStep(\"opencode-prompt-filled\", { ok: true, selector: prompt.selector, textBytes: config.message.length });\n\n const submit = frame.locator(\"[data-action='prompt-submit']\").first();\n await submit.waitFor({ state: \"visible\", timeout: Math.min(config.responseTimeoutMs, 10000) });\n await frame.waitForFunction(() => {\n const button = document.querySelector(\"[data-action='prompt-submit']\");\n return Boolean(button && !button.disabled && button.getAttribute(\"aria-disabled\") !== \"true\");\n }, null, { timeout: 5000 }).catch(() => null);\n await submit.click({ timeout: 10000 });\n recordStep(\"opencode-submit\", { ok: true, selector: \"[data-action='prompt-submit']\" });\n\n const final = await waitForOpenCodeFinal(page, frame, beforeLines, config);\n await jsonArtifact(\"opencode-smoke-events.json\", final.events);\n const finalScreenshot = await screenshot(\"opencode-smoke-final.png\").catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));\n const ok = final.conditions.assistantText && final.conditions.notThinking && final.conditions.updateEvent && final.conditions.finishEvent && final.conditions.idleEvent && final.conditions.expectedText;\n recordStep(\"opencode-final\", { ok, conditions: final.conditions, assistantTextPreview: final.assistantTextPreview, eventTypes: final.events.eventTypes });\n return {\n ok,\n status: ok ? \"pass\" : \"blocked\",\n failedCondition: ok ? null : failedCondition(final.conditions),\n frameUrl: final.url,\n finalUrl: page.url(),\n assistantTextPreview: final.assistantTextPreview,\n bodyPreview: final.bodyPreview,\n eventTypes: final.events.eventTypes,\n eventCount: final.events.eventCount,\n conditions: final.conditions,\n screenshot: finalScreenshot,\n issueEvidence: {\n frameUrl: final.url,\n assistantTextPreview: final.assistantTextPreview,\n eventTypes: final.events.eventTypes,\n conditions: final.conditions,\n screenshotSha256: typeof finalScreenshot?.sha256 === \"string\" ? finalScreenshot.sha256 : null,\n valuesRedacted: true,\n },\n valuesRedacted: true,\n };\n}\n\nasync function findOpenCodeFrame(page, timeoutMs) {\n const deadline = Date.now() + timeoutMs;\n let best = null;\n while (Date.now() < deadline) {\n for (const frame of page.frames()) {\n const summary = await frameSummary(frame);\n const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0);\n if (best === null || score > best.score) best = { frame, summary, score };\n if (summary.hasSubmit && summary.textboxCount > 0) return frame;\n }\n await waitFor(500);\n }\n throw new Error(\"OpenCode frame with prompt submit button was not found; best=\" + JSON.stringify(best?.summary || null));\n}\n\nasync function frameSummary(frame) {\n return await frame.evaluate(() => ({\n url: location.href,\n title: document.title,\n hasSubmit: Boolean(document.querySelector(\"[data-action='prompt-submit']\")),\n textboxCount: document.querySelectorAll(\"[contenteditable='true'], textarea, [role='textbox'], input:not([type]), input[type='text']\").length,\n bodyPreview: (document.body?.innerText || \"\").replace(/\\s+/gu, \" \").trim().slice(0, 240),\n })).catch((error) => ({ url: frame.url(), title: \"\", hasSubmit: false, textboxCount: 0, bodyPreview: String(error).slice(0, 160) }));\n}\n\nasync function fillOpenCodePrompt(frame, message) {\n const selectors = [\n \"[contenteditable='true']\",\n \"textarea\",\n \"[role='textbox']\",\n \"input:not([type])\",\n \"input[type='text']\",\n ];\n let lastError = null;\n for (const selector of selectors) {\n const locator = frame.locator(selector).last();\n const count = await locator.count().catch(() => 0);\n if (count < 1) continue;\n try {\n await locator.click({ timeout: 5000 });\n await locator.fill(message, { timeout: 5000 });\n return { selector };\n } catch (error) {\n lastError = error;\n try {\n await locator.evaluate((element, text) => {\n if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {\n element.value = text;\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true, data: text, inputType: \"insertText\" }));\n element.dispatchEvent(new Event(\"change\", { bubbles: true }));\n return;\n }\n element.textContent = text;\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true, data: text, inputType: \"insertText\" }));\n }, message);\n return { selector, fallback: \"dom-input-event\" };\n } catch (fallbackError) {\n lastError = fallbackError;\n }\n }\n }\n throw new Error(\"OpenCode prompt input was not fillable: \" + (lastError instanceof Error ? lastError.message : String(lastError || \"not found\")));\n}\n\nasync function waitForOpenCodeFinal(page, frame, beforeLines, config) {\n const deadline = Date.now() + config.responseTimeoutMs;\n let latest = await collectState(page, frame, beforeLines, config);\n while (Date.now() < deadline) {\n latest = await collectState(page, frame, beforeLines, config);\n if (latest.conditions.assistantText && latest.conditions.notThinking && latest.conditions.updateEvent && latest.conditions.finishEvent && latest.conditions.idleEvent && latest.conditions.expectedText) return latest;\n await waitFor(750);\n }\n return latest;\n}\n\nasync function collectState(page, frame, beforeLines, config) {\n const dom = await collectDomState(frame, beforeLines, config);\n const events = await collectEventSummary(page);\n const labels = events.eventTypes.join(\"\\n\");\n const conditions = {\n assistantText: Boolean(dom.assistantTextPreview),\n notThinking: dom.thinkingVisible !== true,\n updateEvent: /message[._-]?part[._-]?updated|part[._-]?updated|delta|updated/iu.test(labels),\n finishEvent: /step[._-]?finish|finish|completed|done/iu.test(labels),\n idleEvent: /session[._-]?idle|session[._-]?status[:/._-]?idle|\\bidle\\b/iu.test(labels),\n expectedText: config.expectText === null || dom.expectedTextPresent === true,\n };\n return { ...dom, events, conditions };\n}\n\nasync function collectDomState(frame, beforeLines, config) {\n return await frame.evaluate(({ beforeLines, message, expectText }) => {\n const normalize = (value) => String(value || \"\").replace(/\\r/g, \"\").replace(/[ \\t]+/g, \" \").trim();\n const bodyText = normalize(document.body?.innerText || \"\");\n const lines = bodyText.split(\"\\n\").map((line) => normalize(line)).filter(Boolean).slice(-400);\n const beforeSet = new Set(Array.isArray(beforeLines) ? beforeLines : []);\n const prompt = normalize(message).toLowerCase();\n const blocked = /^(send|stop|cancel|thinking|loading|new session|settings)$/iu;\n const selectorTexts = Array.from(document.querySelectorAll(\"[data-role='assistant'], [data-message-role='assistant'], [data-testid*='assistant' i], [aria-label*='assistant' i], [class*='assistant' i], main article, main [role='article'], main [data-message-id]\")).map((element) => normalize(element.textContent || \"\")).filter((text) => text.length > 0 && text.toLowerCase() !== prompt && !blocked.test(text)).slice(-20);\n const newLines = lines.filter((line) => !beforeSet.has(line) && line.toLowerCase() !== prompt && !blocked.test(line) && !/^you\\b/iu.test(line)).slice(-40);\n const assistantText = selectorTexts.find((text) => text.length > 0) || newLines.find((line) => line.length > 0) || null;\n return {\n url: location.href,\n title: document.title,\n hasSubmit: Boolean(document.querySelector(\"[data-action='prompt-submit']\")),\n lines,\n lineCount: lines.length,\n newLineCount: newLines.length,\n assistantCandidateCount: selectorTexts.length,\n assistantTextPreview: assistantText === null ? null : assistantText.slice(0, 500),\n thinkingVisible: /(^|\\n|\\b)Thinking(\\b|\\n)/u.test(bodyText),\n expectedTextPresent: expectText === null ? true : bodyText.includes(expectText),\n bodyPreview: bodyText.slice(-1200),\n };\n }, { beforeLines, message: config.message, expectText: config.expectText });\n}\n\nasync function collectEventSummary(page) {\n const frameEvents = [];\n for (const frame of page.frames()) {\n const events = await frame.evaluate(() => Array.isArray(window.__unideskOpenCodeEvents) ? window.__unideskOpenCodeEvents.slice(-120) : []).catch(() => []);\n if (events.length > 0) frameEvents.push({ frameUrl: frame.url(), events });\n }\n const labels = [];\n for (const item of frameEvents) {\n for (const event of item.events) {\n const raw = [event.label, event.parsedType, event.status, event.type].filter(Boolean).join(\":\");\n if (raw && !labels.includes(raw)) labels.push(raw);\n }\n }\n return {\n eventCount: frameEvents.reduce((sum, item) => sum + item.events.length, 0),\n eventTypes: labels.slice(0, 40),\n frames: frameEvents.map((item) => ({ frameUrl: item.frameUrl, count: item.events.length, last: item.events.slice(-5) })),\n valuesRedacted: true,\n };\n}\n\nfunction failedCondition(conditions) {\n return Object.entries(conditions).filter(([, ok]) => ok !== true).map(([key]) => key).join(\",\") || \"unknown\";\n}\n\nfunction waitFor(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n`);
}
function patchOpenCodeSmokeScript(source: string): string {
const oldFlow = ` const frame = await findOpenCodeFrame(page, config.responseTimeoutMs);
const before = await collectDomState(frame, [], config);
const beforeLines = before.lines;
recordStep("opencode-frame", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit });
const prompt = await fillOpenCodePrompt(frame, config.message);`;
const newFlow = ` const frame = await findOpenCodeFrame(page, config.responseTimeoutMs);
const project = await ensureOpenCodeProjectOpen(frame, config.responseTimeoutMs);
recordStep("opencode-project", project);
const composer = await waitForOpenCodeComposer(frame, config.responseTimeoutMs);
recordStep("opencode-composer", composer);
const eventProbe = await startOpenCodeEventProbe(frame);
recordStep("opencode-event-probe", eventProbe);
const before = await collectDomState(frame, [], config);
const beforeLines = before.lines;
recordStep("opencode-frame", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit });
const prompt = await fillOpenCodePrompt(frame, config.message);`;
const oldFrameFinder = `async function findOpenCodeFrame(page, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let best = null;
while (Date.now() < deadline) {
for (const frame of page.frames()) {
const summary = await frameSummary(frame);
const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0);
if (best === null || score > best.score) best = { frame, summary, score };
if (summary.hasSubmit && summary.textboxCount > 0) return frame;
}
await waitFor(500);
}
throw new Error("OpenCode frame with prompt submit button was not found; best=" + JSON.stringify(best?.summary || null));
}`;
const newFrameFinder = `async function findOpenCodeFrame(page, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let best = null;
while (Date.now() < deadline) {
for (const frame of page.frames()) {
const summary = await frameSummary(frame);
const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0) + (isOpenCodeFrame(frame, summary) ? 50 : 0);
if (best === null || score > best.score) best = { frame, summary, score };
if (summary.hasSubmit && summary.textboxCount > 0) return frame;
if (isOpenCodeFrame(frame, summary)) return frame;
}
await waitFor(500);
}
throw new Error("OpenCode frame was not found; best=" + JSON.stringify(best?.summary || null));
}
function isOpenCodeFrame(frame, summary) {
const rawUrl = String(summary?.url || frame.url() || "");
let hostname = "";
try {
hostname = new URL(rawUrl).hostname;
} catch {
hostname = "";
}
const hasParent = typeof frame.parentFrame === "function" && frame.parentFrame() !== null;
if (hasParent && (/opencode/iu.test(rawUrl) || String(summary?.title || "") === "OpenCode")) return true;
return /^opencode[-.]/iu.test(hostname);
}
async function ensureOpenCodeProjectOpen(frame, timeoutMs) {
const deadline = Date.now() + Math.min(timeoutMs, 20000);
let clickedProject = null;
let dismissed = [];
let lastSummary = null;
while (Date.now() < deadline) {
lastSummary = await frameSummary(frame);
if (lastSummary.hasSubmit && lastSummary.textboxCount > 0) {
return { ok: true, alreadyOpen: true, frameUrl: lastSummary.url, dismissed, clickedProject };
}
const dismissedButton = await clickButtonMatching(frame, (text) => /^(not yet|skip|maybe later)$/iu.test(text), "dismiss-provider");
if (dismissedButton) {
dismissed.push(dismissedButton.text);
await waitFor(500);
continue;
}
if (/No projects open|Recent projects|Open a project/iu.test(String(lastSummary.bodyPreview || ""))) {
const clicked = await clickButtonMatching(frame, (text) => text === "/" || /^\\/\\s*\\d/iu.test(text) || /^\\/.*ago$/iu.test(text), "recent-project");
if (clicked) {
clickedProject = clicked.text;
await waitFor(1500);
return { ok: true, alreadyOpen: false, frameUrl: lastSummary.url, dismissed, clickedProject };
}
}
await waitFor(500);
}
return { ok: false, alreadyOpen: false, frameUrl: lastSummary?.url ?? frame.url(), dismissed, clickedProject, bodyPreview: lastSummary?.bodyPreview ?? null };
}
async function waitForOpenCodeComposer(frame, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastSummary = null;
while (Date.now() < deadline) {
lastSummary = await frameSummary(frame);
if (lastSummary.hasSubmit && lastSummary.textboxCount > 0) {
return { ok: true, frameUrl: lastSummary.url, textboxCount: lastSummary.textboxCount, hasSubmit: true };
}
await waitFor(750);
}
throw new Error("OpenCode composer was not ready; last=" + JSON.stringify(lastSummary));
}
async function startOpenCodeEventProbe(frame) {
return await frame.evaluate(() => {
const key = "__unideskOpenCodeEvents";
window[key] = Array.isArray(window[key]) ? window[key] : [];
if (window.__unideskManualOpenCodeEventSourceStarted) {
return { ok: true, reused: true, eventCount: window[key].length };
}
window.__unideskManualOpenCodeEventSourceStarted = true;
const eventUrl = new URL("/global/event", location.origin).toString();
const source = new EventSource(eventUrl);
window.__unideskManualOpenCodeEventSource = source;
const push = (type, event) => {
const rawData = typeof event?.data === "string" ? event.data : "";
let parsedType = null;
let status = null;
if (rawData) {
try {
const parsed = JSON.parse(rawData);
parsedType = typeof parsed?.type === "string" ? parsed.type : typeof parsed?.event === "string" ? parsed.event : typeof parsed?.name === "string" ? parsed.name : null;
status = typeof parsed?.status === "string" ? parsed.status : typeof parsed?.part?.status === "string" ? parsed.part.status : null;
} catch {
parsedType = null;
}
}
const label = parsedType || (status ? String(type) + ":" + status : String(type));
window[key].push({ at: Date.now(), source: eventUrl, type: String(type), parsedType, status, label, dataBytes: rawData.length, manualProbe: true });
if (window[key].length > 240) window[key].splice(0, window[key].length - 240);
};
const eventTypes = ["message", "message.part.updated", "message.part.added", "message.updated", "step-finish", "step.finish", "session.idle", "session.status", "session.updated", "session.error", "error"];
for (const type of eventTypes) source.addEventListener(type, (event) => push(type, event));
source.addEventListener("open", () => push("open", { data: "" }));
return { ok: true, reused: false, eventUrl, listenedTypes: eventTypes };
});
}
async function clickButtonMatching(frame, predicate, label) {
const buttons = frame.locator("button,[role='button']");
const count = Math.min(await buttons.count().catch(() => 0), 80);
for (let index = 0; index < count; index += 1) {
const button = buttons.nth(index);
const visible = await button.isVisible().catch(() => false);
if (!visible) continue;
const text = normalizeButtonText(await button.textContent().catch(() => ""));
if (!predicate(text)) continue;
await button.click({ timeout: 5000 });
return { ok: true, label, index, text };
}
return null;
}
function normalizeButtonText(value) {
return String(value || "").replace(/\\s+/gu, " ").trim();
}`;
const oldBlocked = "const blocked = /^(send|stop|cancel|thinking|loading|new session|settings)$/iu;";
const newBlocked = "const blocked = /^(send|stop|cancel|thinking|loading|new session|settings|build|shell|review|last turn changes|create git repository|all files|main branch|deepseek-v4-flash)$/iu;";
const withFlow = source.replace(oldFlow, newFlow);
const withFrameFinder = withFlow.replace(oldFrameFinder, newFrameFinder);
const patched = withFrameFinder.replace(oldBlocked, newBlocked);
if (withFlow === source) throw new Error("internal opencode-smoke generated script patch did not apply: flow");
if (withFrameFinder === withFlow) throw new Error("internal opencode-smoke generated script patch did not apply: frame finder");
if (patched === withFrameFinder) throw new Error("internal opencode-smoke generated script patch did not apply: assistant text filter");
if (!patched.includes("ensureOpenCodeProjectOpen") || !patched.includes("startOpenCodeEventProbe")) throw new Error("internal opencode-smoke generated script patch is missing required helpers");
return patched;
}
function resolveWebProbeScreenshotUrl(origin: string, targetPath: string): string {
try {
return new URL(targetPath || "/", origin).toString();
} catch {
throw new Error(`web-probe screenshot --path cannot be resolved against ${origin}: ${targetPath}`);
}
}
function parseWebProbeScreenshotViewport(value: string): string {
if (!/^[1-9][0-9]{1,4}x[1-9][0-9]{1,4}$/u.test(value)) throw new Error(`web-probe screenshot --viewport must look like 1440x900, got ${value}`);
const [widthRaw, heightRaw] = value.split("x");
const width = Number(widthRaw);
const height = Number(heightRaw);
if (width < 240 || width > 7680 || height < 240 || height > 4320) throw new Error(`web-probe screenshot --viewport out of range: ${value}`);
return value;
}
function parseWebProbeScreenshotName(value: string): string {
if (!/^[A-Za-z0-9._-]{1,120}$/u.test(value)) throw new Error("web-probe screenshot --name must contain only letters, numbers, dot, underscore, or dash, max 120 chars");
return value.endsWith(".png") ? value : `${value}.png`;
}
function parseWebProbeScreenshotWaitUntil(value: string): "load" | "domcontentloaded" | "networkidle" | "commit" {
if (value === "load" || value === "domcontentloaded" || value === "networkidle" || value === "commit") return value;
throw new Error(`web-probe screenshot --wait-until must be load, domcontentloaded, networkidle, or commit; got ${value}`);
}