From b9c86f4d3fc72ddbcbbe1433dc48c1b6ef4d66b7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:34:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=20CI/CD=20?= =?UTF-8?q?=E9=95=BF=E8=BD=AE=E8=AF=A2=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/cli-output-progressive-disclosure.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/cli-output-progressive-disclosure.md b/docs/MDTODO/cli-output-progressive-disclosure.md index b3a1583c..ef9e6caa 100644 --- a/docs/MDTODO/cli-output-progressive-disclosure.md +++ b/docs/MDTODO/cli-output-progressive-disclosure.md @@ -142,3 +142,7 @@ ## R10 [completed] 解决 [UniDesk #1656](https://github.com/pikasTech/unidesk/issues/1656):由 Artificer 在独立 `.worktree/cicd-status-visibility`、`fix/cicd-status-visibility` 分支修复 `cicd status --node` 成功零输出,并统一 PaC consumer 的 registry applicability;GitOps-only/publisher-only 应显示 N/A 且由 PipelineRun、GitOps/Argo 与 runtime 证据闭环,只有 owning YAML 明确声明 image/registry 的 consumer 才把 registry missing 作为 blocker。保持默认输出有界、text/JSON/YAML 同构、缺失可选 YAML 事实降级为 warning,不新增严格合同或手工交付入口;补针对性测试和 `unidesk-cicd` 说明,提交独立 PR 且不自行合并,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R10_Task_Report.md)。 + +## R11 [in_progress] + +解决 [UniDesk #2104](https://github.com/pikasTech/unidesk/issues/2104):把 CLI-SPEC 的只读观测等待规则和 `unidesk-cicd` 受控观测入口统一为默认 `120s` 长轮询,目标完成或状态满足时立即返回,未完成等待至超时并明确返回当前状态、timedOut 与稳定继续入口;禁止 CLI 本地高频短轮询、忙轮询和无输出,mutation 与交付 authority 保持 fire-and-forget,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R11_Task_Report.md)。 From bc59518bf5ed971c1c952e588c370162142b5c6a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:29:09 +0200 Subject: [PATCH 2/3] docs(pikaoa): close production database backup --- .../R1.6.1.1_Task_Report.md | 53 ++++++------------- docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md index d8033862..66fd6a8e 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md @@ -2,48 +2,27 @@ ## 结论 -已实现 pikasTech/unidesk#2115,并提交 PR #2118。PK01 PostgreSQL 逻辑备份已从单个 Sub2API 特例正规化为 YAML-first `logicalDumps[]`;保留 Sub2API 既有 03:17 schedule、14 天 retention、目录与 systemd 资源语义,新增 PikaOA 独立 03:37 schedule、14 天 retention、脚本、service、timer 和目录。 - -任务保持 `in_progress`。本次按派单边界未执行真实 `apply` 或手动 dump,等待主代理审核后完成运行面 mutation 与验收。 +PikaOA 生产 PostgreSQL 逻辑备份已完成实现、合并和 PK01 真实运行面验收。PR pikasTech/unidesk#2118 已合并为 `2ce5b65f9510840b1fcd01e9327b1469654c481f`。PK01 现同时运行 Sub2API 与 PikaOA 两套 YAML-first 独立逻辑备份;PikaOA 首次手动备份已生成非空 dump,timer、最近成功、可恢复性均正常,warning 为空。 ## 变更 -- `config/platform-db/postgres-pk01.yaml`:每项完整声明 database、scriptPath、serviceName、timerName、目录、schedule、retention、warningAfterHours、pgDump 参数和 encryption 状态。 -- `scripts/src/platform-db.ts`:按 YAML 解析、校验、生成远端 payload,动态渲染全部 dump 脚本与 systemd unit;删除 Sub2API 专用路径、unit、timer schedule 硬编码。 -- `scripts/src/platform-db-postgres-backups.ts`:集中生成每项备份的声明、timer、最近成功、最新非空 dump、可恢复性和 warning 投影。 -- `scripts/src/platform-db-postgres-backups.test.ts`:覆盖配置解析、renderer 去硬编码和 warning 非阻塞语义。 +- `config/platform-db/postgres-pk01.yaml`:以 `logicalDumps[]` 完整声明 database、脚本、service、timer、目录、schedule、retention、warningAfterHours、pgDump 参数和 encryption 状态。 +- `scripts/src/platform-db.ts`:按 YAML 解析、校验、采集状态和动态渲染远端脚本与 systemd unit,删除 Sub2API 专用路径、unit 与 schedule 硬编码。 +- `scripts/src/platform-db-postgres-backups.ts`:集中投影每项备份的 timer、最近成功、最新非空 dump、可恢复性和非阻塞 warning。 +- `scripts/src/platform-db-postgres-backups.test.ts`:覆盖双数据库解析、renderer 去硬编码和 warning 非阻塞语义。 -## Primary Workspace 轻量准备 +## 验证 -Primary workspace 仅用于读取 `dad-dev`、`unidesk-ops`、`unidesk-ymalops`、`cli-spec`、`git-spec`、`unidesk-trans`、`unidesk-gh`、`mdtodo-edit` 与 `post-task` 技能;未在 runner 本地读取、编辑或验证目标源码,未把 runner 容器结果作为 Target 证据。 - -## Target 原入口证据 - -全部源码、Git、测试和运行面命令均通过 `trans NC01:/root/unidesk/.worktree/pikaoa-production-backup ...` 执行。 - -- `bun --check scripts/src/platform-db-postgres-backups.ts`:通过。 -- `bun --check scripts/src/platform-db.ts`:通过。 +- `bun --check`、远端 runner `sh -n` 和 `git diff --check` 通过。 - `bun test scripts/src/platform-db-postgres-backups.test.ts`:3 pass,0 fail。 -- 生成的远端 apply runner 经 `sh -n`:通过。 -- `git diff --check`:通过。 -- 只读 `platform-db postgres plan`:`ok=true`、`mutation=false`;Sub2API timer active/enabled,最近成功为 2026-07-15 03:17:01 CST,最新非空 dump 为 90,798,991 bytes,可恢复性为 true;PikaOA 未 apply,脚本/timer/最近成功/latest dump 缺失均只形成 warning。 -- 只读 `platform-db postgres status`:`ok=true`、`mutation=false`、`failure=null`;备份 warning 不阻塞 PikaOA MVP。 +- 合并前后受控 `platform-db postgres plan/status`:`ok=true`、`mutation=false`、`failure=null`,Secret 未输出。 +- 受控 apply job `platform_db_postgres_apply_20260715042722566_116d8b`:32 秒完成,exit 0;远端 job `pk01-pg16-20260715122724-1180092` 到达 `succeeded/complete`。 +- 通过受控 `trans PK01 sh` 手动启动 `unidesk-pk01-pikaoa-pgdump.service`:Result=`success`,ExecMainStatus=`0`。 +- 最终 PikaOA 备份状态:timer active/enabled,下一次执行为 2026-07-16 03:37:00 CST,最近成功为 2026-07-15 12:28:18 CST,最新 dump `pikaoa-20260715T122818.dump` 为 863 bytes,`recoverable=true`,warnings 为空。 +- Sub2API 既有备份保持 healthy,最近非空 dump 为 90,798,991 bytes,`recoverable=true`。 +- PostgreSQL 主服务、TLS、9 个 role/database、11 个 Secret export 与应用连接在 apply 后继续 healthy。 -## Git 与 PR +## 交付边界 -- 分支:`feat/pikaoa-production-backup`。 -- 最新 master 已语义合入:merge commit `3086ea8d`。 -- 功能提交:`c483d6d508f5a9501f211d2b7bffd16a04494483`。 -- PR:pikasTech/unidesk#2118,关联 pikasTech/unidesk#2115,未合并。 - -## 风险与下一步 - -- PikaOA 的 systemd 资源与 dump 文件在 apply 前不存在,当前 warning 与预期一致。 -- 主代理审核后执行: - 1. `bun scripts/cli.ts platform-db postgres plan --config config/platform-db/postgres-pk01.yaml` - 2. `bun scripts/cli.ts platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm` - 3. 按异步 job 返回的 `statusCommand` 短轮询至完成。 - 4. `bun scripts/cli.ts platform-db postgres status --config config/platform-db/postgres-pk01.yaml` - 5. `sudo systemctl start unidesk-pk01-pikaoa-pgdump.service` - 6. 再次执行 status,确认 PikaOA timer active/enabled、最近成功存在、最新非空 dump 大于 0 bytes、recoverable=true 且 warnings 为空。 -- 上述 mutation 与手动 dump 验收完成后,再将 MDTODO R1.6.1.1 标记 complete。 +- 当前备份目标为 PK01 本地目录,满足本阶段数据库逻辑备份;附件 off-node 备份仍属于 R1.6.1 生产上线任务,不由本子任务冒充完成。 +- 当前开发期不实现历史数据迁移、兼容升级、恢复编排或回滚框架。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index cb7a7987..4c013294 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -139,7 +139,7 @@ #### R1.6.1 [in_progress] 建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,部署 NC01 pikaoa namespace,接入 PK01 host PostgreSQL、附件持久化、OTel/Prometheus、备份与 oa.pikapython.com TLS 公网入口(pikasTech/unidesk#2110),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1_Task_Report.md)。 -##### R1.6.1.1 [in_progress] +##### R1.6.1.1 [completed] 将 PK01 PostgreSQL 逻辑备份正规化为 YAML-first 多数据库 logicalDumps,保留 Sub2API 并新增 PikaOA 独立 dump、timer、retention 与受控状态/可恢复性摘要(pikasTech/unidesk#2115),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md)。 ### R1.7 From 81163ab236c5d919a03e71a61efdfa148ba3bd44 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:35:25 +0200 Subject: [PATCH 3/3] feat: add bounded cicd long polling --- .agents/skills/unidesk-cicd/SKILL.md | 8 + .../cli-output-progressive-disclosure.md | 2 +- .../R11_Task_Report.md | 36 +++++ docs/reference/cli.md | 10 ++ scripts/src/ci/pipelinerun-runtime.ts | 18 ++- scripts/src/kubernetes-watch.test.ts | 29 ++++ scripts/src/kubernetes-watch.ts | 17 +++ scripts/src/platform-infra-ops-library.ts | 4 +- .../src/platform-infra-pipelines-as-code.ts | 142 ++++++++++++------ scripts/src/read-only-long-poll.test.ts | 53 +++++++ scripts/src/read-only-long-poll.ts | 63 ++++++++ scripts/src/remote-ssh.ts | 7 +- scripts/src/ssh-runtime.ts | 23 ++- scripts/src/ssh.test.ts | 9 ++ scripts/src/ssh.ts | 1 + 15 files changed, 363 insertions(+), 59 deletions(-) create mode 100644 docs/MDTODO/details/cli-output-progressive-disclosure/R11_Task_Report.md create mode 100644 scripts/src/kubernetes-watch.test.ts create mode 100644 scripts/src/kubernetes-watch.ts create mode 100644 scripts/src/read-only-long-poll.test.ts create mode 100644 scripts/src/read-only-long-poll.ts diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 9f50053e..bde8c7b7 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -33,6 +33,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consu bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10 bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --wait --timeout 120s bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer --id bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer --source-worktree @@ -46,6 +47,13 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 单 consumer 再用 `platform-infra pipelines-as-code status --target --consumer ` 下钻; - 状态输出与 registry applicability 的长期判定见 [references/gitea-pac.md](references/gitea-pac.md)。 +- CI/CD 只读等待: + - 默认使用 `closeout --wait --timeout 120s` 观察 Tekton、Argo 和 runtime 收敛;已处于成功或失败终态时立即返回。 + - 未完成时由目标控制面的 Kubernetes watch 持有单次等待,不得在调用侧每 `1s`、`5s` 重复执行 `status`。 + - timeout 必须保留当前状态、`timedOut=true`、耗时、稳定 identity/cursor 与 `next.wait`;零变化也必须有输出。 + - `--timeout` 只接受严格 `<正整数>ms|s|m`,测试使用短 timeout 或虚拟时钟,不真实等待 `120s`。 + - watch 断连或后端错误必须显式失败,不能折叠为 timeout;该入口固定 `mutation=false`,不触发 PipelineRun、Argo refresh、GitOps 或 rollout。 + - SELFMEDIA 最新一次端到端耗时: - 唯一推荐入口是 `platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01`; - 一次调用直接披露 GitHub PR `mergedAt`、trigger wait、PipelineRun/TaskRun、端到端耗时、Argo、runtime health 和 `evidenceGaps`; diff --git a/docs/MDTODO/cli-output-progressive-disclosure.md b/docs/MDTODO/cli-output-progressive-disclosure.md index ef9e6caa..a1fb649c 100644 --- a/docs/MDTODO/cli-output-progressive-disclosure.md +++ b/docs/MDTODO/cli-output-progressive-disclosure.md @@ -143,6 +143,6 @@ 解决 [UniDesk #1656](https://github.com/pikasTech/unidesk/issues/1656):由 Artificer 在独立 `.worktree/cicd-status-visibility`、`fix/cicd-status-visibility` 分支修复 `cicd status --node` 成功零输出,并统一 PaC consumer 的 registry applicability;GitOps-only/publisher-only 应显示 N/A 且由 PipelineRun、GitOps/Argo 与 runtime 证据闭环,只有 owning YAML 明确声明 image/registry 的 consumer 才把 registry missing 作为 blocker。保持默认输出有界、text/JSON/YAML 同构、缺失可选 YAML 事实降级为 warning,不新增严格合同或手工交付入口;补针对性测试和 `unidesk-cicd` 说明,提交独立 PR 且不自行合并,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R10_Task_Report.md)。 -## R11 [in_progress] +## R11 [completed] 解决 [UniDesk #2104](https://github.com/pikasTech/unidesk/issues/2104):把 CLI-SPEC 的只读观测等待规则和 `unidesk-cicd` 受控观测入口统一为默认 `120s` 长轮询,目标完成或状态满足时立即返回,未完成等待至超时并明确返回当前状态、timedOut 与稳定继续入口;禁止 CLI 本地高频短轮询、忙轮询和无输出,mutation 与交付 authority 保持 fire-and-forget,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R11_Task_Report.md)。 diff --git a/docs/MDTODO/details/cli-output-progressive-disclosure/R11_Task_Report.md b/docs/MDTODO/details/cli-output-progressive-disclosure/R11_Task_Report.md new file mode 100644 index 00000000..8eff479a --- /dev/null +++ b/docs/MDTODO/details/cli-output-progressive-disclosure/R11_Task_Report.md @@ -0,0 +1,36 @@ +# R11 任务报告 + +## 任务上下文 + +- MDTODO:R11。 +- GitHub issue:`pikasTech/unidesk#2104`。 +- Target:NC01。 +- targetWorkspace:`/root/unidesk/.worktree/cicd-long-poll-observe`。 +- repository:`pikasTech/unidesk`。 +- ref:`feat/cicd-long-poll-observe`。 + +## 完成内容 + +- 新增仓库权威 `cli-spec` skill,明确只读观测默认 `120s` 长轮询、严格 duration、终态立即返回和 timeout 可见性。 +- 新增通用 `read-only-long-poll` 状态机,支持稳定 cursor、虚拟时钟、错误透传和取消语义。 +- 将 PaC `closeout --wait` 从 CLI 本地每 `3s` 重复全量 status 改为目标控制面 Kubernetes watch 驱动。 +- PipelineRun 与 PaC 共用同一 watch 判定:空成功为正常 timeout、`141` 为首事件后的 SIGPIPE,其他非零保留认证、网络或 API 错误。 +- 新增 `--timeout 120s`,timeout 输出当前状态、`timedOut`、`elapsed`、identity/cursor 和 `next.wait`。 +- capture 使用显式调用级 transport timeout,并保留用户 `60m` timeout 所需的固定余量,不依赖全局 env。 +- 更新 `unidesk-cicd`、CLI help 和 `docs/reference/cli.md`,明确禁止调用侧 `1s`、`5s` 高频轮询。 +- 权威 `cli-spec` 更新位于 `pikasTech/agent_skills#12`,UniDesk 仓内未保留重复 skill。 +- 未修改 AgentRun events 或 `unidesk-code-queue`,未改变 CI/CD、GitOps、Tekton 或 Argo authority。 + +## 验证 + +- `bun test scripts/src/read-only-long-poll.test.ts`。 +- `bun test scripts/src/kubernetes-watch.test.ts scripts/src/read-only-long-poll.test.ts scripts/src/ssh.test.ts`,共 38 项通过。 +- `bun test scripts/src/cicd-pr-merge-auto-delivery-doc-contract.test.ts scripts/src/cicd-node-status.test.ts`。 +- `bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics`。 +- 非法 `--timeout 1.5s` 被严格拒绝。 + +## 证据边界 + +- primary workspace 只用于读取 resource bundle skills 与执行 `trans` wrapper。 +- 源码读取、修改、测试和 Git 操作全部在 Target route `NC01:/root/unidesk/.worktree/cicd-long-poll-observe` 完成。 +- 本任务不部署、不触发 PipelineRun、不执行 Argo refresh,也不手工触发 CI/CD。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 88c93a18..6739c17e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,5 +1,15 @@ # UniDesk CLI Reference +## 只读观测长轮询 + +- mutation、构建、发布和部署触发保持 fire-and-forget;长轮询只属于只读观测,不改变执行 authority。 +- 只读等待默认 timeout 为 `120s`,显式覆盖只接受 `<正整数>ms|s|m`,最大 `60m`。 +- 后端 API、Kubernetes watch、事件流或原生通知负责持有等待;CLI 调用侧禁止 `1s`、`5s` 高频短轮询、忙轮询和重复全量扫描。 +- 成功、失败、取消或满足条件时立即返回;未完成时保持单次等待到 timeout。 +- timeout 和零变化必须返回当前状态、`timedOut=true`、`elapsed`、稳定 identity/cursor 与继续等待命令。 +- watch 断连和后端错误必须可见并保持原错误语义,不能伪装成 timeout、空变化或成功。 +- CI/CD 通用入口是 `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --wait --timeout 120s`。 + UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts `;普通根 CLI 子命令仍使用该入口。`trans ...` 是 SSH/WSL/k3s 透传专用入口,wrapper 委托轻量 `scripts/ssh-cli.ts` 启动链路,避免被无关根 CLI 子命令模块的解析或语法错误拖垮;长期参考文档、AGENTS 索引、CLI help 和人工远端操作示例都必须优先写 `trans ...`,不得再把 `bun scripts/cli.ts ssh ...` 作为默认透传入口。面向人工的默认输出采用 Kubernetes 风格的简洁表格、短摘要和 drill-down 命令;JSON 只用于 `--json`、`--raw`、`--full`、`-o json` 或其他明确机器消费模式。所有成功和失败路径仍必须给出可判定状态,避免无输出造成不可观测。 - 高频 CLI 默认输出与帮助输出是直接阅读入口: diff --git a/scripts/src/ci/pipelinerun-runtime.ts b/scripts/src/ci/pipelinerun-runtime.ts index 5f362d3b..89bcdb23 100644 --- a/scripts/src/ci/pipelinerun-runtime.ts +++ b/scripts/src/ci/pipelinerun-runtime.ts @@ -20,6 +20,7 @@ import { } from "../artifact-registry"; import { k3sGuardShellLines, defaultNativeKubeconfig } from "../k3s-target-guard"; import { runSshCommandCapture } from "../ssh"; +import { kubernetesWatchOneEventShellFunction } from "../kubernetes-watch"; import type { DispatchResult, PipelineRunCondition } from "./types"; import { status } from "./cleanup"; @@ -48,10 +49,11 @@ export async function waitForPipelineRun(name: string, waitMs: number, target = const command = [ "set -euo pipefail", ...ciTargetGuardShellLines(target), - `printf 'waiting_pipelinerun=%s\\n' ${shellQuote(name)}`, + kubernetesWatchOneEventShellFunction(), `deadline=$((SECONDS + ${Math.ceil(waitMs / 1000)}))`, - "while [ \"$SECONDS\" -lt \"$deadline\" ]; do", - ` condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`, + `printf 'waiting_pipelinerun=%s\\n' ${shellQuote(name)}`, + `condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`, + "while :; do", " case \"$condition\" in", " True*)", " printf 'condition=%s\\n' \"$condition\"", @@ -64,7 +66,15 @@ export async function waitForPipelineRun(name: string, waitMs: number, target = " exit 1", " ;;", " esac", - " sleep 2", + " remaining=$((deadline - SECONDS))", + " [ \"$remaining\" -gt 0 ] || break", + " set +e", + ` event="$(unidesk_watch_one_event kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci --watch-only --request-timeout="\${remaining}s" -o name)"`, + " watch_status=$?", + " set -e", + " [ \"$watch_status\" -eq 0 ] || exit \"$watch_status\"", + " [ -n \"$event\" ] || break", + ` condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`, "done", `echo "Timed out waiting for pipelinerun/${name}" >&2`, `kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`, diff --git a/scripts/src/kubernetes-watch.test.ts b/scripts/src/kubernetes-watch.test.ts new file mode 100644 index 00000000..9858e28d --- /dev/null +++ b/scripts/src/kubernetes-watch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { kubernetesWatchOneEventShellFunction } from "./kubernetes-watch"; + +function runWatch(command: string): { exitCode: number; stdout: string; stderr: string } { + const script = `set -euo pipefail\n${kubernetesWatchOneEventShellFunction()}\nunidesk_watch_one_event bash -c ${JSON.stringify(command)}`; + const result = Bun.spawnSync(["bash", "-c", script], { stdout: "pipe", stderr: "pipe" }); + return { exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() }; +} + +describe("kubernetes watch one event shell helper", () => { + test("returns the first change", () => { + expect(runWatch("printf 'pipelinerun/run-1\\n'")).toEqual({ exitCode: 0, stdout: "pipelinerun/run-1\n", stderr: "" }); + }); + + test("treats an empty successful request-timeout as no change", () => { + expect(runWatch("exit 0")).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + }); + + test("accepts producer SIGPIPE after the first event", () => { + const result = runWatch("trap 'exit 141' PIPE; while :; do printf 'event\\n' || exit 141; done"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("event\n"); + }); + + test("preserves authentication and API failures", () => { + const result = runWatch("echo 'Unauthorized' >&2; exit 7"); + expect(result).toEqual({ exitCode: 7, stdout: "", stderr: "Unauthorized\n" }); + }); +}); diff --git a/scripts/src/kubernetes-watch.ts b/scripts/src/kubernetes-watch.ts new file mode 100644 index 00000000..a0cb6fbf --- /dev/null +++ b/scripts/src/kubernetes-watch.ts @@ -0,0 +1,17 @@ +export function kubernetesWatchOneEventShellFunction(): string { + return [ + "unidesk_watch_one_event() {", + " event_file=$(mktemp)", + " error_file=$(mktemp)", + " set +e", + " \"$@\" 2>\"$error_file\" | head -n 1 >\"$event_file\"", + " statuses=(\"${PIPESTATUS[@]}\")", + " set -e", + " watch_status=${statuses[0]:-255}", + " case \"$watch_status\" in", + " 0|141) cat \"$event_file\"; rm -f \"$event_file\" \"$error_file\"; return 0 ;;", + " *) cat \"$error_file\" >&2; rm -f \"$event_file\" \"$error_file\"; return \"$watch_status\" ;;", + " esac", + "}", + ].join("\n"); +} diff --git a/scripts/src/platform-infra-ops-library.ts b/scripts/src/platform-infra-ops-library.ts index eb69f114..c9daa8e9 100644 --- a/scripts/src/platform-infra-ops-library.ts +++ b/scripts/src/platform-infra-ops-library.ts @@ -25,8 +25,8 @@ export interface OpsCommandOptionSpec { flagOptions?: string[]; } -export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise { - return await runSshCommandCapture(config, route, args, stdin); +export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string, options: { runtimeTimeoutMs?: number } = {}): Promise { + return await runSshCommandCapture(config, route, args, stdin, options); } export function parseJsonOutput(stdout: string): Record | null { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index df4e35a9..d3a1f8cc 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -14,7 +14,9 @@ import { sha256Fingerprint, } from "./platform-infra-ops-library"; import { materializeYamlComposition } from "./yaml-composition"; +import { kubernetesWatchOneEventShellFunction } from "./kubernetes-watch"; import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority"; +import { DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS, parseStrictDuration, readOnlyLongPollTransportTimeoutMs, runReadOnlyLongPoll } from "./read-only-long-poll"; import { canonicalizePacPipelineSpec, pacSourceArtifactSafeError, @@ -250,6 +252,7 @@ export interface PacHistoryConsumerSelection { interface CloseoutOptions extends CommonOptions { sourceCommit: string | null; wait: boolean; + timeoutMs: number; } interface ApplyOptions extends CommonOptions { @@ -291,7 +294,20 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf return options.full || options.raw ? result : options.json ? compactStatusJson(result) : renderStatus(result); } if (action === "closeout") { - const options = parseCloseoutOptions(args.slice(1)); + let options: CloseoutOptions; + try { + options = parseCloseoutOptions(args.slice(1)); + } catch (error) { + return { + ok: false, + action: "platform-infra-pipelines-as-code-closeout-validation", + mutation: false, + code: "validation-failed", + message: error instanceof Error ? error.message : String(error), + usage: "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --wait [--timeout 120s]", + valuesPrinted: false, + }; + } const result = await closeout(config, options); return options.full || options.raw ? result : options.json ? compactCloseoutJson(result) : renderCloseout(result); } @@ -421,7 +437,7 @@ function help(scope: string | null): Record { scope: "explicit-readonly-or-connectivity-diagnostics", configTruth: configLabel, usage: [ - "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --source-commit --wait", + "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --source-commit --wait [--timeout 120s]", "bun scripts/cli.ts platform-infra pipelines-as-code status --target --consumer --full", "bun scripts/cli.ts platform-infra pipelines-as-code history --target --consumer --limit 10 --full", "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target --consumer --json", @@ -1398,22 +1414,25 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const startedAt = Date.now(); - const waitMs = Math.max(1, pac.release.waitTimeoutSeconds) * 1000; - let ciAttempts = 0; - let runtimeAttempts = 0; - let current = await observeCloseoutStatus(config, options, target, consumer, "ci", 1, startedAt); - ciAttempts += 1; - while (options.wait && !closeoutCiReady(current, options.sourceCommit) && Date.now() - startedAt < waitMs) { - await sleep(3000); - current = await observeCloseoutStatus(config, options, target, consumer, "ci", ciAttempts + 1, startedAt); - ciAttempts += 1; - } + let current: Record = {}; + let phase: "ci" | "runtime" = "ci"; + const waitResult = await runReadOnlyLongPoll({ + timeoutMs: options.wait ? options.timeoutMs : 1, + observe: async () => { + current = await observeCloseoutStatus(config, options, target, consumer, phase, 1, startedAt); + phase = closeoutCiReady(current, options.sourceCommit) ? "runtime" : "ci"; + return current; + }, + completed: (value) => !options.wait || closeoutWaitComplete(value, options.sourceCommit), + waitForChange: async (remainingMs) => await waitForCloseoutChange(config, target, consumer, phase, remainingMs), + cursor: closeoutCursor, + }); + current = waitResult.value; let summary = record(current.summary); let latest = record(summary.latestPipelineRun); let diagnostics = record(summary.diagnostics); let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit; - const ciReady = closeoutCiReady(current, options.sourceCommit); let gitOpsMirrorFlush: Record = { ok: true, enabled: false, @@ -1426,16 +1445,6 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis configSource: configLabel, valuesPrinted: false, }; - const runtimeStartedAt = Date.now(); - if (ciReady) { - current = await observeCloseoutStatus(config, options, target, consumer, "runtime", 1, startedAt); - runtimeAttempts += 1; - while (options.wait && !closeoutReady(current, options.sourceCommit) && Date.now() - runtimeStartedAt < waitMs) { - await sleep(3000); - current = await observeCloseoutStatus(config, options, target, consumer, "runtime", runtimeAttempts + 1, startedAt); - runtimeAttempts += 1; - } - } summary = record(current.summary); latest = record(summary.latestPipelineRun); diagnostics = record(summary.diagnostics); @@ -1452,8 +1461,8 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis ready, sourceMatched, diagnosticCode: diagnostics.code, - ciAttempts, - runtimeAttempts, + timedOut: waitResult.timedOut, + observations: waitResult.observations, }); return { ok: ready, @@ -1465,26 +1474,66 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis observedSourceCommit, sourceMatched, ready, + timedOut: waitResult.timedOut, + elapsedMs: waitResult.elapsedMs, + cursor: waitResult.cursor, wait: { requested: options.wait, - attempts: ciAttempts + runtimeAttempts, - ciAttempts, - runtimeAttempts, - elapsedMs: Date.now() - startedAt, - budgetSeconds: pac.release.waitTimeoutSeconds, - budgetScope: "per-phase", - source: `${configLabel}.release.waitTimeoutSeconds`, + completed: waitResult.completed, + timedOut: waitResult.timedOut, + cancelled: waitResult.cancelled, + observations: waitResult.observations, + elapsedMs: waitResult.elapsedMs, + budgetSeconds: options.timeoutMs / 1000, + budgetScope: "whole-observation", + source: options.wait ? "cli --timeout or default 120s" : "single observation", + identity: { target: target.id, consumer: consumer.id, pipelineRun: latest.name ?? null, sourceCommit: options.sourceCommit, valuesPrinted: false }, + cursor: waitResult.cursor, valuesPrinted: false, }, summary, status: current, gitOpsMirrorFlush, blocker: ready ? null : closeoutBlocker(current, options.sourceCommit, observedSourceCommit, sourceMatched, gitOpsMirrorFlush), - next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), + next: { + ...nextCommands(target.id, consumer.id, pac.defaults.consumerId), + wait: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --consumer ${consumer.id}${options.sourceCommit === null ? "" : ` --source-commit ${options.sourceCommit}`} --wait --timeout 120s`, + }, valuesPrinted: false, }; } +async function waitForCloseoutChange(config: UniDeskConfig, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", remainingMs: number): Promise { + const timeoutSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + const args = phase === "ci" + ? ["kubectl", "-n", consumer.namespace, "get", "pipelinerun", "-l", `tekton.dev/pipeline=${consumer.pipeline}`, "--watch-only", `--request-timeout=${timeoutSeconds}s`, "-o", "name"] + : ["kubectl", "-n", consumer.argoNamespace, "get", "application", consumer.argoApplication, "--watch-only", `--request-timeout=${timeoutSeconds}s`, "-o", "name"]; + const script = [ + "set -euo pipefail", + kubernetesWatchOneEventShellFunction(), + `unidesk_watch_one_event ${args.map(shQuote).join(" ")}`, + ].join("\n"); + const result = await capture(config, target.route, ["bash"], script, { runtimeTimeoutMs: readOnlyLongPollTransportTimeoutMs(remainingMs) }); + if (result.exitCode !== 0) throw new Error(`CI/CD ${phase} watch disconnected: exit=${result.exitCode} stderr=${stringValue(compactCapture(result, { full: true }).stderrTail)}`); +} + +function closeoutWaitComplete(value: Record, sourceCommit: string | null): boolean { + if (closeoutReady(value, sourceCommit)) return true; + const latest = record(record(value.summary).latestPipelineRun); + const terminal = stringValue(latest.status, stringValue(latest.reason, "")).toLowerCase(); + return ["failed", "failure", "false", "cancelled", "canceled"].some((status) => terminal.includes(status)); +} + +function closeoutCursor(value: Record): string { + const summary = record(value.summary); + const latest = record(summary.latestPipelineRun); + const argo = record(summary.argo); + const runtime = record(summary.runtime); + return [latest.name, latest.status, latest.reason, argo.revision, argo.sync, argo.health, runtime.digest, runtime.readyReplicas] + .map((item) => stringValue(item, "-")) + .join(":"); +} + async function observeCloseoutStatus(config: UniDeskConfig, options: CloseoutOptions, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", attempt: number, startedAt: number): Promise> { printCloseoutProgress(target, consumer, `${phase}-status`, "started", startedAt, { attempt }); const current = await status(config, options); @@ -2324,6 +2373,9 @@ function compactCloseoutJson(result: Record): Record): RenderedCliResult { ...table(["TARGET", "CONSUMER", "READY", "SOURCE_MATCHED", "MUTATION"], [[stringValue(target.id), stringValue(consumer.id), boolText(result.ready), boolText(result.sourceMatched), boolText(result.mutation)]]), "", "WAIT", - ...table(["REQUESTED", "CI_ATTEMPTS", "RUNTIME_ATTEMPTS", "ELAPSED_MS", "BUDGET_S", "SCOPE", "SOURCE"], [[ + ...table(["REQUESTED", "COMPLETED", "TIMED_OUT", "CANCELLED", "OBSERVATIONS", "ELAPSED_MS", "BUDGET_S"], [[ stringValue(wait.requested), - stringValue(wait.ciAttempts), - stringValue(wait.runtimeAttempts), + boolText(wait.completed), + boolText(wait.timedOut), + boolText(wait.cancelled), + stringValue(wait.observations), stringValue(wait.elapsedMs), stringValue(wait.budgetSeconds), - stringValue(wait.budgetScope), - stringValue(wait.source), ]]), + ` identity: ${compactLine(JSON.stringify(wait.identity ?? {}))}`, + ` cursor: ${stringValue(wait.cursor)}`, "", "SOURCE / PIPELINERUN", ...table(["EXPECTED", "OBSERVED", "PIPELINERUN", "STATUS", "DURATION_S"], [[short(stringValue(result.sourceCommit), 16), short(stringValue(result.observedSourceCommit), 16), stringValue(latest.name), stringValue(latest.reason ?? latest.status), stringValue(latest.durationSeconds)]]), @@ -2701,6 +2755,7 @@ function renderCloseout(result: Record): RenderedCliResult { "NEXT", ` status: ${stringValue(record(result.next).status)}`, ` history: ${stringValue(record(result.next).history)} --limit 10`, + ` wait: ${stringValue(record(result.next).wait)}`, latest.name === undefined ? " pipeline-run: -" : ` pipeline-run: ${stringValue(record(result.next).history)} --id ${stringValue(latest.name)}`, ]; return rendered(result, "platform-infra pipelines-as-code closeout", lines); @@ -2904,6 +2959,7 @@ function parseCloseoutOptions(args: string[]): CloseoutOptions { const commonArgs: string[] = []; let sourceCommit: string | null = null; let wait = false; + let timeoutMs = DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--source-commit" || arg === "--commit") { @@ -2914,6 +2970,11 @@ function parseCloseoutOptions(args: string[]): CloseoutOptions { index += 1; } else if (arg === "--wait") { wait = true; + } else if (arg === "--timeout") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--timeout requires a value"); + timeoutMs = parseStrictDuration(value, "--timeout"); + index += 1; } else { commonArgs.push(arg); if (arg === "--target" || arg === "--node" || arg === "--consumer") { @@ -2922,7 +2983,7 @@ function parseCloseoutOptions(args: string[]): CloseoutOptions { } } } - return { ...parseCommonOptions(commonArgs), sourceCommit, wait }; + return { ...parseCommonOptions(commonArgs), sourceCommit, wait, timeoutMs }; } function parseHistoryOptions(args: string[]): HistoryOptions { @@ -3065,9 +3126,6 @@ function closeoutBlocker(value: Record, expected: string | null }; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} function pipelineRunGate(latest: Record): Record { diff --git a/scripts/src/read-only-long-poll.test.ts b/scripts/src/read-only-long-poll.test.ts new file mode 100644 index 00000000..2d4267db --- /dev/null +++ b/scripts/src/read-only-long-poll.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { parseStrictDuration, readOnlyLongPollTransportTimeoutMs, runReadOnlyLongPoll } from "./read-only-long-poll"; + +describe("read-only long poll", () => { + test("returns an immediate terminal observation", async () => { + const result = await runReadOnlyLongPoll({ observe: async () => "done", completed: (value) => value === "done", waitForChange: async () => { throw new Error("unexpected wait"); }, cursor: (value) => value }); + expect(result).toMatchObject({ completed: true, timedOut: false, observations: 1, cursor: "done" }); + }); + + test("returns after a backend-held change", async () => { + let now = 0; + let state = "running"; + const result = await runReadOnlyLongPoll({ + observe: async () => state, + completed: (value) => value === "done", + waitForChange: async () => { now += 25; state = "done"; }, + cursor: (value) => value, + timeoutMs: 100, + clock: { now: () => now }, + }); + expect(result).toMatchObject({ completed: true, timedOut: false, elapsedMs: 25, observations: 2 }); + }); + + test("returns the current state after an empty timeout", async () => { + let now = 0; + const result = await runReadOnlyLongPoll({ + observe: async () => ({ state: "running", id: "run-1" }), + completed: () => false, + waitForChange: async (remainingMs) => { now += remainingMs; }, + cursor: (value) => value.id, + timeoutMs: 100, + clock: { now: () => now }, + }); + expect(result).toMatchObject({ completed: false, timedOut: true, cancelled: false, elapsedMs: 100, cursor: "run-1" }); + }); + + test("preserves backend errors and cancellation", async () => { + await expect(runReadOnlyLongPoll({ observe: async () => "running", completed: () => false, waitForChange: async () => { throw new Error("watch disconnected"); }, cursor: (value) => value, timeoutMs: 10 })).rejects.toThrow("watch disconnected"); + const controller = new AbortController(); + controller.abort(); + const cancelled = await runReadOnlyLongPoll({ observe: async () => "running", completed: () => false, waitForChange: async () => {}, cursor: (value) => value, signal: controller.signal }); + expect(cancelled).toMatchObject({ completed: false, timedOut: false, cancelled: true }); + }); + + test("validates strict duration syntax", () => { + expect(parseStrictDuration("120s")).toBe(120_000); + expect(parseStrictDuration("250ms")).toBe(250); + expect(() => parseStrictDuration("1.5s")).toThrow(); + expect(() => parseStrictDuration("120")).toThrow(); + expect(() => parseStrictDuration("0s")).toThrow(); + expect(readOnlyLongPollTransportTimeoutMs(60 * 60_000)).toBe(60 * 60_000 + 15_000); + }); +}); diff --git a/scripts/src/read-only-long-poll.ts b/scripts/src/read-only-long-poll.ts new file mode 100644 index 00000000..ecd89523 --- /dev/null +++ b/scripts/src/read-only-long-poll.ts @@ -0,0 +1,63 @@ +export const DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS = 120_000; +export const READ_ONLY_LONG_POLL_TRANSPORT_MARGIN_MS = 15_000; + +export interface ReadOnlyLongPollClock { + now(): number; +} + +export interface ReadOnlyLongPollResult { + value: T; + completed: boolean; + timedOut: boolean; + cancelled: boolean; + elapsedMs: number; + observations: number; + cursor: string; +} + +export function parseStrictDuration(value: string, label = "duration"): number { + const match = /^(\d+)(ms|s|m)$/u.exec(value); + if (match === null) throw new Error(`${label} must match ms|s|m`); + const amount = Number.parseInt(match[1]!, 10); + if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error(`${label} must be greater than zero`); + const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1_000 : 60_000; + const durationMs = amount * multiplier; + if (!Number.isSafeInteger(durationMs) || durationMs > 3_600_000) throw new Error(`${label} must not exceed 60m`); + return durationMs; +} + +export function readOnlyLongPollTransportTimeoutMs(timeoutMs: number): number { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3_600_000) throw new Error("long-poll timeoutMs must be between 1ms and 60m"); + return timeoutMs + READ_ONLY_LONG_POLL_TRANSPORT_MARGIN_MS; +} + +export async function runReadOnlyLongPoll(options: { + observe: () => Promise; + completed: (value: T) => boolean; + waitForChange: (remainingMs: number, signal?: AbortSignal) => Promise; + cursor: (value: T) => string; + timeoutMs?: number; + clock?: ReadOnlyLongPollClock; + signal?: AbortSignal; +}): Promise> { + const timeoutMs = options.timeoutMs ?? DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("timeoutMs must be a positive integer"); + const clock = options.clock ?? { now: () => Date.now() }; + const startedAt = clock.now(); + let observations = 0; + let value = await options.observe(); + observations += 1; + while (!options.completed(value)) { + if (options.signal?.aborted === true) { + return { value, completed: false, timedOut: false, cancelled: true, elapsedMs: clock.now() - startedAt, observations, cursor: options.cursor(value) }; + } + const remainingMs = timeoutMs - (clock.now() - startedAt); + if (remainingMs <= 0) { + return { value, completed: false, timedOut: true, cancelled: false, elapsedMs: clock.now() - startedAt, observations, cursor: options.cursor(value) }; + } + await options.waitForChange(remainingMs, options.signal); + value = await options.observe(); + observations += 1; + } + return { value, completed: true, timedOut: false, cancelled: false, elapsedMs: clock.now() - startedAt, observations, cursor: options.cursor(value) }; +} diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index 9983a545..181b0178 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -18,6 +18,7 @@ import { parseSshInvocation, remoteCommandForRoute, sshFailureHint, + sshCaptureRuntimeTimeoutMs, sshRoutePayloadCwd, sshRouteSeparatorCompatibilityHint, sshRuntimeTimeoutHint, @@ -538,6 +539,7 @@ async function runRemoteSshWebSocketCaptureRemoteCommand( invocation: ReturnType, remoteCommand: string, input?: string, + options: { runtimeTimeoutMs?: number } = {}, ): Promise { const captureInvocation = { ...invocation, @@ -548,7 +550,7 @@ async function runRemoteSshWebSocketCaptureRemoteCommand( cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, }; - const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const runtimeTimeoutMs = sshCaptureRuntimeTimeoutMs(options.runtimeTimeoutMs); const payload = { providerId: invocation.providerId, command: wrapSshRemoteCommand(remoteCommand), @@ -1016,6 +1018,7 @@ export async function runRemoteSshCommandCapture( args: string[], input?: string, env: NodeJS.ProcessEnv = process.env, + options: { runtimeTimeoutMs?: number } = {}, ): Promise { const token = sshClientTokenFromEnv(env); const session = token === null @@ -1028,5 +1031,5 @@ export async function runRemoteSshCommandCapture( const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` : input; - return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin); + return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin, options); } diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts index 1fc1be14..1cc48aaf 100644 --- a/scripts/src/ssh-runtime.ts +++ b/scripts/src/ssh-runtime.ts @@ -56,6 +56,7 @@ import { const defaultSshSlowWarningMs = 10_000; const defaultSshRuntimeTimeoutMs = 60_000; const maxSshRuntimeTimeoutMs = 60_000; +const maxExplicitSshCaptureRuntimeTimeoutMs = 61 * 60_000; const defaultSshBackendCoreDetectTimeoutMs = 15_000; const maxSshBackendCoreDetectTimeoutMs = 60_000; const minSshStdoutStreamMaxBytes = 4 * 1024; @@ -183,6 +184,12 @@ export function sshRuntimeTimeoutMs(env: NodeJS.ProcessEnv = process.env): numbe return Math.min(maxSshRuntimeTimeoutMs, Math.max(1000, Math.trunc(parsed))); } +export function sshCaptureRuntimeTimeoutMs(explicitTimeoutMs?: number, env: NodeJS.ProcessEnv = process.env): number { + if (explicitTimeoutMs === undefined) return sshRuntimeTimeoutMs(env); + if (!Number.isSafeInteger(explicitTimeoutMs) || explicitTimeoutMs <= 0) throw new Error("SSH capture runtimeTimeoutMs must be a positive integer"); + return Math.min(maxExplicitSshCaptureRuntimeTimeoutMs, explicitTimeoutMs); +} + export function sshRuntimeTimeoutHint(options: { invocation: ParsedSshInvocation; transport: SshRuntimeTimeoutHint["transport"]; @@ -982,10 +989,10 @@ async function runSshCaptureCommand(config: UniDeskConfig, invocation: ParsedSsh return await runSshCaptureRemoteCommand(config, invocation, remoteCommand, input); } -async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string): Promise { +async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, remoteCommand: string, input?: string, options: { runtimeTimeoutMs?: number } = {}): Promise { const startedAtMs = Date.now(); const size = terminalSize(); - const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const runtimeTimeoutMs = sshCaptureRuntimeTimeoutMs(options.runtimeTimeoutMs); const payload = { providerId: invocation.providerId, command: wrapSshRemoteCommand(remoteCommand), @@ -1256,7 +1263,7 @@ async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: str }, config); } -export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise { +export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string, options: { runtimeTimeoutMs?: number } = {}): Promise { const invocation = parseSshInvocation(target, args); const parsed = invocation.parsed; if (parsed.remoteCommand === null) throw new Error(`ssh ${target} capture requires a non-interactive operation`); @@ -1265,19 +1272,19 @@ export async function runSshCommandCapture(config: UniDeskConfig, target: string : input; const plan = sshCaptureBackendPlan(config, process.env); if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { - return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin); + return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin, options); } - const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin); + const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin, options); const fallbackHost = sshCaptureRemoteHost(config, process.env); if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) { - return await runRemoteSshCapture(config, fallbackHost, target, args, stdin); + return await runRemoteSshCapture(config, fallbackHost, target, args, stdin, options); } return local; } -async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise { +async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string, options: { runtimeTimeoutMs?: number } = {}): Promise { const { runRemoteSshCommandCapture } = await import("./remote-ssh"); - return await runRemoteSshCommandCapture(config, host, target, args, input); + return await runRemoteSshCommandCapture(config, host, target, args, input, process.env, options); } export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan { diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 908d9e6d..65285715 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -14,6 +14,7 @@ import { remoteCommandForRoute, sshTruncationCompletionSummary, sshCaptureBackendPlan, + sshCaptureRuntimeTimeoutMs, sshStderrStreamMaxBytes, sshStreamDumpExplicitlyRequested, sshStdoutStreamMaxBytes, @@ -25,6 +26,14 @@ import { } from "./ssh"; import { readCliOutputPolicy } from "./output"; +describe("ssh capture runtime timeout", () => { + test("keeps explicit long-poll transport margin beyond the 60m user timeout", () => { + expect(sshCaptureRuntimeTimeoutMs(60 * 60_000 + 15_000)).toBe(60 * 60_000 + 15_000); + expect(sshCaptureRuntimeTimeoutMs(62 * 60_000)).toBe(61 * 60_000); + expect(() => sshCaptureRuntimeTimeoutMs(0)).toThrow("positive integer"); + }); +}); + function sha256Hex(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index b3652488..b9ec0e93 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -2111,6 +2111,7 @@ export { runSsh, runSshCommandCapture, sshCaptureBackendPlan, + sshCaptureRuntimeTimeoutMs, sshFailureHint, sshRuntimeTimeoutHint, sshRuntimeTimeoutMs,