fix: sync HWLAB source workspace through YAML

This commit is contained in:
Codex
2026-07-01 13:15:42 +00:00
parent c7c6832bec
commit 0f6e16b44e
6 changed files with 323 additions and 8 deletions
+1 -1
View File
@@ -868,7 +868,7 @@ lanes:
workspace: /root/workspace/hwlab-v03 workspace: /root/workspace/hwlab-v03
sourceWorkspace: sourceWorkspace:
git: git:
remoteName: origin remoteName: github
remoteUrl: git@github.com:pikasTech/HWLAB.git remoteUrl: git@github.com:pikasTech/HWLAB.git
identityTarget: JD01 identityTarget: JD01
identityId: github.com identityId: github.com
+1 -1
View File
@@ -15,7 +15,7 @@
- JD01 的出网、依赖拉取、k3s/bootstrap、git-mirror 和 web-probe 浏览器依赖都以 UniDesk YAML 中的 host-route/host-proxy 声明为准:`config/hwlab-node-control-plane.yaml``nodes.JD01.egressProxy``config/platform-infra/host-proxy.yaml#targets.JD01`,以及 `config/hwlab-node-lanes.yaml` 的 JD01 network/download/sourceWorkspace 配置。JD01 的 MDTODO、Cloud Web、web-probe 或 rollout 任务不是 Sub2API 任务;除非 issue/CLI 明确指向 Sub2API runtime、Codex pool 或 platform-infra sub2api target,不要把这类故障默认归因到 Sub2API,也不要把 JD01 egress 临时切到 Sub2API 路径。 - JD01 的出网、依赖拉取、k3s/bootstrap、git-mirror 和 web-probe 浏览器依赖都以 UniDesk YAML 中的 host-route/host-proxy 声明为准:`config/hwlab-node-control-plane.yaml``nodes.JD01.egressProxy``config/platform-infra/host-proxy.yaml#targets.JD01`,以及 `config/hwlab-node-lanes.yaml` 的 JD01 network/download/sourceWorkspace 配置。JD01 的 MDTODO、Cloud Web、web-probe 或 rollout 任务不是 Sub2API 任务;除非 issue/CLI 明确指向 Sub2API runtime、Codex pool 或 platform-infra sub2api target,不要把这类故障默认归因到 Sub2API,也不要把 JD01 egress 临时切到 Sub2API 路径。
- JD01 的 node-local registry 是 k3s workload/PVC,不是 host Docker registry。`hwlab nodes control-plane infra status --node JD01 --lane v03` 应显示 registry workload ready、PVC bound、endpoint ready、`zeroSeeded=true``seededFromOldRegistry=false`;旧 host Docker `registry` 容器应保持停止。零种子 registry 迁移后,必须通过受控 `runtime-image preload``infra tools-image build/status` 补齐 BuildKit、runtime/base 和 tools image,再把 HWLAB/AgentRun status 与 web-probe smoke 作为可用性证据。 - JD01 的 node-local registry 是 k3s workload/PVC,不是 host Docker registry。`hwlab nodes control-plane infra status --node JD01 --lane v03` 应显示 registry workload ready、PVC bound、endpoint ready、`zeroSeeded=true``seededFromOldRegistry=false`;旧 host Docker `registry` 容器应保持停止。零种子 registry 迁移后,必须通过受控 `runtime-image preload``infra tools-image build/status` 补齐 BuildKit、runtime/base 和 tools image,再把 HWLAB/AgentRun status 与 web-probe smoke 作为可用性证据。
- HWLAB 项目内长期规则入口仍以目标 repo 的 `AGENTS.md` 为准。进入已解析的目标 workspace 后,必须重新读取该 workspace 的规则文件;不能只凭主 server 的压缩上下文继续操作。 - HWLAB 项目内长期规则入口仍以目标 repo 的 `AGENTS.md` 为准。进入已解析的目标 workspace 后,必须重新读取该 workspace 的规则文件;不能只凭主 server 的压缩上下文继续操作。
- 每次开始 node/lane 源码修改、PR 准备或 repo 内验证前必须通过 UniDesk SSH 桥检查目标 workspace,例如 `trans <node>:<workspace> sh -- 'git fetch origin <branch> && git pull --ff-only origin <branch> && git status --short --branch && git remote -v'`;若不满足目标 lane 预期,先修正 workspace,不能继续把该 workspace 用作开发工作面。CI/CD trigger、status 和 rollout source closeout 仍以 k8s git-mirror snapshot 为准。 - 每次开始 node/lane 源码修改、PR 准备或 repo 内验证前必须通过 YAML-first 受控入口检查并同步目标 workspace:`bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm`,再用 `source-workspace status` 读取 clean、branch、remote、HEAD 和依赖状态。该入口从 `config/hwlab-node-lanes.yaml#lanes.<lane>.targets.<node>.sourceWorkspace` 读取 workspace remote、proxy env、identityTarget/identityId 和 up-to-date policy,并复用 `config/deploy-ssh-identities.yaml` 分发非交互 GitHub SSH 身份;不要把裸 `trans <node>:<workspace> git fetch ...` 当成正式预检入口。CI/CD trigger、status 和 rollout source closeout 仍以 k8s git-mirror snapshot 为准。
- k3s 操作必须使用 YAML 解析出的 route 语法,例如 `trans D601:k3s ...``trans G14:k3s ...`。第一个 route token 必须定位分布式目标,后续 token 才是 operation。 - k3s 操作必须使用 YAML 解析出的 route 语法,例如 `trans D601:k3s ...``trans G14:k3s ...`。第一个 route token 必须定位分布式目标,后续 token 才是 operation。
- D601 node-scoped runtime(例如 `D601` + `v0.3`)不是 legacy;只要 issue/CLI 明确选择 D601 node/lane,就按 YAML 中的 D601 target 执行。D601 legacy 只指旧 DEV/迁移/回滚对照路径(如 `/home/ubuntu/workspace/hwlab-dev`、16666/16667 或历史 `deploy/deploy.json` wrapper),必须由 issue/CLI 明确写成 legacy/迁移/回滚才使用。 - D601 node-scoped runtime(例如 `D601` + `v0.3`)不是 legacy;只要 issue/CLI 明确选择 D601 node/lane,就按 YAML 中的 D601 target 执行。D601 legacy 只指旧 DEV/迁移/回滚对照路径(如 `/home/ubuntu/workspace/hwlab-dev`、16666/16667 或历史 `deploy/deploy.json` wrapper),必须由 issue/CLI 明确写成 legacy/迁移/回滚才使用。
- `/root/HWLAB``/workspace/hwlab``/home/ubuntu/hwlab``/tmp/hwlab-*`、无关 runner clone、master-server checkout 或未由 YAML 选中的 workspace 都不能作为当前 HWLAB 开发 source truthCI/CD source authority 只看 k8s git-mirror snapshot。 - `/root/HWLAB``/workspace/hwlab``/home/ubuntu/hwlab``/tmp/hwlab-*`、无关 runner clone、master-server checkout 或未由 YAML 选中的 workspace 都不能作为当前 HWLAB 开发 source truthCI/CD source authority 只看 k8s git-mirror snapshot。
+2 -1
View File
@@ -12,6 +12,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
examples: [ examples: [
"bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03",
"bun scripts/cli.ts hwlab nodes control-plane status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane status --node D601 --lane v03",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node JD01 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node JD01 --lane v03 --min-age-minutes 30 --limit 200 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node JD01 --lane v03 --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node JD01 --lane v03 --limit 200 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node JD01 --lane v03 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-images --node JD01 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-images --node JD01 --lane v03 --dry-run",
@@ -25,7 +26,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe --help", "bun scripts/cli.ts web-probe --help",
], ],
actions: { actions: {
"control-plane": "YAML-first node-local CI/CD, git-mirror, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.", "control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
"git-mirror": "Inspect or operate the selected node/lane source mirror.", "git-mirror": "Inspect or operate the selected node/lane source mirror.",
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.", "hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.", "fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
+1 -1
View File
@@ -604,7 +604,7 @@ export async function runNodeDelegatedDomain(config: Config, domain: DelegatedNo
return nodeRuntimeBaseImageCommand(scoped); return nodeRuntimeBaseImageCommand(scoped);
} }
if (domain === "control-plane" && scoped.action === "source-workspace") { if (domain === "control-plane" && scoped.action === "source-workspace") {
return nodeRuntimeSourceWorkspaceCommand(scoped); return await nodeRuntimeSourceWorkspaceCommand(config, scoped);
} }
if (domain === "control-plane" && scoped.action === "plan") { if (domain === "control-plane" && scoped.action === "plan") {
const result = nodeRuntimeControlPlanePlan(scoped); const result = nodeRuntimeControlPlanePlan(scoped);
+10
View File
@@ -165,6 +165,16 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record<string,
valuesPrinted: false, valuesPrinted: false,
}, },
sourceWorkspace: spec.sourceWorkspace === undefined ? null : { sourceWorkspace: spec.sourceWorkspace === undefined ? null : {
git: spec.sourceWorkspace.git === undefined ? null : {
remoteName: spec.sourceWorkspace.git.remoteName,
remoteUrl: spec.sourceWorkspace.git.remoteUrl,
identityTarget: spec.sourceWorkspace.git.identityTarget ?? null,
identityId: spec.sourceWorkspace.git.identityId ?? null,
proxyEnvPath: spec.sourceWorkspace.git.proxyEnvPath ?? null,
verifyRemote: spec.sourceWorkspace.git.verifyRemote,
requireUpToDate: spec.sourceWorkspace.git.requireUpToDate,
valuesPrinted: false,
},
requiredCommands: spec.sourceWorkspace.requiredCommands, requiredCommands: spec.sourceWorkspace.requiredCommands,
requiredFiles: spec.sourceWorkspace.requiredFiles, requiredFiles: spec.sourceWorkspace.requiredFiles,
install: spec.sourceWorkspace.install, install: spec.sourceWorkspace.install,
+308 -4
View File
@@ -2,6 +2,8 @@
// Responsibility: YAML-first D518/D601 HWLAB source workspace status and bootstrap for web-probe clients. // Responsibility: YAML-first D518/D601 HWLAB source workspace status and bootstrap for web-probe clients.
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { posix as posixPath } from "node:path"; import { posix as posixPath } from "node:path";
import type { UniDeskConfig } from "../config";
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
import { hwlabNodeControlPlaneSourceWorkspaceBootstrap } from "../hwlab-node-control-plane"; import { hwlabNodeControlPlaneSourceWorkspaceBootstrap } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeSourceWorkspaceHostDependenciesSpec, type HwlabRuntimeSourceWorkspaceSpec } from "../hwlab-node-lanes"; import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeSourceWorkspaceHostDependenciesSpec, type HwlabRuntimeSourceWorkspaceSpec } from "../hwlab-node-lanes";
import { parseNodeScopedDelegatedOptions } from "./plan"; import { parseNodeScopedDelegatedOptions } from "./plan";
@@ -10,10 +12,10 @@ import { compactRuntimeCommand, parseLastJsonLineObject } from "./runtime-common
import { commaListField, keyValueLinesFromText, numericField, shellQuote, statusText } from "./utils"; import { commaListField, keyValueLinesFromText, numericField, shellQuote, statusText } from "./utils";
type ScopedNodeOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>; type ScopedNodeOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>;
type SourceWorkspaceAction = "status" | "bootstrap" | "host-deps"; type SourceWorkspaceAction = "status" | "sync" | "bootstrap" | "host-deps";
type SourceWorkspaceHostDependenciesAction = "status" | "sync"; type SourceWorkspaceHostDependenciesAction = "status" | "sync";
export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Record<string, unknown> { export async function nodeRuntimeSourceWorkspaceCommand(config: UniDeskConfig, scoped: ScopedNodeOptions): Promise<Record<string, unknown>> {
const action = sourceWorkspaceAction(scoped); const action = sourceWorkspaceAction(scoped);
const sourceWorkspace = scoped.spec.sourceWorkspace; const sourceWorkspace = scoped.spec.sourceWorkspace;
if (sourceWorkspace === undefined) { if (sourceWorkspace === undefined) {
@@ -29,18 +31,22 @@ export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Re
}; };
} }
if (action === "host-deps") return nodeRuntimeSourceWorkspaceHostDependencies(scoped, sourceWorkspace); if (action === "host-deps") return nodeRuntimeSourceWorkspaceHostDependencies(scoped, sourceWorkspace);
if (action === "sync") return await nodeRuntimeSourceWorkspaceSync(config, scoped, sourceWorkspace);
if (action === "status") return nodeRuntimeSourceWorkspaceStatus(scoped, sourceWorkspace); if (action === "status") return nodeRuntimeSourceWorkspaceStatus(scoped, sourceWorkspace);
return nodeRuntimeSourceWorkspaceBootstrap(scoped, sourceWorkspace); return nodeRuntimeSourceWorkspaceBootstrap(scoped, sourceWorkspace);
} }
function sourceWorkspaceAction(scoped: ScopedNodeOptions): SourceWorkspaceAction { function sourceWorkspaceAction(scoped: ScopedNodeOptions): SourceWorkspaceAction {
const value = scoped.originalArgs[1]; const value = scoped.originalArgs[1];
if (value !== "status" && value !== "bootstrap" && value !== "host-deps") { if (value !== "status" && value !== "sync" && value !== "bootstrap" && value !== "host-deps") {
throw new Error("control-plane source-workspace usage: source-workspace status|bootstrap|host-deps --node NODE --lane vNN [--dry-run|--confirm]"); throw new Error("control-plane source-workspace usage: source-workspace status|sync|bootstrap|host-deps --node NODE --lane vNN [--dry-run|--confirm]");
} }
if (value === "status" && (scoped.confirm || scoped.dryRun)) { if (value === "status" && (scoped.confirm || scoped.dryRun)) {
throw new Error("control-plane source-workspace status is read-only and does not accept --dry-run or --confirm"); throw new Error("control-plane source-workspace status is read-only and does not accept --dry-run or --confirm");
} }
if (value === "sync" && scoped.wait) {
throw new Error("control-plane source-workspace sync is synchronous and does not accept --wait");
}
return value; return value;
} }
@@ -116,6 +122,304 @@ function nodeRuntimeSourceWorkspaceBootstrap(scoped: ScopedNodeOptions, sourceWo
}; };
} }
async function nodeRuntimeSourceWorkspaceSync(config: UniDeskConfig, scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Promise<Record<string, unknown>> {
if (sourceWorkspace.git === undefined) {
return {
ok: false,
command: `hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane}`,
node: scoped.node,
lane: scoped.lane,
mode: "yaml-missing",
mutation: false,
configPath: hwlabRuntimeLaneConfigPath(),
degradedReason: "source-workspace-git-yaml-missing",
message: "sourceWorkspace.git must declare remoteName, remoteUrl, identityTarget/identityId, proxyEnvPath and up-to-date policy before sync can run.",
};
}
const dryRun = scoped.dryRun || !scoped.confirm;
const before = nodeRuntimeSourceWorkspaceGitStatus(scoped, sourceWorkspace);
const identityPlan = sourceWorkspaceIdentityPlan(scoped, sourceWorkspace);
if (dryRun) {
return {
ok: true,
command: `hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane}`,
node: scoped.node,
lane: scoped.lane,
mode: "dry-run",
mutation: false,
configPath: hwlabRuntimeLaneConfigPath(),
expected: sourceWorkspaceExpected(scoped.spec, sourceWorkspace),
before,
identity: identityPlan,
sync: {
ok: true,
status: "dry-run",
remoteName: sourceWorkspace.git.remoteName,
remoteUrl: sourceWorkspace.git.remoteUrl,
sourceBranch: scoped.spec.sourceBranch,
valuesPrinted: false,
},
after: null,
next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane} --confirm` },
};
}
const identity = await sourceWorkspaceEnsureIdentity(config, scoped, sourceWorkspace);
if (identity.ok === false) {
return {
ok: false,
command: `hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane}`,
node: scoped.node,
lane: scoped.lane,
mode: "identity-distribution-failed",
mutation: false,
configPath: hwlabRuntimeLaneConfigPath(),
expected: sourceWorkspaceExpected(scoped.spec, sourceWorkspace),
before,
identity,
sync: null,
after: null,
degradedReason: "source-workspace-identity-distribution-failed",
next: { retry: `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane} --confirm` },
};
}
const result = runTransHostScript(scoped.node, sourceWorkspaceSyncScript(scoped.spec, sourceWorkspace), "", scoped.timeoutSeconds);
const payload = parseLastJsonLineObject(statusText(result));
const after = nodeRuntimeSourceWorkspaceGitStatus(scoped, sourceWorkspace);
const ok = result.exitCode === 0 && payload.ok === true && after.ok === true;
return {
ok,
command: `hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane}`,
node: scoped.node,
lane: scoped.lane,
mode: "confirmed-sync",
mutation: true,
configPath: hwlabRuntimeLaneConfigPath(),
expected: sourceWorkspaceExpected(scoped.spec, sourceWorkspace),
before,
identity,
sync: payload,
result: compactRuntimeCommand(result),
after,
degradedReason: ok ? undefined : "source-workspace-sync-failed",
next: ok
? { status: `bun scripts/cli.ts hwlab nodes control-plane source-workspace status --node ${scoped.node} --lane ${scoped.lane}` }
: { retry: `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node ${scoped.node} --lane ${scoped.lane} --confirm` },
};
}
function sourceWorkspaceIdentityPlan(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
const git = sourceWorkspace.git;
if (git === undefined) return { required: false, valuesPrinted: false };
const remoteNeedsSsh = remoteUrlNeedsSshIdentity(git.remoteUrl);
return {
required: remoteNeedsSsh,
source: "sourceWorkspace.git",
identityTarget: git.identityTarget ?? null,
identityId: git.identityId ?? null,
configRefs: {
lane: `${hwlabRuntimeLaneConfigPath()}#lanes.${scoped.lane}.targets.${scoped.node}.sourceWorkspace.git`,
identity: git.identityTarget === undefined || git.identityId === undefined ? null : `config/deploy-ssh-identities.yaml#targets.${git.identityTarget}.identities[${git.identityId}]`,
},
valuesPrinted: false,
};
}
async function sourceWorkspaceEnsureIdentity(config: UniDeskConfig, scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Promise<Record<string, unknown>> {
const git = sourceWorkspace.git;
if (git === undefined || !remoteUrlNeedsSshIdentity(git.remoteUrl)) {
return { ok: true, required: false, status: "not-required", valuesPrinted: false };
}
if (git.identityTarget === undefined || git.identityId === undefined) {
return {
ok: false,
required: true,
status: "yaml-missing",
degradedReason: "source-workspace-git-identity-yaml-missing",
message: `sourceWorkspace.git for node=${scoped.node} lane=${scoped.lane} uses SSH remoteUrl and must declare identityTarget and identityId.`,
configPath: hwlabRuntimeLaneConfigPath(),
valuesPrinted: false,
};
}
const result = await ensureGithubSshIdentityForProvider(config, git.identityTarget, git.identityId);
return {
ok: result.ok,
required: true,
status: result.ok ? "distributed" : "failed",
identityTarget: result.targetId ?? git.identityTarget,
identityId: result.identityId ?? git.identityId,
fingerprint: result.fingerprint,
seededFromLocal: result.seededFromLocal,
detail: result.ok ? result.detail : "identity distribution failed; inspect the returned redacted raw tail for details",
configTruth: result.configTruth,
raw: result.ok ? null : result.raw,
valuesPrinted: false,
};
}
function remoteUrlNeedsSshIdentity(remoteUrl: string): boolean {
return remoteUrl.startsWith("git@") || remoteUrl.startsWith("ssh://");
}
function nodeRuntimeSourceWorkspaceGitStatus(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
if (sourceWorkspace.git === undefined) {
return { ok: false, status: "yaml-missing", degradedReason: "source-workspace-git-yaml-missing", valuesPrinted: false };
}
const result = runTransHostScript(scoped.node, sourceWorkspaceGitStatusScript(scoped.spec, sourceWorkspace), "", scoped.timeoutSeconds);
const payload = parseLastJsonLineObject(statusText(result));
return {
ok: result.exitCode === 0 && payload.ok === true,
...payload,
probe: compactRuntimeCommand(result),
valuesPrinted: false,
};
}
function sourceWorkspaceGitStatusScript(spec: HwlabRuntimeLaneSpec, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): string {
const git = sourceWorkspace.git;
if (git === undefined) throw new Error("sourceWorkspace.git is required");
return [
"set +e",
`workspace=${shellQuote(spec.workspace)}`,
`expected_branch=${shellQuote(spec.sourceBranch)}`,
`remote_name=${shellQuote(git.remoteName)}`,
`expected_remote_url=${shellQuote(git.remoteUrl)}`,
`verify_remote=${shellQuote(git.verifyRemote ? "yes" : "no")}`,
`require_up_to_date=${shellQuote(git.requireUpToDate ? "yes" : "no")}`,
`proxy_env_path=${shellQuote(git.proxyEnvPath ?? "")}`,
`git_timeout=${String(spec.downloadProfile.git.timeoutSeconds)}`,
`http_proxy_value=${shellQuote(spec.networkProfile.proxy.http)}`,
`https_proxy_value=${shellQuote(spec.networkProfile.proxy.https)}`,
`all_proxy_value=${shellQuote(spec.networkProfile.proxy.all)}`,
`no_proxy_value=${shellQuote(spec.networkProfile.proxy.noProxy.join(","))}`,
"tmp_dir=$(mktemp -d)",
"trap 'rm -rf \"$tmp_dir\"' EXIT",
"export GIT_TERMINAL_PROMPT=0",
"workspace_exists=false",
"git_dir_exists=false",
"workspace_clean=false",
"branch=",
"local_head=",
"remote_url=",
"remote_matches=false",
"remote_reachable=null",
"remote_head=",
"remote_probe_exit=",
"remote_probe_error_tail=",
"status_short=",
"failure_kind=",
"if [ ! -d \"$workspace\" ]; then",
" failure_kind=workspace-missing",
"else",
" workspace_exists=true",
" if ! git -C \"$workspace\" rev-parse --git-dir >/dev/null 2>&1; then",
" failure_kind=workspace-git-missing",
" else",
" git_dir_exists=true",
" branch=$(git -C \"$workspace\" rev-parse --abbrev-ref HEAD 2>/dev/null || true)",
" local_head=$(git -C \"$workspace\" rev-parse HEAD 2>/dev/null || true)",
" remote_url=$(git -C \"$workspace\" remote get-url \"$remote_name\" 2>/dev/null || true)",
" status_short=$(git -C \"$workspace\" status --short 2>/dev/null || true)",
" [ -z \"$status_short\" ] && workspace_clean=true",
" [ \"$remote_url\" = \"$expected_remote_url\" ] && remote_matches=true",
" if [ \"$verify_remote\" = yes ]; then",
" if [ -n \"$proxy_env_path\" ] && [ -f \"$proxy_env_path\" ]; then . \"$proxy_env_path\" 2>/dev/null || true; fi",
" export HTTP_PROXY=\"${HTTP_PROXY:-$http_proxy_value}\" HTTPS_PROXY=\"${HTTPS_PROXY:-$https_proxy_value}\" ALL_PROXY=\"${ALL_PROXY:-$all_proxy_value}\" NO_PROXY=\"${NO_PROXY:-$no_proxy_value}\"",
" export http_proxy=\"${http_proxy:-$HTTP_PROXY}\" https_proxy=\"${https_proxy:-$HTTPS_PROXY}\" all_proxy=\"${all_proxy:-$ALL_PROXY}\" no_proxy=\"${no_proxy:-$NO_PROXY}\"",
" timeout \"$git_timeout\" git -C \"$workspace\" ls-remote \"$expected_remote_url\" \"refs/heads/$expected_branch\" >\"$tmp_dir/remote.out\" 2>\"$tmp_dir/remote.err\"",
" remote_probe_exit=$?",
" remote_head=$(awk '{print $1}' \"$tmp_dir/remote.out\" | head -n 1)",
" remote_probe_error_tail=$(tail -c 1000 \"$tmp_dir/remote.err\" 2>/dev/null | tr '\\n\\t' ' ' | cut -c1-1000)",
" if [ \"$remote_probe_exit\" = 0 ] && [ -n \"$remote_head\" ]; then remote_reachable=true; else remote_reachable=false; fi",
" fi",
" fi",
"fi",
"WORKSPACE=\"$workspace\" EXPECTED_BRANCH=\"$expected_branch\" WORKSPACE_EXISTS=\"$workspace_exists\" GIT_DIR_EXISTS=\"$git_dir_exists\" WORKSPACE_CLEAN=\"$workspace_clean\" BRANCH=\"$branch\" LOCAL_HEAD=\"$local_head\" REMOTE_NAME=\"$remote_name\" EXPECTED_REMOTE_URL=\"$expected_remote_url\" REMOTE_URL=\"$remote_url\" REMOTE_MATCHES=\"$remote_matches\" VERIFY_REMOTE=\"$verify_remote\" REQUIRE_UP_TO_DATE=\"$require_up_to_date\" REMOTE_REACHABLE=\"$remote_reachable\" REMOTE_HEAD=\"$remote_head\" REMOTE_PROBE_EXIT=\"$remote_probe_exit\" REMOTE_PROBE_ERROR_TAIL=\"$remote_probe_error_tail\" STATUS_SHORT=\"$status_short\" FAILURE_KIND=\"$failure_kind\" node <<'NODE'",
"const statusShort = process.env.STATUS_SHORT || '';",
"const remoteHead = process.env.REMOTE_HEAD || '';",
"const remoteRequired = process.env.VERIFY_REMOTE === 'yes';",
"const upToDateRequired = process.env.REQUIRE_UP_TO_DATE === 'yes';",
"const remoteReachable = process.env.REMOTE_REACHABLE === 'null' ? null : process.env.REMOTE_REACHABLE === 'true';",
"const localHead = process.env.LOCAL_HEAD || '';",
"const ok = process.env.WORKSPACE_EXISTS === 'true' && process.env.GIT_DIR_EXISTS === 'true' && process.env.WORKSPACE_CLEAN === 'true' && process.env.BRANCH === process.env.EXPECTED_BRANCH && process.env.REMOTE_MATCHES === 'true' && (!remoteRequired || remoteReachable === true) && (!upToDateRequired || !remoteHead || localHead === remoteHead);",
"console.log(JSON.stringify({ ok, status: ok ? 'ready' : 'not-ready', workspace: process.env.WORKSPACE, expectedBranch: process.env.EXPECTED_BRANCH, workspaceExists: process.env.WORKSPACE_EXISTS === 'true', gitDirExists: process.env.GIT_DIR_EXISTS === 'true', workspaceClean: process.env.WORKSPACE_CLEAN === 'true', branch: process.env.BRANCH || null, localHead: localHead || null, remoteName: process.env.REMOTE_NAME, expectedRemoteUrl: process.env.EXPECTED_REMOTE_URL, remoteUrl: process.env.REMOTE_URL || null, remoteMatchesYaml: process.env.REMOTE_MATCHES === 'true', remoteReachabilityRequired: remoteRequired, remoteReachable, remoteHead: remoteHead || null, remoteProbeExitCode: process.env.REMOTE_PROBE_EXIT ? Number(process.env.REMOTE_PROBE_EXIT) : null, remoteProbeErrorTail: process.env.REMOTE_PROBE_ERROR_TAIL || null, remoteUpToDateRequired: upToDateRequired, remoteUpToDate: remoteHead ? localHead === remoteHead : null, statusShortLines: statusShort ? statusShort.split(/\\n/).filter(Boolean).length : 0, statusShortPreview: statusShort.replace(/[\\n\\t]+/g, ' ').slice(0, 1000) || null, failureKind: process.env.FAILURE_KIND || null, valuesPrinted: false }));",
"NODE",
].join("\n");
}
function sourceWorkspaceSyncScript(spec: HwlabRuntimeLaneSpec, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): string {
const git = sourceWorkspace.git;
if (git === undefined) throw new Error("sourceWorkspace.git is required");
return [
"set +e",
`workspace=${shellQuote(spec.workspace)}`,
`expected_branch=${shellQuote(spec.sourceBranch)}`,
`remote_name=${shellQuote(git.remoteName)}`,
`remote_url=${shellQuote(git.remoteUrl)}`,
`proxy_env_path=${shellQuote(git.proxyEnvPath ?? "")}`,
`git_timeout=${String(spec.downloadProfile.git.timeoutSeconds)}`,
`http_proxy_value=${shellQuote(spec.networkProfile.proxy.http)}`,
`https_proxy_value=${shellQuote(spec.networkProfile.proxy.https)}`,
`all_proxy_value=${shellQuote(spec.networkProfile.proxy.all)}`,
`no_proxy_value=${shellQuote(spec.networkProfile.proxy.noProxy.join(","))}`,
"tmp_dir=$(mktemp -d)",
"trap 'rm -rf \"$tmp_dir\"' EXIT",
"export GIT_TERMINAL_PROMPT=0",
"if [ -n \"$proxy_env_path\" ] && [ -f \"$proxy_env_path\" ]; then . \"$proxy_env_path\" 2>/dev/null || true; fi",
"export HTTP_PROXY=\"${HTTP_PROXY:-$http_proxy_value}\" HTTPS_PROXY=\"${HTTPS_PROXY:-$https_proxy_value}\" ALL_PROXY=\"${ALL_PROXY:-$all_proxy_value}\" NO_PROXY=\"${NO_PROXY:-$no_proxy_value}\"",
"export http_proxy=\"${http_proxy:-$HTTP_PROXY}\" https_proxy=\"${https_proxy:-$HTTPS_PROXY}\" all_proxy=\"${all_proxy:-$ALL_PROXY}\" no_proxy=\"${no_proxy:-$NO_PROXY}\"",
"failure_kind=",
"fetch_exit=1",
"update_exit=1",
"remote_url_after=",
"remote_head=",
"local_head=",
"branch=",
"status_short=",
"stderr_tail=",
"if [ ! -d \"$workspace/.git\" ]; then",
" failure_kind=workspace-git-missing",
"elif ! cd \"$workspace\"; then",
" failure_kind=workspace-cd-failed",
"else",
" status_short=$(git status --short 2>/dev/null || true)",
" branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)",
" if [ -n \"$status_short\" ]; then",
" failure_kind=workspace-dirty",
" elif [ \"$branch\" != \"$expected_branch\" ]; then",
" failure_kind=branch-mismatch",
" else",
" git remote set-url \"$remote_name\" \"$remote_url\" >\"$tmp_dir/remote.out\" 2>\"$tmp_dir/remote.err\" || git remote add \"$remote_name\" \"$remote_url\" >>\"$tmp_dir/remote.out\" 2>>\"$tmp_dir/remote.err\"",
" remote_set_exit=$?",
" remote_url_after=$(git remote get-url \"$remote_name\" 2>/dev/null || true)",
" if [ \"$remote_set_exit\" -ne 0 ]; then",
" failure_kind=remote-config-failed",
" else",
" timeout \"$git_timeout\" git fetch \"$remote_name\" \"$expected_branch:refs/remotes/$remote_name/$expected_branch\" >\"$tmp_dir/fetch.out\" 2>\"$tmp_dir/fetch.err\"",
" fetch_exit=$?",
" if [ \"$fetch_exit\" -ne 0 ]; then",
" failure_kind=git-fetch-failed",
" else",
" remote_head=$(git rev-parse \"refs/remotes/$remote_name/$expected_branch\" 2>/dev/null || true)",
" git merge --ff-only \"refs/remotes/$remote_name/$expected_branch\" >\"$tmp_dir/update.out\" 2>\"$tmp_dir/update.err\"",
" update_exit=$?",
" if [ \"$update_exit\" -ne 0 ]; then failure_kind=git-ff-only-failed; fi",
" fi",
" fi",
" fi",
" local_head=$(git rev-parse HEAD 2>/dev/null || true)",
" status_short=$(git status --short 2>/dev/null || true)",
"fi",
"stderr_tail=$(cat \"$tmp_dir\"/*.err 2>/dev/null | tail -c 2000 | tr '\\n\\t' ' ' | cut -c1-2000)",
"WORKSPACE=\"$workspace\" EXPECTED_BRANCH=\"$expected_branch\" REMOTE_NAME=\"$remote_name\" REMOTE_URL=\"$remote_url\" REMOTE_URL_AFTER=\"$remote_url_after\" BRANCH=\"$branch\" LOCAL_HEAD=\"$local_head\" REMOTE_HEAD=\"$remote_head\" FETCH_EXIT=\"$fetch_exit\" UPDATE_EXIT=\"$update_exit\" STATUS_SHORT=\"$status_short\" FAILURE_KIND=\"$failure_kind\" STDERR_TAIL=\"$stderr_tail\" node <<'NODE'",
"const statusShort = process.env.STATUS_SHORT || '';",
"const ok = !process.env.FAILURE_KIND && !statusShort && process.env.LOCAL_HEAD && (!process.env.REMOTE_HEAD || process.env.LOCAL_HEAD === process.env.REMOTE_HEAD);",
"console.log(JSON.stringify({ ok, status: ok ? 'synced' : 'failed', workspace: process.env.WORKSPACE, branch: process.env.BRANCH || null, expectedBranch: process.env.EXPECTED_BRANCH, remoteName: process.env.REMOTE_NAME, remoteUrl: process.env.REMOTE_URL_AFTER || process.env.REMOTE_URL, localHead: process.env.LOCAL_HEAD || null, remoteHead: process.env.REMOTE_HEAD || null, remoteUpToDate: process.env.REMOTE_HEAD ? process.env.LOCAL_HEAD === process.env.REMOTE_HEAD : null, fetchExitCode: Number(process.env.FETCH_EXIT || '1'), updateExitCode: Number(process.env.UPDATE_EXIT || '1'), failureKind: process.env.FAILURE_KIND || null, statusShortLines: statusShort ? statusShort.split(/\\n/).filter(Boolean).length : 0, statusShortPreview: statusShort.replace(/[\\n\\t]+/g, ' ').slice(0, 1000) || null, stderrTail: process.env.STDERR_TAIL || null, valuesPrinted: false }));",
"NODE",
].join("\n");
}
function nodeRuntimeSourceWorkspaceHostBootstrap(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> { function nodeRuntimeSourceWorkspaceHostBootstrap(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
const stateDir = sourceWorkspace.install.stateDir; const stateDir = sourceWorkspace.install.stateDir;
if (stateDir === undefined) { if (stateDir === undefined) {