hy-app-monitor/monitors/yumi/chatapp_cron_deploy.py
2026-04-27 18:42:56 +08:00

649 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
import copy
from datetime import datetime
import json
from pathlib import Path
import re
import shlex
import shutil
import tempfile
import threading
import time
from typing import Any, Callable
import uuid
from core.config import (
BUILD_DOCKER_PLATFORM,
CHATAPP_CRON_BINARY_PATH,
CHATAPP_CRON_COMPOSE_FILE,
CHATAPP_CRON_COMPOSE_SERVICE,
CHATAPP_CRON_DEFAULT_GIT_REF,
CHATAPP_CRON_ENABLED,
CHATAPP_CRON_HOST,
CHATAPP_CRON_IP,
CHATAPP_CRON_INSTANCE_ID,
CHATAPP_CRON_RELEASE_META_PATH,
CHATAPP_CRON_REMOTE_DIR,
CHATAPP_CRON_REPO_ROOT,
CHATAPP_CRON_REPO_URL,
CHATAPP_CRON_SERVICE_NAME,
CHATAPP_CRON_SSH_HOST,
CHATAPP_CRON_SSH_PORT,
CHATAPP_CRON_SSH_USE_SUDO,
CHATAPP_CRON_SSH_USER,
ROOT,
SERVICE_BUILD_TIMEOUT_SECONDS,
SERVICE_PULL_TIMEOUT_SECONDS,
utc_now,
)
from .service_release import (
create_temp_worktree,
emit_progress,
ensure_repo_root,
fetch_repo_refs,
local_command_output,
remove_temp_worktree,
resolve_repo_commit,
run_logged_command,
sanitized_ref_fragment,
short_text,
)
from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display
# 后台任务的日志回调统一走这个签名。
ProgressLogger = Callable[[str, str, str], None]
# 内存里最多保留最近若干次 cron 部署记录。
MAX_CRON_DEPLOY_OPERATIONS = 10
# 任务状态和完整日志统一落到 run 目录。
CRON_DEPLOY_JOB_DIR = ROOT / "run" / "chatapp_cron_deploy_jobs"
# 统一保护任务状态的锁。
CRON_DEPLOY_JOB_LOCK = threading.Lock()
# 当前进程内的 cron 部署任务表。
CRON_DEPLOY_JOBS: dict[str, dict[str, Any]] = {}
# 记录任务插入顺序,便于淘汰最旧项。
CRON_DEPLOY_JOB_ORDER: list[str] = []
def chatapp_cron_release_enabled() -> bool:
# 只有目标机、运行目录和仓库信息齐备时才开放部署按钮。
return bool(
CHATAPP_CRON_ENABLED
and str(CHATAPP_CRON_SERVICE_NAME or "").strip()
and str(CHATAPP_CRON_HOST or "").strip()
and str(CHATAPP_CRON_REMOTE_DIR or "").strip()
and str(CHATAPP_CRON_BINARY_PATH or "").strip()
and str(CHATAPP_CRON_COMPOSE_FILE or "").strip()
and str(CHATAPP_CRON_COMPOSE_SERVICE or "").strip()
and (str(CHATAPP_CRON_SSH_HOST or "").strip() or str(CHATAPP_CRON_IP or "").strip())
and (str(CHATAPP_CRON_REPO_URL or "").strip() or str(CHATAPP_CRON_REPO_ROOT or "").strip())
)
def chatapp_cron_host_record() -> dict[str, Any]:
# chatapp-cron 不在 Yumi 业务服务 targets 里,部署主机单独从 .env 读取。
if not chatapp_cron_release_enabled():
raise RuntimeError("chatapp-cron deploy is not configured")
return {
"host": CHATAPP_CRON_HOST,
"ip": CHATAPP_CRON_IP,
"instanceId": CHATAPP_CRON_INSTANCE_ID,
"sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP,
"sshUser": CHATAPP_CRON_SSH_USER,
"sshUseSudo": CHATAPP_CRON_SSH_USE_SUDO,
"sshPort": CHATAPP_CRON_SSH_PORT,
}
def ensure_chatapp_cron_repo_root(*, progress: ProgressLogger | None = None) -> Path:
# 第一次使用时允许按配置仓库地址自动 clone 到 deploy 机。
repo_root = Path(CHATAPP_CRON_REPO_ROOT).expanduser().resolve()
if (repo_root / ".git").exists():
return repo_root
repo_url = str(CHATAPP_CRON_REPO_URL or "").strip()
if not repo_url:
raise RuntimeError("missing CHATAPP_CRON_REPO_URL in .env")
repo_root.parent.mkdir(parents=True, exist_ok=True)
emit_progress(progress, "cron.repo", f"初始化 chatapp-cron 仓库:{repo_url} -> {repo_root}")
local_command_output(
["git", "clone", repo_url, str(repo_root)],
cwd=repo_root.parent,
label="chatapp-cron clone repo",
timeout_seconds=900,
)
return ensure_repo_root(repo_root, "chatapp-cron")
def read_chatapp_cron_metadata() -> dict[str, Any]:
# 元数据只用于展示最近部署版本,读取失败不影响页面打开。
if not chatapp_cron_release_enabled():
return {}
try:
text = read_remote_text(chatapp_cron_host_record(), CHATAPP_CRON_RELEASE_META_PATH, timeout_seconds=30)
return json.loads(text or "{}")
except Exception as exc: # noqa: BLE001
return {"error": str(exc)}
def build_chatapp_cron_overview_payload() -> dict[str, Any]:
# 通用页加载时返回配置摘要和最近部署版本。
enabled = chatapp_cron_release_enabled()
settings = {
"enabled": enabled,
"serviceName": CHATAPP_CRON_SERVICE_NAME,
"host": CHATAPP_CRON_HOST,
"sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP,
"instanceId": CHATAPP_CRON_INSTANCE_ID,
"repoRoot": str(Path(CHATAPP_CRON_REPO_ROOT).expanduser()),
"repoUrl": CHATAPP_CRON_REPO_URL,
"defaultGitRef": CHATAPP_CRON_DEFAULT_GIT_REF,
"remoteDir": CHATAPP_CRON_REMOTE_DIR,
"composeFile": CHATAPP_CRON_COMPOSE_FILE,
"composeService": CHATAPP_CRON_COMPOSE_SERVICE,
"binaryPath": CHATAPP_CRON_BINARY_PATH,
"dockerPlatform": BUILD_DOCKER_PLATFORM,
}
return {
"ok": True,
"enabled": enabled,
"configured": enabled,
"updatedAt": utc_now(),
"settings": settings,
"metadata": read_chatapp_cron_metadata() if enabled else {},
}
def docker_builder_image_name(commit: str) -> str:
# 本地临时 builder 镜像只用于 docker cp 导出二进制。
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
tag = sanitized_ref_fragment(f"{commit[:12]}-{stamp}").lower()
return f"hy-app-monitor/chatapp-cron-builder:{tag}"
def build_chatapp_cron_binary(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]:
# 使用仓库 Dockerfile 的 builder stage 产出 linux/amd64 二进制,避免依赖宿主机 Go 版本。
repo_root = ensure_chatapp_cron_repo_root(progress=progress)
resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF
emit_progress(progress, "cron.build", f"刷新 chatapp-cron refs{resolved_ref}")
fetch_repo_refs(repo_root, "chatapp-cron")
commit = resolve_repo_commit(repo_root, resolved_ref, "chatapp-cron")
emit_progress(progress, "cron.build", f"chatapp-cron ref 已解析:{resolved_ref} -> {commit[:12]}")
worktree_root = create_temp_worktree(repo_root, commit, "chatapp-cron")
artifact_root = Path(tempfile.mkdtemp(prefix="hy-app-monitor-chatapp-cron-artifact-"))
builder_image = docker_builder_image_name(commit)
container_id = ""
binary_path = artifact_root / "week-star-job"
try:
emit_progress(progress, "cron.build", f"开始 Docker builder 构建:{BUILD_DOCKER_PLATFORM}")
run_logged_command(
["docker", "build", "--platform", BUILD_DOCKER_PLATFORM, "--target", "builder", "-t", builder_image, "."],
cwd=worktree_root,
label="chatapp-cron docker builder",
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
progress_stage="cron.build.docker",
progress=progress,
)
container_id = local_command_output(
["docker", "create", builder_image],
cwd=worktree_root,
label="chatapp-cron create builder container",
timeout_seconds=120,
)
local_command_output(
["docker", "cp", f"{container_id}:/out/week-star-job", str(binary_path)],
cwd=worktree_root,
label="chatapp-cron export binary",
timeout_seconds=180,
)
binary_path.chmod(0o755)
emit_progress(progress, "cron.build", f"二进制已生成:{binary_path}")
return {
"kind": "chatapp-cron",
"gitRef": resolved_ref,
"commit": commit,
"binaryPath": str(binary_path),
"builderImage": builder_image,
}
finally:
if container_id:
try:
local_command_output(
["docker", "rm", "-f", container_id],
cwd=worktree_root,
label="chatapp-cron remove builder container",
timeout_seconds=60,
)
except Exception: # noqa: BLE001
pass
try:
local_command_output(
["docker", "rmi", "-f", builder_image],
cwd=worktree_root,
label="chatapp-cron remove builder image",
timeout_seconds=120,
)
except Exception: # noqa: BLE001
pass
remove_temp_worktree(repo_root, worktree_root)
def activate_chatapp_cron_binary(build_result: dict[str, Any], *, progress: ProgressLogger | None = None) -> dict[str, Any]:
# 把新二进制传到 cron 机器,再用 docker compose 重建 week-star-job。
host = chatapp_cron_host_record()
binary_path = Path(str(build_result["binaryPath"]))
if not binary_path.exists():
raise RuntimeError(f"chatapp-cron binary missing: {binary_path}")
remote_tmp = f"/tmp/hy-app-monitor-chatapp-cron-{build_result['commit'][:12]}-{int(time.time())}"
emit_progress(progress, "cron.deploy.copy", f"上传二进制到 {target_display(host)}:{remote_tmp}")
copy_local_path_to_host(
host,
binary_path,
remote_tmp,
timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS,
)
metadata = {
"service": CHATAPP_CRON_SERVICE_NAME,
"component": CHATAPP_CRON_COMPOSE_SERVICE,
"host": CHATAPP_CRON_HOST,
"gitRef": build_result["gitRef"],
"commit": build_result["commit"],
"deployedAt": utc_now(),
"binaryPath": CHATAPP_CRON_BINARY_PATH,
"composeFile": CHATAPP_CRON_COMPOSE_FILE,
"remoteDir": CHATAPP_CRON_REMOTE_DIR,
}
metadata_blob = base64.b64encode(json.dumps(metadata, ensure_ascii=False, indent=2).encode("utf-8")).decode("ascii")
script = f"""set -euo pipefail
REMOTE_DIR={shlex.quote(CHATAPP_CRON_REMOTE_DIR)}
COMPOSE_FILE={shlex.quote(CHATAPP_CRON_COMPOSE_FILE)}
COMPOSE_SERVICE={shlex.quote(CHATAPP_CRON_COMPOSE_SERVICE)}
BINARY_PATH={shlex.quote(CHATAPP_CRON_BINARY_PATH)}
REMOTE_TMP={shlex.quote(remote_tmp)}
META_PATH={shlex.quote(CHATAPP_CRON_RELEASE_META_PATH)}
META_BLOB={shlex.quote(metadata_blob)}
compose() {{
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
else
docker-compose "$@"
fi
}}
mkdir -p "$(dirname "$BINARY_PATH")" "$REMOTE_DIR/.backups"
if [ -f "$BINARY_PATH" ]; then
cp "$BINARY_PATH" "$REMOTE_DIR/.backups/week-star-job.$(date +%Y%m%d-%H%M%S)"
fi
install -m 0755 "$REMOTE_TMP" "$BINARY_PATH"
rm -f "$REMOTE_TMP"
cd "$REMOTE_DIR"
compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
for _ in $(seq 1 30); do
if compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE" | grep -Eiq 'running|up'; then
break
fi
sleep 2
done
STATUS_OUTPUT="$(compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE")"
printf '%s\\n' "$STATUS_OUTPUT"
if ! printf '%s\\n' "$STATUS_OUTPUT" | grep -Eiq 'running|up'; then
exit 1
fi
mkdir -p "$(dirname "$META_PATH")"
printf '%s' "$META_BLOB" | base64 -d > "$META_PATH"
"""
emit_progress(progress, "cron.deploy.activate", f"重建远端服务:{CHATAPP_CRON_COMPOSE_SERVICE}")
result = run_host_script_checked(
host,
script,
command_name="chatapp-cron-activate",
timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS,
)
emit_progress(progress, "cron.deploy.activate", "chatapp-cron 重启完成")
return {
"host": CHATAPP_CRON_HOST,
"target": result.get("target"),
"status": result.get("status"),
"output": short_text(str(result.get("output") or ""), 1000),
"metadata": metadata,
}
def deploy_chatapp_cron_payload(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]:
# 对外的部署主流程:构建二进制 -> 上传目标机 -> compose 重建。
if not chatapp_cron_release_enabled():
raise RuntimeError("chatapp-cron deploy is not configured")
started_at = time.time()
build_result = build_chatapp_cron_binary(git_ref, progress=progress)
try:
deploy_result = activate_chatapp_cron_binary(build_result, progress=progress)
return {
"ok": True,
"message": "chatapp-cron deploy complete",
"updatedAt": utc_now(),
"durationSeconds": round(time.time() - started_at, 2),
"build": {
"kind": build_result["kind"],
"gitRef": build_result["gitRef"],
"commit": build_result["commit"],
"services": [CHATAPP_CRON_SERVICE_NAME],
},
"deploy": deploy_result,
}
finally:
artifact_root = Path(str(build_result.get("binaryPath") or "")).parent
if str(artifact_root).startswith(tempfile.gettempdir()):
shutil.rmtree(artifact_root, ignore_errors=True)
def ensure_cron_deploy_job_dir() -> None:
# 持久化目录不存在时先创建。
CRON_DEPLOY_JOB_DIR.mkdir(parents=True, exist_ok=True)
def cron_deploy_job_state_path(operation_id: str) -> Path:
# 每个任务一份状态 JSON。
return CRON_DEPLOY_JOB_DIR / f"{operation_id}.json"
def cron_deploy_job_log_path(operation_id: str) -> Path:
# 每个任务一份完整文本日志。
return CRON_DEPLOY_JOB_DIR / f"{operation_id}.log"
def valid_operation_id(operation_id: str) -> bool:
# 只接受简单安全字符,避免路径拼接被滥用。
return re.fullmatch(r"[0-9A-Za-z_-]{6,64}", str(operation_id or "").strip()) is not None
def cron_operation_payload(payload: dict[str, Any]) -> dict[str, Any]:
# 对外返回和落盘都统一走这个结构。
return {
"operationId": payload["operationId"],
"kind": payload["kind"],
"status": payload["status"],
"stage": payload["stage"],
"message": payload["message"],
"error": payload["error"],
"startedAt": payload["startedAt"],
"updatedAt": payload["updatedAt"],
"finishedAt": payload["finishedAt"],
"logs": copy.deepcopy(payload.get("logs") or []),
"result": copy.deepcopy(payload.get("result")),
"request": copy.deepcopy(payload.get("request") or {}),
"logUrl": f"/api/chatapp-cron/deploy-log?id={payload['operationId']}",
}
def persist_cron_job_unlocked(payload: dict[str, Any]) -> None:
# 每次状态变化都落盘,服务重启后还能恢复最近任务。
ensure_cron_deploy_job_dir()
cron_deploy_job_state_path(payload["operationId"]).write_text(
json.dumps({"ok": True, "operation": cron_operation_payload(payload)}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def append_cron_log_file(operation_id: str, line: str) -> None:
# 完整日志单独写入文本文件,供页面查看。
ensure_cron_deploy_job_dir()
with cron_deploy_job_log_path(operation_id).open("a", encoding="utf-8") as handle:
handle.write(line)
def trim_cron_jobs_unlocked() -> None:
# 只保留最近若干次任务,老数据直接淘汰内存引用。
while len(CRON_DEPLOY_JOB_ORDER) > MAX_CRON_DEPLOY_OPERATIONS:
operation_id = CRON_DEPLOY_JOB_ORDER.pop(0)
CRON_DEPLOY_JOBS.pop(operation_id, None)
def create_cron_deploy_job_payload(git_ref: str) -> dict[str, Any]:
# 后台任务一创建就落一份初始状态,前端可以马上开始轮询。
resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF
operation_id = uuid.uuid4().hex[:12]
now = utc_now()
payload = {
"operationId": operation_id,
"kind": "chatapp-cron-deploy",
"status": "queued",
"stage": "queued",
"message": "chatapp-cron deploy queued",
"error": "",
"startedAt": now,
"updatedAt": now,
"finishedAt": "",
"logs": [],
"result": None,
"request": {
"gitRef": resolved_ref,
"service": CHATAPP_CRON_SERVICE_NAME,
},
}
with CRON_DEPLOY_JOB_LOCK:
ensure_cron_deploy_job_dir()
CRON_DEPLOY_JOBS[operation_id] = payload
CRON_DEPLOY_JOB_ORDER.append(operation_id)
cron_deploy_job_log_path(operation_id).write_text("", encoding="utf-8")
persist_cron_job_unlocked(payload)
trim_cron_jobs_unlocked()
return snapshot_cron_deploy_job(operation_id)
def append_cron_job_log(operation_id: str, stage: str, message: str, *, level: str = "info") -> None:
# 每条日志都带时间和阶段,页面和状态文件都保留日志。
text = str(message or "").rstrip()
if not text:
return
with CRON_DEPLOY_JOB_LOCK:
payload = CRON_DEPLOY_JOBS.get(operation_id)
if payload is None:
return
payload["updatedAt"] = utc_now()
payload["stage"] = str(stage or payload.get("stage") or "running")
payload["logs"].append(
{
"time": payload["updatedAt"],
"stage": str(stage or "").strip(),
"level": str(level or "info").strip() or "info",
"message": text,
}
)
append_cron_log_file(
operation_id,
f"[{payload['updatedAt']}] [{str(stage or '').strip() or payload['stage']}] [{str(level or 'info').strip() or 'info'}] {text}\n",
)
persist_cron_job_unlocked(payload)
def patch_cron_job(operation_id: str, **fields: Any) -> None:
# 任务元信息更新统一走这里,避免漏掉 updatedAt。
with CRON_DEPLOY_JOB_LOCK:
payload = CRON_DEPLOY_JOBS.get(operation_id)
if payload is None:
return
payload.update(fields)
payload["updatedAt"] = utc_now()
persist_cron_job_unlocked(payload)
def finish_cron_job(operation_id: str, *, result: dict[str, Any], status: str, error: str = "") -> None:
# 结束态统一收口,前端轮询时不用自己猜。
now = utc_now()
with CRON_DEPLOY_JOB_LOCK:
payload = CRON_DEPLOY_JOBS.get(operation_id)
if payload is None:
return
payload["status"] = status
payload["stage"] = status
payload["message"] = (
str(result.get("message") or "").strip()
or ("chatapp-cron deploy complete" if status == "success" else "chatapp-cron deploy failed")
)
payload["error"] = str(error or "").strip()
payload["finishedAt"] = now
payload["updatedAt"] = now
payload["result"] = result
persist_cron_job_unlocked(payload)
def snapshot_cron_deploy_job(operation_id: str) -> dict[str, Any]:
# HTTP 返回前复制一份,避免把锁外的可变对象直接暴露出去。
with CRON_DEPLOY_JOB_LOCK:
payload = CRON_DEPLOY_JOBS.get(operation_id)
if payload is None:
raise KeyError(operation_id)
return {"ok": True, "operation": cron_operation_payload(payload)}
def cron_job_progress_logger(operation_id: str) -> ProgressLogger:
# 部署主流程只关心 stage / level / message落盘细节都交给这里。
def _logger(stage: str, level: str, message: str) -> None:
append_cron_job_log(operation_id, stage, message, level=level)
return _logger
def run_chatapp_cron_deploy_job(operation_id: str, git_ref: str) -> None:
# 真正的部署逻辑放后台线程里执行。
patch_cron_job(operation_id, status="running", stage="prepare", message="chatapp-cron deploy running")
append_cron_job_log(operation_id, "prepare", f"开始部署 chatapp-cron{git_ref}")
try:
result = deploy_chatapp_cron_payload(git_ref, progress=cron_job_progress_logger(operation_id))
append_cron_job_log(operation_id, "success", "chatapp-cron 部署完成")
finish_cron_job(operation_id, result=result, status="success")
except Exception as exc: # noqa: BLE001
error = str(exc)
append_cron_job_log(operation_id, "failed", error, level="error")
finish_cron_job(
operation_id,
result={"ok": False, "updatedAt": utc_now(), "error": error},
status="failed",
error=error,
)
def start_chatapp_cron_deploy_job(git_ref: str) -> dict[str, Any]:
# 创建任务后立刻拉起后台线程,并把 operationId 返回给前端。
if not chatapp_cron_release_enabled():
raise RuntimeError("chatapp-cron deploy is not configured")
payload = create_cron_deploy_job_payload(git_ref)
operation_id = payload["operation"]["operationId"]
resolved_ref = payload["operation"]["request"]["gitRef"]
worker = threading.Thread(
target=run_chatapp_cron_deploy_job,
kwargs={"operation_id": operation_id, "git_ref": resolved_ref},
name=f"chatapp-cron-deploy-{operation_id}",
daemon=True,
)
worker.start()
return snapshot_cron_deploy_job(operation_id)
def recover_lost_cron_job_unlocked(payload: dict[str, Any]) -> None:
# 进程重启后,原后台线程已经不存在;把悬挂中的任务收口成失败态。
if payload.get("status") not in {"queued", "running"}:
return
now = utc_now()
message = "monitor process restarted before chatapp-cron deploy status could be finalized"
payload["status"] = "failed"
payload["stage"] = "failed"
payload["message"] = "chatapp-cron deploy interrupted"
payload["error"] = message
payload["updatedAt"] = now
payload["finishedAt"] = now
payload["logs"].append(
{
"time": now,
"stage": "failed",
"level": "error",
"message": message,
}
)
append_cron_log_file(payload["operationId"], f"[{now}] [failed] [error] {message}\n")
persist_cron_job_unlocked(payload)
def load_cron_job_from_disk(operation_id: str) -> dict[str, Any] | None:
# 任务不在内存时,从持久化状态恢复。
if not valid_operation_id(operation_id):
return None
state_path = cron_deploy_job_state_path(operation_id)
if not state_path.exists():
return None
payload = json.loads(state_path.read_text(encoding="utf-8"))
operation = dict(payload.get("operation") or {})
if not operation:
return None
restored = {
"operationId": str(operation.get("operationId") or operation_id).strip(),
"kind": str(operation.get("kind") or "chatapp-cron-deploy").strip() or "chatapp-cron-deploy",
"status": str(operation.get("status") or "").strip(),
"stage": str(operation.get("stage") or "").strip(),
"message": str(operation.get("message") or "").strip(),
"error": str(operation.get("error") or "").strip(),
"startedAt": str(operation.get("startedAt") or "").strip(),
"updatedAt": str(operation.get("updatedAt") or "").strip(),
"finishedAt": str(operation.get("finishedAt") or "").strip(),
"logs": list(operation.get("logs") or []),
"result": operation.get("result"),
"request": dict(operation.get("request") or {}),
}
recover_lost_cron_job_unlocked(restored)
return restored
def get_chatapp_cron_deploy_job_payload(operation_id: str) -> dict[str, Any]:
# 供 HTTP 轮询接口直接读取任务状态。
try:
return snapshot_cron_deploy_job(operation_id)
except KeyError:
restored = load_cron_job_from_disk(operation_id)
if restored is None:
raise
with CRON_DEPLOY_JOB_LOCK:
CRON_DEPLOY_JOBS[operation_id] = restored
if operation_id not in CRON_DEPLOY_JOB_ORDER:
CRON_DEPLOY_JOB_ORDER.append(operation_id)
trim_cron_jobs_unlocked()
return snapshot_cron_deploy_job(operation_id)
def get_chatapp_cron_deploy_log_text(operation_id: str) -> str:
# 下载或弹层展示完整任务日志。
if not valid_operation_id(operation_id):
raise KeyError(operation_id)
log_path = cron_deploy_job_log_path(operation_id)
if not log_path.exists():
if load_cron_job_from_disk(operation_id) is None:
raise KeyError(operation_id)
return log_path.read_text(encoding="utf-8") if log_path.exists() else ""