refactor: move remote screenshots into web-probe
This commit is contained in:
@@ -21,7 +21,7 @@ trans D601:win/c/test git commit -m 'fix: update docs'
|
|||||||
trans gh:/owner/repo/issue/<number> cat
|
trans gh:/owner/repo/issue/<number> cat
|
||||||
```
|
```
|
||||||
|
|
||||||
Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patch/py/upload/download/playwright/kubectl/logs/skills/tcp-pool 操作,以及 apply-patch envelope 语法和 quoting 陷阱见 [references/full.md](references/full.md)。
|
Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patch/py/upload/download/kubectl/logs/skills/tcp-pool 操作,以及 apply-patch envelope 语法和 quoting 陷阱见 [references/full.md](references/full.md)。
|
||||||
|
|
||||||
## P0 边界
|
## P0 边界
|
||||||
|
|
||||||
@@ -36,4 +36,4 @@ Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patc
|
|||||||
|
|
||||||
- 不确定 route 语法、k3s/workspace/Windows 定位时,读 [references/full.md](references/full.md) 的 Route 语法段。
|
- 不确定 route 语法、k3s/workspace/Windows 定位时,读 [references/full.md](references/full.md) 的 Route 语法段。
|
||||||
- 编写远端 patch 前,读 apply-patch 语法、上下文定位和常见失败段。
|
- 编写远端 patch 前,读 apply-patch 语法、上下文定位和常见失败段。
|
||||||
- 需要 shell heredoc、Python、upload/download、Playwright 或超时处理时,读 Operation 和 60s 段。
|
- 需要 shell heredoc、Python、upload/download 或超时处理时,读 Operation 和 60s 段。
|
||||||
|
|||||||
@@ -170,10 +170,6 @@ trans G14:/root/hwlab download /root/remote-file.txt ./local-file.txt
|
|||||||
|
|
||||||
自动校验 SHA-256,结果中 `verified=true` 即完整性证明。
|
自动校验 SHA-256,结果中 `verified=true` 即完整性证明。
|
||||||
|
|
||||||
### playwright(远端浏览器验收)
|
|
||||||
|
|
||||||
`trans <route> playwright` 只保留为跨 host 短生命周期浏览器执行和截图/PDF 回传底座。UniDesk/HWLAB Web 开发、HWLAB `web-probe run|script`、fake-server Playwright、fixture 脱敏、截图 artifact 与 Workbench/Performance 判定口径统一见 `$unidesk-webdev`;本 SSH 透传 skill 不维护第二套 Web 测试流程。
|
|
||||||
|
|
||||||
### kubectl / logs(k3s 诊断)
|
### kubectl / logs(k3s 诊断)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H
|
|||||||
- 真实数据优先:fixture seed 优先从目标 node/lane 的受控真实样本采集。合成 fixture 只补真实样本难以稳定覆盖的边界,并标明 `derivedFrom` 与 `syntheticReason`。
|
- 真实数据优先:fixture seed 优先从目标 node/lane 的受控真实样本采集。合成 fixture 只补真实样本难以稳定覆盖的边界,并标明 `derivedFrom` 与 `syntheticReason`。
|
||||||
- 原入口闭环:fake-server Playwright 用例负责可重复红灯和源码回归;线上 `web-probe` 负责同一 node/lane public origin 的 P4 原入口验收。二者不能互相替代。
|
- 原入口闭环:fake-server Playwright 用例负责可重复红灯和源码回归;线上 `web-probe` 负责同一 node/lane public origin 的 P4 原入口验收。二者不能互相替代。
|
||||||
- Master server 禁重型验证:不要在 master server 跑仓库级 check、Web build、Playwright/browser smoke 或镜像构建。HWLAB Web 验证走目标 node/lane workspace、k3s/Tekton、D601 runner 或受控 web-probe。
|
- Master server 禁重型验证:不要在 master server 跑仓库级 check、Web build、Playwright/browser smoke 或镜像构建。HWLAB Web 验证走目标 node/lane workspace、k3s/Tekton、D601 runner 或受控 web-probe。
|
||||||
- 禁止裸写 Playwright:UniDesk/HWLAB Web 复现、截图、DOM/API 采样、长程 Workbench 观测和线上 closeout 默认必须走 `web-probe run|script|observe`;不得直接写 `trans <route> playwright` heredoc、`playwright-cli`、临时 Node Playwright 脚本或本地 browser daemon 作为正式证据入口。确有非 HWLAB 外站截图/PDF 等 web-probe 不覆盖的短生命周期需求时,必须说明例外原因,且可复用动作要回收进 web-probe。
|
- 禁止裸写 Playwright:UniDesk/HWLAB Web 复现、截图、DOM/API 采样、长程 Workbench 观测和线上 closeout 默认必须走 `web-probe run|script|observe`;不得直接写 `playwright-cli`、临时 Node Playwright 脚本或本地 browser daemon 作为正式证据入口。确有 web-probe 不覆盖的短生命周期需求时,必须说明例外原因,且可复用动作要回收进 web-probe。
|
||||||
|
|
||||||
## 工作流
|
## 工作流
|
||||||
|
|
||||||
@@ -274,6 +274,6 @@ UniDesk/HWLAB Web 工作不再把裸 Playwright 当作默认操作面。需要
|
|||||||
|
|
||||||
`web-probe observe analyze` 必须把 Workbench session 列表标题纳入默认采样与报告:可见列表中 `Session ses_...` fallback 标题超过一半时输出红灯 finding。修复方向必须让上游 session list projection 直接携带名称;点击详情后的下游补名、reload repair 或多来源仲裁不能作为修复。
|
`web-probe observe analyze` 必须把 Workbench session 列表标题纳入默认采样与报告:可见列表中 `Session ses_...` fallback 标题超过一半时输出红灯 finding。修复方向必须让上游 session list projection 直接携带名称;点击详情后的下游补名、reload repair 或多来源仲裁不能作为修复。
|
||||||
|
|
||||||
`trans <route> playwright`、`playwright-cli` 和临时 Node Playwright 脚本只允许作为非 HWLAB 外站短生命周期截图/PDF、或 web-probe 尚未覆盖且一次性不可复用的诊断例外。例外使用前必须写明为什么 `web-probe script` 或 `web-probe observe` 不适用;同类动作出现第二次就应补进 web-probe CLI 或 repo-owned probe。
|
`playwright-cli` 和临时 Node Playwright 脚本只允许作为 web-probe 尚未覆盖且一次性不可复用的诊断例外。例外使用前必须写明为什么 `web-probe run|script|observe|screenshot` 不适用;同类动作出现第二次就应补进 web-probe CLI 或 repo-owned probe。
|
||||||
|
|
||||||
需要把截图回传到本机时,优先用 `web-probe script` 的 `screenshot(name)`,或长程观测中使用 `observe command --type screenshot --label <label>`,并在 issue 中引用截图 SHA、report SHA、observer id 和 stateDir。不要把本地 Playwright artifact 当作 HWLAB node/lane 原入口验收替代品。
|
需要把截图回传到本机时,优先用 `web-probe screenshot`,或长程观测中使用 `observe command --type screenshot --label <label>`,并在 issue 中引用截图 SHA、report SHA、observer id 和 stateDir。不要把本地 Playwright artifact 当作 HWLAB node/lane 原入口验收替代品。
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status
|
|||||||
|
|
||||||
## Web / Playwright
|
## Web / Playwright
|
||||||
|
|
||||||
UniDesk/HWLAB Web 开发、Playwright wrapper、`trans <route> playwright`、HWLAB `web-probe run|script`、fake-server 回归、截图 artifact 和 node/lane 原入口验收统一见 `$unidesk-webdev`。本文件只保留 CLI 命名索引,不复制 Web 测试操作面,避免形成多路径和 fallback。
|
UniDesk/HWLAB Web 开发、HWLAB `web-probe run|script|observe|screenshot`、fake-server 回归、截图 artifact 和 node/lane 原入口验收统一见 `$unidesk-webdev`。本文件只保留 CLI 命名索引,不复制 Web 测试操作面,避免形成多路径和 fallback。
|
||||||
|
|
||||||
## Async Job State
|
## Async Job State
|
||||||
|
|
||||||
|
|||||||
@@ -1463,7 +1463,6 @@ function traceSignals(text: string): Record<string, boolean> {
|
|||||||
transTimeoutHint: /UNIDESK_TRAN_TIMEOUT_HINT/iu.test(text),
|
transTimeoutHint: /UNIDESK_TRAN_TIMEOUT_HINT/iu.test(text),
|
||||||
sshTiming: /UNIDESK_SSH_TIMING/iu.test(text),
|
sshTiming: /UNIDESK_SSH_TIMING/iu.test(text),
|
||||||
downloadProgress: /unidesk\.ssh\.download\.progress/iu.test(text),
|
downloadProgress: /unidesk\.ssh\.download\.progress/iu.test(text),
|
||||||
sshPlaywright: /"command"\s*:\s*"ssh playwright"|command['"]?\s*[:=]\s*['"]?ssh playwright/iu.test(text),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,7 +1588,6 @@ function signalLabel(signals: Record<string, unknown> | null): string {
|
|||||||
signals.transTimeoutHint === true ? "timeout" : "",
|
signals.transTimeoutHint === true ? "timeout" : "",
|
||||||
signals.sshTiming === true ? "timing" : "",
|
signals.sshTiming === true ? "timing" : "",
|
||||||
signals.downloadProgress === true ? "download" : "",
|
signals.downloadProgress === true ? "download" : "",
|
||||||
signals.sshPlaywright === true ? "playwright" : "",
|
|
||||||
].filter((label) => label.length > 0);
|
].filter((label) => label.length > 0);
|
||||||
return labels.length === 0 ? "-" : labels.join(",");
|
return labels.length === 0 ? "-" : labels.join(",");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ export function sshHelp(): unknown {
|
|||||||
"trans <providerId>:/absolute/workspace apply-patch < patch.diff",
|
"trans <providerId>:/absolute/workspace apply-patch < patch.diff",
|
||||||
"trans <route> upload <local-file> <remote-file>",
|
"trans <route> upload <local-file> <remote-file>",
|
||||||
"trans <route> download <remote-file> <local-file>",
|
"trans <route> download <remote-file> <local-file>",
|
||||||
"trans <providerId> playwright [--local-dir /tmp] <<'PW'",
|
|
||||||
"trans <providerId> apply-patch-v1 [--allow-loose] < patch.diff",
|
"trans <providerId> apply-patch-v1 [--allow-loose] < patch.diff",
|
||||||
"trans <providerId> py [script-args...] < script.py",
|
"trans <providerId> py [script-args...] < script.py",
|
||||||
"trans <providerId> sh [arg...] <<'SH'",
|
"trans <providerId> sh [arg...] <<'SH'",
|
||||||
@@ -230,7 +229,6 @@ export function sshHelp(): unknown {
|
|||||||
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
|
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
|
||||||
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s and Windows workspace routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.",
|
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s and Windows workspace routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.",
|
||||||
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. Downloads stream over `host.ssh.tcp-pool`, emit progress JSON, and may receive a caller-supplied `--inactivity-timeout-ms` from async artifact/deploy jobs so active large transfers are not killed by the generic short-command budget.",
|
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. Downloads stream over `host.ssh.tcp-pool`, emit progress JSON, and may receive a caller-supplied `--inactivity-timeout-ms` from async artifact/deploy jobs so active large transfers are not killed by the generic short-command budget.",
|
||||||
"`playwright` runs a stdin heredoc on the target POSIX host/workload with a temporary `playwright-cli` wrapper in PATH, submits the remote script as a background job, polls short status commands for the manifest, then downloads image/pdf artifacts to local `/tmp` by default with the same verified SHA-256 transfer path. Canonical syntax is `trans D601 playwright <<'PW' ... PW`; workspace and known UniDesk host workspaces are preferred for wrapper discovery before external skill passthroughs.",
|
|
||||||
"`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
|
"`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
|
||||||
"`sh` inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY and runs target `/bin/sh`; use `bash` only for Bash syntax such as `pipefail`, arrays, substring expansion, or `[[ ... ]]`, not as a proxy workaround.",
|
"`sh` inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY and runs target `/bin/sh`; use `bash` only for Bash syntax such as `pipefail`, arrays, substring expansion, or `[[ ... ]]`, not as a proxy workaround.",
|
||||||
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win ps` for Windows PowerShell and `<provider>:win cmd` for Windows cmd.exe, with `<provider>:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use `<provider>:k3s` for the control plane and `<provider>:k3s:<namespace>:<workload>[:<container>]` for a workload/container. In k3s routes, `:` is the distributed route separator; `/...` is only an in-container filesystem cwd and never selects a container. Prefer operation `--cwd /path` when a container is also specified.",
|
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win ps` for Windows PowerShell and `<provider>:win cmd` for Windows cmd.exe, with `<provider>:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use `<provider>:k3s` for the control plane and `<provider>:k3s:<namespace>:<workload>[:<container>]` for a workload/container. In k3s routes, `:` is the distributed route separator; `/...` is only an in-container filesystem cwd and never selects a container. Prefer operation `--cwd /path` when a container is also specified.",
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { join } from "node:path";
|
|||||||
import { repoRoot, rootPath } from "./config";
|
import { repoRoot, rootPath } from "./config";
|
||||||
import { runCommand, type CommandResult } from "./command";
|
import { runCommand, type CommandResult } from "./command";
|
||||||
import { startJob } from "./jobs";
|
import { startJob } from "./jobs";
|
||||||
import { transPath } from "./hwlab-node/runtime-common";
|
|
||||||
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
|
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
|
||||||
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
|
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
|
||||||
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||||
import type { RenderedCliResult } from "./output";
|
import type { RenderedCliResult } from "./output";
|
||||||
|
import { runWebProbeRemoteArtifactJob } from "./web-probe-remote-artifact";
|
||||||
|
|
||||||
export type WebProbeSentinelConfigAction = "plan" | "status";
|
export type WebProbeSentinelConfigAction = "plan" | "status";
|
||||||
export type WebProbeSentinelImageAction = "status" | "build";
|
export type WebProbeSentinelImageAction = "status" | "build";
|
||||||
@@ -1844,7 +1844,7 @@ function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extrac
|
|||||||
const script = [
|
const script = [
|
||||||
"set -eu",
|
"set -eu",
|
||||||
`export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(`${publicBaseUrl}/`)}`,
|
`export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(`${publicBaseUrl}/`)}`,
|
||||||
`export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(screenshotName)}`,
|
`export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR"/${shellQuote(screenshotName)}`,
|
||||||
`export UNIDESK_SENTINEL_DASHBOARD_CAPTURE=${shellQuote(options.action === "screenshot" ? "1" : "0")}`,
|
`export UNIDESK_SENTINEL_DASHBOARD_CAPTURE=${shellQuote(options.action === "screenshot" ? "1" : "0")}`,
|
||||||
`export UNIDESK_SENTINEL_DASHBOARD_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
|
`export UNIDESK_SENTINEL_DASHBOARD_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
|
||||||
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
|
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
|
||||||
@@ -1859,24 +1859,22 @@ function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extrac
|
|||||||
"else",
|
"else",
|
||||||
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=",
|
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=",
|
||||||
"fi",
|
"fi",
|
||||||
"cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\" <<'WEB_PROBE_SENTINEL_DASHBOARD_JS'",
|
"cat > \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\" <<'WEB_PROBE_SENTINEL_DASHBOARD_JS'",
|
||||||
sentinelDashboardBrowserModule(),
|
sentinelDashboardBrowserModule(),
|
||||||
"WEB_PROBE_SENTINEL_DASHBOARD_JS",
|
"WEB_PROBE_SENTINEL_DASHBOARD_JS",
|
||||||
"bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"",
|
"bun \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const route = `${state.spec.nodeId}:${state.spec.workspace}`;
|
const route = `${state.spec.nodeId}:${state.spec.workspace}`;
|
||||||
const result = runCommand([
|
const job = runWebProbeRemoteArtifactJob({
|
||||||
transPath(),
|
|
||||||
route,
|
route,
|
||||||
"playwright",
|
localDir: options.localDir,
|
||||||
"--local-dir",
|
waitTimeoutMs: options.waitTimeoutMs,
|
||||||
options.localDir,
|
commandTimeoutMs: options.commandTimeoutSeconds * 1000,
|
||||||
"--wait-timeout-ms",
|
inactivityTimeoutMs: 30000,
|
||||||
String(options.waitTimeoutMs),
|
runIdPrefix: `web-probe-sentinel-dashboard-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}-${state.sentinelId}`,
|
||||||
"--inactivity-timeout-ms",
|
}, script);
|
||||||
"30000",
|
const result = job.result;
|
||||||
], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 });
|
const transport = record(job.transport);
|
||||||
const transport = record(parseJsonObject(result.stdout));
|
|
||||||
const remote = record(transport.remote);
|
const remote = record(transport.remote);
|
||||||
const page = parseDashboardBrowserPayload(typeof remote.stdoutTail === "string" ? remote.stdoutTail : "");
|
const page = parseDashboardBrowserPayload(typeof remote.stdoutTail === "string" ? remote.stdoutTail : "");
|
||||||
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record).map(compactDashboardArtifact) : [];
|
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record).map(compactDashboardArtifact) : [];
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from
|
|||||||
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
||||||
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
||||||
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
||||||
|
import { runWebProbeRemoteArtifactJob } from "../web-probe-remote-artifact";
|
||||||
import type { RenderedCliResult } from "../output";
|
import type { RenderedCliResult } from "../output";
|
||||||
|
|
||||||
import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeScreenshotOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry";
|
import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeScreenshotOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry";
|
||||||
@@ -619,19 +620,17 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
|
|||||||
export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||||
const route = `${options.node}:${spec.workspace}`;
|
const route = `${options.node}:${spec.workspace}`;
|
||||||
const script = webProbeScreenshotRemoteScript(options);
|
const script = webProbeScreenshotRemoteScript(options);
|
||||||
const result = runCommand([
|
const job = runWebProbeRemoteArtifactJob({
|
||||||
transPath(),
|
|
||||||
route,
|
route,
|
||||||
"playwright",
|
localDir: options.localDir,
|
||||||
"--local-dir",
|
waitTimeoutMs: options.waitTimeoutMs,
|
||||||
options.localDir,
|
commandTimeoutMs: options.commandTimeoutSeconds * 1000,
|
||||||
"--wait-timeout-ms",
|
inactivityTimeoutMs: 30000,
|
||||||
String(options.waitTimeoutMs),
|
keepRemote: options.keepRemote,
|
||||||
"--inactivity-timeout-ms",
|
runIdPrefix: `web-probe-screenshot-${options.node.toLowerCase()}-${options.lane}`,
|
||||||
"30000",
|
}, script);
|
||||||
...(options.keepRemote ? ["--keep-remote"] : []),
|
const result = job.result;
|
||||||
], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 });
|
const transport = record(job.transport);
|
||||||
const transport = record(parseJsonObject(result.stdout));
|
|
||||||
const transportParsed = Object.keys(transport).length > 0;
|
const transportParsed = Object.keys(transport).length > 0;
|
||||||
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record) : [];
|
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record) : [];
|
||||||
const screenshot = artifacts.find((artifact) => {
|
const screenshot = artifacts.find((artifact) => {
|
||||||
@@ -749,7 +748,7 @@ function webProbeScreenshotRemoteScript(options: NodeWebProbeScreenshotOptions):
|
|||||||
return [
|
return [
|
||||||
"set -eu",
|
"set -eu",
|
||||||
`export UNIDESK_WEB_PROBE_SCREENSHOT_URL=${shellQuote(options.url)}`,
|
`export UNIDESK_WEB_PROBE_SCREENSHOT_URL=${shellQuote(options.url)}`,
|
||||||
`export UNIDESK_WEB_PROBE_SCREENSHOT_PATH="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(options.name)}`,
|
`export UNIDESK_WEB_PROBE_SCREENSHOT_PATH="$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR"/${shellQuote(options.name)}`,
|
||||||
`export UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
|
`export UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
|
||||||
`export UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
|
`export UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
|
||||||
`export UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
|
`export UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
|
||||||
@@ -765,10 +764,10 @@ function webProbeScreenshotRemoteScript(options: NodeWebProbeScreenshotOptions):
|
|||||||
"else",
|
"else",
|
||||||
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=",
|
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=",
|
||||||
"fi",
|
"fi",
|
||||||
"cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\" <<'WEB_PROBE_SCREENSHOT_JS'",
|
"cat > \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-screenshot.mjs\" <<'WEB_PROBE_SCREENSHOT_JS'",
|
||||||
webProbeScreenshotRemoteModule(),
|
webProbeScreenshotRemoteModule(),
|
||||||
"WEB_PROBE_SCREENSHOT_JS",
|
"WEB_PROBE_SCREENSHOT_JS",
|
||||||
"bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\"",
|
"bun \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-screenshot.mjs\"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,562 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { basename, join, resolve } from "node:path";
|
|
||||||
import {
|
|
||||||
downloadSshFileVerified,
|
|
||||||
type SshFileTransferCommandBuilders,
|
|
||||||
type SshRemoteCommandExecutor,
|
|
||||||
type SshVerifiedDownloadResult,
|
|
||||||
} from "./ssh-file-transfer";
|
|
||||||
import type { ParsedSshInvocation, ParsedSshRoute, SshCaptureResult } from "./ssh";
|
|
||||||
|
|
||||||
interface SshPlaywrightOptions {
|
|
||||||
localDir: string;
|
|
||||||
remoteDir: string | null;
|
|
||||||
keepRemote: boolean;
|
|
||||||
inactivityTimeoutMs?: number;
|
|
||||||
pollIntervalMs: number;
|
|
||||||
waitTimeoutMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemotePlaywrightManifest {
|
|
||||||
runId: string;
|
|
||||||
exitCode: number;
|
|
||||||
remoteDir: string;
|
|
||||||
stdoutPath: string;
|
|
||||||
stderrPath: string;
|
|
||||||
cliMode: string | null;
|
|
||||||
cliCwd: string | null;
|
|
||||||
cliBin: string | null;
|
|
||||||
defaultScreenshot: string | null;
|
|
||||||
stdoutTail: string;
|
|
||||||
stderrTail: string;
|
|
||||||
artifacts: Array<{
|
|
||||||
remotePath: string;
|
|
||||||
bytes: number | null;
|
|
||||||
sha256: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestBegin = "__UNIDESK_PLAYWRIGHT_MANIFEST_BEGIN__";
|
|
||||||
const manifestEnd = "__UNIDESK_PLAYWRIGHT_MANIFEST_END__";
|
|
||||||
|
|
||||||
export function isSshPlaywrightOperation(args: string[]): boolean {
|
|
||||||
return (args[0] ?? "") === "playwright";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSshPlaywrightOperation(
|
|
||||||
invocation: ParsedSshInvocation,
|
|
||||||
args: string[],
|
|
||||||
executor: SshRemoteCommandExecutor,
|
|
||||||
builders: SshFileTransferCommandBuilders,
|
|
||||||
): Promise<number> {
|
|
||||||
if (invocation.route.plane === "win") {
|
|
||||||
throw new Error(`ssh ${invocation.route.raw} playwright is not supported for Windows routes; use a POSIX host/workspace route`);
|
|
||||||
}
|
|
||||||
if (invocation.route.plane === "k3s" && (invocation.route.namespace === null || invocation.route.resource === null)) {
|
|
||||||
throw new Error(`ssh ${invocation.route.raw} playwright requires a workload route, or use a host workspace route`);
|
|
||||||
}
|
|
||||||
const options = parseSshPlaywrightOptions(args);
|
|
||||||
const userScript = await readAllStdin();
|
|
||||||
if (userScript.trim().length === 0) {
|
|
||||||
throw new Error("ssh playwright requires a stdin heredoc script; example: trans D601 playwright <<'PW'\nplaywright-cli screenshot https://example.com \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"\nPW");
|
|
||||||
}
|
|
||||||
|
|
||||||
const runId = `unidesk-playwright-${safePathSegment(invocation.providerId)}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
||||||
const remoteDir = options.remoteDir ?? `/tmp/${runId}`;
|
|
||||||
const localDir = resolve(options.localDir);
|
|
||||||
const submitCommand = builders.buildRouteCommand(invocation.route, ["sh", "-c", remotePlaywrightSubmitScript(remoteDir), "unidesk-playwright-submit"], { stdin: true });
|
|
||||||
const submit = await executor.runRemoteCommand(submitCommand, userScript);
|
|
||||||
const submitRecovery = submit.exitCode !== 0 && isRecoverableSubmitTimeout(submit)
|
|
||||||
? {
|
|
||||||
recovered: true,
|
|
||||||
reason: "submit-short-connection-timeout",
|
|
||||||
remoteDir,
|
|
||||||
runId,
|
|
||||||
exitCode: submit.exitCode,
|
|
||||||
stdoutTail: submit.stdout.slice(-1000),
|
|
||||||
stderrTail: submit.stderr.slice(-1000),
|
|
||||||
next: "submit may have been cut by the 60s trans runtime limit after the background job was launched; polling remote status by remoteDir/runId",
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
if (submit.exitCode !== 0 && submitRecovery === null) {
|
|
||||||
throw new Error(`ssh playwright submit failed: exitCode=${submit.exitCode}; remoteDir=${remoteDir}; runId=${runId}; stdoutTail=${JSON.stringify(submit.stdout.slice(-1000))}; stderrTail=${JSON.stringify(submit.stderr.slice(-1000))}`);
|
|
||||||
}
|
|
||||||
const manifest = await pollRemoteManifest(invocation.route, executor, builders, remoteDir, runId, options);
|
|
||||||
const artifacts: Array<SshVerifiedDownloadResult & { manifestBytes: number | null; manifestSha256: string | null }> = [];
|
|
||||||
let downloadFailure: Record<string, unknown> | null = null;
|
|
||||||
|
|
||||||
for (const artifact of manifest.artifacts) {
|
|
||||||
const localPath = join(localDir, `${runId}-${safePathSegment(basename(artifact.remotePath) || "artifact")}`);
|
|
||||||
const maxAttempts = 3;
|
|
||||||
let downloaded = false;
|
|
||||||
let lastError: unknown = null;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
||||||
try {
|
|
||||||
const download = await downloadSshFileVerified(
|
|
||||||
invocation,
|
|
||||||
executor,
|
|
||||||
builders,
|
|
||||||
artifact.remotePath,
|
|
||||||
localPath,
|
|
||||||
options.inactivityTimeoutMs,
|
|
||||||
);
|
|
||||||
artifacts.push({ ...download, manifestBytes: artifact.bytes, manifestSha256: artifact.sha256 });
|
|
||||||
downloaded = true;
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (attempt < maxAttempts) await sleep(750 * attempt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!downloaded) {
|
|
||||||
downloadFailure = {
|
|
||||||
remotePath: artifact.remotePath,
|
|
||||||
attempts: maxAttempts,
|
|
||||||
message: lastError instanceof Error ? lastError.message : String(lastError),
|
|
||||||
name: lastError instanceof Error ? lastError.name : undefined,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = await cleanupRemoteDir(invocation.route, executor, builders, remoteDir, options.keepRemote);
|
|
||||||
const ok = manifest.exitCode === 0 && downloadFailure === null;
|
|
||||||
const payload = {
|
|
||||||
ok,
|
|
||||||
command: "ssh playwright",
|
|
||||||
route: invocation.route.raw,
|
|
||||||
providerId: invocation.providerId,
|
|
||||||
runId,
|
|
||||||
localDir,
|
|
||||||
remote: {
|
|
||||||
exitCode: manifest.exitCode,
|
|
||||||
remoteDir: manifest.remoteDir,
|
|
||||||
stdoutPath: manifest.stdoutPath,
|
|
||||||
stderrPath: manifest.stderrPath,
|
|
||||||
stdoutTail: manifest.stdoutTail,
|
|
||||||
stderrTail: manifest.stderrTail,
|
|
||||||
cliMode: manifest.cliMode,
|
|
||||||
cliCwd: manifest.cliCwd,
|
|
||||||
cliBin: manifest.cliBin,
|
|
||||||
defaultScreenshot: manifest.defaultScreenshot,
|
|
||||||
},
|
|
||||||
artifacts,
|
|
||||||
artifactCount: artifacts.length,
|
|
||||||
expectedArtifactCount: manifest.artifacts.length,
|
|
||||||
cleanup,
|
|
||||||
downloadFailure,
|
|
||||||
remoteCommand: {
|
|
||||||
exitCode: manifest.exitCode,
|
|
||||||
submitExitCode: submit.exitCode,
|
|
||||||
submitRecovered: submitRecovery !== null,
|
|
||||||
submitRecovery,
|
|
||||||
submitStdoutBytes: Buffer.byteLength(submit.stdout, "utf8"),
|
|
||||||
submitStderrBytes: Buffer.byteLength(submit.stderr, "utf8"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
||||||
if (ok) return 0;
|
|
||||||
return manifest.exitCode === 0 ? 1 : manifest.exitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSshPlaywrightOptions(args: string[]): SshPlaywrightOptions {
|
|
||||||
const options: SshPlaywrightOptions = {
|
|
||||||
localDir: "/tmp",
|
|
||||||
remoteDir: null,
|
|
||||||
keepRemote: false,
|
|
||||||
pollIntervalMs: 2_000,
|
|
||||||
waitTimeoutMs: 180_000,
|
|
||||||
};
|
|
||||||
for (let index = 1; index < args.length; index += 1) {
|
|
||||||
const arg = args[index] ?? "";
|
|
||||||
const next = args[index + 1];
|
|
||||||
if (arg === "--help" || arg === "-h" || arg === "help") {
|
|
||||||
process.stdout.write(`${JSON.stringify(playwrightHelp(), null, 2)}\n`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
if (arg === "--keep-remote") {
|
|
||||||
options.keepRemote = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--local-dir") {
|
|
||||||
options.localDir = requireValue(arg, next);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg.startsWith("--local-dir=")) {
|
|
||||||
options.localDir = requireValue("--local-dir", arg.slice("--local-dir=".length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--remote-dir") {
|
|
||||||
options.remoteDir = requireAbsoluteRemotePath(arg, next);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg.startsWith("--remote-dir=")) {
|
|
||||||
options.remoteDir = requireAbsoluteRemotePath("--remote-dir", arg.slice("--remote-dir=".length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--inactivity-timeout-ms") {
|
|
||||||
options.inactivityTimeoutMs = parsePositiveInteger(arg, next);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg.startsWith("--inactivity-timeout-ms=")) {
|
|
||||||
options.inactivityTimeoutMs = parsePositiveInteger("--inactivity-timeout-ms", arg.slice("--inactivity-timeout-ms=".length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--poll-interval-ms") {
|
|
||||||
options.pollIntervalMs = parsePositiveInteger(arg, next);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg.startsWith("--poll-interval-ms=")) {
|
|
||||||
options.pollIntervalMs = parsePositiveInteger("--poll-interval-ms", arg.slice("--poll-interval-ms=".length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--wait-timeout-ms") {
|
|
||||||
options.waitTimeoutMs = parsePositiveInteger(arg, next);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg.startsWith("--wait-timeout-ms=")) {
|
|
||||||
options.waitTimeoutMs = parsePositiveInteger("--wait-timeout-ms", arg.slice("--wait-timeout-ms=".length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`unsupported ssh playwright option: ${arg}`);
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function playwrightHelp(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
command: "ssh playwright",
|
|
||||||
usage: [
|
|
||||||
"trans <providerId> playwright [--local-dir /tmp] <<'PW'",
|
|
||||||
"playwright-cli screenshot https://example.com \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\" --full-page",
|
|
||||||
"PW",
|
|
||||||
"trans <providerId>:/absolute/workspace playwright [--local-dir /tmp] <<'PW'",
|
|
||||||
"playwright-cli screenshot http://127.0.0.1:18081/ \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"",
|
|
||||||
"PW",
|
|
||||||
],
|
|
||||||
behavior: [
|
|
||||||
"Reads a POSIX shell heredoc from stdin and runs it on the target route.",
|
|
||||||
"Prepends a temporary playwright-cli wrapper to PATH. The wrapper prefers ./scripts/playwright-cli.ts in the route workspace or known UniDesk host workspaces, then ~/.agents/skills/playwright*/scripts/playwright-cli.ts, then a playwright-cli binary.",
|
|
||||||
"Submits the remote script as a background job and polls short status commands for the manifest, so multi-step Playwright flows do not occupy one SSH connection past the 60s trans runtime limit.",
|
|
||||||
"Sets UNIDESK_PLAYWRIGHT_REMOTE_DIR and UNIDESK_PLAYWRIGHT_SCREENSHOT. Files created under that remote dir with image/pdf extensions are downloaded to --local-dir with byte and sha256 verification.",
|
|
||||||
],
|
|
||||||
options: {
|
|
||||||
"--local-dir <path>": "Local directory for returned artifacts. Default: /tmp.",
|
|
||||||
"--remote-dir <absolute-path>": "Remote artifact directory. Default: /tmp/unidesk-playwright-<provider>-<timestamp>-<id>.",
|
|
||||||
"--keep-remote": "Do not remove the remote artifact directory after the transfer.",
|
|
||||||
"--inactivity-timeout-ms <n>": "Forwarded to verified artifact download when large screenshots are returned.",
|
|
||||||
"--wait-timeout-ms <n>": "Maximum wall-clock time to wait for the remote Playwright job. Default: 180000.",
|
|
||||||
"--poll-interval-ms <n>": "Short-query polling interval for the remote job. Default: 2000.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireValue(name: string, value: string | undefined): string {
|
|
||||||
if (value === undefined || value.length === 0) throw new Error(`ssh playwright ${name} requires a non-empty value`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireAbsoluteRemotePath(name: string, value: string | undefined): string {
|
|
||||||
const pathValue = requireValue(name, value);
|
|
||||||
if (!pathValue.startsWith("/")) throw new Error(`ssh playwright ${name} must be an absolute POSIX path`);
|
|
||||||
return pathValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePositiveInteger(name: string, value: string | undefined): number {
|
|
||||||
const parsed = Number(requireValue(name, value));
|
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`ssh playwright ${name} must be a positive integer`);
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readAllStdin(): Promise<string> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
||||||
}
|
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollRemoteManifest(
|
|
||||||
route: ParsedSshRoute,
|
|
||||||
executor: SshRemoteCommandExecutor,
|
|
||||||
builders: SshFileTransferCommandBuilders,
|
|
||||||
remoteDir: string,
|
|
||||||
runId: string,
|
|
||||||
options: SshPlaywrightOptions,
|
|
||||||
): Promise<RemotePlaywrightManifest> {
|
|
||||||
const deadline = Date.now() + options.waitTimeoutMs;
|
|
||||||
let lastStatus: SshCaptureResult | null = null;
|
|
||||||
while (Date.now() <= deadline) {
|
|
||||||
const command = builders.buildRouteCommand(route, ["sh", "-c", remotePlaywrightStatusScript(), "unidesk-playwright-status", remoteDir]);
|
|
||||||
const status = await executor.runRemoteCommand(command);
|
|
||||||
lastStatus = status;
|
|
||||||
if (status.exitCode === 0 && status.stdout.includes(manifestEnd)) return parseRemoteManifest(status, remoteDir, runId);
|
|
||||||
if (status.exitCode !== 0 && !/status\t(?:pending|running)/u.test(status.stdout)) {
|
|
||||||
throw new Error(`ssh playwright status failed: exitCode=${status.exitCode}; remoteDir=${remoteDir}; runId=${runId}; stdoutTail=${JSON.stringify(status.stdout.slice(-1000))}; stderrTail=${JSON.stringify(status.stderr.slice(-1000))}`);
|
|
||||||
}
|
|
||||||
await sleep(options.pollIntervalMs);
|
|
||||||
}
|
|
||||||
throw new Error(`ssh playwright timed out waiting for remote job after ${options.waitTimeoutMs}ms; remoteDir=${remoteDir}; runId=${runId}; lastStdoutTail=${JSON.stringify(lastStatus?.stdout.slice(-1000) ?? "")}; lastStderrTail=${JSON.stringify(lastStatus?.stderr.slice(-1000) ?? "")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecoverableSubmitTimeout(submit: SshCaptureResult): boolean {
|
|
||||||
if (submit.exitCode === 124) return true;
|
|
||||||
const combined = `${submit.stdout}\n${submit.stderr}`;
|
|
||||||
return /(?:timed?\s*out|timeout|60s|exitCode=124|signal\s+TERM|signal\s+KILL)/iu.test(combined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function remotePlaywrightSubmitScript(remoteDir: string): string {
|
|
||||||
const runner = Buffer.from(remotePlaywrightRunnerScript(remoteDir), "utf8").toString("base64");
|
|
||||||
return [
|
|
||||||
"set -eu",
|
|
||||||
`remote_dir=${shellQuote(remoteDir)}`,
|
|
||||||
'mkdir -p -- "$remote_dir"',
|
|
||||||
'user_script="$remote_dir/user-script.sh"',
|
|
||||||
'runner_script="$remote_dir/runner.sh"',
|
|
||||||
'pid_file="$remote_dir/pid"',
|
|
||||||
'submit_stdout="$remote_dir/submit.out"',
|
|
||||||
'submit_stderr="$remote_dir/submit.err"',
|
|
||||||
'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then',
|
|
||||||
' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
|
||||||
" exit 0",
|
|
||||||
"fi",
|
|
||||||
'cat > "$user_script"',
|
|
||||||
'chmod 700 "$user_script"',
|
|
||||||
`printf %s ${shellQuote(runner)} | base64 -d >"$runner_script"`,
|
|
||||||
'chmod 700 "$runner_script"',
|
|
||||||
'nohup sh "$runner_script" "$user_script" >"$submit_stdout" 2>"$submit_stderr" < /dev/null &',
|
|
||||||
'pid=$!',
|
|
||||||
'printf "%s\\n" "$pid" >"$pid_file"',
|
|
||||||
'printf "status\\tsubmitted\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$pid"',
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function remotePlaywrightStatusScript(): string {
|
|
||||||
return [
|
|
||||||
"set -eu",
|
|
||||||
'remote_dir="$1"',
|
|
||||||
'manifest="$remote_dir/manifest.tsv"',
|
|
||||||
'pid_file="$remote_dir/pid"',
|
|
||||||
'if [ -f "$manifest" ]; then cat "$manifest"; exit 0; fi',
|
|
||||||
'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then',
|
|
||||||
' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
|
||||||
" exit 1",
|
|
||||||
"fi",
|
|
||||||
'if [ -f "$pid_file" ]; then',
|
|
||||||
' printf "status\\texited-without-manifest\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
|
||||||
' if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi',
|
|
||||||
' if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi',
|
|
||||||
" exit 2",
|
|
||||||
"fi",
|
|
||||||
'printf "status\\tpending\\nremote_dir\\t%s\\n" "$remote_dir"',
|
|
||||||
'if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi',
|
|
||||||
'if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi',
|
|
||||||
"exit 1",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function remotePlaywrightRunnerScript(remoteDir: string): string {
|
|
||||||
return [
|
|
||||||
"set -eu",
|
|
||||||
`remote_dir=${shellQuote(remoteDir)}`,
|
|
||||||
'if [ "$#" -lt 1 ]; then printf "missing user script path\\n" >&2; exit 2; fi',
|
|
||||||
'user_script="$1"',
|
|
||||||
'mkdir -p -- "$remote_dir/bin"',
|
|
||||||
'stdout_file="$remote_dir/stdout.log"',
|
|
||||||
'stderr_file="$remote_dir/stderr.log"',
|
|
||||||
'artifacts_file="$remote_dir/artifacts.tsv"',
|
|
||||||
'manifest_file="$remote_dir/manifest.tsv"',
|
|
||||||
"sha256_file() {",
|
|
||||||
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
|
||||||
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
|
||||||
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
|
||||||
" printf 'missing sha256 tool\\n' >&2; return 127",
|
|
||||||
"}",
|
|
||||||
"resolve_playwright_cli() {",
|
|
||||||
" if command -v bun >/dev/null 2>&1; then",
|
|
||||||
" if [ -f ./scripts/playwright-cli.ts ]; then",
|
|
||||||
" UNIDESK_PLAYWRIGHT_CLI_MODE=repo; UNIDESK_PLAYWRIGHT_CLI_CWD=$(pwd); UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0",
|
|
||||||
" fi",
|
|
||||||
" for dir in \"$HOME/workspace/unidesk-dev\" \"$HOME/unidesk\" \"/home/ubuntu/workspace/unidesk-dev\" \"/root/unidesk\"; do",
|
|
||||||
" if [ -f \"$dir/scripts/playwright-cli.ts\" ]; then",
|
|
||||||
" UNIDESK_PLAYWRIGHT_CLI_MODE=repo; UNIDESK_PLAYWRIGHT_CLI_CWD=$dir; UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0",
|
|
||||||
" fi",
|
|
||||||
" done",
|
|
||||||
" fi",
|
|
||||||
" for dir in \"$HOME/.agents/skills/playwright\" \"$HOME/.agents/skills/playwright-cli\" \"$HOME/.codex/skills/playwright\" \"$HOME/.codex/skills/playwright-cli\"; do",
|
|
||||||
" if command -v bun >/dev/null 2>&1 && [ -f \"$dir/scripts/playwright-cli.ts\" ]; then",
|
|
||||||
" UNIDESK_PLAYWRIGHT_CLI_MODE=skill; UNIDESK_PLAYWRIGHT_CLI_CWD=$dir; UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0",
|
|
||||||
" fi",
|
|
||||||
" done",
|
|
||||||
" if command -v playwright-cli >/dev/null 2>&1; then",
|
|
||||||
" UNIDESK_PLAYWRIGHT_CLI_MODE=bin; UNIDESK_PLAYWRIGHT_CLI_CWD=; UNIDESK_PLAYWRIGHT_CLI_BIN=$(command -v playwright-cli); return 0",
|
|
||||||
" fi",
|
|
||||||
" return 42",
|
|
||||||
"}",
|
|
||||||
"if ! resolve_playwright_cli; then",
|
|
||||||
" printf 'unable to find playwright-cli wrapper: expected ./scripts/playwright-cli.ts, ~/.agents/skills/playwright*/scripts/playwright-cli.ts, or playwright-cli in PATH\\n' >&2",
|
|
||||||
" exit 42",
|
|
||||||
"fi",
|
|
||||||
"export UNIDESK_PLAYWRIGHT_CLI_MODE UNIDESK_PLAYWRIGHT_CLI_CWD UNIDESK_PLAYWRIGHT_CLI_BIN",
|
|
||||||
'cat > "$remote_dir/bin/playwright-cli" <<\'UNIDESK_PLAYWRIGHT_WRAPPER\'',
|
|
||||||
"#!/bin/sh",
|
|
||||||
"set -eu",
|
|
||||||
"case \"${UNIDESK_PLAYWRIGHT_CLI_MODE:-}\" in",
|
|
||||||
" repo|skill) cd \"$UNIDESK_PLAYWRIGHT_CLI_CWD\"; exec bun scripts/playwright-cli.ts \"$@\" ;;",
|
|
||||||
" bin) exec \"$UNIDESK_PLAYWRIGHT_CLI_BIN\" \"$@\" ;;",
|
|
||||||
" *) printf 'UNIDESK playwright wrapper is not resolved\\n' >&2; exit 42 ;;",
|
|
||||||
"esac",
|
|
||||||
"UNIDESK_PLAYWRIGHT_WRAPPER",
|
|
||||||
'chmod 700 "$remote_dir/bin/playwright-cli"',
|
|
||||||
"if command -v npx >/dev/null 2>&1; then",
|
|
||||||
" cat > \"$remote_dir/bin/npx.cmd\" <<'UNIDESK_NPX_CMD_WRAPPER'",
|
|
||||||
"#!/bin/sh",
|
|
||||||
"exec npx \"$@\"",
|
|
||||||
"UNIDESK_NPX_CMD_WRAPPER",
|
|
||||||
" chmod 700 \"$remote_dir/bin/npx.cmd\"",
|
|
||||||
"fi",
|
|
||||||
'export PATH="$remote_dir/bin:$PATH"',
|
|
||||||
'export UNIDESK_PLAYWRIGHT_REMOTE_DIR="$remote_dir"',
|
|
||||||
'export UNIDESK_PLAYWRIGHT_SCREENSHOT="$remote_dir/screenshot.png"',
|
|
||||||
"set +e",
|
|
||||||
'sh "$user_script" >"$stdout_file" 2>"$stderr_file"',
|
|
||||||
"user_rc=$?",
|
|
||||||
': > "$artifacts_file"',
|
|
||||||
'find "$remote_dir" -type f | while IFS= read -r file; do',
|
|
||||||
' case "$file" in "$user_script"|"$stdout_file"|"$stderr_file"|"$artifacts_file"|"$remote_dir/bin/"*) continue ;; esac',
|
|
||||||
' case "$file" in *.png|*.jpg|*.jpeg|*.webp|*.pdf)',
|
|
||||||
' bytes=$(wc -c < "$file" | tr -d "[:space:]")',
|
|
||||||
' digest=$(sha256_file "$file")',
|
|
||||||
' printf "artifact\\t%s\\t%s\\t%s\\n" "$bytes" "$digest" "$file" >> "$artifacts_file"',
|
|
||||||
" ;;",
|
|
||||||
" esac",
|
|
||||||
"done",
|
|
||||||
"b64_tail() { if [ -f \"$1\" ]; then tail -c 4000 \"$1\" | base64 | tr -d '\\n'; fi; }",
|
|
||||||
`{`,
|
|
||||||
`printf '%s\\n' ${shellQuote(manifestBegin)}`,
|
|
||||||
"printf 'run_id\\t%s\\n' \"${remote_dir##*/}\"",
|
|
||||||
"printf 'exit_code\\t%s\\n' \"$user_rc\"",
|
|
||||||
"printf 'remote_dir\\t%s\\n' \"$remote_dir\"",
|
|
||||||
"printf 'stdout_path\\t%s\\n' \"$stdout_file\"",
|
|
||||||
"printf 'stderr_path\\t%s\\n' \"$stderr_file\"",
|
|
||||||
"printf 'cli_mode\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_MODE\"",
|
|
||||||
"printf 'cli_cwd\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_CWD\"",
|
|
||||||
"printf 'cli_bin\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_BIN\"",
|
|
||||||
"printf 'default_screenshot\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"",
|
|
||||||
"printf 'stdout_tail_b64\\t%s\\n' \"$(b64_tail \"$stdout_file\")\"",
|
|
||||||
"printf 'stderr_tail_b64\\t%s\\n' \"$(b64_tail \"$stderr_file\")\"",
|
|
||||||
'cat "$artifacts_file"',
|
|
||||||
`printf '%s\\n' ${shellQuote(manifestEnd)}`,
|
|
||||||
`} > "$manifest_file"`,
|
|
||||||
'exit "$user_rc"',
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRemoteManifest(result: SshCaptureResult, fallbackRemoteDir: string, fallbackRunId: string): RemotePlaywrightManifest {
|
|
||||||
const start = result.stdout.indexOf(manifestBegin);
|
|
||||||
const end = result.stdout.indexOf(manifestEnd, start < 0 ? 0 : start);
|
|
||||||
if (start < 0 || end < 0) {
|
|
||||||
throw new Error(`ssh playwright did not emit a manifest; exitCode=${result.exitCode}; stdoutTail=${JSON.stringify(result.stdout.slice(-1000))}; stderrTail=${JSON.stringify(result.stderr.slice(-1000))}`);
|
|
||||||
}
|
|
||||||
const body = result.stdout.slice(start + manifestBegin.length, end).trim();
|
|
||||||
const manifest: RemotePlaywrightManifest = {
|
|
||||||
runId: fallbackRunId,
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
remoteDir: fallbackRemoteDir,
|
|
||||||
stdoutPath: join(fallbackRemoteDir, "stdout.log"),
|
|
||||||
stderrPath: join(fallbackRemoteDir, "stderr.log"),
|
|
||||||
cliMode: null,
|
|
||||||
cliCwd: null,
|
|
||||||
cliBin: null,
|
|
||||||
defaultScreenshot: null,
|
|
||||||
stdoutTail: "",
|
|
||||||
stderrTail: "",
|
|
||||||
artifacts: [],
|
|
||||||
};
|
|
||||||
for (const rawLine of body.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trimEnd();
|
|
||||||
if (line.length === 0) continue;
|
|
||||||
const parts = line.split("\t");
|
|
||||||
const key = parts[0] ?? "";
|
|
||||||
if (key === "artifact") {
|
|
||||||
const bytes = Number(parts[1] ?? "");
|
|
||||||
const sha256 = parts[2] ?? "";
|
|
||||||
const remotePath = parts.slice(3).join("\t");
|
|
||||||
if (remotePath.length > 0) {
|
|
||||||
manifest.artifacts.push({
|
|
||||||
remotePath,
|
|
||||||
bytes: Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : null,
|
|
||||||
sha256: /^[0-9a-f]{64}$/u.test(sha256) ? sha256 : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value = parts.slice(1).join("\t");
|
|
||||||
if (key === "run_id" && value.length > 0) manifest.runId = value;
|
|
||||||
else if (key === "exit_code") {
|
|
||||||
const exitCode = Number(value);
|
|
||||||
manifest.exitCode = Number.isInteger(exitCode) ? exitCode : result.exitCode;
|
|
||||||
} else if (key === "remote_dir" && value.length > 0) manifest.remoteDir = value;
|
|
||||||
else if (key === "stdout_path" && value.length > 0) manifest.stdoutPath = value;
|
|
||||||
else if (key === "stderr_path" && value.length > 0) manifest.stderrPath = value;
|
|
||||||
else if (key === "cli_mode") manifest.cliMode = value || null;
|
|
||||||
else if (key === "cli_cwd") manifest.cliCwd = value || null;
|
|
||||||
else if (key === "cli_bin") manifest.cliBin = value || null;
|
|
||||||
else if (key === "default_screenshot") manifest.defaultScreenshot = value || null;
|
|
||||||
else if (key === "stdout_tail_b64") manifest.stdoutTail = decodeBase64(value);
|
|
||||||
else if (key === "stderr_tail_b64") manifest.stderrTail = decodeBase64(value);
|
|
||||||
}
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupRemoteDir(
|
|
||||||
route: ParsedSshRoute,
|
|
||||||
executor: SshRemoteCommandExecutor,
|
|
||||||
builders: SshFileTransferCommandBuilders,
|
|
||||||
remoteDir: string,
|
|
||||||
keepRemote: boolean,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
if (keepRemote) return { attempted: false, kept: true, remoteDir };
|
|
||||||
const command = builders.buildRouteCommand(route, ["sh", "-c", "rm -rf -- \"$1\"", "unidesk-playwright-cleanup", remoteDir]);
|
|
||||||
const result = await executor.runRemoteCommand(command);
|
|
||||||
return {
|
|
||||||
attempted: true,
|
|
||||||
kept: false,
|
|
||||||
remoteDir,
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
ok: result.exitCode === 0,
|
|
||||||
stderrTail: result.stderr.slice(-1000),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64(value: string): string {
|
|
||||||
if (value.length === 0) return "";
|
|
||||||
try {
|
|
||||||
return Buffer.from(value, "base64").toString("utf8");
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function safePathSegment(value: string): string {
|
|
||||||
const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
||||||
return cleaned.length > 0 ? cleaned.slice(0, 120) : "artifact";
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellQuote(value: string): string {
|
|
||||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
||||||
}
|
|
||||||
+2
-16
@@ -22,10 +22,6 @@ import {
|
|||||||
type SshRemoteCommandExecutor,
|
type SshRemoteCommandExecutor,
|
||||||
type SshRemoteCommandStreamHandlers,
|
type SshRemoteCommandStreamHandlers,
|
||||||
} from "./ssh-file-transfer";
|
} from "./ssh-file-transfer";
|
||||||
import {
|
|
||||||
isSshPlaywrightOperation,
|
|
||||||
runSshPlaywrightOperation,
|
|
||||||
} from "./ssh-playwright";
|
|
||||||
|
|
||||||
export interface ParsedSshArgs {
|
export interface ParsedSshArgs {
|
||||||
remoteCommand: string | null;
|
remoteCommand: string | null;
|
||||||
@@ -979,7 +975,7 @@ export function parseSshArgs(args: string[], routeRaw = "<route>"): ParsedSshArg
|
|||||||
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||||
}
|
}
|
||||||
if (subcommand === "playwright") {
|
if (subcommand === "playwright") {
|
||||||
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
throw new Error("The remote Playwright operation has been removed; use `bun scripts/cli.ts web-probe screenshot ...` or `web-probe sentinel dashboard screenshot ...` for remote screenshots");
|
||||||
}
|
}
|
||||||
if (subcommand === "script" || subcommand === "shell") {
|
if (subcommand === "script" || subcommand === "shell") {
|
||||||
throw removedShellAliasError(subcommand, routeRaw, args.slice(1));
|
throw removedShellAliasError(subcommand, routeRaw, args.slice(1));
|
||||||
@@ -1173,7 +1169,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (operation === "playwright") {
|
if (operation === "playwright") {
|
||||||
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
throw new Error("The remote Playwright operation has been removed; use `bun scripts/cli.ts web-probe screenshot ...` or `web-probe sentinel dashboard screenshot ...` for remote screenshots");
|
||||||
}
|
}
|
||||||
if (isWindowsFsReadOnlyOperation(operation)) {
|
if (isWindowsFsReadOnlyOperation(operation)) {
|
||||||
return {
|
return {
|
||||||
@@ -3613,16 +3609,6 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
|||||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isSshPlaywrightOperation(normalizedArgs)) {
|
|
||||||
const executor: SshRemoteCommandExecutor = {
|
|
||||||
runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input),
|
|
||||||
streamRemoteCommand: (remoteCommand, handlers, input, options) => runSshStreamRemoteCommand(config, invocation, remoteCommand, handlers, input, options),
|
|
||||||
};
|
|
||||||
return await runSshPlaywrightOperation(invocation, normalizedArgs, executor, {
|
|
||||||
buildRouteCommand: remoteCommandForRoute,
|
|
||||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (operationName === "apply-patch") {
|
if (operationName === "apply-patch") {
|
||||||
const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1));
|
const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1));
|
||||||
const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win"
|
const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win"
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
import { repoRoot } from "./config";
|
||||||
|
import { runCommand, type CommandResult } from "./command";
|
||||||
|
|
||||||
|
export interface WebProbeRemoteArtifactJobOptions {
|
||||||
|
route: string;
|
||||||
|
localDir: string;
|
||||||
|
waitTimeoutMs: number;
|
||||||
|
commandTimeoutMs: number;
|
||||||
|
inactivityTimeoutMs?: number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
keepRemote?: boolean;
|
||||||
|
runIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteWebProbeArtifactManifest {
|
||||||
|
runId: string;
|
||||||
|
exitCode: number;
|
||||||
|
remoteDir: string;
|
||||||
|
stdoutPath: string;
|
||||||
|
stderrPath: string;
|
||||||
|
stdoutTail: string;
|
||||||
|
stderrTail: string;
|
||||||
|
artifacts: Array<{
|
||||||
|
remotePath: string;
|
||||||
|
bytes: number | null;
|
||||||
|
sha256: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebProbeRemoteArtifactJobResult {
|
||||||
|
result: CommandResult;
|
||||||
|
transport: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBegin = "__UNIDESK_WEB_PROBE_ARTIFACT_MANIFEST_BEGIN__";
|
||||||
|
const manifestEnd = "__UNIDESK_WEB_PROBE_ARTIFACT_MANIFEST_END__";
|
||||||
|
|
||||||
|
export function runWebProbeRemoteArtifactJob(options: WebProbeRemoteArtifactJobOptions, userScript: string): WebProbeRemoteArtifactJobResult {
|
||||||
|
const runIdPrefix = safePathSegment(options.runIdPrefix ?? "web-probe-artifact");
|
||||||
|
const runId = `${runIdPrefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||||
|
const remoteDir = `/tmp/${runId}`;
|
||||||
|
const localDir = resolve(options.localDir);
|
||||||
|
const commandTimeoutMs = Math.max(1_000, options.commandTimeoutMs);
|
||||||
|
const pollIntervalMs = Math.max(250, options.pollIntervalMs ?? 2_000);
|
||||||
|
const inactivityTimeoutMs = options.inactivityTimeoutMs;
|
||||||
|
const keepRemote = options.keepRemote === true;
|
||||||
|
const submitCommand = [transPath(), options.route, "sh"];
|
||||||
|
const submit = runCommand(submitCommand, repoRoot, {
|
||||||
|
input: remoteArtifactSubmitScript(remoteDir, runId, userScript),
|
||||||
|
timeoutMs: Math.min(60_000, commandTimeoutMs),
|
||||||
|
});
|
||||||
|
const submitRecovery = submit.exitCode !== 0 && isRecoverableSubmitTimeout(submit)
|
||||||
|
? {
|
||||||
|
recovered: true,
|
||||||
|
reason: "submit-short-connection-timeout",
|
||||||
|
remoteDir,
|
||||||
|
runId,
|
||||||
|
exitCode: submit.exitCode,
|
||||||
|
stdoutTail: submit.stdout.slice(-1000),
|
||||||
|
stderrTail: submit.stderr.slice(-1000),
|
||||||
|
next: "submit may have been cut by the 60s trans runtime limit after the background job was launched; polling remote status by remoteDir/runId",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let manifest: RemoteWebProbeArtifactManifest | null = null;
|
||||||
|
let pollFailure: Record<string, unknown> | null = null;
|
||||||
|
let downloadFailure: Record<string, unknown> | null = null;
|
||||||
|
const commandResults: CommandResult[] = [submit];
|
||||||
|
if (submit.exitCode === 0 || submitRecovery !== null) {
|
||||||
|
const polled = pollRemoteManifest(options.route, remoteDir, runId, options.waitTimeoutMs, pollIntervalMs);
|
||||||
|
commandResults.push(...polled.results);
|
||||||
|
manifest = polled.manifest;
|
||||||
|
pollFailure = polled.failure;
|
||||||
|
} else {
|
||||||
|
pollFailure = {
|
||||||
|
phase: "submit",
|
||||||
|
message: "web-probe remote artifact submit failed",
|
||||||
|
exitCode: submit.exitCode,
|
||||||
|
stdoutTail: submit.stdout.slice(-1000),
|
||||||
|
stderrTail: submit.stderr.slice(-1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifacts: Array<Record<string, unknown>> = [];
|
||||||
|
if (manifest !== null && pollFailure === null) {
|
||||||
|
for (const artifact of manifest.artifacts) {
|
||||||
|
const localPath = join(localDir, `${runId}-${safePathSegment(basename(artifact.remotePath) || "artifact")}`);
|
||||||
|
const maxAttempts = 3;
|
||||||
|
let downloaded = false;
|
||||||
|
let lastDownload: CommandResult | null = null;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
const downloadArgs = [transPath(), options.route, "download"];
|
||||||
|
if (inactivityTimeoutMs !== undefined) downloadArgs.push("--inactivity-timeout-ms", String(inactivityTimeoutMs));
|
||||||
|
downloadArgs.push(artifact.remotePath, localPath);
|
||||||
|
const download = runCommand(downloadArgs, repoRoot, { timeoutMs: commandTimeoutMs });
|
||||||
|
commandResults.push(download);
|
||||||
|
lastDownload = download;
|
||||||
|
const parsed = parseJsonObject(download.stdout);
|
||||||
|
if (download.exitCode === 0 && parsed.ok === true && parsed.verified === true) {
|
||||||
|
artifacts.push({
|
||||||
|
remotePath: artifact.remotePath,
|
||||||
|
localPath,
|
||||||
|
bytes: numberOrNull(parsed.bytes) ?? artifact.bytes,
|
||||||
|
sha256: typeof parsed.sha256 === "string" ? parsed.sha256 : artifact.sha256,
|
||||||
|
verified: true,
|
||||||
|
verification: parsed.verification ?? null,
|
||||||
|
transfer: parsed.transfer ?? null,
|
||||||
|
manifestBytes: artifact.bytes,
|
||||||
|
manifestSha256: artifact.sha256,
|
||||||
|
});
|
||||||
|
downloaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (attempt < maxAttempts) sleepSync(750 * attempt);
|
||||||
|
}
|
||||||
|
if (!downloaded) {
|
||||||
|
downloadFailure = {
|
||||||
|
remotePath: artifact.remotePath,
|
||||||
|
attempts: maxAttempts,
|
||||||
|
exitCode: lastDownload?.exitCode ?? null,
|
||||||
|
stdoutTail: lastDownload?.stdout.slice(-1000) ?? "",
|
||||||
|
stderrTail: lastDownload?.stderr.slice(-1000) ?? "",
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = cleanupRemoteDir(options.route, remoteDir, keepRemote);
|
||||||
|
if (cleanup.result !== null) commandResults.push(cleanup.result);
|
||||||
|
const manifestExitCode = manifest?.exitCode ?? null;
|
||||||
|
const ok = pollFailure === null && manifest !== null && manifest.exitCode === 0 && downloadFailure === null;
|
||||||
|
const transport = {
|
||||||
|
ok,
|
||||||
|
command: "web-probe remote-artifact",
|
||||||
|
route: options.route,
|
||||||
|
runId,
|
||||||
|
localDir,
|
||||||
|
remote: {
|
||||||
|
exitCode: manifestExitCode,
|
||||||
|
remoteDir: manifest?.remoteDir ?? remoteDir,
|
||||||
|
stdoutPath: manifest?.stdoutPath ?? join(remoteDir, "stdout.log"),
|
||||||
|
stderrPath: manifest?.stderrPath ?? join(remoteDir, "stderr.log"),
|
||||||
|
stdoutTail: manifest?.stdoutTail ?? "",
|
||||||
|
stderrTail: manifest?.stderrTail ?? "",
|
||||||
|
defaultScreenshot: null,
|
||||||
|
},
|
||||||
|
artifacts,
|
||||||
|
artifactCount: artifacts.length,
|
||||||
|
expectedArtifactCount: manifest?.artifacts.length ?? 0,
|
||||||
|
cleanup: cleanup.payload,
|
||||||
|
pollFailure,
|
||||||
|
downloadFailure,
|
||||||
|
remoteCommand: {
|
||||||
|
exitCode: manifestExitCode,
|
||||||
|
submitExitCode: submit.exitCode,
|
||||||
|
submitRecovered: submitRecovery !== null,
|
||||||
|
submitRecovery,
|
||||||
|
submitStdoutBytes: Buffer.byteLength(submit.stdout, "utf8"),
|
||||||
|
submitStderrBytes: Buffer.byteLength(submit.stderr, "utf8"),
|
||||||
|
},
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
result: syntheticCommandResult(
|
||||||
|
[transPath(), options.route, "web-probe-remote-artifact"],
|
||||||
|
ok ? 0 : manifestExitCode ?? submit.exitCode ?? 1,
|
||||||
|
JSON.stringify(transport, null, 2),
|
||||||
|
commandResults,
|
||||||
|
),
|
||||||
|
transport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollRemoteManifest(
|
||||||
|
route: string,
|
||||||
|
remoteDir: string,
|
||||||
|
runId: string,
|
||||||
|
waitTimeoutMs: number,
|
||||||
|
pollIntervalMs: number,
|
||||||
|
): { manifest: RemoteWebProbeArtifactManifest | null; failure: Record<string, unknown> | null; results: CommandResult[] } {
|
||||||
|
const deadline = Date.now() + Math.max(1_000, waitTimeoutMs);
|
||||||
|
const results: CommandResult[] = [];
|
||||||
|
let lastStatus: CommandResult | null = null;
|
||||||
|
while (Date.now() <= deadline) {
|
||||||
|
const status = runCommand([transPath(), route, "sh", "--", remoteArtifactStatusScript(remoteDir)], repoRoot, { timeoutMs: Math.min(60_000, Math.max(5_000, waitTimeoutMs)) });
|
||||||
|
results.push(status);
|
||||||
|
lastStatus = status;
|
||||||
|
if (status.exitCode === 0 && status.stdout.includes(manifestEnd)) {
|
||||||
|
try {
|
||||||
|
return { manifest: parseRemoteManifest(status, remoteDir, runId), failure: null, results };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
manifest: null,
|
||||||
|
failure: {
|
||||||
|
phase: "parse-manifest",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stdoutTail: status.stdout.slice(-1000),
|
||||||
|
stderrTail: status.stderr.slice(-1000),
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.exitCode !== 0 && !/status\t(?:pending|running)/u.test(status.stdout)) {
|
||||||
|
return {
|
||||||
|
manifest: null,
|
||||||
|
failure: {
|
||||||
|
phase: "status",
|
||||||
|
exitCode: status.exitCode,
|
||||||
|
stdoutTail: status.stdout.slice(-1000),
|
||||||
|
stderrTail: status.stderr.slice(-1000),
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
sleepSync(pollIntervalMs);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
manifest: null,
|
||||||
|
failure: {
|
||||||
|
phase: "timeout",
|
||||||
|
message: `web-probe remote artifact timed out waiting for remote job after ${waitTimeoutMs}ms`,
|
||||||
|
remoteDir,
|
||||||
|
runId,
|
||||||
|
lastStdoutTail: lastStatus?.stdout.slice(-1000) ?? "",
|
||||||
|
lastStderrTail: lastStatus?.stderr.slice(-1000) ?? "",
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRemoteDir(route: string, remoteDir: string, keepRemote: boolean): { payload: Record<string, unknown>; result: CommandResult | null } {
|
||||||
|
if (keepRemote) return { payload: { attempted: false, kept: true, remoteDir }, result: null };
|
||||||
|
const result = runCommand([transPath(), route, "sh", "--", `rm -rf -- ${shellQuote(remoteDir)}`], repoRoot, { timeoutMs: 60_000 });
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
attempted: true,
|
||||||
|
kept: false,
|
||||||
|
remoteDir,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
ok: result.exitCode === 0,
|
||||||
|
stderrTail: result.stderr.slice(-1000),
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteArtifactSubmitScript(remoteDir: string, runId: string, userScript: string): string {
|
||||||
|
return [
|
||||||
|
"set -eu",
|
||||||
|
`remote_dir=${shellQuote(remoteDir)}`,
|
||||||
|
'mkdir -p -- "$remote_dir"',
|
||||||
|
'user_script="$remote_dir/user-script.sh"',
|
||||||
|
'runner_script="$remote_dir/runner.sh"',
|
||||||
|
'pid_file="$remote_dir/pid"',
|
||||||
|
'submit_stdout="$remote_dir/submit.out"',
|
||||||
|
'submit_stderr="$remote_dir/submit.err"',
|
||||||
|
'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then',
|
||||||
|
' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
||||||
|
" exit 0",
|
||||||
|
"fi",
|
||||||
|
writeBase64FileShell("$user_script", userScript, "UNIDESK_WEB_PROBE_ARTIFACT_USER_B64"),
|
||||||
|
"chmod 700 \"$user_script\"",
|
||||||
|
writeBase64FileShell("$runner_script", remoteArtifactRunnerScript(remoteDir, runId), "UNIDESK_WEB_PROBE_ARTIFACT_RUNNER_B64"),
|
||||||
|
"chmod 700 \"$runner_script\"",
|
||||||
|
'nohup sh "$runner_script" "$user_script" >"$submit_stdout" 2>"$submit_stderr" < /dev/null &',
|
||||||
|
'pid=$!',
|
||||||
|
'printf "%s\\n" "$pid" >"$pid_file"',
|
||||||
|
'printf "status\\tsubmitted\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$pid"',
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteArtifactStatusScript(remoteDir: string): string {
|
||||||
|
return [
|
||||||
|
"set -eu",
|
||||||
|
`remote_dir=${shellQuote(remoteDir)}`,
|
||||||
|
'manifest="$remote_dir/manifest.tsv"',
|
||||||
|
'pid_file="$remote_dir/pid"',
|
||||||
|
'if [ -f "$manifest" ]; then cat "$manifest"; exit 0; fi',
|
||||||
|
'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then',
|
||||||
|
' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
||||||
|
" exit 1",
|
||||||
|
"fi",
|
||||||
|
'if [ -f "$pid_file" ]; then',
|
||||||
|
' printf "status\\texited-without-manifest\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"',
|
||||||
|
' if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi',
|
||||||
|
' if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi',
|
||||||
|
" exit 2",
|
||||||
|
"fi",
|
||||||
|
'printf "status\\tpending\\nremote_dir\\t%s\\n" "$remote_dir"',
|
||||||
|
'if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi',
|
||||||
|
'if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi',
|
||||||
|
"exit 1",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteArtifactRunnerScript(remoteDir: string, runId: string): string {
|
||||||
|
return [
|
||||||
|
"set -eu",
|
||||||
|
`remote_dir=${shellQuote(remoteDir)}`,
|
||||||
|
`run_id=${shellQuote(runId)}`,
|
||||||
|
'if [ "$#" -lt 1 ]; then printf "missing user script path\\n" >&2; exit 2; fi',
|
||||||
|
'user_script="$1"',
|
||||||
|
'stdout_file="$remote_dir/stdout.log"',
|
||||||
|
'stderr_file="$remote_dir/stderr.log"',
|
||||||
|
'artifacts_file="$remote_dir/artifacts.tsv"',
|
||||||
|
'manifest_file="$remote_dir/manifest.tsv"',
|
||||||
|
"sha256_file() {",
|
||||||
|
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
||||||
|
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
||||||
|
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
||||||
|
" printf 'missing sha256 tool\\n' >&2; return 127",
|
||||||
|
"}",
|
||||||
|
'export UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR="$remote_dir"',
|
||||||
|
"set +e",
|
||||||
|
'sh "$user_script" >"$stdout_file" 2>"$stderr_file"',
|
||||||
|
"user_rc=$?",
|
||||||
|
': > "$artifacts_file"',
|
||||||
|
'find "$remote_dir" -type f | while IFS= read -r file; do',
|
||||||
|
' case "$file" in "$user_script"|"$stdout_file"|"$stderr_file"|"$artifacts_file"|"$manifest_file"|"$remote_dir/runner.sh"|"$remote_dir/submit.out"|"$remote_dir/submit.err"|"$remote_dir/pid") continue ;; esac',
|
||||||
|
' case "$file" in *.png|*.jpg|*.jpeg|*.webp)',
|
||||||
|
' bytes=$(wc -c < "$file" | tr -d "[:space:]")',
|
||||||
|
' digest=$(sha256_file "$file")',
|
||||||
|
' printf "artifact\\t%s\\t%s\\t%s\\n" "$bytes" "$digest" "$file" >> "$artifacts_file"',
|
||||||
|
" ;;",
|
||||||
|
" esac",
|
||||||
|
"done",
|
||||||
|
"b64_tail() { if [ -f \"$1\" ]; then tail -c 4000 \"$1\" | base64 | tr -d '\\n'; fi; }",
|
||||||
|
"{",
|
||||||
|
`printf '%s\\n' ${shellQuote(manifestBegin)}`,
|
||||||
|
"printf 'run_id\\t%s\\n' \"$run_id\"",
|
||||||
|
"printf 'exit_code\\t%s\\n' \"$user_rc\"",
|
||||||
|
"printf 'remote_dir\\t%s\\n' \"$remote_dir\"",
|
||||||
|
"printf 'stdout_path\\t%s\\n' \"$stdout_file\"",
|
||||||
|
"printf 'stderr_path\\t%s\\n' \"$stderr_file\"",
|
||||||
|
"printf 'stdout_tail_b64\\t%s\\n' \"$(b64_tail \"$stdout_file\")\"",
|
||||||
|
"printf 'stderr_tail_b64\\t%s\\n' \"$(b64_tail \"$stderr_file\")\"",
|
||||||
|
'cat "$artifacts_file"',
|
||||||
|
`printf '%s\\n' ${shellQuote(manifestEnd)}`,
|
||||||
|
"} > \"$manifest_file\"",
|
||||||
|
'exit "$user_rc"',
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRemoteManifest(result: CommandResult, fallbackRemoteDir: string, fallbackRunId: string): RemoteWebProbeArtifactManifest {
|
||||||
|
const start = result.stdout.indexOf(manifestBegin);
|
||||||
|
const end = result.stdout.indexOf(manifestEnd, start < 0 ? 0 : start);
|
||||||
|
if (start < 0 || end < 0) {
|
||||||
|
throw new Error(`web-probe remote artifact did not emit a manifest; exitCode=${result.exitCode}; stdoutTail=${JSON.stringify(result.stdout.slice(-1000))}; stderrTail=${JSON.stringify(result.stderr.slice(-1000))}`);
|
||||||
|
}
|
||||||
|
const body = result.stdout.slice(start + manifestBegin.length, end).trim();
|
||||||
|
const manifest: RemoteWebProbeArtifactManifest = {
|
||||||
|
runId: fallbackRunId,
|
||||||
|
exitCode: result.exitCode ?? 1,
|
||||||
|
remoteDir: fallbackRemoteDir,
|
||||||
|
stdoutPath: join(fallbackRemoteDir, "stdout.log"),
|
||||||
|
stderrPath: join(fallbackRemoteDir, "stderr.log"),
|
||||||
|
stdoutTail: "",
|
||||||
|
stderrTail: "",
|
||||||
|
artifacts: [],
|
||||||
|
};
|
||||||
|
for (const rawLine of body.split(/\r?\n/u)) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (line.length === 0) continue;
|
||||||
|
const parts = line.split("\t");
|
||||||
|
const key = parts[0] ?? "";
|
||||||
|
if (key === "artifact") {
|
||||||
|
const bytes = Number(parts[1] ?? "");
|
||||||
|
const sha256 = parts[2] ?? "";
|
||||||
|
const remotePath = parts.slice(3).join("\t");
|
||||||
|
if (remotePath.length > 0) {
|
||||||
|
manifest.artifacts.push({
|
||||||
|
remotePath,
|
||||||
|
bytes: Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : null,
|
||||||
|
sha256: /^[0-9a-f]{64}$/u.test(sha256) ? sha256 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = parts.slice(1).join("\t");
|
||||||
|
if (key === "run_id" && value.length > 0) manifest.runId = value;
|
||||||
|
else if (key === "exit_code") {
|
||||||
|
const exitCode = Number(value);
|
||||||
|
manifest.exitCode = Number.isInteger(exitCode) ? exitCode : result.exitCode ?? 1;
|
||||||
|
} else if (key === "remote_dir" && value.length > 0) manifest.remoteDir = value;
|
||||||
|
else if (key === "stdout_path" && value.length > 0) manifest.stdoutPath = value;
|
||||||
|
else if (key === "stderr_path" && value.length > 0) manifest.stderrPath = value;
|
||||||
|
else if (key === "stdout_tail_b64") manifest.stdoutTail = decodeBase64(value);
|
||||||
|
else if (key === "stderr_tail_b64") manifest.stderrTail = decodeBase64(value);
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticCommandResult(command: string[], exitCode: number | null, stdout: string, results: readonly CommandResult[]): CommandResult {
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
cwd: repoRoot,
|
||||||
|
exitCode,
|
||||||
|
stdout,
|
||||||
|
stderr: results.map((item) => item.stderr).filter((item) => item.length > 0).join("\n").slice(-8000),
|
||||||
|
signal: null,
|
||||||
|
timedOut: results.some((item) => item.timedOut),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBase64FileShell(pathExpression: string, content: string, marker: string): string {
|
||||||
|
return [
|
||||||
|
`cat > ${pathExpression}.b64 <<'${marker}'`,
|
||||||
|
wrapBase64(Buffer.from(content, "utf8").toString("base64")),
|
||||||
|
marker,
|
||||||
|
`base64 -d < ${pathExpression}.b64 > ${pathExpression}`,
|
||||||
|
`rm -f -- ${pathExpression}.b64`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapBase64(value: string): string {
|
||||||
|
return value.replace(/.{1,76}/gu, "$&\n").trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length === 0) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(value: string): string {
|
||||||
|
if (value.length === 0) return "";
|
||||||
|
try {
|
||||||
|
return Buffer.from(value, "base64").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableSubmitTimeout(submit: CommandResult): boolean {
|
||||||
|
if (submit.exitCode === 124 || submit.timedOut) return true;
|
||||||
|
const combined = `${submit.stdout}\n${submit.stderr}`;
|
||||||
|
return /(?:timed?\s*out|timeout|60s|exitCode=124|signal\s+TERM|signal\s+KILL)/iu.test(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepSync(ms: number): void {
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePathSegment(value: string): string {
|
||||||
|
const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
return cleaned.length > 0 ? cleaned.slice(0, 120) : "artifact";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transPath(): string {
|
||||||
|
return join(repoRoot, "scripts", "trans");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user