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, }