diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 301a3c66..98571101 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -868,7 +868,7 @@ lanes: workspace: /root/workspace/hwlab-v03 sourceWorkspace: git: - remoteName: origin + remoteName: github remoteUrl: git@github.com:pikasTech/HWLAB.git identityTarget: JD01 identityId: github.com diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index e8a8a206..eace5a28 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -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 的 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 的压缩上下文继续操作。 -- 每次开始 node/lane 源码修改、PR 准备或 repo 内验证前必须通过 UniDesk SSH 桥检查目标 workspace,例如 `trans : sh -- 'git fetch origin && git pull --ff-only origin && 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 --lane --confirm`,再用 `source-workspace status` 读取 clean、branch、remote、HEAD 和依赖状态。该入口从 `config/hwlab-node-lanes.yaml#lanes..targets..sourceWorkspace` 读取 workspace remote、proxy env、identityTarget/identityId 和 up-to-date policy,并复用 `config/deploy-ssh-identities.yaml` 分发非交互 GitHub SSH 身份;不要把裸 `trans : 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。 - 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 truth;CI/CD source authority 只看 k8s git-mirror snapshot。 diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index bd41b407..a0bc5958 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -12,6 +12,7 @@ export function hwlabNodeHelp(): Record { examples: [ "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 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-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", @@ -25,7 +26,7 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts web-probe --help", ], 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.", "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.", diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 2f299161..e117eb3f 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -604,7 +604,7 @@ export async function runNodeDelegatedDomain(config: Config, domain: DelegatedNo return nodeRuntimeBaseImageCommand(scoped); } if (domain === "control-plane" && scoped.action === "source-workspace") { - return nodeRuntimeSourceWorkspaceCommand(scoped); + return await nodeRuntimeSourceWorkspaceCommand(config, scoped); } if (domain === "control-plane" && scoped.action === "plan") { const result = nodeRuntimeControlPlanePlan(scoped); diff --git a/scripts/src/hwlab-node/plan.ts b/scripts/src/hwlab-node/plan.ts index caf4c411..3c9a0747 100644 --- a/scripts/src/hwlab-node/plan.ts +++ b/scripts/src/hwlab-node/plan.ts @@ -165,6 +165,16 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record; -type SourceWorkspaceAction = "status" | "bootstrap" | "host-deps"; +type SourceWorkspaceAction = "status" | "sync" | "bootstrap" | "host-deps"; type SourceWorkspaceHostDependenciesAction = "status" | "sync"; -export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Record { +export async function nodeRuntimeSourceWorkspaceCommand(config: UniDeskConfig, scoped: ScopedNodeOptions): Promise> { const action = sourceWorkspaceAction(scoped); const sourceWorkspace = scoped.spec.sourceWorkspace; if (sourceWorkspace === undefined) { @@ -29,18 +31,22 @@ export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Re }; } if (action === "host-deps") return nodeRuntimeSourceWorkspaceHostDependencies(scoped, sourceWorkspace); + if (action === "sync") return await nodeRuntimeSourceWorkspaceSync(config, scoped, sourceWorkspace); if (action === "status") return nodeRuntimeSourceWorkspaceStatus(scoped, sourceWorkspace); return nodeRuntimeSourceWorkspaceBootstrap(scoped, sourceWorkspace); } function sourceWorkspaceAction(scoped: ScopedNodeOptions): SourceWorkspaceAction { const value = scoped.originalArgs[1]; - if (value !== "status" && 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]"); + if (value !== "status" && value !== "sync" && value !== "bootstrap" && value !== "host-deps") { + 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)) { 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; } @@ -116,6 +122,304 @@ function nodeRuntimeSourceWorkspaceBootstrap(scoped: ScopedNodeOptions, sourceWo }; } +async function nodeRuntimeSourceWorkspaceSync(config: UniDeskConfig, scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Promise> { + 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 { + 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> { + 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 { + 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 { const stateDir = sourceWorkspace.install.stateDir; if (stateDir === undefined) {