From 2ab5fb87a2693ad6076e3abdeb3801c74269be69 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:29:37 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Sub2API=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=85=AC=E5=91=8A=E5=8F=97=E6=8E=A7=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-sub2api/SKILL.md | 2 +- .../unidesk-sub2api/references/codex-pool.md | 10 + .../announcements.ts | 201 ++++++++++++++++++ .../src/platform-infra-sub2api-codex/index.ts | 1 + .../platform-infra-sub2api-codex/options.ts | 2 + .../remote-python-announcements.ts | 58 +++++ .../remote-python-core.ts | 2 +- .../remote-python-sentinel-probe.ts | 2 + .../remote-python-sync.ts | 4 +- .../remote-scripts.ts | 6 + .../src/platform-infra-sub2api-codex/types.ts | 3 +- 11 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 scripts/src/platform-infra-sub2api-codex/announcements.ts create mode 100644 scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 2647c09f..0e126584 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -45,7 +45,7 @@ bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm ## 何时读取 reference - 部署、状态、target 边界、PK01 host-Docker、k3s target、egress proxy、镜像升级:读 [references/operations.md](references/operations.md)。 -- Codex pool、统一 key、runtime CRUD、精准批量、模型探测、trace、account temp-unschedulable、`codex-pool sync|validate`:读 [references/codex-pool.md](references/codex-pool.md)。 +- Codex pool、统一 key、runtime CRUD、精准批量、模型探测、原生公告、trace、account temp-unschedulable、`codex-pool sync|validate`:读 [references/codex-pool.md](references/codex-pool.md)。 - Sentinel、marker-only 判定、账号冻结/恢复、`sentinel-report|sentinel-probe|sentinel-image`:读 [references/sentinel.md](references/sentinel.md)。 - 受保护手动账号代理、分组绑定、WebUI account test:读 [references/manual-accounts.md](references/manual-accounts.md)。 - 添加或删除上游 profile/account:读 [references/upstreams.md](references/upstreams.md)。 diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index c9398c0c..c4a65335 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -111,6 +111,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --mont bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month 2026-07 --forecast bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month --forecast --sale-rate --scenario-without-account '' bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-since 24h --amount-usd 20 --reason +bun scripts/cli.ts platform-infra sub2api codex-pool announcements list --target PK01 +bun scripts/cli.ts platform-infra sub2api codex-pool announcements get --target PK01 --id +bun scripts/cli.ts platform-infra sub2api codex-pool announcements create --target PK01 --title --content --targeting-json '{"any_of":[]}' bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status --target D601 bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --target D601 --confirm @@ -154,6 +157,13 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账; - 实际批量充值属于外部财务影响操作,执行前必须取得用户对本次具体名单、金额和原因的明确授权;仅提出设计目标或要求 dry-run 不构成授权。 +- `announcements list|get|create` 管理 Sub2API 原生公告: + - `list` 和 `get` 只读调用原生 `/api/v1/admin/announcements`,支持分页、状态、搜索、排序和按 ID 下钻; + - `create` 支持标题、Markdown 内容、`draft|active|archived`、`silent|popup`、原生 `any_of/all_of` targeting 以及可选 Unix 秒或 RFC3339 开始/结束时间;targeting 必须通过 `--targeting-json` 的 JSON 解析器输入,不拼接临时结构; + - `create` 默认只输出完整预览,固定 `mutation=false`、`writeAttempted=0`,不登录运行面也不调用创建 API; + - 只有显式 `--confirm` 才调用原生创建 API,并按返回的正整数 ID 回读对账;发布公告属于面向客户的外部影响操作,必须取得用户对本次具体标题、内容、状态、通知方式、targeting 和时间窗的明确授权; + - 默认输出 Kubernetes 风格紧凑文本,显式 `--json` 输出同一份机器结构;命令不打印管理凭据或 Secret。 + `config/platform-infra/sub2api-codex-pool.yaml` 控制: - `pool.groupName`: Sub2API group 名称。 diff --git a/scripts/src/platform-infra-sub2api-codex/announcements.ts b/scripts/src/platform-infra-sub2api-codex/announcements.ts new file mode 100644 index 00000000..9619b05f --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/announcements.ts @@ -0,0 +1,201 @@ +import { readFileSync } from "node:fs"; +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; +import { renderTable } from "./render"; +import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; +import { announcementsScript } from "./remote-scripts"; +import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target"; + +type AnnouncementAction = "list" | "get" | "create"; + +interface AnnouncementOptions { + action: AnnouncementAction; + id: number | null; + page: number; + pageSize: number; + status: string | null; + search: string | null; + sortBy: string; + sortOrder: "asc" | "desc"; + title: string | null; + content: string | null; + announcementStatus: "draft" | "active" | "archived"; + notifyMode: "silent" | "popup"; + targeting: Record; + startsAt: number | null; + endsAt: number | null; + confirm: boolean; + targetId: string; + json: boolean; +} + +export async function codexPoolAnnouncements(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + if (args[0] === "help" || args.includes("--help")) return renderAnnouncementsHelp(); + const options = parseAnnouncementOptions(args); + const target = codexPoolRuntimeTarget(options.targetId); + const payload = announcementPayload(options); + if (options.action === "create" && !options.confirm) { + const response = { + ok: true, + action: "platform-infra-sub2api-codex-pool-announcements", + target: targetSummary(target), + request: payload, + report: { + ok: true, + mode: "dry-run", + mutation: false, + writeAttempted: 0, + announcement: payload.announcement, + valuesPrinted: false, + }, + valuesPrinted: false, + }; + return options.json ? renderAnnouncementsJson(response) : renderAnnouncements(response); + } + const remote = await runRemoteCodexPoolScript(config, "announcements", announcementsScript(payload, target), target); + const report = parseJsonOutput(remote.stdout); + const ok = remote.exitCode === 0 && boolField(report, "ok", false); + const response = { + ok, + action: "platform-infra-sub2api-codex-pool-announcements", + target: targetSummary(target), + request: payload, + report, + remote: compactCapture(remote, { full: !ok || report === null }), + valuesPrinted: false, + }; + return options.json ? renderAnnouncementsJson(response) : renderAnnouncements(response); +} + +function parseAnnouncementOptions(args: string[]): AnnouncementOptions { + const [actionRaw = "list", ...rest] = args; + if (!(["list", "get", "create"] as string[]).includes(actionRaw)) throw new Error("announcements usage: list|get|create [options]"); + const action = actionRaw as AnnouncementAction; + let id: number | null = null; + let page = 1; + let pageSize = 20; + let status: string | null = null; + let search: string | null = null; + let sortBy = "created_at"; + let sortOrder: "asc" | "desc" = "desc"; + let title: string | null = null; + let content: string | null = null; + let contentFile: string | null = null; + let announcementStatus: "draft" | "active" | "archived" = "draft"; + let notifyMode: "silent" | "popup" = "silent"; + let targeting: Record = {}; + let startsAt: number | null = null; + let endsAt: number | null = null; + let confirm = false; + let targetId = defaultCodexPoolRuntimeTargetId(); + let json = false; + const readValue = (index: number, option: string): [string, number] => { + const value = rest[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`); + return [value, index + 1]; + }; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + if (arg === "--confirm") confirm = true; + else if (arg === "--json") json = true; + else if (arg === "--id") { let value: string; [value, index] = readValue(index, arg); id = positiveInteger(value, arg); } + else if (arg.startsWith("--id=")) id = positiveInteger(arg.slice(5), "--id"); + else if (arg === "--page") { let value: string; [value, index] = readValue(index, arg); page = positiveInteger(value, arg); } + else if (arg.startsWith("--page=")) page = positiveInteger(arg.slice(7), "--page"); + else if (arg === "--page-size") { let value: string; [value, index] = readValue(index, arg); pageSize = boundedPageSize(value); } + else if (arg.startsWith("--page-size=")) pageSize = boundedPageSize(arg.slice(12)); + else if (arg === "--status") [status, index] = readValue(index, arg); + else if (arg.startsWith("--status=")) status = arg.slice(9); + else if (arg === "--search") [search, index] = readValue(index, arg); + else if (arg.startsWith("--search=")) search = arg.slice(9); + else if (arg === "--sort-by") [sortBy, index] = readValue(index, arg); + else if (arg.startsWith("--sort-by=")) sortBy = arg.slice(10); + else if (arg === "--sort-order") { let value: string; [value, index] = readValue(index, arg); sortOrder = parseSortOrder(value); } + else if (arg.startsWith("--sort-order=")) sortOrder = parseSortOrder(arg.slice(13)); + else if (arg === "--title") [title, index] = readValue(index, arg); + else if (arg.startsWith("--title=")) title = arg.slice(8); + else if (arg === "--content") [content, index] = readValue(index, arg); + else if (arg.startsWith("--content=")) content = arg.slice(10); + else if (arg === "--content-file") [contentFile, index] = readValue(index, arg); + else if (arg.startsWith("--content-file=")) contentFile = arg.slice(15); + else if (arg === "--announcement-status") { let value: string; [value, index] = readValue(index, arg); announcementStatus = parseAnnouncementStatus(value); } + else if (arg.startsWith("--announcement-status=")) announcementStatus = parseAnnouncementStatus(arg.slice(22)); + else if (arg === "--notify-mode") { let value: string; [value, index] = readValue(index, arg); notifyMode = parseNotifyMode(value); } + else if (arg.startsWith("--notify-mode=")) notifyMode = parseNotifyMode(arg.slice(14)); + else if (arg === "--targeting-json") { let value: string; [value, index] = readValue(index, arg); targeting = parseTargeting(value); } + else if (arg.startsWith("--targeting-json=")) targeting = parseTargeting(arg.slice(17)); + else if (arg === "--starts-at") { let value: string; [value, index] = readValue(index, arg); startsAt = parseTimestamp(value, arg); } + else if (arg.startsWith("--starts-at=")) startsAt = parseTimestamp(arg.slice(12), "--starts-at"); + else if (arg === "--ends-at") { let value: string; [value, index] = readValue(index, arg); endsAt = parseTimestamp(value, arg); } + else if (arg.startsWith("--ends-at=")) endsAt = parseTimestamp(arg.slice(10), "--ends-at"); + else if (arg === "--target") [targetId, index] = readValue(index, arg); + else if (arg.startsWith("--target=")) targetId = arg.slice(9); + else throw new Error(`unsupported announcements option: ${arg}`); + } + if (action !== "create" && confirm) throw new Error("--confirm is only valid for announcements create"); + if (action === "get" && id === null) throw new Error("announcements get requires --id"); + if (action === "create") { + if ((content === null) === (contentFile === null)) throw new Error("announcements create requires exactly one of --content or --content-file"); + if (contentFile !== null) content = readFileSync(contentFile, "utf8"); + title = title?.trim() ?? null; + content = content?.trim() ?? null; + if (title === null || title.length === 0 || title.length > 200) throw new Error("announcements create requires --title up to 200 characters"); + if (content === null || content.length === 0) throw new Error("announcements create requires non-empty Markdown content"); + if (startsAt !== null && endsAt !== null && startsAt >= endsAt) throw new Error("--starts-at must be earlier than --ends-at"); + } + return { action, id, page, pageSize, status, search, sortBy, sortOrder, title, content, announcementStatus, notifyMode, targeting, startsAt, endsAt, confirm, targetId, json }; +} + +function announcementPayload(options: AnnouncementOptions): Record { + if (options.action === "list") return { action: "list", page: options.page, pageSize: options.pageSize, status: options.status, search: options.search, sortBy: options.sortBy, sortOrder: options.sortOrder }; + if (options.action === "get") return { action: "get", id: options.id }; + return { + action: "create", + confirm: options.confirm, + announcement: { title: options.title, content: options.content, status: options.announcementStatus, notify_mode: options.notifyMode, targeting: options.targeting, starts_at: options.startsAt, ends_at: options.endsAt }, + }; +} + +function renderAnnouncements(response: Record): RenderedCliResult { + const request = record(response.request); + const report = record(response.report); + const action = String(request.action ?? "-"); + const lines = [`ANNOUNCEMENTS action=${action} mode=${text(report.mode)} ok=${text(response.ok)} mutation=${text(report.mutation)}`]; + if (action === "list") { + const items = arrayOfRecords(report.items); + lines.push(`SUMMARY total=${text(report.total)} page=${text(report.page)} page-size=${text(report.pageSize)} returned=${items.length}`); + if (items.length > 0) lines.push("", renderTable([["ID", "STATUS", "NOTIFY", "TITLE", "STARTS_AT", "ENDS_AT"], ...items.map((item) => [text(item.id), text(item.status), text(item.notify_mode), text(item.title), text(item.starts_at), text(item.ends_at)])])); + } else { + const announcement = record(report.announcement); + lines.push(`WRITE attempted=${text(report.writeAttempted)} reconciled=${text(report.reconciled)} id=${text(announcement.id)}`); + lines.push(`TITLE ${text(announcement.title)}`, `STATUS ${text(announcement.status)} NOTIFY_MODE ${text(announcement.notify_mode)}`, `STARTS_AT ${text(announcement.starts_at)} ENDS_AT ${text(announcement.ends_at)}`, `TARGETING ${JSON.stringify(record(announcement.targeting))}`, "CONTENT", String(announcement.content ?? "")); + } + if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`); + return { ok: response.ok === true, command: "platform-infra sub2api codex-pool announcements", renderedText: lines.join("\n"), contentType: "text/plain", projection: response }; +} + +function renderAnnouncementsJson(response: Record): RenderedCliResult { + return { ok: response.ok === true, command: "platform-infra sub2api codex-pool announcements --json", renderedText: JSON.stringify(response), contentType: "application/json", projection: response }; +} + +function renderAnnouncementsHelp(): RenderedCliResult { + const renderedText = [ + "platform-infra sub2api codex-pool announcements list [--page 1] [--page-size 20] [--status draft|active|archived] [--search text] [--sort-by created_at] [--sort-order asc|desc] [--target PK01] [--json]", + "platform-infra sub2api codex-pool announcements get --id [--target PK01] [--json]", + "platform-infra sub2api codex-pool announcements create --title (--content |--content-file ) [--announcement-status draft|active|archived] [--notify-mode silent|popup] [--targeting-json ] [--starts-at ] [--ends-at ] [--target PK01] [--confirm] [--json]", + "create defaults to dry-run; only --confirm calls the native Sub2API create API and then reads the returned ID back.", + ].join("\n"); + return { ok: true, command: "platform-infra sub2api codex-pool announcements --help", renderedText, contentType: "text/plain", projection: { ok: true, mutation: false } }; +} + +function parseTargeting(value: string): Record { const parsed: unknown = JSON.parse(value); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--targeting-json requires a JSON object"); return parsed as Record; } +function parseTimestamp(value: string, option: string): number { if (/^[1-9][0-9]*$/u.test(value)) return Number(value); const millis = Date.parse(value); if (!Number.isFinite(millis)) throw new Error(`${option} requires Unix seconds or RFC3339`); return Math.floor(millis / 1000); } +function positiveInteger(value: string, option: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${option} requires a positive integer`); return parsed; } +function boundedPageSize(value: string): number { const parsed = positiveInteger(value, "--page-size"); if (parsed > 100) throw new Error("--page-size must be at most 100"); return parsed; } +function parseSortOrder(value: string): "asc" | "desc" { if (value !== "asc" && value !== "desc") throw new Error("--sort-order must be asc or desc"); return value; } +function parseAnnouncementStatus(value: string): "draft" | "active" | "archived" { if (value !== "draft" && value !== "active" && value !== "archived") throw new Error("--announcement-status must be draft, active, or archived"); return value; } +function parseNotifyMode(value: string): "silent" | "popup" { if (value !== "silent" && value !== "popup") throw new Error("--notify-mode must be silent or popup"); return value; } +function targetSummary(target: ReturnType): Record { return { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }; } +function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } +function arrayOfRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } +function text(value: unknown): string { return value === null || value === undefined || value === "" ? "-" : String(value).replace(/[\r\n\t]+/gu, " "); } diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index 13dd55f3..3d016072 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -16,4 +16,5 @@ export * from "./remote"; export * from "./feedback"; export * from "./profit"; export * from "./credits"; +export * from "./announcements"; export * from "./error-passthrough"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index 99cc4904..e863f121 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -29,6 +29,7 @@ import { codexPoolRuntime } from "./runtime"; import { codexPoolFaults } from "./faults"; import { codexPoolProfit } from "./profit"; import { codexPoolCredits } from "./credits"; +import { codexPoolAnnouncements } from "./announcements"; import { codexPoolErrorPassthrough } from "./error-passthrough"; import { defaultCodexPoolRuntimeTargetId } from "./runtime-target"; import { codexPoolHelp } from "./types"; @@ -46,6 +47,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]) if (action === "faults") return await codexPoolFaults(config, args.slice(1)); if (action === "profit") return await codexPoolProfit(config, args.slice(1)); if (action === "credits") return await codexPoolCredits(config, args.slice(1)); + if (action === "announcements") return await codexPoolAnnouncements(config, args.slice(1)); if (action === "error-passthrough") return await codexPoolErrorPassthrough(config, args.slice(1)); if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1))); if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1))); diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts new file mode 100644 index 00000000..373b83d4 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts @@ -0,0 +1,58 @@ +export const remotePythonAnnouncementsScript = String.raw` + +def announcements_payload(): + return json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) + +def announcements_list(token, payload): + query = [ + "page=" + quote(str(payload.get("page") or 1), safe=""), + "page_size=" + quote(str(payload.get("pageSize") or 20), safe=""), + "sort_by=" + quote(str(payload.get("sortBy") or "created_at"), safe=""), + "sort_order=" + quote(str(payload.get("sortOrder") or "desc"), safe=""), + ] + if payload.get("status"): + query.append("status=" + quote(str(payload.get("status")), safe="")) + if payload.get("search"): + query.append("search=" + quote(str(payload.get("search")), safe="")) + data = ensure_success(curl_api("GET", "/api/v1/admin/announcements?" + "&".join(query), bearer=token), "list announcements") + items = extract_items(data) + return { + "ok": True, "mode": "read-only", "mutation": False, "writeAttempted": 0, + "items": items, + "total": data.get("total", len(items)) if isinstance(data, dict) else len(items), + "page": data.get("page", payload.get("page")) if isinstance(data, dict) else payload.get("page"), + "pageSize": data.get("page_size", payload.get("pageSize")) if isinstance(data, dict) else payload.get("pageSize"), + "totalPages": data.get("total_pages") if isinstance(data, dict) else None, + "valuesPrinted": False, + } + +def announcements_get(token, announcement_id): + item = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "get announcement") + return {"ok": isinstance(item, dict), "mode": "read-only", "mutation": False, "writeAttempted": 0, "announcement": item, "valuesPrinted": False} + +def announcements_create(token, payload): + announcement = payload.get("announcement") if isinstance(payload.get("announcement"), dict) else {} + created = ensure_success(curl_api("POST", "/api/v1/admin/announcements", bearer=token, payload=announcement), "create announcement") + announcement_id = created.get("id") if isinstance(created, dict) else None + if not isinstance(announcement_id, int) or announcement_id <= 0: + raise RuntimeError("create announcement response missing positive id") + actual = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "read announcement after create") + reconciled = isinstance(actual, dict) and actual.get("id") == announcement_id + return { + "ok": reconciled, "mode": "confirmed", "mutation": True, "writeAttempted": 1, + "writeSucceeded": 1, "writeFailed": 0, "reconciled": reconciled, + "announcement": actual, "valuesPrinted": False, + } + +def run_announcements(): + payload = announcements_payload() + _, token, _ = login() + action = payload.get("action") + if action == "list": + return announcements_list(token, payload) + if action == "get": + return announcements_get(token, payload.get("id")) + if action == "create" and payload.get("confirm") is True: + return announcements_create(token, payload) + raise RuntimeError("unsupported announcements action") +`; diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts index 7dbdd257..cf04cb17 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts @@ -30,7 +30,7 @@ function pyJson(value: unknown): string { return `json.loads(${JSON.stringify(JSON.stringify(value))})`; } -export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { +export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits" | "announcements", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { const hostDockerEnvPath = target.runtimeMode === "host-docker" ? target.hostDockerEnvPath : null; return ` set -u diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts index 6cc3907c..62b7a6a9 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts @@ -174,6 +174,8 @@ try: result = run_profit_report() elif MODE == "credits": result = run_credits_grant() + elif MODE == "announcements": + result = run_announcements() else: result = run_validate() except Exception as exc: diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 4c64ad7d..674c7da6 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -9,8 +9,9 @@ import { remotePythonTraceScript } from "./remote-python-trace"; import { remotePythonSentinelProbeScript } from "./remote-python-sentinel-probe"; import { remotePythonProfitScript } from "./remote-python-profit"; import { remotePythonCreditsScript } from "./remote-python-credits"; +import { remotePythonAnnouncementsScript } from "./remote-python-announcements"; -export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { +export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits" | "announcements", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { return [ remotePythonCoreScript(mode, encodedPayload, pool, target), remotePythonSentinelScript, @@ -19,6 +20,7 @@ export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanu remotePythonTraceScript, remotePythonProfitScript, remotePythonCreditsScript, + remotePythonAnnouncementsScript, remotePythonSentinelProbeScript, ].join(""); } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index a455ddf2..cf72e990 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -63,6 +63,12 @@ export function creditsScript(payload: unknown, target: CodexPoolRuntimeTarget): return remotePythonScript("credits", encoded, pool, target); } +export function announcementsScript(payload: unknown, target: CodexPoolRuntimeTarget): string { + const pool = readCodexPoolConfig(); + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + return remotePythonScript("announcements", encoded, pool, target); +} + export function sentinelReportScript(pool: CodexPoolConfig, events: number, target: CodexPoolRuntimeTarget): string { const stateName = pool.sentinel.stateConfigMapName; const cronJobName = pool.sentinel.cronJobName; diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index cbaa155e..a0463a8a 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -389,7 +389,7 @@ export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(); return { - command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|credits|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", + command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|credits|announcements|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", output: "json, except trace, feedback, and sentinel-report default to low-noise text tables", usage: [ "bun scripts/cli.ts platform-infra sub2api codex-pool plan", @@ -402,6 +402,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate ] [--scenario-without-account ...] [--accounts [--page-token ]|--missing-costs] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool announcements list|get|create [--target PK01] [--confirm for create] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --template [--target PK01] [--confirm] [--full|--raw]", From a53270f9fe410b0671546ad1622fea73f27707f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:37:32 +0200 Subject: [PATCH 02/12] docs(pikaoa): track test CI/CD delivery --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 7b234812..98747a05 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -129,6 +129,9 @@ ##### R1.5.9.12 [in_progress] 修复 PikaOA HTTP hostIP 测试入口因 crypto.randomUUID 不可用导致 Web 登录失败的问题,并完成桌面与移动端真实入口验收(pikainc/pikaoa#22),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md)。 +##### R1.5.9.13 [in_progress] + +接入仅跟随产品 master 的 pikaoa-test-nc01 独立测试 CI/CD,通过正常 source PR merge 自动交付 NC01 pikaoa-test,保持生产 consumer 不触发(pikasTech/unidesk#2105),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.13_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 From 4493e5d98abd8548edf1336885c8ca7998ec31ce Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:40:22 +0200 Subject: [PATCH 03/12] docs(pikaoa): close test runtime acceptance tasks --- .../R1.5.9.10_Task_Report.md | 54 ++++++++----------- .../R1.5.9.11_Task_Report.md | 9 ++++ .../R1.5.9.12_Task_Report.md | 10 ++++ .../R1.5.9.7_Task_Report.md | 8 +++ .../R1.5.9.8_Task_Report.md | 9 ++++ .../R1.5.9.9_Task_Report.md | 51 +++++++----------- docs/MDTODO/pikaoa-enterprise-platform.md | 12 ++--- 7 files changed, 83 insertions(+), 70 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.8_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.10_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.10_Task_Report.md index a0ada990..c163e5ff 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.10_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.10_Task_Report.md @@ -1,41 +1,29 @@ # R1.5.9.10 任务报告 -## 状态 +## 结论 -- 当前状态:`in_progress`。 -- 关联 issue:`pikasTech/unidesk#2092`。 -- 实现分支:`fix/pikaoa-observability`。 -- 本阶段仅完成源码、owning YAML、fixture 和定向验证;未执行任何运行面 mutation,未启动 Prometheus、Collector、Tempo 或 PikaOA Pod。 -- 后续仍需主代理通过受控 observability apply 完成 NC01 trace/metrics 原入口验收后再决定完成状态。 +- UniDesk PR pikasTech/unidesk#2100 已合并,merge commit 为 35d3a6c1。 +- PikaOA 测试与生产 OTel endpoint 统一为无 scheme 的 gRPC 4317。 +- Prometheus selector label 从 platform-infra owning YAML configRef 解析并应用于 API/Worker Pod。 +- 受控 observability apply 后 Collector、Tempo 与 Prometheus ready。 +- 新 API/Worker Pod 的旧 OTLP 地址 warning 数量为 0。 +- outbox handler_not_registered 保持非阻塞并登记到 pikainc/pikaoa#24。 ## 实现 -- `config/pikaoa.yaml`: - - 测试与生产 `otlpEndpoint` 统一为无 scheme 的 OTLP gRPC endpoint `otel-collector.platform-infra.svc.cluster.local:4317`; - - 删除生产旧字段 `otlpHttpEndpoint`,不保留兼容入口; - - 测试 Prometheus 新增 `configRef`,指向 `config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector`。 -- `scripts/src/pikaoa-test-target.ts`: - - 复用共享 `scripts/src/ops/config-refs.ts` 的 `resolveConfigRef`; - - 校验 configRef、OTLP gRPC endpoint 及 Kubernetes label key/value; - - 将 selector label 加入 API/Worker Pod template,Web 不添加; - - 在 plan/status 投影 selector 的 `sourceRef`、`presence`、`fingerprint` 和 `valuesPrinted=false`; - - selector 漂移与 OTel exporter 失败均保持 `blocking=false` warning,不阻塞业务 MVP。 -- fixture 与测试: - - 更新 endpoint 与 selector configRef; - - 增加非法 label fixture; - - 覆盖 endpoint、API/Worker selector label、Web 无 selector、configRef 缺失、引用路径错误、非法 Kubernetes label、旧生产字段删除及 `valuesPrinted=false`。 +- 删除生产旧字段 otlpHttpEndpoint,不保留兼容入口。 +- 测试 Prometheus configRef 指向 config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector。 +- 复用共享 resolveConfigRef,投影 selector sourceRef、presence、fingerprint 与 valuesPrinted=false。 +- API/Worker Pod template 增加 unidesk.ai/metrics=true;Web 不增加。 +- selector 漂移和 exporter 失败均保持 blocking=false warning。 +- 定向测试覆盖 gRPC endpoint、selector label、Web 排除、configRef 缺失/错误和旧字段删除。 +- bun scripts/cli.ts check --syntax-only 与 git diff --check 通过。 -## 验证 +## 真实运行面证据 -- `bun --check scripts/src/pikaoa-test-target.ts`:通过。 -- `bun test scripts/src/pikaoa-test-target-async.test.ts`:`2 pass, 0 fail`。 -- `bun scripts/cli.ts check --syntax-only`:`11 passed, 0 failed`。 -- `bun scripts/cli.ts pikaoa test-target plan ...` compact text:通过,`mutation=false`、`valuesPrinted=false`;clean Artificer workspace 缺 fixture Secret 时仅显示既有非变更 blocker,不读取或打印 Secret,也不作为本任务运行面门禁。 -- `git diff --check`:通过。 -- `scripts/src/pikaoa-test-target.ts`:1910 行,低于 2000 行。 - -## 边界与后续 - -- 用户确认开发初期旧数据可丢;本任务不实现旧数据迁移、兼容或回滚,现有 migration 命名仅表示全新数据库初始化。 -- Prometheus 当前未 apply/ready 不阻塞业务 MVP;后续由主代理按受控入口完成 apply、Tempo trace 和 Prometheus metrics 验收。 -- 本报告回链 MDTODO `R1.5.9.10`,任务保持进行中。 +- API 合同查询 trace 21cf522b075a4f89ca4f6e7e9e034ba0 在 Tempo 中找到 13 spans、0 error。 +- Worker service pikaoa-test-acceptance-worker 的 trace 可通过 TraceQL 检索。 +- Prometheus:database_ready=1、migration_ready=1。 +- Prometheus:contracts operations=1、invoice actions=2、worker runs=234。 +- HTTP request 与 identity login 指标均有数据。 +- API、Worker、Web 各 1 副本 Ready;Prometheus selector label 仅存在于 API/Worker。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md new file mode 100644 index 00000000..6f9d3058 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md @@ -0,0 +1,9 @@ +# R1.5.9.11 任务报告 + +## 结论 + +- 产品 PR pikainc/pikaoa#21 已合并,merge commit 为 d9d3de2。 +- 合同 PL/pgSQL trigger function 已用 Goose StatementBegin/StatementEnd 包裹,避免语句拆分失败。 +- NC01 运行的初始化镜像使用完整 commit d9d3de224d53498ed75c07c9a802e5b5bbf2463f。 +- PK01 pikaoa_test 全新 schema 真实执行 00001 至 00007 成功,合同版本不可变 trigger 已创建。 +- 不承担历史数据迁移;该 SQL 链仅作为当前开发阶段的全新数据库初始化器。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md new file mode 100644 index 00000000..0028b6a6 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md @@ -0,0 +1,10 @@ +# R1.5.9.12 任务报告 + +## 结论 + +- 产品 PR pikainc/pikaoa#23 已合并,merge commit 为 266901f。 +- Web 请求 ID helper 在可用时优先 crypto.randomUUID;HTTP 非安全上下文缺失该函数时使用 crypto.getRandomValues 生成 RFC 4122 UUIDv4。 +- NC01 构建并推送 Web 镜像 266901fe621232403794c7187a1781ebc766ef7d,registry digest 为 sha256:98679772537e7602389211edfaf6e422614497025a262b1b74f5038dfe059133。 +- 真实 HTTP hostIP 登录返回 200。 +- Playwright 桌面 1920×1080 与移动 390×844 均通过伙伴、合同版本历史、发票废弃详情;横向溢出 false,console error 为 0。 +- 截图证据位于 /root/.unidesk/.state/playwright-cli/pikaoa-live-266901/。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md new file mode 100644 index 00000000..66ef97ad --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md @@ -0,0 +1,8 @@ +# R1.5.9.7 任务报告 + +## 结论 + +- 产品 PR pikainc/pikaoa#17 已合并,merge commit 为 70e34fe。 +- 事件主版本不一致降级为具名 warning,不再阻塞 API/Worker 启动。 +- NC01 pikaoa-test 的 API、Worker、Web 均已 Ready,合同、发票和审计 CLI 实测通过。 +- 当前阶段不增加完整契约审计门禁;一致性问题继续保持非阻塞 warning。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.8_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.8_Task_Report.md new file mode 100644 index 00000000..ff47c402 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.8_Task_Report.md @@ -0,0 +1,9 @@ +# R1.5.9.8 任务报告 + +## 结论 + +- 产品 PR pikainc/pikaoa#19 已合并,merge commit 为 68342e3。 +- Goose 版本表固定为 pikaoa.goose_db_version,业务表与版本表统一位于 pikaoa schema。 +- 主代理明确断言连接身份为 pikaoa_test database/pikaoa_test role 后,仅清空测试 schema;生产 pikaoa database/role 未触碰。 +- 全新测试库初始化完成,00001 至 00007 全部成功,Goose version=7。 +- 用户确认开发初期旧数据可直接丢;本任务不实现旧数据迁移、兼容或回滚框架。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.9_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.9_Task_Report.md index b081a8ba..9ffb2d7f 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.9_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.9_Task_Report.md @@ -1,39 +1,28 @@ # R1.5.9.9 任务报告 -## 状态 +## 结论 -- 当前状态:进行中。 -- 关联问题:`pikasTech/unidesk#2090`。 -- 待办:由主代理在真实 `pikaoa-test` 运行面复测后决定是否完成任务。 +- UniDesk PR pikasTech/unidesk#2096 已合并,merge commit 为 cfc7ffb。 +- 初始化 Job 由有界短轮询识别 Complete、Failed 与 FailureTarget;失败立即返回具名终态,不再等待 Complete 至超时。 +- NC01 pikaoa-test 真实全新库初始化以该 runner 完成,Job Complete 后 API/Worker/Web 单步启动成功。 +- 保留 submit-and-poll 与 Secret/DSN 脱敏;一致性检查仅 warning。 ## 实现 -- 将 migration runner 从单一 `kubectl wait --for=condition=complete` 改为有界短轮询。 -- `Complete=True` 时成功结束。 -- `FailureTarget=True` 时立即返回 `migration-job-failure-target`。 -- `Failed=True` 时立即返回 `migration-job-failed`。 -- Job 查询失败时返回 `migration-job-query-failed`。 -- condition JSON 无法解析时返回 `migration-job-condition-invalid`。 -- 超过总等待时间时返回 `migration-job-timeout`。 -- 每轮查询前检查取消请求,查询超时上限收紧为 5 秒,轮询间隔为 1 秒。 -- 保留异步 submit-and-poll、现有状态文件边界和 Secret/DSN 脱敏行为。 +- Complete=True 时成功结束。 +- FailureTarget=True 时返回 migration-job-failure-target。 +- Failed=True 时返回 migration-job-failed。 +- Job 查询失败时返回 migration-job-query-failed。 +- condition JSON 解析失败时返回 migration-job-condition-invalid。 +- 超过总等待时间时返回 migration-job-timeout。 +- 每轮查询前检查取消请求,单次查询超时上限为 5 秒,轮询间隔为 1 秒。 +- 失败终态保留有界日志,不读取或打印数据库 DSN。 -## Target 原入口证据 +## 验证 -- Target:`NC01`。 -- targetWorkspace:`/root/unidesk/.worktree/pikaoa-migration-job-terminal`。 -- 分支:`fix/pikaoa-migration-job-terminal`。 -- 基线提交:`47ca9a7cb218528c45fef0f88cb4e4bc51a5b805`。 -- `bun --check scripts/src/pikaoa-test-target.ts`:通过。 -- `bun test scripts/src/pikaoa-test-target-async.test.ts`:2 pass,0 fail。 -- 定向测试覆盖 `Complete`、`Failed`、`FailureTarget` 和查询失败终态。 -- `bun scripts/cli.ts check --syntax-only`:11 pass,0 fail。 -- `git diff --check`:通过。 -- `scripts/src/pikaoa-test-target.ts`:1818 行,低于 2000 行。 -- 未执行运行面 mutation,未删除或重建真实 Job。 - -## Primary Workspace 轻量准备 - -- 仅用于读取 runtime resource bundle 中的技能与 `trans` 工具说明。 -- 未在 primary workspace 读取、修改或验证目标源码。 -- 未将 runner 容器本地结果作为 Target 验证证据。 +- bun --check scripts/src/pikaoa-test-target.ts:通过。 +- bun test scripts/src/pikaoa-test-target-async.test.ts:2 pass,0 fail。 +- 定向覆盖 Complete、Failed、FailureTarget、查询失败和取消路径。 +- bun scripts/cli.ts check --syntax-only:11 pass,0 fail。 +- git diff --check:通过。 +- 真实初始化执行 00001 至 00007,Goose version=7。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 98747a05..662ed57b 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -111,22 +111,22 @@ ##### R1.5.9.6 [completed] 为产品仓库补齐 API、Worker、Web 的 commit-pinned OCI 构建输入,使 NC01 测试运行面和后续 PaC 可构建镜像,执行记录见 [pikainc/pikaoa#14](https://github.com/pikainc/pikaoa/issues/14),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.6_Task_Report.md)。 -##### R1.5.9.7 [in_progress] +##### R1.5.9.7 [completed] 降级事件版本一致性校验并解除 MVP 启动阻塞(pikainc/pikaoa#16),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md)。 -##### R1.5.9.8 [in_progress] +##### R1.5.9.8 [completed] 修复 PikaOA 独立 schema 下 Goose 首次迁移 bootstrap,使版本表与业务表统一位于 pikaoa schema 并完成 NC01/PK01 真实 migration 验收(pikainc/pikaoa#18),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.8_Task_Report.md)。 -##### R1.5.9.9 [in_progress] +##### R1.5.9.9 [completed] 修复 PikaOA 测试 migration Job 进入 Failed/FailureTarget 后异步 runner 仍等待 Complete 至超时的问题,使失败立即以具名终态投影并支持快速单步调试(pikasTech/unidesk#2090),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.9_Task_Report.md)。 -##### R1.5.9.10 [in_progress] +##### R1.5.9.10 [completed] 修正 PikaOA 测试与生产 OTel gRPC endpoint 和 Prometheus selector label,并通过受控 observability apply 完成 NC01 trace/metrics 真实验收(pikasTech/unidesk#2092),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.10_Task_Report.md)。 -##### R1.5.9.11 [in_progress] +##### R1.5.9.11 [completed] 修复合同迁移中 PL/pgSQL trigger function 的 Goose StatementBegin/StatementEnd 边界并完成 00001-00007 真实迁移验收(pikainc/pikaoa#20),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md)。 -##### R1.5.9.12 [in_progress] +##### R1.5.9.12 [completed] 修复 PikaOA HTTP hostIP 测试入口因 crypto.randomUUID 不可用导致 Web 登录失败的问题,并完成桌面与移动端真实入口验收(pikainc/pikaoa#22),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md)。 ##### R1.5.9.13 [in_progress] From 709f981c980fe9531f94bb60d070ff681dbf50b6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:44:16 +0200 Subject: [PATCH 04/12] docs(pikaoa): track production release delivery --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 662ed57b..012c55ca 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -136,6 +136,9 @@ 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 +#### R1.6.1 [in_progress] + +建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,部署 NC01 pikaoa namespace,接入 PK01 host PostgreSQL、附件持久化、OTel/Prometheus、备份与 oa.pikapython.com TLS 公网入口(pikasTech/unidesk#2110),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1_Task_Report.md)。 ### R1.7 通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。 From 2c0cf9479197a37728a3c697da230fb9e7ce28de Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:45:17 +0200 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=E5=AE=8C=E6=95=B4=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=20Sub2API=20=E5=85=AC=E5=91=8A=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 2 +- .../announcements.ts | 5 +- .../remote-python-announcements.ts | 52 ++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index c4a65335..36a6221d 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -161,7 +161,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `list` 和 `get` 只读调用原生 `/api/v1/admin/announcements`,支持分页、状态、搜索、排序和按 ID 下钻; - `create` 支持标题、Markdown 内容、`draft|active|archived`、`silent|popup`、原生 `any_of/all_of` targeting 以及可选 Unix 秒或 RFC3339 开始/结束时间;targeting 必须通过 `--targeting-json` 的 JSON 解析器输入,不拼接临时结构; - `create` 默认只输出完整预览,固定 `mutation=false`、`writeAttempted=0`,不登录运行面也不调用创建 API; - - 只有显式 `--confirm` 才调用原生创建 API,并按返回的正整数 ID 回读对账;发布公告属于面向客户的外部影响操作,必须取得用户对本次具体标题、内容、状态、通知方式、targeting 和时间窗的明确授权; + - 只有显式 `--confirm` 才调用原生创建 API,并按返回的正整数 ID 回读;对账必须逐字段核对 `title`、`content`、`status`、`notify_mode`、`targeting`、`starts_at`、`ends_at`,输出 `mismatchedFields`,任一字段不一致都不得报告成功;发布公告属于面向客户的外部影响操作,必须取得用户对本次具体标题、内容、状态、通知方式、targeting 和时间窗的明确授权; - 默认输出 Kubernetes 风格紧凑文本,显式 `--json` 输出同一份机器结构;命令不打印管理凭据或 Secret。 `config/platform-infra/sub2api-codex-pool.yaml` 控制: diff --git a/scripts/src/platform-infra-sub2api-codex/announcements.ts b/scripts/src/platform-infra-sub2api-codex/announcements.ts index 9619b05f..c4384617 100644 --- a/scripts/src/platform-infra-sub2api-codex/announcements.ts +++ b/scripts/src/platform-infra-sub2api-codex/announcements.ts @@ -167,7 +167,8 @@ function renderAnnouncements(response: Record): RenderedCliResu if (items.length > 0) lines.push("", renderTable([["ID", "STATUS", "NOTIFY", "TITLE", "STARTS_AT", "ENDS_AT"], ...items.map((item) => [text(item.id), text(item.status), text(item.notify_mode), text(item.title), text(item.starts_at), text(item.ends_at)])])); } else { const announcement = record(report.announcement); - lines.push(`WRITE attempted=${text(report.writeAttempted)} reconciled=${text(report.reconciled)} id=${text(announcement.id)}`); + const mismatchedFields = Array.isArray(report.mismatchedFields) ? report.mismatchedFields.join(",") : "-"; + lines.push(`WRITE attempted=${text(report.writeAttempted)} reconciled=${text(report.reconciled)} id=${text(announcement.id)} mismatched-fields=${mismatchedFields || "-"}`); lines.push(`TITLE ${text(announcement.title)}`, `STATUS ${text(announcement.status)} NOTIFY_MODE ${text(announcement.notify_mode)}`, `STARTS_AT ${text(announcement.starts_at)} ENDS_AT ${text(announcement.ends_at)}`, `TARGETING ${JSON.stringify(record(announcement.targeting))}`, "CONTENT", String(announcement.content ?? "")); } if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`); @@ -183,7 +184,7 @@ function renderAnnouncementsHelp(): RenderedCliResult { "platform-infra sub2api codex-pool announcements list [--page 1] [--page-size 20] [--status draft|active|archived] [--search text] [--sort-by created_at] [--sort-order asc|desc] [--target PK01] [--json]", "platform-infra sub2api codex-pool announcements get --id [--target PK01] [--json]", "platform-infra sub2api codex-pool announcements create --title (--content |--content-file ) [--announcement-status draft|active|archived] [--notify-mode silent|popup] [--targeting-json ] [--starts-at ] [--ends-at ] [--target PK01] [--confirm] [--json]", - "create defaults to dry-run; only --confirm calls the native Sub2API create API and then reads the returned ID back.", + "create defaults to dry-run; only --confirm calls the native Sub2API create API and then reconciles every planned announcement field after reading the returned ID back.", ].join("\n"); return { ok: true, command: "platform-infra sub2api codex-pool announcements --help", renderedText, contentType: "text/plain", projection: { ok: true, mutation: false } }; } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts index 373b83d4..cc3920bc 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-announcements.ts @@ -30,6 +30,49 @@ def announcements_get(token, announcement_id): item = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "get announcement") return {"ok": isinstance(item, dict), "mode": "read-only", "mutation": False, "writeAttempted": 0, "announcement": item, "valuesPrinted": False} +def announcement_time_seconds(value): + if value is None or value == "": + return None + if isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + except ValueError: + return value + return value + +def announcement_targeting(value): + if not isinstance(value, dict): + return value + normalized = dict(value) + if normalized.get("any_of") is None: + normalized["any_of"] = [] + return normalized + +def announcements_reconcile(expected, actual): + fields = ("title", "content", "status", "notify_mode", "targeting", "starts_at", "ends_at") + expected_values = {} + actual_values = {} + mismatched_fields = [] + for field in fields: + expected_value = expected.get(field) if isinstance(expected, dict) else None + actual_value = actual.get(field) if isinstance(actual, dict) else None + if field in ("starts_at", "ends_at"): + expected_value = announcement_time_seconds(expected_value) + actual_value = announcement_time_seconds(actual_value) + elif field == "targeting": + expected_value = announcement_targeting(expected_value) + actual_value = announcement_targeting(actual_value) + expected_values[field] = expected_value + actual_values[field] = actual_value + if expected_value != actual_value: + mismatched_fields.append(field) + return {"expected": expected_values, "actual": actual_values, "mismatchedFields": mismatched_fields} + def announcements_create(token, payload): announcement = payload.get("announcement") if isinstance(payload.get("announcement"), dict) else {} created = ensure_success(curl_api("POST", "/api/v1/admin/announcements", bearer=token, payload=announcement), "create announcement") @@ -37,10 +80,17 @@ def announcements_create(token, payload): if not isinstance(announcement_id, int) or announcement_id <= 0: raise RuntimeError("create announcement response missing positive id") actual = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "read announcement after create") - reconciled = isinstance(actual, dict) and actual.get("id") == announcement_id + reconciliation = announcements_reconcile(announcement, actual) + id_matched = isinstance(actual, dict) and actual.get("id") == announcement_id + mismatched_fields = list(reconciliation.get("mismatchedFields") or []) + if not id_matched: + mismatched_fields.insert(0, "id") + reconciled = id_matched and len(mismatched_fields) == 0 return { "ok": reconciled, "mode": "confirmed", "mutation": True, "writeAttempted": 1, "writeSucceeded": 1, "writeFailed": 0, "reconciled": reconciled, + "mismatchedFields": mismatched_fields, + "reconciliation": {**reconciliation, "idMatched": id_matched}, "announcement": actual, "valuesPrinted": False, } From 00223ceea9073e5df43b628c51502a53d3495e84 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:51:44 +0200 Subject: [PATCH 06/12] docs: add selfmedia web verification shortcut --- .agents/skills/unidesk-selfmedia/SKILL.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.agents/skills/unidesk-selfmedia/SKILL.md b/.agents/skills/unidesk-selfmedia/SKILL.md index 3a1d3eaa..c6b03ab7 100644 --- a/.agents/skills/unidesk-selfmedia/SKILL.md +++ b/.agents/skills/unidesk-selfmedia/SKILL.md @@ -18,10 +18,15 @@ description: UniDesk SelfMedia 工厂的 YAML-first 运维技能,覆盖 NC01 ## 高频流程 -1. 管理员凭据更新时,按[运维入口](references/operations.md#管理员凭据分发)依次执行 `plan`、`sync`、job 查询和 `status`。 -2. 确认 `selfmedia-nc01` 的 `consumerRollout` 已声明 `selfmedia` Deployment;应用启动时读取 Secret,缺少 rollout 不能判定新凭据已生效。 -3. 代码发布只观察 PR merge 后的自动 PaC/GitOps 收敛;只有自动链失败时才下钻 status/history/debug-step。 -4. 最后从 YAML 解析公网地址,验证 `/` 与 `/healthz` 可达、匿名业务 API 返回 `401`,再用用户提供的新账号完成登录。 +1. SelfMedia Web 源码改动纳入 Git index 后,只执行一次仓库自带验证入口: + - `trans NC01:/root/.worktrees/selfmedia/ argv bun run verify:web`; + - 该入口根据 `package.json` 与 `bun.lock` 自动安装或复用根目录和 Web 依赖; + - 同一次调用依次完成 Web 测试、生产构建和 `git diff --check HEAD`; + - 禁止先探测 `node_modules`、手工安装依赖,再分别重复上述校验。 +2. 管理员凭据更新时,按[运维入口](references/operations.md#管理员凭据分发)依次执行 `plan`、`sync`、job 查询和 `status`。 +3. 确认 `selfmedia-nc01` 的 `consumerRollout` 已声明 `selfmedia` Deployment;应用启动时读取 Secret,缺少 rollout 不能判定新凭据已生效。 +4. 代码发布只观察 PR merge 后的自动 PaC/GitOps 收敛;只有自动链失败时才下钻 status/history/debug-step。 +5. 最后从 YAML 解析公网地址,验证 `/` 与 `/healthz` 可达、匿名业务 API 返回 `401`,再用用户提供的新账号完成登录。 ## 验收判定 @@ -29,6 +34,7 @@ description: UniDesk SelfMedia 工厂的 YAML-first 运维技能,覆盖 NC01 - `secrets status` 必须确认 `selfmedia-access`、`selfmedia-runtime`、`selfmedia-codex` 的声明键完整。 - WebTerm 必须保留 PVC session、restart/resume、统一 SelfMedia CLI 与非 root、无 ServiceAccount token/Kubernetes/Docker/宿主权限边界。 - 公网验收只记录 HTTP 状态、session/job/trace、fingerprint 和 artifact SHA,不记录 cookie、账号、密码或 API key。 +- `verify:web` 只负责源码层依赖准备、Web 测试、生产构建和差异格式检查;浏览器、公网、鉴权、WebTerm 与 PaC/Argo 运行面验收仍按任务范围执行。 ## 相关技能 From 98dd3f81940084f6db887d45c15c99f33f31876b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 05:57:23 +0200 Subject: [PATCH 07/12] docs(pikaoa): track production database backup --- docs/MDTODO/pikaoa-enterprise-platform.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 012c55ca..cb7a7987 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -139,6 +139,9 @@ #### R1.6.1 [in_progress] 建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,部署 NC01 pikaoa namespace,接入 PK01 host PostgreSQL、附件持久化、OTel/Prometheus、备份与 oa.pikapython.com TLS 公网入口(pikasTech/unidesk#2110),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1_Task_Report.md)。 +##### R1.6.1.1 [in_progress] + +将 PK01 PostgreSQL 逻辑备份正规化为 YAML-first 多数据库 logicalDumps,保留 Sub2API 并新增 PikaOA 独立 dump、timer、retention 与受控状态/可恢复性摘要(pikasTech/unidesk#2115),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md)。 ### R1.7 通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。 From 09462cc6b1ffd95c30d2495ce246950e39903d9c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:01:06 +0200 Subject: [PATCH 08/12] =?UTF-8?q?docs:=20=E6=94=B6=E5=8F=A3=20WebTerm=20?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E4=BD=93=E9=AA=8C=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../details/webterm-product-experience/R1_Task_Report.md | 6 ++++++ .../details/webterm-product-experience/R2_Task_Report.md | 7 +++++++ docs/MDTODO/webterm-product-experience.md | 6 +++--- 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 docs/MDTODO/details/webterm-product-experience/R1_Task_Report.md create mode 100644 docs/MDTODO/details/webterm-product-experience/R2_Task_Report.md diff --git a/docs/MDTODO/details/webterm-product-experience/R1_Task_Report.md b/docs/MDTODO/details/webterm-product-experience/R1_Task_Report.md new file mode 100644 index 00000000..2e680ecd --- /dev/null +++ b/docs/MDTODO/details/webterm-product-experience/R1_Task_Report.md @@ -0,0 +1,6 @@ +# R1 任务报告 + +- WebTerm issue #3 由 Artificer 在独立 worktree 完成,PR #5 已合并到集成分支,merge commit `d4e6bb68f88eadff9648b1b746eec0731bad3e31`。 +- 删除桌面端与移动端 `config-head`,标签栏固定可见;桌面配置隐藏后终端占用释放高度,移动端始终固定 50/50 以保留手机键盘/控制区。 +- 真实 Chromium 验证:1440×1000 桌面终端高度由 500 增至 883,drawer 由 500 收至 117;390×844 移动端为 422/422;标签栏可见且 `config-head` 不存在。 +- `npm run check` 与 `npm test` 通过;最终随 WebTerm 集成提交部署到 `local-7682`,未触碰 7683。 diff --git a/docs/MDTODO/details/webterm-product-experience/R2_Task_Report.md b/docs/MDTODO/details/webterm-product-experience/R2_Task_Report.md new file mode 100644 index 00000000..13ba71ce --- /dev/null +++ b/docs/MDTODO/details/webterm-product-experience/R2_Task_Report.md @@ -0,0 +1,7 @@ +# R2 任务报告 + +- WebTerm issue #4 由同一 Artificer 在 R1 基线上完成,PR #6 已合并,merge commit `32bc9f5adbac0ba5340217d27d1538a057ee95b1`;主审发现并修复跨 port claim 未迁移 `provider_id` 的 blocker,追加提交 `b1db65a`。 +- PG 增加 runtime active/closed、各 data-domain 的 YAML preset 生命周期,以及跨 port runtime closed 共享登记;runtime claim 在事务中迁移 `data_domain` 与目标 local `provider_id`,避免目标 port 重启后跳过恢复。 +- 加号面板同时展示本端 YAML 关闭项和共享 runtime 关闭项;打开面板主动刷新,服务端每 2 秒低频同步共享目录;运行项不重复出现。 +- `npm run check`、`npm test` 与隔离 Chromium UI 验证通过。真实 7682 验收完成:关闭 TERM 后 sessions 从 6 降至 5;仅重启 7682 后仍为 5,证明 closed 状态未被 YAML 自动拉起;加号显示 `TERM / YAML 预装 / mycx / /root/unidesk`,点击恢复后 sessions 回到 6。 +- 已部署到 `local-7682`,最终 health 为 providers=1、sessions=6、runtime records=4、backgroundMode=live;7683 未触碰。跨 port runtime 原子 claim 由 store 回归测试覆盖,未为测试部署或扰动其他端口。 diff --git a/docs/MDTODO/webterm-product-experience.md b/docs/MDTODO/webterm-product-experience.md index ca5ab27b..e4b2078d 100644 --- a/docs/MDTODO/webterm-product-experience.md +++ b/docs/MDTODO/webterm-product-experience.md @@ -4,10 +4,10 @@ - 交付:源码修改进入 `pikasTech/webterm`,本文件只登记跨代理任务与有界报告。 -## R1 [in_progress] +## R1 [completed] 依据 [WebTerm #3](https://github.com/pikasTech/webterm/issues/3) 改进配置页纵向空间:桌面模式支持隐藏“新建终端”“正在管理”等配置且终端实际占满释放高度;移动端与桌面端删除无意义的 config-head 行并固定展示标签栏;保持会话、恢复、持久化和后台绘制协议不变,由 Artificer 在独立 worktree 实现并提交 PR,完成任务后将详细报告写入[任务报告](./details/webterm-product-experience/R1_Task_Report.md)。 -## R2 +## R2 [completed] -依据 [WebTerm #4](https://github.com/pikasTech/webterm/issues/4) 让已关闭的可恢复终端保留恢复定义并进入加号创建面板的待恢复列表:区分停止与永久删除,服务端投影当前数据域的已关闭运行时和预置恢复项,选择后以 CLI 参数恢复且不模拟键盘;三个 PostgreSQL 数据域继续隔离,预置与运行时记录共存;依赖 R1 布局基线后再派实现,完成任务后将详细报告写入[任务报告](./details/webterm-product-experience/R2_Task_Report.md)。 +依据 [WebTerm #4](https://github.com/pikasTech/webterm/issues/4) 让已关闭的可恢复终端保留恢复定义并进入加号创建面板:YAML 预装项按各 port/data-domain 保存生命周期且 owning YAML 仍是定义真相;runtime 定义保留在 host PG,关闭登记跨 port 共享并由目标 port 原子 claim 迁移 data-domain/provider;服务重启只恢复 active,关闭项可从加号恢复且不模拟键盘;依赖 R1 布局基线,由 Artificer 实现并提交 PR,完成任务后将详细报告写入[任务报告](./details/webterm-product-experience/R2_Task_Report.md)。 From aab251173e07999c5ab8a559396fc9cc5bc92f40 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:09:58 +0200 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E6=9F=A5=E8=AF=A2=E6=B4=BB=E8=B7=83=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=97=B6=E9=97=B4=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 5 +- config/platform-infra/sub2api-codex-pool.yaml | 3 + .../platform-infra-sub2api-codex/config.ts | 14 ++- .../platform-infra-sub2api-codex/credits.ts | 92 +++++++++++++++++-- .../remote-python-credits.ts | 37 ++++++-- .../remote-scripts.ts | 3 +- .../src/platform-infra-sub2api-codex/types.ts | 7 +- 7 files changed, 140 insertions(+), 21 deletions(-) diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 36a6221d..ea9997e6 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -111,6 +111,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --mont bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month 2026-07 --forecast bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month --forecast --sale-rate --scenario-without-account '' bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-since 24h --amount-usd 20 --reason +bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-start 2026-07-14T17:00:00 --active-end 2026-07-14T18:00:00 --amount-usd 10 --reason bun scripts/cli.ts platform-infra sub2api codex-pool announcements list --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool announcements get --target PK01 --id bun scripts/cli.ts platform-infra sub2api codex-pool announcements create --target PK01 --title --content --targeting-json '{"any_of":[]}' @@ -151,7 +152,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `--json` 保留精确十进制字符串、原生来源、时间窗、分页数和完整性明细;命令不修改 runtime。 - `credits grant` 管理客户余额补偿: - - `--active-since ` 从原生 usage 选取窗口内有请求的非管理员活跃用户;`--users ` 使用精确 selector;两者必须且只能选择一个; + - `--active-since ` 从原生 usage 选取相对窗口内有请求的非管理员活跃用户; + - `--active-start --active-end ` 使用精确半开区间 `[start,end)`;未携带 UTC 偏移的输入按 `config/platform-infra/sub2api-codex-pool.yaml#credits.timezone` 解释,并同时披露本地与 UTC 边界; + - `--users ` 使用精确 selector;相对窗口、固定窗口和精确 selector 三者必须且只能选择一个; - 默认 dry-run,逐用户披露完整邮箱、用户 ID、请求数、当前余额、增加额和目标余额; - 活跃窗口只允许生成计划;实际写入必须把 dry-run 返回的用户 ID 固定为精确 `--users` selectors,再显式 `--confirm`,防止移动窗口在确认时扩大或缩小名单; - 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账; diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index b66e3468..31939b6c 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -76,6 +76,9 @@ profit: selfUse: [自用] excluded: [grok] +credits: + timezone: Asia/Shanghai + pool: groupName: unidesk-codex-pool groupDescription: UniDesk-managed Codex API-key pool for YAML-selected Sub2API clients. diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index 69633e59..955b840c 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -21,7 +21,7 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types"; +import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolCreditsConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types"; import { booleanValue, integerArrayConfigField, integerConfigField, isRecord, normalizeBaseUrl, numberValue, readRequiredBaseUrl, readRequiredDescription, readRequiredEmail, readRequiredPort, readRequiredPositiveNumber, requiredRecordConfigField, requiredStringConfigField, stringValue, validateKubernetesName } from "./config-utils"; import { readAuthAPIKey, uniqueAccountName } from "./redaction"; import { codexPoolConfigPath } from "./types"; @@ -180,6 +180,7 @@ export function readCodexPoolConfig(): CodexPoolConfig { profiles, runtime, profit: readProfitConfig(parsed.profit), + credits: readCreditsConfig(parsed.credits), manualAccounts, publicExposure: readPublicExposureConfig(parsed.publicExposure), localCodex: readLocalCodexConfig(parsed.localCodex), @@ -200,6 +201,17 @@ export function readCodexPoolConfig(): CodexPoolConfig { return config; } +function readCreditsConfig(value: unknown): CodexPoolCreditsConfig { + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.credits must be a YAML object`); + const timezone = requiredStringConfigField(value, "timezone", "credits"); + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0)); + } catch { + throw new Error(`${codexPoolConfigPath}.credits.timezone must be a valid IANA timezone`); + } + return { timezone }; +} + function readProfitConfig(value: unknown): CodexPoolProfitConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.profit must be a YAML object`); const groups = requiredRecordConfigField(value, "groups", "profit"); diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts index 30a61635..664118f7 100644 --- a/scripts/src/platform-infra-sub2api-codex/credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -4,9 +4,15 @@ import { renderTable } from "./render"; import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; import { creditsScript } from "./remote-scripts"; import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target"; +import { readCodexPoolConfig } from "./config"; interface CreditsOptions { activeSince: string | null; + activeStart: string | null; + activeEnd: string | null; + activeStartUtc: string | null; + activeEndUtc: string | null; + timezone: string; users: string[]; amountUsd: number; reason: string; @@ -17,9 +23,10 @@ interface CreditsOptions { export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { if (args.includes("--help")) return renderCreditsHelp(); - const options = parseCreditsOptions(args); + const pool = readCodexPoolConfig(); + const options = parseCreditsOptions(args, pool.credits.timezone); const target = codexPoolRuntimeTarget(options.targetId); - const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, target), target); + const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, pool, target), target); const report = parseJsonOutput(remote.stdout); const ok = remote.exitCode === 0 && boolField(report, "ok", false); const response = { @@ -27,8 +34,13 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P action: "platform-infra-sub2api-codex-pool-credits", target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }, request: { - selection: options.activeSince === null ? "exact-users" : "active-users", + selection: options.users.length > 0 ? "exact-users" : options.activeSince === null ? "active-window" : "active-users", activeSince: options.activeSince, + activeStart: options.activeStart, + activeEnd: options.activeEnd, + activeStartUtc: options.activeStartUtc, + activeEndUtc: options.activeEndUtc, + timezone: options.timezone, users: options.users, amountUsd: options.amountUsd, reason: options.reason, @@ -41,10 +53,12 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P return options.json ? renderCreditsJson(response) : renderCredits(response); } -function parseCreditsOptions(args: string[]): CreditsOptions { +function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { const [action = "grant", ...rest] = args; - if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); + if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); let activeSince: string | null = null; + let activeStart: string | null = null; + let activeEnd: string | null = null; let users: string[] = []; let amountUsd: number | null = null; let reason: string | null = null; @@ -62,6 +76,10 @@ function parseCreditsOptions(args: string[]): CreditsOptions { else if (arg === "--json") json = true; else if (arg === "--active-since") [activeSince, index] = readValue(index, arg); else if (arg.startsWith("--active-since=")) activeSince = arg.slice("--active-since=".length).trim(); + else if (arg === "--active-start") [activeStart, index] = readValue(index, arg); + else if (arg.startsWith("--active-start=")) activeStart = arg.slice("--active-start=".length).trim(); + else if (arg === "--active-end") [activeEnd, index] = readValue(index, arg); + else if (arg.startsWith("--active-end=")) activeEnd = arg.slice("--active-end=".length).trim(); else if (arg === "--users") { let value: string; [value, index] = readValue(index, arg); @@ -78,12 +96,64 @@ function parseCreditsOptions(args: string[]): CreditsOptions { else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim(); else throw new Error(`unsupported credits option: ${arg}`); } - if ((activeSince === null) === (users.length === 0)) throw new Error("use exactly one of --active-since or --users"); + if ((activeStart === null) !== (activeEnd === null)) throw new Error("--active-start and --active-end must be provided together"); + const selectionCount = Number(activeSince !== null) + Number(activeStart !== null) + Number(users.length > 0); + if (selectionCount !== 1) throw new Error("use exactly one of --active-since, --active-start/--active-end, or --users"); if (activeSince !== null && !/^[1-9][0-9]*(?:m|h|d)$/u.test(activeSince)) throw new Error("--active-since must use a duration such as 24h"); - if (confirm && activeSince !== null) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); + if (activeStart !== null && !isIsoDateTime(activeStart)) throw new Error("--active-start must use ISO date-time format"); + if (activeEnd !== null && !isIsoDateTime(activeEnd)) throw new Error("--active-end must use ISO date-time format"); + const activeStartUtc = activeStart === null ? null : isoInTimezoneToUtc(activeStart, timezone); + const activeEndUtc = activeEnd === null ? null : isoInTimezoneToUtc(activeEnd, timezone); + if (activeStartUtc !== null && activeEndUtc !== null && activeEndUtc <= activeStartUtc) { + throw new Error("credits active window end must be after start"); + } + if (confirm && users.length === 0) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); if (amountUsd === null) throw new Error("credits grant requires --amount-usd"); if (reason === null || reason.length === 0 || reason.length > 500 || /[\r\n\0]/u.test(reason)) throw new Error("credits grant requires a single-line --reason up to 500 characters"); - return { activeSince, users, amountUsd, reason, confirm, targetId, json }; + return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, users, amountUsd, reason, confirm, targetId, json }; +} + +function isIsoDateTime(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})?$/u.test(value); +} + +function isoInTimezoneToUtc(value: string, timezone: string): string { + if (/(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) { + const instant = new Date(value); + if (!Number.isFinite(instant.getTime())) throw new Error("fixed-window date-time is invalid"); + return instant.toISOString(); + } + const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?$/u.exec(value); + if (matched === null) throw new Error("fixed-window date-time is invalid"); + const components = matched.slice(1, 7).map((item) => Number(item ?? 0)); + const milliseconds = Number((matched[7] ?? "").padEnd(3, "0").slice(0, 3)); + const localEpoch = Date.UTC(components[0]!, components[1]! - 1, components[2]!, components[3]!, components[4]!, components[5]!, milliseconds); + let instant = localEpoch; + for (let attempt = 0; attempt < 3; attempt += 1) { + const actual = timezoneComponents(instant, timezone); + const represented = Date.UTC(actual[0], actual[1] - 1, actual[2], actual[3], actual[4], actual[5], milliseconds); + instant = localEpoch - (represented - instant); + } + const actual = timezoneComponents(instant, timezone); + if (actual.some((item, index) => item !== components[index])) { + throw new Error(`fixed-window date-time does not exist in timezone ${timezone}: ${value}`); + } + return new Date(instant).toISOString(); +} + +function timezoneComponents(epochMs: number, timezone: string): number[] { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(epochMs)); + const values = new Map(parts.map((part) => [part.type, Number(part.value)])); + return ["year", "month", "day", "hour", "minute", "second"].map((key) => values.get(key) ?? Number.NaN); } function parseSelectors(value: string): string[] { @@ -108,9 +178,11 @@ function renderCreditsHelp(): RenderedCliResult { "SUB2API USER CREDITS", "Usage:", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --amount-usd 20 --reason [--confirm]", + " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-start --active-end --amount-usd 20 --reason ", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users --amount-usd 20 --reason [--confirm]", "Notes:", " Default mode is dry-run. --active-since selects distinct active non-admin users from native usage.", + " Offset-free fixed-window values use config/platform-infra/sub2api-codex-pool.yaml#credits.timezone and the interval is [start,end).", " --confirm performs additive balance writes and then reads every selected user back.", ].join("\n"), contentType: "text/plain", @@ -125,6 +197,10 @@ function renderCredits(response: Record): RenderedCliResult { `SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`, `WRITE attempted=${text(summary.writeAttempted)} succeeded=${text(summary.writeSucceeded)} failed=${text(summary.writeFailed)} reconciled=${text(summary.reconciled)} mutation=${text(report.mutation)}`, ]; + const selection = record(report.selection); + if (selection.kind === "active-users" || selection.kind === "active-window") { + lines.push(`WINDOW timezone=${text(selection.timezone)} start=${text(selection.startAt)} end=${text(selection.endAt)} start-utc=${text(selection.startAtUtc)} end-utc=${text(selection.endAtUtc)}`); + } if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`); if (rows.length > 0) { lines.push("", renderTable([ diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts index b6dc0066..fe4595ad 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -28,9 +28,17 @@ def credits_all_users(token): return users page += 1 -def credits_active_usage(token, since): - end_at = datetime.now(timezone.utc) - start_at = credits_window_start(since, end_at) +def credits_active_usage(token, since, active_start, active_end, active_start_utc, active_end_utc, timezone_name): + if since: + end_at = datetime.now(timezone.utc) + start_at = credits_window_start(since, end_at) + selection_kind = "active-users" + else: + start_at = credits_parse_time(active_start_utc) + end_at = credits_parse_time(active_end_utc) + selection_kind = "active-window" + if start_at is None or end_at is None or end_at <= start_at: + raise RuntimeError("credits active window end must be after start") user_requests = {} page = 1 while True: @@ -46,11 +54,19 @@ def credits_active_usage(token, since): reached_start = True continue user_id = row.get("user_id") if row.get("user_id") is not None else row.get("userId") - if user_id is not None and (created_at is None or created_at <= end_at): + if user_id is not None and created_at is not None and start_at <= created_at < end_at: user_requests[str(user_id)] = user_requests.get(str(user_id), 0) + 1 total = int(data.get("total", 0)) if isinstance(data, dict) else 0 if not batch or len(batch) < 100 or reached_start or page * 100 >= total: - return user_requests, start_at, end_at + return user_requests, start_at, end_at, { + "kind": selection_kind, + "since": since, + "timezone": timezone_name if selection_kind == "active-window" else "UTC", + "startAt": active_start if selection_kind == "active-window" else start_at.isoformat(), + "endAt": active_end if selection_kind == "active-window" else end_at.isoformat(), + "startAtUtc": start_at.isoformat().replace("+00:00", "Z"), + "endAtUtc": end_at.isoformat().replace("+00:00", "Z"), + } page += 1 def credits_selected_users(token, payload): @@ -58,8 +74,13 @@ def credits_selected_users(token, payload): users_by_id = {str(item.get("id")): item for item in users if item.get("id") is not None} users_by_email = {str(item.get("email") or "").strip().lower(): item for item in users if item.get("email")} active_since = payload.get("activeSince") - if active_since: - requests, start_at, end_at = credits_active_usage(token, active_since) + active_start = payload.get("activeStart") + active_end = payload.get("activeEnd") + if active_since or (active_start and active_end): + requests, _, _, selection = credits_active_usage( + token, active_since, active_start, active_end, + payload.get("activeStartUtc"), payload.get("activeEndUtc"), payload.get("timezone"), + ) selected = [] excluded = [] for user_id, request_count in requests.items(): @@ -72,7 +93,7 @@ def credits_selected_users(token, payload): excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "inactive-or-deleted"}) else: selected.append((user, request_count)) - return selected, excluded, {"kind": "active-users", "since": active_since, "startAt": start_at.isoformat(), "endAt": end_at.isoformat()} + return selected, excluded, selection selected = [] seen = set() for selector in payload.get("users") or []: diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index cf72e990..b724a46b 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -57,8 +57,7 @@ export function profitScript(payload: unknown, pool: CodexPoolConfig, target: Co return remotePythonScript("profit", encoded, pool, target); } -export function creditsScript(payload: unknown, target: CodexPoolRuntimeTarget): string { - const pool = readCodexPoolConfig(); +export function creditsScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); return remotePythonScript("credits", encoded, pool, target); } diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index a0463a8a..4b9b2080 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -219,6 +219,10 @@ export interface CodexPoolProfitConfig { }; } +export interface CodexPoolCreditsConfig { + timezone: string; +} + export interface CodexPoolConfig { version: number; kind: string; @@ -245,6 +249,7 @@ export interface CodexPoolConfig { profiles: CodexPoolProfileConfig[]; runtime: CodexRuntimeConfig; profit: CodexPoolProfitConfig; + credits: CodexPoolCreditsConfig; manualAccounts: CodexPoolManualAccountsConfig; publicExposure: CodexPoolPublicExposureConfig; localCodex: CodexPoolLocalCodexConfig; @@ -401,7 +406,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group |--all-groups] [--platform ] [--page-token ] [--account ] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate ] [--scenario-without-account ...] [--accounts [--page-token ]|--missing-costs] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool announcements list|get|create [--target PK01] [--confirm for create] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", From c483d6d508f5a9501f211d2b7bffd16a04494483 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:20:43 +0200 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20PK01=20?= =?UTF-8?q?=E5=A4=9A=E6=95=B0=E6=8D=AE=E5=BA=93=E9=80=BB=E8=BE=91=E5=A4=87?= =?UTF-8?q?=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-db/postgres-pk01.yaml | 47 ++- .../src/platform-db-postgres-backups.test.ts | 57 ++++ scripts/src/platform-db-postgres-backups.ts | 90 ++++++ scripts/src/platform-db.ts | 286 ++++++++++++++---- 4 files changed, 404 insertions(+), 76 deletions(-) create mode 100644 scripts/src/platform-db-postgres-backups.test.ts create mode 100644 scripts/src/platform-db-postgres-backups.ts diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 276cf0bd..e0c80f83 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -11,6 +11,7 @@ metadata: - 297 - 300 - 2078 + - 2115 cluster: role: primary @@ -822,17 +823,41 @@ exports: backup: phase: minimum-restoreable - logicalDump: - enabled: true - schedule: "17 3 * * *" - database: sub2api - retentionDays: 14 - destination: - type: pk01-local - path: /var/backups/unidesk/platform-db/pk01/sub2api - encryption: - enabled: false - futureKeyRef: platform-db/backup-age-recipient.txt + logicalDumps: + - enabled: true + database: sub2api + scriptPath: /usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh + serviceName: unidesk-pk01-sub2api-pgdump.service + timerName: unidesk-pk01-sub2api-pgdump.timer + schedule: "*-*-* 03:17:00" + retentionDays: 14 + warningAfterHours: 36 + destination: + type: pk01-local + path: /var/backups/unidesk/platform-db/pk01/sub2api + pgDump: + host: 127.0.0.1 + format: custom + encryption: + enabled: false + futureKeyRef: platform-db/backup-age-recipient.txt + - enabled: true + database: pikaoa + scriptPath: /usr/local/sbin/unidesk-pk01-pikaoa-pgdump.sh + serviceName: unidesk-pk01-pikaoa-pgdump.service + timerName: unidesk-pk01-pikaoa-pgdump.timer + schedule: "*-*-* 03:37:00" + retentionDays: 14 + warningAfterHours: 36 + destination: + type: pk01-local + path: /var/backups/unidesk/platform-db/pk01/pikaoa + pgDump: + host: 127.0.0.1 + format: custom + encryption: + enabled: false + futureKeyRef: platform-db/backup-age-recipient.txt physicalWalArchive: enabled: false futureTool: pgbackrest diff --git a/scripts/src/platform-db-postgres-backups.test.ts b/scripts/src/platform-db-postgres-backups.test.ts new file mode 100644 index 00000000..deb73bfe --- /dev/null +++ b/scripts/src/platform-db-postgres-backups.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { projectLogicalDumps } from "./platform-db-postgres-backups"; +import { readPostgresLogicalDumps, renderPostgresRemoteRunnerForTest } from "./platform-db"; + +describe("PK01 PostgreSQL logical dumps", () => { + test("parses independent Sub2API and PikaOA declarations", () => { + const logicalDumps = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml"); + + expect(logicalDumps).toHaveLength(2); + expect(logicalDumps.map((item) => item.database)).toEqual(["sub2api", "pikaoa"]); + expect(logicalDumps[0]).toMatchObject({ + scriptPath: "/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh", + serviceName: "unidesk-pk01-sub2api-pgdump.service", + timerName: "unidesk-pk01-sub2api-pgdump.timer", + schedule: "*-*-* 03:17:00", + retentionDays: 14, + }); + expect(logicalDumps[1]).toMatchObject({ + destination: { path: "/var/backups/unidesk/platform-db/pk01/pikaoa" }, + schedule: "*-*-* 03:37:00", + retentionDays: 14, + }); + }); + + test("renders YAML-driven backup resources without Sub2API constants", () => { + const runner = renderPostgresRemoteRunnerForTest(); + + expect(runner).toContain('data["backup"]["logicalDumps"][index]'); + expect(runner).toContain("backup.logicalDumps.$backup_index.serviceName"); + expect(runner).toContain("backup.logicalDumps.$backup_index.timerName"); + expect(runner).not.toContain("unidesk-pk01-sub2api-pgdump.service"); + expect(runner).not.toContain("OnCalendar=*-*-* 03:17:00"); + }); + + test("reports stale or missing evidence as warnings", () => { + const [declaration] = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml"); + const [projection] = projectLogicalDumps([declaration], [{ + database: declaration.database, + databaseExists: true, + scriptPath: declaration.scriptPath, + scriptExists: true, + serviceName: declaration.serviceName, + serviceResult: "success", + serviceExitStatus: 0, + lastSuccessAt: null, + timerName: declaration.timerName, + timerActive: true, + timerEnabled: true, + nextRunAt: null, + latestNonEmptyDump: null, + }]); + + expect(projection.recoverability.recoverable).toBeFalse(); + expect(projection.warnings).toContain("backup-recent-success-missing"); + expect(projection.warnings).toContain("backup-latest-non-empty-dump-missing"); + }); +}); diff --git a/scripts/src/platform-db-postgres-backups.ts b/scripts/src/platform-db-postgres-backups.ts new file mode 100644 index 00000000..096b0821 --- /dev/null +++ b/scripts/src/platform-db-postgres-backups.ts @@ -0,0 +1,90 @@ +export interface LogicalDumpConfig { + enabled: boolean; + database: string; + scriptPath: string; + serviceName: string; + timerName: string; + schedule: string; + retentionDays: number; + warningAfterHours: number; + destination: { type: string; path: string }; + pgDump: { host: string; format: "custom" }; + encryption: { enabled: boolean }; +} + +export interface LogicalDumpRuntimeFact { + database: string; + databaseExists: boolean; + scriptPath: string; + scriptExists: boolean; + serviceName: string; + serviceResult: string | null; + serviceExitStatus: number | null; + lastSuccessAt: string | null; + timerName: string; + timerActive: boolean; + timerEnabled: boolean; + nextRunAt: string | null; + latestNonEmptyDump: { + path: string; + bytes: number; + modifiedAt: string; + } | null; +} + +export interface LogicalDumpProjection extends LogicalDumpConfig { + observed: LogicalDumpRuntimeFact | null; + recoverability: { + recoverable: boolean; + latestNonEmptyDump: LogicalDumpRuntimeFact["latestNonEmptyDump"]; + }; + warnings: string[]; +} + +export function projectLogicalDumps( + declarations: LogicalDumpConfig[], + facts: LogicalDumpRuntimeFact[] | null, + observedAt = Date.now(), +): LogicalDumpProjection[] { + return declarations.map((declaration) => { + const observed = facts?.find((item) => item.database === declaration.database && item.timerName === declaration.timerName) ?? null; + const warnings: string[] = []; + if (!declaration.enabled) return projection(declaration, observed, warnings); + if (observed === null) { + warnings.push("backup-observation-unavailable"); + return projection(declaration, observed, warnings); + } + if (!observed.databaseExists) warnings.push("backup-database-missing"); + if (!observed.scriptExists) warnings.push("backup-script-missing"); + if (!observed.timerEnabled) warnings.push("backup-timer-disabled"); + if (!observed.timerActive) warnings.push("backup-timer-inactive"); + if (observed.lastSuccessAt === null) warnings.push("backup-recent-success-missing"); + if (observed.latestNonEmptyDump === null) { + warnings.push("backup-latest-non-empty-dump-missing"); + } else { + const modifiedAt = Date.parse(observed.latestNonEmptyDump.modifiedAt); + if (!Number.isFinite(modifiedAt) || observedAt - modifiedAt > declaration.warningAfterHours * 60 * 60 * 1000) { + warnings.push("backup-latest-dump-stale"); + } + } + if (observed.serviceResult !== null && observed.serviceResult !== "success") warnings.push("backup-last-run-failed"); + if (observed.serviceExitStatus !== null && observed.serviceExitStatus !== 0) warnings.push("backup-last-run-nonzero"); + return projection(declaration, observed, warnings); + }); +} + +function projection( + declaration: LogicalDumpConfig, + observed: LogicalDumpRuntimeFact | null, + warnings: string[], +): LogicalDumpProjection { + return { + ...declaration, + observed, + recoverability: { + recoverable: observed?.databaseExists === true && observed.latestNonEmptyDump !== null, + latestNonEmptyDump: observed?.latestNonEmptyDump ?? null, + }, + warnings, + }; +} diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 377618f7..442e6e77 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -8,6 +8,11 @@ import { startJob } from "./jobs"; import { MONITOR_CENTRAL_MIGRATIONS, MONITOR_CENTRAL_SCHEMA_VERSION } from "./monitor-central-persistence"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, parseJsonOutput } from "./platform-infra-ops-library"; +import { + projectLogicalDumps, + type LogicalDumpConfig, + type LogicalDumpRuntimeFact, +} from "./platform-db-postgres-backups"; const defaultConfigPath = "config/platform-db/postgres-pk01.yaml"; const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres"; @@ -138,16 +143,7 @@ interface PostgresHostConfig { exports: { connectionStrings: ConnectionStringExportConfig[]; }; - backup: { - logicalDump: { - enabled: boolean; - schedule: string; - database: string; - retentionDays: number; - destination: { type: string; path: string }; - encryption: { enabled: boolean }; - }; - }; + backup: { logicalDumps: LogicalDumpConfig[] }; managedOperations?: { monitorReset: MonitorResetConfig }; } @@ -272,6 +268,7 @@ interface RemoteFacts { error: string | null; }>; }; + backup: { logicalDumps: LogicalDumpRuntimeFact[] }; raw?: Record; } @@ -442,6 +439,7 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< const facts = remote.parsed; const checks = facts === null ? [] : desiredChecks(pg, facts, secrets); const desired = desiredSummary(pg, secrets, facts); + const logicalDumps = projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null); const result = { ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok, action: "platform-db-postgres-plan", @@ -450,6 +448,8 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< secrets: secretSummary(secrets), remoteFacts: facts, desired, + logicalDumps, + warnings: logicalDumps.flatMap((item) => item.warnings.map((warning) => ({ database: item.database, warning }))), checks, next: { apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, @@ -491,6 +491,7 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis ...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]), ]; const exportTargets = inspectExportTargets(pg); + const logicalDumps = projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null); const result = { ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok, action: "platform-db-postgres-status", @@ -526,6 +527,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis dnsRequiredBeforeSub2ApiCutover: false, dnsDisposition: facts.network.dns.resolves ? "optional-alias-resolves" : "optional-alias-unresolved", }, + logicalDumps, + warnings: logicalDumps.flatMap((item) => item.warnings.map((warning) => ({ database: item.database, warning }))), remoteFacts: facts, secrets: secretSummary(secrets), exports: exportTargets, @@ -668,9 +671,6 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { const exportsConfig = objectField(parsed, "exports", configPath); const backup = objectField(parsed, "backup", configPath); const managedOperations = parsed.managedOperations === undefined ? null : objectField(parsed, "managedOperations", configPath); - const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`); - const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`); - const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`); const manager = stringField(postgresPackage, "manager", `${configPath}.postgres.package`); if (manager !== "apt") throw new Error(`${configPath}.postgres.package.manager must be apt`); const packageVersion = stringField(postgresPackage, "version", `${configPath}.postgres.package`); @@ -697,6 +697,23 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { databaseNames.add(database.name); } const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`)); + const logicalDumps = arrayOfRecords(backup.logicalDumps, `${configPath}.backup.logicalDumps`).map((item, index) => logicalDumpConfig(item, `${configPath}.backup.logicalDumps[${index}]`)); + if (logicalDumps.length === 0) throw new Error(`${configPath}.backup.logicalDumps must declare at least one logical dump`); + const logicalDumpKeys = new Set(); + for (const logicalDump of logicalDumps) { + if (!databaseNames.has(logicalDump.database)) throw new Error(`${configPath}.backup.logicalDumps.${logicalDump.database}.database must reference a declared database`); + for (const [kind, value] of [ + ["database", logicalDump.database], + ["scriptPath", logicalDump.scriptPath], + ["serviceName", logicalDump.serviceName], + ["timerName", logicalDump.timerName], + ["destination.path", logicalDump.destination.path], + ] as const) { + const key = `${kind}:${value}`; + if (logicalDumpKeys.has(key)) throw new Error(`${configPath}.backup.logicalDumps contains duplicate ${kind} ${value}`); + logicalDumpKeys.add(key); + } + } const monitorReset = managedOperations === null ? null : monitorResetConfig(objectField(managedOperations, "monitorReset", `${configPath}.managedOperations`), `${configPath}.managedOperations.monitorReset`); if (monitorReset !== null) { const database = databases.find((item) => item.name === monitorReset.database); @@ -800,25 +817,53 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { }, objects: { roles, databases }, exports: { connectionStrings }, - backup: { - logicalDump: { - enabled: booleanField(logicalDump, "enabled", `${configPath}.backup.logicalDump`), - schedule: stringField(logicalDump, "schedule", `${configPath}.backup.logicalDump`), - database: stringField(logicalDump, "database", `${configPath}.backup.logicalDump`), - retentionDays: integerField(logicalDump, "retentionDays", `${configPath}.backup.logicalDump`), - destination: { - type: stringField(destination, "type", `${configPath}.backup.logicalDump.destination`), - path: absolutePathField(destination, "path", `${configPath}.backup.logicalDump.destination`), - }, - encryption: { - enabled: booleanField(encryption, "enabled", `${configPath}.backup.logicalDump.encryption`), - }, - }, - }, + backup: { logicalDumps }, ...(monitorReset === null ? {} : { managedOperations: { monitorReset } }), }; } +export function readPostgresLogicalDumps(pathArg: string): LogicalDumpConfig[] { + return readPostgresHostConfig(pathArg).backup.logicalDumps; +} + +function logicalDumpConfig(item: Record, path: string): LogicalDumpConfig { + const destination = objectField(item, "destination", path); + const pgDump = objectField(item, "pgDump", path); + const encryption = objectField(item, "encryption", path); + const serviceName = systemdUnitName(stringField(item, "serviceName", path), `${path}.serviceName`, ".service"); + const timerName = systemdUnitName(stringField(item, "timerName", path), `${path}.timerName`, ".timer"); + const format = stringField(pgDump, "format", `${path}.pgDump`); + if (format !== "custom") throw new Error(`${path}.pgDump.format must be custom`); + const retentionDays = integerField(item, "retentionDays", path); + const warningAfterHours = integerField(item, "warningAfterHours", path); + if (retentionDays < 1) throw new Error(`${path}.retentionDays must be positive`); + if (warningAfterHours < 1) throw new Error(`${path}.warningAfterHours must be positive`); + return { + enabled: booleanField(item, "enabled", path), + database: pgIdentifier(stringField(item, "database", path), `${path}.database`), + scriptPath: absolutePathField(item, "scriptPath", path), + serviceName, + timerName, + schedule: stringField(item, "schedule", path), + retentionDays, + warningAfterHours, + destination: { + type: stringField(destination, "type", `${path}.destination`), + path: absolutePathField(destination, "path", `${path}.destination`), + }, + pgDump: { + host: stringField(pgDump, "host", `${path}.pgDump`), + format, + }, + encryption: { enabled: booleanField(encryption, "enabled", `${path}.encryption`) }, + }; +} + +function systemdUnitName(value: string, path: string, suffix: ".service" | ".timer"): string { + if (!/^[A-Za-z0-9_.@-]+$/u.test(value) || !value.endsWith(suffix)) throw new Error(`${path} must be a safe ${suffix} unit name`); + return value; +} + function monitorResetConfig(item: Record, path: string): MonitorResetConfig { return { database: pgIdentifier(stringField(item, "database", path), `${path}.database`), @@ -1080,6 +1125,7 @@ function configSummary(pg: PostgresHostConfig): Record { roles: pg.objects.roles.map((role) => ({ name: role.name, login: role.login })), databases: pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner })), }, + logicalDumps: pg.backup.logicalDumps, noK3s: true, }; } @@ -1142,6 +1188,7 @@ function compactPlan( key: item.writeToSecretSource.key, consumerScopes: item.consumers.map((consumer) => consumer.scope), })), + logicalDumps: compactLogicalDumps(pg, facts), checks: { ok: checks.every((check) => check.ok === true), passed: checks.filter((check) => check.ok === true).length, @@ -1276,6 +1323,7 @@ function compactStatus( fingerprint: item.fingerprint, }), }), + logicalDumps: compactLogicalDumps(pg, facts), remote: compactPlanCapture(capture), next: { plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`, @@ -1292,6 +1340,28 @@ function compactStatus( }; } +function compactLogicalDumps(pg: PostgresHostConfig, facts: RemoteFacts | null): Array> { + return projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null).map((item) => ({ + database: item.database, + enabled: item.enabled, + scriptPath: item.scriptPath, + serviceName: item.serviceName, + timerName: item.timerName, + directory: item.destination.path, + schedule: item.schedule, + retentionDays: item.retentionDays, + timer: item.observed === null ? null : { + active: item.observed.timerActive, + enabled: item.observed.timerEnabled, + nextRunAt: item.observed.nextRunAt, + }, + lastSuccessAt: item.observed?.lastSuccessAt ?? null, + latestNonEmptyDump: item.recoverability.latestNonEmptyDump, + recoverable: item.recoverability.recoverable, + warnings: item.warnings, + })); +} + function compactExportSecrets( pg: PostgresHostConfig, localState: { inspection: SecretInspection; summary: Record; exports: Array> }, @@ -1773,7 +1843,7 @@ function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts consumers: item.consumers, valuesPrinted: false, })), - backup: pg.backup.logicalDump, + backup: { logicalDumps: projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null) }, }; } @@ -1886,6 +1956,20 @@ function factsScript(pg: PostgresHostConfig, probes: Array<{ user: string; passw `printf '%s' ${shellQuote(appHost)} >"$tmp/appConnHost.${index}"`, ].join("\n"); }).join("\n"); + const logicalDumpCommands = pg.backup.logicalDumps.map((logicalDump, index) => { + const encoded = Buffer.from(JSON.stringify(logicalDump), "utf8").toString("base64"); + return [ + `printf '%s' '${encoded}' | base64 -d >"$tmp/logicalDump.${index}.json"`, + `[ -f ${shellQuote(logicalDump.scriptPath)} ]; printf '%s' "$?" >"$tmp/logicalDumpScriptRc.${index}"`, + `systemctl is-active ${shellQuote(logicalDump.timerName)} >"$tmp/logicalDumpTimerActive.${index}" 2>/dev/null`, + `systemctl is-enabled ${shellQuote(logicalDump.timerName)} >"$tmp/logicalDumpTimerEnabled.${index}" 2>/dev/null`, + `systemctl show ${shellQuote(logicalDump.timerName)} -p NextElapseUSecRealtime --value >"$tmp/logicalDumpNextRun.${index}" 2>/dev/null`, + `systemctl show ${shellQuote(logicalDump.serviceName)} -p Result --value >"$tmp/logicalDumpServiceResult.${index}" 2>/dev/null`, + `systemctl show ${shellQuote(logicalDump.serviceName)} -p ExecMainStatus --value >"$tmp/logicalDumpServiceExitStatus.${index}" 2>/dev/null`, + `systemctl show ${shellQuote(logicalDump.serviceName)} -p InactiveExitTimestamp --value >"$tmp/logicalDumpLastSuccess.${index}" 2>/dev/null`, + `find ${shellQuote(logicalDump.destination.path)} -type f -name ${shellQuote(`${logicalDump.database}-*.dump`)} -size +0c -printf '%T@\t%s\t%p\n' 2>/dev/null | sort -nr | head -n 1 >"$tmp/logicalDumpLatest.${index}"`, + ].join("\n"); + }).join("\n"); return ` set +e tmp="$(mktemp -d)" @@ -1939,8 +2023,9 @@ if command -v psql >/dev/null 2>&1; then : ${probeCommands} fi +${logicalDumpCommands} python3 - "$tmp" "${release}" <<'PY' -import json, os, sys +import datetime, json, os, sys tmp, release = sys.argv[1], sys.argv[2] def text(name): try: @@ -1968,6 +2053,44 @@ existing_roles = set_from_lines("rolesExisting") existing_databases = set_from_lines("databasesExisting") roles = [{"name": name, "exists": name in existing_roles} for name in expected_roles] databases = [{"name": item["name"], "owner": item["owner"], "exists": item["name"] in existing_databases} for item in expected_databases] +logical_dumps = [] +index = 0 +while True: + declaration = load_json(f"logicalDump.{index}.json", None) + if declaration is None: + break + latest_fields = text(f"logicalDumpLatest.{index}").split("\t", 2) + latest = None + if len(latest_fields) == 3: + try: + latest = { + "path": latest_fields[2], + "bytes": int(latest_fields[1]), + "modifiedAt": datetime.datetime.fromtimestamp(float(latest_fields[0]), datetime.timezone.utc).isoformat(), + } + except (TypeError, ValueError): + latest = None + service_result = text(f"logicalDumpServiceResult.{index}") or None + exit_status = int_or_none(f"logicalDumpServiceExitStatus.{index}") + last_success = text(f"logicalDumpLastSuccess.{index}") or None + if service_result != "success" or exit_status not in {None, 0}: + last_success = None + logical_dumps.append({ + "database": declaration["database"], + "databaseExists": declaration["database"] in existing_databases, + "scriptPath": declaration["scriptPath"], + "scriptExists": yes_file(f"logicalDumpScriptRc.{index}"), + "serviceName": declaration["serviceName"], + "serviceResult": service_result, + "serviceExitStatus": exit_status, + "lastSuccessAt": last_success, + "timerName": declaration["timerName"], + "timerActive": text(f"logicalDumpTimerActive.{index}") == "active", + "timerEnabled": text(f"logicalDumpTimerEnabled.{index}") == "enabled", + "nextRunAt": text(f"logicalDumpNextRun.{index}") or None, + "latestNonEmptyDump": latest, + }) + index += 1 app_connections = [] index = 0 while True: @@ -2045,6 +2168,7 @@ payload = { "appConnectionError": primary_conn.get("error"), "appConnections": app_connections, }, + "backup": {"logicalDumps": logical_dumps}, } print(json.dumps(payload, ensure_ascii=False, indent=2)) PY @@ -2099,7 +2223,7 @@ function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection): databases: pg.objects.databases, role: roles[0], database: pg.objects.databases[0], - backup: pg.backup.logicalDump, + backup: pg.backup, hbaMarkers: { start: managedHbaStart, end: managedHbaEnd }, }; } @@ -2222,7 +2346,7 @@ import json, sys data = json.load(open(sys.argv[1], encoding="utf-8")) value = data for part in sys.argv[2].split("."): - value = value[part] + value = value[int(part)] if isinstance(value, list) else value[part] if isinstance(value, bool): print("true" if value else "false") else: @@ -2232,10 +2356,15 @@ PY render_file() { name="$1" - python3 - "$payload" "$name" <<'PY' + index="" + if [ "$#" -ge 2 ]; then + index="$2" + fi + python3 - "$payload" "$name" "$index" <<'PY' import json, sys data = json.load(open(sys.argv[1], encoding="utf-8")) name = sys.argv[2] +index = int(sys.argv[3]) if sys.argv[3] else None if name == "pg_hba": markers = data["hbaMarkers"] print(markers["start"]) @@ -2268,7 +2397,7 @@ elif name == "databases_tsv": for database in data["databases"]: print("\t".join([database["name"], database["owner"], database["encoding"], database["locale"]])) elif name == "backup_script": - backup = data["backup"] + backup = data["backup"]["logicalDumps"][index] db = backup["database"] database = next((item for item in data["databases"] if item["name"] == db), None) if database is None: @@ -2277,6 +2406,7 @@ elif name == "backup_script": if role is None: raise SystemExit(f"backup owner {database['owner']} has no declared role") role_name = role["name"] + host = backup["pgDump"]["host"] port = data["network"]["port"] dest = backup["destination"]["path"] retention = backup["retentionDays"] @@ -2286,8 +2416,27 @@ elif name == "backup_script": print(f"dest='{dest}'") print("mkdir -p \"$dest\"") print("ts=$(date +%Y%m%dT%H%M%S)") - print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role_name} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"") + print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h {host} -p {port} -U {role_name} -d {db} --format={backup['pgDump']['format']} --file \"$dest/{db}-$ts.dump\"") print(f"find \"$dest\" -type f -name '{db}-*.dump' -mtime +{retention} -delete") +elif name == "backup_service": + backup = data["backup"]["logicalDumps"][index] + print("[Unit]") + print(f"Description=UniDesk PK01 {backup['database']} PostgreSQL logical backup") + print("") + print("[Service]") + print("Type=oneshot") + print(f"ExecStart={backup['scriptPath']}") +elif name == "backup_timer": + backup = data["backup"]["logicalDumps"][index] + print("[Unit]") + print(f"Description=Scheduled UniDesk PK01 {backup['database']} PostgreSQL logical backup") + print("") + print("[Timer]") + print(f"OnCalendar={backup['schedule']}") + print("Persistent=true") + print("") + print("[Install]") + print("WantedBy=timers.target") PY } @@ -2307,8 +2456,6 @@ SWAP_PATH="$(json_get node.swap.path)" SWAP_SIZE="$(json_get node.swap.size)" CONFIG_DIR="$(json_get paths.configDir)" DATA_DIR="$(json_get paths.dataDir)" -BACKUP_ENABLED="$(json_get backup.enabled)" -BACKUP_PATH="$(json_get backup.destination.path)" TLS_ENABLED="$(json_get tls.enabled)" TLS_COMMON_NAME="$(json_get tls.commonName)" TLS_CERT_FILE="$(json_get tls.certFile)" @@ -2408,34 +2555,39 @@ while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do fi done < "$databases_tsv" -if [ "$BACKUP_ENABLED" = "true" ]; then - write_state running configure-backup - mkdir -p "$BACKUP_PATH" - backup_script="/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh" - render_file backup_script > "$backup_script" - chmod 700 "$backup_script" - cat > /etc/systemd/system/unidesk-pk01-sub2api-pgdump.service < /etc/systemd/system/unidesk-pk01-sub2api-pgdump.timer < "$backup_script" + chmod 700 "$backup_script" + render_file backup_service "$backup_index" > "/etc/systemd/system/$backup_service" + render_file backup_timer "$backup_index" > "/etc/systemd/system/$backup_timer" + fi + backup_index=$((backup_index + 1)) +done +systemctl daemon-reload +backup_index=0 +while [ "$backup_index" -lt "$BACKUP_COUNT" ]; do + enabled="$(json_get backup.logicalDumps.$backup_index.enabled)" + if [ "$enabled" = "true" ]; then + backup_timer="$(json_get backup.logicalDumps.$backup_index.timerName)" + systemctl enable --now "$backup_timer" + fi + backup_index=$((backup_index + 1)) +done write_state running final-check runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null @@ -2450,6 +2602,10 @@ exit 0 `; } +export function renderPostgresRemoteRunnerForTest(): string { + return remoteRunnerScript(); +} + async function waitRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig, remoteJobId: string): Promise> { const observations: Array> = []; const maxPolls = 240; From fc4befc58360df9f73410bc4d3454d2b35d43012 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:22:56 +0200 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=E6=8E=92=E9=99=A4=E5=86=85?= =?UTF-8?q?=E9=83=A8=E5=91=98=E5=B7=A5=E8=A1=A5=E5=81=BF=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 1 + config/platform-infra/sub2api-codex-pool.yaml | 3 ++ .../platform-infra-sub2api-codex/config.ts | 9 +++++- .../platform-infra-sub2api-codex/credits.ts | 28 +++++++++++++++---- .../remote-python-credits.ts | 27 ++++++++++++++++-- .../src/platform-infra-sub2api-codex/types.ts | 1 + 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index ea9997e6..7c96d50c 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -155,6 +155,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `--active-since ` 从原生 usage 选取相对窗口内有请求的非管理员活跃用户; - `--active-start --active-end ` 使用精确半开区间 `[start,end)`;未携带 UTC 偏移的输入按 `config/platform-infra/sub2api-codex-pool.yaml#credits.timezone` 解释,并同时披露本地与 UTC 边界; - `--users ` 使用精确 selector;相对窗口、固定窗口和精确 selector 三者必须且只能选择一个; + - `config/platform-infra/sub2api-codex-pool.yaml#credits.excludedUsers` 声明不得获得客户补偿的精确用户 ID 或邮箱;活跃窗口和显式用户模式都必须排除,并在默认表格中披露匹配 selector 与原因; - 默认 dry-run,逐用户披露完整邮箱、用户 ID、请求数、当前余额、增加额和目标余额; - 活跃窗口只允许生成计划;实际写入必须把 dry-run 返回的用户 ID 固定为精确 `--users` selectors,再显式 `--confirm`,防止移动窗口在确认时扩大或缩小名单; - 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账; diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 31939b6c..fd73e785 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -78,6 +78,9 @@ profit: credits: timezone: Asia/Shanghai + excludedUsers: + - zhuo@test.com + - huiguo@test.com pool: groupName: unidesk-codex-pool diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index 955b840c..8b1bb558 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -204,12 +204,19 @@ export function readCodexPoolConfig(): CodexPoolConfig { function readCreditsConfig(value: unknown): CodexPoolCreditsConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.credits must be a YAML object`); const timezone = requiredStringConfigField(value, "timezone", "credits"); + const excludedUsers = readUniqueStringArray(value.excludedUsers, "credits.excludedUsers", true); + if (excludedUsers.some((selector) => selector.length > 256 || /[\r\n\0]/u.test(selector))) { + throw new Error(`${codexPoolConfigPath}.credits.excludedUsers contains an unsupported selector`); + } + if (new Set(excludedUsers.map((selector) => selector.toLowerCase())).size !== excludedUsers.length) { + throw new Error(`${codexPoolConfigPath}.credits.excludedUsers must not contain case-insensitive duplicates`); + } try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0)); } catch { throw new Error(`${codexPoolConfigPath}.credits.timezone must be a valid IANA timezone`); } - return { timezone }; + return { timezone, excludedUsers }; } function readProfitConfig(value: unknown): CodexPoolProfitConfig { diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts index 664118f7..9f721b14 100644 --- a/scripts/src/platform-infra-sub2api-codex/credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -13,6 +13,7 @@ interface CreditsOptions { activeStartUtc: string | null; activeEndUtc: string | null; timezone: string; + excludedUsers: string[]; users: string[]; amountUsd: number; reason: string; @@ -24,7 +25,7 @@ interface CreditsOptions { export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { if (args.includes("--help")) return renderCreditsHelp(); const pool = readCodexPoolConfig(); - const options = parseCreditsOptions(args, pool.credits.timezone); + const options = parseCreditsOptions(args, pool.credits); const target = codexPoolRuntimeTarget(options.targetId); const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, pool, target), target); const report = parseJsonOutput(remote.stdout); @@ -41,6 +42,7 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P activeStartUtc: options.activeStartUtc, activeEndUtc: options.activeEndUtc, timezone: options.timezone, + excludedUsers: options.excludedUsers, users: options.users, amountUsd: options.amountUsd, reason: options.reason, @@ -53,7 +55,8 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P return options.json ? renderCreditsJson(response) : renderCredits(response); } -function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { +function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; excludedUsers: string[] }): CreditsOptions { + const { timezone, excludedUsers } = creditsConfig; const [action = "grant", ...rest] = args; if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); let activeSince: string | null = null; @@ -110,7 +113,7 @@ function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { if (confirm && users.length === 0) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); if (amountUsd === null) throw new Error("credits grant requires --amount-usd"); if (reason === null || reason.length === 0 || reason.length > 500 || /[\r\n\0]/u.test(reason)) throw new Error("credits grant requires a single-line --reason up to 500 characters"); - return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, users, amountUsd, reason, confirm, targetId, json }; + return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, excludedUsers, users, amountUsd, reason, confirm, targetId, json }; } function isIsoDateTime(value: string): boolean { @@ -183,6 +186,7 @@ function renderCreditsHelp(): RenderedCliResult { "Notes:", " Default mode is dry-run. --active-since selects distinct active non-admin users from native usage.", " Offset-free fixed-window values use config/platform-infra/sub2api-codex-pool.yaml#credits.timezone and the interval is [start,end).", + " YAML credits.excludedUsers selectors are excluded from both active-window and exact-user grants.", " --confirm performs additive balance writes and then reads every selected user back.", ].join("\n"), contentType: "text/plain", @@ -193,8 +197,9 @@ function renderCredits(response: Record): RenderedCliResult { const report = record(response.report); const summary = record(report.summary); const rows = arrayOfRecords(report.items); + const excludedRows = arrayOfRecords(report.excluded); const lines = [ - `SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`, + `SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} excluded=${text(summary.excluded)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`, `WRITE attempted=${text(summary.writeAttempted)} succeeded=${text(summary.writeSucceeded)} failed=${text(summary.writeFailed)} reconciled=${text(summary.reconciled)} mutation=${text(report.mutation)}`, ]; const selection = record(report.selection); @@ -211,10 +216,21 @@ function renderCredits(response: Record): RenderedCliResult { ]), ])); } + if (excludedRows.length > 0) { + lines.push("", "EXCLUDED", renderTable([ + ["EMAIL", "USER_ID", "REQUESTS", "REASON", "MATCHED_SELECTOR"], + ...excludedRows.map((row) => [ + text(row.email), text(row.userId), text(row.requestCount), text(row.reason), text(row.matchedSelector), + ]), + ])); + } if (report.mode === "dry-run") { - const selectors = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds.join(",") : ""; + const confirmationUserIds = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds : []; + const selectors = confirmationUserIds.length > 0 ? confirmationUserIds.join(",") : ""; lines.push("", `CONFIRM SELECTORS --users ${selectors}`); - lines.push("NEXT: use these exact selectors with --confirm only after explicit user authorization."); + lines.push(confirmationUserIds.length > 0 + ? "NEXT: use these exact selectors with --confirm only after explicit user authorization." + : "NEXT: no users remain eligible for confirmation."); } return { ok: response.ok === true, command: "platform-infra sub2api codex-pool credits", renderedText: lines.join("\n"), contentType: "text/plain", projection: response }; } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts index fe4595ad..eff90d8b 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -69,6 +69,15 @@ def credits_active_usage(token, since, active_start, active_end, active_start_ut } page += 1 +def credits_excluded_selector(user, payload): + user_id = str(user.get("id")) if user.get("id") is not None else "" + email = str(user.get("email") or "").strip().lower() + for selector in payload.get("excludedUsers") or []: + normalized = str(selector).strip().lower() + if normalized == user_id or normalized == email: + return str(selector) + return None + def credits_selected_users(token, payload): users = credits_all_users(token) users_by_id = {str(item.get("id")): item for item in users if item.get("id") is not None} @@ -85,8 +94,14 @@ def credits_selected_users(token, payload): excluded = [] for user_id, request_count in requests.items(): user = users_by_id.get(user_id) + excluded_selector = credits_excluded_selector(user, payload) if isinstance(user, dict) else None if not isinstance(user, dict): excluded.append({"userId": user_id, "reason": "user-not-found"}) + elif excluded_selector is not None: + excluded.append({ + "userId": user.get("id"), "email": user.get("email"), "requestCount": request_count, + "reason": "yaml-excluded-user", "matchedSelector": excluded_selector, + }) elif user.get("role") == "admin": excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "admin-excluded"}) elif user.get("status") != "active" or user.get("deleted_at") is not None: @@ -95,6 +110,7 @@ def credits_selected_users(token, payload): selected.append((user, request_count)) return selected, excluded, selection selected = [] + excluded = [] seen = set() for selector in payload.get("users") or []: user = users_by_id.get(str(selector)) or users_by_email.get(str(selector).strip().lower()) @@ -104,8 +120,15 @@ def credits_selected_users(token, payload): if user_id in seen: raise RuntimeError("duplicate user selector: " + str(selector)) seen.add(user_id) - selected.append((user, None)) - return selected, [], {"kind": "exact-users", "selectors": payload.get("users") or []} + excluded_selector = credits_excluded_selector(user, payload) + if excluded_selector is not None: + excluded.append({ + "userId": user.get("id"), "email": user.get("email"), "requestCount": None, + "reason": "yaml-excluded-user", "matchedSelector": excluded_selector, + }) + else: + selected.append((user, None)) + return selected, excluded, {"kind": "exact-users", "selectors": payload.get("users") or []} def run_credits_grant(): payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {} diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 4b9b2080..742b388a 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -221,6 +221,7 @@ export interface CodexPoolProfitConfig { export interface CodexPoolCreditsConfig { timezone: string; + excludedUsers: string[]; } export interface CodexPoolConfig { From 55deb0f60801476d7e11d28c7d3d6ab65c5c6fec Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:23:15 +0200 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20PK01=20?= =?UTF-8?q?=E5=A4=9A=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD=E4=BA=A4?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.6.1.1_Task_Report.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md new file mode 100644 index 00000000..d8033862 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md @@ -0,0 +1,49 @@ +# R1.6.1.1 任务报告 + +## 结论 + +已实现 pikasTech/unidesk#2115,并提交 PR #2118。PK01 PostgreSQL 逻辑备份已从单个 Sub2API 特例正规化为 YAML-first `logicalDumps[]`;保留 Sub2API 既有 03:17 schedule、14 天 retention、目录与 systemd 资源语义,新增 PikaOA 独立 03:37 schedule、14 天 retention、脚本、service、timer 和目录。 + +任务保持 `in_progress`。本次按派单边界未执行真实 `apply` 或手动 dump,等待主代理审核后完成运行面 mutation 与验收。 + +## 变更 + +- `config/platform-db/postgres-pk01.yaml`:每项完整声明 database、scriptPath、serviceName、timerName、目录、schedule、retention、warningAfterHours、pgDump 参数和 encryption 状态。 +- `scripts/src/platform-db.ts`:按 YAML 解析、校验、生成远端 payload,动态渲染全部 dump 脚本与 systemd unit;删除 Sub2API 专用路径、unit、timer schedule 硬编码。 +- `scripts/src/platform-db-postgres-backups.ts`:集中生成每项备份的声明、timer、最近成功、最新非空 dump、可恢复性和 warning 投影。 +- `scripts/src/platform-db-postgres-backups.test.ts`:覆盖配置解析、renderer 去硬编码和 warning 非阻塞语义。 + +## Primary Workspace 轻量准备 + +Primary workspace 仅用于读取 `dad-dev`、`unidesk-ops`、`unidesk-ymalops`、`cli-spec`、`git-spec`、`unidesk-trans`、`unidesk-gh`、`mdtodo-edit` 与 `post-task` 技能;未在 runner 本地读取、编辑或验证目标源码,未把 runner 容器结果作为 Target 证据。 + +## Target 原入口证据 + +全部源码、Git、测试和运行面命令均通过 `trans NC01:/root/unidesk/.worktree/pikaoa-production-backup ...` 执行。 + +- `bun --check scripts/src/platform-db-postgres-backups.ts`:通过。 +- `bun --check scripts/src/platform-db.ts`:通过。 +- `bun test scripts/src/platform-db-postgres-backups.test.ts`:3 pass,0 fail。 +- 生成的远端 apply runner 经 `sh -n`:通过。 +- `git diff --check`:通过。 +- 只读 `platform-db postgres plan`:`ok=true`、`mutation=false`;Sub2API timer active/enabled,最近成功为 2026-07-15 03:17:01 CST,最新非空 dump 为 90,798,991 bytes,可恢复性为 true;PikaOA 未 apply,脚本/timer/最近成功/latest dump 缺失均只形成 warning。 +- 只读 `platform-db postgres status`:`ok=true`、`mutation=false`、`failure=null`;备份 warning 不阻塞 PikaOA MVP。 + +## Git 与 PR + +- 分支:`feat/pikaoa-production-backup`。 +- 最新 master 已语义合入:merge commit `3086ea8d`。 +- 功能提交:`c483d6d508f5a9501f211d2b7bffd16a04494483`。 +- PR:pikasTech/unidesk#2118,关联 pikasTech/unidesk#2115,未合并。 + +## 风险与下一步 + +- PikaOA 的 systemd 资源与 dump 文件在 apply 前不存在,当前 warning 与预期一致。 +- 主代理审核后执行: + 1. `bun scripts/cli.ts platform-db postgres plan --config config/platform-db/postgres-pk01.yaml` + 2. `bun scripts/cli.ts platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm` + 3. 按异步 job 返回的 `statusCommand` 短轮询至完成。 + 4. `bun scripts/cli.ts platform-db postgres status --config config/platform-db/postgres-pk01.yaml` + 5. `sudo systemctl start unidesk-pk01-pikaoa-pgdump.service` + 6. 再次执行 status,确认 PikaOA timer active/enabled、最近成功存在、最新非空 dump 大于 0 bytes、recoverable=true 且 warnings 为空。 +- 上述 mutation 与手动 dump 验收完成后,再将 MDTODO R1.6.1.1 标记 complete。