From d36eef53700c5d116d9e3b5defff58ed0ba3cc75 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 08:07:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=B7=B2=E9=85=8D=E7=BD=AE=E7=9A=84=20runtime=20=E7=AD=96?= =?UTF-8?q?=E7=95=A5=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 | 8 ++- .../R4_Task_Report.md | 68 +++++++++++++++++++ docs/MDTODO/sub2api-upstream-reliability.md | 4 ++ .../runtime-mutation-render.ts | 29 ++++++-- .../runtime-options.ts | 39 +++++++++-- .../runtime-remote-script-tail.ts | 66 ++++++++++++++++-- .../platform-infra-sub2api-codex/runtime.ts | 46 ++++++++++++- .../src/platform-infra-sub2api-codex/types.ts | 1 + 8 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R4_Task_Report.md diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 071eaa0e..f06d31a3 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -65,7 +65,12 @@ - 错误列表没有账号全部路由尝试数时,命令只报告错误数量,不伪造错误率; - Ops 记录与 runtime 日志是独立证据,命令明确披露采样数量,不按日志位置强行关联; - `runtime apply|delete` 支持 `temp-unschedulable` 和 `model-mapping` 两类 runtime 配置; - - 单账号必须显式使用 `--account`,精准批量必须显式使用 `--accounts `,不提供隐式 `all`; + - 单账号必须显式使用 `--account`,已知精准批量必须显式使用 `--accounts `,不提供隐式 `all`; + - 更新已配置同类 runtime 策略的手工账号时,可显式使用 `--select-configured-policy --account-type apikey` 自动选集,并用 `--exclude-accounts ` 排除例外: + - 该入口只选择当前 credentials 已存在临时不可调度字段的账号,不把未配置账号自动纳入; + - 不以 `matchingTemplate` 作为更新选集,因为模板内容变化后旧 runtime 不再与新 YAML 精确相等; + - dry-run 必须披露候选数、已配置数、类型匹配数、排除账号、最终名称/ID 和选中来源; + - confirm 继续复用批量预检、顺序写入和自动回读对账; - 批量操作先校验全部 selector 存在且账号类型可修改,再顺序调用 Sub2API admin API: - 选择错误不会产生部分写入; - 上游 admin API 在批次中途失败时仍需逐账号 `get` 对账; @@ -94,6 +99,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account --template --confirm bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts --template bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts --template --confirm +bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --template --select-configured-policy --account-type apikey --exclude-accounts bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts --kind priority --priority <0-1000> --confirm bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account --kind account-name --name --confirm bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account --kind temp-unschedulable diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R4_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R4_Task_Report.md new file mode 100644 index 00000000..4e2e1e02 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R4_Task_Report.md @@ -0,0 +1,68 @@ +# R4 任务报告 + +## 目标 + +- 关联 [UniDesk #2281](https://github.com/pikasTech/unidesk/issues/2281)。 +- 将模板更新流程从以下三步: + - `runtime list`; + - 人工抄写账号 ID; + - `runtime apply --accounts ...`; +- 收敛为一个受控 dry-run 命令。 + +## 实现 + +- 新增显式自动选集参数: + - `--select-configured-policy`; + - `--account-type apikey`; + - `--exclude-accounts `。 +- 自动选集只覆盖当前 credentials 已存在临时不可调度字段的账号: + - 不把未配置账号隐式纳入; + - 不提供隐式 `all`; + - 不带 `--confirm` 时保持 dry-run; + - confirm 复用既有批量预检、顺序写入和自动回读对账。 +- 默认 Kubernetes 风格输出新增: + - 候选账号数; + - 已配置策略数; + - 类型匹配数; + - 请求排除数与实际从候选移除数; + - 排除账号名称、ID、selector 和原因; + - 每个最终账号的 `SELECTED_BY`。 +- 显式 JSON 增加 mutation 专用紧凑投影: + - 保留 request selector、selection provenance、汇总和每账号结果; + - dry-run noop 不重复输出相同的 before、desired、actual 三份策略; + - update 和 confirmed 对账仍保留必要差异。 + +## 设计修正 + +- 没有使用 `matchingTemplate` 自动选集: + - owning YAML 模板内容变化后,旧 runtime 与新模板不再精确相等; + - 本次真实运行面在更新前已显示 `TEMPLATE=-`; + - 以精确模板匹配选集会错误选中 0 个待更新账号。 +- 因此使用“已配置同类策略 + 显式账号类型 + 显式排除”的通用选集,并要求先看 dry-run 最终名单。 + +## 验证 + +- 单命令 PK01 dry-run: + - `candidateAccounts=11`; + - `configuredPolicyMatches=9`; + - `accountTypeMatches=9`; + - `selected=9`; + - `changed=0`; + - 9 个账号全部 `noop`; + - `mutation=false`。 +- `--exclude-accounts 15` 明确显示: + - `lyon9801 0.0`; + - ID 15; + - 原因 `explicit-exclusion`。 +- 缺少 `--account-type` 时命令在本地 fail-closed: + - 非零退出; + - 明确提示 `--select-configured-policy requires --account-type apikey`; + - 不访问写入路径。 +- 显式 JSON 最终完整返回: + - 未触发 10 KiB 输出保护; + - 保留所有 9 个账号和 selection provenance。 + +## 边界 + +- 本任务所有真实 Sub2API 调用均未携带 `--confirm`。 +- 未修改 Sub2API 源码、版本、runtime、YAML、账号配置或外部哨兵。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index 4fba09d4..172c4dcd 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -275,3 +275,7 @@ ### R3.7 [completed] 在 Sub2API 公告 skill 与 CLI create 预览中固化明确范围规则:补偿公告必须在用户可见正文列出故障或补偿时间窗、全部实际接收用户、每人金额和总额;邮箱允许脱敏但必须保留稳定用户 ID,`targeting` 仅表示投递范围、不能替代补偿资格名单。本次只记录并验证 dry-run 展示,不重新发布或修改既有公告,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.7_Task_Report.md)。 + +## R4 [completed] + +关联 [UniDesk #2281](https://github.com/pikasTech/unidesk/issues/2281),增强 Sub2API runtime 模板批量 CRUD:支持通过显式参数按原生 runtime 已配置同类策略和账号类型自动选集,并支持精确排除 selector;dry-run 直接输出最终账号名称、ID、选中来源与排除摘要,confirm 复用既有写后自动回读对账,避免先 list、人工抄账号 ID、再 apply 的重复调用;显式 JSON 使用紧凑 mutation 投影保留 provenance 且不触发输出保护,不修改 Sub2API 源码、版本、runtime、YAML 或外部哨兵,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R4_Task_Report.md)。 diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts b/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts index 164993fa..3d9f18ee 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts @@ -5,6 +5,7 @@ import type { RuntimeOptions } from "./runtime-options"; export function renderRuntimeMutationResult(ok: boolean, options: RuntimeOptions, parsed: Record | null, exitCode: number): RenderedCliResult { const summary = parsed !== null && isRecord(parsed.summary) ? parsed.summary : {}; + const selection = parsed !== null && isRecord(parsed.selection) ? parsed.selection : {}; const items = parsed !== null && Array.isArray(parsed.items) ? parsed.items.filter(isRecord) : []; const lines = [[ "SUB2API RUNTIME", `operation=${options.action}`, `mode=${stringValue(parsed?.mode) ?? (options.confirm ? "confirmed" : "dry-run")}`, @@ -12,6 +13,22 @@ export function renderRuntimeMutationResult(ok: boolean, options: RuntimeOptions `writeSucceeded=${summary.writeSucceeded ?? "?"}`, `writeFailed=${summary.writeFailed ?? "?"}`, `reconciled=${summary.reconciled ?? "?"}`, `mismatched=${summary.mismatched ?? "?"}`, ].join(" ")]; + if (typeof selection.mode === "string") { + lines.push([ + "SELECTION", `mode=${selection.mode}`, `accountType=${selection.accountType ?? "-"}`, + `candidates=${selection.candidateAccounts ?? selection.requestedSelectors ?? "?"}`, + `configured=${selection.configuredPolicyMatches ?? "-"}`, `typeMatched=${selection.accountTypeMatches ?? "-"}`, + `excludeRequested=${selection.explicitExclusionsRequested ?? 0}`, `excludedSelected=${selection.explicitlyExcluded ?? 0}`, + ].join(" ")); + } + const excludedAccounts = Array.isArray(selection.excludedAccounts) ? selection.excludedAccounts.filter(isRecord) : []; + if (excludedAccounts.length > 0) lines.push(renderTable([ + ["EXCLUDED_ACCOUNT", "ID", "SELECTOR", "REASON"], + ...excludedAccounts.map((item) => [ + stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), + stringValue(item.selector) ?? "-", stringValue(item.reason) ?? "-", + ]), + ])); if (items.length > 0) { const fields = items.map((item) => ({ item, @@ -21,29 +38,29 @@ export function renderRuntimeMutationResult(ok: boolean, options: RuntimeOptions actual: isRecord(item.actual) ? item.actual : {}, })); if (options.kind === "priority") lines.push(renderTable([ - ["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE_PRIORITY", "DESIRED_PRIORITY", "ACTUAL_PRIORITY", "FAILURE"], + ["ACCOUNT", "ID", "SELECTED_BY", "CHANGE", "WRITE", "RECONCILED", "BEFORE_PRIORITY", "DESIRED_PRIORITY", "ACTUAL_PRIORITY", "FAILURE"], ...fields.map(({ item, write, before, desired, actual }) => [ - stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.change) ?? "-", + stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.selectionReason) ?? "-", stringValue(item.change) ?? "-", stringValue(write.status) ?? "-", item.reconciled === true ? "true" : item.reconciled === false ? "false" : "-", String(before.priority ?? "-"), String(desired.priority ?? "-"), String(actual.priority ?? "-"), stringValue(write.error) ?? stringValue(item.reconcileError) ?? "-", ]), ])); else if (options.kind === "account-name") lines.push(renderTable([ - ["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE_NAME", "DESIRED_NAME", "ACTUAL_NAME", "FAILURE"], + ["ACCOUNT", "ID", "SELECTED_BY", "CHANGE", "WRITE", "RECONCILED", "BEFORE_NAME", "DESIRED_NAME", "ACTUAL_NAME", "FAILURE"], ...fields.map(({ item, write, before, desired, actual }) => [ - stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.change) ?? "-", + stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.selectionReason) ?? "-", stringValue(item.change) ?? "-", stringValue(write.status) ?? "-", item.reconciled === true ? "true" : item.reconciled === false ? "false" : "-", stringValue(before.name) ?? "-", stringValue(desired.name) ?? "-", stringValue(actual.name) ?? "-", stringValue(write.error) ?? stringValue(item.reconcileError) ?? "-", ]), ])); else lines.push(renderTable([ - ["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE", "DESIRED", "ACTUAL", "BEFORE_CODES", "DESIRED_CODES", "ACTUAL_CODES", "FAILURE"], + ["ACCOUNT", "ID", "SELECTED_BY", "CHANGE", "WRITE", "RECONCILED", "BEFORE", "DESIRED", "ACTUAL", "BEFORE_CODES", "DESIRED_CODES", "ACTUAL_CODES", "FAILURE"], ...fields.map(({ item, write, before, desired, actual }) => { const codes = (value: unknown): string => Array.isArray(value) ? value.join(",") || "-" : "-"; return [ - stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.change) ?? "-", + stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.selectionReason) ?? "-", stringValue(item.change) ?? "-", stringValue(write.status) ?? "-", item.reconciled === true ? "true" : item.reconciled === false ? "false" : "-", String(before.ruleCount ?? "-"), String(desired.ruleCount ?? "-"), String(actual.ruleCount ?? "-"), codes(before.statusCodes), codes(desired.statusCodes), codes(actual.statusCodes), diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-options.ts b/scripts/src/platform-infra-sub2api-codex/runtime-options.ts index d6b2df15..98985006 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-options.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-options.ts @@ -6,6 +6,9 @@ export interface RuntimeOptions { action: RuntimeAction; account: string | null; accounts: string[]; + selectConfiguredPolicy: boolean; + accountType: "apikey" | null; + excludeAccounts: string[]; group: string | null; allGroups: boolean; platform: string | null; @@ -26,10 +29,13 @@ export interface RuntimeOptions { export function parseRuntimeOptions(args: string[]): RuntimeOptions { const [actionRaw = "list", ...rest] = args; if (!(["list", "get", "errors", "infrastructure", "apply", "delete"] as string[]).includes(actionRaw)) { - throw new Error("runtime usage: list|get|errors|infrastructure|apply|delete [--account |--accounts ] [--group |--all-groups] [--platform ] [--page-token ] [--since 24h] [--tail 50000] [--template ] [--kind temp-unschedulable|priority|account-name] [--priority <0-1000>|--name ] [--confirm] [--target ] [--json|--full|--raw]"); + throw new Error("runtime usage: list|get|errors|infrastructure|apply|delete [--account |--accounts |--select-configured-policy --account-type apikey [--exclude-accounts ]] [--group |--all-groups] [--platform ] [--page-token ] [--since 24h] [--tail 50000] [--template ] [--kind temp-unschedulable|priority|account-name] [--priority <0-1000>|--name ] [--confirm] [--target ] [--json|--full|--raw]"); } let account: string | null = null; let accounts: string[] = []; + let selectConfiguredPolicy = false; + let accountType: "apikey" | null = null; + let excludeAccounts: string[] = []; let group: string | null = null; let allGroups = false; let platform: string | null = null; @@ -62,6 +68,11 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions { else if (arg.startsWith("--account=")) account = arg.slice("--account=".length).trim(); else if (arg === "--accounts") accounts = parseAccountSelectors(readValue("--accounts")); else if (arg.startsWith("--accounts=")) accounts = parseAccountSelectors(arg.slice("--accounts=".length)); + else if (arg === "--select-configured-policy") selectConfiguredPolicy = true; + else if (arg === "--account-type") accountType = parseAccountType(readValue("--account-type")); + else if (arg.startsWith("--account-type=")) accountType = parseAccountType(arg.slice("--account-type=".length)); + else if (arg === "--exclude-accounts") excludeAccounts = parseAccountSelectors(readValue("--exclude-accounts"), "--exclude-accounts"); + else if (arg.startsWith("--exclude-accounts=")) excludeAccounts = parseAccountSelectors(arg.slice("--exclude-accounts=".length), "--exclude-accounts"); else if (arg === "--group") group = readValue("--group"); else if (arg.startsWith("--group=")) group = arg.slice("--group=".length).trim(); else if (arg === "--all-groups") allGroups = true; @@ -87,8 +98,11 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions { } const action = actionRaw as RuntimeAction; if (account !== null && accounts.length > 0) throw new Error("use only one of --account or --accounts"); + if (selectConfiguredPolicy && (account !== null || accounts.length > 0)) throw new Error("--select-configured-policy cannot be combined with --account or --accounts"); if ((action === "get" || action === "infrastructure") && !account) throw new Error(`runtime ${action} requires --account `); - if ((action === "apply" || action === "delete") && account === null && accounts.length === 0) throw new Error(`runtime ${action} requires --account or --accounts`); + if ((action === "apply" || action === "delete") && account === null && accounts.length === 0 && !selectConfiguredPolicy) { + throw new Error(`runtime ${action} requires --account, --accounts, or --select-configured-policy`); + } if (accounts.length > 0 && action !== "apply" && action !== "delete") throw new Error(`runtime ${action} does not accept --accounts`); if (account !== null && (account.length > 256 || /[\r\n]/u.test(account))) throw new Error("--account has an unsupported format"); if (group !== null && (group.length > 256 || /[\r\n]/u.test(group))) throw new Error("--group has an unsupported format"); @@ -99,6 +113,12 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions { if ((group !== null || platform !== null) && action !== "errors" && action !== "infrastructure") throw new Error(`runtime ${action} does not accept group scope options`); if (pageToken !== null && !allGroups) throw new Error("--page-token requires --all-groups"); if (account !== null && allGroups) throw new Error("--account cannot be combined with --all-groups; use --group first"); + if (selectConfiguredPolicy && !((action === "apply" || action === "delete") && kind === "temp-unschedulable")) { + throw new Error("--select-configured-policy only supports runtime apply|delete --kind temp-unschedulable"); + } + if (selectConfiguredPolicy && accountType === null) throw new Error("--select-configured-policy requires --account-type apikey"); + if (!selectConfiguredPolicy && accountType !== null) throw new Error("--account-type requires --select-configured-policy"); + if (!selectConfiguredPolicy && excludeAccounts.length > 0) throw new Error("--exclude-accounts requires --select-configured-policy"); if (action === "apply" && kind === "temp-unschedulable" && !template) throw new Error("runtime apply --kind temp-unschedulable requires --template "); if (kind === "priority" && action !== "apply") throw new Error("runtime --kind priority only supports apply"); if (kind === "priority" && priority === null) throw new Error("runtime apply --kind priority requires --priority <0-1000>"); @@ -116,17 +136,22 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions { if (!/^\d+[mhd]$/u.test(since)) throw new Error("--since must use m, h, or d"); if (["list", "get", "errors", "infrastructure"].includes(action) && confirm) throw new Error(`runtime ${action} does not accept --confirm`); if ([full, raw, json].filter(Boolean).length > 1) throw new Error("use only one of --json, --full, or --raw"); - return { action, account, accounts, group, allGroups, platform, pageToken, template, kind, priority, name, confirm, full, raw, json, since, tail, targetId }; + return { action, account, accounts, selectConfiguredPolicy, accountType, excludeAccounts, group, allGroups, platform, pageToken, template, kind, priority, name, confirm, full, raw, json, since, tail, targetId }; } -function parseAccountSelectors(value: string): string[] { +function parseAccountSelectors(value: string, option = "--accounts"): string[] { const selectors = value.split(",").map((item) => item.trim()).filter(Boolean); - if (selectors.length === 0 || selectors.length > 100) throw new Error("--accounts requires 1 to 100 comma-separated selectors"); - if (selectors.some((item) => item.length > 256 || /[\r\n]/u.test(item))) throw new Error("--accounts has an unsupported selector"); - if (new Set(selectors).size !== selectors.length) throw new Error("--accounts contains duplicate selectors"); + if (selectors.length === 0 || selectors.length > 100) throw new Error(`${option} requires 1 to 100 comma-separated selectors`); + if (selectors.some((item) => item.length > 256 || /[\r\n]/u.test(item))) throw new Error(`${option} has an unsupported selector`); + if (new Set(selectors).size !== selectors.length) throw new Error(`${option} contains duplicate selectors`); return selectors; } +function parseAccountType(value: string): "apikey" { + if (value !== "apikey") throw new Error("--account-type currently supports only apikey"); + return value; +} + function parseRuntimeKind(value: string): "temp-unschedulable" | "priority" | "account-name" { if (value !== "temp-unschedulable" && value !== "priority" && value !== "account-name") throw new Error("--kind must be temp-unschedulable, priority, or account-name"); return value; diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts b/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts index 52b0d751..506e919f 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts @@ -1053,6 +1053,50 @@ def observed_runtime_errors(token, accounts, group_id, platform=None): } def selected_account_details(token, accounts): + if PAYLOAD.get("selectConfiguredPolicy") is True: + account_type = PAYLOAD.get("accountType") + details = [account_detail(token, item["id"]) for item in accounts] + configured = [] + for detail in details: + credentials = detail.get("credentials") if isinstance(detail.get("credentials"), dict) else {} + if "temp_unschedulable_enabled" in credentials or "temp_unschedulable_rules" in credentials: + configured.append(detail) + type_matches = [detail for detail in configured if detail.get("type") == account_type] + excluded_ids = set() + excluded_accounts = [] + for raw_selector in PAYLOAD.get("excludeAccounts") or []: + excluded = find_account(token, accounts, str(raw_selector)) + account_id = excluded.get("id") + if account_id in excluded_ids: + raise RuntimeError("duplicate-excluded-account-selector: " + str(raw_selector)) + excluded_ids.add(account_id) + excluded_accounts.append({ + "accountName": excluded.get("name"), + "accountId": account_id, + "selector": str(raw_selector), + "reason": "explicit-exclusion", + }) + selected = [] + for detail in type_matches: + if detail.get("id") in excluded_ids: + continue + detail["_selectionReason"] = "configured-policy" + selected.append(detail) + return { + "details": selected, + "selection": { + "mode": "configured-policy", + "accountType": account_type, + "candidateAccounts": len(details), + "configuredPolicyMatches": len(configured), + "accountTypeMatches": len(type_matches), + "accountTypeFiltered": len(configured) - len(type_matches), + "explicitExclusionsRequested": len(excluded_accounts), + "explicitlyExcluded": len([item for item in type_matches if item.get("id") in excluded_ids]), + "selected": len(selected), + "excludedAccounts": excluded_accounts, + }, + } selectors = PAYLOAD.get("accounts") if not isinstance(selectors, list) or not selectors: selector = PAYLOAD.get("account") @@ -1067,9 +1111,18 @@ def selected_account_details(token, accounts): raise RuntimeError("duplicate-account-selector: " + selector) if PAYLOAD.get("kind") == "temp-unschedulable" and detail.get("type") != "apikey": raise RuntimeError("runtime temp-unschedulable policy requires an apikey account: " + selector) + detail["_selectionReason"] = "explicit-selector" selected_ids.add(account_id) details.append(detail) - return details + return { + "details": details, + "selection": { + "mode": "explicit-selector", + "requestedSelectors": len(selectors), + "selected": len(details), + "excludedAccounts": [], + }, + } def mutation_plan(detail, action, include_rules): if PAYLOAD.get("kind") == "account-name": @@ -1078,6 +1131,7 @@ def mutation_plan(detail, action, include_rules): return { "accountName": before_name, "accountId": detail.get("id"), + "selectionReason": detail.get("_selectionReason"), "change": "noop" if before_name == desired_name else "update", "before": {"name": before_name}, "desired": {"name": desired_name}, @@ -1091,6 +1145,7 @@ def mutation_plan(detail, action, include_rules): return { "accountName": detail.get("name"), "accountId": detail.get("id"), + "selectionReason": detail.get("_selectionReason"), "change": "noop" if before_priority == desired_priority else "update", "before": {"priority": before_priority}, "desired": {"priority": desired_priority}, @@ -1118,6 +1173,7 @@ def mutation_plan(detail, action, include_rules): return { "accountName": detail.get("name"), "accountId": detail.get("id"), + "selectionReason": detail.get("_selectionReason"), "change": change, "before": policy_summary(before, include_rules), "desired": policy_summary(desired_policy, include_rules), @@ -1219,7 +1275,9 @@ def runtime_result(): "infrastructure": infrastructure_snapshot(token, detail), } include_plan_rules = PAYLOAD.get("full") is True - details = selected_account_details(token, accounts) + selected_accounts = selected_account_details(token, accounts) + details = selected_accounts["details"] + selection = selected_accounts["selection"] plans = [mutation_plan(item, action, include_plan_rules) for item in details] changed = sum(1 for plan in plans if plan["change"] != "noop") if PAYLOAD.get("confirm") is not True: @@ -1232,7 +1290,7 @@ def runtime_result(): item["reconcileError"] = None items.append(item) summary = {"selected": len(items), "changed": changed, "writeSucceeded": 0, "writeFailed": 0, "reconciled": 0, "mismatched": 0} - return {**base, "ok": True, "operation": action, "mode": "dry-run", "mutation": False, "partialWrite": False, "summary": summary, "items": items, "credentialsPrinted": False} + return {**base, "ok": True, "operation": action, "mode": "dry-run", "mutation": False, "partialWrite": False, "selection": selection, "summary": summary, "items": items, "credentialsPrinted": False} writes = [write_mutation_plan(token, plan) for plan in plans] items = [reconcile_mutation_plan(token, plan, write, include_plan_rules) for plan, write in zip(plans, writes)] write_succeeded = sum(1 for item in items if item["write"]["status"] == "succeeded") @@ -1241,7 +1299,7 @@ def runtime_result(): mismatched = sum(1 for item in items if item["reconciled"] is False) summary = {"selected": len(items), "changed": changed, "writeSucceeded": write_succeeded, "writeFailed": write_failed, "reconciled": reconciled, "mismatched": mismatched} ok = write_failed == 0 and mismatched == 0 - return {**base, "ok": ok, "operation": action, "mode": "confirmed", "mutation": write_succeeded > 0, "partialWrite": write_succeeded > 0 and write_failed > 0, "summary": summary, "items": items, "credentialsPrinted": False} + return {**base, "ok": ok, "operation": action, "mode": "confirmed", "mutation": write_succeeded > 0, "partialWrite": write_succeeded > 0 and write_failed > 0, "selection": selection, "summary": summary, "items": items, "credentialsPrinted": False} try: result = runtime_result() diff --git a/scripts/src/platform-infra-sub2api-codex/runtime.ts b/scripts/src/platform-infra-sub2api-codex/runtime.ts index 10676f32..d44c1cba 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime.ts @@ -2,6 +2,7 @@ import type { UniDeskConfig } from "../config"; import type { RenderedCliResult } from "../output"; import { desiredAccountNames } from "./accounts"; import { readCodexPoolConfig } from "./config"; +import { isRecord } from "./config-utils"; import { protectedManualAccountsForTarget } from "./public-exposure"; import { renderSub2ApiTempUnschedulableCredentials } from "./redaction"; import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; @@ -22,6 +23,9 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P action: options.action, account: options.account, accounts: options.accounts, + selectConfiguredPolicy: options.selectConfiguredPolicy, + accountType: options.accountType, + excludeAccounts: options.excludeAccounts, group: options.group, allGroups: options.allGroups, platform: options.platform, @@ -78,6 +82,9 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P operation: options.action, account: options.account, accounts: options.accounts, + selectConfiguredPolicy: options.selectConfiguredPolicy, + accountType: options.accountType, + excludeAccounts: options.excludeAccounts, group: options.group, allGroups: options.allGroups, platform: options.platform, @@ -90,7 +97,9 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P since: options.action === "errors" || options.action === "infrastructure" ? options.since : undefined, tail: options.action === "errors" || options.action === "infrastructure" ? options.tail : undefined, }, - runtime: compactRuntimeErrors(parsed, options), + runtime: options.json && (options.action === "apply" || options.action === "delete") + ? compactRuntimeMutationJson(parsed) + : compactRuntimeErrors(parsed, options), remote: compactCapture(result, { full: result.exitCode !== 0 || parsed === null }), disclosure: options.action === "list" ? { @@ -102,3 +111,38 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P }; return options.full || options.json ? response : renderRuntimeResult(response, options); } + +function compactRuntimeMutationJson(parsed: Record | null): Record | null { + if (parsed === null) return null; + const items = Array.isArray(parsed.items) ? parsed.items.filter(isRecord) : []; + const confirmed = parsed.mode === "confirmed"; + return { + ok: parsed.ok, + operation: parsed.operation, + mode: parsed.mode, + mutation: parsed.mutation, + partialWrite: parsed.partialWrite, + selection: parsed.selection, + summary: parsed.summary, + items: items.map((item) => { + const write = isRecord(item.write) ? item.write : {}; + const desiredDiffers = JSON.stringify(item.desired) !== JSON.stringify(item.before); + return { + accountName: item.accountName, + accountId: item.accountId, + selectionReason: item.selectionReason, + change: item.change, + template: item.template, + before: item.before, + desired: desiredDiffers ? item.desired : null, + actual: confirmed ? item.actual : undefined, + writeStatus: write.status, + writeError: write.error, + reconciled: item.reconciled, + reconcileError: item.reconcileError, + }; + }), + credentialsPrinted: false, + valuesPrinted: false, + }; +} diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 742b388a..aa016c7b 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -412,6 +412,7 @@ export function codexPoolHelp(): unknown { "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]", + "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --template --select-configured-policy --account-type apikey [--exclude-accounts ] [--target PK01] [--confirm] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --kind priority --priority <0-1000> [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --kind account-name --name [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]",