fix: surface pgdata backup read-only blockers

This commit is contained in:
Codex
2026-05-20 20:03:43 +00:00
parent c6a27e6c2b
commit d766875a39
6 changed files with 173 additions and 4 deletions
+4
View File
@@ -101,6 +101,10 @@
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D518 `/mnt/d/work/todo_note` 已复制到主 server `/root/todo_note`,运行 `bun scripts/cli.ts microservice list`,确认 `todo-note` 显示为 `providerId=main-server``public=false``frontendOnly=true`、仓库 URL `https://gitee.com/Lyon1998/todo_note``todo-note:4211` 后端映射和 `todo-note-backend` 容器摘要;运行 `bun scripts/cli.ts microservice health todo-note`,确认返回 `storage=postgres`;运行 `bun scripts/cli.ts microservice proxy todo-note /api/instances --max-body-bytes 8000`,确认能看到 `CONSTAR``大论文``找工作``小论文``事务` 五个迁移清单,总任务数不低于 100。随后通过 backend-core 或 `bun scripts/cli.ts e2e run` 执行临时清单 create/add/toggle/undo/delete 写入循环,确认 Todo Note 写入真实经过 backend-core、main-server provider-gateway、`todo-note-backend` 和主 PostgreSQL,且删除前必须按唯一临时清单名称重新选中临时清单,不能误删迁移清单。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Todo Note`,确认清单、树形任务、筛选、提醒、移动、撤销/重做、字号控制都以 React 控件展示,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。
## T22A PGDATA -> Baidu Netdisk 只读复核
阅读 `AGENTS.md``docs/reference/deploy.md`,然后用 cli 手动测试以下内容:只运行 `bun scripts/cli.ts server status``bun scripts/cli.ts schedule list``bun scripts/cli.ts schedule runs --limit 20``bun scripts/cli.ts microservice status baidu-netdisk``bun scripts/cli.ts microservice health baidu-netdisk``bun scripts/cli.ts microservice proxy baidu-netdisk /api/auth/status --raw``bun scripts/cli.ts microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw` 这些只读命令,确认能观察目标栈、Baidu auth health、配置存在性和最近 run/transfer 状态;不得运行 `schedule run``schedule retry-run``server start``server rebuild``deploy apply` 或 Baidu transfer POST。若 backend-core 目标容器缺失或仅有 `*.verify-*` 容器,CLI 必须非零退出并返回 `failureKind=target-stack-not-running``runnerDisposition=infra-blocked``targetStack.verifyOnlyObserved``readOnlyCommands``authorizationRequiredForRecovery`,而不是把空 stdout/stderr 当作通过。需要恢复时,测试记录必须先写明最小授权动作:恢复非空 Baidu Netdisk secret、启动或部署包含 backend-core/database/baidu-netdisk 的目标栈,随后再单独授权 `schedule retry-run``schedule run` 才能触发真实 PGDATA 备份。
## T23 D601 Code Queue User Service
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue-mgr` 显示为 `providerId=main-server``deployment.mode=internal-sidecar`、Compose 后端 `http://code-queue-mgr:4278``frontend.integrated=false`,并确认稳定 `code-queue` 条目说明队列管理/提交/历史/轻量 Trace 默认由主 server `code-queue-mgr` 负责,D601 k3s Code Queue 只负责 scheduler/runner/active run control 和执行态写回;使用 `bun scripts/cli.ts server rebuild code-queue-mgr` 重建主 server 控制面,再运行 `bun scripts/cli.ts microservice health code-queue-mgr``bun scripts/cli.ts microservice health code-queue``bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='``bun scripts/cli.ts codex submit --dry-run --queue <queueId> <prompt>``bun scripts/cli.ts codex steer <runningTaskId> --prompt-stdin --dry-run``bun scripts/cli.ts codex task <已有taskId>`,确认普通控制/读取路径经 backend-core 分流到 master `code-queue-mgr`,返回 `role=master-control-plane``schemaReady=true`、PostgreSQL pool 上限、`noRunnerDependencies=true`、任务初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时;`codex steer --dry-run` 必须返回 `path=/api/tasks/<runningTaskId>/steer``method=POST`、prompt 字符数和 `truncated=false`,且不触碰运行中 session。随后运行 `bun scripts/cli.ts codex deploy <已push的commitId>`,确认命令返回结构化错误并明确说明维护通道直连 D601 部署已禁用,且不会返回异步部署 job id;再运行 `bun scripts/cli.ts deploy apply --service code-queue --dry-run --run-now` 可只做 would-deploy 预览,去掉 `--dry-run` 时必须在运行时变更前拒绝 D601 直连部署。确认主 server 根目录 `docker-compose.yml` 中只存在 `code-queue-mgr` 而不存在执行面 `code-queue` service,并通过 `bun scripts/cli.ts ssh D601 argv bash -lc 'systemctl is-active k3s && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide && sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F docker.io/rancher/mirrored-pause:3.6 && ! docker ps --format "{{.Names}} {{.Image}}" | grep -E "[[:space:]]rancher/k3s:" && ! docker ps --format "{{.Names}}" | grep -Fx code-queue-backend'` 或等价检查证明 D601 k3s 是 WSL 原生 systemd 服务、native containerd 已有正确 pause sandbox 镜像、没有 active `rancher/k3s` 控制面容器且旧 direct Docker `code-queue-backend` 没有并行运行。运行 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw` 和执行面专属 `bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`,确认 D601 scheduler/read/write ready endpoint、`queue.storage.primary=postgres``queue.storage.postgresReady=true``queue.devReady.missingTools=[]``queue.devReady.docker.versionOk=true``queue.devReady.docker.composeOk=true``queue.devReady.ssh.ready` 只在需要跨 Provider SSH/Windows-native 任务时作为强制项。在 D601 active Code Queue Pod 内验证主 PostgreSQL 端口映射可执行 `select 1`,主 OA Event Flow 端口映射 `/health` 可访问,集群内 ClaudeQQ Service `http://claudeqq.unidesk.svc.cluster.local:3290/health` 可访问;这些映射不得成为任意公网入口。
+1 -1
View File
@@ -37,7 +37,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON`findings` 会带 `bodyKind=issue-body|comment-body``issueNumber``issueId``commentId``lineNumber``column``kind``snippet``classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true``gh pr list|view [--json ...]` 提供 REST 列表和详情,PR 字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt``gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]``gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title ...] [--dry-run]``gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]``gh pr comment delete <commentId> [--dry-run]``gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>``gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`PR 生命周期删除语义请使用 `close`
- PR dry-run/probe 的最小手动序列是:`bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 只读检查 token 来源、GitHub REST egress、repo 可见性和 issue read`bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <head> --dry-run` 检查创建计划;`bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --limit 5 --json number,title,state,url,head,base``bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,head,base` 做只读 PR 观察;`bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run` 检查评论计划;`bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk` 必须失败并返回结构化 `unsupported-command`。Code Queue runner 可用 `bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base master --head <head> --comment-pr <number>` 一次性跑只读 auth status 与 PR create/comment dry-run;该脚本不得输出 token 值,也不会创建、评论或 merge PR。
- `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core``publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId``sourceCommit``sourceRepo``dockerfile``imageRef``tag``digest``digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`
- `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule runs --limit N` 是全局历史视图,返回 `scope=global``scheduleId=null``schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId``observeCommand``schedule retry-run <failedRunId>` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId``scheduleId``newRunId``observeCommand`
- `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list``schedule get``schedule runs --limit N``schedule runs <scheduleId> --limit N` 是只读观察入口;`schedule run``schedule retry-run``schedule delete``schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global``scheduleId=null``schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId``observeCommand``schedule retry-run <failedRunId>` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId``scheduleId``newRunId``observeCommand`当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running``runnerDisposition=infra-blocked``readOnlyCommands``authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。
- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`
- `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQLD601 scheduler 只轮询并执行已入库任务。
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。需要旧式详细摘要时显式加 `--detail`;需要完整 prompt/response 文本时加 `--full`;需要工具调用、judge、attempt 全量摘要时使用 `--detail --full --tool-limit N`。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。
+2
View File
@@ -195,6 +195,8 @@ Existing service-specific commands such as Code Queue deploy are disabled as dir
Baidu Netdisk is the main-server `unidesk-direct` sample for artifact CD and a dependency of the PGDATA-to-Baidu-Netdisk backup path. Controlled dev validation and prod CD use the D601 registry artifact consumer: it verifies `unidesk/baidu-netdisk:<commit>` exists in the registry, streams the image to the main server through provider-gateway Host SSH, retags `baidu-netdisk` and `baidu-netdisk:<commit>`, stamps `UNIDESK_BAIDU_NETDISK_DEPLOY_*` in the canonical Compose env file, recreates only Compose service `baidu-netdisk`, and verifies container health, image labels, service id, `/health.deploy.commit`, and `/health.auth`. Live apply must fail or return degraded before success if `UNIDESK_BAIDU_NETDISK_CLIENT_ID`, `UNIDESK_BAIDU_NETDISK_CLIENT_SECRET`, or `UNIDESK_BAIDU_NETDISK_TOKEN_KEY` is absent from the controlled env source, or if `/health.auth.configured`, `clientIdConfigured`, `clientSecretConfigured`, `tokenKeyConfigured`, or `loggedIn` is not true after recreate. Dry-run only reports that these secret presences and auth fields are required and pending live check; it must not read or print secret values. It must not use `server rebuild baidu-netdisk`, mutable tags, dirty worktrees, hand-built images, or public `4244` exposure as deployment truth.
For PGDATA-to-Baidu-Netdisk incident review, the no-authorization read-only boundary is limited to `server status`, `schedule list`, `schedule get`, `schedule runs`, `microservice status/health baidu-netdisk`, `microservice proxy baidu-netdisk /api/auth/status --raw`, and `microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw`. These commands may report `failureKind=target-stack-not-running` when `unidesk-backend-core`, `unidesk-database`, or `baidu-netdisk-backend` is absent, especially when only `*.verify-*` containers are visible; that state is an infrastructure blocker, not a successful empty backup history. Recovery actions such as restoring non-empty Baidu secrets, `server start`, `server rebuild backend-core`, `server rebuild baidu-netdisk`, `deploy apply --env prod --service baidu-netdisk`, `schedule run`, or `schedule retry-run` can affect production or trigger a real backup and require explicit operator authorization.
Decision Center is a standard `k3sctl-managed` service in this model, but D601 maintenance-channel direct apply must not deploy it. Controlled CD for Decision Center uses the D601 registry artifact consumer in both dev and prod: it verifies `unidesk/decision-center:<commit>` exists in the registry, imports `unidesk-decision-center:<commit>` into native k3s containerd, applies the appropriate Decision Center manifest, stamps the Deployment, and verifies health through `/api/microservices/decision-center/health` while proving the live and requested commit match. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center.
MDTODO and ClaudeQQ are standard `k3sctl-managed` artifact consumers in the same model. Dev rollout lands in `unidesk-dev` using their dev manifests; production rollout lands in `unidesk` using the production manifests. Both services must pass dev validation before production rollout, must expose deploy metadata in health when practical, and must verify through the Kubernetes API service proxy instead of NodePort, hostPort or provider-gateway direct HTTP.
+12 -2
View File
@@ -94,6 +94,10 @@ function dispatchPayload(command: DebugDispatchCommand): Record<string, unknown>
return { source: "cli-debug", ...explicit };
}
function resultOk(result: unknown): boolean {
return typeof result !== "object" || result === null || !("ok" in result) || (result as { ok?: unknown }).ok !== false;
}
function latestJobId(): string {
const jobs = listJobs();
if (jobs.length === 0) throw new Error("No jobs found");
@@ -238,7 +242,10 @@ async function main(): Promise<void> {
}
if (top === "microservice") {
emitJson(commandName, await runMicroserviceCommand(config, args.slice(1)));
const result = await runMicroserviceCommand(config, args.slice(1));
const ok = resultOk(result);
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
@@ -261,7 +268,10 @@ async function main(): Promise<void> {
}
if (top === "schedule") {
emitJson(commandName, await runScheduleCommand(config, args.slice(1)));
const result = await runScheduleCommand(config, args.slice(1));
const ok = resultOk(result);
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
+27
View File
@@ -1,4 +1,5 @@
import { scheduleRetryRunObservation, scheduleRunObservation, scheduleRunsScope } from "./src/schedules";
import { backendCoreUnavailableDiagnostic } from "./src/microservices";
type JsonRecord = Record<string, unknown>;
@@ -43,6 +44,31 @@ export function runScheduleCliContract(): JsonRecord {
assertCondition(retryObservation.newRunId === "schedrun_retry", "retry-run output must expose newRunId", retryObservation);
assertCondition(String(retryObservation.observeCommand).includes("schedule runs unidesk-pgdata-baidu-daily --limit 20"), "retry-run output must expose observeCommand", retryObservation);
const unavailable = backendCoreUnavailableDiagnostic({
exitCode: 1,
stdoutTail: "",
stderrTail: "Error response from daemon: No such container: unidesk-backend-core\n",
relatedContainers: [
{ name: "unidesk-backend-core.verify-20260520T153456Z", image: "unidesk-backend-core:latest", status: "Exited (255)" },
{ name: "unidesk-database.verify-20260520T153456Z", image: "postgres:16-alpine", status: "Exited (255)" },
],
envPath: "/tmp/docker-compose.env",
baiduSecretPresence: {
envPath: "/tmp/docker-compose.env",
exists: true,
keys: {
UNIDESK_BAIDU_NETDISK_CLIENT_ID: { present: true, nonEmpty: false },
UNIDESK_BAIDU_NETDISK_CLIENT_SECRET: { present: true, nonEmpty: false },
UNIDESK_BAIDU_NETDISK_TOKEN_KEY: { present: true, nonEmpty: false },
},
},
});
assertCondition(unavailable.ok === false, "backend-core unavailable diagnostic must be a failed result", unavailable);
assertCondition(unavailable.failureKind === "target-stack-not-running", "backend-core unavailable diagnostic must classify target stack absence", unavailable);
assertCondition((unavailable.targetStack as JsonRecord).verifyOnlyObserved === true, "backend-core unavailable diagnostic must expose verify-only evidence", unavailable);
assertCondition(Array.isArray(unavailable.authorizationRequiredForRecovery), "backend-core unavailable diagnostic must list authorization-gated recovery actions", unavailable);
assertCondition(Array.isArray(unavailable.readOnlyCommands), "backend-core unavailable diagnostic must list read-only observation commands", unavailable);
return {
ok: true,
checks: [
@@ -51,6 +77,7 @@ export function runScheduleCliContract(): JsonRecord {
"numeric positional guard",
"run wait timeout observation",
"retry-run observation",
"target stack unavailable diagnostic",
],
};
}
+127 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
@@ -27,11 +28,136 @@ function dockerCoreFetchCommand(path: string, init?: { method?: string; body?: u
return ["docker", "exec", "unidesk-backend-core", "sh", "-lc", script];
}
interface RelatedContainer {
name: string;
image: string;
status: string;
}
function unquoteEnvValue(value: string): string {
return value.replace(/^['"]|['"]$/g, "");
}
function baiduSecretPresence(envPath: string): Record<string, unknown> {
const keys = [
"UNIDESK_BAIDU_NETDISK_CLIENT_ID",
"UNIDESK_BAIDU_NETDISK_CLIENT_SECRET",
"UNIDESK_BAIDU_NETDISK_TOKEN_KEY",
];
if (!existsSync(envPath)) {
return { envPath, exists: false, keys: Object.fromEntries(keys.map((key) => [key, { present: false, nonEmpty: false }])) };
}
const env = new Map<string, string>();
for (const line of readFileSync(envPath, "utf8").split(/\r?\n/u)) {
if (line.length === 0 || line.trimStart().startsWith("#")) continue;
const index = line.indexOf("=");
if (index === -1) continue;
env.set(line.slice(0, index), unquoteEnvValue(line.slice(index + 1)));
}
return {
envPath,
exists: true,
keys: Object.fromEntries(keys.map((key) => [key, { present: env.has(key), nonEmpty: Boolean((env.get(key) || "").trim()) }])),
};
}
function listRelatedStackContainers(): RelatedContainer[] {
const result = runCommand(["docker", "ps", "-a", "--format", "{{json .}}"], repoRoot);
if (result.exitCode !== 0 || result.stdout.trim().length === 0) return [];
const names = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"];
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as Record<string, string>;
} catch {
return null;
}
})
.filter((row): row is Record<string, string> => row !== null)
.filter((row) => names.some((name) => row.Names === name || String(row.Names || "").startsWith(`${name}.`)))
.map((row) => ({ name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "" }));
}
export function backendCoreUnavailableDiagnostic(detail: {
exitCode: number | null;
stdoutTail: string;
stderrTail: string;
relatedContainers: RelatedContainer[];
envPath: string;
baiduSecretPresence: Record<string, unknown>;
}): Record<string, unknown> {
const expectedContainers = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"];
const presentExactNames = new Set(detail.relatedContainers.map((container) => container.name));
const missingContainers = expectedContainers.filter((name) => !presentExactNames.has(name));
const verifyOnlyObserved = detail.relatedContainers.some((container) => /\.verify-/u.test(container.name));
return {
ok: false,
failureKind: "target-stack-not-running",
degradedReason: "backend-core-container-missing",
runnerDisposition: "infra-blocked",
readOnlyReview: true,
message: verifyOnlyObserved
? "backend-core/database target containers are not running; only verify-only containers were observed."
: "backend-core target container is not running, so private backend-core API checks cannot observe schedules or microservices.",
targetStack: {
expectedContainers,
missingContainers,
relatedContainers: detail.relatedContainers,
verifyOnlyObserved,
},
observed: {
commandExitCode: detail.exitCode,
stdoutTail: detail.stdoutTail,
stderrTail: detail.stderrTail,
},
baiduNetdiskConfigPresence: detail.baiduSecretPresence,
readOnlyCommands: [
"bun scripts/cli.ts server status",
"bun scripts/cli.ts schedule list",
"bun scripts/cli.ts schedule runs --limit 20",
"bun scripts/cli.ts microservice health baidu-netdisk",
"bun scripts/cli.ts microservice proxy baidu-netdisk /api/auth/status --raw",
"bun scripts/cli.ts microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw",
],
authorizationRequiredForRecovery: [
"restore non-empty Baidu Netdisk runtime secrets in the controlled Compose env source without printing secret values",
"start or deploy the production target stack containing backend-core, database, and baidu-netdisk",
"after health is green, explicitly authorize schedule retry-run or schedule run before triggering a real PGDATA backup",
],
blockedWithoutAuthorization: [
"server start",
"server rebuild backend-core",
"server rebuild baidu-netdisk",
"deploy apply --env prod --service baidu-netdisk",
"schedule run <id>",
"schedule retry-run <failedRunId>",
],
};
}
function backendCoreContainerMissing(stderr: string): boolean {
return stderr.includes("No such container: unidesk-backend-core");
}
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown {
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
const command = dockerCoreFetchCommand(path, init);
const result = runCommand(command, repoRoot);
if (result.exitCode !== 0) {
if (backendCoreContainerMissing(result.stderr)) {
const envPath = join(repoRoot, ".state", "docker-compose.env");
return backendCoreUnavailableDiagnostic({
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-1200),
stderrTail: result.stderr.slice(-1200),
relatedContainers: listRelatedStackContainers(),
envPath,
baiduSecretPresence: baiduSecretPresence(envPath),
});
}
const parsedStdout = parseJsonRecord(result.stdout.trim());
if (parsedStdout !== null) {
return {