From aed6cba625bf1fc8274ecc41961c8ed69187c864 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 11:12:03 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Sub2API=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E5=8F=AA=E8=AF=BB=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/cli.md | 2 +- docs/reference/sub2api-user-feedback.md | 61 ++ .../platform-infra-sub2api-codex/feedback.ts | 593 ++++++++++++++++++ .../src/platform-infra-sub2api-codex/index.ts | 1 + .../platform-infra-sub2api-codex/options.ts | 63 +- .../src/platform-infra-sub2api-codex/types.ts | 14 +- scripts/src/platform-infra/entry.ts | 2 + 7 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 docs/reference/sub2api-user-feedback.md create mode 100644 scripts/src/platform-infra-sub2api-codex/feedback.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8da82dc..2f7a0ac5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -164,7 +164,7 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。 `status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。 如果 PipelineRun 的 `gitops-promote` 阶段报 git mirror 控制面漂移或 refs 不一致,先执行 `hwlab g14 git-mirror apply --confirm` 重新应用当前 `devops-infra/git-mirror.yaml` hook/ConfigMap,再执行 `hwlab g14 git-mirror sync --confirm --wait` 复核 refs;失败的同名 PipelineRun 只能通过 `hwlab g14 control-plane cleanup-runs --lane --pipeline-run --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run ` 和 `git-mirror status` 分别确认 runtime closeout 与 GitHub flush。 -- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路,以及 D601 Windows 隔离 PC 微信 + WeChatFerry host + D601 k3s 只读 collector 的 personal WeChat upstream;collector 必须复用 D601 已有 `platform-infra` namespace,`createNamespace=false`。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 +- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,其中用户反馈一键只读追踪见 `docs/reference/sub2api-user-feedback.md`;`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路,以及 D601 Windows 隔离 PC 微信 + WeChatFerry host + D601 k3s 只读 collector 的 personal WeChat upstream;collector 必须复用 D601 已有 `platform-infra` namespace,`createNamespace=false`。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 - `platform-infra observability plan|apply|status|validate|trace --target ` 是 `platform-infra` 内 OTel Collector 和 Tempo trace backend 的受控入口,`--target` 必须选择 trace 所属运行面节点;具体 trace 查询、噪声压制、Code Agent/AgentRun 排障和“先改进 OTel”的操作面统一见 `$unidesk-otel`(`.agents/skills/unidesk-otel/SKILL.md`)。 - `secrets plan|sync|status --config config/secrets-distribution.yaml --scope platform-infra` 是平台服务本地 Secret sourceRef 到 Kubernetes Secret key 的受控下发入口。`plan` 只读展示 sourceRef、必需 key、目标 Secret/key、missingKeys 和 fingerprint;`sync --confirm` 只按 YAML 声明创建允许生成的本地 key 并下发到声明的目标 Secret;`status` 只验证 live Secret key presence。该入口禁止从 live pod 或 Kubernetes Secret 反推密码、API key、JWT secret、n8n encryption key 或 `DATABASE_URL`,输出也不得打印 base64、解码值或远端 raw transcript;即使显式 `--raw` 也只返回脱敏 summary 和 raw omission 标记。LangBot/n8n Secret 轮换和缺 key 修复规则见 `docs/reference/platform-infra.md#secret-distribution-boundary`。 - `hwlab g14 observability status|apply|query|targets|boundary|closeout [--lane v02] [--promql ] [--expect-count N] [--expect-value V] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施和 HWLAB v0.2 监控 closeout 的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,支持 `--expect-count` / `--expect-value` 输出 `assertion`、bad values 和 missing/extra series;`targets` 汇总 ServiceMonitor/PrometheusRule、metrics sidecar readiness/restart、三层指标值和 `metrics.k8s.io` 当前 CPU/内存资源快照;`boundary` 验证 workload namespace 没有 Prometheus/Alertmanager,并对 `19666/19667` 公网 `/metrics` 做负向验证;`closeout` 聚合平台 ready、scrape reachable、sidecar serving、business health probe、resource snapshot、namespace boundary 和 public metrics exposure 语义结论。长期边界见 `docs/reference/g14-observability-infra.md`。 diff --git a/docs/reference/sub2api-user-feedback.md b/docs/reference/sub2api-user-feedback.md new file mode 100644 index 00000000..b56d700c --- /dev/null +++ b/docs/reference/sub2api-user-feedback.md @@ -0,0 +1,61 @@ +# Sub2API 用户反馈只读追踪 + +## 入口 + +- 按用户邮箱或正整数用户 ID 查询: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user + ``` + +- 默认窗口为 `30m`;可显式选择 `5m|30m|1h|6h|24h|7d|30d`。 +- 默认输出 Kubernetes 列表风格紧凑文本;只有显式 `--json` 才输出完整脱敏 JSON。 +- 请求表固定返回 10 条,并输出 `returned`、`total`、`moreAvailable`、`nextPageToken`;下一页使用 CLI 给出的完整 `Next` 命令,不提供手工 `limit`。 +- `REQUEST ID INDEX` 输出当前页完整、可复制的 request ID;主表使用页内序号避免重复宽字段。 +- 按默认表格中的 client request ID 或内部 request ID 下钻: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user \ + --request-id + ``` + +- 稳定游标下一页: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user \ + --window 24h \ + --page-token + ``` + +## 证据组合 + +- 用户与 API Key:使用 Sub2API 原生 `admin users` 和用户 API Key 列表,输出邮箱、名称、分组与当前并发,不输出 Key 值。 +- 请求时间线:自动分页读取窗口内全部 `ops requests`,逐项显示相邻请求空窗、模型、状态、耗时、账号和 proxy。 +- ID 关联:一次读取 `ops system-logs`,在本地关联 client request ID 与内部 request ID;指定 `--request-id` 时披露关联日志。 +- 实时状态:组合用户、账号和分组并发,区分无入站证据、排队、在途与已完成。 +- 基础设施:组合账号可用性、账号当前状态、proxy 状态和 Sub2API 组件健康摘要。 + +## 归因边界 + +- `client-before-submit-or-no-inbound-evidence` 表示窗口内没有 Sub2API 入站记录且实时并发为零,不等价于证明客户端没有尝试调用。 +- `sub2api-queue` 需要实时等待队列证据;`in-flight` 需要实时并发证据。 +- `upstream-first-byte-stream`、`proxy-egress` 和 `client-connection` 只根据原生 Ops 日志、账号、proxy 与基础设施证据分类,不执行探针或写操作。 +- 无足够分层证据时返回 `sub2api-or-upstream-unknown`,禁止扩大结论。 + +## 脱敏与只读 + +- 入口只调用 GET 类 admin/ops 接口、管理员登录和运行组件健康读取。 +- 不调用账号测试、探针、调度、取消、清理、更新或其他运行面写接口。 +- JSON 和文本均不披露 API Key、管理员 token、账号 credentials、proxy 用户名、密码或其他 Secret。 +- 所有分页由 CLI 自动完成,不提供手工 `limit` 作为输出规避手段。 + +## 调用量 + +- issue `#2000` 的人工调查包含用户、请求、系统日志、两种 request ID trace、账号、proxy、进程/端口和 journal 共 9 次工具入口。 +- 本入口将用户侧工具调用收敛为 1 次,减少 8 次,即 `88.9%`;内部只读 API 来源在 JSON `sources` 字段披露。 diff --git a/scripts/src/platform-infra-sub2api-codex/feedback.ts b/scripts/src/platform-infra-sub2api-codex/feedback.ts new file mode 100644 index 00000000..d2e9f6e3 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/feedback.ts @@ -0,0 +1,593 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; + +import { readCodexPoolConfig } from "./config"; +import { capture, compactCapture, parseJsonOutput } from "./remote"; +import { renderedCliResult, renderTable, shorten, shortIso, textValue } from "./render"; +import { codexPoolRuntimeTarget } from "./runtime-target"; +import type { CodexPoolConfig, CodexPoolRuntimeTarget, FeedbackOptions } from "./types"; + +function pyJson(value: unknown): string { + return `json.loads(${JSON.stringify(JSON.stringify(value))})`; +} + +export async function codexPoolFeedback(config: UniDeskConfig, options: FeedbackOptions): Promise | RenderedCliResult> { + const pool = readCodexPoolConfig(); + const target = codexPoolRuntimeTarget(options.targetId); + const result = await capture(config, target.route, ["sh"], feedbackScript(pool, options, target)); + const report = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && report?.ok === true; + const remote = compactCapture(result, { full: result.exitCode !== 0 || report === null }); + if (options.json) { + return { + ok, + action: "platform-infra-sub2api-codex-pool-feedback", + target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode }, + report, + remote, + valuesPrinted: false, + }; + } + return renderedCliResult(ok, "platform-infra sub2api codex-pool feedback", renderFeedbackReport(report, options, remote)); +} + +function feedbackScript(pool: CodexPoolConfig, options: FeedbackOptions, target: CodexPoolRuntimeTarget): string { + return ` +set -u +python3 - <<'PY' +import base64 +import json +import re +import subprocess +from datetime import datetime, timezone, timedelta +from urllib.parse import quote, urlencode + +RUNTIME_MODE = ${pyJson(target.runtimeMode)} +NAMESPACE = ${pyJson(target.namespace)} +APP_SECRET_NAME = ${pyJson(target.appSecretName)} +ADMIN_EMAIL_DEFAULT = ${pyJson(pool.adminEmailDefault)} +HOST_DOCKER_APP_PORT = ${pyJson(target.hostDockerAppPort)} +HOST_DOCKER_ENV_PATH = ${pyJson(target.hostDockerEnvPath)} +USER_SELECTOR = ${pyJson(options.user)} +WINDOW = ${pyJson(options.window)} +REQUEST_SELECTOR = ${pyJson(options.requestId)} +PAGE_TOKEN = ${pyJson(options.pageToken)} +APP_CONTAINER = "sub2api-app" +PAGE_SIZE = 10 + +def run(command, input_bytes=None): + return subprocess.run(command, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + +def text(value, limit=1000): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return str(value or "")[-limit:] + +def docker(args): + proc = run(["docker", *args]) + if proc.returncode == 0: + return proc + fallback = run(["sudo", "-n", "docker", *args]) + return fallback if fallback.returncode == 0 else proc + +def read_host_env(): + if RUNTIME_MODE != "host-docker": + return {} + try: + with open(HOST_DOCKER_ENV_PATH, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except PermissionError: + proc = run(["sudo", "-n", "cat", HOST_DOCKER_ENV_PATH]) + if proc.returncode != 0: + raise RuntimeError("read host-docker env failed: " + text(proc.stderr)) + lines = proc.stdout.decode("utf-8", errors="replace").splitlines() + values = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + values[key.strip()] = value + return values + +def runtime_secret(key): + if RUNTIME_MODE == "host-docker": + return read_host_env().get(key) + proc = run(["kubectl", "-n", NAMESPACE, "get", "secret", APP_SECRET_NAME, "-o", "json"]) + if proc.returncode != 0: + raise RuntimeError("read app secret failed: " + text(proc.stderr)) + raw = (json.loads(proc.stdout.decode("utf-8")).get("data") or {}).get(key) + return base64.b64decode(raw).decode("utf-8") if raw else None + +def runtime_config(key): + if RUNTIME_MODE == "host-docker": + return read_host_env().get(key) + proc = run(["kubectl", "-n", NAMESPACE, "get", "configmap", "sub2api-config", "-o", "json"]) + if proc.returncode != 0: + return None + return (json.loads(proc.stdout.decode("utf-8")).get("data") or {}).get(key) + +def parse_curl(proc): + stdout = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP_CODE__:" + position = stdout.rfind(marker) + if position < 0: + return {"ok": False, "httpStatus": 0, "json": None, "message": text(proc.stderr)} + body = stdout[:position] + try: + status = int(stdout[position + len(marker):].strip()[-3:]) + except Exception: + status = 0 + try: + parsed = json.loads(body) if body.strip() else None + except Exception: + parsed = None + message = parsed.get("message") if isinstance(parsed, dict) else text(body) + return {"ok": proc.returncode == 0 and 200 <= status < 300, "httpStatus": status, "json": parsed, "message": message} + +def curl_api(method, path, bearer=None, payload=None): + body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8") + script = r'''set -eu +method="$1" +url="$2" +token="\${3:-}" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" +args="" +if [ -n "$token" ]; then args="Authorization: Bearer $token"; fi +if [ "$method" = GET ] && [ ! -s "$tmp" ]; then + if [ -n "$args" ]; then curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -H "$args" "$url"; else curl -sS -w '\\n__HTTP_CODE__:%{http_code}' "$url"; fi +else + if [ -n "$args" ]; then curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "$args" --data-binary @"$tmp" "$url"; else curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"; fi +fi''' + if RUNTIME_MODE == "host-docker": + proc = run(["sh", "-c", script, "sh", method, f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}{path}", bearer or ""], body) + else: + proc = run(["kubectl", "-n", NAMESPACE, "exec", "deploy/sub2api", "--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or ""], body) + return parse_curl(proc) + +def api_data(method, path, token=None, payload=None, label=None): + response = curl_api(method, path, token, payload) + parsed = response.get("json") + code = parsed.get("code") if isinstance(parsed, dict) else None + if not response.get("ok") or (code is not None and code != 0): + raise RuntimeError(f"{label or path} failed: http={response.get('httpStatus')} message={response.get('message')}") + return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed + +def find_token(value): + if isinstance(value, dict): + for key in ("access_token", "token"): + if isinstance(value.get(key), str) and value.get(key): + return value[key] + for item in value.values(): + token = find_token(item) + if token: + return token + return None + +def login(): + email = runtime_config("ADMIN_EMAIL") or ADMIN_EMAIL_DEFAULT + password = runtime_secret("ADMIN_PASSWORD") + if not password: + raise RuntimeError("ADMIN_PASSWORD missing") + token = find_token(api_data("POST", "/api/v1/auth/login", payload={"email": email, "password": password}, label="admin login")) + if not token: + raise RuntimeError("admin login returned no token") + return email, token + +def items(data): + if isinstance(data, list): + return data + if isinstance(data, dict): + if isinstance(data.get("items"), list): + return data["items"] + for key in ("users", "api_keys", "keys", "groups", "accounts", "proxies", "logs"): + if isinstance(data.get(key), list): + return data[key] + return [] + +def page_all(token, path, query=None, page_size=100): + query = dict(query or {}) + collected = [] + page = 1 + while True: + params = {**query, "page": page, "page_size": page_size} + separator = "&" if "?" in path else "?" + data = api_data("GET", path + separator + urlencode(params), token, label=path) + batch = items(data) + collected.extend(batch) + total = data.get("total") if isinstance(data, dict) else None + if not batch or (isinstance(total, int) and len(collected) >= total) or len(batch) < page_size: + break + page += 1 + return collected + +def exact_user(token): + if USER_SELECTOR.isdigit() and int(USER_SELECTOR) > 0: + user = api_data("GET", f"/api/v1/admin/users/{int(USER_SELECTOR)}", token, label="get user") + return user if isinstance(user, dict) else None + candidates = page_all(token, "/api/v1/admin/users", {"search": USER_SELECTOR}, 100) + exact = [user for user in candidates if isinstance(user, dict) and str(user.get("email") or "").lower() == USER_SELECTOR.lower()] + if len(exact) > 1: + raise RuntimeError("email matched multiple users") + return exact[0] if exact else None + +def safe_call(errors, name, callback, fallback): + try: + return callback() + except Exception as exc: + errors.append({"source": name, "error": redact_text(str(exc))}) + return fallback + +def redact_text(value): + value = str(value or "") + value = re.sub(r"(?i)(bearer\\s+)[A-Za-z0-9._~+\\-/=]+", r"\\1", value) + value = re.sub(r"(?i)((?:api[_-]?key|token|password)\\s*[=:]\\s*)[^\\s,;]+", r"\\1", value) + value = re.sub(r"\\bsk-[A-Za-z0-9_-]{8,}\\b", "sk-", value) + return value[:500] + +def iso_epoch(value): + if not isinstance(value, str) or not value: + return None + try: + match = re.match(r"^(\\d{4}-\\d\\d-\\d\\d[T ]\\d\\d:\\d\\d:\\d\\d)(?:\\.(\\d+))?(Z|[+-]\\d\\d:?\\d\\d)$", value) + if not match: + return None + base = datetime.strptime(match.group(1).replace(" ", "T"), "%Y-%m-%dT%H:%M:%S") + fraction = int((match.group(2) or "0")[:6].ljust(6, "0")) + zone = match.group(3) + if zone == "Z": + offset = timezone.utc + else: + sign = 1 if zone[0] == "+" else -1 + digits = zone[1:].replace(":", "") + offset = timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:]))) + return base.replace(microsecond=fraction, tzinfo=offset).timestamp() + except Exception: + return None + +def encode_page_token(row): + payload = json.dumps({"v": 1, "at": row.get("at"), "requestId": row.get("requestId")}, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + +def decode_page_token(value): + if not isinstance(value, str) or not value: + return None + try: + padded = value + "=" * (-len(value) % 4) + parsed = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")) + except Exception: + raise RuntimeError("invalid page token") + if not isinstance(parsed, dict) or parsed.get("v") != 1 or not parsed.get("at") or not parsed.get("requestId"): + raise RuntimeError("invalid page token") + return parsed + +def dict_by_id(rows): + return {str(row.get("id")): row for row in rows if isinstance(row, dict) and row.get("id") is not None} + +def keyed_map(value, key): + source = value.get(key) if isinstance(value, dict) else None + return source if isinstance(source, dict) else {} + +def runtime_health(): + if RUNTIME_MODE == "host-docker": + proc = docker(["inspect", APP_CONTAINER, "sub2api-redis"]) + if proc.returncode != 0: + return {"ok": False, "mode": RUNTIME_MODE, "error": text(proc.stderr)} + rows = [] + for item in json.loads(proc.stdout.decode("utf-8")): + state = item.get("State") or {} + health = state.get("Health") or {} + rows.append({"name": str(item.get("Name") or "").lstrip("/"), "running": state.get("Running"), "status": health.get("Status") or state.get("Status")}) + return {"ok": all(row.get("running") is True for row in rows), "mode": RUNTIME_MODE, "components": rows} + proc = run(["kubectl", "-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api", "-o", "json"]) + if proc.returncode != 0: + return {"ok": False, "mode": RUNTIME_MODE, "error": text(proc.stderr)} + rows = [] + for item in json.loads(proc.stdout.decode("utf-8")).get("items") or []: + status = item.get("status") or {} + containers = status.get("containerStatuses") or [] + rows.append({"name": (item.get("metadata") or {}).get("name"), "running": status.get("phase") == "Running", "status": "ready" if containers and all(entry.get("ready") is True for entry in containers) else status.get("phase")}) + return {"ok": bool(rows) and all(row.get("running") is True for row in rows), "mode": RUNTIME_MODE, "components": rows} + +def compact_log(row): + extra = row.get("extra") if isinstance(row.get("extra"), dict) else {} + allow = {} + for key in ("phase", "error_owner", "status_code", "duration_ms", "latency_ms", "time_to_first_token_ms", "upstream_status", "path", "stream"): + if key in extra: + allow[key] = extra.get(key) + return { + "id": row.get("id"), + "at": row.get("created_at"), + "level": row.get("level"), + "component": row.get("component"), + "message": redact_text(row.get("message")), + "requestId": row.get("request_id"), + "clientRequestId": row.get("client_request_id"), + "accountId": row.get("account_id"), + "extra": allow, + } + +def related_logs(request, logs): + ids = {str(request.get("request_id") or "")} + matched = [] + changed = True + while changed: + changed = False + for row in logs: + request_id = str(row.get("request_id") or "") + client_id = str(row.get("client_request_id") or "") + if row in matched or (request_id not in ids and client_id not in ids): + continue + matched.append(row) + before = len(ids) + ids.update(value for value in (request_id, client_id) if value) + changed = changed or len(ids) != before + return matched + +def classify(request, logs, account, proxy, queued): + evidence = " ".join([str(request.get("phase") or ""), str(request.get("message") or ""), *[str(log.get("component") or "") + " " + str(log.get("message") or "") for log in logs]]).lower() + if any(marker in evidence for marker in ("client disconnect", "broken pipe", "context canceled", "client canceled")): + return "client-connection" + if any(marker in evidence for marker in ("proxy", "dial tcp", "dns", "connection refused", "network")) or (proxy and proxy.get("status") not in (None, "active")): + return "proxy-egress" + if any(marker in evidence for marker in ("upstream", "provider", "first token", "stream")) or str(request.get("phase") or "") == "upstream": + return "upstream-first-byte-stream" + if queued > 0: + return "sub2api-queue" + if request.get("kind") == "error" or (isinstance(request.get("status_code"), int) and request.get("status_code") >= 400): + return "sub2api-or-upstream-unknown" + return "completed" + +def main(): + errors = [] + admin_email, token = login() + user = exact_user(token) + if not isinstance(user, dict): + return {"ok": False, "mode": "feedback", "error": "user-not-found", "selector": USER_SELECTOR, "valuesPrinted": False} + user_id = user.get("id") + groups = safe_call(errors, "groups", lambda: items(api_data("GET", "/api/v1/admin/groups/all", token, label="groups")), []) + keys = safe_call(errors, "api-keys", lambda: page_all(token, f"/api/v1/admin/users/{user_id}/api-keys", {}, 100), []) + requests = safe_call(errors, "ops-requests", lambda: page_all(token, "/api/v1/admin/ops/requests", {"user_id": user_id, "time_range": WINDOW, "kind": "all", "sort": "created_at_desc"}, 100), []) + logs = safe_call(errors, "ops-system-logs", lambda: page_all(token, "/api/v1/admin/ops/system-logs", {"user_id": user_id, "time_range": WINDOW}, 200), []) + concurrency = safe_call(errors, "ops-concurrency", lambda: api_data("GET", "/api/v1/admin/ops/concurrency", token, label="concurrency"), {}) + user_concurrency = safe_call(errors, "ops-user-concurrency", lambda: api_data("GET", "/api/v1/admin/ops/user-concurrency", token, label="user concurrency"), {}) + availability = safe_call(errors, "ops-account-availability", lambda: api_data("GET", "/api/v1/admin/ops/account-availability", token, label="account availability"), {}) + accounts = safe_call(errors, "accounts", lambda: page_all(token, "/api/v1/admin/accounts", {}, 100), []) + proxies = safe_call(errors, "proxies", lambda: page_all(token, "/api/v1/admin/proxies", {}, 100), []) + infrastructure = safe_call(errors, "infrastructure", runtime_health, {"ok": False, "mode": RUNTIME_MODE}) + + groups_by_id = dict_by_id(groups) + keys_by_id = dict_by_id(keys) + accounts_by_id = dict_by_id(accounts) + proxies_by_id = dict_by_id(proxies) + user_current_map = keyed_map(user_concurrency, "user") + account_concurrency_map = keyed_map(concurrency, "account") + account_availability_map = keyed_map(availability, "account") + current_user = user_current_map.get(str(user_id)) or {} + user_current = int(current_user.get("current_in_use") or user.get("current_concurrency") or 0) + user_waiting = int(current_user.get("waiting_in_queue") or 0) + + compact_keys = [] + for key in keys: + group = groups_by_id.get(str(key.get("group_id"))) or {} + compact_keys.append({ + "id": key.get("id"), "name": key.get("name"), "status": key.get("status"), + "groupId": key.get("group_id"), "groupName": group.get("name"), + "currentConcurrency": key.get("current_concurrency"), "keyPrinted": False, + }) + + timeline = [] + ordered = sorted([row for row in requests if isinstance(row, dict)], key=lambda row: iso_epoch(row.get("created_at")) or 0) + previous_epoch = None + for row in ordered: + matched_logs = related_logs(row, logs) + client_id = next((log.get("client_request_id") for log in matched_logs if log.get("client_request_id")), None) + account = accounts_by_id.get(str(row.get("account_id"))) or {} + proxy = proxies_by_id.get(str(account.get("proxy_id"))) or {} + account_load = account_concurrency_map.get(str(row.get("account_id"))) or {} + queued = int(account_load.get("waiting_in_queue") or 0) + epoch = iso_epoch(row.get("created_at")) + gap = round(epoch - previous_epoch, 1) if epoch is not None and previous_epoch is not None else None + previous_epoch = epoch if epoch is not None else previous_epoch + timeline.append({ + "at": row.get("created_at"), "gapSeconds": gap, "kind": row.get("kind"), + "requestId": row.get("request_id"), "clientRequestId": client_id, + "model": row.get("model"), "statusCode": row.get("status_code"), "durationMs": row.get("duration_ms"), + "apiKeyId": row.get("api_key_id"), "apiKeyName": (keys_by_id.get(str(row.get("api_key_id"))) or {}).get("name"), + "accountId": row.get("account_id"), "accountName": account.get("name"), + "proxyId": account.get("proxy_id"), "proxyName": proxy.get("name"), + "attribution": classify(row, matched_logs, account, proxy, queued), + "evidence": {"systemLogCount": len(matched_logs), "accountStatus": account.get("status"), "accountSchedulable": account.get("schedulable"), "proxyStatus": proxy.get("status")}, + }) + + all_timeline = timeline + max_gap = max([row.get("gapSeconds") for row in all_timeline if isinstance(row.get("gapSeconds"), (int, float))], default=None) + next_page_token = None + more_available = False + if REQUEST_SELECTOR: + selected_ids = {REQUEST_SELECTOR} + for row in all_timeline: + if row.get("requestId") == REQUEST_SELECTOR or row.get("clientRequestId") == REQUEST_SELECTOR: + selected_ids.update(value for value in (row.get("requestId"), row.get("clientRequestId")) if isinstance(value, str) and value) + timeline = [row for row in all_timeline if any(isinstance(value, str) and value in selected_ids for value in (row.get("requestId"), row.get("clientRequestId")))] + detail_logs = [compact_log(row) for row in logs if any(isinstance(value, str) and value in selected_ids for value in (row.get("request_id"), row.get("client_request_id")))] + else: + detail_logs = [] + ordered_desc = list(reversed(all_timeline)) + cursor = decode_page_token(PAGE_TOKEN) + start = 0 + if cursor: + start = next((index + 1 for index, row in enumerate(ordered_desc) if row.get("at") == cursor.get("at") and row.get("requestId") == cursor.get("requestId")), -1) + if start < 0: + raise RuntimeError("page token cursor is no longer present in the selected window") + timeline = ordered_desc[start:start + PAGE_SIZE] + more_available = start + len(timeline) < len(ordered_desc) + if more_available and timeline: + next_page_token = encode_page_token(timeline[-1]) + if not requests and user_waiting > 0: + conclusion = "sub2api-queue" + elif not requests and user_current > 0: + conclusion = "in-flight" + elif not requests: + conclusion = "client-before-submit-or-no-inbound-evidence" + elif timeline: + conclusion = timeline[0].get("attribution") + else: + conclusion = "request-selector-not-found" + + related_account_ids = {str(row.get("accountId")) for row in timeline if row.get("accountId") is not None} + account_evidence = [] + for account_id in sorted(related_account_ids): + account = accounts_by_id.get(account_id) or {} + proxy = proxies_by_id.get(str(account.get("proxy_id"))) or {} + load = account_concurrency_map.get(account_id) or {} + available = account_availability_map.get(account_id) or {} + account_evidence.append({ + "id": account.get("id"), "name": account.get("name"), "status": account.get("status"), "schedulable": account.get("schedulable"), + "currentInUse": load.get("current_in_use"), "waitingInQueue": load.get("waiting_in_queue"), "maxCapacity": load.get("max_capacity"), + "available": available.get("is_available"), "rateLimited": available.get("is_rate_limited"), "overloaded": available.get("is_overloaded"), + "proxyId": account.get("proxy_id"), "proxyName": proxy.get("name"), "proxyStatus": proxy.get("status"), + }) + + return { + "ok": True, + "mode": "feedback", + "selector": {"user": USER_SELECTOR, "requestId": REQUEST_SELECTOR, "pageToken": PAGE_TOKEN, "window": WINDOW}, + "user": {"id": user_id, "email": user.get("email"), "username": user.get("username"), "status": user.get("status"), "maxConcurrency": user.get("concurrency"), "currentInUse": user_current, "waitingInQueue": user_waiting}, + "apiKeys": compact_keys, + "lifecycle": {"notInbound": not requests and user_current == 0 and user_waiting == 0, "queued": user_waiting, "inFlight": user_current, "completed": len(requests)}, + "timeline": timeline, + "timelineSummary": {"returned": len(timeline), "total": len(all_timeline), "moreAvailable": more_available, "nextPageToken": next_page_token, "pageSize": PAGE_SIZE, "systemLogCount": len(logs), "maxGapSeconds": max_gap}, + "requestDetail": {"logs": detail_logs, "logCount": len(detail_logs)} if REQUEST_SELECTOR else None, + "accounts": account_evidence, + "infrastructure": infrastructure, + "conclusion": {"attribution": conclusion, "boundary": "只读证据归因;无入站记录不等于客户端未调用,运行中请求仅由实时并发佐证。"}, + "toolCallReduction": {"manualInvestigationCalls": 9, "feedbackCliCalls": 1, "reducedCalls": 8, "reductionPercent": 88.9, "basis": "issue-2000: user, requests, system logs, two request-id traces, account, proxy, process/port, journal"}, + "sources": ["admin users", "admin user api-keys", "ops requests", "ops system-logs", "ops concurrency", "ops user-concurrency", "ops account-availability", "admin accounts", "admin proxies", "runtime health"], + "errors": errors, + "admin": {"email": admin_email, "tokenPrinted": False}, + "valuesPrinted": False, + } + +try: + print(json.dumps(main(), ensure_ascii=False)) +except Exception as exc: + print(json.dumps({"ok": False, "mode": "feedback", "error": redact_text(str(exc)), "valuesPrinted": False}, ensure_ascii=False)) + raise +PY +`; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function renderFeedbackReport(report: Record | null, options: FeedbackOptions, remote: Record): string { + if (report === null) { + return [ + `SUB2API FEEDBACK user=${options.user} unavailable`, + `remote_exit=${remote.exitCode ?? "?"} stdout_bytes=${remote.stdoutBytes ?? "?"} stderr_bytes=${remote.stderrBytes ?? "?"}`, + textValue(remote.stderrTail ?? remote.stdoutTail), + ].join("\n"); + } + if (report.ok !== true) { + return [ + `SUB2API FEEDBACK user=${options.user} ok=false`, + `error=${textValue(report.error)}`, + "JSON: add --json for the structured failure envelope.", + ].join("\n"); + } + const user = record(report.user); + const lifecycle = record(report.lifecycle); + const summary = record(report.timelineSummary); + const conclusion = record(report.conclusion); + const reduction = record(report.toolCallReduction); + const timeline = records(report.timeline); + const accounts = records(report.accounts); + const apiKeys = records(report.apiKeys); + const detail = record(report.requestDetail); + const detailLogs = records(detail.logs); + const infrastructure = record(report.infrastructure); + const components = records(infrastructure.components); + const errors = records(report.errors); + const lines: string[] = []; + lines.push(`SUB2API FEEDBACK user=${textValue(user.email)}#${textValue(user.id)} window=${options.window} ok=true`); + lines.push(`CONCLUSION attribution=${textValue(conclusion.attribution)} boundary=${textValue(conclusion.boundary)}`); + lines.push(`LIFECYCLE not_inbound=${textValue(lifecycle.notInbound)} queued=${textValue(lifecycle.queued)} in_flight=${textValue(lifecycle.inFlight)} completed=${textValue(lifecycle.completed)} max_gap_s=${textValue(summary.maxGapSeconds)}`); + lines.push(`TOOLS manual=${textValue(reduction.manualInvestigationCalls)} cli=${textValue(reduction.feedbackCliCalls)} reduced=${textValue(reduction.reducedCalls)} reduction=${textValue(reduction.reductionPercent)}%`); + if (apiKeys.length > 0) { + lines.push(""); + lines.push("API KEYS"); + lines.push(renderTable([ + ["ID", "NAME", "GROUP", "STATUS", "CURRENT"], + ...apiKeys.map((key) => [textValue(key.id), shorten(textValue(key.name), 30), shorten(textValue(key.groupName), 24), textValue(key.status), textValue(key.currentConcurrency)]), + ])); + } + lines.push(""); + lines.push(`REQUESTS returned=${textValue(summary.returned)} total=${textValue(summary.total)} moreAvailable=${textValue(summary.moreAvailable)} system_logs=${textValue(summary.systemLogCount)}`); + if (timeline.length === 0) { + lines.push("No matching completed request records in the selected window."); + } else { + lines.push(renderTable([ + ["#", "AT", "GAP_S", "STATUS", "DURATION", "MODEL", "ACCOUNT", "ATTRIBUTION"], + ...timeline.map((item, index) => [ + String(index + 1), shortIso(item.at), textValue(item.gapSeconds), textValue(item.statusCode ?? item.kind), textValue(item.durationMs), + shorten(textValue(item.model), 18), shorten(`${textValue(item.accountName)}#${textValue(item.accountId)}`, 28), textValue(item.attribution), + ]), + ])); + lines.push(""); + lines.push("REQUEST ID INDEX"); + lines.push(renderTable([ + ["#", "REQUEST_ID", "CLIENT_ID"], + ...timeline.map((item, index) => [String(index + 1), textValue(item.requestId), textValue(item.clientRequestId)]), + ])); + } + if (accounts.length > 0 || components.length > 0) { + lines.push(""); + lines.push("ACCOUNT / INFRA"); + lines.push(renderTable([ + ["ACCOUNT", "STATUS", "SCHED", "IN_USE", "QUEUE", "CAP", "AVAILABLE", "PROXY", "P_STATUS"], + ...accounts.map((item) => [ + shorten(`${textValue(item.name)}#${textValue(item.id)}`, 32), textValue(item.status), textValue(item.schedulable), + textValue(item.currentInUse), textValue(item.waitingInQueue), textValue(item.maxCapacity), textValue(item.available), + shorten(`${textValue(item.proxyName)}#${textValue(item.proxyId)}`, 24), textValue(item.proxyStatus), + ]), + ...components.map((item) => [shorten(textValue(item.name), 32), textValue(item.status), "-", "-", "-", "-", textValue(item.running), "runtime", textValue(infrastructure.mode)]), + ])); + } + if (options.requestId !== null) { + lines.push(""); + lines.push(`REQUEST DETAIL id=${options.requestId} logs=${textValue(detail.logCount)}`); + if (detailLogs.length > 0) { + lines.push(renderTable([ + ["LOG_ID", "AT", "LEVEL", "COMPONENT", "REQUEST_ID", "CLIENT_ID", "MESSAGE"], + ...detailLogs.map((item) => [ + textValue(item.id), shortIso(item.at), textValue(item.level), shorten(textValue(item.component), 22), + shorten(textValue(item.requestId), 18), shorten(textValue(item.clientRequestId), 18), shorten(textValue(item.message), 64), + ]), + ])); + } + } + if (errors.length > 0) { + lines.push(""); + lines.push("PARTIAL EVIDENCE"); + lines.push(renderTable([["SOURCE", "ERROR"], ...errors.map((item) => [textValue(item.source), shorten(textValue(item.error), 90)])])); + } + lines.push(""); + if (summary.moreAvailable === true && typeof summary.nextPageToken === "string") { + lines.push(`NEXT_PAGE_TOKEN ${summary.nextPageToken}`); + lines.push(`Next: bun scripts/cli.ts platform-infra sub2api codex-pool feedback --target ${options.targetId} --user ${options.user} --window ${options.window} --page-token ${summary.nextPageToken}`); + } + lines.push("Disclosure: rerun with --request-id for correlated indexed logs."); + lines.push("JSON: add --json for the complete redacted report."); + return lines.join("\n"); +} diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index 51e6ff6f..9ec51ffa 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -13,3 +13,4 @@ export * from "./remote-scripts"; export * from "./accounts"; export * from "./remote-python-sync"; export * from "./remote"; +export * from "./feedback"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index a17ddade..2006d7d5 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -21,8 +21,9 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types"; +import type { ConfirmOptions, DisclosureOptions, FeedbackOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types"; import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions"; +import { codexPoolFeedback } from "./feedback"; import { renderCodexPoolPlan } from "./render"; import { codexPoolRuntime } from "./runtime"; import { defaultCodexPoolRuntimeTargetId } from "./runtime-target"; @@ -39,6 +40,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]) if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1))); if (action === "runtime") return await codexPoolRuntime(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))); if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1))); if (action === "sentinel-probe") return await codexPoolSentinelProbe(config, parseSentinelProbeOptions(args.slice(1))); if (action === "sentinel-report") return await codexPoolSentinelReport(config, parseSentinelReportOptions(args.slice(1))); @@ -181,6 +183,7 @@ export function parseSentinelReportOptions(args: string[]): SentinelReportOption export function parseTraceOptions(args: string[]): TraceOptions { let requestId: string | null = null; + let pageToken: string | null = null; let since = "24h"; let tail = 20_000; let contextSeconds = 300; @@ -258,6 +261,64 @@ export function parseTraceOptions(args: string[]): TraceOptions { return { ...disclosure, requestId, since, tail, contextSeconds, showLines }; } +export function parseFeedbackOptions(args: string[]): FeedbackOptions { + let user: string | null = null; + let window: FeedbackOptions["window"] = "30m"; + let requestId: string | null = null; + let pageToken: string | null = null; + let json = false; + const targetArgs: string[] = []; + const windows = new Set(["5m", "30m", "1h", "6h", "24h", "7d", "30d"]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--target" || arg.startsWith("--target=")) { + targetArgs.push(arg); + if (arg === "--target") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); + targetArgs.push(value); + index += 1; + } + continue; + } + const readValue = (option: string): string => { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`); + index += 1; + return value.trim(); + }; + if (arg === "--user") user = readValue(arg); + else if (arg.startsWith("--user=")) user = arg.slice("--user=".length).trim(); + else if (arg === "--window") { + const value = readValue(arg) as FeedbackOptions["window"]; + if (!windows.has(value)) throw new Error("--window must be one of 5m, 30m, 1h, 6h, 24h, 7d, 30d"); + window = value; + } else if (arg.startsWith("--window=")) { + const value = arg.slice("--window=".length) as FeedbackOptions["window"]; + if (!windows.has(value)) throw new Error("--window must be one of 5m, 30m, 1h, 6h, 24h, 7d, 30d"); + window = value; + } else if (arg === "--request-id") requestId = readValue(arg); + else if (arg.startsWith("--request-id=")) requestId = arg.slice("--request-id=".length).trim(); + else if (arg === "--page-token") pageToken = readValue(arg); + else if (arg.startsWith("--page-token=")) pageToken = arg.slice("--page-token=".length).trim(); + else throw new Error(`unsupported option: ${arg}`); + } + if (user === null || user.length === 0) throw new Error("feedback requires --user "); + if (user.length > 320 || /[\r\n]/u.test(user)) throw new Error("--user must be a single email or positive user id"); + if (requestId !== null && (requestId.length === 0 || requestId.length > 256 || /[\r\n]/u.test(requestId))) { + throw new Error("--request-id must be a non-empty stable request id up to 256 characters"); + } + if (pageToken !== null && (pageToken.length === 0 || pageToken.length > 1024 || !/^[A-Za-z0-9_-]+$/u.test(pageToken))) { + throw new Error("--page-token must be a URL-safe cursor token"); + } + if (requestId !== null && pageToken !== null) throw new Error("--request-id and --page-token cannot be combined"); + return { user, window, requestId, pageToken, json, targetId: parseTargetId(targetArgs) }; +} + export function parseKubectlDuration(raw: string): string { const value = raw.trim(); if (!/^[1-9][0-9]*(?:s|m|h)$/u.test(value)) throw new Error("--since must be a kubectl duration such as 24h, 90m, or 300s"); diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 347d4685..0133efe1 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -71,6 +71,15 @@ export interface TraceOptions extends DisclosureOptions { showLines: boolean; } +export interface FeedbackOptions { + user: string; + window: "5m" | "30m" | "1h" | "6h" | "24h" | "7d" | "30d"; + requestId: string | null; + pageToken: string | null; + json: boolean; + targetId: string; +} + export interface SentinelImageOptions extends DisclosureOptions { action: "status" | "build"; confirm: boolean; @@ -341,8 +350,8 @@ export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(); return { - command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|trace|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", - output: "json, except trace and sentinel-report default to low-noise text tables", + command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|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", "bun scripts/cli.ts platform-infra sub2api codex-pool plan --target D601", @@ -354,6 +363,7 @@ export function codexPoolHelp(): unknown { "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 delete --account --kind temp-unschedulable [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--target PK01] [--json]", "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", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe [--target D601] --account unidesk-codex-hy --confirm", diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index d9116132..a62892b1 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -421,6 +421,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", @@ -503,6 +504,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", ], module: "scripts/src/platform-infra-sub2api-codex.ts", From 0cfbdea0314091ea1c1b402121f06144980c3260 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 12:47:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=20Sub2API=20=E4=B8=8A=E6=B8=B8=E8=B4=A6=E5=8F=B7=E5=90=8D?= =?UTF-8?q?=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/platform-infra-sub2api-codex/feedback.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/src/platform-infra-sub2api-codex/feedback.ts b/scripts/src/platform-infra-sub2api-codex/feedback.ts index d2e9f6e3..57ddea77 100644 --- a/scripts/src/platform-infra-sub2api-codex/feedback.ts +++ b/scripts/src/platform-infra-sub2api-codex/feedback.ts @@ -541,7 +541,7 @@ function renderFeedbackReport(report: Record | null, options: F ["#", "AT", "GAP_S", "STATUS", "DURATION", "MODEL", "ACCOUNT", "ATTRIBUTION"], ...timeline.map((item, index) => [ String(index + 1), shortIso(item.at), textValue(item.gapSeconds), textValue(item.statusCode ?? item.kind), textValue(item.durationMs), - shorten(textValue(item.model), 18), shorten(`${textValue(item.accountName)}#${textValue(item.accountId)}`, 28), textValue(item.attribution), + shorten(textValue(item.model), 18), `${textValue(item.accountName)}#${textValue(item.accountId)}`, textValue(item.attribution), ]), ])); lines.push(""); @@ -557,7 +557,7 @@ function renderFeedbackReport(report: Record | null, options: F lines.push(renderTable([ ["ACCOUNT", "STATUS", "SCHED", "IN_USE", "QUEUE", "CAP", "AVAILABLE", "PROXY", "P_STATUS"], ...accounts.map((item) => [ - shorten(`${textValue(item.name)}#${textValue(item.id)}`, 32), textValue(item.status), textValue(item.schedulable), + `${textValue(item.name)}#${textValue(item.id)}`, textValue(item.status), textValue(item.schedulable), textValue(item.currentInUse), textValue(item.waitingInQueue), textValue(item.maxCapacity), textValue(item.available), shorten(`${textValue(item.proxyName)}#${textValue(item.proxyId)}`, 24), textValue(item.proxyStatus), ]),