feat: add desired-state deploy reconciler
This commit is contained in:
@@ -28,7 +28,8 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts provider attach <providerId> [--master-server URL] [--up] [--force]`:在新增计算节点上生成两项配置的 provider-gateway 挂载包;默认只需要主 server URL(默认 `http://74.48.78.17/`)和唯一 Provider ID,生成的 Compose 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace`、SSH 维护私钥挂载和 loopback egress proxy 端口,规则见 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 v3s 控制面上的用户服务,OA Event Flow/Todo Note/Baidu Netdisk on main-server、V3S Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts codex deploy <commitId>`:按已 push 到 remote 的 UniDesk commit 部署 D601 v3s/k8s Code Queue,自动 fetch/export、同步 `/home/ubuntu/cq-deploy`、构建镜像、导入 k3s、apply manifest、写入部署 commit 戳记、rollout,并通过真实 `/health` 校验 `deploy.commit`,避免旧服务充数;规则见 `docs/reference/codex-deploy.md`。
|
||||
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json] [--service <id>]`:按根目录 `deploy.json` 的服务 repo 和 commit 期望状态校验或更新用户服务,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`。
|
||||
- `bun scripts/cli.ts codex deploy <commitId>`:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`。
|
||||
- `bun scripts/cli.ts codex task <taskId>`:按 Code Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。
|
||||
- `bun scripts/cli.ts codex judge <taskId> --attempt <n> [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。
|
||||
- `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
|
||||
@@ -58,5 +59,6 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `docs/reference/oa-event-flow.md`:统一 OA 事件流微服务、事件表、tag 订阅、Trace/STEP 统计中心和前端可见性规则。
|
||||
- `docs/reference/pipeline-oa-event-flow.md`:Pipeline/OA 事件流、审核/无审核流转、单步调试、甘特图渲染和最终去残留规则。
|
||||
- `docs/reference/pipeline-model-proxy.md`:Pipeline v2 model proxy 链路架构、D601 宿主 proxy 服务部署、harness token 注入规则和 smoke test 验证流程。
|
||||
- `docs/reference/deploy.md`:`deploy.json` desired-state、target-side build、一次性构建 proxy、直管/代管服务部署 executor 和 live commit 验证规则。
|
||||
- `docs/reference/codex-deploy.md`:D601 Code Queue `codex deploy <commitId>` 异步部署管线、路径约定和验证入口。
|
||||
- `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"services": [
|
||||
{
|
||||
"id": "findjob",
|
||||
"repo": "https://gitee.com/Lyon1998/findjob",
|
||||
"commitId": "2d43212c5f474df5d87820985a6c75a8c2e7ac42"
|
||||
},
|
||||
{
|
||||
"id": "pipeline",
|
||||
"repo": "https://github.com/pikasTech/pipeline",
|
||||
"commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14"
|
||||
},
|
||||
{
|
||||
"id": "met-nonlinear",
|
||||
"repo": "https://github.com/pikasTech/met_nonlinear",
|
||||
"commitId": "9fcdfc0b505e52cc88cf51b196543dc055da2334"
|
||||
},
|
||||
{
|
||||
"id": "claudeqq",
|
||||
"repo": "https://gitee.com/lyon1998/agent_skills",
|
||||
"commitId": "203b1f46684c91340ecbbd8a74502bd55e4f2011"
|
||||
},
|
||||
{
|
||||
"id": "todo-note",
|
||||
"repo": "https://gitee.com/Lyon1998/todo_note",
|
||||
"commitId": "a14ce0eb855a685fa17b47adacd54623e72cd2ff"
|
||||
},
|
||||
{
|
||||
"id": "project-manager",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
},
|
||||
{
|
||||
"id": "baidu-netdisk",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
},
|
||||
{
|
||||
"id": "oa-event-flow",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
},
|
||||
{
|
||||
"id": "v3sctl-adapter",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
},
|
||||
{
|
||||
"id": "code-queue",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
},
|
||||
{
|
||||
"id": "mdtodo",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,7 +19,8 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
|
||||
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。
|
||||
- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的用户服务(底层命令名仍为 microservice);`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。
|
||||
- `codex deploy <commitId>` 创建异步 job,将已 push 到 remote 的 UniDesk commit 部署为 D601 v3s/k8s Code Queue:fetch/export tracked files、同步 `/home/ubuntu/cq-deploy`、构建 `unidesk-code-queue:d601`、导入 k3s containerd、apply manifest、写入部署 commit 戳记、rollout restart,并通过真实 Code Queue `/health` 校验 `deploy.commit` 精确匹配本次 remote commit;详细规则见 `docs/reference/codex-deploy.md`。
|
||||
- `deploy check/plan/apply` 从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 v3s/k3s 代管服务;规则见 `docs/reference/deploy.md`。
|
||||
- `codex deploy <commitId>` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `docs/reference/codex-deploy.md`。
|
||||
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。
|
||||
- `codex task <taskId> --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。
|
||||
- `codex output <taskId> --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取完整信息,默认仍有单条文本预览上限,显式 `--full-text` 才返回该页全文。
|
||||
@@ -33,7 +34,9 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
|
||||
长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log` 与 `.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status <jobId>` 查询进度和尾部输出。
|
||||
|
||||
`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk` 和 `microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow` 和 `microservice proxy oa-event-flow /api/diagnostics` 验证。Code Queue 后端由 D601 v3s/k8s 控制面代管,必须使用 `bun scripts/cli.ts codex deploy <commitId>` 部署已 push 的 remote commit;部署 job 自身必须通过真实 `/health` 返回的 `deploy.commit` 证明不是旧服务在充数,之后再用 `microservice health code-queue` 和 `microservice proxy code-queue /api/tasks/overview` 做人工复核。不得把 `docker rm` 手工兜底当成正式交付步骤。
|
||||
`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk` 和 `microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow` 和 `microservice proxy oa-event-flow /api/diagnostics` 验证。Code Queue 后端由 D601 v3s/k8s 控制面代管,必须使用 `bun scripts/cli.ts deploy apply --service code-queue` 或兼容入口 `bun scripts/cli.ts codex deploy <commitId>` 部署已 push 的 remote commit;部署 job 自身必须通过真实 `/health` 和 k3s Deployment annotation 证明不是旧服务在充数,之后再用 `microservice health code-queue` 和 `microservice proxy code-queue /api/tasks/overview` 做人工复核。不得把 `docker rm` 手工兜底当成正式交付步骤。
|
||||
|
||||
新部署入口优先使用 `deploy apply`。旧的 `server rebuild` 和 `codex deploy` 只保留为兼容入口,后续实现应收敛到同一个 reconciler:从 remote commit 导出源码,在目标节点一次性代理构建镜像,部署后用 live commit 校验证明不是旧服务。
|
||||
|
||||
## Output Contract
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Code Queue Deploy
|
||||
|
||||
`bun scripts/cli.ts codex deploy <commitId>` 是 D601 Code Queue 的正式部署入口。命令只在主 server 工作区执行;它会立即返回异步 job id,后台 job 通过 backend-core 的 `host.ssh` dispatch 在 D601 完成实际部署。
|
||||
`bun scripts/cli.ts codex deploy <commitId>` 是兼容入口。新的正式部署入口是 `bun scripts/cli.ts deploy apply --service code-queue`;兼容入口会生成一个只包含 Code Queue repo 与 commit 的临时 desired manifest,再调用同一个 deploy reconciler。命令只在主 server 工作区执行;它会立即返回异步 job id,后台 job 通过 backend-core 的 `host.ssh` dispatch 在 D601 完成实际部署。
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts deploy apply --service code-queue
|
||||
bun scripts/cli.ts codex deploy <commitId>
|
||||
bun scripts/cli.ts job status <jobId> --tail-bytes 30000
|
||||
```
|
||||
@@ -12,15 +13,15 @@ bun scripts/cli.ts job status <jobId> --tail-bytes 30000
|
||||
- `commitId` 必须是已经 push 到 remote 的 7-40 位 hex commit SHA。
|
||||
- `--provider-id D601` 是默认值;当前部署路径只支持 D601 active instance。
|
||||
- `--timeout-ms N` 控制后台部署总超时,默认 `900000`。
|
||||
- `--skip-build` 只用于已确认目标镜像已在 D601 Docker 和 k3s containerd 中存在的诊断场景;正式部署默认必须构建镜像。
|
||||
- `--skip-build` 不再支持;target-side Docker build 是强制步骤。
|
||||
|
||||
## Pipeline
|
||||
|
||||
部署 job 的步骤固定为:
|
||||
|
||||
1. 在 D601 `/home/ubuntu/unidesk` 中 `git fetch` remote,并用 `git archive <commitId>` 导出 tracked files 到 `/tmp/unidesk-codex-deploy-src-*`。
|
||||
1. 在 D601 的 deploy cache 中 `git fetch` remote,并用 `git archive <commitId>` 导出 tracked files 到一次性 export 目录。
|
||||
2. 用 `rsync --delete` 同步导出的 repo 到 `/home/ubuntu/cq-deploy`,保留 `.state/`、`logs/`、`.git/`、`node_modules/` 和 `dist/`。
|
||||
3. 在 `/home/ubuntu/cq-deploy` 构建 `unidesk-code-queue:d601`。
|
||||
3. 在 D601 用目标 Docker daemon 的本地 BuildKit builder 构建 `unidesk-code-queue:d601`,复用 D601 上已有基础镜像和 layer cache;provider-gateway egress proxy 只作为本次 build 的环境变量与 build-arg 注入,并配合本次 build 的 `--network host` 让 RUN 阶段访问 D601 宿主 loopback proxy,不能污染 D601 宿主 Docker/HTTP proxy 配置。
|
||||
4. `docker save` 镜像并导入 k3s containerd:`docker exec -i unidesk-v8s-server ctr -n k8s.io images import -`。
|
||||
5. `kubectl apply -f src/components/microservices/v3sctl-adapter/v3s/code-queue.k8s.yaml`,其中包含 Code Queue 和 `d601-tcp-egress-gateway`。
|
||||
6. 将解析后的 40 位 remote commit 写入 `deployment/code-queue` 的 `CODE_QUEUE_DEPLOY_COMMIT` / `CODE_QUEUE_DEPLOY_REQUESTED_COMMIT`,并记录到 Deployment annotation。
|
||||
@@ -29,7 +30,7 @@ bun scripts/cli.ts job status <jobId> --tail-bytes 30000
|
||||
|
||||
## Observability
|
||||
|
||||
`codex deploy` 本身不阻塞等待部署结束。返回 JSON 中的 `statusCommand` 和 `tailCommand` 是唯一状态入口。后台 job 的 stderr 是 JSONL progress,每个长步骤会记录远端 `/tmp/unidesk-codex-deploy-*.log` 和 sentinel 文件;失败时 `job status` 会显示最后日志尾部。
|
||||
`codex deploy` 本身不阻塞等待部署结束。返回 JSON 中的 `statusCommand` 和 `tailCommand` 是唯一状态入口。后台 job 的 stderr 是 JSONL progress,每个长步骤会记录远端 `/tmp/unidesk-deploy-*.log` 和 sentinel 文件;失败时 `job status` 会显示最后日志尾部。
|
||||
|
||||
`job status` 到 `succeeded` 时,部署 job 已经完成 live commit 验证。需要人工复核时可用以下命令确认 `deploy.commit`:
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Desired Deploy Reconciler
|
||||
|
||||
UniDesk deployment is driven by a desired-state manifest. The manifest answers only one question: which service should run which repository commit. Runtime topology, ports, providers, compose files, Kubernetes manifests, health paths and proxy policy remain in `config.json` and the existing service manifests.
|
||||
|
||||
## Manifest
|
||||
|
||||
The root `deploy.json` is intentionally minimal:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"services": [
|
||||
{
|
||||
"id": "code-queue",
|
||||
"repo": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`deploy.json` must not contain provider IDs, ports, compose service names, Kubernetes namespace, health paths, environment variables, Dockerfile paths or build commands. The deploy reconciler joins each `id` with `config.json.microservices[]` and existing v3s manifests to resolve those details. A service listed in `deploy.json` but missing from `config.json` is an error. A service with no Dockerfile source artifact is reported as unsupported rather than silently skipped.
|
||||
|
||||
`config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler.
|
||||
|
||||
## CLI
|
||||
|
||||
`bun scripts/cli.ts deploy check [--file deploy.json] [--service <id>]` checks the live runtime against the desired repo and commit without changing the system.
|
||||
|
||||
`bun scripts/cli.ts deploy plan [--file deploy.json] [--service <id>]` prints the same live state plus the intended action: `noop`, `deploy` or `unsupported`.
|
||||
|
||||
`bun scripts/cli.ts deploy apply [--file deploy.json] [--service <id>] [--dry-run] [--force]` starts an asynchronous job. Use `bun scripts/cli.ts job status <jobId> --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches.
|
||||
|
||||
All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output.
|
||||
|
||||
## Target-Side Build
|
||||
|
||||
Target-side build is the only standard deployment mode. The controller may run on the main server, but source materialization, compile/build, Docker image creation and deployment happen on the target node that will run the service.
|
||||
|
||||
- Main server services are fetched, built and deployed on the main server.
|
||||
- D601 services are fetched, built and deployed on D601.
|
||||
- D518 services are fetched, built and deployed on D518.
|
||||
- v3s/k3s managed services are built on the active control target and then imported into that target's Kubernetes container runtime.
|
||||
|
||||
The reconciler distributes only repository URL, commit ID, Dockerfile path, build context and the existing deployment manifest/compose declaration. It must not distribute large Docker images between hosts as the default path, and it must not accept `docker commit` images, dirty worktrees or hand-mutated runtime containers as deployment truth.
|
||||
|
||||
Each target fetches the remote repository, resolves the requested commit to a full 40 character SHA and exports tracked files with `git archive`. Build contexts are created from that archive, not from the operator's current working tree.
|
||||
|
||||
## One-Shot Build Proxy
|
||||
|
||||
Target-side Docker builds that need external network access use a one-shot build proxy scope to the main server network environment. The build path must not mutate host-global proxy settings:
|
||||
|
||||
- Do not edit `/etc/docker/daemon.json`.
|
||||
- Do not edit shell profiles or global Docker CLI config.
|
||||
- Do not leave long-lived host `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`.
|
||||
- Do not silently fall back to target local direct internet.
|
||||
|
||||
The standard implementation first uses the target Docker daemon's local BuildKit builder so target-side base image and layer caches are reused. Proxy variables are scoped to the current build process and passed as matching `--build-arg` values for Dockerfile `RUN` steps; they are not written to daemon or shell configuration. Provider targets also use `docker buildx build --network host` so `127.0.0.1:<proxy-port>` inside `RUN` resolves to the target host's loopback proxy. If a service later needs an isolated `docker-container` builder, it may use one only as a service-specific fallback and must still log proxy resolution, proxy probe result and builder cleanup. The default path should not discard target-local image cache by creating a fresh builder for every deploy.
|
||||
|
||||
Provider targets should use their local provider-gateway egress proxy endpoint when available, such as `http://127.0.0.1:18789`. Main server targets may build without a proxy unless a service explicitly requires one.
|
||||
|
||||
## Deployment Executors
|
||||
|
||||
The reconciler selects the executor from `config.json`:
|
||||
|
||||
- `deployment.mode=unidesk-direct` on `main-server`: build the image on the main server, then use the fixed UniDesk Compose project and `up -d --no-build --no-deps --force-recreate <service>`.
|
||||
- `deployment.mode=unidesk-direct` on a provider: dispatch `host.ssh` to that provider, build on the provider, then use the service's provider-local compose file and project. The executor resolves the actual Compose project, image name, build context, Dockerfile and target from the running container labels and `docker compose config`; it must not guess an image tag that the service will not actually run.
|
||||
- `deployment.mode=v3sctl-managed`: dispatch to the active control target, build on that target, import the image into k3s/containerd, apply the existing Kubernetes manifest, stamp the Deployment and wait for rollout.
|
||||
|
||||
Existing service-specific commands such as Code Queue deploy should converge onto this reconciler path instead of keeping a parallel implementation.
|
||||
|
||||
## Version Stamping And Verification
|
||||
|
||||
Every successful deployment must stamp the source version in the runtime:
|
||||
|
||||
- Docker image labels: `unidesk.ai/service-id`, `unidesk.ai/source-repo`, `unidesk.ai/source-commit` and `unidesk.ai/dockerfile`.
|
||||
- Runtime env or Kubernetes annotations: `UNIDESK_DEPLOY_SERVICE_ID`, `UNIDESK_DEPLOY_REPO`, `UNIDESK_DEPLOY_COMMIT` and `UNIDESK_DEPLOY_REQUESTED_COMMIT`.
|
||||
- Service health response should expose `deploy.repo` and `deploy.commit` when practical. Existing service-specific health contracts such as Code Queue's `deploy.commit` remain valid.
|
||||
|
||||
The deploy job is not complete until live verification proves the running service matches the requested commit. For Docker services this includes image label inspection on the running container. For v3s/k3s services this includes Deployment annotation/env inspection and service health through the same UniDesk microservice proxy path used by the frontend. A healthy old service must fail verification.
|
||||
|
||||
## Unsupported Services
|
||||
|
||||
Image-only services, such as a service declared directly as `docker.io/vendor/image:tag` without a Dockerfile source artifact, do not satisfy target-side build policy. They must be converted to a source repository with a Dockerfile wrapper before the reconciler can manage them. Until then, `deploy check` and `deploy plan` should report them as unsupported.
|
||||
@@ -26,6 +26,8 @@ CLI 会优先使用 `docker compose` v2 plugin;当 v2 plugin 不存在时才
|
||||
|
||||
Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生命周期用 `server start` / `server stop`,单服务重建用 `server rebuild <service>`。不要因为 v2 可用就直接在生产栈上手工执行未纳入 CLI 的 `up --build`、`down -v` 或跨项目清理命令;所有会影响容器的动作都应保持 job 可观测、Compose project 固定、database named volume 保留。
|
||||
|
||||
版本化用户服务部署优先使用 `bun scripts/cli.ts deploy apply`。`deploy.json` 只声明服务 `id`、`repo` 和 `commitId`;目标节点、Dockerfile、Compose、Kubernetes manifest、健康检查和代理路径继续来自 `config.json` 与现有 manifest。部署必须遵循 target-side build:服务部署到哪台 target,就在哪台 target 从 remote commit 导出源码、一次性代理构建镜像并部署;不得把中心构建镜像作为默认分发路径,也不得用 `docker commit` 或脏 worktree 作为部署输入。完整规则见 `docs/reference/deploy.md`。
|
||||
|
||||
## Start And Stop
|
||||
|
||||
`bun scripts/cli.ts server start` 与 `bun scripts/cli.ts server stop` 都是异步 job。启动 job 只执行固定 Compose project 的 `up -d --build --remove-orphans`,不得先 `down`,避免在 provider-gateway 旧容器或网络冲突时把长驻控制面容器先删掉又启动失败;停止 job 才允许执行 `down --remove-orphans`。启动和停止流程都禁止删除 Docker named volume。所有会改变主 server Compose 状态的 job 必须通过 `.state/locks/server-compose.lock` 串行化;连续 `server rebuild` 命令只代表连续创建异步 job,不能代表第一个 job 已结束,实际容器变更仍必须由 Compose lock 串行执行。
|
||||
|
||||
@@ -152,6 +152,7 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
|
||||
- 默认出网代理:D601 active Code Queue Pod 必须默认把 `HTTP_PROXY`、`HTTPS_PROXY` 和 `ALL_PROXY` 注入给 Codex/OpenCode、`git`、`curl`、`npm` 等任务子进程;当前唯一上游是 D601 provider-gateway egress HTTP CONNECT 代理,并通过 Kubernetes `Service d601-provider-egress-proxy` 暴露给 `unidesk` namespace 内的 Pod。该 Service 的 EndpointSlice 指向 D601 provider-gateway 私有 Docker network endpoint,Pod 内代理 URL 使用 `http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789`,provider-gateway 宿主端口仍只允许绑定 `127.0.0.1`,不得开放公网;如 provider-gateway 容器 IP 变化,必须同步刷新 EndpointSlice 并用 Code Queue `/health.egressProxy.connected=true` 验证。这里的 provider-gateway 只承担出网代理,不承担 Code Queue 业务 HTTP 代理;业务访问仍只能走 Kubernetes API service proxy。k3s/k8s 原生 egress gateway、service mesh 或 CNI egress policy 只作为后续网络层增强方向,当前交付态不引入第二套出网控制面。远程开发/执行容器不得只依赖这些环境变量,必须在容器网络层用 TUN 默认路由和 OUTPUT 防火墙强制外网流量只能经 master TUN 出口。
|
||||
- 出网代理无 fallback 纪律:Code Queue 的运行时配置只允许一个默认出网路径,即 provider-gateway egress proxy;不得在代码中同时保留 Code Queue 自建 WebSocket proxy、临时 shell proxy、D601 本地直连公网、主 server direct HTTP proxy 等隐式分支。任何新增网络 fallback 都必须先进入本参考文档并配套 `/health` 可见状态,否则视为残留旧路径。
|
||||
- 上线纪律:Code Queue 相关的前端或后端改进必须在同一任务内正式上线并验证公网 frontend 或 live API,不能只停留在源码、构建产物或“后续再上线”。修改 Code Queue 自身时不得等待当前 Code Queue task 结束、等待 queue idle 或等待 `0 running` 后才重启;D601 active 实例的正式后端部署入口是 `bun scripts/cli.ts codex deploy <commitId>`,它按已 push 的 remote commit 做 build-first 镜像替换、k3s image import、manifest apply、rollout 和健康验证,并用 v3s adapter、Code Queue live API 或公网 frontend 证明任务和队列仍可读可继续。
|
||||
- 期望状态部署:新的通用入口是 `bun scripts/cli.ts deploy apply --service code-queue`,它从 `deploy.json` 读取 repo 与 commit,再按 `docs/reference/deploy.md` 的 target-side build 规范在 D601 构建、导入 k3s、rollout 并验证 live commit。`codex deploy <commitId>` 是兼容入口,后续实现应复用同一个 reconciler,不得维护第二套部署语义。
|
||||
- 更名与灾备恢复:旧版 Codex 队列服务名只允许作为兼容诊断和一次性迁移来源;`code-queue-backend` 容器自身 `/health` 正常但 `microservice health code-queue` 返回 provider 直连错误时,优先判定为 backend-core 仍加载旧 `MICROSERVICES_JSON` 或 adapter manifest 未刷新,必须刷新 `.state/docker-compose.env`、重建/替换 `backend-core` 与 `v3sctl-adapter`,随后用 `microservice list` 验证 `code-queue` 的 `runtime.orchestrator=v3sctl`、`backend.proxyMode=v3sctl-adapter-http` 和无业务容器直连摘要。
|
||||
- Codex 认证:容器只从 D601 的 `/home/ubuntu/.codex/config.toml` 同步 Codex provider 配置到 D601 `.state/code-queue/codex-home`,并通过 D601 `.state/code-queue-d601.env` 透传 `OPENAI_API_KEY`、`CRS_OAI_KEY` 等 provider 所需变量;这些 provider 环境变量不得写入仓库,必须由 D601 Compose env-file 注入,确保容器重建和重启后不会丢失认证。新增 provider 的 `env_key` 时必须增加同类运行时透传和 Compose env 持久化,禁止把 Codex 或 MiniMax 密钥写入仓库文件。Code Queue 容器必须只读挂载 D601 host 的 SSH 目录到 `/root/.ssh`(默认 `/home/ubuntu/.ssh`),让容器内 `git push`、`ssh -T git@github.com` 与 host 使用同一套 GitHub SSH key/known_hosts;不得把私钥复制进镜像或仓库。
|
||||
- Develop-ready 镜像:Code Queue 镜像必须在启动前预装 UniDesk/Pipeline 调试所需工具,至少包含 `codex`、`bun`、`node`、`npm`/`npx`、`git`、`rg`、`curl`、`python3`/`pip3`、`docker`、`docker compose`、`docker-compose`、`jq`、`ssh`、`rsync`、`make`、`gcc`/`g++`、`iptables`、`tar`、`gzip` 和 `unzip`;不得依赖 Codex 任务运行时再 `apt-get install` 这些基础环境。
|
||||
|
||||
+12
-3
@@ -9,7 +9,7 @@ import { runSsh } from "./src/ssh";
|
||||
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
||||
import { runMicroserviceCommand } from "./src/microservices";
|
||||
import { runCodeQueueCommand } from "./src/code-queue";
|
||||
import { runCodexDeployCommand } from "./src/codex-deploy";
|
||||
import { runCodeQueueDeployCompatCommand, runDeployCommand } from "./src/deploy";
|
||||
import { runProviderCommand } from "./src/provider-attach";
|
||||
import { runScheduleCommand } from "./src/schedules";
|
||||
|
||||
@@ -43,9 +43,10 @@ function help(): unknown {
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id>", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." },
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." },
|
||||
{ command: "deploy check|plan|apply [--file deploy.json] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest using target-side build and live commit verification." },
|
||||
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
|
||||
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
|
||||
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N] [--skip-build]", description: "Start an async D601 v3s/k8s Code Queue deployment job from a specific remote git commit." },
|
||||
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
|
||||
{ command: "codex task <taskId> [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Code Queue task summary; trace rows are opt-in and paged with next/previous commands to avoid output explosion." },
|
||||
{ command: "codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records by seq when a trace row has omitted command/output text." },
|
||||
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
|
||||
@@ -179,6 +180,14 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "deploy") {
|
||||
const result = await runDeployCommand(config, args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "provider") {
|
||||
emitJson(commandName, await runProviderCommand(config, args.slice(1)));
|
||||
return;
|
||||
@@ -191,7 +200,7 @@ async function main(): Promise<void> {
|
||||
|
||||
if (top === "codex") {
|
||||
if (sub === "deploy") {
|
||||
const result = await runCodexDeployCommand(config, args.slice(2));
|
||||
const result = await runCodeQueueDeployCompatCommand(config, args.slice(2));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckIte
|
||||
fileItem("src/components/microservices/oa-event-flow/src/index.ts"),
|
||||
fileItem("src/components/microservices/v3sctl-adapter/src/index.ts"),
|
||||
fileItem("src/components/microservices/mdtodo/src/index.ts"),
|
||||
fileItem("scripts/src/codex-deploy.ts"),
|
||||
fileItem("scripts/src/deploy.ts"),
|
||||
fileItem("scripts/src/e2e.ts"),
|
||||
unifiedLogRotationItem(),
|
||||
commandItem("bun:version", ["bun", "--version"]),
|
||||
|
||||
@@ -1,679 +0,0 @@
|
||||
import { startJob, type JobRecord } from "./jobs";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
import { type UniDeskConfig, rootPath } from "./config";
|
||||
|
||||
const defaultProviderId = "D601";
|
||||
const defaultTimeoutMs = 900_000;
|
||||
const pollIntervalMs = 5_000;
|
||||
const shortDispatchWaitMs = 25_000;
|
||||
const shortRemoteTimeoutMs = 20_000;
|
||||
|
||||
const defaultSourceRepoDir = "/home/ubuntu/unidesk";
|
||||
const defaultDeployDir = "/home/ubuntu/cq-deploy";
|
||||
const defaultKubeconfigPath = "/home/ubuntu/cq-deploy/.state/v8s/kubeconfig";
|
||||
const defaultK3sContainer = "unidesk-v8s-server";
|
||||
const defaultImageTag = "unidesk-code-queue:d601";
|
||||
const k8sNamespace = "unidesk";
|
||||
const k8sDeployment = "code-queue";
|
||||
const tcpEgressDeployment = "d601-tcp-egress-gateway";
|
||||
const k8sManifestRelPath = "src/components/microservices/v3sctl-adapter/v3s/code-queue.k8s.yaml";
|
||||
|
||||
interface DeployCliOptions {
|
||||
commitId: string;
|
||||
providerId: string;
|
||||
repoUrl: string;
|
||||
sourceRepoDir: string;
|
||||
deployDir: string;
|
||||
kubeconfigPath: string;
|
||||
k3sContainer: string;
|
||||
imageTag: string;
|
||||
timeoutMs: number;
|
||||
skipBuild: boolean;
|
||||
runNow: boolean;
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
step: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
interface DispatchResult {
|
||||
ok: boolean;
|
||||
taskId: string | null;
|
||||
status: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
interface BackgroundPoll {
|
||||
done: boolean;
|
||||
exitCode: number | null;
|
||||
logTail: string;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function optionValue(args: string[], names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) continue;
|
||||
const raw = args[index + 1];
|
||||
if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`);
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function positiveIntegerOption(args: string[], names: string[], defaultValue: number): number {
|
||||
const raw = optionValue(args, names);
|
||||
if (raw === undefined) return defaultValue;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${names[0]} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positionalArgs(args: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const value = args[index] ?? "";
|
||||
if (value.startsWith("--")) {
|
||||
if (!["--skip-build", "--run-now"].includes(value)) index += 1;
|
||||
continue;
|
||||
}
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function codeQueueRepoUrl(config: UniDeskConfig): string {
|
||||
const service = config.microservices.find((item) => item.id === "code-queue");
|
||||
if (service === undefined) throw new Error("config.json does not contain microservice id=code-queue");
|
||||
return service.repository.url;
|
||||
}
|
||||
|
||||
function parseOptions(config: UniDeskConfig, args: string[]): DeployCliOptions {
|
||||
const commitId = optionValue(args, ["--commit", "--commit-id"]) ?? positionalArgs(args)[0] ?? "";
|
||||
if (commitId.length === 0) {
|
||||
throw new Error("codex deploy requires a commit ID: codex deploy <commitId>");
|
||||
}
|
||||
return {
|
||||
commitId,
|
||||
providerId: optionValue(args, ["--provider-id", "--provider"]) ?? defaultProviderId,
|
||||
repoUrl: optionValue(args, ["--repo-url"]) ?? codeQueueRepoUrl(config),
|
||||
sourceRepoDir: optionValue(args, ["--source-repo-dir"]) ?? defaultSourceRepoDir,
|
||||
deployDir: optionValue(args, ["--deploy-dir"]) ?? defaultDeployDir,
|
||||
kubeconfigPath: optionValue(args, ["--kubeconfig"]) ?? defaultKubeconfigPath,
|
||||
k3sContainer: optionValue(args, ["--k3s-container"]) ?? defaultK3sContainer,
|
||||
imageTag: optionValue(args, ["--image"]) ?? defaultImageTag,
|
||||
timeoutMs: positiveIntegerOption(args, ["--timeout-ms"], defaultTimeoutMs),
|
||||
skipBuild: args.includes("--skip-build"),
|
||||
runNow: args.includes("--run-now"),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOptions(options: DeployCliOptions): void {
|
||||
if (!/^[0-9a-f]{7,40}$/iu.test(options.commitId)) {
|
||||
throw new Error(`commit id must be a 7-40 character hex SHA, got: ${options.commitId}`);
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]+$/u.test(options.providerId)) throw new Error(`invalid provider id: ${options.providerId}`);
|
||||
if (options.providerId !== defaultProviderId) {
|
||||
throw new Error(`codex deploy currently supports only ${defaultProviderId}; got ${options.providerId}`);
|
||||
}
|
||||
if (!/^https?:\/\//u.test(options.repoUrl) && !/^[A-Za-z0-9_.-]+@/u.test(options.repoUrl)) {
|
||||
throw new Error(`repo url must be an http(s) or ssh git URL, got: ${options.repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number): number {
|
||||
return Math.max(0, Date.now() - startedAt);
|
||||
}
|
||||
|
||||
function compactTail(text: string, maxChars = 900): string {
|
||||
return text.length > maxChars ? text.slice(text.length - maxChars) : text;
|
||||
}
|
||||
|
||||
function progressLine(step: string, message: string, detail?: unknown): void {
|
||||
const payload = detail === undefined
|
||||
? { at: nowIso(), step, message }
|
||||
: { at: nowIso(), step, message, detail };
|
||||
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function coreBody(response: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(response);
|
||||
return asRecord(record?.body);
|
||||
}
|
||||
|
||||
function dispatchStdout(raw: unknown): string {
|
||||
const task = asRecord(raw);
|
||||
const result = asRecord(task?.result);
|
||||
return asString(result?.stdout);
|
||||
}
|
||||
|
||||
function parseFullCommit(value: string): string {
|
||||
const match = value.match(/\b[0-9a-f]{40}\b/iu);
|
||||
return match?.[0]?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
async function dispatchSsh(
|
||||
config: UniDeskConfig,
|
||||
providerId: string,
|
||||
command: string,
|
||||
cwd: string | null,
|
||||
waitMs = shortDispatchWaitMs,
|
||||
remoteTimeoutMs = shortRemoteTimeoutMs,
|
||||
): Promise<DispatchResult> {
|
||||
const dispatchResponse = coreInternalFetch("/api/dispatch", {
|
||||
method: "POST",
|
||||
body: {
|
||||
providerId,
|
||||
command: "host.ssh",
|
||||
payload: {
|
||||
source: "codex-deploy",
|
||||
mode: "exec",
|
||||
command,
|
||||
timeoutMs: remoteTimeoutMs,
|
||||
...(cwd === null ? {} : { cwd }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const dispatchBody = coreBody(dispatchResponse);
|
||||
const taskId = asString(dispatchBody?.taskId);
|
||||
if (dispatchBody?.ok !== true || taskId.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
taskId: taskId || null,
|
||||
status: null,
|
||||
stdout: "",
|
||||
stderr: asString(dispatchBody?.error) || "dispatch did not return a task id",
|
||||
exitCode: null,
|
||||
raw: dispatchResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const deadline = Date.now() + waitMs;
|
||||
let latest: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
const task = asRecord(coreBody(latest)?.task);
|
||||
const status = asString(task?.status);
|
||||
if (status === "succeeded" || status === "failed") {
|
||||
const result = asRecord(task?.result);
|
||||
const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null;
|
||||
const stdout = asString(result?.stdout);
|
||||
const stderr = asString(result?.stderr);
|
||||
return {
|
||||
ok: status === "succeeded" && (exitCode === null || exitCode === 0),
|
||||
taskId,
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
raw: task,
|
||||
};
|
||||
}
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
taskId,
|
||||
status: "timeout",
|
||||
stdout: "",
|
||||
stderr: `host.ssh task ${taskId} did not finish within ${waitMs}ms`,
|
||||
exitCode: null,
|
||||
raw: latest,
|
||||
};
|
||||
}
|
||||
|
||||
async function launchBackground(
|
||||
config: UniDeskConfig,
|
||||
providerId: string,
|
||||
shellScript: string,
|
||||
cwd: string,
|
||||
logFile: string,
|
||||
sentinelFile: string,
|
||||
): Promise<{ ok: boolean; pid: string; raw: unknown; error: string }> {
|
||||
const wrapped = [
|
||||
`bash -lc ${shellQuote(shellScript)}`,
|
||||
"code=$?",
|
||||
`printf '%s\\n' "$code" > ${shellQuote(sentinelFile)}`,
|
||||
"exit \"$code\"",
|
||||
].join("; ");
|
||||
const command = [
|
||||
`rm -f ${shellQuote(sentinelFile)} ${shellQuote(logFile)}`,
|
||||
`nohup bash -lc ${shellQuote(wrapped)} > ${shellQuote(logFile)} 2>&1 < /dev/null & echo $!`,
|
||||
].join("; ");
|
||||
const result = await dispatchSsh(config, providerId, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
||||
const pid = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!result.ok || !/^\d+$/u.test(pid)) {
|
||||
return { ok: false, pid: "", raw: result.raw, error: result.stderr || result.stdout || "failed to launch background command" };
|
||||
}
|
||||
return { ok: true, pid, raw: result.raw, error: "" };
|
||||
}
|
||||
|
||||
async function pollBackground(
|
||||
config: UniDeskConfig,
|
||||
providerId: string,
|
||||
cwd: string,
|
||||
logFile: string,
|
||||
sentinelFile: string,
|
||||
): Promise<BackgroundPoll> {
|
||||
const command = [
|
||||
`if [ -f ${shellQuote(sentinelFile)} ]; then printf 'SENTINEL:%s\\n' "$(cat ${shellQuote(sentinelFile)} 2>/dev/null || true)"; else echo RUNNING; fi`,
|
||||
`tail -n 80 ${shellQuote(logFile)} 2>/dev/null || true`,
|
||||
].join("; ");
|
||||
const result = await dispatchSsh(config, providerId, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
||||
const stdout = result.stdout.trimEnd();
|
||||
const [head = "", ...rest] = stdout.split("\n");
|
||||
if (head.startsWith("SENTINEL:")) {
|
||||
const rawExitCode = head.slice("SENTINEL:".length).trim();
|
||||
const exitCode = /^\d+$/u.test(rawExitCode) ? Number(rawExitCode) : null;
|
||||
return { done: true, exitCode, logTail: rest.join("\n").trim(), raw: result.raw };
|
||||
}
|
||||
return { done: false, exitCode: null, logTail: rest.join("\n").trim(), raw: result.raw };
|
||||
}
|
||||
|
||||
async function backgroundStep(
|
||||
config: UniDeskConfig,
|
||||
options: DeployCliOptions,
|
||||
step: string,
|
||||
shellScript: string,
|
||||
timeoutMs: number,
|
||||
cwd: string | null = options.deployDir,
|
||||
): Promise<StepResult> {
|
||||
const startedAt = nowIso();
|
||||
const startedMs = Date.now();
|
||||
const runId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const logFile = `/tmp/unidesk-codex-deploy-${step}-${runId}.log`;
|
||||
const sentinelFile = `/tmp/unidesk-codex-deploy-${step}-${runId}.done`;
|
||||
progressLine(step, "launching remote background step", { logFile, sentinelFile, timeoutMs });
|
||||
const launch = await launchBackground(config, options.providerId, shellScript, cwd ?? "/home/ubuntu", logFile, sentinelFile);
|
||||
if (!launch.ok) {
|
||||
return { step, ok: false, detail: launch.error, startedAt, finishedAt: nowIso(), raw: launch.raw };
|
||||
}
|
||||
progressLine(step, "remote background step started", { pid: launch.pid, logFile });
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastTail = "";
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(pollIntervalMs);
|
||||
const poll = await pollBackground(config, options.providerId, cwd ?? "/home/ubuntu", logFile, sentinelFile);
|
||||
const tail = compactTail(poll.logTail, 1200);
|
||||
if (tail.length > 0 && tail !== lastTail) {
|
||||
lastTail = tail;
|
||||
progressLine(step, "remote log tail", { elapsedMs: elapsedMs(startedMs), tail });
|
||||
}
|
||||
if (poll.done) {
|
||||
const ok = poll.exitCode === 0;
|
||||
return {
|
||||
step,
|
||||
ok,
|
||||
detail: ok
|
||||
? `completed in ${elapsedMs(startedMs)}ms; log=${logFile}`
|
||||
: `failed with exit ${poll.exitCode}; log=${logFile}; tail=${compactTail(poll.logTail)}`,
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
raw: poll.raw,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { step, ok: false, detail: `timed out after ${timeoutMs}ms; log=${logFile}`, startedAt, finishedAt: nowIso(), raw: null };
|
||||
}
|
||||
|
||||
function bashScriptCommand(script: string): string {
|
||||
return `bash -lc ${shellQuote(script)}`;
|
||||
}
|
||||
|
||||
async function directStep(
|
||||
config: UniDeskConfig,
|
||||
options: DeployCliOptions,
|
||||
step: string,
|
||||
command: string,
|
||||
cwd: string | null,
|
||||
waitMs = 60_000,
|
||||
remoteTimeoutMs = 45_000,
|
||||
): Promise<StepResult> {
|
||||
const startedAt = nowIso();
|
||||
const startedMs = Date.now();
|
||||
progressLine(step, "running remote command");
|
||||
const result = await dispatchSsh(config, options.providerId, command, cwd, waitMs, remoteTimeoutMs);
|
||||
const detail = compactTail([result.stdout, result.stderr].filter(Boolean).join("\n"), 1200);
|
||||
return {
|
||||
step,
|
||||
ok: result.ok,
|
||||
detail: result.ok ? detail || `completed in ${elapsedMs(startedMs)}ms` : detail || `failed with status=${result.status} exit=${result.exitCode}`,
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
raw: result.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareSourceScript(options: DeployCliOptions, exportDir: string): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`repo=${shellQuote(options.sourceRepoDir)}`,
|
||||
`repo_url=${shellQuote(options.repoUrl)}`,
|
||||
`commit=${shellQuote(options.commitId)}`,
|
||||
`export_dir=${shellQuote(exportDir)}`,
|
||||
"mkdir -p \"$(dirname \"$repo\")\"",
|
||||
"if [ ! -d \"$repo/.git\" ]; then rm -rf \"$repo\"; git clone --no-checkout \"$repo_url\" \"$repo\"; fi",
|
||||
"cd \"$repo\"",
|
||||
"git remote get-url origin >/dev/null",
|
||||
"git fetch --no-tags origin \"$commit\" || git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'",
|
||||
"resolved=$(git rev-parse --verify \"$commit^{commit}\")",
|
||||
"rm -rf \"$export_dir\"",
|
||||
"mkdir -p \"$export_dir\"",
|
||||
"git archive --format=tar \"$resolved\" | tar -xf - -C \"$export_dir\"",
|
||||
"printf 'resolved_commit=%s\\nexport_dir=%s\\n' \"$resolved\" \"$export_dir\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolveCommitScript(options: DeployCliOptions): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`repo=${shellQuote(options.sourceRepoDir)}`,
|
||||
`commit=${shellQuote(options.commitId)}`,
|
||||
"git -C \"$repo\" rev-parse --verify \"$commit^{commit}\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function syncDeployScript(options: DeployCliOptions, exportDir: string): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`export_dir=${shellQuote(exportDir)}`,
|
||||
`deploy_dir=${shellQuote(options.deployDir)}`,
|
||||
"test -f \"$export_dir/src/components/microservices/code-queue/Dockerfile\"",
|
||||
"mkdir -p \"$deploy_dir\"",
|
||||
[
|
||||
"rsync -a --delete",
|
||||
"--exclude '.git/'",
|
||||
"--exclude '.state/'",
|
||||
"--exclude 'logs/'",
|
||||
"--exclude '**/node_modules/'",
|
||||
"--exclude '**/dist/'",
|
||||
"\"$export_dir/\"",
|
||||
"\"$deploy_dir/\"",
|
||||
].join(" "),
|
||||
"test -f \"$deploy_dir/src/components/microservices/code-queue/Dockerfile\"",
|
||||
"test -f \"$deploy_dir/src/components/microservices/v3sctl-adapter/v3s/code-queue.k8s.yaml\"",
|
||||
"printf 'synced deploy tree to %s\\n' \"$deploy_dir\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildImageScript(options: DeployCliOptions): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`deploy_dir=${shellQuote(options.deployDir)}`,
|
||||
`image=${shellQuote(options.imageTag)}`,
|
||||
"cd \"$deploy_dir\"",
|
||||
"docker build -t \"$image\" -f src/components/microservices/code-queue/Dockerfile .",
|
||||
"docker image inspect \"$image\" --format 'image_id={{.Id}} repo_tags={{json .RepoTags}}'",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function importImageScript(options: DeployCliOptions): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`image=${shellQuote(options.imageTag)}`,
|
||||
`k3s_container=${shellQuote(options.k3sContainer)}`,
|
||||
"docker image inspect \"$image\" >/dev/null",
|
||||
"docker ps --format '{{.Names}}' | grep -Fx \"$k3s_container\" >/dev/null",
|
||||
"docker save \"$image\" | docker exec -i \"$k3s_container\" ctr -n k8s.io images import -",
|
||||
"docker exec \"$k3s_container\" ctr -n k8s.io images ls | grep -F \"$image\" || true",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function applyManifestCommand(options: DeployCliOptions): string {
|
||||
const manifest = `${options.deployDir}/${k8sManifestRelPath}`;
|
||||
return `KUBECONFIG=${shellQuote(options.kubeconfigPath)} kubectl apply -f ${shellQuote(manifest)}`;
|
||||
}
|
||||
|
||||
function stampDeployCommitScript(options: DeployCliOptions, resolvedCommit: string): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`kubeconfig=${shellQuote(options.kubeconfigPath)}`,
|
||||
`namespace=${shellQuote(k8sNamespace)}`,
|
||||
`deployment=${shellQuote(k8sDeployment)}`,
|
||||
`tcp_deployment=${shellQuote(tcpEgressDeployment)}`,
|
||||
`resolved_commit=${shellQuote(resolvedCommit)}`,
|
||||
`requested_commit=${shellQuote(options.commitId)}`,
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" set env \"deployment/$tcp_deployment\" \"deployment/$deployment\" CODE_QUEUE_DEPLOY_COMMIT=\"$resolved_commit\" CODE_QUEUE_DEPLOY_REQUESTED_COMMIT=\"$requested_commit\"",
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" annotate \"deployment/$tcp_deployment\" \"deployment/$deployment\" unidesk.ai/deploy-commit=\"$resolved_commit\" unidesk.ai/deploy-requested-commit=\"$requested_commit\" --overwrite",
|
||||
"current=$(KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" get deploy \"$deployment\" -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name==\"CODE_QUEUE_DEPLOY_COMMIT\")].value}')",
|
||||
"test \"$current\" = \"$resolved_commit\"",
|
||||
"printf 'deployment_commit=%s\\nrequested_commit=%s\\n' \"$current\" \"$requested_commit\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function rolloutRestartCommand(options: DeployCliOptions): string {
|
||||
return `KUBECONFIG=${shellQuote(options.kubeconfigPath)} kubectl -n ${shellQuote(k8sNamespace)} rollout restart deployment/${shellQuote(tcpEgressDeployment)} deployment/${shellQuote(k8sDeployment)}`;
|
||||
}
|
||||
|
||||
function rolloutStatusScript(options: DeployCliOptions): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`kubeconfig=${shellQuote(options.kubeconfigPath)}`,
|
||||
`namespace=${shellQuote(k8sNamespace)}`,
|
||||
`deployment=${shellQuote(k8sDeployment)}`,
|
||||
`tcp_deployment=${shellQuote(tcpEgressDeployment)}`,
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" rollout status \"deployment/$tcp_deployment\" --timeout=120s",
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" rollout status \"deployment/$deployment\" --timeout=180s",
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" get deploy \"$tcp_deployment\" \"$deployment\" -o wide",
|
||||
"KUBECONFIG=\"$kubeconfig\" kubectl -n \"$namespace\" get pods -l app.kubernetes.io/name=code-queue,unidesk.ai/instance-id=D601 -o wide",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function healthDeployCommit(body: Record<string, unknown> | null): string {
|
||||
const deploy = asRecord(body?.deploy);
|
||||
return asString(deploy?.commit).toLowerCase();
|
||||
}
|
||||
|
||||
function healthProbeSummary(body: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (body === null) return null;
|
||||
return {
|
||||
ok: body.ok,
|
||||
service: body.service,
|
||||
instanceId: body.instanceId,
|
||||
deploy: body.deploy,
|
||||
status: body.status,
|
||||
databaseReady: body.databaseReady,
|
||||
startedAt: body.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function microserviceHealthStep(config: UniDeskConfig, expectedCommit: string, timeoutMs: number): Promise<StepResult> {
|
||||
const step = "unidesk-health";
|
||||
const startedAt = nowIso();
|
||||
const startedMs = Date.now();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest: unknown = null;
|
||||
let latestCommit = "";
|
||||
while (Date.now() < deadline) {
|
||||
latest = coreInternalFetch("/api/microservices/code-queue/health");
|
||||
const record = asRecord(latest);
|
||||
const body = asRecord(record?.body);
|
||||
latestCommit = healthDeployCommit(body);
|
||||
const commitMatches = latestCommit === expectedCommit;
|
||||
const ok = record?.ok === true && body?.ok !== false && commitMatches;
|
||||
progressLine(step, "health probe", { ok, status: record?.status ?? null, expectedCommit, deployedCommit: latestCommit || null, commitMatches, body: healthProbeSummary(body) });
|
||||
if (ok) {
|
||||
return {
|
||||
step,
|
||||
ok: true,
|
||||
detail: `Code Queue health passed with deployed commit ${latestCommit} in ${elapsedMs(startedMs)}ms`,
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
raw: latest,
|
||||
};
|
||||
}
|
||||
await Bun.sleep(pollIntervalMs);
|
||||
}
|
||||
return {
|
||||
step,
|
||||
ok: false,
|
||||
detail: `Code Queue health did not expose expected commit ${expectedCommit} within ${timeoutMs}ms; latest deployed commit=${latestCommit || "none"}`,
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
raw: latest,
|
||||
};
|
||||
}
|
||||
|
||||
function remainingTimeout(deadline: number, fallbackMs: number): number {
|
||||
return Math.max(30_000, Math.min(fallbackMs, deadline - Date.now()));
|
||||
}
|
||||
|
||||
export async function codexDeploy(config: UniDeskConfig, options: DeployCliOptions): Promise<unknown> {
|
||||
validateOptions(options);
|
||||
const startedAt = nowIso();
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
const exportDir = `/tmp/unidesk-codex-deploy-src-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const steps: StepResult[] = [];
|
||||
const pushStep = (step: StepResult): boolean => {
|
||||
steps.push(step);
|
||||
progressLine(step.step, step.ok ? "step succeeded" : "step failed", { detail: step.detail });
|
||||
return step.ok;
|
||||
};
|
||||
|
||||
progressLine("deploy", "starting Code Queue deployment", {
|
||||
commitId: options.commitId,
|
||||
providerId: options.providerId,
|
||||
repoUrl: options.repoUrl,
|
||||
sourceRepoDir: options.sourceRepoDir,
|
||||
deployDir: options.deployDir,
|
||||
imageTag: options.imageTag,
|
||||
timeoutMs: options.timeoutMs,
|
||||
skipBuild: options.skipBuild,
|
||||
});
|
||||
|
||||
const prepare = await backgroundStep(config, options, "prepare-source", prepareSourceScript(options, exportDir), remainingTimeout(deadline, 180_000), null);
|
||||
if (!pushStep(prepare)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
|
||||
const resolveCommit = await directStep(config, options, "resolve-commit", bashScriptCommand(resolveCommitScript(options)), null, 60_000, 45_000);
|
||||
if (!pushStep(resolveCommit)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
const resolvedCommit = parseFullCommit(dispatchStdout(resolveCommit.raw) || resolveCommit.detail);
|
||||
if (resolvedCommit.length !== 40) {
|
||||
const parseFailure = {
|
||||
step: "resolve-commit-parse",
|
||||
ok: false,
|
||||
detail: `remote commit did not resolve to a full 40-character SHA; output=${resolveCommit.detail}`,
|
||||
startedAt: nowIso(),
|
||||
finishedAt: nowIso(),
|
||||
};
|
||||
pushStep(parseFailure);
|
||||
return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
}
|
||||
progressLine("resolve-commit", "resolved requested commit", { requestedCommit: options.commitId, resolvedCommit });
|
||||
|
||||
const sync = await directStep(config, options, "sync-deploy-tree", bashScriptCommand(syncDeployScript(options, exportDir)), null, 60_000, 45_000);
|
||||
if (!pushStep(sync)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
|
||||
if (options.skipBuild) {
|
||||
steps.push({ step: "docker-build", ok: true, detail: "skipped by --skip-build", startedAt: nowIso(), finishedAt: nowIso() });
|
||||
steps.push({ step: "import-k3s-image", ok: true, detail: "skipped by --skip-build", startedAt: nowIso(), finishedAt: nowIso() });
|
||||
} else {
|
||||
const build = await backgroundStep(config, options, "docker-build", buildImageScript(options), remainingTimeout(deadline, 540_000));
|
||||
if (!pushStep(build)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
const imageImport = await backgroundStep(config, options, "import-k3s-image", importImageScript(options), remainingTimeout(deadline, 180_000));
|
||||
if (!pushStep(imageImport)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
}
|
||||
|
||||
const apply = await directStep(config, options, "kubectl-apply", applyManifestCommand(options), options.deployDir, 60_000, 45_000);
|
||||
if (!pushStep(apply)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
|
||||
const stampCommit = await directStep(config, options, "stamp-deploy-commit", bashScriptCommand(stampDeployCommitScript(options, resolvedCommit)), options.deployDir, 60_000, 45_000);
|
||||
if (!pushStep(stampCommit)) return { ok: false, startedAt, finishedAt: nowIso(), options, resolvedCommit, steps };
|
||||
|
||||
const restart = await directStep(config, options, "rollout-restart", rolloutRestartCommand(options), options.deployDir, 60_000, 45_000);
|
||||
if (!pushStep(restart)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
|
||||
const rollout = await backgroundStep(config, options, "rollout-status", rolloutStatusScript(options), remainingTimeout(deadline, 240_000));
|
||||
if (!pushStep(rollout)) return { ok: false, startedAt, finishedAt: nowIso(), options, steps };
|
||||
|
||||
const health = await microserviceHealthStep(config, resolvedCommit, remainingTimeout(deadline, 90_000));
|
||||
pushStep(health);
|
||||
|
||||
return {
|
||||
ok: health.ok,
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
options,
|
||||
resolvedCommit,
|
||||
steps,
|
||||
statusCommands: {
|
||||
health: "bun scripts/cli.ts microservice health code-queue",
|
||||
overview: "bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deployJobCommand(options: DeployCliOptions): string[] {
|
||||
const command = [
|
||||
process.execPath,
|
||||
rootPath("scripts", "cli.ts"),
|
||||
"codex",
|
||||
"deploy",
|
||||
options.commitId,
|
||||
"--run-now",
|
||||
"--provider-id",
|
||||
options.providerId,
|
||||
"--repo-url",
|
||||
options.repoUrl,
|
||||
"--source-repo-dir",
|
||||
options.sourceRepoDir,
|
||||
"--deploy-dir",
|
||||
options.deployDir,
|
||||
"--kubeconfig",
|
||||
options.kubeconfigPath,
|
||||
"--k3s-container",
|
||||
options.k3sContainer,
|
||||
"--image",
|
||||
options.imageTag,
|
||||
"--timeout-ms",
|
||||
String(options.timeoutMs),
|
||||
];
|
||||
if (options.skipBuild) command.push("--skip-build");
|
||||
return command;
|
||||
}
|
||||
|
||||
function startDeployJob(options: DeployCliOptions): { ok: true; mode: "async-job"; job: JobRecord; commitId: string; providerId: string; statusCommand: string; tailCommand: string; note: string } {
|
||||
const command = deployJobCommand(options);
|
||||
const job = startJob("codex_deploy", command, `Deploy Code Queue ${options.commitId} to ${options.providerId} v3s/k8s`);
|
||||
return {
|
||||
ok: true,
|
||||
mode: "async-job",
|
||||
job,
|
||||
commitId: options.commitId,
|
||||
providerId: options.providerId,
|
||||
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
|
||||
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
|
||||
note: "Deployment continues in the background: fetch remote commit, export tracked files, sync D601 deploy tree, build/import image, apply k8s manifest, restart rollout, then verify Code Queue health.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodexDeployCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const options = parseOptions(config, args);
|
||||
validateOptions(options);
|
||||
if (!options.runNow) return startDeployJob(options);
|
||||
return codexDeploy(config, options);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user