Compare commits
16
Commits
a1a50f1e81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834eced686 | ||
|
|
4d3ea048a5 | ||
|
|
d5642c6252 | ||
|
|
3e0ff898e0 | ||
|
|
41f3985248 | ||
|
|
37ba6876bf | ||
|
|
2fcde375a4 | ||
|
|
698398dafb | ||
|
|
adc2a02f6c | ||
|
|
6a70775ad9 | ||
|
|
6d009af48f | ||
|
|
da027f8628 | ||
|
|
1eefe17474 | ||
|
|
f76a566aa8 | ||
|
|
ce98aaa448 | ||
|
|
6ba822daa2 |
No files matched your search
@@ -10,7 +10,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
rm -rf src
|
rm -rf src
|
||||||
git clone https://gitea.tose.top/admin/codex-app-server.git src
|
git clone https://gitea.tose.top/admin/codex-app-server.git src
|
||||||
cd src && git rev-parse HEAD
|
cd src && git fetch origin "${{ gitea.ref }}" && git checkout FETCH_HEAD
|
||||||
|
echo "pushed ref: ${{ gitea.ref }}"
|
||||||
|
git rev-parse HEAD && git log --oneline -1
|
||||||
- name: host tests
|
- name: host tests
|
||||||
run: cd src && python3 -m unittest discover -s tests -v
|
run: cd src && python3 -m unittest discover -s tests -v
|
||||||
- name: cloud tests
|
- name: cloud tests
|
||||||
|
|||||||
@@ -72,8 +72,9 @@ agent 循环 + 沙箱 + 工具 + MCP + skills + 线程持久化
|
|||||||
agent 循环、沙箱、工具执行、上下文压缩、线程存储,全部是官方
|
agent 循环、沙箱、工具执行、上下文压缩、线程存储,全部是官方
|
||||||
codex 二进制里的,不是我复刻的简化版。
|
codex 二进制里的,不是我复刻的简化版。
|
||||||
|
|
||||||
实测版本:`codex-cli 0.164.0-alpha.9`,协议定义就是从它
|
实测版本:`codex-cli 0.166.0`,协议定义就是从它
|
||||||
导出的,见 `protocol/`。
|
导出的,见 `protocol/`(2026-11-05 由 0.166.0 二进制重导,见
|
||||||
|
`docs/upstream-166-alignment.md`)。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -734,7 +734,12 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
|||||||
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
|
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
|
||||||
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
|
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
|
||||||
项目的任何文件;同时每个项目进独立的 Docker 网络
|
项目的任何文件;同时每个项目进独立的 Docker 网络
|
||||||
`codex-<项目>`(容器跨项目网络隔离),容器统一加
|
`codex-<项目>`(容器跨项目网络隔离),建网时显式
|
||||||
|
`--subnet` 把子网固定在 172.16.0.0/16 内(/24 按网络名
|
||||||
|
确定性划段),保证落在宿主 iptables 的代理放行范围
|
||||||
|
(172.16.0.0/12)内;2026-11-07 之前建好的范围外旧网
|
||||||
|
(如 codex-smoke-20261107)不会自动纠正,须人工删除后
|
||||||
|
重建。容器统一加
|
||||||
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
|
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
|
||||||
只播种模板的 config.toml(relay 端点与 provider 名,不含
|
只播种模板的 config.toml(relay 端点与 provider 名,不含
|
||||||
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
|
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ honour that today, and this track says so instead of claiming it:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
+235
-52
@@ -67,10 +67,50 @@ Semantics (pinned by tests, not left to each caller):
|
|||||||
|
|
||||||
Persistence notes:
|
Persistence notes:
|
||||||
|
|
||||||
|
- One table, one definition. ``task_attempts`` is defined ONCE
|
||||||
|
below (``TASK_ATTEMPTS_DDL``, aliased as ``SQLITE_DDL`` /
|
||||||
|
``PG_DDL``); ``cloud.attempts`` (the resume-history writer) and
|
||||||
|
``cloud.db.POSTGRES_SCHEMA`` (the fresh-Postgres schema) both
|
||||||
|
import this same string instead of carrying their own copy.
|
||||||
|
The two historical shapes -- main's resume DDL (``kind`` /
|
||||||
|
``interrupted_reason`` / ``checkpoint_round`` / ``result_text``
|
||||||
|
/ ``error_text``) and gap 3's ledger DDL (``outcome`` /
|
||||||
|
``is_defect`` / ``defect_category`` / ``corrects_attempt_no``)
|
||||||
|
-- are superseded by this unified definition, which carries
|
||||||
|
both column groups. The shared columns (``task_id``,
|
||||||
|
``attempt_no``, ``created_at``) had identical meaning and type
|
||||||
|
on both sides, so there is no semantic conflict to arbitrate:
|
||||||
|
the union of the column sets IS the unified schema, with one
|
||||||
|
deliberate relaxation (see below).
|
||||||
|
- Row kinds in one table. A row is a ledger row iff ``outcome``
|
||||||
|
is non-NULL (written by this module, outcome validated in
|
||||||
|
Python before the insert); a row with ``outcome`` NULL is a
|
||||||
|
resume-history row (written by ``cloud.attempts``). The two
|
||||||
|
writers insert disjoint column groups and read by column name,
|
||||||
|
so they share the table without overwriting each other's
|
||||||
|
fields. ``aggregate_attempts`` counts ONLY ledger rows: a
|
||||||
|
resume row (outcome NULL) is skipped, never counted as a
|
||||||
|
defect-free first pass -- otherwise a task that was merely
|
||||||
|
auto-resumed would silently gain a counted attempt.
|
||||||
|
- The one deliberate relaxation: ``outcome`` is nullable in the
|
||||||
|
unified DDL even though the ledger writer always supplies it.
|
||||||
|
A ``NOT NULL`` on ``outcome`` would make the resume writer's
|
||||||
|
insert (which has no outcome to report) fail on a fresh table,
|
||||||
|
so the constraint lives in Python validation here, not in the
|
||||||
|
DDL. ``is_defect`` keeps ``NOT NULL DEFAULT 0`` so a resume
|
||||||
|
row that omits it still reads as "not a defect judgement".
|
||||||
- SQLite: ``ensure_task_attempts_table`` runs
|
- SQLite: ``ensure_task_attempts_table`` runs
|
||||||
``CREATE TABLE IF NOT EXISTS`` -- idempotent, safe to call on
|
``CREATE TABLE IF NOT EXISTS`` with the unified DDL, then
|
||||||
every write, and a no-op on a database that already has the
|
migrates a pre-existing legacy table (old resume shape, gap-3
|
||||||
table (including one that already has rows).
|
ledger shape, or the rehearsal-era 12-column shape) by adding
|
||||||
|
whichever unified columns it lacks. Added columns are always
|
||||||
|
nullable or defaulted, so existing rows are never blocked;
|
||||||
|
SQLite ``ALTER TABLE`` cannot tighten an existing column's
|
||||||
|
nullability, so a migrated table keeps its looser column
|
||||||
|
types -- the column SET and the data are unified, the
|
||||||
|
``NOT NULL`` spelling on fresh tables is not retrofitted.
|
||||||
|
This is a migration to the single canonical definition, not a
|
||||||
|
second definition assembled at runtime.
|
||||||
- Postgres: the production table is NOT created by
|
- Postgres: the production table is NOT created by
|
||||||
``init_postgres_schema`` (deliberately: that schema is covered by
|
``init_postgres_schema`` (deliberately: that schema is covered by
|
||||||
the migrations test asserting ``MIGRATION_TABLES`` equals the
|
the migrations test asserting ``MIGRATION_TABLES`` equals the
|
||||||
@@ -87,7 +127,18 @@ Persistence notes:
|
|||||||
``test_attempt_ledger.py``). Until that manual step runs, the
|
``test_attempt_ledger.py``). Until that manual step runs, the
|
||||||
endpoint treats a missing table exactly like "no ledger rows":
|
endpoint treats a missing table exactly like "no ledger rows":
|
||||||
unknown rates, not an error -- the same fail-soft shape already
|
unknown rates, not an error -- the same fail-soft shape already
|
||||||
used for missing ``cost_records``.
|
used for missing ``cost_records``. The Postgres upgrade path
|
||||||
|
for a legacy-shaped table was verified against a real
|
||||||
|
Postgres 16.15 instance on 2026-11-06 (fresh ``PG_DDL``,
|
||||||
|
three legacy shapes migrated in place, both writers, PK,
|
||||||
|
idempotent ensure; see
|
||||||
|
``verification/codex-task-attempts-pg-proof-20261106.md``).
|
||||||
|
One gap-3-only step relaxes a gap-3 table's ``outcome``
|
||||||
|
from NOT NULL with ``ALTER COLUMN outcome DROP NOT NULL``
|
||||||
|
(see ``ensure_task_attempts_table``); the statement works
|
||||||
|
on Postgres and on SQLite 3.53.0+, and is swallowed by
|
||||||
|
``try/except`` on older SQLite, where the column stays
|
||||||
|
NOT NULL.
|
||||||
- The read path (``GET /metrics-report``) never creates the table:
|
- The read path (``GET /metrics-report``) never creates the table:
|
||||||
a read-only endpoint must not mutate schema. Table creation
|
a read-only endpoint must not mutate schema. Table creation
|
||||||
happens only on the write path, inside ``record_attempt`` /
|
happens only on the write path, inside ``record_attempt`` /
|
||||||
@@ -104,18 +155,15 @@ Persistence notes:
|
|||||||
unknown until the hook is deliberately enabled. The worker hook
|
unknown until the hook is deliberately enabled. The worker hook
|
||||||
always records ``is_defect=False``: it never judges a defect,
|
always records ``is_defect=False``: it never judges a defect,
|
||||||
so enabling it can never turn a transient retry into a defect.
|
so enabling it can never turn a transient retry into a defect.
|
||||||
- Schema evolution note (pre-production, this track):
|
- Schema evolution note (unified schema, this batch):
|
||||||
``corrects_attempt_no`` was added to the DDL after audit gap 3
|
the old resume DDL, the gap-3 ledger DDL, and the rehearsal
|
||||||
but BEFORE either DDL reached production (gap 3 is still an
|
12-column shape are all legacy shapes of the SAME table.
|
||||||
OPEN, unmerged PR), so no production table needs migration:
|
``ensure_task_attempts_table`` migrates any of them to the
|
||||||
fresh tables get the column from the DDL below. A database
|
unified column set in place (add missing columns only, never
|
||||||
created from gap 3's DDL alone (temporary/test databases only)
|
drop or rewrite rows); the read path uses ``SELECT *``
|
||||||
gains the column with one statement --
|
deliberately so rows of any shape still read, and
|
||||||
``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
|
``aggregate_attempts`` treats a row without an outcome as a
|
||||||
INTEGER`` -- which is also the documented step if gap 3's DDL
|
resume row, not as a ledger attempt (see above).
|
||||||
was ever run on production Postgres before this track lands.
|
|
||||||
The read path uses ``SELECT *`` deliberately so a table without
|
|
||||||
the column still reads (its rows aggregate as plain attempts).
|
|
||||||
|
|
||||||
This module imports no ``cloud.*`` module and opens no database
|
This module imports no ``cloud.*`` module and opens no database
|
||||||
connection itself: callers pass a connection in, so the layer rules
|
connection itself: callers pass a connection in, so the layer rules
|
||||||
@@ -130,49 +178,174 @@ TABLE = "task_attempts"
|
|||||||
|
|
||||||
OUTCOMES = frozenset({"success", "failure", "interrupted"})
|
OUTCOMES = frozenset({"success", "failure", "interrupted"})
|
||||||
|
|
||||||
# One DDL serves both backends: every type and the composite key are
|
# THE single canonical definition of task_attempts (unified
|
||||||
# valid in SQLite and in Postgres, and there is no AUTOINCREMENT or
|
# schema, FLAG-1 decision 2026-11-05: "unified schema", rejecting
|
||||||
# identity column for either backend's sequence handling to chase.
|
# both the rehearsal-era runtime column-union workaround and a
|
||||||
# ``corrects_attempt_no`` (NULL = a real attempt row; non-NULL = a
|
# ledger rename). One DDL serves both backends: every type and
|
||||||
# correction row for that attempt_no, never counted as an attempt)
|
# the composite key are valid in SQLite and in Postgres, and
|
||||||
# was added by the lifecycle-hook track; it SUPERSEDES gap 3's DDL
|
# there is no AUTOINCREMENT or identity column for either
|
||||||
# for any production manual step (gap 3 never merged, so no
|
# backend's sequence handling to chase.
|
||||||
# production table exists yet -- see the module docstring).
|
#
|
||||||
SQLITE_DDL = (
|
# Column groups, in order (each group's internal order preserves
|
||||||
|
# its historical DDL order):
|
||||||
|
# - shared: task_id, attempt_no (composite PK), created_at last;
|
||||||
|
# - ledger (gap 3 + lifecycle hook): outcome, is_defect,
|
||||||
|
# defect_category, corrects_attempt_no (NULL = a real attempt
|
||||||
|
# row; non-NULL = a correction row for that attempt_no, never
|
||||||
|
# counted as an attempt);
|
||||||
|
# - resume (main v6): kind, interrupted_reason,
|
||||||
|
# checkpoint_round, result_text, error_text.
|
||||||
|
# ``outcome`` is deliberately nullable (see the module docstring:
|
||||||
|
# the resume writer has no outcome to report, so a NOT NULL here
|
||||||
|
# would break its insert; the ledger writer validates outcome in
|
||||||
|
# Python instead). ``cloud.attempts.CREATE_TABLE_SQL`` and the
|
||||||
|
# ``task_attempts`` entry of ``cloud.db.POSTGRES_SCHEMA`` are
|
||||||
|
# this same string object, imported -- never a second copy.
|
||||||
|
TASK_ATTEMPTS_DDL = (
|
||||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
"CREATE TABLE IF NOT EXISTS task_attempts("
|
||||||
"task_id TEXT NOT NULL, "
|
"task_id TEXT NOT NULL, "
|
||||||
"attempt_no INTEGER NOT NULL, "
|
"attempt_no INTEGER NOT NULL, "
|
||||||
"outcome TEXT NOT NULL, "
|
"outcome TEXT, "
|
||||||
"is_defect INTEGER NOT NULL DEFAULT 0, "
|
"is_defect INTEGER NOT NULL DEFAULT 0, "
|
||||||
"defect_category TEXT, "
|
"defect_category TEXT, "
|
||||||
"created_at DOUBLE PRECISION, "
|
|
||||||
"corrects_attempt_no INTEGER, "
|
"corrects_attempt_no INTEGER, "
|
||||||
|
"kind TEXT, "
|
||||||
|
"interrupted_reason TEXT, "
|
||||||
|
"checkpoint_round INTEGER, "
|
||||||
|
"result_text TEXT, "
|
||||||
|
"error_text TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION, "
|
||||||
"PRIMARY KEY (task_id, attempt_no))"
|
"PRIMARY KEY (task_id, attempt_no))"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SQLITE_DDL = TASK_ATTEMPTS_DDL
|
||||||
|
|
||||||
# Production Postgres: run this verbatim by hand and verify the
|
# Production Postgres: run this verbatim by hand and verify the
|
||||||
# table exists before expecting real rates from a Postgres-backed
|
# table exists before expecting real rates from a Postgres-backed
|
||||||
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
|
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
|
||||||
# the module docstring: migrations coverage must not widen silently).
|
# the module docstring: migrations coverage must not widen silently).
|
||||||
# This is the NEW DDL (with ``corrects_attempt_no``): if gap 3's
|
# For a legacy-shaped table already on Postgres, add whichever
|
||||||
# older DDL was ever run first, follow it with
|
# unified columns are missing (see UNIFIED_COLUMN_TYPES) and
|
||||||
# ``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
|
# relax a NOT NULL outcome if the legacy shape declared one
|
||||||
# INTEGER`` and verify before expecting corrections to work.
|
# (see ensure_task_attempts_table); that Postgres upgrade path
|
||||||
PG_DDL = SQLITE_DDL
|
# was verified against a real Postgres 16.15 instance on
|
||||||
|
# 2026-11-06 -- see verification/
|
||||||
|
# codex-task-attempts-pg-proof-20261106.md.
|
||||||
|
PG_DDL = TASK_ATTEMPTS_DDL
|
||||||
|
|
||||||
# One-line upgrade for a table created from gap 3's DDL (no
|
# Unified column set, in DDL order. The migration in
|
||||||
# ``corrects_attempt_no`` column yet). Valid in both backends.
|
# ``ensure_task_attempts_table`` adds exactly these columns when
|
||||||
|
# missing; the ALTER types below are the migration spellings
|
||||||
|
# (nullable/defaulted, so existing rows are never blocked --
|
||||||
|
# SQLite ALTER cannot add a NOT NULL column without a default
|
||||||
|
# to a table that already has rows, and cannot tighten an
|
||||||
|
# existing column at all).
|
||||||
|
UNIFIED_COLUMNS = (
|
||||||
|
"task_id", "attempt_no", "outcome", "is_defect",
|
||||||
|
"defect_category", "corrects_attempt_no", "kind",
|
||||||
|
"interrupted_reason", "checkpoint_round", "result_text",
|
||||||
|
"error_text", "created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
UNIFIED_COLUMN_TYPES = {
|
||||||
|
"task_id": "TEXT",
|
||||||
|
"attempt_no": "INTEGER",
|
||||||
|
"outcome": "TEXT",
|
||||||
|
"is_defect": "INTEGER DEFAULT 0",
|
||||||
|
"defect_category": "TEXT",
|
||||||
|
"corrects_attempt_no": "INTEGER",
|
||||||
|
"kind": "TEXT",
|
||||||
|
"interrupted_reason": "TEXT",
|
||||||
|
"checkpoint_round": "INTEGER",
|
||||||
|
"result_text": "TEXT",
|
||||||
|
"error_text": "TEXT",
|
||||||
|
"created_at": "DOUBLE PRECISION",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy one-line upgrade for a table created from gap 3's DDL
|
||||||
|
# (no ``corrects_attempt_no`` column yet). Retained for
|
||||||
|
# backward compatibility with existing callers/tests; it is a
|
||||||
|
# single step of the general migration above, not a second
|
||||||
|
# schema definition. Valid in both backends.
|
||||||
OLD_TABLE_ALTER = (
|
OLD_TABLE_ALTER = (
|
||||||
"ALTER TABLE task_attempts "
|
"ALTER TABLE task_attempts "
|
||||||
"ADD COLUMN corrects_attempt_no INTEGER"
|
"ADD COLUMN corrects_attempt_no INTEGER"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_columns(conn, table):
|
||||||
|
"""已有列集合(automations.py 同款口径:SQLite 走 PRAGMA,
|
||||||
|
其它后端异常时回空集、后续 ALTER 逐条 try/except 兜底)。"""
|
||||||
|
try:
|
||||||
|
return {row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(%s)" % table).fetchall()}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_notnull(conn, table):
|
||||||
|
"""列名 -> 是否 NOT NULL(PRAGMA table_info 的 row[3];
|
||||||
|
SQLite 与 PG 适配层含义一致:1 = NOT NULL,0 = 可空;
|
||||||
|
探针失败时回空 dict,调用方按“无需放宽”处理)。"""
|
||||||
|
try:
|
||||||
|
return {row[1]: bool(row[3]) for row in conn.execute(
|
||||||
|
"PRAGMA table_info(%s)" % table).fetchall()}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def ensure_task_attempts_table(conn):
|
def ensure_task_attempts_table(conn):
|
||||||
"""Create the ledger table if absent. Idempotent; does not
|
"""Create the unified table if absent; migrate a legacy
|
||||||
touch existing rows. The caller owns the commit (``record_attempt``
|
table to it if present. Idempotent; does not touch existing
|
||||||
commits; a bare ensure inside a larger transaction does not)."""
|
rows. The caller owns the commit (``record_attempt``
|
||||||
conn.execute(SQLITE_DDL)
|
commits; a bare ensure inside a larger transaction does not).
|
||||||
|
|
||||||
|
Unified schema (FLAG-1, user decision 2026-11-05): there is
|
||||||
|
exactly one ``task_attempts`` definition
|
||||||
|
(``TASK_ATTEMPTS_DDL``). A fresh database gets it verbatim
|
||||||
|
from ``CREATE TABLE IF NOT EXISTS``. A database whose table
|
||||||
|
was built from any legacy shape -- the old resume DDL, the
|
||||||
|
gap-3 ledger DDL, or the rehearsal-era 12-column shape --
|
||||||
|
is migrated in place by adding whichever unified columns it
|
||||||
|
lacks (``UNIFIED_COLUMN_TYPES``); added columns are always
|
||||||
|
nullable or defaulted, so existing rows are never blocked,
|
||||||
|
and no row is dropped or rewritten. A gap-3 table's
|
||||||
|
``outcome`` was created NOT NULL; ensure also relaxes it to
|
||||||
|
nullable (``ALTER COLUMN outcome DROP NOT NULL`` -- works
|
||||||
|
on Postgres and on SQLite 3.53.0+, swallowed on older
|
||||||
|
SQLite), because the resume writer inserts outcome NULL --
|
||||||
|
without the relax every resume write on such a table fails.
|
||||||
|
This replaces the
|
||||||
|
rehearsal-era runtime column-completion workaround: the
|
||||||
|
target here is the single canonical definition, and
|
||||||
|
``cloud.attempts`` / ``cloud.db`` share that same string
|
||||||
|
instead of each carrying a DDL of their own.
|
||||||
|
"""
|
||||||
|
conn.execute(TASK_ATTEMPTS_DDL)
|
||||||
|
existing = _existing_columns(conn, "task_attempts")
|
||||||
|
for col in UNIFIED_COLUMNS:
|
||||||
|
if col not in existing:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE task_attempts ADD COLUMN %s %s"
|
||||||
|
% (col, UNIFIED_COLUMN_TYPES[col]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# gap-3 形态的 outcome 是 NOT NULL,而统一 DDL 要求它可空
|
||||||
|
# (恢复写路不报 outcome,NOT NULL 会使它在 PG 上每次写
|
||||||
|
# 行都报 NotNullViolation)。只在探针报 NOT NULL 时放宽:
|
||||||
|
# ALTER COLUMN outcome DROP NOT NULL 在 Postgres 上生效;
|
||||||
|
# SQLite 3.53.0 起也支持该语法,在此之前不合法、被
|
||||||
|
# try/except 吞掉,gap-3 表仍保留 NOT NULL(恢复写路
|
||||||
|
# 会被 INSERT OR IGNORE 静默丢弃——版本相关边界,见
|
||||||
|
# test_column_notnull_probe_sees_gap3_outcome)。
|
||||||
|
if _column_notnull(conn, "task_attempts").get("outcome"):
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE task_attempts "
|
||||||
|
"ALTER COLUMN outcome DROP NOT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def record_attempt(conn, task_id, outcome, is_defect=False,
|
def record_attempt(conn, task_id, outcome, is_defect=False,
|
||||||
@@ -231,11 +404,11 @@ def record_correction(conn, task_id, attempt_no, is_defect,
|
|||||||
correction row, a target whose outcome is ``interrupted``
|
correction row, a target whose outcome is ``interrupted``
|
||||||
(interrupted rows are never defects, so there is no defect
|
(interrupted rows are never defects, so there is no defect
|
||||||
judgement to correct), and a defect category without a defect
|
judgement to correct), and a defect category without a defect
|
||||||
flag all raise ``ValueError`` and write nothing. On a table
|
flag all raise ``ValueError`` and write nothing. A table
|
||||||
created from gap 3's DDL (no ``corrects_attempt_no`` column),
|
created from a legacy DDL is migrated by ``ensure`` first
|
||||||
the INSERT raises the backend's OperationalError loudly --
|
(above), so the ``corrects_attempt_no`` column is present
|
||||||
run ``OLD_TABLE_ALTER`` first (see the module docstring);
|
before the INSERT runs; a correction must never silently
|
||||||
a correction must never silently degrade into a plain row.
|
degrade into a plain row.
|
||||||
Returns the correction row's own ``attempt_no``.
|
Returns the correction row's own ``attempt_no``.
|
||||||
"""
|
"""
|
||||||
if not task_id:
|
if not task_id:
|
||||||
@@ -284,12 +457,13 @@ def read_attempt_rows(conn):
|
|||||||
OperationalError when the table does not exist yet; the
|
OperationalError when the table does not exist yet; the
|
||||||
endpoint caller treats that as "no ledger", not as a failure.
|
endpoint caller treats that as "no ledger", not as a failure.
|
||||||
|
|
||||||
``SELECT *`` is deliberate: a table created from gap 3's DDL
|
``SELECT *`` is deliberate: a legacy-shaped table may lack
|
||||||
has no ``corrects_attempt_no`` column, and naming that column
|
columns the unified DDL has, and naming a missing column
|
||||||
here would turn those readable rows into an endpoint error.
|
here would turn readable rows into an endpoint error.
|
||||||
With ``*`` the rows still read, and ``aggregate_attempts``
|
With ``*`` the rows still read, and ``aggregate_attempts``
|
||||||
treats a missing ``corrects_attempt_no`` as a plain attempt
|
treats a missing ``corrects_attempt_no`` as a plain attempt
|
||||||
row (see ``_field_optional``).
|
row (see ``_field_optional``) and a missing/NULL ``outcome``
|
||||||
|
as a resume row, not a ledger attempt.
|
||||||
"""
|
"""
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"SELECT * FROM task_attempts "
|
"SELECT * FROM task_attempts "
|
||||||
@@ -323,10 +497,14 @@ def aggregate_attempts(rows):
|
|||||||
|
|
||||||
Interrupted rows are excluded from both counts, and a task
|
Interrupted rows are excluded from both counts, and a task
|
||||||
with only interrupted rows is omitted entirely (no counted
|
with only interrupted rows is omitted entirely (no counted
|
||||||
attempt => its rates stay unknown, never 0). Correction rows
|
attempt => its rates stay unknown, never 0). Resume rows
|
||||||
(``corrects_attempt_no`` set) are excluded from the attempt
|
(``outcome`` NULL -- written by ``cloud.attempts``, which
|
||||||
count and instead OVERWRITE the defect judgement of their
|
records an interrupted attempt's history, not a ledger
|
||||||
target attempt, in attempt order, last-wins: a task's
|
outcome) are likewise excluded: they are not ledger attempts
|
||||||
|
and must never inflate a task's counted attempts. Correction
|
||||||
|
rows (``corrects_attempt_no`` set) are excluded from the
|
||||||
|
attempt count and instead OVERWRITE the defect judgement of
|
||||||
|
their target attempt, in attempt order, last-wins: a task's
|
||||||
``defects`` is the number of its counted attempts whose
|
``defects`` is the number of its counted attempts whose
|
||||||
judgement AFTER corrections is a defect, and its
|
judgement AFTER corrections is a defect, and its
|
||||||
``defect_category`` is the category of its FIRST defect
|
``defect_category`` is the category of its FIRST defect
|
||||||
@@ -351,7 +529,12 @@ def aggregate_attempts(rows):
|
|||||||
bool(_field(row, "is_defect")),
|
bool(_field(row, "is_defect")),
|
||||||
_field(row, "defect_category")]
|
_field(row, "defect_category")]
|
||||||
continue
|
continue
|
||||||
if _field(row, "outcome") == "interrupted":
|
outcome = _field_optional(row, "outcome")
|
||||||
|
if outcome is None:
|
||||||
|
# Resume-history row (cloud.attempts): no ledger
|
||||||
|
# outcome, so not a counted ledger attempt.
|
||||||
|
continue
|
||||||
|
if outcome == "interrupted":
|
||||||
continue
|
continue
|
||||||
entries = judged.setdefault(task_id, {})
|
entries = judged.setdefault(task_id, {})
|
||||||
entries[_field(row, "attempt_no")] = [
|
entries[_field(row, "attempt_no")] = [
|
||||||
|
|||||||
+18
-14
@@ -31,7 +31,13 @@ host.py、不改 codex 线程恢复:
|
|||||||
result_text / error_text 各截断 2000 字。记账与回 queued
|
result_text / error_text 各截断 2000 字。记账与回 queued
|
||||||
在同一事务里,记账失败不单独炸恢复循环(外层已兜底),
|
在同一事务里,记账失败不单独炸恢复循环(外层已兜底),
|
||||||
但同一 (task_id, attempt_no) 重复记时 INSERT OR IGNORE
|
但同一 (task_id, attempt_no) 重复记时 INSERT OR IGNORE
|
||||||
保留第一次,不覆盖。
|
保留第一次,不覆盖。表定义是统一 schema(FLAG-1,
|
||||||
|
2026-11-05):本模块不自带一份 DDL,CREATE_TABLE_SQL
|
||||||
|
与建表/迁移均取自 ``cloud.attempt_ledger`` 的
|
||||||
|
``TASK_ATTEMPTS_DDL`` / ``ensure_task_attempts_table``,
|
||||||
|
与台账写路径共用同一定义;本模块写的行 outcome 为
|
||||||
|
NULL(恢复历史行,非台账计数行,见 attempt_ledger 对
|
||||||
|
aggregate 的口径)。
|
||||||
② 重跑前言:非 goal 平台全新重排的任务在认领时,worker
|
② 重跑前言:非 goal 平台全新重排的任务在认领时,worker
|
||||||
在内存副本的 prompt 前贴一段上次尝试说明(原 prompt
|
在内存副本的 prompt 前贴一段上次尝试说明(原 prompt
|
||||||
不落库,与项目/待办/记忆注入同款只改内存副本),内容
|
不落库,与项目/待办/记忆注入同款只改内存副本),内容
|
||||||
@@ -63,25 +69,23 @@ host.py、不改 codex 线程恢复:
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from cloud.attempt_ledger import TASK_ATTEMPTS_DDL
|
||||||
|
from cloud.attempt_ledger import ensure_task_attempts_table as _ensure_unified
|
||||||
|
|
||||||
ATTEMPT_TEXT_CHARS = 2000
|
ATTEMPT_TEXT_CHARS = 2000
|
||||||
PREAMBLE_TEXT_CHARS = 1000
|
PREAMBLE_TEXT_CHARS = 1000
|
||||||
|
|
||||||
CREATE_TABLE_SQL = (
|
# 统一 schema:与台账共用同一定义,绝不在此另写一份 DDL
|
||||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
# (历史教训:两份 DDL 同名异构,CREATE TABLE IF NOT EXISTS
|
||||||
"task_id TEXT, "
|
# 使后建的一方成空操作、写行报缺列)。别名保留,供旧调用
|
||||||
"attempt_no INTEGER, "
|
# 方按名取定义;建表与老表迁移一律走台账的 ensure。
|
||||||
"kind TEXT, "
|
CREATE_TABLE_SQL = TASK_ATTEMPTS_DDL
|
||||||
"interrupted_reason TEXT, "
|
|
||||||
"checkpoint_round INTEGER, "
|
|
||||||
"result_text TEXT, "
|
|
||||||
"error_text TEXT, "
|
|
||||||
"created_at DOUBLE PRECISION, "
|
|
||||||
"PRIMARY KEY(task_id, attempt_no))")
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_attempts_table(conn):
|
def ensure_attempts_table(conn):
|
||||||
"""幂等建尝试账本表(server/worker 两支共用同一 DDL)。"""
|
"""幂等建尝试账本表(server/worker 两支共用同一 DDL):
|
||||||
conn.execute(CREATE_TABLE_SQL)
|
新库建统一表,老库迁移到统一列集(见 attempt_ledger)。"""
|
||||||
|
_ensure_unified(conn)
|
||||||
|
|
||||||
|
|
||||||
def _trunc(text, limit):
|
def _trunc(text, limit):
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
build_scorecard(..., baseline_head=None) 的 baseline_head 是机器字段,
|
build_scorecard(..., baseline_head=None) 的 baseline_head 是机器字段,
|
||||||
只能由调用方显式传入当前 main 头;本模块绝不在生产代码里探测
|
只能由调用方显式传入当前 main 头;本模块绝不在生产代码里探测
|
||||||
版本控制状态、绝不读版本控制目录、绝不编造一个头。
|
版本控制状态、绝不读版本控制目录、绝不编造一个头。
|
||||||
|
接受任意字符串、原样记录,不做任何格式校验(不要求形如
|
||||||
|
commit hash);本模块不验证该值是否真实、可信,可信 main 头
|
||||||
|
的来源仍是调用方责任。
|
||||||
不传入(或传入空串)时 baseline_head 为 None,并在 missing_items
|
不传入(或传入空串)时 baseline_head 为 None,并在 missing_items
|
||||||
里记 baseline_head_missing,不把缺失读成“与当前一致”。
|
里记 baseline_head_missing,不把缺失读成“与当前一致”。
|
||||||
check_baseline_stale(scorecard, current_main_head) 是唯一的漂移判定:
|
check_baseline_stale(scorecard, current_main_head) 是唯一的漂移判定:
|
||||||
@@ -916,6 +919,16 @@ def check_baseline_stale(scorecard, current_main_head):
|
|||||||
参数全部由调用方传入:scorecard 是 build_scorecard 的输出 dict,
|
参数全部由调用方传入:scorecard 是 build_scorecard 的输出 dict,
|
||||||
current_main_head 是调用方当前确认的 main 头。本函数不在生产
|
current_main_head 是调用方当前确认的 main 头。本函数不在生产
|
||||||
代码里探测版本控制状态,也不读版本控制目录。
|
代码里探测版本控制状态,也不读版本控制目录。
|
||||||
|
调用方契约(gapfill2 H4,只写文档、不改行为):
|
||||||
|
current_main_head 接受任意字符串、原样比对,不做任何格式
|
||||||
|
校验(不要求形如 commit hash,不校验长度、字符集、前缀);
|
||||||
|
本函数不验证该值是否真实、可信、是否确为 main 头——可信
|
||||||
|
main 头的来源仍是调用方责任。空串先归一化为 None,与未
|
||||||
|
传入同等处理。注意端点层与函数层的区别:评分卡端点在缺失
|
||||||
|
或空串时根本不调用本函数(不做判定、不附加判定字段);
|
||||||
|
调用方若直接调本函数且 current_main_head 缺失,本函数按
|
||||||
|
失败关闭返回 stale=True(见下三种情形之二),那是函数层
|
||||||
|
面的判定结果,不是端点行为。
|
||||||
|
|
||||||
返回 dict(调用方凭 stale / usable_as_pass_basis 判断,绝不能
|
返回 dict(调用方凭 stale / usable_as_pass_basis 判断,绝不能
|
||||||
只看旧卡的 overall / overall_pass):
|
只看旧卡的 overall / overall_pass):
|
||||||
@@ -1068,7 +1081,8 @@ def build_scorecard(evidence, thresholds=None, rubric_version=None,
|
|||||||
(user_side_items 可选占位),product=build_product_scorecard
|
(user_side_items 可选占位),product=build_product_scorecard
|
||||||
(无真实产品轮证据时恒为 not_pass)。新增键不改变旧字段的值。
|
(无真实产品轮证据时恒为 not_pass)。新增键不改变旧字段的值。
|
||||||
另新增 rubric_version(见 cloud/scorecard_rubric.py)、
|
另新增 rubric_version(见 cloud/scorecard_rubric.py)、
|
||||||
baseline_head(机器字段,见 check_baseline_stale)、
|
baseline_head(机器字段,见 check_baseline_stale;接受任意
|
||||||
|
字符串原样记录、不做格式校验,空串按未传入处理)、
|
||||||
evidence_ledger_head 与 evidence_row_ids(见
|
evidence_ledger_head 与 evidence_row_ids(见
|
||||||
compute_evidence_ledger)三个机制字段;无证据行时头为 None。
|
compute_evidence_ledger)三个机制字段;无证据行时头为 None。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Bridge to the real local `codex app-server`.
|
||||||
|
*
|
||||||
|
* The desktop app talks to the Codex app server over stdio JSON-RPC; the main
|
||||||
|
* process owns that child process. In a browser we cannot spawn anything, so
|
||||||
|
* this module owns the child on the Node side and exposes a plain
|
||||||
|
* request/response surface over HTTP.
|
||||||
|
*
|
||||||
|
* The binary ships inside the ChatGPT desktop app:
|
||||||
|
* ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex
|
||||||
|
*
|
||||||
|
* On Linux (this repo's server target) the same binary is found via
|
||||||
|
* $CODEX_BIN (same env name host.py uses), /opt/hatch-image/bin/codex, or
|
||||||
|
* PATH. $CODEX_APP_SERVER_BIN keeps the highest priority.
|
||||||
|
*
|
||||||
|
* Nothing here reaches the network: the app server reads `~/.codex` and runs
|
||||||
|
* locally. Methods that would require a remote model call are only invoked when
|
||||||
|
* the UI explicitly asks for them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// A6: Linux candidates first after the explicit override, macOS paths kept.
|
||||||
|
const CANDIDATE_BINARIES = [
|
||||||
|
process.env.CODEX_APP_SERVER_BIN,
|
||||||
|
process.env.CODEX_BIN,
|
||||||
|
"/opt/hatch-image/bin/codex",
|
||||||
|
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex",
|
||||||
|
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
/** Search PATH for an executable `codex` (last-resort candidate, A6). */
|
||||||
|
function findCodexOnPath() {
|
||||||
|
const sep = process.platform === "win32" ? ";" : ":";
|
||||||
|
for (const dir of (process.env.PATH || "").split(sep)) {
|
||||||
|
if (!dir) continue;
|
||||||
|
const p = path.join(dir, "codex");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
||||||
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCodexBinary() {
|
||||||
|
for (const p of CANDIDATE_BINARIES) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p) && fs.statSync(p).isFile()) return p;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findCodexOnPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay configuration. The web build talks to an OpenAI-compatible relay
|
||||||
|
* instead of a ChatGPT account, so the app server is started with per-process
|
||||||
|
* `-c` overrides. Nothing is written to ~/.codex/config.toml — the desktop app
|
||||||
|
* keeps its own settings and the override dies with this process.
|
||||||
|
*
|
||||||
|
* Shape: { providerId, providerName, baseUrl, apiKey, envKey, model, wireApi }
|
||||||
|
*/
|
||||||
|
export function loadRelayConfig() {
|
||||||
|
const file = process.env.CODEX_WEB_RELAY_CONFIG || path.join(HERE, "relay-config.json");
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
return cfg && cfg.baseUrl ? cfg : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate relay config into `codex app-server` CLI overrides. */
|
||||||
|
export function relayArgs(cfg) {
|
||||||
|
if (!cfg) return [];
|
||||||
|
const id = cfg.providerId || "relay";
|
||||||
|
const args = [
|
||||||
|
"-c", `model_provider=${JSON.stringify(id)}`,
|
||||||
|
"-c", `model=${JSON.stringify(cfg.model)}`,
|
||||||
|
"-c", `preferred_auth_method="apikey"`,
|
||||||
|
"-c", `model_providers.${id}.name=${JSON.stringify(cfg.providerName || id)}`,
|
||||||
|
"-c", `model_providers.${id}.base_url=${JSON.stringify(cfg.baseUrl)}`,
|
||||||
|
"-c", `model_providers.${id}.wire_api=${JSON.stringify(cfg.wireApi || "responses")}`,
|
||||||
|
"-c", `model_providers.${id}.env_key=${JSON.stringify(cfg.envKey || "RELAY_API_KEY")}`,
|
||||||
|
// Everything that would talk to OpenAI's backend (plugin catalog, account
|
||||||
|
// endpoints, telemetry) is answered by the mock backend served by the web
|
||||||
|
// host itself: well-formed local JSON, no request ever leaves the machine,
|
||||||
|
// and callers see success instead of connection errors.
|
||||||
|
"-c", `chatgpt_base_url=${JSON.stringify(cfg.chatgptBaseUrl || mockBackendUrl())}`,
|
||||||
|
];
|
||||||
|
// Optional safety overrides. Left out by default so the app server keeps
|
||||||
|
// whatever ~/.codex/config.toml says; set them in relay-config.json to pin
|
||||||
|
// the sandbox for a shared or unattended browser build.
|
||||||
|
if (cfg.approvalPolicy) args.push("-c", `approval_policy=${JSON.stringify(cfg.approvalPolicy)}`);
|
||||||
|
if (cfg.sandboxMode) args.push("-c", `sandbox_mode=${JSON.stringify(cfg.sandboxMode)}`);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The web host serves the ChatGPT-backend stand-in under /mock. */
|
||||||
|
function mockBackendUrl() {
|
||||||
|
const port = process.env.CODEX_WEB_PORT || "8095";
|
||||||
|
return `http://127.0.0.1:${port}/mock`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Methods the app server can leave unanswered when the surrounding desktop
|
||||||
|
* infrastructure is absent. MCP discovery is the only one today: with no MCP
|
||||||
|
* servers configured there is nothing to enumerate, and the request simply
|
||||||
|
* never completes. Answer it locally after a short grace period.
|
||||||
|
*/
|
||||||
|
const METHOD_TIMEOUT_MS = { "mcpServerStatus/list": 6_000 };
|
||||||
|
const METHOD_FALLBACKS = { "mcpServerStatus/list": { data: [] } };
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ A1–A4
|
||||||
|
// Emulation for methods the vendored UI calls but codex 0.164.0-alpha.9 does
|
||||||
|
// not implement (verified: the binary answers them with `unknown variant`).
|
||||||
|
// Only the seven method names listed here are intercepted; every other
|
||||||
|
// method passes through to the real app server untouched.
|
||||||
|
//
|
||||||
|
// A1 thread/stop -> turn/interrupt (the Codex App stop button is
|
||||||
|
// exactly a turn/interrupt; host.py confirms this).
|
||||||
|
// A2 thread/rollback -> thread/revert (exists in this binary).
|
||||||
|
// A3 thread/startAeon -> thread/start. Aeon is an experimental mode with
|
||||||
|
// no entry point in this binary, so this is a
|
||||||
|
// downgrade to an ordinary start, not Aeon.
|
||||||
|
// A4 interactive/* -> the interactive-sessions surface does not exist
|
||||||
|
// in this binary. *list methods answer with empty
|
||||||
|
// lists (no sessions are fabricated — the UI shows
|
||||||
|
// "no live sessions"); upload / sandbox read answer
|
||||||
|
// with a structured -32601 method-not-found error
|
||||||
|
// instead of fake data. (Per docs/codex-native-ui-
|
||||||
|
// alignment.md A4: list 类回空列表.)
|
||||||
|
const EMULATED_REDIRECTS = {
|
||||||
|
"thread/stop": "turn/interrupt",
|
||||||
|
"thread/rollback": "thread/revert",
|
||||||
|
"thread/startAeon": "thread/start",
|
||||||
|
};
|
||||||
|
const INTERACTIVE_EMPTY_LIST_METHODS = new Set([
|
||||||
|
"interactive/liveSessions/list",
|
||||||
|
"interactive/sessionSandbox/list",
|
||||||
|
]);
|
||||||
|
const INTERACTIVE_UNAVAILABLE_METHODS = new Set([
|
||||||
|
"interactive/session/upload",
|
||||||
|
"interactive/sessionSandbox/read",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class AppServerBridge {
|
||||||
|
constructor({ log = console, onNotification = null } = {}) {
|
||||||
|
this.log = log;
|
||||||
|
this.onNotification = onNotification;
|
||||||
|
this.child = null;
|
||||||
|
this.seq = 0;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.buffer = "";
|
||||||
|
this.ready = null;
|
||||||
|
this.notifications = [];
|
||||||
|
this.waiter = null;
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get binary() {
|
||||||
|
return findCodexBinary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn the app server (once) and complete the JSON-RPC handshake. */
|
||||||
|
async ensure() {
|
||||||
|
if (this.ready) return this.ready;
|
||||||
|
|
||||||
|
this.ready = (async () => {
|
||||||
|
const bin = this.binary;
|
||||||
|
if (!bin) throw new Error("codex binary not found");
|
||||||
|
|
||||||
|
const relay = loadRelayConfig();
|
||||||
|
const args = ["app-server", ...relayArgs(relay)];
|
||||||
|
const env = { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "error" };
|
||||||
|
if (relay?.apiKey) env[relay.envKey || "RELAY_API_KEY"] = relay.apiKey;
|
||||||
|
|
||||||
|
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
||||||
|
this.child = child;
|
||||||
|
if (relay) {
|
||||||
|
this.log.log?.(`[app-server] relay ${relay.baseUrl} model=${relay.model}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => this.#onStdout(chunk));
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
// Full, untruncated stderr for diagnosis; ANSI colour codes included.
|
||||||
|
this.stderrFile = fs.createWriteStream("/tmp/codex-appserver-stderr.log", { flags: "a" });
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = String(chunk);
|
||||||
|
this.stderrFile?.write(text);
|
||||||
|
const clean = text.trim();
|
||||||
|
if (clean && !/unrecognized configuration settings|is ignored/.test(clean)) {
|
||||||
|
this.lastError = clean.slice(0, 400);
|
||||||
|
// Backend-shape mismatches surface here (the mock backend answers
|
||||||
|
// every path; a parse complaint means a shape to improve).
|
||||||
|
this.log.warn?.(`[app-server] ${clean.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
this.log.warn?.(`[app-server] exited code=${code} signal=${signal}`);
|
||||||
|
this.child = null;
|
||||||
|
this.ready = null;
|
||||||
|
for (const { reject } of this.pending.values()) {
|
||||||
|
reject(new Error("app-server exited"));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.request("initialize", {
|
||||||
|
clientInfo: { name: "codex-web", title: "Codex Web", version: "1.0.0" },
|
||||||
|
// The renderer calls `collaborationMode/list` and friends, which the
|
||||||
|
// server rejects with -32600 unless the client opts into the
|
||||||
|
// experimental surface during the handshake.
|
||||||
|
capabilities: { experimentalApi: true },
|
||||||
|
});
|
||||||
|
this.log.log?.("[app-server] ready");
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.ready.catch((err) => {
|
||||||
|
this.log.error?.(`[app-server] failed: ${err.message}`);
|
||||||
|
this.ready = null;
|
||||||
|
});
|
||||||
|
return this.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onStdout(chunk) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
let nl;
|
||||||
|
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = this.buffer.slice(0, nl).trim();
|
||||||
|
this.buffer = this.buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.id != null && this.pending.has(msg.id) && !msg.method) {
|
||||||
|
const { resolve, reject } = this.pending.get(msg.id);
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
msg.error ? reject(Object.assign(new Error(msg.error.message ?? "rpc error"), { rpc: msg.error })) : resolve(msg.result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.method) {
|
||||||
|
// Server-initiated request (has id) or notification (no id). Both go on
|
||||||
|
// the same stream; the renderer distinguishes them by `id`.
|
||||||
|
this.notifications.push(msg);
|
||||||
|
if (this.notifications.length > 2000) this.notifications.shift();
|
||||||
|
if (this.onNotification) {
|
||||||
|
try {
|
||||||
|
this.onNotification(msg);
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn?.(`[app-server] onNotification handler failed: ${err?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the pending long-poll, if any. */
|
||||||
|
#wake() {
|
||||||
|
const waiter = this.waiter;
|
||||||
|
if (!waiter) return;
|
||||||
|
this.waiter = null;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiter.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until at least one notification is queued (or the timeout fires), then
|
||||||
|
* hand back everything that accumulated. Long-polling keeps the renderer in
|
||||||
|
* step with the app server without a websocket.
|
||||||
|
*/
|
||||||
|
waitForNotifications(timeoutMs = 15_000) {
|
||||||
|
if (this.notifications.length) return Promise.resolve(this.drainNotifications());
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.waiter = null;
|
||||||
|
resolve(this.drainNotifications());
|
||||||
|
}, timeoutMs);
|
||||||
|
this.waiter = { resolve: () => resolve(this.drainNotifications()), timer };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request(method, params) {
|
||||||
|
// A1–A3: redirect to the real method that implements the same action.
|
||||||
|
// Only these three names are redirected; anything else falls through.
|
||||||
|
if (method in EMULATED_REDIRECTS) {
|
||||||
|
const target = EMULATED_REDIRECTS[method];
|
||||||
|
this.log.log?.(`[app-server] emulated ${method} -> ${target}`);
|
||||||
|
return this.ensure().then(() => this.request(target, params));
|
||||||
|
}
|
||||||
|
// A4: no live sessions exist in this binary — answer empty, fabricate none.
|
||||||
|
// The three list-shaped fields cover the shapes the renderer dereferences;
|
||||||
|
// all three are empty.
|
||||||
|
if (INTERACTIVE_EMPTY_LIST_METHODS.has(method)) {
|
||||||
|
return Promise.resolve({ sessions: [], data: [], items: [] });
|
||||||
|
}
|
||||||
|
// A4: interactive session upload / sandbox access is unavailable in
|
||||||
|
// codex 0.164.0-alpha.9. Structured -32601, exactly as the app server
|
||||||
|
// reports an unknown method — the renderer surfaces it, nothing is faked.
|
||||||
|
if (INTERACTIVE_UNAVAILABLE_METHODS.has(method)) {
|
||||||
|
const error = new Error(
|
||||||
|
`method not found: ${method} (interactive sessions not available in codex 0.164.0-alpha.9)`,
|
||||||
|
);
|
||||||
|
error.code = -32601;
|
||||||
|
error.rpc = { code: -32601, message: error.message };
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (!this.child) return Promise.reject(new Error("app-server not running"));
|
||||||
|
const id = ++this.seq;
|
||||||
|
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
||||||
|
const timeoutMs = METHOD_TIMEOUT_MS[method] ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
// A few methods never answer in a headless build (MCP discovery has no
|
||||||
|
// servers to enumerate). Resolve with the truthful empty result instead
|
||||||
|
// of leaving the renderer's query spinning for the full timeout.
|
||||||
|
if (method in METHOD_FALLBACKS) {
|
||||||
|
resolve(METHOD_FALLBACKS[method]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`app-server timeout: ${method}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
reject: (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.child.stdin.write(payload + "\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain queued server notifications (used by the polling endpoint). */
|
||||||
|
drainNotifications() {
|
||||||
|
const out = this.notifications;
|
||||||
|
this.notifications = [];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.child?.kill();
|
||||||
|
this.child = null;
|
||||||
|
this.ready = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
[
|
||||||
|
"account/bedrock/checkGovCloudRequirements",
|
||||||
|
"account/bedrock/discover",
|
||||||
|
"account/bedrock/setup",
|
||||||
|
"account/gatewayOAuth/cancel",
|
||||||
|
"account/gatewayOAuth/login",
|
||||||
|
"account/gatewayOAuth/read",
|
||||||
|
"account/login/cancel",
|
||||||
|
"account/login/start",
|
||||||
|
"account/logout",
|
||||||
|
"account/rateLimitResetCredit/consume",
|
||||||
|
"account/rateLimits/read",
|
||||||
|
"account/read",
|
||||||
|
"account/sendAddCreditsNudgeEmail",
|
||||||
|
"account/usage/read",
|
||||||
|
"account/workspaceMessages/read",
|
||||||
|
"app/installed",
|
||||||
|
"app/list",
|
||||||
|
"app/read",
|
||||||
|
"collaborationMode/list",
|
||||||
|
"command/exec",
|
||||||
|
"command/exec/resize",
|
||||||
|
"command/exec/terminate",
|
||||||
|
"command/exec/write",
|
||||||
|
"config/batchWrite",
|
||||||
|
"config/mcpServer/reload",
|
||||||
|
"config/read",
|
||||||
|
"config/value/write",
|
||||||
|
"configRequirements/read",
|
||||||
|
"environment/add",
|
||||||
|
"environment/info",
|
||||||
|
"environment/status",
|
||||||
|
"experimentalFeature/enablement/set",
|
||||||
|
"experimentalFeature/list",
|
||||||
|
"externalAgentConfig/detect",
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
"externalAgentConfig/import/readHistories",
|
||||||
|
"externalAgentConfig/import/recordHistory",
|
||||||
|
"feedback/upload",
|
||||||
|
"fs/copy",
|
||||||
|
"fs/createDirectory",
|
||||||
|
"fs/getMetadata",
|
||||||
|
"fs/readDirectory",
|
||||||
|
"fs/readFile",
|
||||||
|
"fs/remove",
|
||||||
|
"fs/unwatch",
|
||||||
|
"fs/watch",
|
||||||
|
"fs/writeFile",
|
||||||
|
"fuzzyFileSearch/sessionStart",
|
||||||
|
"fuzzyFileSearch/sessionStop",
|
||||||
|
"fuzzyFileSearch/sessionUpdate",
|
||||||
|
"hooks/list",
|
||||||
|
"interactive/liveSessions/list",
|
||||||
|
"interactive/session/upload",
|
||||||
|
"interactive/sessionSandbox/list",
|
||||||
|
"interactive/sessionSandbox/read",
|
||||||
|
"marketplace/add",
|
||||||
|
"marketplace/remove",
|
||||||
|
"marketplace/upgrade",
|
||||||
|
"mcpServer/event/stream/start",
|
||||||
|
"mcpServer/event/stream/stop",
|
||||||
|
"mcpServer/oauth/login",
|
||||||
|
"mcpServer/resource/read",
|
||||||
|
"mcpServer/tool/call",
|
||||||
|
"mcpServerStatus/list",
|
||||||
|
"memory/reset",
|
||||||
|
"memory/status",
|
||||||
|
"mock/experimentalMethod",
|
||||||
|
"model/list",
|
||||||
|
"modelProvider/capabilities/read",
|
||||||
|
"permissionProfile/list",
|
||||||
|
"plugin/install",
|
||||||
|
"plugin/installed",
|
||||||
|
"plugin/list",
|
||||||
|
"plugin/read",
|
||||||
|
"plugin/reconcile",
|
||||||
|
"plugin/search",
|
||||||
|
"plugin/share/checkout",
|
||||||
|
"plugin/share/delete",
|
||||||
|
"plugin/share/list",
|
||||||
|
"plugin/share/save",
|
||||||
|
"plugin/share/updateTargets",
|
||||||
|
"plugin/skill/read",
|
||||||
|
"plugin/uninstall",
|
||||||
|
"process/kill",
|
||||||
|
"process/resizePty",
|
||||||
|
"process/spawn",
|
||||||
|
"process/writeStdin",
|
||||||
|
"project/create",
|
||||||
|
"project/delete",
|
||||||
|
"project/import",
|
||||||
|
"project/list",
|
||||||
|
"project/move",
|
||||||
|
"project/read",
|
||||||
|
"project/update",
|
||||||
|
"remoteControl/client/list",
|
||||||
|
"remoteControl/client/revoke",
|
||||||
|
"remoteControl/disable",
|
||||||
|
"remoteControl/enable",
|
||||||
|
"remoteControl/pairing/start",
|
||||||
|
"remoteControl/pairing/status",
|
||||||
|
"remoteControl/status/read",
|
||||||
|
"review/start",
|
||||||
|
"rollout/compress",
|
||||||
|
"server/diagnostics",
|
||||||
|
"skills/config/write",
|
||||||
|
"skills/extraRoots/set",
|
||||||
|
"skills/list",
|
||||||
|
"thread/approveGuardianDeniedAction",
|
||||||
|
"thread/archive",
|
||||||
|
"thread/attachment/add",
|
||||||
|
"thread/attachment/list",
|
||||||
|
"thread/attachment/remove",
|
||||||
|
"thread/attachmentOwner/list",
|
||||||
|
"thread/backgroundTerminals/clean",
|
||||||
|
"thread/backgroundTerminals/list",
|
||||||
|
"thread/backgroundTerminals/terminate",
|
||||||
|
"thread/compact/start",
|
||||||
|
"thread/decrement_elicitation",
|
||||||
|
"thread/delete",
|
||||||
|
"thread/fork",
|
||||||
|
"thread/goal/clear",
|
||||||
|
"thread/goal/get",
|
||||||
|
"thread/goal/set",
|
||||||
|
"thread/increment_elicitation",
|
||||||
|
"thread/inject_items",
|
||||||
|
"thread/items/list",
|
||||||
|
"thread/items/read",
|
||||||
|
"thread/list",
|
||||||
|
"thread/loaded/list",
|
||||||
|
"thread/memoryMode/set",
|
||||||
|
"thread/metadata/update",
|
||||||
|
"thread/name/set",
|
||||||
|
"thread/prediction/request",
|
||||||
|
"thread/queue/add",
|
||||||
|
"thread/queue/delete",
|
||||||
|
"thread/queue/list",
|
||||||
|
"thread/queue/reorder",
|
||||||
|
"thread/queue/start",
|
||||||
|
"thread/queue/update",
|
||||||
|
"thread/read",
|
||||||
|
"thread/readState/update",
|
||||||
|
"thread/realtime/appendAudio",
|
||||||
|
"thread/realtime/appendSpeech",
|
||||||
|
"thread/realtime/appendText",
|
||||||
|
"thread/realtime/listVoices",
|
||||||
|
"thread/realtime/start",
|
||||||
|
"thread/realtime/stop",
|
||||||
|
"thread/resume",
|
||||||
|
"thread/revert",
|
||||||
|
"thread/rollback",
|
||||||
|
"thread/sample",
|
||||||
|
"thread/sample/cancel",
|
||||||
|
"thread/search",
|
||||||
|
"thread/searchOccurrences",
|
||||||
|
"thread/section/move",
|
||||||
|
"thread/settings/update",
|
||||||
|
"thread/shellCommand",
|
||||||
|
"thread/start",
|
||||||
|
"thread/startAeon",
|
||||||
|
"thread/stop",
|
||||||
|
"thread/timeline/list",
|
||||||
|
"thread/turns/list",
|
||||||
|
"thread/unarchive",
|
||||||
|
"thread/unsubscribe",
|
||||||
|
"threadSection/create",
|
||||||
|
"threadSection/delete",
|
||||||
|
"threadSection/list",
|
||||||
|
"threadSection/update",
|
||||||
|
"turn/interrupt",
|
||||||
|
"turn/settings/update",
|
||||||
|
"turn/start",
|
||||||
|
"turn/steer",
|
||||||
|
"userVerification/cancel",
|
||||||
|
"userVerification/delete",
|
||||||
|
"userVerification/enroll",
|
||||||
|
"userVerification/status",
|
||||||
|
"userVerification/verify",
|
||||||
|
"windowsSandbox/readiness",
|
||||||
|
"windowsSandbox/setupStart"
|
||||||
|
]
|
||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Load the local web host in the headless browser, capture console + exceptions,
|
||||||
|
* then dump the resulting page state and a screenshot.
|
||||||
|
*
|
||||||
|
* node host/cdp-load.mjs --port 9333 --url http://127.0.0.1:8095/ --out /tmp/shot.png
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const argOf = (name, fallback) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||||
|
};
|
||||||
|
const PORT = argOf("--port", "9333");
|
||||||
|
const URL_ = argOf("--url", "http://127.0.0.1:8095/");
|
||||||
|
const OUT = argOf("--out", "/tmp/codexweb.png");
|
||||||
|
const SETTLE = Number(argOf("--settle", "6000"));
|
||||||
|
|
||||||
|
const version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json();
|
||||||
|
const ws = new WebSocket(version.webSocketDebuggerUrl);
|
||||||
|
let seq = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
const send = (method, params = {}, sessionId) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const id = ++seq;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
const msg = { id, method, params };
|
||||||
|
if (sessionId) msg.sessionId = sessionId;
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("message", (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.method) {
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled") {
|
||||||
|
const text = (msg.params.args || [])
|
||||||
|
.map((a) => a.value ?? a.description ?? a.unserializableValue ?? "")
|
||||||
|
.join(" ")
|
||||||
|
.slice(0, 600);
|
||||||
|
events.push(`[${msg.params.type}] ${text}`);
|
||||||
|
} else if (msg.method === "Runtime.exceptionThrown") {
|
||||||
|
const d = msg.params.exceptionDetails;
|
||||||
|
events.push(`[EXCEPTION] ${d.exception?.description ?? d.text}`.slice(0, 2200));
|
||||||
|
} else if (msg.method === "Log.entryAdded") {
|
||||||
|
const e = msg.params.entry;
|
||||||
|
events.push(`[log:${e.level}] ${e.text}`.slice(0, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!msg.id || !pending.has(msg.id)) return;
|
||||||
|
const { resolve, reject } = pending.get(msg.id);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
msg.error ? reject(new Error(JSON.stringify(msg.error).slice(0, 400))) : resolve(msg.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => ws.addEventListener("open", r, { once: true }));
|
||||||
|
|
||||||
|
const { targetInfos } = await send("Target.getTargets");
|
||||||
|
const pages = targetInfos.filter((t) => t.type === "page");
|
||||||
|
const main = pages.find((t) => t.url.includes("127.0.0.1")) ?? pages[0];
|
||||||
|
if (!main) {
|
||||||
|
console.error("no page target");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await send("Target.attachToTarget", { targetId: main.targetId, flatten: true });
|
||||||
|
await send("Page.enable", {}, sessionId);
|
||||||
|
await send("Runtime.enable", {}, sessionId);
|
||||||
|
await send("Log.enable", {}, sessionId);
|
||||||
|
|
||||||
|
await send("Page.navigate", { url: URL_ }, sessionId);
|
||||||
|
// Runtime.enable replays the previous page's console buffer; drop it so the
|
||||||
|
// report only covers this load.
|
||||||
|
events.length = 0;
|
||||||
|
await new Promise((r) => setTimeout(r, SETTLE));
|
||||||
|
|
||||||
|
const state = await send(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const root = document.querySelector('#root');
|
||||||
|
const text = (root?.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
return JSON.stringify({
|
||||||
|
url: location.href,
|
||||||
|
title: document.title,
|
||||||
|
rootChildren: root ? root.children.length : -1,
|
||||||
|
rootBytes: root ? root.innerHTML.length : 0,
|
||||||
|
textLength: text.length,
|
||||||
|
textSample: text.slice(0, 500),
|
||||||
|
bootstrapped: !!globalThis.__codexBootstrapped,
|
||||||
|
hasShim: typeof globalThis.__codexHost,
|
||||||
|
}, null, 1);
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("STATE:", state.result.value);
|
||||||
|
console.log(`\nEVENTS (${events.length}):`);
|
||||||
|
for (const e of events.slice(0, 60)) console.log(" " + e);
|
||||||
|
|
||||||
|
const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
|
||||||
|
fs.writeFileSync(OUT, Buffer.from(shot.data, "base64"));
|
||||||
|
console.log(`\nscreenshot: ${OUT} (${fs.statSync(OUT).size} bytes)`);
|
||||||
|
ws.close();
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* Local app host for the browser build.
|
||||||
|
*
|
||||||
|
* The desktop renderer obtains its host through `connect-app-host`, which in
|
||||||
|
* Electron hands a MessagePort to the main process; the main process answers
|
||||||
|
* with a capability-RPC object exposing `services` plus a handful of host
|
||||||
|
* lifecycle hooks. In a browser there is no main process, so this file supplies
|
||||||
|
* the same object locally:
|
||||||
|
*
|
||||||
|
* - data services are generated from the app-server method table and proxied
|
||||||
|
* to the real local `codex app-server` (see host/app-server-bridge.mjs);
|
||||||
|
* - lifecycle members that only exist inside Electron are stubbed.
|
||||||
|
*
|
||||||
|
* The generated shape matters: `services.thread.list` must resolve to a call
|
||||||
|
* that issues `thread/list`, `services.config.read` to `config/read`, and so
|
||||||
|
* on. Anything the UI probes but we do not provide is reported once on the
|
||||||
|
* console so gaps are visible instead of silent.
|
||||||
|
*
|
||||||
|
* Served as a classic script. The marker below is replaced with the app-server
|
||||||
|
* method list at response time.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const METHODS = /*@METHODS@*/ [];
|
||||||
|
|
||||||
|
// Every path prefix of every method becomes a namespace object, so feature
|
||||||
|
// detection (`if (services.terminal) ...`) sees `undefined` for members that
|
||||||
|
// genuinely do not exist.
|
||||||
|
const namespaces = new Set();
|
||||||
|
for (const method of METHODS) {
|
||||||
|
const parts = method.split("/");
|
||||||
|
for (let i = 1; i < parts.length; i += 1) namespaces.add(parts.slice(0, i).join("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reported = new Set();
|
||||||
|
const reportMissing = (path) => {
|
||||||
|
if (reported.has(path)) return;
|
||||||
|
reported.add(path);
|
||||||
|
const stack = (new Error().stack || "").split("\n").slice(2, 4).join(" | ");
|
||||||
|
console.info("[codex-local-host] unimplemented service member:", path, "@", stack);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Permissive stub for Electron-main-only service namespaces: every unknown
|
||||||
|
// member is an inert async function, so unguarded calls do not throw. Only
|
||||||
|
// use this where the renderer never feature-detects the member itself.
|
||||||
|
const permissive = (label, members = {}) =>
|
||||||
|
new Proxy(members, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop in target) return target[prop];
|
||||||
|
if (typeof prop === "symbol" || prop === "then" || prop === "toJSON") return undefined;
|
||||||
|
reportMissing(`${label}.${String(prop)}`);
|
||||||
|
return () => Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function rpc(method, params) {
|
||||||
|
const res = await fetch("/__codex_rpc", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ method, params: params ?? {} }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`app-server HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
if (body && body.error) {
|
||||||
|
const err = new Error(body.error.message || `app-server error: ${method}`);
|
||||||
|
err.code = body.error.code;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return body ? body.result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- persisted state
|
||||||
|
// `r$t()` in the renderer resolves `mi.applicationState` and then calls
|
||||||
|
// `.read(key)` -> `{ value }` and `.write(key, value)` -> `{ success }`.
|
||||||
|
// The desktop build backs this with an Electron store on disk; localStorage
|
||||||
|
// gives the same semantics here, so onboarding flags, the selected project
|
||||||
|
// and the pinned-thread list survive a reload instead of erroring out.
|
||||||
|
const STATE_PREFIX = "codex.appstate.";
|
||||||
|
const stateKey = (key) => STATE_PREFIX + String(key);
|
||||||
|
|
||||||
|
const applicationState = {
|
||||||
|
read: async (key) => {
|
||||||
|
console.info("[codex-local-host] applicationState.read:", String(key).slice(0, 80));
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(stateKey(key));
|
||||||
|
const out = { value: raw == null ? null : JSON.parse(raw) };
|
||||||
|
console.info("[codex-local-host] applicationState.read ->", String(key).slice(0, 60), JSON.stringify(out).slice(0, 120));
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
console.info("[codex-local-host] applicationState.read ERROR:", String(e));
|
||||||
|
return { value: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
write: async (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(stateKey(key), JSON.stringify(value));
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: async (key) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(stateKey(key));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
keys: async () => {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i += 1) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k && k.startsWith(STATE_PREFIX)) out.push(k.slice(STATE_PREFIX.length));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- http fetch
|
||||||
|
// `$_t` calls `httpFetch.fetch(id, request, onUploadProgress)` where
|
||||||
|
// `request` is `{ url, method, body, headers, keepalive, cache, ... }` and
|
||||||
|
// awaits `{ response }` or `{ error, status, ... }` — the handle (both the
|
||||||
|
// promise and the settled value) carries `Symbol.dispose`.
|
||||||
|
//
|
||||||
|
// Backend API paths (`/wham/...`, `/backend-api/...`, …) are answered by the
|
||||||
|
// local mock backend served by the web host — the same policy the app
|
||||||
|
// server's chatgpt_base_url follows. Nothing ever leaves the page.
|
||||||
|
const API_PATH_RE = /^\/(wham|backend-api|ces|v1|api|public)\//;
|
||||||
|
const httpFetch = {
|
||||||
|
fetch: (id, request, onUploadProgress) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const dispose = () => controller.abort();
|
||||||
|
const opts = typeof request === "string" ? { url: request } : request || {};
|
||||||
|
let url = String(opts.url ?? "");
|
||||||
|
const method = String(opts.method || "GET").toUpperCase();
|
||||||
|
if (API_PATH_RE.test(url)) url = "/mock" + url;
|
||||||
|
const init = {
|
||||||
|
method,
|
||||||
|
headers: opts.headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: opts.cache,
|
||||||
|
keepalive: opts.keepalive,
|
||||||
|
};
|
||||||
|
if (opts.body != null && method !== "GET" && method !== "HEAD") {
|
||||||
|
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
||||||
|
}
|
||||||
|
const pending = Promise.resolve()
|
||||||
|
.then(() => fetch(url, init))
|
||||||
|
.then(
|
||||||
|
(response) => ({ response, [Symbol.dispose]: dispose }),
|
||||||
|
(error) => ({
|
||||||
|
error: String((error && error.message) || error),
|
||||||
|
status: 0,
|
||||||
|
errorCode: null,
|
||||||
|
responseStatus: 0,
|
||||||
|
errorKind: "network",
|
||||||
|
[Symbol.dispose]: dispose,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// `$_t` disposes the handle *and* the settled value, so both carry one.
|
||||||
|
pending[Symbol.dispose] = dispose;
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
cancel: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------- projectless threads
|
||||||
|
// A conversation that is not attached to a project still needs a working
|
||||||
|
// directory. The desktop main process allocates one; here the Node host does
|
||||||
|
// it (host/projectless.mjs) and this is the renderer-side handle.
|
||||||
|
//
|
||||||
|
// `createPaths` returns a cancellable resource rather than a promise: the
|
||||||
|
// caller awaits `.wait()` and disposes the handle when it aborts.
|
||||||
|
const hostCall = async (path, body) => {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`host call ${path} -> HTTP ${res.status}`);
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload && payload.error) throw new Error(payload.error.message || "host call failed");
|
||||||
|
return payload ? payload.result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectlessThreads = {
|
||||||
|
createPaths: (options) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const settled = hostCall("/__codex_projectless", { op: "createPaths", args: options ?? {} });
|
||||||
|
return {
|
||||||
|
wait: () => settled,
|
||||||
|
[Symbol.dispose]: () => controller.abort(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
readWorkspaceRoot: () => hostCall("/__codex_projectless", { op: "readWorkspaceRoot" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- lifecycle
|
||||||
|
// Members that live in the Electron main process rather than the app server.
|
||||||
|
// Shapes follow the call sites in the renderer's bootstrap.
|
||||||
|
const noopDisposable = { [Symbol.dispose]() {} };
|
||||||
|
|
||||||
|
const explicit = {
|
||||||
|
"startup.whenReady": () => Promise.resolve(),
|
||||||
|
// The renderer reports paint milestones through this and chains `.catch`.
|
||||||
|
"startup.reach": async () => {},
|
||||||
|
|
||||||
|
applicationState,
|
||||||
|
httpFetch,
|
||||||
|
projectlessThreads,
|
||||||
|
// `Q$` falls back to `{ values: {} }` when this is absent, which is the
|
||||||
|
// right answer for a build with no desktop settings store behind it.
|
||||||
|
settings: null,
|
||||||
|
|
||||||
|
// `remoteConnections` is consumed as `e.catalog.subscribe(fn)` and
|
||||||
|
// `e.setRemoteControlEnabled(...)`; both must exist and return promises.
|
||||||
|
remoteConnections: permissive("remoteConnections", {
|
||||||
|
catalog: { subscribe: () => noopDisposable },
|
||||||
|
setRemoteControlEnabled: async () => {},
|
||||||
|
}),
|
||||||
|
|
||||||
|
"executionHosts.getDefaultHost": async () => ({ hostId: "local", kind: "local" }),
|
||||||
|
"executionHosts.getExecutionHostKeys": () => ["local"],
|
||||||
|
|
||||||
|
"threadReadState.initialize": async () => {},
|
||||||
|
"threadReadState.dispose": () => {},
|
||||||
|
|
||||||
|
"tracing.exportTraceBatch": async () => {},
|
||||||
|
"tracing.setSampleRate": async () => ({}),
|
||||||
|
|
||||||
|
// Local-only build: no remote telemetry pipeline to feed.
|
||||||
|
performanceTelemetry: { report: () => {}, flush: async () => {} },
|
||||||
|
|
||||||
|
// ---- host-owned app metadata -----------------------------------------
|
||||||
|
// Normally answered by the Electron main process from the bundle plist.
|
||||||
|
// Values are derived from the browser environment so the shell can print a
|
||||||
|
// real platform string instead of "unknown".
|
||||||
|
"appInfo.get": async () => ({
|
||||||
|
version: "26.1021.31345",
|
||||||
|
buildFlavor: "codex",
|
||||||
|
buildNumber: "26.1021.31345",
|
||||||
|
systemVersion: navigator.userAgent,
|
||||||
|
preloadAttribution: null,
|
||||||
|
chatGptApiBaseUrl: null,
|
||||||
|
}),
|
||||||
|
"appInfo.getSystemInfo": async () => ({
|
||||||
|
platform: "darwin",
|
||||||
|
osVersion: navigator.userAgent,
|
||||||
|
arch: "arm64",
|
||||||
|
release: navigator.userAgent,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- thread configuration ---------------------------------------------
|
||||||
|
// The desktop main process reads two things off disk before a thread can
|
||||||
|
// start: developer instructions and the MCP block of the Codex config.
|
||||||
|
// Neither exists for a browser build. The renderer dereferences
|
||||||
|
// `.instructions` directly, so the shape matters even when it is empty.
|
||||||
|
threadConfiguration: {
|
||||||
|
readDeveloperInstructions: async () => ({ instructions: null }),
|
||||||
|
readMcpConfig: async () => ({ config: null }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// The desktop shell auto-resolves pending user-input prompts after a
|
||||||
|
// timeout, and tracks whether a conversation is on screen so it only fires
|
||||||
|
// for visible chats. A local build never auto-resolves anything, but the
|
||||||
|
// renderer calls these unconditionally (only the method itself is
|
||||||
|
// optional-chained), so the namespace must exist.
|
||||||
|
requestUserInputAutoResolution: {
|
||||||
|
setConversationPresented: async () => {},
|
||||||
|
recordConversationActivity: async () => {},
|
||||||
|
snooze: async () => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- git integration ----------------------------------------------------
|
||||||
|
// The desktop main process owns git queries (`RA('git')`). The UI gates the
|
||||||
|
// composer on the `['git','availability']` query, which is only ever filled
|
||||||
|
// as a side effect of a `stable-metadata` answer — so the service must
|
||||||
|
// exist and answer non-null, or Send stays disabled with "Loading…".
|
||||||
|
//
|
||||||
|
// A7: instead of the old stub (always `{available:false, …}`), forward to
|
||||||
|
// the host's POST /__codex_git, which runs read-only git commands inside
|
||||||
|
// the cwd the renderer passes in params. The renderer calls this as
|
||||||
|
// request({method, params:{cwd, …}}) (two-arg request(method, params)
|
||||||
|
// is accepted too). Without a cwd there is nothing to inspect, so fall
|
||||||
|
// back to the old stub shape for stable-metadata and null otherwise.
|
||||||
|
// stable-metadata keeps the original six-field shape, now populated.
|
||||||
|
git: permissive("git", {
|
||||||
|
request: async (arg1, arg2) => {
|
||||||
|
let method;
|
||||||
|
let params = {};
|
||||||
|
if (typeof arg1 === "string") {
|
||||||
|
method = arg1;
|
||||||
|
params = arg2 ?? {};
|
||||||
|
} else {
|
||||||
|
({ method, params = {} } = arg1 ?? {});
|
||||||
|
}
|
||||||
|
const cwd = typeof params?.cwd === "string" ? params.cwd : null;
|
||||||
|
if (method === "stable-metadata" && !cwd) {
|
||||||
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||||
|
}
|
||||||
|
if (!cwd) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/__codex_git", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ op: method, cwd }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = await res.json();
|
||||||
|
if (body && body.error) {
|
||||||
|
// Not a repo / bad cwd: report unavailable instead of throwing,
|
||||||
|
// so the composer degrades the way the old stub degraded it.
|
||||||
|
if (method === "stable-metadata") {
|
||||||
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return body ? body.result ?? null : null;
|
||||||
|
} catch {
|
||||||
|
if (method === "stable-metadata") {
|
||||||
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- statsig evaluations ----------------------------------------------
|
||||||
|
// In the desktop app the main process evaluates Statsig and pushes results
|
||||||
|
// over this service; the renderer never talks to the network for it. The
|
||||||
|
// local build pushes one truthful "no evaluations" payload: `payload: null`
|
||||||
|
// maps to `hasEvaluations: false`, feature defaults apply, and nothing
|
||||||
|
// ever polls a remote endpoint.
|
||||||
|
statsigEvaluations: {
|
||||||
|
subscribe: async (onValue) => {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
onValue({
|
||||||
|
userId: null,
|
||||||
|
accountId: null,
|
||||||
|
authMethod: null,
|
||||||
|
stableId: "codex-web-local",
|
||||||
|
payload: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { [Symbol.dispose]() {} };
|
||||||
|
},
|
||||||
|
// The renderer publishes execution evaluations back to the host (which in
|
||||||
|
// Electron forwards them to the main process). There is nothing to feed
|
||||||
|
// locally; accept and drop.
|
||||||
|
publish: async () => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Account state comes from the local app server, which reads ~/.codex.
|
||||||
|
"accountInfo.readAccountInfo": () => rpc("account/read", {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const explicitNulls = new Set(["histogramCollector", "statsig", "clientCoordination", "terminal", "devboxService"]);
|
||||||
|
|
||||||
|
function makeNamespace(path) {
|
||||||
|
const node = {};
|
||||||
|
const children = new Set();
|
||||||
|
for (const candidate of namespaces) {
|
||||||
|
if (!candidate.startsWith(path ? path + "/" : "")) continue;
|
||||||
|
const rest = path ? candidate.slice(path.length + 1) : candidate;
|
||||||
|
if (rest.includes("/")) continue;
|
||||||
|
children.add(rest);
|
||||||
|
}
|
||||||
|
for (const method of METHODS) {
|
||||||
|
if (!method.startsWith(path ? path + "/" : "")) continue;
|
||||||
|
const rest = path ? method.slice(path.length + 1) : method;
|
||||||
|
if (rest.includes("/")) continue;
|
||||||
|
node[rest] = (params) => rpc(method, params);
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
node[child] = makeNamespace(path ? `${path}/${child}` : child);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServices() {
|
||||||
|
const services = makeNamespace("");
|
||||||
|
|
||||||
|
// Overlay the host-only members, then strip the null ones.
|
||||||
|
for (const key of Object.keys(explicit)) {
|
||||||
|
const parts = key.split(".");
|
||||||
|
let cursor = services;
|
||||||
|
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||||
|
cursor[parts[i]] = cursor[parts[i]] ?? {};
|
||||||
|
cursor = cursor[parts[i]];
|
||||||
|
}
|
||||||
|
cursor[parts[parts.length - 1]] = explicit[key];
|
||||||
|
}
|
||||||
|
for (const key of explicitNulls) services[key] = null;
|
||||||
|
|
||||||
|
// Report accesses to members we do not model.
|
||||||
|
return new Proxy(services, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
|
||||||
|
if (prop === "then" || prop === "toJSON") return undefined;
|
||||||
|
if (!(prop in target)) {
|
||||||
|
reportMissing(String(prop));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const servicesPromise = Promise.resolve().then(buildServices);
|
||||||
|
|
||||||
|
globalThis.__codexLocalHost = () => ({
|
||||||
|
services: servicesPromise,
|
||||||
|
onRpcBroken: () => () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info("[codex-local-host] app host installed", METHODS.length, "methods");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Local stand-in for the model wire.
|
||||||
|
*
|
||||||
|
* The app server speaks the OpenAI Responses API (`wire_api = "responses"`):
|
||||||
|
* one POST to `{base_url}/responses` answered with a `text/event-stream` of
|
||||||
|
* typed events. Producing that stream here keeps every byte of the
|
||||||
|
* conversation on this machine — no model provider is ever contacted.
|
||||||
|
*
|
||||||
|
* Extracted from `web-server.mjs` so the local model lives in one place: the
|
||||||
|
* host serves it, and any other local caller gets the identical wire shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Read a JSON request body with a hard size cap. */
|
||||||
|
export const readJsonBody = (req, limit = 4_000_000) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
let raw = "";
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.on("data", (c) => {
|
||||||
|
raw += c;
|
||||||
|
if (raw.length > limit) reject(new Error("body too large"));
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(raw ? JSON.parse(raw) : {});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Collect every text fragment the app server put in the request. */
|
||||||
|
const collectText = (payload) => {
|
||||||
|
const texts = [];
|
||||||
|
const collect = (item) => {
|
||||||
|
if (!item || typeof item !== "object") return;
|
||||||
|
if (typeof item.text === "string") texts.push(item.text);
|
||||||
|
else if (Array.isArray(item.content)) for (const c of item.content) collect(c);
|
||||||
|
else if (typeof item.content === "string") texts.push(item.content);
|
||||||
|
};
|
||||||
|
for (const item of payload?.input ?? []) collect(item);
|
||||||
|
if (typeof payload?.input === "string") texts.push(payload.input);
|
||||||
|
return texts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Craft a deterministic local reply from the user's input.
|
||||||
|
*
|
||||||
|
* Deterministic arithmetic answers make end-to-end checks (`5+5 -> 10`)
|
||||||
|
* readable at a glance; anything else echoes back what arrived so a wrong
|
||||||
|
* request shape is immediately visible in the UI.
|
||||||
|
*/
|
||||||
|
export const mockReplyFor = (payload) => {
|
||||||
|
const joined = collectText(payload);
|
||||||
|
if (/5\s*\+\s*5/i.test(joined)) return "10";
|
||||||
|
if (/3\s*\+\s*3/i.test(joined)) return "6";
|
||||||
|
if (/2\s*\+\s*2/i.test(joined)) return "4";
|
||||||
|
if (/hello/i.test(joined)) return "hello from the local mock model";
|
||||||
|
return `Local mock reply. I received: ${joined.slice(0, 200) || "(empty input)"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
const sseEvent = (res, type, data) => {
|
||||||
|
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer `/mockrelay/v1/*` — the model provider surface.
|
||||||
|
*
|
||||||
|
* @param {import("node:http").IncomingMessage} req
|
||||||
|
* @param {import("node:http").ServerResponse} res
|
||||||
|
* @param {string} urlPath path beginning with `/mockrelay`
|
||||||
|
* @param {{ onRequest?: (line: string) => void }} [opts]
|
||||||
|
*/
|
||||||
|
export const serveMockRelay = (req, res, urlPath, opts = {}) => {
|
||||||
|
opts.onRequest?.(`${req.method} ${urlPath} (mock model relay)`);
|
||||||
|
|
||||||
|
const route = urlPath.replace(/^\/mockrelay/, "") || "/";
|
||||||
|
const json = (obj, status = 200) => {
|
||||||
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Model list — keeps provider probes happy.
|
||||||
|
if (route === "/v1/models" || route === "/models") {
|
||||||
|
json({ object: "list", data: [{ id: "gpt-6.1-sol", object: "model", owned_by: "local" }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === "/v1/responses" || route === "/responses") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405).end("method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
readJsonBody(req)
|
||||||
|
.then((payload) => {
|
||||||
|
const reply = mockReplyFor(payload);
|
||||||
|
const model = typeof payload?.model === "string" ? payload.model : "gpt-6.1-sol";
|
||||||
|
const n = ++callCount;
|
||||||
|
const respId = `resp_local_${Date.now().toString(36)}_${n}`;
|
||||||
|
const msgId = `msg_local_${n}`;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const responseShell = (status, extra = {}) => ({
|
||||||
|
id: respId,
|
||||||
|
object: "response",
|
||||||
|
created_at: now,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
output: [],
|
||||||
|
usage: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
connection: "keep-alive",
|
||||||
|
});
|
||||||
|
|
||||||
|
sseEvent(res, "response.created", { type: "response.created", sequence_number: 0, response: responseShell("in_progress") });
|
||||||
|
sseEvent(res, "response.in_progress", { type: "response.in_progress", sequence_number: 1, response: responseShell("in_progress") });
|
||||||
|
|
||||||
|
const messageItem = { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] };
|
||||||
|
sseEvent(res, "response.output_item.added", {
|
||||||
|
type: "response.output_item.added",
|
||||||
|
sequence_number: 2,
|
||||||
|
output_index: 0,
|
||||||
|
item: messageItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream the reply in small chunks so the renderer's delta path runs.
|
||||||
|
const chunks = reply.match(/.{1,8}/gs) ?? [reply];
|
||||||
|
let seq = 3;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
sseEvent(res, "response.output_text.delta", {
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
sequence_number: seq++,
|
||||||
|
item_id: msgId,
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
delta: chunk,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedItem = {
|
||||||
|
type: "message",
|
||||||
|
id: msgId,
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text: reply, annotations: [] }],
|
||||||
|
};
|
||||||
|
sseEvent(res, "response.output_text.done", {
|
||||||
|
type: "response.output_text.done",
|
||||||
|
sequence_number: seq++,
|
||||||
|
item_id: msgId,
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
text: reply,
|
||||||
|
});
|
||||||
|
sseEvent(res, "response.output_item.done", {
|
||||||
|
type: "response.output_item.done",
|
||||||
|
sequence_number: seq++,
|
||||||
|
output_index: 0,
|
||||||
|
item: completedItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalResponse = responseShell("completed", {
|
||||||
|
output: [completedItem],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 0,
|
||||||
|
input_tokens_details: { cached_tokens: 0 },
|
||||||
|
output_tokens: 1,
|
||||||
|
output_tokens_details: { reasoning_tokens: 0 },
|
||||||
|
total_tokens: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sseEvent(res, "response.completed", { type: "response.completed", sequence_number: seq++, response: finalResponse });
|
||||||
|
res.end();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify({ error: { message: "bad request body" } }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json({ error: { message: `mock relay: unhandled path ${route}` } }, 404);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer the ChatGPT-backend surface (`/mock/*`) with truthful local shapes.
|
||||||
|
*
|
||||||
|
* Every response is either the exact JSON the desktop main process would hand
|
||||||
|
* over, or an empty-but-valid object. The point is that nothing errors and
|
||||||
|
* nothing leaves the machine.
|
||||||
|
*/
|
||||||
|
export const serveMockBackend = (req, res, urlPath, opts = {}) => {
|
||||||
|
opts.onRequest?.(`${req.method} ${urlPath}`);
|
||||||
|
const json = (obj, status = 200) => {
|
||||||
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
const p = urlPath.replace(/^\/mock/, "") || "/";
|
||||||
|
|
||||||
|
// Cloud MCP endpoint (ChatGPT-hosted app connectors). 404 makes the app
|
||||||
|
// server mark these unavailable immediately — a semantically empty 200
|
||||||
|
// would be retried and block thread startup on MCP enumeration.
|
||||||
|
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
|
||||||
|
json({ error: { message: "cloud connectors are unavailable in a local build" } }, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^\/wham\/tasks\/list/.test(p)) {
|
||||||
|
json({ items: [], cursor: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/plugin/i.test(p)) {
|
||||||
|
json({ schema_version: 1, fetched_at: new Date().toISOString(), plugins: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json({});
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Projectless thread workspaces.
|
||||||
|
*
|
||||||
|
* Codex keeps two kinds of conversation: one attached to a project directory and
|
||||||
|
* one that starts from nothing. The desktop main process owns the second kind —
|
||||||
|
* it picks a folder under the user's workspace root, creates it, and hands the
|
||||||
|
* paths back to the renderer through `projectlessThreads.createPaths`.
|
||||||
|
*
|
||||||
|
* The browser cannot touch the filesystem, so the Node host does it here and
|
||||||
|
* exposes the same three paths over HTTP:
|
||||||
|
*
|
||||||
|
* cwd the directory this conversation runs in
|
||||||
|
* outputDirectory where generated files land (same folder)
|
||||||
|
* workspaceRoot the shared root that contains every thread
|
||||||
|
*
|
||||||
|
* Layout mirrors the desktop app: <root>/<YYYY-MM-DD>/<slug>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const WORKSPACE_ROOT =
|
||||||
|
// Linux server default: ~/Codex. The desktop default ~/Documents/Codex
|
||||||
|
// does not exist on a server, so it is not used here.
|
||||||
|
process.env.CODEX_WEB_WORKSPACE_ROOT || path.join(os.homedir(), "Codex");
|
||||||
|
|
||||||
|
export function ensureWorkspaceRoot() {
|
||||||
|
fs.mkdirSync(WORKSPACE_ROOT, { recursive: true });
|
||||||
|
return WORKSPACE_ROOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, collapse anything awkward, keep CJK, cap the length. */
|
||||||
|
function slugify(value) {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectlessPaths({ directoryName, prompt } = {}) {
|
||||||
|
const root = ensureWorkspaceRoot();
|
||||||
|
const day = new Date().toISOString().slice(0, 10);
|
||||||
|
const base = slugify(directoryName) || slugify(prompt) || "thread";
|
||||||
|
|
||||||
|
let dir = path.join(root, day, base);
|
||||||
|
let suffix = 1;
|
||||||
|
while (fs.existsSync(dir)) {
|
||||||
|
suffix += 1;
|
||||||
|
dir = path.join(root, day, `${base}-${suffix}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
return { cwd: dir, outputDirectory: dir, workspaceRoot: root };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWorkspaceRoot() {
|
||||||
|
return { workspaceRoot: ensureWorkspaceRoot() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# relay-config.json — mock 现状与接真实 relay
|
||||||
|
|
||||||
|
`relay-config.json` 保持 UI 栈自带的 Bumian 配置原样,未换 provider、未写任何密钥值。
|
||||||
|
|
||||||
|
## 当前状态:模型调用走本地 mock
|
||||||
|
|
||||||
|
- `baseUrl` 是 `http://127.0.0.1:8095/mockrelay/v1`:app-server 的模型请求由本机 `host/mock-model.mjs` 用确定性替身回答,不出本机、不产生真实调用、不花钱。
|
||||||
|
- `relayBaseUrl`(`https://www.bumianapi.top/v1`)只写在 JSON 里,host 代码没有引用它,当前不生效。
|
||||||
|
- `apiKey` 为空字符串:真实 relay 需要 key 时,key 值绝不写进本文件。
|
||||||
|
|
||||||
|
## 接真实 relay:两步
|
||||||
|
|
||||||
|
1. 把 `baseUrl` 的值改为 `relayBaseUrl` 的值(即 `https://www.bumianapi.top/v1`)。
|
||||||
|
只改这一处;`providerId` / `providerName` / `model` / `wireApi` / `envKey` 不动。
|
||||||
|
2. 在启动 UI host 的环境里设置 `BUMIAN_API_KEY` 环境变量(key 值只进环境,不进文件、不进仓库):
|
||||||
|
`BUMIAN_API_KEY=<你的 key> scripts/run-codex-ui.sh`
|
||||||
|
|
||||||
|
改完后 app-server 启动时按 `relay-config.json` 的 `-c` 覆盖走真实 relay;`chatgptBaseUrl`
|
||||||
|
仍指向本机 `/mock`,插件目录、账号端点、遥测等 ChatGPT 后端调用继续由本地替身回答。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"providerId": "bumian",
|
||||||
|
"providerName": "Bumian Relay",
|
||||||
|
"baseUrl": "http://127.0.0.1:8095/mockrelay/v1",
|
||||||
|
"relayBaseUrl": "https://www.bumianapi.top/v1",
|
||||||
|
"apiKey": "",
|
||||||
|
"envKey": "BUMIAN_API_KEY",
|
||||||
|
"model": "gpt-6.1-sol",
|
||||||
|
"wireApi": "responses",
|
||||||
|
"chatgptBaseUrl": "http://127.0.0.1:8095/mock"
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/* Codex web host shim — browser-only replacement for the Electron preload.
|
||||||
|
*
|
||||||
|
* Loaded as a classic script before the module entry. It provides the two
|
||||||
|
* pieces of the Electron shell the renderer cannot live without:
|
||||||
|
*
|
||||||
|
* window.electronBridge — the preload API surface. Only the members the
|
||||||
|
* renderer actually probes are meaningful; the rest
|
||||||
|
* resolve to inert promises so .then/.catch chains
|
||||||
|
* stay valid.
|
||||||
|
* the app-server bridge — request envelopes sent through
|
||||||
|
* sendMessageFromView are forwarded to the local
|
||||||
|
* codex app server and answered on the window
|
||||||
|
* message bus, which is where the renderer listens.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
globalThis.__codexOut = [];
|
||||||
|
globalThis.__codexIn = [];
|
||||||
|
let rpcCount = 0;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ inbound
|
||||||
|
// The renderer validates inbound messages with `event.source === null` (it
|
||||||
|
// skips the origin check in that case), which is exactly what a synthesized
|
||||||
|
// MessageEvent gives us.
|
||||||
|
const dispatchInbound = (payload) => {
|
||||||
|
globalThis.__codexIn.push(payload);
|
||||||
|
window.dispatchEvent(new MessageEvent("message", { data: payload }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const forwardRequest = (msg) => {
|
||||||
|
const { id, method, params } = msg.request;
|
||||||
|
rpcCount += 1;
|
||||||
|
if (rpcCount <= 500) console.info("[codex-web] rpc ->", method);
|
||||||
|
fetch("/__codex_rpc", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ method: method, params: params ?? {} }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((body) => {
|
||||||
|
// The renderer's app-server request client reads the reply from
|
||||||
|
// `message: { id, result | error }`. `response` is kept alongside it
|
||||||
|
// because the app-host side of the same bus uses that spelling.
|
||||||
|
const message =
|
||||||
|
body && body.error
|
||||||
|
? { id: id, error: { code: body.error.code ?? -32000, message: body.error.message ?? "rpc failed" } }
|
||||||
|
: { id: id, result: body ? body.result : null };
|
||||||
|
dispatchInbound({
|
||||||
|
type: "mcp-response",
|
||||||
|
hostId: msg.hostId,
|
||||||
|
message: message,
|
||||||
|
response: message,
|
||||||
|
requestMethod: method,
|
||||||
|
receivedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = { id: id, error: { code: -32000, message: String((err && err.message) || err) } };
|
||||||
|
dispatchInbound({
|
||||||
|
type: "mcp-response",
|
||||||
|
hostId: msg.hostId,
|
||||||
|
message: message,
|
||||||
|
response: message,
|
||||||
|
requestMethod: method,
|
||||||
|
receivedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- events
|
||||||
|
// Everything the app server says on its own initiative — streaming deltas,
|
||||||
|
// turn progress, server-initiated approval prompts — arrives on this
|
||||||
|
// long-poll. The renderer subscribes to it as `mcp-notification`.
|
||||||
|
globalThis.__codexEvents = [];
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
const pumpEvents = async () => {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
while (polling) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/__codex_events", { cache: "no-store" });
|
||||||
|
const body = await res.json();
|
||||||
|
for (const event of body.events || []) {
|
||||||
|
globalThis.__codexEvents.push(event);
|
||||||
|
if (globalThis.__codexEvents.length > 400) globalThis.__codexEvents.shift();
|
||||||
|
if (event.id != null) {
|
||||||
|
// Server-initiated request: the renderer owns the answer path, which
|
||||||
|
// this shim does not model yet. Keep it visible instead of dropping.
|
||||||
|
globalThis.__codexServerRequests = globalThis.__codexServerRequests || [];
|
||||||
|
globalThis.__codexServerRequests.push(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dispatchInbound({
|
||||||
|
type: "mcp-notification",
|
||||||
|
hostId: "local",
|
||||||
|
method: event.method,
|
||||||
|
params: event.params ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pumpEvents();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- electron API
|
||||||
|
// Synchronous getters first: callers treat these as plain values.
|
||||||
|
const SYNC = {
|
||||||
|
getInitialSidebarBootstrap: () => null,
|
||||||
|
getConfig: () => null,
|
||||||
|
getPlatform: () => "darwin",
|
||||||
|
getLocale: () => "zh-CN",
|
||||||
|
getVersion: () => "26.1021.31345",
|
||||||
|
isPackaged: () => true,
|
||||||
|
getWindowType: () => "electron",
|
||||||
|
getZoomLevel: () => 0,
|
||||||
|
isDeviceCheckSupported: () => true,
|
||||||
|
isIntelMacBuild: () => false,
|
||||||
|
getSystemThemeVariant: () => "light",
|
||||||
|
subscribeToSystemThemeVariant: () => noop,
|
||||||
|
getBuildFlavor: () => "codex",
|
||||||
|
getAppSessionId: () => "codex-web-local",
|
||||||
|
getDesktopUserAgent: () => navigator.userAgent,
|
||||||
|
getSentryInitOptions: () => ({ appVersion: "26.1021.31345", buildFlavor: "codex" }),
|
||||||
|
getSharedObjectSnapshotValue: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeBridge = () =>
|
||||||
|
new Proxy(SYNC, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop in target) return target[prop];
|
||||||
|
if (prop === "then") return undefined; // never a thenable
|
||||||
|
if (typeof prop === "symbol") return undefined;
|
||||||
|
if (prop === "sendMessageFromView") {
|
||||||
|
return (msg) => {
|
||||||
|
globalThis.__codexOut.push(msg);
|
||||||
|
if (msg && msg.type === "mcp-request" && msg.request && typeof msg.request.method === "string") {
|
||||||
|
return forwardRequest(msg);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Unknown member: an inert async function, so feature probes see
|
||||||
|
// "present" and promise chains stay valid.
|
||||||
|
return () => Promise.resolve();
|
||||||
|
},
|
||||||
|
has: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window.electronBridge === "undefined") window.electronBridge = makeBridge();
|
||||||
|
if (typeof window.codexWindowType === "undefined") window.codexWindowType = "electron";
|
||||||
|
if (typeof window.__SENTRY_IPC__ === "undefined") window.__SENTRY_IPC__ = { sendMessage: noop };
|
||||||
|
window.__codexDesktop = window.__codexDesktop || {};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- offline net
|
||||||
|
// Nothing leaves the machine. Absolute URLs get a well-formed empty JSON body
|
||||||
|
// so callers fail softly instead of hitting the CSP. The remaining browser
|
||||||
|
// transports — XHR, sendBeacon, WebSocket, EventSource — are stubbed the same
|
||||||
|
// way: local requests pass through, everything else succeeds locally with
|
||||||
|
// empty data.
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
const isLocal = (url) =>
|
||||||
|
!url ||
|
||||||
|
url.startsWith("/") ||
|
||||||
|
url.startsWith("data:") ||
|
||||||
|
url.startsWith("blob:") ||
|
||||||
|
url.startsWith(location.origin);
|
||||||
|
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const url = typeof input === "string" ? input : (input && input.url) || "";
|
||||||
|
if (isLocal(url)) return realFetch(input, init);
|
||||||
|
console.info("[codex-web] outbound:", (init?.method || "GET"), url.slice(0, 140));
|
||||||
|
const json = (obj, status = 200) =>
|
||||||
|
Promise.resolve(new Response(typeof obj === "string" ? obj : JSON.stringify(obj), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}));
|
||||||
|
// Statsig telemetry hosts. The SDK requires its initialize payload to
|
||||||
|
// carry `has_updates` (see _typedJsonParse), so hand back a valid empty
|
||||||
|
// evaluation set — every gate then resolves to "off" locally and the SDK
|
||||||
|
// completes initialization instead of retrying forever.
|
||||||
|
if (/ab\.chatgpt\.com|api\.oaistatsig\.com|statsigcdn\.openai\.com/i.test(url)) {
|
||||||
|
if (/\/initialize/i.test(url)) {
|
||||||
|
return json({
|
||||||
|
has_updates: true,
|
||||||
|
hash_used: "djb2",
|
||||||
|
time: Date.now(),
|
||||||
|
feature_gates: {},
|
||||||
|
dynamic_configs: {},
|
||||||
|
layer_configs: {},
|
||||||
|
param_stores: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (/sdk_exception/i.test(url)) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
// rgstr / download_config_specs: accepted-and-empty.
|
||||||
|
return Promise.resolve(new Response(null, { status: 202 }));
|
||||||
|
}
|
||||||
|
// Everything else: generic empty JSON success.
|
||||||
|
return json({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// XMLHttpRequest: same policy. A cross-origin send completes immediately
|
||||||
|
// with `{}` and status 200, on the microtask queue so ordering stays sane.
|
||||||
|
const XHR = window.XMLHttpRequest;
|
||||||
|
if (XHR && XHR.prototype) {
|
||||||
|
const openOrig = XHR.prototype.open;
|
||||||
|
const sendOrig = XHR.prototype.send;
|
||||||
|
XHR.prototype.open = function (method, url, ...rest) {
|
||||||
|
this.__codexUrl = String(url ?? "");
|
||||||
|
return openOrig.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
XHR.prototype.send = function (body) {
|
||||||
|
if (isLocal(this.__codexUrl)) return sendOrig.call(this, body);
|
||||||
|
const payload = this.responseType === "json" ? {} : "{}";
|
||||||
|
queueMicrotask(() => {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(this, "readyState", { value: 4, configurable: true });
|
||||||
|
Object.defineProperty(this, "status", { value: 200, configurable: true });
|
||||||
|
Object.defineProperty(this, "statusText", { value: "OK", configurable: true });
|
||||||
|
Object.defineProperty(this, "responseText", { value: payload, configurable: true });
|
||||||
|
Object.defineProperty(this, "response", { value: payload, configurable: true });
|
||||||
|
Object.defineProperty(this, "responseURL", { value: this.__codexUrl, configurable: true });
|
||||||
|
this.dispatchEvent(new Event("readystatechange"));
|
||||||
|
this.dispatchEvent(new ProgressEvent("load"));
|
||||||
|
this.dispatchEvent(new ProgressEvent("loadend"));
|
||||||
|
} catch {
|
||||||
|
/* the request object went away; nothing to satisfy */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendBeacon is fire-and-forget by contract; "delivered" locally means dropped.
|
||||||
|
try {
|
||||||
|
if (navigator.sendBeacon) navigator.sendBeacon = () => true;
|
||||||
|
} catch {
|
||||||
|
/* some engines make it a getter; the CSP covers those */
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket: hand back a socket that is already closed. No network dial, no
|
||||||
|
// browser console noise, and well-behaved clients see a terminal state.
|
||||||
|
const RealWS = window.WebSocket;
|
||||||
|
if (RealWS) {
|
||||||
|
class ClosedSocket extends EventTarget {
|
||||||
|
constructor(url) {
|
||||||
|
super();
|
||||||
|
this.url = String(url ?? "");
|
||||||
|
this.readyState = 3; // CLOSED
|
||||||
|
this.bufferedAmount = 0;
|
||||||
|
this.extensions = "";
|
||||||
|
this.protocol = "";
|
||||||
|
this.binaryType = "blob";
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.dispatchEvent(new Event("error"));
|
||||||
|
this.dispatchEvent(new CloseEvent("close", { wasClean: true, code: 1000 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
send() {
|
||||||
|
throw new DOMException("The socket is closed.", "InvalidStateError");
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
for (const k of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) ClosedSocket[k] = RealWS[k];
|
||||||
|
window.WebSocket = ClosedSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventSource: a stream that closes immediately, for the same reason.
|
||||||
|
if (window.EventSource) {
|
||||||
|
class ClosedStream extends EventTarget {
|
||||||
|
constructor(url) {
|
||||||
|
super();
|
||||||
|
this.url = String(url ?? "");
|
||||||
|
this.readyState = 2; // CLOSED
|
||||||
|
this.withCredentials = false;
|
||||||
|
queueMicrotask(() => this.dispatchEvent(new Event("error")));
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
window.EventSource = ClosedStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[codex-web] offline host shim installed");
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- config preseed
|
||||||
|
// The scope-query bridge only fetches from mounted React readers, and the
|
||||||
|
// config/effective + permission-profiles queries have no mounted reader in a
|
||||||
|
// browser fallback (no project tree, no workspace picker). Prime both from the
|
||||||
|
// local app server up front so the composer's agent-mode resolution sees real
|
||||||
|
// data on first render instead of a query that would stay pending forever.
|
||||||
|
(() => {
|
||||||
|
const rpc = (method, params) =>
|
||||||
|
fetch("/__codex_rpc", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ method, params: params ?? null }),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((b) => (b && b.result ? b.result : null))
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
globalThis.__codexCfg = null;
|
||||||
|
globalThis.__codexPerms = null;
|
||||||
|
|
||||||
|
rpc("config/read", { includeLayers: true, cwd: null }).then((r) => {
|
||||||
|
if (r && r.config) {
|
||||||
|
globalThis.__codexCfg = r;
|
||||||
|
console.info("[codex-web] config preseeded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rpc("permissionProfile/list", { cursor: null, limit: 50, cwd: null }).then((r) => {
|
||||||
|
if (r && Array.isArray(r.data)) {
|
||||||
|
globalThis.__codexPerms = r.data;
|
||||||
|
console.info("[codex-web] permission profiles preseeded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"source": "/tmp/codex-asar/webview",
|
||||||
|
"vendoredAt": "2026-10-27T03:31:32.962Z",
|
||||||
|
"fileCount": 20918,
|
||||||
|
"indexHtmlSha256": "85e5c5b4ce1cd7e0fc42ccbac7fe6d9c6ac7b2eb4b8a7f77beb39d280fdca9f5",
|
||||||
|
"verification": "diff -rq clean; identical sha256"
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Vendor the original webview verbatim into ./original.
|
||||||
|
*
|
||||||
|
* Source: the extracted ChatGPT.app/Contents/Resources/app.asar, i.e.
|
||||||
|
* <extracted-asar>/webview
|
||||||
|
*
|
||||||
|
* Nothing is rewritten. The copy is byte-for-byte identical, which is what makes
|
||||||
|
* the UI 100% the original rather than an approximation.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node host/vendor-original.mjs --from /tmp/codex-asar/webview
|
||||||
|
* node host/vendor-original.mjs --from /path/to/webview --clean
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const getArg = (name, fallback) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = path.resolve(getArg("--from", "/tmp/codex-asar/webview"));
|
||||||
|
const to = path.resolve(projectRoot, getArg("--to", "original"));
|
||||||
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(from, "index.html"))) {
|
||||||
|
console.error(`[vendor] not a webview directory: ${from}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean && fs.existsSync(to)) {
|
||||||
|
console.log(`[vendor] removing ${to}`);
|
||||||
|
fs.rmSync(to, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(to, { recursive: true });
|
||||||
|
|
||||||
|
console.log(`[vendor] copying ${from} -> ${to}`);
|
||||||
|
fs.cpSync(from, to, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Manifest (written outside ./original so the vendored tree stays untouched).
|
||||||
|
function sha256(file) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||||
|
}
|
||||||
|
function countFiles(dir) {
|
||||||
|
let n = 0;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
n += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
source: from,
|
||||||
|
vendoredAt: new Date().toISOString(),
|
||||||
|
fileCount: countFiles(to),
|
||||||
|
indexHtmlSha256: sha256(path.join(to, "index.html")),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, "host", "vendor-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[vendor] done — ${manifest.fileCount} files`);
|
||||||
|
console.log(`[vendor] index.html sha256 ${manifest.indexHtmlSha256}`);
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Local web host for the vendored Codex webview.
|
||||||
|
*
|
||||||
|
* `original/` stays byte-identical — every adaptation happens in the HTTP
|
||||||
|
* response layer, so the vendored tree remains a clean reference.
|
||||||
|
*
|
||||||
|
* The desktop renderer is not a plain web app; three pieces of the Electron
|
||||||
|
* shell are load-bearing and are replaced here:
|
||||||
|
*
|
||||||
|
* 1. CSP. The shipped policy is `default-src 'none'` with a connect-src that
|
||||||
|
* only whitelists `wss://chatgpt.com`. A browser tab could not reach
|
||||||
|
* anything, and we do not want it to: the policy is rewritten to `'self'`
|
||||||
|
* so the page is fully local.
|
||||||
|
* 2. The app host. `connect-app-host` creates a MessageChannel and posts one
|
||||||
|
* port to the preload, which hands it to the main process. Here the port is
|
||||||
|
* never used: `__codexLocalHost` answers instead (host/local-host.js).
|
||||||
|
* 3. Host services. The bootstrap awaits `appHost.services`, which the main
|
||||||
|
* process resolves with its service layer. Our local host resolves it with
|
||||||
|
* a proxy onto the real `codex app-server` (host/app-server-bridge.mjs).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node host/web-server.mjs [--port 8095] [--root original]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { AppServerBridge } from "./app-server-bridge.mjs";
|
||||||
|
import { serveMockRelay } from "./mock-model.mjs";
|
||||||
|
import { createProjectlessPaths, readWorkspaceRoot, WORKSPACE_ROOT } from "./projectless.mjs";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PROJECT = path.resolve(HERE, "..");
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const argOf = (name, fallback) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||||
|
};
|
||||||
|
const PORT = Number(argOf("--port", process.env.CODEX_WEB_PORT || "8095"));
|
||||||
|
// --root wins; otherwise CODEX_UI_ORIGINAL_ROOT (set by scripts/run-codex-ui.sh),
|
||||||
|
// otherwise the in-repo `original/` directory (never committed — see §4).
|
||||||
|
const ROOT = path.resolve(PROJECT, argOf("--root", process.env.CODEX_UI_ORIGINAL_ROOT || "original"));
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
|
||||||
|
console.error(`[web] missing index.html under ${ROOT}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHODS = JSON.parse(fs.readFileSync(path.join(HERE, "appserver-methods.json"), "utf8"));
|
||||||
|
const bridge = new AppServerBridge();
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- CSP rewrite
|
||||||
|
// Fully local: nothing outside this origin is reachable. Blob/data stay allowed
|
||||||
|
// because the app builds workers and assets in memory.
|
||||||
|
const WEB_CSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' data: blob:",
|
||||||
|
"media-src 'self' data: blob:",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"frame-src 'self' blob:",
|
||||||
|
"child-src 'self' blob:",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".otf": "font/otf",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rewrite rules applied on the fly; `original/` on disk is never touched. */
|
||||||
|
const JS_PATCHES = [
|
||||||
|
{
|
||||||
|
name: "local-app-host",
|
||||||
|
// `connect-app-host` opens a MessageChannel and posts one port to the
|
||||||
|
// preload, which forwards it to the Electron main process. There is no main
|
||||||
|
// process here, so answer with the local host instead.
|
||||||
|
find: /function (\w+)\((\w+)\)\{let\{port1:(\w+),port2:(\w+)\}=new MessageChannel;return window\.postMessage\(\{type:`connect-app-host`/g,
|
||||||
|
replace: (_m, fn, arg, p1, p2) =>
|
||||||
|
`function ${fn}(${arg}){if(globalThis.__codexLocalHost)return globalThis.__codexLocalHost(${arg});` +
|
||||||
|
`let{port1:${p1},port2:${p2}}=new MessageChannel;return window.postMessage({type:\`connect-app-host\``,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bootstrap-guard",
|
||||||
|
// The original bootstrap wires up seven main-process facilities: remote
|
||||||
|
// connection discovery, an execution host, an RPC-broken hook, thread read
|
||||||
|
// state, Statsig, client coordination and the terminal. Only the first two
|
||||||
|
// affect the shell; the rest are Electron-only and would abort the whole
|
||||||
|
// sequence on the first rejection. Keep the parts that matter, guard them,
|
||||||
|
// and drop the rest.
|
||||||
|
find: /async function pL\(\)\{[\s\S]*?\}function mL\(\)/g,
|
||||||
|
replace: () =>
|
||||||
|
`async function pL(){` +
|
||||||
|
`const T=(ms)=>new Promise(r=>setTimeout(r,ms));` +
|
||||||
|
`const guard=async(f,ms)=>{try{return await Promise.race([f(),T(ms)])}catch(e){console.warn("[codex-web] init step skipped:",e&&e.message)}};` +
|
||||||
|
`await guard(async()=>{$t(kS(lL))},5000);` +
|
||||||
|
`await guard(async()=>{dr(await Pt.services)},10000);` +
|
||||||
|
`await guard(async()=>{await ql(H.remoteConnections)},2000);` +
|
||||||
|
`await guard(async()=>{di(await H.executionHosts?.getDefaultHost())},2000);` +
|
||||||
|
`globalThis.__codexBootstrapped=!0` +
|
||||||
|
`}function mL()`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skip-shell-gate",
|
||||||
|
// The shell waits for auth, app-info, os-info and one more readiness signal
|
||||||
|
// before rendering anything. In the desktop app the main process supplies
|
||||||
|
// all four; here auth in particular only settles after the app server has
|
||||||
|
// been asked for an account, which cannot happen until the shell exists.
|
||||||
|
// Render the shell immediately and let the data arrive underneath it.
|
||||||
|
find: /if\((\w+)\.isLoading\|\|(\w+)\|\|(\w+)\|\|(\w+)\)\{/g,
|
||||||
|
replace: (_m, r, a, s, c) =>
|
||||||
|
`if(console.info("[codex-web] shell gate",JSON.stringify({rLoad:${r}&&${r}.isLoading,a:${a},s:${s},c:${c}})),!1){`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode-probe",
|
||||||
|
// `Ck` picks the window mode (`app` / `onboarding` / `login`) from a pile of
|
||||||
|
// readiness signals and falls back to the loading blossom when it comes back
|
||||||
|
// empty. The guard is a comma expression inside a single `if`, so the anchor
|
||||||
|
// is the tail of that expression rather than the `if(` itself. Log the
|
||||||
|
// decision so the missing input is identifiable.
|
||||||
|
find: /,!(\w+)\|\|(\w+)===\x60loading\x60\)\{/g,
|
||||||
|
replace: (_m, mode, status) =>
|
||||||
|
`,console.info("[codex-web] mode",JSON.stringify({mode:${mode},status:${status}})),!${mode}||${status}===\x60loading\x60){`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode-inputs",
|
||||||
|
// Bind the mode-input object to the global scope on its way into the
|
||||||
|
// decision function, so a probe can read exactly what the shell decided on.
|
||||||
|
find: /,(\w+)=(\w+)\(\{bedrockLoginPending:/g,
|
||||||
|
replace: (_m, out, fn) => `,${out}=${fn}(globalThis.__modeIn={bedrockLoginPending:`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode-decide",
|
||||||
|
// The window-mode decision gates on two desktop-only concepts:
|
||||||
|
//
|
||||||
|
// gatewayOAuthReadiness — an OpenAI gateway handshake that stays `loading`
|
||||||
|
// forever here because there is no gateway: this build talks to an
|
||||||
|
// API-key relay, so there is nothing to wait for.
|
||||||
|
// onboarding completion — the desktop onboarding flow needs a ChatGPT
|
||||||
|
// account and a backend, neither of which this build has.
|
||||||
|
//
|
||||||
|
// Collapse both: honour an explicit "login required", otherwise go straight
|
||||||
|
// to the app. The relay authenticates with a key, so there is no login step.
|
||||||
|
find: /(\w+)===\x60loading\x60\?null:\1===\x60required\x60\?\x60login\x60:[\s\S]{0,200}?:\x60welcome\x60\):\x60app\x60\}/g,
|
||||||
|
replace: (_m, readiness) =>
|
||||||
|
`${readiness}===\x60required\x60?\x60login\x60:\x60app\x60:\x60app\x60}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config-effective-preseed",
|
||||||
|
// The scope-query bridge fetches only from mounted React readers. In a
|
||||||
|
// browser fallback nothing mounts the config/effective query, so `ofo()`
|
||||||
|
// would read `isPending: true` forever and keep `isAgentModeLoading` true.
|
||||||
|
// Short-circuit to the preseeded app-server config (host/shim.js).
|
||||||
|
find: /let\{data:(\w+),isPending:(\w+)\}=ixn\((\w+)===`~`\?null:\3,(\w+)\),/,
|
||||||
|
replace: (_m, w, T, p, C) =>
|
||||||
|
`let{data:${w},isPending:${T}}=globalThis.__codexCfg?{data:{config:globalThis.__codexCfg.config,origins:globalThis.__codexCfg.origins??{},layers:null},isPending:!1}:(ixn(${p}===\`~\`?null:${p},${C}),{data:void 0,isPending:!0}),`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission-profiles-preseed",
|
||||||
|
// Same mount requirement for the permission-profiles query read inside the
|
||||||
|
// agent-mode hook (`sfo`); short-circuit to the preseeded profile list.
|
||||||
|
find: /let (\w+)=ns\(kdo,(\w+),(\w+)\),(\w+)=\w+\|\|\w+\|\|\1\.isPending,(\w+)=\4\|\|\1\.isError,/,
|
||||||
|
replace: (_m, O, T2, D2, k, A) =>
|
||||||
|
`let ${O}=globalThis.__codexPerms?{data:globalThis.__codexPerms,isPending:!1,isError:!1}:ns(kdo,${T2},${D2}),${k}=h||g||${O}.isPending,${A}=${k}||${O}.isError,`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guardian-off",
|
||||||
|
// The guardian-approvals feature flag derives from a Statsig dynamic
|
||||||
|
// config the desktop main process evaluates; with a null evaluations
|
||||||
|
// payload (this build has no ChatGPT account) the guard reads as
|
||||||
|
// "unavailable" and `ofo()` treats guardian mode as pending forever.
|
||||||
|
// This build has no guardian capability at all, so pin it off.
|
||||||
|
find: /N=(\w+)&&oee\(\w+\?\?void 0\)!==!1&&\(\w+===void 0\|\|\w+\),/,
|
||||||
|
replace: (_m, e) => `N=!1,`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "statsig-bootstrap-local",
|
||||||
|
// The renderer fetches its Statsig bootstrap from the ChatGPT backend
|
||||||
|
// (`POST /wham/statsig/bootstrap`). Without a ChatGPT token that request
|
||||||
|
// is rejected before it is ever sent, and the SDK retries forever with
|
||||||
|
// "SDK initialization has not completed" warnings. Serve the bootstrap
|
||||||
|
// from the local host instead (see /__codex_statsig_bootstrap).
|
||||||
|
find: /function (\w+)\((\w+),(\w+)\)\{return (\w+)\.safePost\(`\/wham\/statsig\/bootstrap`,\{requestBody:\2,retry:!1,signal:\3\}\)\}/g,
|
||||||
|
replace: (_m, fn, reqBody, signal) =>
|
||||||
|
`async function ${fn}(${reqBody},${signal}){` +
|
||||||
|
`const r=await fetch("/__codex_statsig_bootstrap",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(${reqBody}??{}),signal:${signal}});` +
|
||||||
|
`return r.json()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expose-app-host",
|
||||||
|
// setAppHost / setAppServices only assign module-scoped variables, which
|
||||||
|
// makes the wiring invisible from the outside. Mirror them for diagnostics.
|
||||||
|
find: /function hSt\((\w+)\)\{_St=\1\}/g,
|
||||||
|
replace: (_m, v) => `function hSt(${v}){_St=${v};globalThis.__appHost=${v}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expose-app-services",
|
||||||
|
find: /function gSt\((\w+)\)\{f\$=\1\}/g,
|
||||||
|
replace: (_m, v) => `function gSt(${v}){f$=${v};globalThis.__appServices=${v}}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function patchJs(source) {
|
||||||
|
let out = source;
|
||||||
|
const applied = [];
|
||||||
|
for (const rule of JS_PATCHES) {
|
||||||
|
if (!rule.find.test(out)) continue;
|
||||||
|
rule.find.lastIndex = 0;
|
||||||
|
out = out.replace(rule.find, rule.replace);
|
||||||
|
applied.push(rule.name);
|
||||||
|
}
|
||||||
|
return { out, applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchHtml(source) {
|
||||||
|
let out = source;
|
||||||
|
|
||||||
|
out = out.replace(
|
||||||
|
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/?>/i,
|
||||||
|
`<meta http-equiv="Content-Security-Policy" content="${WEB_CSP.replace(/"/g, """)}" />`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both shims must run before the module entry.
|
||||||
|
if (!out.includes("__codex_shim.js")) {
|
||||||
|
out = out.replace(
|
||||||
|
/<script type="module"/i,
|
||||||
|
`<script src="/__codex_local_host.js"></script>\n <script src="/__codex_shim.js"></script>\n <script type="module"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_HOST_JS = fs
|
||||||
|
.readFileSync(path.join(HERE, "local-host.js"), "utf8")
|
||||||
|
.replace("/*@METHODS@*/ []", JSON.stringify(METHODS));
|
||||||
|
|
||||||
|
const SHIM = fs.readFileSync(path.join(HERE, "shim.js"), "utf8");
|
||||||
|
// ------------------------------------------------------------- mock backend
|
||||||
|
// Local stand-in for the ChatGPT backend. The app server runs with
|
||||||
|
// chatgpt_base_url pointed at /mock, so every call that would reach OpenAI's
|
||||||
|
// cloud — plugin catalog refresh, account endpoints, telemetry — is answered
|
||||||
|
// from this process with well-formed local JSON. Nothing leaves the machine
|
||||||
|
// and no caller ever sees a connection error.
|
||||||
|
const MOCK_LOG = [];
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
/** Valid, empty Statsig initialize payload — every gate resolves to "off". */
|
||||||
|
const statsigBootstrapPayload = (stableId = "codex-web-local") => ({
|
||||||
|
has_updates: true,
|
||||||
|
hash_used: "djb2",
|
||||||
|
time: Date.now(),
|
||||||
|
feature_gates: {},
|
||||||
|
dynamic_configs: {},
|
||||||
|
layer_configs: {},
|
||||||
|
param_stores: {},
|
||||||
|
user: {
|
||||||
|
userID: null,
|
||||||
|
email: null,
|
||||||
|
custom: { auth_method: null },
|
||||||
|
customIDs: { stableID: stableId, WebAnonymousCookieID: stableId, DeviceId: stableId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A remote-catalog plugin descriptor the renderer can render without data. */
|
||||||
|
const mockRemotePluginRead = (pluginName) => ({
|
||||||
|
plugin: {
|
||||||
|
summary: {
|
||||||
|
id: pluginName,
|
||||||
|
name: String(pluginName).replace(/^plugin_[^_]*_/, ""),
|
||||||
|
remotePluginId: pluginName,
|
||||||
|
version: null,
|
||||||
|
installed: false,
|
||||||
|
enabled: false,
|
||||||
|
installPolicy: "NOT_AVAILABLE",
|
||||||
|
installPolicySource: null,
|
||||||
|
mustShowInstallationInterstitial: false,
|
||||||
|
authPolicy: "ON_USE",
|
||||||
|
availability: "NOT_AVAILABLE",
|
||||||
|
disabledReason: "unavailable_offline",
|
||||||
|
interface: {
|
||||||
|
displayName: pluginName,
|
||||||
|
shortDescription: "Cloud plugin (offline build)",
|
||||||
|
longDescription: "This plugin belongs to the ChatGPT cloud catalog and is not available in a fully local build.",
|
||||||
|
developerName: null,
|
||||||
|
category: null,
|
||||||
|
capabilities: [],
|
||||||
|
websiteUrl: null,
|
||||||
|
privacyPolicyUrl: null,
|
||||||
|
termsOfServiceUrl: null,
|
||||||
|
defaultPrompt: [],
|
||||||
|
brandColor: "#6B7280",
|
||||||
|
composerIcon: null,
|
||||||
|
composerIconUrl: null,
|
||||||
|
logo: null,
|
||||||
|
logoDark: null,
|
||||||
|
logoUrl: null,
|
||||||
|
logoUrlDark: null,
|
||||||
|
screenshots: [],
|
||||||
|
screenshotUrls: [],
|
||||||
|
},
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
skills: [],
|
||||||
|
onboardingSkill: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serveMock = (req, res, urlPath) => {
|
||||||
|
let search = "";
|
||||||
|
try {
|
||||||
|
search = new URL(req.url, "http://localhost").search;
|
||||||
|
} catch {
|
||||||
|
/* keep empty */
|
||||||
|
}
|
||||||
|
MOCK_LOG.push(`${req.method} ${urlPath}${search}`);
|
||||||
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||||
|
|
||||||
|
const json = (obj, status = 200) => {
|
||||||
|
res.writeHead(status, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
const p = urlPath.replace(/^\/mock/, "") || "/";
|
||||||
|
|
||||||
|
// Cloud MCP endpoint (ChatGPT-hosted app connectors). Answer 404 so the app
|
||||||
|
// server marks these connectors unavailable immediately — with a semantically
|
||||||
|
// empty 200 it would retry and block thread startup on MCP enumeration.
|
||||||
|
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
|
||||||
|
res.writeHead(404, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ error: { message: "cloud connectors are unavailable in a local build" } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- known wham (ChatGPT backend) shapes -------------------------------
|
||||||
|
// Cloud tasks: the renderer dereferences `.items` and paginates on `.cursor`.
|
||||||
|
if (/^\/wham\/tasks\/list/.test(p)) {
|
||||||
|
json({ items: [], cursor: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Statsig bootstrap: `{ statsigPayload: <string> }`, exactly as the desktop
|
||||||
|
// main process hands it over.
|
||||||
|
if (/^\/wham\/statsig\/bootstrap/.test(p)) {
|
||||||
|
json({ statsigPayload: JSON.stringify(statsigBootstrapPayload()) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin catalog refresh: the app server persists this under
|
||||||
|
// ~/.codex/cache/remote_plugin_catalog. The truthful local answer is an
|
||||||
|
// empty catalog — no cloud plugins exist in this build.
|
||||||
|
if (/plugin/i.test(p)) {
|
||||||
|
json({ schema_version: 1, fetched_at: nowIso(), plugins: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Generic empty-but-valid JSON: callers parse it, find nothing to do, and
|
||||||
|
// move on without errors.
|
||||||
|
json({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------- mock model provider
|
||||||
|
// The model wire lives in ./mock-model.mjs so the local model is defined once
|
||||||
|
// and every caller gets the identical Responses-API stream.
|
||||||
|
const logMockRequest = (line) => {
|
||||||
|
MOCK_LOG.push(line);
|
||||||
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- server
|
||||||
|
// Read-only git ops accepted by POST /__codex_git (A7). Anything else is
|
||||||
|
// rejected; there is intentionally no write op in this set.
|
||||||
|
const GIT_OPS = new Set(["stable-metadata", "branch", "status"]);
|
||||||
|
|
||||||
|
const readJsonBody = (req) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
let raw = "";
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.on("data", (c) => {
|
||||||
|
raw += c;
|
||||||
|
if (raw.length > 4_000_000) reject(new Error("body too large"));
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(raw ? JSON.parse(raw) : {});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
let urlPath;
|
||||||
|
try {
|
||||||
|
urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400).end("bad request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === "/__codex_shim.js") {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
||||||
|
res.end(SHIM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === "/__codex_local_host.js") {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
||||||
|
res.end(LOCAL_HOST_JS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filesystem work the Electron main process would do: allocate a directory for
|
||||||
|
// a conversation that is not attached to a project.
|
||||||
|
if (urlPath === "/__codex_projectless") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405).end("method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { op, args } = await readJsonBody(req);
|
||||||
|
const result = op === "readWorkspaceRoot" ? readWorkspaceRoot() : createProjectlessPaths(args ?? {});
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ result }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ error: { message: err?.message ?? "workspace error" } }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only git (A7). The renderer gates the composer on
|
||||||
|
// `git.request("stable-metadata")`, which the desktop main process answers.
|
||||||
|
// Only three read-only ops are accepted; write operations cannot be
|
||||||
|
// expressed, and no caller-supplied path ever reaches argv — `cwd` is
|
||||||
|
// passed to execFile as the working-directory option only, and every git
|
||||||
|
// invocation runs strictly inside that directory. A missing cwd or a
|
||||||
|
// non-repository cwd gets a structured error, not a silent stub.
|
||||||
|
if (urlPath === "/__codex_git") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405).end("method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gitError = (message) => {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ error: { message } }));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { op, cwd } = await readJsonBody(req);
|
||||||
|
if (!GIT_OPS.has(op)) {
|
||||||
|
gitError(`unsupported git op: ${String(op)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof cwd !== "string" || !cwd) {
|
||||||
|
gitError("git request needs a cwd");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = path.resolve(cwd);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = fs.statSync(dir);
|
||||||
|
} catch {
|
||||||
|
gitError(`cwd does not exist: ${dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) {
|
||||||
|
gitError(`cwd is not a directory: ${dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runGit = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", timeout: 10_000 }).trim();
|
||||||
|
let toplevel;
|
||||||
|
try {
|
||||||
|
toplevel = runGit(["rev-parse", "--show-toplevel"]);
|
||||||
|
} catch {
|
||||||
|
gitError(`not a git repository: ${dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let branch = null;
|
||||||
|
try {
|
||||||
|
const raw = runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
|
branch = raw === "HEAD" ? null : raw; // detached HEAD, or no commits yet
|
||||||
|
} catch {
|
||||||
|
branch = null;
|
||||||
|
}
|
||||||
|
let upstream = null;
|
||||||
|
try {
|
||||||
|
upstream = runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||||
|
} catch {
|
||||||
|
/* no upstream configured */
|
||||||
|
}
|
||||||
|
const porcelain = runGit(["status", "--porcelain=v1"]);
|
||||||
|
const clean = porcelain === "";
|
||||||
|
// stable-metadata keeps the six fields the old stub returned, now
|
||||||
|
// populated for real; branch/status return the subset they need.
|
||||||
|
const result =
|
||||||
|
op === "branch"
|
||||||
|
? { branch, upstream }
|
||||||
|
: op === "status"
|
||||||
|
? { branch, upstream, clean, files: clean ? [] : porcelain.split("\n") }
|
||||||
|
: {
|
||||||
|
available: true,
|
||||||
|
branch,
|
||||||
|
upstream,
|
||||||
|
repository: toplevel,
|
||||||
|
state: clean ? "clean" : "dirty",
|
||||||
|
worktree: toplevel,
|
||||||
|
};
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ result }));
|
||||||
|
} catch (err) {
|
||||||
|
gitError(err?.message ?? "git error");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic: what has the app server asked the mock backend for?
|
||||||
|
if (urlPath === "/__codex_mocklog") {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ requests: MOCK_LOG }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statsig bootstrap. The renderer normally POSTs /wham/statsig/bootstrap to
|
||||||
|
// the ChatGPT backend; the `statsig-bootstrap-local` patch redirects it here.
|
||||||
|
// The response is a valid, empty initialize payload — every gate resolves to
|
||||||
|
// "off" and the SDK completes initialization instead of retrying forever.
|
||||||
|
if (urlPath === "/__codex_statsig_bootstrap") {
|
||||||
|
MOCK_LOG.push(`POST ${urlPath} (statsig bootstrap)`);
|
||||||
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405).end("method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = await readJsonBody(req);
|
||||||
|
} catch {
|
||||||
|
/* empty request body is fine */
|
||||||
|
}
|
||||||
|
const stableId = typeof body.stable_id === "string" ? body.stable_id : "codex-web-local";
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
// The renderer expects `{ statsigPayload: <string> }` — the payload as a
|
||||||
|
// JSON string, exactly as the desktop main process hands it over.
|
||||||
|
res.end(JSON.stringify({ statsigPayload: JSON.stringify(statsigBootstrapPayload(stableId)) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin Statsig event flush: the renderer posts to /ces/v1/rgstr, which
|
||||||
|
// lands here. Accept and drop — nothing is measured, nothing leaves.
|
||||||
|
if (urlPath.startsWith("/ces/")) {
|
||||||
|
res.writeHead(202, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end("{}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock model provider (see ./mock-model.mjs). The app server's
|
||||||
|
// model_provider points here, so `turn/start` completions are answered from
|
||||||
|
// this process with a well-formed Responses-API SSE stream. Nothing reaches
|
||||||
|
// the network and the reply renders exactly as a live model's would.
|
||||||
|
if (urlPath === "/mockrelay" || urlPath.startsWith("/mockrelay/")) {
|
||||||
|
serveMockRelay(req, res, urlPath, { onRequest: logMockRequest });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock ChatGPT backend (see serveMock above).
|
||||||
|
if (urlPath === "/mock" || urlPath.startsWith("/mock/")) {
|
||||||
|
serveMock(req, res, urlPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event stream. Everything the app server emits on its own initiative —
|
||||||
|
// streaming deltas, turn progress, server-initiated approval requests — comes
|
||||||
|
// back through here. Long-polled so the renderer stays in step without a
|
||||||
|
// websocket.
|
||||||
|
if (urlPath === "/__codex_events") {
|
||||||
|
try {
|
||||||
|
await bridge.ensure();
|
||||||
|
const messages = await bridge.waitForNotifications(15_000);
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ events: messages }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ events: [], error: String(err?.message ?? err) }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local JSON-RPC passthrough to the real codex app server.
|
||||||
|
if (urlPath === "/__codex_rpc") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405).end("method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let method, params;
|
||||||
|
try {
|
||||||
|
({ method, params } = await readJsonBody(req));
|
||||||
|
if (typeof method !== "string") {
|
||||||
|
res.writeHead(400, { "content-type": MIME[".json"] }).end('{"error":{"message":"missing method"}}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The desktop main process strips `originator` filtering before it
|
||||||
|
// forwards `thread/list` to the app server — a local app-server rejects
|
||||||
|
// the parameter with -32602. Mirror the sanitisation here.
|
||||||
|
if (method === "thread/list" && params && typeof params === "object") {
|
||||||
|
const strip = (obj) => {
|
||||||
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (key === "originator" || key === "originators") delete obj[key];
|
||||||
|
else if (obj[key] && typeof obj[key] === "object") strip(obj[key]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
strip(params);
|
||||||
|
}
|
||||||
|
await bridge.ensure();
|
||||||
|
const result = await bridge.request(method, params);
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ result: result ?? null }));
|
||||||
|
} catch (err) {
|
||||||
|
// A cloud-catalog plugin cannot be fetched in a local build; answer with
|
||||||
|
// a well-formed offline descriptor instead of an error the renderer
|
||||||
|
// would surface as a toast.
|
||||||
|
if (method === "plugin/read" && params && typeof params.pluginName === "string") {
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ result: mockRemotePluginRead(params.pluginName) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = err?.rpc?.message ?? err?.message ?? "rpc failed";
|
||||||
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify({ error: { code: err?.rpc?.code ?? -32000, message } }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||||
|
const abs = path.join(ROOT, rel);
|
||||||
|
|
||||||
|
if (!abs.startsWith(ROOT)) {
|
||||||
|
res.writeHead(403).end("forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = abs;
|
||||||
|
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||||
|
file = path.join(ROOT, "index.html"); // SPA fallback
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
res.writeHead(404).end("not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const type = MIME[ext] || "application/octet-stream";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let body = fs.readFileSync(file);
|
||||||
|
let patched = null;
|
||||||
|
|
||||||
|
if (ext === ".html") {
|
||||||
|
body = Buffer.from(patchHtml(body.toString("utf8")), "utf8");
|
||||||
|
} else if (ext === ".js") {
|
||||||
|
const { out, applied } = patchJs(body.toString("utf8"));
|
||||||
|
if (applied.length) {
|
||||||
|
body = Buffer.from(out, "utf8");
|
||||||
|
patched = applied.join(",");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": type,
|
||||||
|
"cache-control": "no-store",
|
||||||
|
...(patched ? { "x-codex-patched": patched } : {}),
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[web] read error", file, err.message);
|
||||||
|
res.writeHead(500).end("read error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[web] Codex webview served from ${ROOT}`);
|
||||||
|
console.log(`[web] http://127.0.0.1:${PORT}/`);
|
||||||
|
console.log(`[web] app server: ${bridge.binary ?? "not found"}`);
|
||||||
|
console.log(`[web] workspace root: ${WORKSPACE_ROOT}`);
|
||||||
|
console.log(`[web] mock backend: http://127.0.0.1:${PORT}/mock`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A long-running local host must survive its own bugs: log loudly, keep
|
||||||
|
// serving. Without this one bad request would take the whole app down.
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error("[web] uncaught:", err?.stack ?? err);
|
||||||
|
});
|
||||||
|
process.on("unhandledRejection", (err) => {
|
||||||
|
console.error("[web] unhandled rejection:", err?.stack ?? err);
|
||||||
|
});
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the standalone original-UI web host in development mode.
|
||||||
|
#
|
||||||
|
# ./start.sh UI :8095
|
||||||
|
# UI_PORT=9000 ./start.sh custom port
|
||||||
|
# node verify.mjs --deep run the self-check in another shell
|
||||||
|
#
|
||||||
|
# The app-server binary ships inside ChatGPT.app; set CODEX_APP_SERVER_BIN
|
||||||
|
# if it lives elsewhere. There is no orchestrator/API layer in this stack.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
UI_PORT="${UI_PORT:-8095}"
|
||||||
|
|
||||||
|
export CODEX_WEB_PORT="$UI_PORT"
|
||||||
|
|
||||||
|
echo "[stack] UI http://127.0.0.1:${UI_PORT}/"
|
||||||
|
echo "[stack] test node ${ROOT}/verify.mjs --deep"
|
||||||
|
|
||||||
|
exec node "$ROOT/host/web-server.mjs" --port "$UI_PORT"
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Self-check for the standalone original-UI web host. Run while ./start.sh is up.
|
||||||
|
* node verify.mjs served assets + HTTP surface
|
||||||
|
* node verify.mjs --deep also drive a real app-server RPC through the UI host
|
||||||
|
*
|
||||||
|
* The orchestrator/API layer is intentionally not part of this stack; nothing
|
||||||
|
* here probes port 8899. Everything the UI needs is served by host/web-server.mjs
|
||||||
|
* on UI_PORT, including the local JSON-RPC passthrough and the mock backends.
|
||||||
|
*/
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const uiPort = Number(process.env.UI_PORT || 8095);
|
||||||
|
const deep = process.argv.includes("--deep");
|
||||||
|
|
||||||
|
function request(path, { method = "GET", headers = {}, body = null, timeout = 8000 } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: uiPort, path, method, headers }, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on("data", (c) => chunks.push(c));
|
||||||
|
res.on("end", () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
body: Buffer.concat(chunks).toString("utf8"),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
req.setTimeout(timeout, () => req.destroy(new Error("timeout")));
|
||||||
|
req.on("error", (e) => resolve({ status: 0, body: String(e.message) }));
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(r) {
|
||||||
|
try { return JSON.parse(r.body); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
async function check(name, fn) {
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = await fn();
|
||||||
|
} catch (e) {
|
||||||
|
out = { ok: false, detail: String(e.message) };
|
||||||
|
}
|
||||||
|
results.push({ name, ...out });
|
||||||
|
console.log(`${out.ok ? "PASS" : "FAIL"} ${name} ${out.detail ?? ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = (path, payload, timeout = 8000) => request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET / 注入两个宿主脚本", async () => {
|
||||||
|
const r = await request("/");
|
||||||
|
const ok = r.status === 200
|
||||||
|
&& r.body.includes("/__codex_local_host.js")
|
||||||
|
&& r.body.includes("/__codex_shim.js");
|
||||||
|
return { ok, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /__codex_local_host.js", async () => {
|
||||||
|
const r = await request("/__codex_local_host.js");
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
|
||||||
|
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /__codex_shim.js", async () => {
|
||||||
|
const r = await request("/__codex_shim.js");
|
||||||
|
return { ok: r.status === 200 && r.body.length > 1000, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI 响应层补丁命中 connect-app-host", async () => {
|
||||||
|
const r = await request("/assets/connect-app-host-90a50ca9e4b4.js");
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
|
||||||
|
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI 原版入口 chunk 可加载", async () => {
|
||||||
|
const r = await request("/assets/index-fcd3fcef6840.js");
|
||||||
|
return { ok: r.status === 200 && r.body.length > 100, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /ces/v1/rgstr 遥测丢弃", async () => {
|
||||||
|
const r = await request("/ces/v1/rgstr");
|
||||||
|
return { ok: r.status === 202, detail: `HTTP ${r.status}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /__codex_rpc 拒绝非 POST", async () => {
|
||||||
|
const r = await request("/__codex_rpc");
|
||||||
|
return { ok: r.status === 405, detail: `HTTP ${r.status}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI POST /__codex_projectless 工作区根", async () => {
|
||||||
|
const r = await post("/__codex_projectless", { op: "readWorkspaceRoot" });
|
||||||
|
const j = json(r);
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && typeof j?.result?.workspaceRoot === "string",
|
||||||
|
detail: `HTTP ${r.status}, ${j?.result?.workspaceRoot ?? r.body.slice(0, 80)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI POST /__codex_statsig_bootstrap 空配置", async () => {
|
||||||
|
const r = await post("/__codex_statsig_bootstrap", { stable_id: "verify" });
|
||||||
|
const j = json(r);
|
||||||
|
const gates = json({ body: j?.statsigPayload ?? "" })?.feature_gates;
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && gates && Object.keys(gates).length === 0,
|
||||||
|
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /mock/wham/tasks/list 云端替身", async () => {
|
||||||
|
const r = await request("/mock/wham/tasks/list");
|
||||||
|
const j = json(r);
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && Array.isArray(j?.items) && j.items.length === 0,
|
||||||
|
detail: `HTTP ${r.status}, ${r.body.trim()}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI GET /__codex_mocklog 诊断日志", async () => {
|
||||||
|
const r = await request("/__codex_mocklog");
|
||||||
|
const j = json(r);
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && Array.isArray(j?.requests),
|
||||||
|
detail: `HTTP ${r.status}, ${j?.requests?.length ?? 0} 条`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deep) {
|
||||||
|
await check("UI POST /__codex_rpc -> 真实 app-server thread/list", async () => {
|
||||||
|
const r = await post("/__codex_rpc", { method: "thread/list", params: { limit: 1 } }, 45000);
|
||||||
|
const j = json(r);
|
||||||
|
const ok = r.status === 200 && j && !j.error && j.result != null;
|
||||||
|
return { ok: !!ok, detail: `HTTP ${r.status}, ${r.body.slice(0, 140)}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("UI POST /__codex_rpc -> config/read 含 relay 覆盖", async () => {
|
||||||
|
const r = await post("/__codex_rpc", { method: "config/read", params: {} }, 45000);
|
||||||
|
const j = json(r);
|
||||||
|
const model = j?.result?.config?.model;
|
||||||
|
return {
|
||||||
|
ok: r.status === 200 && typeof model === "string" && model.length > 0,
|
||||||
|
detail: `HTTP ${r.status}, model=${model ?? "n/a"}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(`\n${results.length - failed.length}/${results.length} passed`);
|
||||||
|
console.log("orchestrator 已移除;本栈只有 :8095 一个服务,无 :8899 API。");
|
||||||
|
if (!deep) console.log("shallow run: pass --deep to exercise the real app-server RPC");
|
||||||
|
process.exit(failed.length ? 1 : 0);
|
||||||
@@ -9,7 +9,6 @@ export default function DispatchSection() {
|
|||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [coordParts, setCoordParts] = useState("");
|
const [coordParts, setCoordParts] = useState("");
|
||||||
const [goalMode, setGoalMode] = useState(false);
|
const [goalMode, setGoalMode] = useState(false);
|
||||||
const [goalInvincible, setGoalInvincible] = useState(false);
|
|
||||||
const [planMode, setPlanMode] = useState(false);
|
const [planMode, setPlanMode] = useState(false);
|
||||||
const [executor, setExecutor] = useState("docker");
|
const [executor, setExecutor] = useState("docker");
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
@@ -45,19 +44,18 @@ export default function DispatchSection() {
|
|||||||
}
|
}
|
||||||
if (planMode) {
|
if (planMode) {
|
||||||
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
|
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
|
||||||
if (goalMode || goalInvincible) { setMsg("计划模式不能与 goal 模式同用"); return; }
|
if (goalMode) { setMsg("计划模式不能与 goal 模式同用"); return; }
|
||||||
body.plan = 1;
|
body.plan = 1;
|
||||||
}
|
}
|
||||||
// 无敌版 = goal 模式 + 任务不限时:勾了它就同时发 goal_mode 与
|
// 唯一 goal 模式 = 原无敌版:勾选即同时发 goal_mode 与
|
||||||
// no_time_limit(服务端已支持该字段);与普通 goal 勾选不冲突,
|
// no_time_limit(任务不限时);普通 goal 入口已取消,不再
|
||||||
// 无敌版优先,单独勾普通 goal 时行为不变。
|
// 单独发不带 no_time_limit 的 goal_mode。
|
||||||
if (goalInvincible) { body.goal_mode = 1; body.no_time_limit = 1; }
|
if (goalMode) { body.goal_mode = 1; body.no_time_limit = 1; }
|
||||||
else if (goalMode) body.goal_mode = 1;
|
|
||||||
const coordRaw = coordParts.trim();
|
const coordRaw = coordParts.trim();
|
||||||
if (coordRaw) {
|
if (coordRaw) {
|
||||||
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
|
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
|
||||||
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
|
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
|
||||||
if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
|
if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
|
||||||
}
|
}
|
||||||
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
|
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +79,6 @@ export default function DispatchSection() {
|
|||||||
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
|
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
|
||||||
</select>
|
</select>
|
||||||
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
|
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
|
||||||
<label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label>
|
|
||||||
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
|
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
|
||||||
</Bar>
|
</Bar>
|
||||||
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||||
@@ -113,8 +110,7 @@ export default function DispatchSection() {
|
|||||||
</select>
|
</select>
|
||||||
</Bar>
|
</Bar>
|
||||||
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
|
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
|
||||||
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
|
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止;任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
|
||||||
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
|
|
||||||
<Bar>
|
<Bar>
|
||||||
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
||||||
<input ref={filesRef} type="file" multiple title="文件" />
|
<input ref={filesRef} type="file" multiple title="文件" />
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function TaskDetailPanel() {
|
|||||||
const fc = firstChar(t);
|
const fc = firstChar(t);
|
||||||
const cost = ctx.costSummary;
|
const cost = ctx.costSummary;
|
||||||
const modeText = t.plan ? "计划模式(只读)"
|
const modeText = t.plan ? "计划模式(只读)"
|
||||||
: t.goal_mode ? (t.no_time_limit ? "goal 模式无敌版(不限时)" : "goal 模式(不限轮次,模型宣告完成)")
|
: t.goal_mode ? (t.no_time_limit ? "goal 模式(不限轮次,模型宣告完成;任务不限时)" : "goal 模式(不限轮次,模型宣告完成)")
|
||||||
: t.verify_command ? "goal 模式(旧语义,验收命令)" : null;
|
: t.verify_command ? "goal 模式(旧语义,验收命令)" : null;
|
||||||
return (
|
return (
|
||||||
<div className="detail-stack" id="nav-detail">
|
<div className="detail-stack" id="nav-detail">
|
||||||
|
|||||||
+28
-28
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Codex 命令台</title>
|
<title>Codex 命令台</title>
|
||||||
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const C of y)if(C.type==="childList")for(const M of C.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const C={};return y.integrity&&(C.integrity=y.integrity),y.referrerPolicy&&(C.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?C.credentials="include":y.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(y){if(y.ep)return;y.ep=!0;const C=h(y);fetch(y.href,C)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},si={};/**
|
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const C of y)if(C.type==="childList")for(const A of C.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(y){const C={};return y.integrity&&(C.integrity=y.integrity),y.referrerPolicy&&(C.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?C.credentials="include":y.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(y){if(y.ep)return;y.ep=!0;const C=h(y);fetch(y.href,C)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},si={};/**
|
||||||
* @license React
|
* @license React
|
||||||
* react-jsx-runtime.production.js
|
* react-jsx-runtime.production.js
|
||||||
*
|
*
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/var Dh;function Hy(){if(Dh)return si;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,C){var M=null;if(C!==void 0&&(M=""+C),y.key!==void 0&&(M=""+y.key),"key"in y){C={};for(var S in y)S!=="key"&&(C[S]=y[S])}else C=y;return y=C.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:C}}return si.Fragment=r,si.jsx=h,si.jsxs=h,si}var Uh;function By(){return Uh||(Uh=1,io.exports=Hy()),io.exports}var c=By(),co={exports:{}},yt={};/**
|
*/var Dh;function Hy(){if(Dh)return si;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,C){var A=null;if(C!==void 0&&(A=""+C),y.key!==void 0&&(A=""+y.key),"key"in y){C={};for(var S in y)S!=="key"&&(C[S]=y[S])}else C=y;return y=C.ref,{$$typeof:i,type:f,key:A,ref:y!==void 0?y:null,props:C}}return si.Fragment=r,si.jsx=h,si.jsxs=h,si}var Uh;function By(){return Uh||(Uh=1,io.exports=Hy()),io.exports}var c=By(),co={exports:{}},gt={};/**
|
||||||
* @license React
|
* @license React
|
||||||
* react.production.js
|
* react.production.js
|
||||||
*
|
*
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/var wh;function qy(){if(wh)return yt;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),M=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),O=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),L=Symbol.iterator;function tt(g){return g===null||typeof g!="object"?null:(g=L&&g[L]||g["@@iterator"],typeof g=="function"?g:null)}var dt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},J=Object.assign,gt={};function _t(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}_t.prototype.isReactComponent={},_t.prototype.setState=function(g,U){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,U,"setState")},_t.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function Dt(){}Dt.prototype=_t.prototype;function Q(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}var ft=Q.prototype=new Dt;ft.constructor=Q,J(ft,_t.prototype),ft.isPureReactComponent=!0;var D=Array.isArray;function X(){}var et={H:null,A:null,T:null,S:null},xt=Object.prototype.hasOwnProperty;function Mt(g,U,ct){var it=ct.ref;return{$$typeof:i,type:g,key:U,ref:it!==void 0?it:null,props:ct}}function mt(g,U){return Mt(g.type,U,g.props)}function Nt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function B(g){var U={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(ct){return U[ct]})}var nt=/\/+/g;function rt(g,U){return typeof g=="object"&&g!==null&&g.key!=null?B(""+g.key):U.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(X,X):(g.status="pending",g.then(function(U){g.status==="pending"&&(g.status="fulfilled",g.value=U)},function(U){g.status==="pending"&&(g.status="rejected",g.reason=U)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function A(g,U,ct,it,Ot){var Et=typeof g;(Et==="undefined"||Et==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Et){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Y:return kt=g._init,A(kt(g._payload),U,ct,it,Ot)}}if(kt)return Ot=Ot(g),kt=it===""?"."+rt(g,0):it,D(Ot)?(ct="",kt!=null&&(ct=kt.replace(nt,"$&/")+"/"),A(Ot,U,ct,"",function(se){return se})):Ot!=null&&(Nt(Ot)&&(Ot=mt(Ot,ct+(Ot.key==null||g&&g.key===Ot.key?"":(""+Ot.key).replace(nt,"$&/")+"/")+kt)),U.push(Ot)),1;kt=0;var P=it===""?".":it+":";if(D(g))for(var ht=0;ht<g.length;ht++)it=g[ht],Et=P+rt(it,ht),kt+=A(it,U,ct,Et,Ot);else if(ht=tt(g),typeof ht=="function")for(g=ht.call(g),ht=0;!(it=g.next()).done;)it=it.value,Et=P+rt(it,ht++),kt+=A(it,U,ct,Et,Ot);else if(Et==="object"){if(typeof g.then=="function")return A(G(g),U,ct,it,Ot);throw U=String(g),Error("Objects are not valid as a React child (found: "+(U==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":U)+"). If you meant to render a collection of children, use an array instead.")}return kt}function ut(g,U,ct){if(g==null)return g;var it=[],Ot=0;return A(g,it,"","",function(Et){return U.call(ct,Et,Ot++)}),it}function bt(g){if(g._status===-1){var U=g._result,ct=U();ct.then(function(it){(g._status===0||g._status===-1)&&(g._status=1,g._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(g._status===0||g._status===-1)&&(g._status=2,g._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),g._status===-1&&(g._status=0,g._result=ct)}if(g._status===1)return g._result.default;throw g._result}var pt=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var U=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(U))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function Se(g){var U=et.T,ct={};ct.types=U!==null?U.types:null,et.T=ct;try{var it=g(),Ot=et.S;Ot!==null&&Ot(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(X,pt)}catch(Et){pt(Et)}finally{U!==null&&ct.types!==null&&(U.types=ct.types),et.T=U}}function we(g){var U=et.T;if(U!==null){var ct=U.types;ct===null?U.types=[g]:ct.indexOf(g)===-1&&ct.push(g)}else Se(we.bind(null,g))}var Ne={map:ut,forEach:function(g,U,ct){ut(g,function(){U.apply(this,arguments)},ct)},count:function(g){var U=0;return ut(g,function(){U++}),U}Line truncated
|
*/var wh;function qy(){if(wh)return gt;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),B=Symbol.for("react.view_transition"),V=Symbol.iterator;function et(g){return g===null||typeof g!="object"?null:(g=V&&g[V]||g["@@iterator"],typeof g=="function"?g:null)}var ft={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},F=Object.assign,yt={};function _t(g,H,st){this.props=g,this.context=H,this.refs=yt,this.updater=st||ft}_t.prototype.isReactComponent={},_t.prototype.setState=function(g,H){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,H,"setState")},_t.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function kt(){}kt.prototype=_t.prototype;function Q(g,H,st){this.props=g,this.context=H,this.refs=yt,this.updater=st||ft}var dt=Q.prototype=new kt;dt.constructor=Q,F(dt,_t.prototype),dt.isPureReactComponent=!0;var R=Array.isArray;function X(){}var lt={H:null,A:null,T:null,S:null},pt=Object.prototype.hasOwnProperty;function Et(g,H,st){var ct=st.ref;return{$$typeof:i,type:g,key:H,ref:ct!==void 0?ct:null,props:st}}function mt(g,H){return Et(g.type,H,g.props)}function Rt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function k(g){var H={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(st){return H[st]})}var ut=/\/+/g;function U(g,H){return typeof g=="object"&&g!==null&&g.key!=null?k(""+g.key):H.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(X,X):(g.status="pending",g.then(function(H){g.status==="pending"&&(g.status="fulfilled",g.value=H)},function(H){g.status==="pending"&&(g.status="rejected",g.reason=H)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function L(g,H,st,ct,Mt){var Nt=typeof g;(Nt==="undefined"||Nt==="boolean")&&(g=null);var wt=!1;if(g===null)wt=!0;else switch(Nt){case"bigint":case"string":case"number":wt=!0;break;case"object":switch(g.$$typeof){case i:case r:wt=!0;break;case Y:return wt=g._init,L(wt(g._payload),H,st,ct,Mt)}}if(wt)return Mt=Mt(g),wt=ct===""?"."+U(g,0):ct,R(Mt)?(st="",wt!=null&&(st=wt.replace(ut,"$&/")+"/"),L(Mt,H,st,"",function(re){return re})):Mt!=null&&(Rt(Mt)&&(Mt=mt(Mt,st+(Mt.key==null||g&&g.key===Mt.key?"":(""+Mt.key).replace(ut,"$&/")+"/")+wt)),H.push(Mt)),1;wt=0;var tt=ct===""?".":ct+":";if(R(g))for(var ht=0;ht<g.length;ht++)ct=g[ht],Nt=tt+U(ct,ht),wt+=L(ct,H,st,Nt,Mt);else if(ht=et(g),typeof ht=="function")for(g=ht.call(g),ht=0;!(ct=g.next()).done;)ct=ct.value,Nt=tt+U(ct,ht++),wt+=L(ct,H,st,Nt,Mt);else if(Nt==="object"){if(typeof g.then=="function")return L(G(g),H,st,ct,Mt);throw H=String(g),Error("Objects are not valid as a React child (found: "+(H==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":H)+"). If you meant to render a collection of children, use an array instead.")}return wt}function it(g,H,st){if(g==null)return g;var ct=[],Mt=0;return L(g,ct,"","",function(Nt){return H.call(st,Nt,Mt++)}),ct}function jt(g){if(g._status===-1){var H=g._result,st=H();st.then(function(ct){(g._status===0||g._status===-1)&&(g._status=1,g._result=ct,st.status===void 0&&(st.status="fulfilled",st.value=ct))},function(ct){(g._status===0||g._status===-1)&&(g._status=2,g._result=ct,st.status===void 0&&(st.status="rejected",st.reason=ct))}),g._status===-1&&(g._status=0,g._result=st)}if(g._status===1)return g._result.default;throw g._result}var St=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var H=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(H))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function ye(g){var H=lt.T,st={};st.types=H!==null?H.types:null,lt.T=st;try{var ct=g(),Mt=lt.S;Mt!==null&&Mt(st,ct),typeof ct=="object"&&ct!==null&&typeof ct.then=="function"&&ct.then(X,St)}catch(Nt){St(Nt)}finally{H!==null&&st.types!==null&&(H.types=st.types),lt.T=H}}function we(g){var H=lt.T;if(H!==null){var st=H.types;st===null?H.types=[g]:st.indexOf(g)===-1&&st.push(g)}else ye(we.bind(null,g))}var ce={map:it,forEach:function(g,H,st){it(g,function(){H.apply(this,arguments)},st)},count:function(g){var H=0;return it(g,function(){H++}),H},Line truncated
|
||||||
* @license React
|
* @license React
|
||||||
* scheduler.production.js
|
* scheduler.production.js
|
||||||
*
|
*
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,A){var ut=G.length;G.push(A);t:for(;0<ut;){var bt=ut-1>>>1,pt=G[bt];if(0<y(pt,A))G[bt]=A,G[ut]=pt,ut=bt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var A=G[0],ut=G.pop();if(ut!==A){G[0]=ut;t:for(var bt=0,pt=G.length,Se=pt>>>1;bt<Se;){var we=2*(bt+1)-1,Ne=G[we],g=we+1,U=G[g];if(0>y(Ne,ut))g<pt&&0>y(U,Ne)?(G[bt]=U,G[g]=ut,bt=g):(G[bt]=Ne,G[we]=ut,bt=we);else if(g<pt&&0>y(U,ut))G[bt]=U,G[g]=ut,bt=g;else break t}}return A}function y(G,A){var ut=G.sortIndex-A.sortIndex;return ut!==0?ut:G.id-A.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var M=Date,S=M.now();i.unstable_now=function(){return M.now()-S}}var _=[],O=[],Y=1,x=null,H=3,L=!1,tt=!1,dt=!1,J=!1,gt=typeof setTimeout=="function"?setTimeout:null,_t=typeof clearTimeout=="function"?clearTimeout:null,Dt=typeof setImmediate<"u"?setImmediate:null;function Q(G){for(var A=h(O);A!==null;){if(A.callback===null)f(O);else if(A.startTime<=G)f(O),A.sortIndex=A.expirationTime,r(_,A);else break;A=h(O)}}function ft(G){if(dt=!1,Q(G),!tt)if(h(_)!==null)tt=!0,D||(D=!0,Nt());else{var A=h(O);A!==null&&rt(ft,A.startTime-G)}}var D=!1,X=-1,et=5,xt=-1;function Mt(){return J?!0:!(i.unstable_now()-xt<et)}function mt(){if(J=!1,D){var G=i.unstable_now();xt=G;var A=!0;try{t:{tt=!1,dt&&(dt=!1,_t(X),X=-1),L=!0;var ut=H;try{e:{for(Q(G),x=h(_);x!==null&&!(x.expirationTime>G&&Mt());){var bt=x.callback;if(typeof bt=="function"){x.callback=null,H=x.priorityLevel;var pt=bt(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Q(G),A=!0;break e}x===h(_)&&f(_),Q(G)}else f(_);x=h(_)}if(x!==null)A=!0;else{var Se=h(O);Se!==null&&rt(ft,Se.startTime-G),A=!1}}break t}finally{x=null,H=ut,L=!1}A=void 0}}finally{A?Nt():D=!1}}}var Nt;if(typeof Dt=="function")Nt=function(){Dt(mt)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,nt=B.port2;B.port1.onmessage=mt,Nt=function(){nt.postMessage(null)}}else Nt=function(){gt(mt,0)};function rt(G,A){X=gt(function(){G(i.unstable_now())},A)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var A=3;break;default:A=H}var ut=H;H=A;try{return G()}finally{H=ut}},i.unstable_requestPaint=function(){J=!0},i.unstable_runWithPriority=function(G,A){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=H;H=G;try{return A()}finally{H=ut}},i.unstable_scheduleCallback=function(G,A,ut){var bt=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?bt+ut:bt):ut=bt,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:A,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>bt?(G.sortIndex=ut,r(O,G),h(_)===null&&G===h(O)&&(dt?(_t(X),X=-1):dt=!0,rt(ft,ut-bt))):(G.sortIndex=pt,r(_,G),tt||L||(tt=!0,D||(D=!0,Nt()))),G},i.unstable_shouldYield=Mt,i.unstable_wrapCallback=function(G){var A=H;return function(){var ut=H;H=A;try{return G.apply(this,arguments)}finally{H=ut}}}})(oo)),oo}var Bh;function Qy(){return Bh||(Bh=1,ro.exports=Gy()),ro.exports}var fo={exports:{}},Ue={};/**
|
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,L){var it=G.length;G.push(L);t:for(;0<it;){var jt=it-1>>>1,St=G[jt];if(0<y(St,L))G[jt]=L,G[it]=St,it=jt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var L=G[0],it=G.pop();if(it!==L){G[0]=it;t:for(var jt=0,St=G.length,ye=St>>>1;jt<ye;){var we=2*(jt+1)-1,ce=G[we],g=we+1,H=G[g];if(0>y(ce,it))g<St&&0>y(H,ce)?(G[jt]=H,G[g]=it,jt=g):(G[jt]=ce,G[we]=it,jt=we);else if(g<St&&0>y(H,it))G[jt]=H,G[g]=it,jt=g;else break t}}return L}function y(G,L){var it=G.sortIndex-L.sortIndex;return it!==0?it:G.id-L.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var _=[],M=[],Y=1,x=null,B=3,V=!1,et=!1,ft=!1,F=!1,yt=typeof setTimeout=="function"?setTimeout:null,_t=typeof clearTimeout=="function"?clearTimeout:null,kt=typeof setImmediate<"u"?setImmediate:null;function Q(G){for(var L=h(M);L!==null;){if(L.callback===null)f(M);else if(L.startTime<=G)f(M),L.sortIndex=L.expirationTime,r(_,L);else break;L=h(M)}}function dt(G){if(ft=!1,Q(G),!et)if(h(_)!==null)et=!0,R||(R=!0,Rt());else{var L=h(M);L!==null&&U(dt,L.startTime-G)}}var R=!1,X=-1,lt=5,pt=-1;function Et(){return F?!0:!(i.unstable_now()-pt<lt)}function mt(){if(F=!1,R){var G=i.unstable_now();pt=G;var L=!0;try{t:{et=!1,ft&&(ft=!1,_t(X),X=-1),V=!0;var it=B;try{e:{for(Q(G),x=h(_);x!==null&&!(x.expirationTime>G&&Et());){var jt=x.callback;if(typeof jt=="function"){x.callback=null,B=x.priorityLevel;var St=jt(x.expirationTime<=G);if(G=i.unstable_now(),typeof St=="function"){x.callback=St,Q(G),L=!0;break e}x===h(_)&&f(_),Q(G)}else f(_);x=h(_)}if(x!==null)L=!0;else{var ye=h(M);ye!==null&&U(dt,ye.startTime-G),L=!1}}break t}finally{x=null,B=it,V=!1}L=void 0}}finally{L?Rt():R=!1}}}var Rt;if(typeof kt=="function")Rt=function(){kt(mt)};else if(typeof MessageChannel<"u"){var k=new MessageChannel,ut=k.port2;k.port1.onmessage=mt,Rt=function(){ut.postMessage(null)}}else Rt=function(){yt(mt,0)};function U(G,L){X=yt(function(){G(i.unstable_now())},L)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):lt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return B},i.unstable_next=function(G){switch(B){case 1:case 2:case 3:var L=3;break;default:L=B}var it=B;B=L;try{return G()}finally{B=it}},i.unstable_requestPaint=function(){F=!0},i.unstable_runWithPriority=function(G,L){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var it=B;B=G;try{return L()}finally{B=it}},i.unstable_scheduleCallback=function(G,L,it){var jt=i.unstable_now();switch(typeof it=="object"&&it!==null?(it=it.delay,it=typeof it=="number"&&0<it?jt+it:jt):it=jt,G){case 1:var St=-1;break;case 2:St=250;break;case 5:St=1073741823;break;case 4:St=1e4;break;default:St=5e3}return St=it+St,G={id:Y++,callback:L,priorityLevel:G,startTime:it,expirationTime:St,sortIndex:-1},it>jt?(G.sortIndex=it,r(M,G),h(_)===null&&G===h(M)&&(ft?(_t(X),X=-1):ft=!0,U(dt,it-jt))):(G.sortIndex=St,r(_,G),et||V||(et=!0,R||(R=!0,Rt()))),G},i.unstable_shouldYield=Et,i.unstable_wrapCallback=function(G){var L=B;return function(){var it=B;B=L;try{return G.apply(this,arguments)}finally{B=it}}}})(oo)),oo}var Bh;function Qy(){return Bh||(Bh=1,ro.exports=Gy()),ro.exports}var fo={exports:{}},Ue={};/**
|
||||||
* @license React
|
* @license React
|
||||||
* react-dom.production.js
|
* react-dom.production.js
|
||||||
*
|
*
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/var qh;function Xy(){if(qh)return Ue;qh=1;var i=go();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)x+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function S(Y,x,H){var L=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:L==null?null:L===M?M:""+L,children:Y,containerInfo:x,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function O(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(Y){return{$$typeof:C,_reason:Y}},Ue.createPortal=function(Y,x){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,H)},Ue.flushSync=function(Y){var x=_.T,H=f.p;try{if(_.T=null,f.p=2,Y)return Y()}finally{_.T=x,f.p=H,f.d.f()}},Ue.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},Ue.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},Ue.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin),tt=typeof x.integrity=="string"?x.integrity:void 0,dt=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;H==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:L,integrity:tt,fetchPriority:dt}):H==="script"&&f.d.X(Y,{crossOrigin:L,integrity:tt,fetchPriority:dt,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},Ue.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var H=O(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},Ue.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin);f.d.L(Y,H,{crossOrigin:L,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},Ue.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var H=O(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},Ue.requestFormReset=function(Y){f.d.r(Y)},Ue.unstable_batchedUpdates=function(Y,x){return Y(x)},Ue.useFormState=function(Y,x,H){return _.H.useFormState(Y,x,H)},Ue.useFormStatus=function(){return _.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Yh;function Ly(){if(Yh)return fo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),fo.exports=Xy(),fo.exports}/**
|
*/var qh;function Xy(){if(qh)return Ue;qh=1;var i=go();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var B=2;B<arguments.length;B++)x+="&args[]="+encodeURIComponent(arguments[B])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,B){var V=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:V==null?null:V===A?A:""+V,children:Y,containerInfo:x,implementation:B}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function M(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(Y){return{$$typeof:C,_reason:Y}},Ue.createPortal=function(Y,x){var B=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,B)},Ue.flushSync=function(Y){var x=_.T,B=f.p;try{if(_.T=null,f.p=2,Y)return Y()}finally{_.T=x,f.p=B,f.d.f()}},Ue.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},Ue.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},Ue.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var B=x.as,V=M(B,x.crossOrigin),et=typeof x.integrity=="string"?x.integrity:void 0,ft=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;B==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:V,integrity:et,fetchPriority:ft}):B==="script"&&f.d.X(Y,{crossOrigin:V,integrity:et,fetchPriority:ft,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},Ue.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var B=M(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:B,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},Ue.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var B=x.as,V=M(B,x.crossOrigin);f.d.L(Y,B,{crossOrigin:V,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},Ue.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var B=M(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:B,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},Ue.requestFormReset=function(Y){f.d.r(Y)},Ue.unstable_batchedUpdates=function(Y,x){return Y(x)},Ue.useFormState=function(Y,x,B){return _.H.useFormState(Y,x,B)},Ue.useFormStatus=function(){return _.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Yh;function Ly(){if(Yh)return fo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),fo.exports=Xy(),fo.exports}/**
|
||||||
* @license React
|
* @license React
|
||||||
* react-dom-client.production.js
|
* react-dom-client.production.js
|
||||||
*
|
*
|
||||||
@@ -44,37 +44,37 @@
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/var Gh;function Vy(){if(Gh)return ri;Gh=1;var i=Qy(),r=go(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function M(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(C(t)!==t)throw Error(f(188))}function O(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function L(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function tt(t){var e=[null,null],l=H(t);return l===null||dt(e,t,l.child,{foundSelf:!1}),e}function dt(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&dt(t,e,l.child,a))return!0;l=l.sibling}return!1}function J(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var gt=null,_t=null;function Dt(t,e,l){return t===l?!0:t===e?(gt=t,!0):!1}function Q(t,e,l){return t===l?(_t=t,!1):t===e?(_t!==null&&(gt=t),!0):!1}function ft(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function D(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var X=Object.assign,et=Symbol.for("react.element"),xt=Symbol.for("react.transitional.element"),Mt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Nt=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),nt=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),A=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),bt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),U=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Ot=Symbol.for("react.client.reference");function Et(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ot?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case B:return"Profiler";case Nt:return"StrictMode";case A:return"Suspense";case ut:return"SuspenseList";case Se:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Mt:return"Portal";case rt:return t.displayName||"Context";case nt:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case bt:return e=t.displayName||null,e!==null?e:Et(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Et(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,se={pending:!1,data:null,method:null,actiLine truncated
|
*/var Gh;function Vy(){if(Gh)return ri;Gh=1;var i=Qy(),r=go(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(C(t)!==t)throw Error(f(188))}function M(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function B(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function V(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function et(t){var e=[null,null],l=B(t);return l===null||ft(e,t,l.child,{foundSelf:!1}),e}function ft(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&ft(t,e,l.child,a))return!0;l=l.sibling}return!1}function F(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var yt=null,_t=null;function kt(t,e,l){return t===l?!0:t===e?(yt=t,!0):!1}function Q(t,e,l){return t===l?(_t=t,!1):t===e?(_t!==null&&(yt=t),!0):!1}function dt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function R(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var X=Object.assign,lt=Symbol.for("react.element"),pt=Symbol.for("react.transitional.element"),Et=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Rt=Symbol.for("react.strict_mode"),k=Symbol.for("react.profiler"),ut=Symbol.for("react.consumer"),U=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),L=Symbol.for("react.suspense"),it=Symbol.for("react.suspense_list"),jt=Symbol.for("react.memo"),St=Symbol.for("react.lazy"),ye=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),ce=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),H=Symbol.for("react.recoverable"),st=Symbol.iterator;function ct(t){return t===null||typeof t!="object"?null:(t=st&&t[st]||t["@@iterator"],typeof t=="function"?t:null)}var Mt=Symbol.for("react.client.reference");function Nt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Mt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case k:return"Profiler";case Rt:return"StrictMode";case L:return"Suspense";case it:return"SuspenseList";case ye:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Et:return"Portal";case U:return t.displayName||"Context";case ut:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case jt:return e=t.displayName||null,e!==null?e:Nt(t.type)||"Memo";case St:e=t._payload,t=t._init;try{return Nt(t(e))}catch{}}return null}var wt=Array.isArray,tt=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,re={pending:!1,data:null,method:null,actioLine truncated
|
||||||
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
|
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||||
`+He+t+re}var fl=!1;function $l(t,e){if(!t||fl)return"";fl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var R=function(){throw Error()};if(Object.defineProperty(R.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(R,[])}catch(V){var p=V}Reflect.construct(t,[],R)}else{try{R.call()}catch(V){p=V}R=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),R=!0,new t}finally{R&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(V){p=V}(R=t())&&typeof R.catch=="function"&&R.catch(function(){})}}catch(V){if(V&&p&&typeof V.stack=="string")return[V.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
|
`+He+t+oe}var fl=!1;function $l(t,e){if(!t||fl)return"";fl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(Z){var p=Z}Reflect.construct(t,[],z)}else{try{z.call()}catch(Z){p=Z}z=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new t}finally{z&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(Z){p=Z}(z=t())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(Z){if(Z&&p&&typeof Z.stack=="string")return[Z.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
|
||||||
`),j=o.split(`
|
`),b=o.split(`
|
||||||
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var N=`
|
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<b.length&&!b[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===b.length)for(a=m.length-1,n=b.length-1;1<=a&&0<=n&&m[a]!==b[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==b[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==b[n]){var N=`
|
||||||
`+m[a].replace(" at new "," at ");return t.displayName&&N.includes("<anonymous>")&&(N=N.replace("<anonymous>",t.displayName)),N}while(1<=a&&0<=n);break}}}finally{fl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?oe(l):""}function ya(t,e){switch(t.tag){case 26:case 27:case 5:return oe(t.type);case 16:return oe("Lazy");case 13:return t.child!==e&&e!==null?oe("Suspense Fallback"):oe("Suspense");case 19:return oe("SuspenseList");case 0:case 15:return $l(t.type,!1);case 11:return $l(t.type.render,!1);case 1:return $l(t.type,!0);case 31:return oe("Activity");case 30:return oe("ViewTransition");default:return""}}function kl(t){try{var e="",l=null;do e+=ya(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
|
`+m[a].replace(" at new "," at ");return t.displayName&&N.includes("<anonymous>")&&(N=N.replace("<anonymous>",t.displayName)),N}while(1<=a&&0<=n);break}}}finally{fl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?fe(l):""}function ya(t,e){switch(t.tag){case 26:case 27:case 5:return fe(t.type);case 16:return fe("Lazy");case 13:return t.child!==e&&e!==null?fe("Suspense Fallback"):fe("Suspense");case 19:return fe("SuspenseList");case 0:case 15:return $l(t.type,!1);case 11:return $l(t.type.render,!1);case 1:return $l(t.type,!0);case 31:return fe("Activity");case 30:return fe("ViewTransition");default:return""}}function kl(t){try{var e="",l=null;do e+=ya(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
|
||||||
Error generating stack: `+a.message+`
|
Error generating stack: `+a.message+`
|
||||||
`+a.stack}}var te=Object.prototype.hasOwnProperty,tl=i.unstable_scheduleCallback,Be=i.unstable_cancelCallback,Nl=i.unstable_shouldYield,dl=i.unstable_requestPaint,$t=i.unstable_now,Hl=i.unstable_getCurrentPriorityLevel,vn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Il=i.unstable_LowPriority,ga=i.unstable_IdlePriority,fu=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function ml(t){if(typeof fu=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var q=256,k=262144,I=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function qt(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function hl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function w(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function Z(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function K(){var t=I;return I<<=1,(I&62914560)===0&&(I=4194304),t}function Yt(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function St(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qe(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var N=31-Ae(l),R=1<<N;o[N]=0,m[N]=-1;var p=j[N];if(p!==null)for(j[N]=null,N=0;N<p.length;N++){var E=p[N];E!==null&&(E.lane&=-536870913)}l&=~R}a!==0&&Cl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function Cl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function yn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function ta(t,e){var l=e&-e;return l=(l&42)!==0?1:Bl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Bl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function yu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function gu(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function Ke(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var ne=Math.random().toString(36).slice(2),ye="__reactFiber$"+ne,Je="__reactProps$"+ne,gn="__reactContainer$"+ne,xo="__reactEvents$"+ne,Ph="__reactListeners$"+ne,tv="__reactHandles$"+ne,bo="__reactResources$"+ne,pu="__reactMarker$"+ne,fi="__reactLoad$"+ne;function di(t){delete t[ye],delete t[Je],delete t[Ph],delete t[tv]}function Za(t){var e;if(e=t[ye])return e;for(var l=t.parentNode;l;){if(e=l[gn]||l[ye]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[ye])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function pn(t){if(t=t[ye]||t[gn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function Su(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Sn(t){var e=t[bo];return e||(e=t[bo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[pu]=!0}function jo(t){t[fi]=void 0}var To=new Set,_o={};function Ka(t,e){xn(t,e),xn(t+"Capture",e)}function xn(t,eLine truncated
|
`+a.stack}}var te=Object.prototype.hasOwnProperty,tl=i.unstable_scheduleCallback,Be=i.unstable_cancelCallback,Nl=i.unstable_shouldYield,dl=i.unstable_requestPaint,$t=i.unstable_now,Hl=i.unstable_getCurrentPriorityLevel,vn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Il=i.unstable_LowPriority,ga=i.unstable_IdlePriority,fu=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function ml(t){if(typeof fu=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var q=256,w=262144,P=4194304;function nt(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function qt(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=nt(a):(s&=o,s!==0?n=nt(s):l||(l=o&~t,l!==0&&(n=nt(l))))):(o=a&~u,o!==0?n=nt(o):s!==0?n=nt(s):l||(l=a&~t,l!==0&&(n=nt(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function hl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function D(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function K(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function J(){var t=P;return P<<=1,(P&62914560)===0&&(P=4194304),t}function Yt(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function xt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qe(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,b=t.hiddenUpdates;for(l=s&~l;0<l;){var N=31-Ae(l),z=1<<N;o[N]=0,m[N]=-1;var p=b[N];if(p!==null)for(b[N]=null,N=0;N<p.length;N++){var E=p[N];E!==null&&(E.lane&=-536870913)}l&=~z}a!==0&&Cl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function Cl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function yn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function ta(t,e){var l=e&-e;return l=(l&42)!==0?1:Bl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Bl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function yu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function gu(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function Ke(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var ne=Math.random().toString(36).slice(2),pe="__reactFiber$"+ne,Je="__reactProps$"+ne,gn="__reactContainer$"+ne,xo="__reactEvents$"+ne,Ph="__reactListeners$"+ne,tv="__reactHandles$"+ne,jo="__reactResources$"+ne,pu="__reactMarker$"+ne,fi="__reactLoad$"+ne;function di(t){delete t[pe],delete t[Je],delete t[Ph],delete t[tv]}function Za(t){var e;if(e=t[pe])return e;for(var l=t.parentNode;l;){if(e=l[gn]||l[pe]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[pe])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function pn(t){if(t=t[pe]||t[gn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function Su(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Sn(t){var e=t[jo];return e||(e=t[jo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Te(t){t[pu]=!0}function bo(t){t[fi]=void 0}var To=new Set,_o={};function Ka(t,e){xn(t,e),xn(t+"Capture",e)}function xn(t,eLine truncated
|
||||||
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||jn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&jn(t,""+a);else return;break;case"className":hi(t,"class",a);break;case"tabIndex":hi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":hi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){hi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=ql);return;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=vi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Rt("beforetoggle",t),Rt("toggle",t),mi(t,"popover",a);break;case"xlinkActuate":ea(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ea(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ea(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ea(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ea(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ea(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ea(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ea(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ea(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":mi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,mi(t,l,a);else return}Bt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")jn(t,a);else if(typeof a=="number"||typeof a=="bigint")jn(t,""+a);else return;break;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scroLine truncated
|
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||bn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&bn(t,""+a);else return;break;case"className":hi(t,"class",a);break;case"tabIndex":hi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":hi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){hi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=ql);return;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=vi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":zt("beforetoggle",t),zt("toggle",t),mi(t,"popover",a);break;case"xlinkActuate":ea(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ea(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ea(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ea(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ea(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ea(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ea(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ea(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ea(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":mi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,mi(t,l,a);else return}Bt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")bn(t,a);else if(typeof a=="number"||typeof a=="bigint")bn(t,""+a);else return;break;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scroLine truncated
|
||||||
`;Yt&&(qe+=Yt+`
|
`;Yt&&(qe+=Yt+`
|
||||||
`),St&&(qe+=(Array.isArray(St)?St.join(`
|
`),xt&&(qe+=(Array.isArray(xt)?xt.join(`
|
||||||
`):String(St))+`
|
`):String(xt))+`
|
||||||
`),qe+=JSON.stringify(K,null,2).slice(0,4e3),_l(qe),se("已返回登录信息,见下方")}catch(K){se("发起失败:"+K.message)}},[]),I=d.useCallback(async()=>{const w=tt.trim();if(!w){L("请填要查配额的用户 id");return}try{const Z=await W(F.adminQuotaUser(w));L("用户 "+w.slice(0,8)+":本月用量 "+iu(Z.used_usd_micros)+" / 配额 "+iu(Z.quota_usd_micros)+(Z.remaining_usd_micros!==null&&Z.remaining_usd_micros!==void 0?"(剩余 "+iu(Z.remaining_usd_micros)+")":"")+" | 月份 "+(Z.month||""))}catch(Z){L(Dc(Z))}},[tt]),at=d.useCallback(async(w,Z)=>{const K=w.trim();if(!K){L("请填要设置配额的用户 id");return}let Yt;if(!Z.trim())Yt=null;else{const St=Number(Z.trim());if(!Number.isFinite(St)||St<0){L("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Yt=Math.round(St*1e6)}try{const St=await W(F.adminQuotas,"POST",{user_id:K,quota_usd_micros:Yt});L("配额已设置:用户 "+K.slice(0,8)+":本月用量 "+iu(St.used_usd_micros)+" / 配额 "+iu(St.quota_usd_micros)+(St.remaining_usd_micros!==null&&St.remaining_usd_micros!==void 0?"(剩余 "+iu(St.remaining_usd_micros)+")":"")+" | 月份 "+(St.month||""))}catch(St){L(Dc(St))}},[]),qt=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const Z=(await W(F.adminSweep,"POST",{})).swept||[];L("sweep 完成,清理 "+Z.length+" 个任务"+(Z.length?":"+Z.join("、"):""))}catch(w){L(Dc(w))}},[]),hl=d.useCallback(async()=>{try{const w=await W(F.adminBackup,"POST",{});L("备份完成:backend="+w.backend+" | 路径 "+w.path+" | 大小 "+w.size_bytes+" 字节 | 任务 "+w.task_count+" 行")}catch(w){L(Dc(w))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Y,adminResult:H,adminUserId:tt,setAdminUserId:dt,projects:J,projectId:_t,setProjectId:Dt,projectMsg:Q,githubRepos:D,workers:et,workersSummary:Mt,workersError:Nt,members:nt,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:g,skillMsg:ct,skillError:Ot,mcpMsg:kt,mcpOauthMsg:ht,mcpOauthBox:La,envs:Ve,envCount:Qt,envMsg:Wl,envRepoLine:va,envRepoBar:Ul,envRepoMsg:El,envSelect:re,setEnvSelect:oe,models:fl,modelCustomVisible:ya,refreshHealth:Be,loadQuota:Nl,probeAdmin:dl,refreshProjects:$t,loadGithubRepos:Hl,loadRelayModels:vn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Il,refreshSkills:ga,saveKeyInput:fu,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:ml,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:q,mcpOauth:k,adminQuota:I,adminSetQuota:at,adminSweep:qt,adminBackup:hl}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(0),[H,L]=d.useState(0),[tt,dt]=d.useState({}),J=d.useRef(0),gt=d.useRef({status:"",executor:"",q:""});gt.current={status:y,executor:M,q:_};const _t=d.useRef(""),Dt=d.useCallback(async(nt,rt,G)=>{const A=nt!==void 0?nt:rt;if(!A){r([]),L(0),dt({});return}_t.current=A;const ut=G!==void 0?G:J.current,bt=gt.current;try{const pt=await W(F.tasksPage(A,mo,ut*mo,bt.status,bt.executor,bt.q));r(pt.tasks||[]),L(pt.total??(pt.tasks||[]).length),dt(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Q=d.useCallback(nt=>{const rt=Math.max(0,nt);J.current=rt,x(rt)},[]),ft=d.useCallback(nt=>{Q(nt),Dt(_t.current||void 0,void 0,nt)},[Dt,Q]);d.useEffect(()=>{if(!_t.current)return;const nt=setTimeout(()=>{Dt(_t.current,void 0,0)},300);return()=>clearTimeout(nt)},[y,M,_]);const D=d.useCallback(nt=>{C(nt),Q(0)},[Q]),X=d.useCallback(nt=>{S(nt),Q(0)},[Q]),et=d.useCallback(nt=>{O(nt),Q(0)},[Q]),xt=d.useCallback(async nt=>{Q(0),await Dt(nt,void 0,0)},[Dt,Q]),Mt=Math.max(1,Math.ceil(H/mo)),mt=H===0?"共 0 个任务":"共 "+H+" 个任务,第 "+(Y+1)+" / "+Mt+" 页",Nt=i,B=d.useCallback(async(nt,rt)=>{const G={...nt};rt.length&&(G.upload_id=await tg(rt));const A=await W(F.tasksCreate,"POST",G);return"任务已建:"+pe(A.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:D,executorFilter:M,setExecutorFilter:X,searchText:_,setSearchText:et,filteredTasks:Nt,taskCountText:mt,refreshTasks:Dt,refreshTasksFirstPage:xt,page:Y,totalPages:Mt,taskTotal:H,taskStatusCounts:tt,goToPage:ft,dispatch:B}}function ng(i,r){const[h,f]=d.useState(null),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(""),[H,L]=d.useState(void 0),[tt,dt]=d.useState(!1),[J,gt]=d.useState(""),[_t,Dt]=d.useState([]),[Q,ft]=d.useState([]),[D,X]=d.useState(""),[et,xt]=d.useState([]),[Mt,mt]=d.useState(""),[Nt,B]=d.useState(""),[nt,rt]=d.useState([]),[G,A]=d.useState(""),[ut,bt]=d.useState(""),[pt,Se]=d.useState(""),[we,Ne]=d.useState(""),[g,U]=d.useState([]),[ct,it]=d.useState([]),[Ot,Et]=d.useState(""),[kt,P]=d.useState([]),[ht,se]=d.useState(""),[La,_l]=d.useState(""),[Ve,ae]=d.useState(""),[Qt,ve]=d.useState(""),[Wl,xe]=d.useState([]),[va,Ze]=d.useState(""),Line truncated
|
`),qe+=JSON.stringify(J,null,2).slice(0,4e3),_l(qe),re("已返回登录信息,见下方")}catch(J){re("发起失败:"+J.message)}},[]),P=d.useCallback(async()=>{const D=et.trim();if(!D){V("请填要查配额的用户 id");return}try{const K=await $(W.adminQuotaUser(D));V("用户 "+D.slice(0,8)+":本月用量 "+iu(K.used_usd_micros)+" / 配额 "+iu(K.quota_usd_micros)+(K.remaining_usd_micros!==null&&K.remaining_usd_micros!==void 0?"(剩余 "+iu(K.remaining_usd_micros)+")":"")+" | 月份 "+(K.month||""))}catch(K){V(Dc(K))}},[et]),nt=d.useCallback(async(D,K)=>{const J=D.trim();if(!J){V("请填要设置配额的用户 id");return}let Yt;if(!K.trim())Yt=null;else{const xt=Number(K.trim());if(!Number.isFinite(xt)||xt<0){V("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Yt=Math.round(xt*1e6)}try{const xt=await $(W.adminQuotas,"POST",{user_id:J,quota_usd_micros:Yt});V("配额已设置:用户 "+J.slice(0,8)+":本月用量 "+iu(xt.used_usd_micros)+" / 配额 "+iu(xt.quota_usd_micros)+(xt.remaining_usd_micros!==null&&xt.remaining_usd_micros!==void 0?"(剩余 "+iu(xt.remaining_usd_micros)+")":"")+" | 月份 "+(xt.month||""))}catch(xt){V(Dc(xt))}},[]),qt=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const K=(await $(W.adminSweep,"POST",{})).swept||[];V("sweep 完成,清理 "+K.length+" 个任务"+(K.length?":"+K.join("、"):""))}catch(D){V(Dc(D))}},[]),hl=d.useCallback(async()=>{try{const D=await $(W.adminBackup,"POST",{});V("备份完成:backend="+D.backend+" | 路径 "+D.path+" | 大小 "+D.size_bytes+" 字节 | 任务 "+D.task_count+" 行")}catch(D){V(Dc(D))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:A,adminGate:_,adminBody:Y,adminResult:B,adminUserId:et,setAdminUserId:ft,projects:F,projectId:_t,setProjectId:kt,projectMsg:Q,githubRepos:R,workers:lt,workersSummary:Et,workersError:Rt,members:ut,memberMsg:G,memberError:it,skills:St,apps:we,mcpServers:g,skillMsg:st,skillError:Mt,mcpMsg:wt,mcpOauthMsg:ht,mcpOauthBox:La,envs:Ve,envCount:Qt,envMsg:Wl,envRepoLine:va,envRepoBar:Ul,envRepoMsg:El,envSelect:oe,setEnvSelect:fe,models:fl,modelCustomVisible:ya,refreshHealth:Be,loadQuota:Nl,probeAdmin:dl,refreshProjects:$t,loadGithubRepos:Hl,loadRelayModels:vn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Il,refreshSkills:ga,saveKeyInput:fu,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:ml,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:q,mcpOauth:w,adminQuota:P,adminSetQuota:nt,adminSweep:qt,adminBackup:hl}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,C]=d.useState(""),[A,S]=d.useState(""),[_,M]=d.useState(""),[Y,x]=d.useState(0),[B,V]=d.useState(0),[et,ft]=d.useState({}),F=d.useRef(0),yt=d.useRef({status:"",executor:"",q:""});yt.current={status:y,executor:A,q:_};const _t=d.useRef(""),kt=d.useCallback(async(ut,U,G)=>{const L=ut!==void 0?ut:U;if(!L){r([]),V(0),ft({});return}_t.current=L;const it=G!==void 0?G:F.current,jt=yt.current;try{const St=await $(W.tasksPage(L,mo,it*mo,jt.status,jt.executor,jt.q));r(St.tasks||[]),V(St.total??(St.tasks||[]).length),ft(St.status_counts||{}),f("")}catch(St){f("加载失败:"+St.message)}},[]),Q=d.useCallback(ut=>{const U=Math.max(0,ut);F.current=U,x(U)},[]),dt=d.useCallback(ut=>{Q(ut),kt(_t.current||void 0,void 0,ut)},[kt,Q]);d.useEffect(()=>{if(!_t.current)return;const ut=setTimeout(()=>{kt(_t.current,void 0,0)},300);return()=>clearTimeout(ut)},[y,A,_]);const R=d.useCallback(ut=>{C(ut),Q(0)},[Q]),X=d.useCallback(ut=>{S(ut),Q(0)},[Q]),lt=d.useCallback(ut=>{M(ut),Q(0)},[Q]),pt=d.useCallback(async ut=>{Q(0),await kt(ut,void 0,0)},[kt,Q]),Et=Math.max(1,Math.ceil(B/mo)),mt=B===0?"共 0 个任务":"共 "+B+" 个任务,第 "+(Y+1)+" / "+Et+" 页",Rt=i,k=d.useCallback(async(ut,U)=>{const G={...ut};U.length&&(G.upload_id=await tg(U));const L=await $(W.tasksCreate,"POST",G);return"任务已建:"+xe(L.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:R,executorFilter:A,setExecutorFilter:X,searchText:_,setSearchText:lt,filteredTasks:Rt,taskCountText:mt,refreshTasks:kt,refreshTasksFirstPage:pt,page:Y,totalPages:Et,taskTotal:B,taskStatusCounts:et,goToPage:dt,dispatch:k}}function ng(i,r){const[h,f]=d.useState(null),[y,C]=d.useState(""),[A,S]=d.useState(""),[_,M]=d.useState(""),[Y,x]=d.useState(""),[B,V]=d.useState(void 0),[et,ft]=d.useState(!1),[F,yt]=d.useState(""),[_t,kt]=d.useState([]),[Q,dt]=d.useState([]),[R,X]=d.useState(""),[lt,pt]=d.useState([]),[Et,mt]=d.useState(""),[Rt,k]=d.useState(""),[ut,U]=d.useState([]),[G,L]=d.useState(""),[it,jt]=d.useState(""),[St,ye]=d.useState(""),[we,ce]=d.useState(""),[g,H]=d.useState([]),[st,ct]=d.useState([]),[Mt,Nt]=d.useState(""),[wt,tt]=d.useState([]),[ht,re]=d.useState(""),[La,_l]=d.useState(""),[Ve,ae]=d.useState(""),[Qt,ge]=d.useState(""),[Wl,je]=d.useState([]),[va,Ze]=d.useState(""),[Ul,wl]=Line truncated
|
||||||
|
|
||||||
`);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
|
`);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
|
||||||
`).filter(Ke=>Ke.indexOf("data: ")===0).map(Ke=>Ke.slice(6)).join(`
|
`).filter(Ke=>Ke.indexOf("data: ")===0).map(Ke=>Ke.slice(6)).join(`
|
||||||
`);if(gu)try{const Ke=JSON.parse(gu);if(Ke.type==="snapshot")at=Ke.entries||[],Object.keys(qt).forEach(ne=>delete qt[ne]),at.forEach((ne,ye)=>{ne.id&&(qt[ne.id]=ye)}),hl=Ke.note||"",rt([...at]),A(hl),$l(q);else if(Ke.type==="entry"){const ne=Ke.entry;ne.id&&ne.id in qt?at[qt[ne.id]]=ne:(ne.id&&(qt[ne.id]=at.length),at.push(ne)),rt([...at]),$l(q)}else Ke.type==="done"&&(w=!0,Z=Ke.status,bt(Z))}catch{}}if(w)break}}catch(K){if(K&&K.name==="AbortError")return;A("链路流中断:"+K.message);return}w&&r()},[oe,$l,r]),be=d.useCallback(async q=>{oe();try{const k=await W(F.task(q));f(k);let I="";k.result_text&&(I+=`=== result ===
|
`);if(gu)try{const Ke=JSON.parse(gu);if(Ke.type==="snapshot")nt=Ke.entries||[],Object.keys(qt).forEach(ne=>delete qt[ne]),nt.forEach((ne,pe)=>{ne.id&&(qt[ne.id]=pe)}),hl=Ke.note||"",U([...nt]),L(hl),$l(q);else if(Ke.type==="entry"){const ne=Ke.entry;ne.id&&ne.id in qt?nt[qt[ne.id]]=ne:(ne.id&&(qt[ne.id]=nt.length),nt.push(ne)),U([...nt]),$l(q)}else Ke.type==="done"&&(D=!0,K=Ke.status,jt(K))}catch{}}if(D)break}}catch(J){if(J&&J.name==="AbortError")return;L("链路流中断:"+J.message);return}D&&r()},[fe,$l,r]),be=d.useCallback(async q=>{fe();try{const w=await $(W.task(q));f(w);let P="";w.result_text&&(P+=`=== result ===
|
||||||
`+k.result_text.slice(0,8e3)+`
|
`+w.result_text.slice(0,8e3)+`
|
||||||
`),k.error_text&&(I+=`=== error ===
|
`),w.error_text&&(P+=`=== error ===
|
||||||
`+k.error_text.slice(0,8e3)+`
|
`+w.error_text.slice(0,8e3)+`
|
||||||
`),I||(I="(暂无 result/error 输出)"),C(I),x(""),L(void 0),kl(q),dt(!1),gt(""),se(""),ve(""),vn(k.status);const at=i(k.project_id),qt=at==="maintainer"||at==="owner",hl=at==="contributor"||qt,w=su.includes(k.status),Z=[];["failed","interrupted","merge-failed"].includes(k.status)&&qt||Z.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(qt?"":",且仅 maintainer 可重试")),!w&&hl||Z.push("取消:不可用——"+(w?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),k.status==="completed"&&qt||Z.push("合并:不可用——仅 completed 可合并"+(qt?"":",且仅 maintainer 可合并")),k.status==="merged"&&qt||Z.push("提 PR:不可用——仅 merged 可提 PR"+(qt?"":",且仅 maintainer 可提 PR")),O(Z.join(";")),S(""),Va(q,k.status),fl(q),ya(k),tl(q),te(q),dl(q),$t(q),Hl(q),Be(q),Nl(q)}catch(k){f(null),C("加载失败:"+k.message)}},[oe,Va,fl,ya,tl,te,dl,$t,Hl,Be,Nl,kl,i]),Il=d.useCallback(async()=>{const q=re.current;if(q){try{const k=await W(F.task(q.id));f(k);let I="";k.result_text&&(I+=`=== result ===
|
`),P||(P="(暂无 result/error 输出)"),C(P),x(""),V(void 0),kl(q),ft(!1),yt(""),re(""),ge(""),vn(w.status);const nt=i(w.project_id),qt=nt==="maintainer"||nt==="owner",hl=nt==="contributor"||qt,D=su.includes(w.status),K=[];["failed","interrupted","merge-failed"].includes(w.status)&&qt||K.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(qt?"":",且仅 maintainer 可重试")),!D&&hl||K.push("取消:不可用——"+(D?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),w.status==="completed"&&qt||K.push("合并:不可用——仅 completed 可合并"+(qt?"":",且仅 maintainer 可合并")),w.status==="merged"&&qt||K.push("提 PR:不可用——仅 merged 可提 PR"+(qt?"":",且仅 maintainer 可提 PR")),M(K.join(";")),S(""),Va(q,w.status),fl(q),ya(w),tl(q),te(q),dl(q),$t(q),Hl(q),Be(q),Nl(q)}catch(w){f(null),C("加载失败:"+w.message)}},[fe,Va,fl,ya,tl,te,dl,$t,Hl,Be,Nl,kl,i]),Il=d.useCallback(async()=>{const q=oe.current;if(q){try{const w=await $(W.task(q.id));f(w);let P="";w.result_text&&(P+=`=== result ===
|
||||||
`+k.result_text.slice(0,8e3)+`
|
`+w.result_text.slice(0,8e3)+`
|
||||||
`),k.error_text&&(I+=`=== error ===
|
`),w.error_text&&(P+=`=== error ===
|
||||||
`+k.error_text.slice(0,8e3)+`
|
`+w.error_text.slice(0,8e3)+`
|
||||||
`),C(I||"(暂无 result/error 输出)")}catch{}te(q.id),tl(q.id),Be(q.id),Nl(q.id),ya(q),fl(q.id),dl(q.id),$t(q.id),Hl(q.id),kl(q.id)}},[te,tl,Be,Nl,ya,fl,dl,$t,Hl,kl]),ga=d.useCallback(async q=>{const k=re.current;if(!k)return;const I={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(I[q]&&!window.confirm(I[q])))try{const at=await W(F.taskAction(k.id,q),"POST",{});S(q+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await be(k.id)}catch(at){S(q+" 失败:"+at.message)}},[be,r]),fu=d.useCallback(async()=>{const q=re.current;if(q)try{const k=await W(F.taskCost(q.id));x(JSON.stringify(k,null,2).slice(0,4e3))}catch(k){x("查成本失败:"+k.message)}},[]),du=d.useCallback(async q=>{const k=re.current;if(k){if(!q.trim()){gt("分支消息必填");return}if(!su.includes(k.status)){gt("仅终态任务可分支");return}try{const I=await W(F.taskFork(k.id),"POST",{message:q.trim()});gt("分支任务已建:"+I.id.slice(0,8)),r()}catch(I){gt("分支失败:"+I.message)}}},[r]),Pl=d.useCallback(async q=>{const k=re.current;if(k)try{await W(F.taskReviews(k.id),"POST",q),mt("评审已登记(requested)"),await Be(k.id)}catch(I){mt("登记评审需求失败:"+I.message)}},[Be]),Ce=d.useCallback(q=>{const k=re.current;if(!k)return;const I=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");I!==null&&W(F.taskReviewFindings(k.id,q),"POST",{findings:I}).then(()=>void Be(k.id)).catch(at=>mt("写 findings 失败:"+at.message))},[Be]),ml=d.useCallback((q,k)=>{const I=re.current;if(!I)return;const at=k==="approved"?"approve":"deny";W(F.taskApprovalDecision(I.id,q,at),"POST",{}).then(()=>{Et("已记录"+(k==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Nl(I.id)}).catch(qt=>Et("决定失败:"+qt.message))},[Nl]),Ae=d.useCallback(async(q,k,I)=>{const at=re.current;if(at){if(!k.trim()){se("目标任务 id 不能为空");return}if(!I.trim()){se("消息正文不能为空");return}try{const qt=await W(F.taskMessagesTo(k.trim()),"POST",{from_task_id:at.id,body:I.trim(),mode:q});se("已发送("+qt.delivery+")"),await dl(at.id)}catch(qt){se("发送失败:"+qt.message)}}},[dl]),mu=d.useCallback((q,k)=>{const I=re.current;if(!I)return;if(!["completed","failed","interrupted"].includes(I.status)){ve("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(q==="rename"){if(!k.trim()){ve("新线程名不能为空");return}at.name=k.trim()}W(F.taskThreadAction(I.id,q),"POST",at).then(qt=>{ve(q+" 已提交:"+JSON.stringify(qt).slice(0,300)),$t(I.id),Hl(I.id)}).catch(qt=>ve(q+" 失败:"+qt.message))},[$t,Hl]),hu=d.useCallback(async q=>{const k=re.current;if(!k)return;const I=q.trim();if(!I){Ze("消息不能为空");return}try{su.includes(k.status)&&k.status!=="merged"?(await W(F.taskContinue(k.id),"POST",{message:I}),Ze("已重新排队,同一会话继续"),r(),await be(k.id)):["queued","running"].includes(k.status)?(await W(F.taskFollowups(k.id),"POST",{body:I,mode:"direct"}),Ze("已直接发送"),await te(k.id),await tl(k.id)):Ze("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ze("发送失败:"+at.message)}},[be,r,te,tl]),vu=d.useCallback(async()=>{const q=re.current;if(!q){Ne("先选一个任务");return}try{const k=await W(F.taskTrace(q.id)),I=new Blob([JSON.stringify({task_id:q.id,status:k.status,note:k.note||"",count:(k.entries||[]).length,entries:k.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL(I),at.download="trace-"+q.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ne("已下载完整链路 "+(k.entries||[]).length+" 条")}catch(k){Ne("下载失败:"+k.message)}},[]);return d.useEffect(()=>()=>oe(),[oe]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Y,costSummary:H,forkVisible:tt,setForkVisible:dt,forkMsg:J,coordChildren:_t,agents:Q,agentsNote:D,reviews:et,reviewMsg:Mt,reviewError:Nt,traceEntries:nt,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:Se,traceMsg:we,followups:g,approvals:ct,approvalMsg:Ot,peerMessages:kt,peerMsg:ht,threadText:La,threadGateNote:Ve,threadMsg:Qt,threadList:Wl,chatMsg:va,chatHistory:Ul,showDetail:be,refreshDetailLists:Il,doAction:ga,taskCost:fu,forkTask:du,postReview:Pl,submitFindings:Ce,decideApproval:ml,postPeer:Ae,threadAction:mu,sendChatMessage:hu,downloadTrace:vu,refreshReviews:()=>void Be(),refreshApprovals:()=>void Nl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[C,M]=d.useState(null),[S,_]=d.useState(""),[O,Y]=d.useState(null),[x,H]=d.useState(""),L=d.useCallback(async Q=>{const ft=Q!==void 0?Q:i();if(!ft){h([]);return}try{const D=await W(F.projectAutomations(ft));h(D.automations||[])}catLine truncated
|
`),C(P||"(暂无 result/error 输出)")}catch{}te(q.id),tl(q.id),Be(q.id),Nl(q.id),ya(q),fl(q.id),dl(q.id),$t(q.id),Hl(q.id),kl(q.id)}},[te,tl,Be,Nl,ya,fl,dl,$t,Hl,kl]),ga=d.useCallback(async q=>{const w=oe.current;if(!w)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[q]&&!window.confirm(P[q])))try{const nt=await $(W.taskAction(w.id,q),"POST",{});S(q+" 已提交:"+JSON.stringify(nt).slice(0,300)),r(),await be(w.id)}catch(nt){S(q+" 失败:"+nt.message)}},[be,r]),fu=d.useCallback(async()=>{const q=oe.current;if(q)try{const w=await $(W.taskCost(q.id));x(JSON.stringify(w,null,2).slice(0,4e3))}catch(w){x("查成本失败:"+w.message)}},[]),du=d.useCallback(async q=>{const w=oe.current;if(w){if(!q.trim()){yt("分支消息必填");return}if(!su.includes(w.status)){yt("仅终态任务可分支");return}try{const P=await $(W.taskFork(w.id),"POST",{message:q.trim()});yt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){yt("分支失败:"+P.message)}}},[r]),Pl=d.useCallback(async q=>{const w=oe.current;if(w)try{await $(W.taskReviews(w.id),"POST",q),mt("评审已登记(requested)"),await Be(w.id)}catch(P){mt("登记评审需求失败:"+P.message)}},[Be]),Ce=d.useCallback(q=>{const w=oe.current;if(!w)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&$(W.taskReviewFindings(w.id,q),"POST",{findings:P}).then(()=>void Be(w.id)).catch(nt=>mt("写 findings 失败:"+nt.message))},[Be]),ml=d.useCallback((q,w)=>{const P=oe.current;if(!P)return;const nt=w==="approved"?"approve":"deny";$(W.taskApprovalDecision(P.id,q,nt),"POST",{}).then(()=>{Nt("已记录"+(w==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Nl(P.id)}).catch(qt=>Nt("决定失败:"+qt.message))},[Nl]),Ae=d.useCallback(async(q,w,P)=>{const nt=oe.current;if(nt){if(!w.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const qt=await $(W.taskMessagesTo(w.trim()),"POST",{from_task_id:nt.id,body:P.trim(),mode:q});re("已发送("+qt.delivery+")"),await dl(nt.id)}catch(qt){re("发送失败:"+qt.message)}}},[dl]),mu=d.useCallback((q,w)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ge("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const nt={};if(q==="rename"){if(!w.trim()){ge("新线程名不能为空");return}nt.name=w.trim()}$(W.taskThreadAction(P.id,q),"POST",nt).then(qt=>{ge(q+" 已提交:"+JSON.stringify(qt).slice(0,300)),$t(P.id),Hl(P.id)}).catch(qt=>ge(q+" 失败:"+qt.message))},[$t,Hl]),hu=d.useCallback(async q=>{const w=oe.current;if(!w)return;const P=q.trim();if(!P){Ze("消息不能为空");return}try{su.includes(w.status)&&w.status!=="merged"?(await $(W.taskContinue(w.id),"POST",{message:P}),Ze("已重新排队,同一会话继续"),r(),await be(w.id)):["queued","running"].includes(w.status)?(await $(W.taskFollowups(w.id),"POST",{body:P,mode:"direct"}),Ze("已直接发送"),await te(w.id),await tl(w.id)):Ze("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(nt){Ze("发送失败:"+nt.message)}},[be,r,te,tl]),vu=d.useCallback(async()=>{const q=oe.current;if(!q){ce("先选一个任务");return}try{const w=await $(W.taskTrace(q.id)),P=new Blob([JSON.stringify({task_id:q.id,status:w.status,note:w.note||"",count:(w.entries||[]).length,entries:w.entries||[]},null,2)],{type:"application/json"}),nt=document.createElement("a");nt.href=URL.createObjectURL(P),nt.download="trace-"+q.id.slice(0,8)+".json",document.body.appendChild(nt),nt.click(),nt.remove(),setTimeout(()=>URL.revokeObjectURL(nt.href),1e3),ce("已下载完整链路 "+(w.entries||[]).length+" 条")}catch(w){ce("下载失败:"+w.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:y,actionMsg:A,actionReasons:_,costBox:Y,costSummary:B,forkVisible:et,setForkVisible:ft,forkMsg:F,coordChildren:_t,agents:Q,agentsNote:R,reviews:lt,reviewMsg:Et,reviewError:Rt,traceEntries:ut,traceNote:G,traceDone:it,traceKind:St,setTraceKind:ye,traceMsg:we,followups:g,approvals:st,approvalMsg:Mt,peerMessages:wt,peerMsg:ht,threadText:La,threadGateNote:Ve,threadMsg:Qt,threadList:Wl,chatMsg:va,chatHistory:Ul,showDetail:be,refreshDetailLists:Il,doAction:ga,taskCost:fu,forkTask:du,postReview:Pl,submitFindings:Ce,decideApproval:ml,postPeer:Ae,threadAction:mu,sendChatMessage:hu,downloadTrace:vu,refreshReviews:()=>void Be(),refreshApprovals:()=>void Nl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[C,A]=d.useState(null),[S,_]=d.useState(""),[M,Y]=d.useState(null),[x,B]=d.useState(""),V=d.useCallback(async Q=>{const dt=Q!==void 0?Q:i();if(!dt){h([]);return}try{const R=await $(W.projectAutomations(dt));h(R.automations||[])}catLine truncated
|
||||||
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:yo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:yo(i)}}function pg(){const i=Lt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(tt=>({id:tt.id,role:tt.role,text:tt.text,label:tt.label,kind:tt.kind}));const H=new Set(_.map(tt=>tt.id)),L=i.traceEntries.filter(tt=>tt.id&&!H.has(tt.id)&&!String(tt.id).startsWith("fu-")).map((tt,dt)=>Lh(tt,dt));_=[..._,...L]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],L=i.traceEntries.filter(dt=>!String(dt.id||"").startsWith("fu-")).map((dt,J)=>Lh(dt,J)),tt=i.followups.map((dt,J)=>({id:"followup-"+J,role:"user",text:String(dt.body||""),label:"后续消息"}));_=[...H,...L,...tt]}const O=i.traceKind,Y=O?_.filter(H=>H.role==="user"||!H.kind||H.kind===O):_,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=su.includes(r.status),y=["queued","running"].includes(r.status),C=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Lt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=su.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),childrLine truncated
|
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:yo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:yo(i)}}function pg(){const i=Lt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(et=>({id:et.id,role:et.role,text:et.text,label:et.label,kind:et.kind}));const B=new Set(_.map(et=>et.id)),V=i.traceEntries.filter(et=>et.id&&!B.has(et.id)&&!String(et.id).startsWith("fu-")).map((et,ft)=>Lh(et,ft));_=[..._,...V]}else{const B=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],V=i.traceEntries.filter(ft=>!String(ft.id||"").startsWith("fu-")).map((ft,F)=>Lh(ft,F)),et=i.followups.map((ft,F)=>({id:"followup-"+F,role:"user",text:String(ft.body||""),label:"后续消息"}));_=[...B,...V,...et]}const M=i.traceKind,Y=M?_.filter(B=>B.role==="user"||!B.kind||B.kind===M):_,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=su.includes(r.status),y=["queued","running"].includes(r.status),C=f||y,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Lt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=su.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),childrLine truncated
|
||||||
|
|
||||||
`);for(;nt>=0;){const rt=mt.slice(0,nt);mt=mt.slice(nt+2);for(const G of rt.split(`
|
`);for(;ut>=0;){const U=mt.slice(0,ut);mt=mt.slice(ut+2);for(const G of U.split(`
|
||||||
`))if(G.startsWith("data: "))try{const A=JSON.parse(G.slice(6));A.waiting?S(A.task_status==="interrupted"?"任务已中断,等待续跑…":A.task_status==="paused"?"任务已暂停…":"等待会话…"):A.image&&(f("data:image/jpeg;base64,"+A.image),S("直播中"));const ut={};A.holder&&(ut.holder=A.holder),A.url&&(ut.url=A.url),A.title!==void 0&&(ut.title=A.title),Object.keys(ut).length>0&&C(bt=>({...bt||{},...ut}))}catch{}nt=mt.indexOf(`
|
`))if(G.startsWith("data: "))try{const L=JSON.parse(G.slice(6));L.waiting?S(L.task_status==="interrupted"?"任务已中断,等待续跑…":L.task_status==="paused"?"任务已暂停…":"等待会话…"):L.image&&(f("data:image/jpeg;base64,"+L.image),S("直播中"));const it={};L.holder&&(it.holder=L.holder),L.url&&(it.url=L.url),L.title!==void 0&&(it.title=L.title),Object.keys(it).length>0&&C(jt=>({...jt||{},...it}))}catch{}ut=mt.indexOf(`
|
||||||
|
|
||||||
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;O(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),J=d.useCallback(()=>{var D;(D=H.current)==null||D.abort(),x(!1),S("已停止")},[]),gt=d.useCallback(async D=>{if(r)try{const X=await W(F.taskBrowserControl(r.id),"POST",{holder:D});C(X),O("")}catch(X){O(X instanceof Error?X.message:String(X))}},[r]),_t=d.useCallback(async D=>{if(!r||(y==null?void 0:y.holder)!=="user"||!L.current)return;const X=L.current.getBoundingClientRect(),et=Math.round((D.clientX-X.left)/X.width*1280),xt=Math.round((D.clientY-X.top)/X.height*800);try{await W(F.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:xt})}catch(Mt){O(Mt instanceof Error?Mt.message:String(Mt))}},[r,y]),Dt=d.useCallback(async D=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){D.preventDefault();try{D.key.length===1?await W(F.taskBrowserInput(r.id),"POST",{type:"type",text:D.key}):await W(F.taskBrowserInput(r.id),"POST",{type:"key",key:D.key})}catch(X){O(X instanceof Error?X.message:String(X))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(y==null?void 0:y.holder)||"—",ft=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:M})]}),c.jsxs("span",{className:"muted",children:["会话 ",ft?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:J,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void dt(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void tt(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void gt("user"),disabled:!ft,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void gt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:L,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:D=>void _t(D),onKeyDown:D=>void Dt(D),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ft?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Lt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[C,M]=d.useState(""),[S,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:O=>y(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>M(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:O=>_(O.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(ou,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",pe(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.sLine truncated
|
`)}}S("流已断开")}catch(lt){if(lt.name==="AbortError")return;M(lt instanceof Error?lt.message:String(lt)),S("未开始")}finally{x(!1)}},[r]),F=d.useCallback(()=>{var R;(R=B.current)==null||R.abort(),x(!1),S("已停止")},[]),yt=d.useCallback(async R=>{if(r)try{const X=await $(W.taskBrowserControl(r.id),"POST",{holder:R});C(X),M("")}catch(X){M(X instanceof Error?X.message:String(X))}},[r]),_t=d.useCallback(async R=>{if(!r||(y==null?void 0:y.holder)!=="user"||!V.current)return;const X=V.current.getBoundingClientRect(),lt=Math.round((R.clientX-X.left)/X.width*1280),pt=Math.round((R.clientY-X.top)/X.height*800);try{await $(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:lt,y:pt})}catch(Et){M(Et instanceof Error?Et.message:String(Et))}},[r,y]),kt=d.useCallback(async R=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){R.preventDefault();try{R.key.length===1?await $(W.taskBrowserInput(r.id),"POST",{type:"type",text:R.key}):await $(W.taskBrowserInput(r.id),"POST",{type:"key",key:R.key})}catch(X){M(X instanceof Error?X.message:String(X))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(y==null?void 0:y.holder)||"—",dt=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",dt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:F,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void ft(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void et(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void yt("user"),disabled:!dt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void yt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:V,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:R=>void _t(R),onKeyDown:R=>void kt(R),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:dt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function bg(){const i=Lt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[C,A]=d.useState(""),[S,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>y(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:M=>A(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:M=>_(M.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(ou,{children:i.reviews.map(M=>{let Y=M.target_type||"";return M.branch&&(Y+=":"+M.branch),M.sha&&(Y+=":"+String(M.sha).slice(0,12)),M.instructions&&(Y+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",xe(M.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.sLine truncated
|
||||||
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
|
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+12
-13
@@ -65,6 +65,12 @@ import time
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
# 统一 schema:task_attempts 的 PG 新库定义与台账定义是同一
|
||||||
|
# 字符串(cloud.attempt_ledger.TASK_ATTEMPTS_DDL),不在此另
|
||||||
|
# 写一份。attempt_ledger 只依赖标准库 time,与本模块无循环
|
||||||
|
# 导入;层规则上它也不属于 orchestration 模块集合。
|
||||||
|
from cloud.attempt_ledger import TASK_ATTEMPTS_DDL as _TASK_ATTEMPTS_DDL
|
||||||
|
|
||||||
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
|
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
|
||||||
# PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位
|
# PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位
|
||||||
# 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。
|
# 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。
|
||||||
@@ -727,7 +733,7 @@ class PgCursor:
|
|||||||
info_cur.close()
|
info_cur.close()
|
||||||
self._fake_rows = [
|
self._fake_rows = [
|
||||||
PgRow((i, r[1], r[2],
|
PgRow((i, r[1], r[2],
|
||||||
0 if r[3] == "NO" else 1, r[4], r[5]),
|
1 if r[3] == "NO" else 0, r[4], r[5]),
|
||||||
("cid", "name", "type", "notnull",
|
("cid", "name", "type", "notnull",
|
||||||
"dflt_value", "pk"))
|
"dflt_value", "pk"))
|
||||||
for i, r in enumerate(raw)]
|
for i, r in enumerate(raw)]
|
||||||
@@ -876,18 +882,11 @@ POSTGRES_SCHEMA = (
|
|||||||
"created_at DOUBLE PRECISION)",
|
"created_at DOUBLE PRECISION)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
|
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
|
||||||
" ON task_steps(task_id, step_no)",
|
" ON task_steps(task_id, step_no)",
|
||||||
# 尝试账本(v6,见 cloud/attempts.py):(task_id, attempt_no)
|
# 尝试账本(统一 schema,见 cloud/attempt_ledger.py):
|
||||||
# 唯一;PG 新库一次建齐,老库走 server/worker ensure。
|
# (task_id, attempt_no) 唯一;PG 新库一次建齐,老库走
|
||||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
# server/worker ensure 迁移到同一列集。此处是导入的统
|
||||||
"task_id TEXT, "
|
# 一定义本身,不是第二份 DDL。
|
||||||
"attempt_no INTEGER, "
|
_TASK_ATTEMPTS_DDL,
|
||||||
"kind TEXT, "
|
|
||||||
"interrupted_reason TEXT, "
|
|
||||||
"checkpoint_round INTEGER, "
|
|
||||||
"result_text TEXT, "
|
|
||||||
"error_text TEXT, "
|
|
||||||
"created_at DOUBLE PRECISION, "
|
|
||||||
"PRIMARY KEY(task_id, attempt_no))",
|
|
||||||
# 审计循环状态(audit loop):task_id 单行主键;PG 新库一次
|
# 审计循环状态(audit loop):task_id 单行主键;PG 新库一次
|
||||||
# 建齐,老库走 server/worker ensure(见 cloud/audit_loop.py)。
|
# 建齐,老库走 server/worker ensure(见 cloud/audit_loop.py)。
|
||||||
"CREATE TABLE IF NOT EXISTS task_audit_loop_state("
|
"CREATE TABLE IF NOT EXISTS task_audit_loop_state("
|
||||||
|
|||||||
+24
-16
@@ -21,16 +21,19 @@
|
|||||||
报告:表齐但钩子关 ≠ 就绪,钩子开但表不齐 ≠ 就绪。
|
报告:表齐但钩子关 ≠ 就绪,钩子开但表不齐 ≠ 就绪。
|
||||||
3. DDL 清单不另造:清单直接引用 attempt_ledger.PG_DDL
|
3. DDL 清单不另造:清单直接引用 attempt_ledger.PG_DDL
|
||||||
与 attempt_hook.PG_MARKER_DDL 两个常量(本模块不
|
与 attempt_hook.PG_MARKER_DDL 两个常量(本模块不
|
||||||
复制 DDL 文本,复制就会漂移)。另含 gap 3 旧表升级句
|
复制 DDL 文本,复制就会漂移)。老形态表升级走
|
||||||
attempt_ledger.OLD_TABLE_ALTER(仅当旧 DDL 建过表时
|
attempt_ledger.ensure_task_attempts_table 的统一列补齐
|
||||||
需要;gap 3 未合并,生产尚无旧表,属防御项)。
|
(仅当老形态建过表时需要;属防御项,真实 PG 16.15
|
||||||
|
升级路径已于 2026-11-06 实证,见 verification/
|
||||||
|
codex-task-attempts-pg-proof-20261106.md)。
|
||||||
4. 校验函数 verify_ledger_ready(conn):只读探针——查两
|
4. 校验函数 verify_ledger_ready(conn):只读探针——查两
|
||||||
张表的存在与必需列,不建表、不写行(读路径绝不改
|
张表的存在与必需列,不建表、不写行(读路径绝不改
|
||||||
schema,与 metrics-report 端点的只读纪律一致)。
|
schema,与 metrics-report 端点的只读纪律一致)。
|
||||||
5. 生产 PG 手动 DDL 仍是运维侧动作:本轨无生产 PG、无
|
5. 生产 PG 手动 DDL 仍是运维侧动作:升级路径本身已在
|
||||||
本地 PG,PG 证据与此前各轨同款——适配器级(? 占位符
|
真实 PG 16.15 上实证(见 verification/
|
||||||
经 cloud.db 翻译、DDL 仅用 PG 合法类型、无自增列),
|
codex-task-attempts-pg-proof-20261106.md),但“生产 PG
|
||||||
绝不写成“生产 PG 已执行”。运维执行清单见
|
已执行”仍须运维在生产库上跑过才算,绝不写成已执行。
|
||||||
|
运维执行清单见
|
||||||
LEDGER_DDL_CHECKLIST(顺序执行 + 每步验证命令)。
|
LEDGER_DDL_CHECKLIST(顺序执行 + 每步验证命令)。
|
||||||
|
|
||||||
零第三方依赖,只用标准库。conn 约定与 attempt_ledger
|
零第三方依赖,只用标准库。conn 约定与 attempt_ledger
|
||||||
@@ -42,11 +45,14 @@ from cloud.attempt_hook import PG_MARKER_DDL as _PG_MARKER_DDL
|
|||||||
from cloud.attempt_ledger import OLD_TABLE_ALTER as _OLD_TABLE_ALTER
|
from cloud.attempt_ledger import OLD_TABLE_ALTER as _OLD_TABLE_ALTER
|
||||||
from cloud.attempt_ledger import PG_DDL as _LEDGER_PG_DDL
|
from cloud.attempt_ledger import PG_DDL as _LEDGER_PG_DDL
|
||||||
|
|
||||||
# 必需列:缺任一列即视为表不齐(旧 DDL 建的表缺
|
# 必需列:统一 schema 的全部 12 列;缺任一列即视为表
|
||||||
# corrects_attempt_no 时走 OLD_TABLE_ALTER 补列)。
|
# 不齐(老形态建的表由 ensure_task_attempts_table 补齐
|
||||||
|
# 缺失列后复核,不再只补 corrects_attempt_no 一列)。
|
||||||
LEDGER_REQUIRED_COLUMNS = (
|
LEDGER_REQUIRED_COLUMNS = (
|
||||||
"task_id", "attempt_no", "outcome", "is_defect",
|
"task_id", "attempt_no", "outcome", "is_defect",
|
||||||
"defect_category", "created_at", "corrects_attempt_no",
|
"defect_category", "corrects_attempt_no", "kind",
|
||||||
|
"interrupted_reason", "checkpoint_round", "result_text",
|
||||||
|
"error_text", "created_at",
|
||||||
)
|
)
|
||||||
MARKER_REQUIRED_COLUMNS = ("task_id", "origin", "queued_at")
|
MARKER_REQUIRED_COLUMNS = ("task_id", "origin", "queued_at")
|
||||||
|
|
||||||
@@ -72,12 +78,14 @@ LEDGER_DDL_CHECKLIST = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": 3,
|
"step": 3,
|
||||||
"action": "仅当表由 gap 3 旧 DDL 建过时:执行"
|
"action": "仅当表已由老形态(旧恢复 DDL、gap 3 台账 DDL"
|
||||||
" attempt_ledger.OLD_TABLE_ALTER 补"
|
" 或演练形态)建过时:经 attempt_ledger."
|
||||||
" corrects_attempt_no 列(gap 3 未合并,"
|
"ensure_task_attempts_table 补齐缺失的统一列"
|
||||||
"生产正常路径跳过此步)",
|
"(SQLite 与真实 PG 16.15 均已实测,见 verification/"
|
||||||
"ddl_source": "cloud.attempt_ledger.OLD_TABLE_ALTER",
|
"codex-task-attempts-pg-proof-20261106.md;生产 PG"
|
||||||
"verify": "task_attempts 含 corrects_attempt_no 列",
|
" 上执行仍是运维侧动作)",
|
||||||
|
"ddl_source": "cloud.attempt_ledger.TASK_ATTEMPTS_DDL",
|
||||||
|
"verify": "task_attempts 含统一 schema 全部 12 列",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": 4,
|
"step": 4,
|
||||||
|
|||||||
+53
-2
@@ -13,6 +13,7 @@
|
|||||||
- CloudExecutor:占位,未接通云端执行。
|
- CloudExecutor:占位,未接通云端执行。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -43,6 +44,39 @@ from cloud.peer_messages import extract_peer_messages as _extract_peer_messages
|
|||||||
|
|
||||||
MAX_STDOUT_CHARS = 2000
|
MAX_STDOUT_CHARS = 2000
|
||||||
|
|
||||||
|
# 租户 Docker 网络子网划段(2026-11-07 B 方案)。
|
||||||
|
#
|
||||||
|
# 背景:宿主 iptables 只放行 172.16.0.0/12 与 127.0.0.1 访问
|
||||||
|
# Tinyproxy(8889),容器内 codex 必须经该代理到模型网关。
|
||||||
|
# 以前建网不传 --subnet,子网由 Docker 默认池分配,曾分到
|
||||||
|
# 192.168.32.0/20(放行范围外),容器零次补全、单轮 600 秒
|
||||||
|
# 超时两轮后任务失败。划段区选 172.16.0.0/16:它在放行范围
|
||||||
|
# 内,且 Docker 默认池从 172.17.0.0/16 起分,永远不会自动分
|
||||||
|
# 到 172.16 段,与现存网络、宿主 LAN、Tailscale 均不重叠
|
||||||
|
# (2026-11-07 主机实测:10 个网络,172.16.0.0/16 空闲)。
|
||||||
|
# 每个租户网占一个 /24(254 个地址足够);同一网络名每次
|
||||||
|
# 算出的候选顺序相同(sha256 首字节起算 + 线性探测),不依
|
||||||
|
# 赖任何持久化状态;候选子网已被别的租户网占用时顺延下一
|
||||||
|
# 个。容量上限 256 个租户网/宿主(32 次探测封顶),满了就
|
||||||
|
# 明确返回失败,不静默降级——扩容需另行决策(删残留旧网或
|
||||||
|
# 扩大放行范围),不要在这里偷偷换段。
|
||||||
|
TENANT_SUBNET_PREFIX = "172.16"
|
||||||
|
TENANT_SUBNET_SLOTS = 256
|
||||||
|
TENANT_SUBNET_MAX_PROBES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_subnet_candidates(name):
|
||||||
|
"""按网络名确定性排出候选租户子网(/24,172.16.0.0/16 内)。
|
||||||
|
|
||||||
|
首候选由 sha256(网络名) 首字节决定,其余按槽位顺延;
|
||||||
|
不同网络名一般落在不同槽,撞槽时靠 ensure_network 的
|
||||||
|
overlap 探测顺延。纯函数,不碰 Docker、不读状态。
|
||||||
|
"""
|
||||||
|
seed = hashlib.sha256(name.encode("utf-8")).digest()[0]
|
||||||
|
for i in range(TENANT_SUBNET_MAX_PROBES):
|
||||||
|
slot = (seed + i) % TENANT_SUBNET_SLOTS
|
||||||
|
yield "%s.%d.0/24" % (TENANT_SUBNET_PREFIX, slot)
|
||||||
|
|
||||||
|
|
||||||
def tmp_isolation_status():
|
def tmp_isolation_status():
|
||||||
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
|
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
|
||||||
@@ -1957,6 +1991,15 @@ class DockerExecutor(Executor):
|
|||||||
def ensure_network(self, name):
|
def ensure_network(self, name):
|
||||||
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
|
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
|
||||||
|
|
||||||
|
创建时显式 --subnet 固定在 172.16.0.0/12 内划段(见
|
||||||
|
tenant_subnet_candidates):宿主 iptables 只放行该范围
|
||||||
|
访问 egress 代理,不传子网时 Docker 默认池可能分到范
|
||||||
|
围外(如 192.168.32.0/20),容器连不上代理、任务空等
|
||||||
|
600 秒超时。候选子网已被占用时按 overlap 报错顺延;
|
||||||
|
已存在(同名)直接复用——注意:这意味着 2026-11-07
|
||||||
|
之前建好的范围外旧网(如 codex-smoke-20261107)不会
|
||||||
|
被自动纠正,须人工删除后重建,见部署清单。
|
||||||
|
|
||||||
返回 True 表示网络可用(创建成功或本来就存在),
|
返回 True 表示网络可用(创建成功或本来就存在),
|
||||||
False 表示创建失败(调用方继续派发,docker run 时会
|
False 表示创建失败(调用方继续派发,docker run 时会
|
||||||
因网络不存在而明确失败,不在这里静默降级默认网络——
|
因网络不存在而明确失败,不在这里静默降级默认网络——
|
||||||
@@ -1966,8 +2009,11 @@ class DockerExecutor(Executor):
|
|||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return False
|
return False
|
||||||
|
for subnet in tenant_subnet_candidates(name):
|
||||||
try:
|
try:
|
||||||
result = self._call_runner(["docker", "network", "create", name],
|
result = self._call_runner(
|
||||||
|
["docker", "network", "create",
|
||||||
|
"--subnet", subnet, name],
|
||||||
timeout=30)
|
timeout=30)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return "already exists" in str(exc)
|
return "already exists" in str(exc)
|
||||||
@@ -1976,9 +2022,14 @@ class DockerExecutor(Executor):
|
|||||||
return True
|
return True
|
||||||
text = (getattr(result, "stderr", "") or "") + \
|
text = (getattr(result, "stderr", "") or "") + \
|
||||||
(getattr(result, "stdout", "") or "")
|
(getattr(result, "stdout", "") or "")
|
||||||
return "already exists" in text
|
if "already exists" in text:
|
||||||
|
return True
|
||||||
|
if "overlap" in text.lower():
|
||||||
|
continue
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def execute(self, task) -> dict:
|
def execute(self, task) -> dict:
|
||||||
"""只读执行:容器内调 host.py status,不直接跑模型回合。
|
"""只读执行:容器内调 host.py status,不直接跑模型回合。
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
行字段:
|
行字段:
|
||||||
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
|
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
|
||||||
本模块不规定口径内容、不替用户拍板阈值)。
|
本模块不规定口径内容、不替用户拍板阈值)。
|
||||||
- baseline_head:冻结时调用方传入的 main 头(字符串,非空)。
|
- baseline_head:冻结时调用方传入的 main 头(字符串,非空;
|
||||||
|
接受任意字符串原样存、不做格式校验,本模块不验证其真实性,
|
||||||
|
可信来源仍是调用方责任)。
|
||||||
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
|
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
|
||||||
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
|
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
|
||||||
文本存入,读回时还原为 dict。
|
文本存入,读回时还原为 dict。
|
||||||
|
|||||||
+66
-13
@@ -54,6 +54,7 @@ from collections import deque
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
from cloud.agent_def import default_agents_dir as _agent_default_dir
|
from cloud.agent_def import default_agents_dir as _agent_default_dir
|
||||||
from cloud.agent_def import list_known_skills as _agent_known_skills
|
from cloud.agent_def import list_known_skills as _agent_known_skills
|
||||||
@@ -61,7 +62,6 @@ from cloud.agent_def import parse_agent_definition as _parse_agent_definition
|
|||||||
from cloud.agent_def import resolve_registry_agent as _resolve_agent
|
from cloud.agent_def import resolve_registry_agent as _resolve_agent
|
||||||
from cloud.attempts import ensure_attempts_table as _ensure_attempts
|
from cloud.attempts import ensure_attempts_table as _ensure_attempts
|
||||||
from cloud.audit_loop import ensure_audit_loop_table as _ensure_audit_loop
|
from cloud.audit_loop import ensure_audit_loop_table as _ensure_audit_loop
|
||||||
from cloud.auth import AuthStore
|
|
||||||
# 演练注:删 _parse_tools_col/_parse_tool_approvals_col(随 helper
|
# 演练注:删 _parse_tools_col/_parse_tool_approvals_col(随 helper
|
||||||
# 移 handler_support)、_MEMBER_ROLES/_coord_parse_parts(用点已随
|
# 移 handler_support)、_MEMBER_ROLES/_coord_parse_parts(用点已随
|
||||||
# split 移走,server.py 内无其余引用,ruff F401 计)。
|
# split 移走,server.py 内无其余引用,ruff F401 计)。
|
||||||
@@ -1043,6 +1043,28 @@ def _db_backend_name():
|
|||||||
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
|
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_ui_url():
|
||||||
|
"""原生 UI host 地址:环境变量 CODEX_UI_URL,未设则默认本机 :8095。"""
|
||||||
|
return os.environ.get("CODEX_UI_URL") or "http://127.0.0.1:8095/"
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_ui_reachable():
|
||||||
|
"""探测 UI host 是否可达:GET <CODEX_UI_URL 的 origin>/,2 秒超时。
|
||||||
|
|
||||||
|
任一异常(连接失败、超时、DNS 失败等)或非 200 一律视为不可达,
|
||||||
|
调用方回落旧控制台;探测失败绝不影响 /console 本身的响应。"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(_codex_ui_url())
|
||||||
|
if not parsed.netloc:
|
||||||
|
return False
|
||||||
|
probe = "{}://{}/".format(parsed.scheme or "http", parsed.netloc)
|
||||||
|
request = urllib.request.Request(probe, method="GET")
|
||||||
|
with urllib.request.urlopen(request, timeout=2) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _deployed_commit():
|
def _deployed_commit():
|
||||||
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
|
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
|
||||||
|
|
||||||
@@ -2330,6 +2352,29 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
|
|||||||
self._responded = True
|
self._responded = True
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_redirect(self, location):
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", location)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
# 发头即算已响应(与 _send_json/_send_html 同款约定)。
|
||||||
|
self._responded = True
|
||||||
|
|
||||||
|
def _serve_console_classic(self):
|
||||||
|
# 旧 React 控制台(cloud/console.html):只出静态壳,不走鉴权
|
||||||
|
# 与限流(与 /health 同款 pre-auth 待遇)。壳里没有任何数据;
|
||||||
|
# 数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只存在
|
||||||
|
# 用户浏览器 localStorage。页面文件与 server.py 同目录,改动
|
||||||
|
# 随部署走。/console(UI host 不可达时回落)与 /console-classic
|
||||||
|
# 共用本方法,不要复制两份读文件代码。
|
||||||
|
try:
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
with open(os.path.join(here, "console.html"),
|
||||||
|
"r", encoding="utf-8") as f:
|
||||||
|
self._send_html(200, f.read())
|
||||||
|
except OSError:
|
||||||
|
self._send_json(404, {"error": "console not found"})
|
||||||
|
|
||||||
def _read_body(self):
|
def _read_body(self):
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
@@ -2617,18 +2662,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
|
|||||||
"commit": _deployed_commit(),
|
"commit": _deployed_commit(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
if path == "/console" and method == "GET":
|
if path in ("/console", "/console-classic") and method == "GET":
|
||||||
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
|
# /console:原生 UI 入口(见 docs/codex-native-ui-alignment.md
|
||||||
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
|
# §3):UI host 可达时 302 到 CODEX_UI_URL(默认
|
||||||
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
|
# http://127.0.0.1:8095/),不可达时回落旧控制台;
|
||||||
# localStorage。页面文件与 server.py 同目录,改动随部署走。
|
# /console-classic 永远 serve 旧控制台。两者都不走鉴权与
|
||||||
try:
|
# 限流(与 /health 同款 pre-auth 待遇)。
|
||||||
here = os.path.dirname(os.path.abspath(__file__))
|
if path == "/console" and _codex_ui_reachable():
|
||||||
with open(os.path.join(here, "console.html"),
|
self._send_redirect(_codex_ui_url())
|
||||||
"r", encoding="utf-8") as f:
|
else:
|
||||||
self._send_html(200, f.read())
|
self._serve_console_classic()
|
||||||
except OSError:
|
|
||||||
self._send_json(404, {"error": "console not found"})
|
|
||||||
return
|
return
|
||||||
if path.startswith("/oauth/"):
|
if path.startswith("/oauth/"):
|
||||||
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
||||||
@@ -9194,6 +9237,16 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
|
|||||||
参数也永远返回 200 + 报告,只是多一个判定字段。
|
参数也永远返回 200 + 报告,只是多一个判定字段。
|
||||||
生产代码绝不读 git、不起 subprocess 查版本:main 头
|
生产代码绝不读 git、不起 subprocess 查版本:main 头
|
||||||
只能由调用方传入,端点本身不探测版本控制状态。
|
只能由调用方传入,端点本身不探测版本控制状态。
|
||||||
|
调用方契约(gapfill2 H4,只写文档、不改行为):
|
||||||
|
current_main_head 由调用方提供,接受任意字符串、原样
|
||||||
|
使用,不做任何格式校验(不要求形如 commit hash,不
|
||||||
|
校验长度、字符集、前缀);端点不验证该值是否真实、
|
||||||
|
是否可信、是否确为 main 头——可信 main 头的来源仍是
|
||||||
|
调用方责任。缺失(未传该查询参数)与空串一律按未传入
|
||||||
|
处理:端点不调用判定、不附加 baseline_staleness 字段,
|
||||||
|
响应与以前完全一致。传入非空值后,端点永远返回 200 +
|
||||||
|
报告、只附加判定字段:判定为 stale 也不挡查询,绝不改
|
||||||
|
写报告的 overall / overall_pass。
|
||||||
|
|
||||||
权限:与 /metrics 同款,任意项目成员即可读(不要求 owner),
|
权限:与 /metrics 同款,任意项目成员即可读(不要求 owner),
|
||||||
但本端点不统计任何项目数据、不写库、不写账本、不改链头、
|
但本端点不统计任何项目数据、不写库、不写账本、不改链头、
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ def _table_exists(db_path, table):
|
|||||||
return row[0] == 1
|
return row[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _task_attempts_row_count(db_path):
|
||||||
|
"""task_attempts 行数(表不存在时计 0;COUNT(*) 与列集无关,
|
||||||
|
旧 DDL 表与并集表均可查)。"""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
if not _table_exists(db_path, "task_attempts"):
|
||||||
|
return 0
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM task_attempts").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _completed_runner(cmd):
|
def _completed_runner(cmd):
|
||||||
"""Fake CLI: dispatch/run always succeed, host.py-style JSON."""
|
"""Fake CLI: dispatch/run always succeed, host.py-style JSON."""
|
||||||
if "dispatch" in cmd:
|
if "dispatch" in cmd:
|
||||||
@@ -466,7 +479,11 @@ class TestWorkerHook(unittest.TestCase):
|
|||||||
self.assertFalse(worker.attempt_ledger_hook)
|
self.assertFalse(worker.attempt_ledger_hook)
|
||||||
result = worker.run_one()
|
result = worker.run_one()
|
||||||
self.assertEqual(result["status"], "completed")
|
self.assertEqual(result["status"], "completed")
|
||||||
self.assertFalse(_table_exists(self.db_path, "task_attempts"))
|
# 演练注:main 侧 init_db 已先建同名旧 DDL 的
|
||||||
|
# task_attempts 表(见 attempt_ledger 并集表说明),
|
||||||
|
# “表不存在”在合入后不再是可观测口径;保留的意图是
|
||||||
|
# “关时不写行”,故此处断言零行而非表不存在。
|
||||||
|
self.assertEqual(_task_attempts_row_count(self.db_path), 0)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
_table_exists(self.db_path, "task_attempt_origin_markers"))
|
_table_exists(self.db_path, "task_attempt_origin_markers"))
|
||||||
|
|
||||||
@@ -559,7 +576,9 @@ class TestWorkerHook(unittest.TestCase):
|
|||||||
worker = self._worker(attempt_ledger_hook=True, env={})
|
worker = self._worker(attempt_ledger_hook=True, env={})
|
||||||
result = worker.run_one()
|
result = worker.run_one()
|
||||||
self.assertEqual(result["status"], "failed")
|
self.assertEqual(result["status"], "failed")
|
||||||
self.assertFalse(_table_exists(self.db_path, "task_attempts"))
|
# 演练注:同上,门失败不调钩子、故零行;表本身由
|
||||||
|
# main 侧 init_db 预建,不再断言表不存在。
|
||||||
|
self.assertEqual(_task_attempts_row_count(self.db_path), 0)
|
||||||
|
|
||||||
def test_hook_on_cancelled_dispatch_writes_interrupted_row(self):
|
def test_hook_on_cancelled_dispatch_writes_interrupted_row(self):
|
||||||
# Established pattern (test_worker
|
# Established pattern (test_worker
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ class CoordGateLookupFailureTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def test_direct_lookup_raises_instead_of_empty_set(self):
|
def test_direct_lookup_raises_instead_of_empty_set(self):
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class PersistedWhitespaceModelTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _policy(self):
|
def _policy(self):
|
||||||
@@ -308,7 +308,7 @@ class ExplicitNonSub2apiGateTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _policy(self, mode, capabilities):
|
def _policy(self, mode, capabilities):
|
||||||
@@ -446,7 +446,7 @@ class CoordGateAttributionTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _merge(self, child_error, audit_rows=()):
|
def _merge(self, child_error, audit_rows=()):
|
||||||
@@ -555,7 +555,7 @@ class GateResultsCheckedTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def test_direct_call_checked_subset_and_legacy(self):
|
def test_direct_call_checked_subset_and_legacy(self):
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class _RunOneCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ class I1MarkerTestCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _merge(self, child_error, audit_rows=()):
|
def _merge(self, child_error, audit_rows=()):
|
||||||
@@ -343,7 +343,7 @@ class _WireCase(unittest.TestCase):
|
|||||||
if self._old is None:
|
if self._old is None:
|
||||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||||
else:
|
else:
|
||||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _worker(self, **kwargs):
|
def _worker(self, **kwargs):
|
||||||
|
|||||||
@@ -43,10 +43,25 @@ from pathlib import Path
|
|||||||
_PATTERN = re.compile(r"INSERT\s+INTO\s+audit_log", re.IGNORECASE)
|
_PATTERN = re.compile(r"INSERT\s+INTO\s+audit_log", re.IGNORECASE)
|
||||||
# Fixture files that deliberately insert audit_log rows into
|
# Fixture files that deliberately insert audit_log rows into
|
||||||
# throwaway test databases (see module docstring).
|
# throwaway test databases (see module docstring).
|
||||||
|
# 演练注:合入时 main 侧 9 个审计测试文件同样只往临时库插
|
||||||
|
# audit_log 行(TemporaryDirectory/control.db,逐文件核过),
|
||||||
|
# 与已列 3 个属同类 fixture,按名补入——生产侧口径不变,
|
||||||
|
# 仍只允许 cloud/auth.py。若用户要更严口径(测试也一律走
|
||||||
|
# AuthStore.audit),那是另起一批改 9 个文件的活,本演练
|
||||||
|
# 不悄悄替用户定(见演练报告 FLAG)。
|
||||||
_FIXTURE_FILES = {
|
_FIXTURE_FILES = {
|
||||||
"cloud/tests/test_tamper_ledger.py",
|
"cloud/tests/test_tamper_ledger.py",
|
||||||
"cloud/tests/test_chat_history.py",
|
"cloud/tests/test_chat_history.py",
|
||||||
"cloud/tests/test_automation_run_due.py",
|
"cloud/tests/test_automation_run_due.py",
|
||||||
|
"cloud/tests/test_95_code.py",
|
||||||
|
"cloud/tests/test_audit_batch4.py",
|
||||||
|
"cloud/tests/test_audit_batch5.py",
|
||||||
|
"cloud/tests/test_audit_batch6.py",
|
||||||
|
"cloud/tests/test_audit_fixes4.py",
|
||||||
|
"cloud/tests/test_audit_fixes5.py",
|
||||||
|
"cloud/tests/test_audit_loop_coord.py",
|
||||||
|
"cloud/tests/test_audit_loop_coord_fix.py",
|
||||||
|
"cloud/tests/test_routing_shadow.py",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Codex 原生 UI host 入仓清单单测(见 docs/codex-native-ui-alignment.md §4 第 4 项)。
|
||||||
|
|
||||||
|
只做静态断言,不起服务、不调网络、不读密钥:
|
||||||
|
- appserver-methods.json 是合法 JSON、179 个方法、无重复;
|
||||||
|
- app-server-bridge.mjs 里 7 个仿真方法都在(读文件文本断言方法名出现);
|
||||||
|
- 仓内无 cloud/codex-ui-host/original/ 目录(650MB 原版栈不入仓);
|
||||||
|
- scripts/run-codex-ui.sh 存在且可执行。
|
||||||
|
|
||||||
|
路径一律以仓库根(本文件向上两级)为基准,不写死绝对路径。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# §4 适配 A5:150 个 UI 清单方法 + 29 个实验协议方法 = 179。
|
||||||
|
EXPECTED_METHOD_COUNT = 179
|
||||||
|
|
||||||
|
# §4 适配 A1–A4:桥接层仿真的 7 个缺失方法。
|
||||||
|
SIMULATED_METHODS = [
|
||||||
|
"thread/stop",
|
||||||
|
"thread/rollback",
|
||||||
|
"thread/startAeon",
|
||||||
|
"interactive/liveSessions/list",
|
||||||
|
"interactive/session/upload",
|
||||||
|
"interactive/sessionSandbox/list",
|
||||||
|
"interactive/sessionSandbox/read",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppserverMethodsJson(unittest.TestCase):
|
||||||
|
def test_valid_json_179_methods_no_duplicates(self):
|
||||||
|
path = os.path.join(
|
||||||
|
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||||
|
"appserver-methods.json")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
methods = json.load(f)
|
||||||
|
self.assertIsInstance(methods, list)
|
||||||
|
self.assertEqual(len(methods), EXPECTED_METHOD_COUNT)
|
||||||
|
self.assertTrue(all(isinstance(m, str) for m in methods))
|
||||||
|
self.assertEqual(len(set(methods)), len(methods),
|
||||||
|
"appserver-methods.json must not list a method twice")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppServerBridgeSimulations(unittest.TestCase):
|
||||||
|
def test_seven_simulated_methods_present(self):
|
||||||
|
path = os.path.join(
|
||||||
|
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||||
|
"app-server-bridge.mjs")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
for name in SIMULATED_METHODS:
|
||||||
|
self.assertIn(name, text,
|
||||||
|
"%s must be simulated in app-server-bridge.mjs"
|
||||||
|
% name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOriginalNotVendored(unittest.TestCase):
|
||||||
|
def test_no_original_directory_in_repo(self):
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(
|
||||||
|
REPO_ROOT, "cloud", "codex-ui-host", "original")),
|
||||||
|
"cloud/codex-ui-host/original/ must not be vendored "
|
||||||
|
"(650MB original stack stays out of the repo)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunScript(unittest.TestCase):
|
||||||
|
def test_run_script_exists_and_is_executable(self):
|
||||||
|
path = os.path.join(REPO_ROOT, "scripts", "run-codex-ui.sh")
|
||||||
|
self.assertTrue(os.path.isfile(path),
|
||||||
|
"scripts/run-codex-ui.sh must exist")
|
||||||
|
self.assertTrue(os.access(path, os.X_OK),
|
||||||
|
"scripts/run-codex-ui.sh must be executable")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
|
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
|
||||||
|
|
||||||
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
|
/console 是原生 UI 入口(见 docs/codex-native-ui-alignment.md §3):
|
||||||
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
|
UI host 可达时 302 到 CODEX_UI_URL,不可达时回落旧控制台;
|
||||||
端点负责(已有单测覆盖)。
|
/console-classic 永远 serve 旧控制台。两者都不走鉴权(只是个
|
||||||
|
壳),数据一律凭 X-API-Key 调接口,所以无 key 也应 200 出 HTML;
|
||||||
|
真正的鉴权由各 API 端点负责(已有单测覆盖)。
|
||||||
|
|
||||||
|
默认测试环境没有 UI host 在跑,所以 /console 的断言是回落行为;
|
||||||
|
302 行为由 test_console_redirects_to_ui_host_when_reachable 用
|
||||||
|
测试内起的假 UI host 单独覆盖。
|
||||||
"""
|
"""
|
||||||
import http.client
|
import http.client
|
||||||
import os
|
import os
|
||||||
@@ -10,7 +16,9 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cloud.auth import AuthStore
|
from cloud.auth import AuthStore
|
||||||
@@ -32,6 +40,13 @@ class ConsoleTestCase(unittest.TestCase):
|
|||||||
self.thread = threading.Thread(
|
self.thread = threading.Thread(
|
||||||
target=self.server.serve_forever, daemon=True)
|
target=self.server.serve_forever, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
# 回落断言必须与环境无关:显式把 UI host 指到一个不可能有
|
||||||
|
# 监听的端口(127.0.0.1:1 立即 connection refused),而不是
|
||||||
|
# 赌测试机 :8095 上恰好没服务;302 测试在内层再覆盖它。
|
||||||
|
self._env_patch = mock.patch.dict(
|
||||||
|
os.environ, {"CODEX_UI_URL": "http://127.0.0.1:1/"})
|
||||||
|
self._env_patch.start()
|
||||||
|
self.addCleanup(self._env_patch.stop)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
@@ -56,10 +71,47 @@ class ConsoleTestCase(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_console_serves_html_without_key(self):
|
def test_console_falls_back_to_classic_when_ui_host_unreachable(self):
|
||||||
|
# UI host 不可达时 /console 回落旧控制台:无 key 200 出 HTML。
|
||||||
status, headers, body = self._get("/console")
|
status, headers, body = self._get("/console")
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
self.assertIn("text/html", headers.get("Content-Type", ""))
|
self.assertIn("text/html", headers.get("Content-Type", ""))
|
||||||
|
self.assertIn("Codex 命令台", body.decode("utf-8"))
|
||||||
|
|
||||||
|
def test_console_redirects_to_ui_host_when_reachable(self):
|
||||||
|
# 假 UI host:探测只看 GET / 是否 200,内容不重要。
|
||||||
|
class _FakeUIHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
body = b"ok"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUIHandler)
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=fake.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
ui_url = "http://127.0.0.1:{}/".format(
|
||||||
|
fake.server_address[1])
|
||||||
|
with mock.patch.dict(os.environ, {"CODEX_UI_URL": ui_url}):
|
||||||
|
status, headers, _body = self._get("/console")
|
||||||
|
self.assertEqual(status, 302)
|
||||||
|
self.assertEqual(headers.get("Location"), ui_url)
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
def test_console_classic_serves_html_without_key(self):
|
||||||
|
status, headers, body = self._get("/console-classic")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("text/html", headers.get("Content-Type", ""))
|
||||||
text = body.decode("utf-8")
|
text = body.decode("utf-8")
|
||||||
self.assertIn("Codex 命令台", text)
|
self.assertIn("Codex 命令台", text)
|
||||||
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
||||||
@@ -95,7 +147,8 @@ class ConsoleTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
def test_console_does_not_leak_data_without_key(self):
|
def test_console_does_not_leak_data_without_key(self):
|
||||||
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
|
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
|
||||||
_status, _headers, body = self._get("/console")
|
for path in ("/console", "/console-classic"):
|
||||||
|
_status, _headers, body = self._get(path)
|
||||||
text = body.decode("utf-8")
|
text = body.decode("utf-8")
|
||||||
self.assertNotIn(self.owner_id, text)
|
self.assertNotIn(self.owner_id, text)
|
||||||
|
|
||||||
@@ -106,8 +159,9 @@ class ConsoleTestCase(unittest.TestCase):
|
|||||||
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
|
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
|
||||||
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
|
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
|
||||||
# 有 key 时调 loadRelayModels;断言意图不变,只换成
|
# 有 key 时调 loadRelayModels;断言意图不变,只换成
|
||||||
# React 构建产物里实际存在的标记。
|
# React 构建产物里实际存在的标记。断言对象是旧控制台壳,
|
||||||
_status, _headers, body = self._get("/console")
|
# 所以走 /console-classic(/console 只在回落时才出这个壳)。
|
||||||
|
_status, _headers, body = self._get("/console-classic")
|
||||||
text = body.decode("utf-8")
|
text = body.decode("utf-8")
|
||||||
self.assertIn("loadRelayModels", text)
|
self.assertIn("loadRelayModels", text)
|
||||||
self.assertIn("/relay/models", text)
|
self.assertIn("/relay/models", text)
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""gapfill2 H3:_merge_task 成功审计段里 rollback 自身的 except: pass。
|
||||||
|
|
||||||
|
上一批唯一未覆盖分支:G7 新段里 ``conn.rollback()`` 本身抛错时的
|
||||||
|
防御分支(基底行号 server.py:6615-6616)。rollback 从不失败,故
|
||||||
|
全量覆盖率里该分支一直缺失。本文件只用注入式测试覆盖它,绝不
|
||||||
|
改动 rollback 语义、绝不把该分支改成可观测行为:
|
||||||
|
|
||||||
|
- 注入一个 rollback 会抛错的连接代理(除 rollback 外全部透传
|
||||||
|
真实连接),同时让审计失败(沿用 G7 的 RuntimeError 注入),
|
||||||
|
使执行流必然走进 ``except Exception: pass``;
|
||||||
|
- 断言:rollback 的异常被吞掉(原审计错误原样上抛,不被掩盖)、
|
||||||
|
任务状态仍是 completed、审计 0 行、原样重试后成功 200。
|
||||||
|
|
||||||
|
真实模型 0 轮,不联网;不跑真实 HTTP(handler 直调 +
|
||||||
|
真实 AuthStore/DB,审计行直接查库验证)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import cloud.server as server_mod # noqa: E402
|
||||||
|
from cloud.auth import AuthStore # noqa: E402
|
||||||
|
from cloud.server import CloudControlPlaneHandler, init_db # noqa: E402
|
||||||
|
|
||||||
|
NO_BLOCK = {"merge_blocked": False, "release_blocked": False,
|
||||||
|
"blocked_by": None, "round": None, "failed_checks": []}
|
||||||
|
|
||||||
|
HARD_BODY = {
|
||||||
|
"files_changed": ["apps/web/a.py"],
|
||||||
|
"owned_dir": "apps/web",
|
||||||
|
"branch_protection": "protected",
|
||||||
|
"ci_status": "success",
|
||||||
|
"ci_checks": [{"name": "ci", "status": "success"}],
|
||||||
|
"required_checks": ["ci"],
|
||||||
|
"contract_version": "v2",
|
||||||
|
"contract_frozen": True,
|
||||||
|
"coordinator_approved": True,
|
||||||
|
"unresolved_review_comments": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _RollbackFailingConn:
|
||||||
|
"""代理真实连接:除 rollback 外全部透传,rollback 计数后抛错。
|
||||||
|
|
||||||
|
execute / commit / close / row_factory 等一律委托给真实连接,
|
||||||
|
只有 rollback 被替换为“计数 + 抛错”,精确模拟“rollback 自身
|
||||||
|
失败”这一防御分支的前提,不改动其它任何连接语义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, real, calls):
|
||||||
|
self._real = real
|
||||||
|
self._calls = calls
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
self._calls["n"] += 1
|
||||||
|
raise RuntimeError("rollback down")
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._real, name)
|
||||||
|
|
||||||
|
|
||||||
|
class RollbackFailureSwallowedTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self.auth = AuthStore(self.db_path)
|
||||||
|
self.owner_id, _key = self.auth.create_user("owner")
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||||||
|
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)",
|
||||||
|
("p1", "P", self.owner_id, time.time(), None))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||||
|
" created_by, created_at, updated_at)"
|
||||||
|
" VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("t1", "p1", "do work", "completed", self.owner_id,
|
||||||
|
time.time(), time.time()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.auth.create_project("p1", self.owner_id)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self.auth.conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _handler(self):
|
||||||
|
h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler)
|
||||||
|
h.server = SimpleNamespace(db_path=self.db_path)
|
||||||
|
sent = []
|
||||||
|
h._send_json = lambda status, obj, extra_headers=None: sent.append(
|
||||||
|
(status, obj))
|
||||||
|
h._read_json = lambda: (dict(HARD_BODY), None)
|
||||||
|
h.sent = sent
|
||||||
|
return h
|
||||||
|
|
||||||
|
def _merge(self, h):
|
||||||
|
with mock.patch.object(server_mod, "_pushback_block",
|
||||||
|
return_value=dict(NO_BLOCK)):
|
||||||
|
h._merge_task(self.owner_id, "t1")
|
||||||
|
|
||||||
|
def _status(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT status FROM tasks WHERE id = 't1'").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _merge_audits(self):
|
||||||
|
return [a for a in self.auth.list_audit("p1")
|
||||||
|
if a["action"] == "merge_task"]
|
||||||
|
|
||||||
|
def test_rollback_failure_is_swallowed(self):
|
||||||
|
# 审计失败 + rollback 自身失败:rollback 的异常必须被吞掉,
|
||||||
|
# 原审计错误原样上抛(不被 rollback 错误掩盖),状态回滚
|
||||||
|
# 语义不变(completed、审计 0 行),原样重试后成功。
|
||||||
|
h = self._handler()
|
||||||
|
real_db = CloudControlPlaneHandler._db
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def failing_db(handler_self):
|
||||||
|
return _RollbackFailingConn(real_db(handler_self), calls)
|
||||||
|
|
||||||
|
with mock.patch.object(CloudControlPlaneHandler, "_db",
|
||||||
|
failing_db):
|
||||||
|
with mock.patch.object(
|
||||||
|
server_mod, "_audit_on_conn",
|
||||||
|
side_effect=RuntimeError("audit down")):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self._merge(h)
|
||||||
|
self.assertEqual(str(ctx.exception), "audit down")
|
||||||
|
self.assertEqual(calls["n"], 1)
|
||||||
|
self.assertEqual(h.sent, [])
|
||||||
|
self.assertEqual(self._status(), "completed")
|
||||||
|
self.assertEqual(self._merge_audits(), [])
|
||||||
|
# 原样重试(审计恢复、连接恢复后)必须成功:状态仍是
|
||||||
|
# completed,不是卡在 merged 导致重复合并 409。
|
||||||
|
h2 = self._handler()
|
||||||
|
self._merge(h2)
|
||||||
|
self.assertEqual(h2.sent[-1][0], 200)
|
||||||
|
self.assertEqual(self._status(), "merged")
|
||||||
|
self.assertEqual(len(self._merge_audits()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""gapfill2 H4:G5 调用方契约的文档钉死(只改文档、不改行为)。
|
||||||
|
|
||||||
|
本文件断言的全部是 docstring 文本,不是行为:上一批残留
|
||||||
|
“可信 main 头的来源仍是调用方责任”在本批补进 scorecard
|
||||||
|
相关模块与端点的 docstring 后,必须写清四点——
|
||||||
|
current_main_head 由调用方提供、接受什么形式的值、缺失与
|
||||||
|
空串按未传入处理且不做 stale 判定、传入后永远 200 且不改写
|
||||||
|
overall;且绝不新增任何校验或拒绝行为(行为断言见既有
|
||||||
|
test_gapfill_g5_baseline_snapshot,本文件不重复测行为)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import cloud.audit_scorecard as scorecard_mod # noqa: E402
|
||||||
|
import cloud.scorecard_baseline_store as store_mod # noqa: E402
|
||||||
|
from cloud.server import CloudControlPlaneHandler # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _flat(doc):
|
||||||
|
"""docstring 空白归一化:去掉全部空白后按语义短语查,
|
||||||
|
不依赖换行位置(中文短语常被换行切开)。"""
|
||||||
|
return "".join((doc or "").split())
|
||||||
|
|
||||||
|
|
||||||
|
class CallerContractDocsTestCase(unittest.TestCase):
|
||||||
|
def test_endpoint_docstring_states_caller_contract(self):
|
||||||
|
doc = _flat(CloudControlPlaneHandler._audit_scorecard.__doc__)
|
||||||
|
self.assertIn("由调用方提供", doc)
|
||||||
|
self.assertIn("接受任意字符串", doc)
|
||||||
|
self.assertIn("不做任何格式校验", doc)
|
||||||
|
self.assertIn("不要求形如commithash", doc)
|
||||||
|
self.assertIn("缺失", doc)
|
||||||
|
self.assertIn("空串一律按未传入处理", doc)
|
||||||
|
self.assertIn("不附加baseline_staleness字段", doc)
|
||||||
|
self.assertIn("永远返回200", doc)
|
||||||
|
self.assertIn("绝不改写报告的overall/overall_pass", doc)
|
||||||
|
self.assertIn("可信main头的来源仍是调用方责任", doc)
|
||||||
|
|
||||||
|
def test_check_baseline_stale_docstring_states_contract(self):
|
||||||
|
doc = _flat(scorecard_mod.check_baseline_stale.__doc__)
|
||||||
|
self.assertIn("接受任意字符串", doc)
|
||||||
|
self.assertIn("不做任何格式校验", doc)
|
||||||
|
self.assertIn("空串先归一化为None", doc)
|
||||||
|
self.assertIn("根本不调用本函数", doc)
|
||||||
|
self.assertIn("可信", doc)
|
||||||
|
|
||||||
|
def test_module_and_store_docstrings_state_contract(self):
|
||||||
|
self.assertIn("接受任意字符串",
|
||||||
|
_flat(scorecard_mod.__doc__))
|
||||||
|
self.assertIn("不做格式校验", _flat(store_mod.__doc__))
|
||||||
|
self.assertIn("可信来源仍是调用方责任",
|
||||||
|
_flat(store_mod.__doc__))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""gapfill2 H2:全仓低于 90% 文件的扫尾(生产代码 0 改动)。
|
||||||
|
|
||||||
|
基线(本批自己跑的 CI 同款覆盖率,基底 5d0651e)低于 90% 的
|
||||||
|
文件共 37 个。本文件补其中三个缺口最小、可纯函数 / 注入式
|
||||||
|
真实驱动的文件,缺口大的文件(resident、sandbox、ledger、
|
||||||
|
playwright_browser 等,缺口均在 50 行以上且多需 Docker / 真实
|
||||||
|
外部服务)不在本批硬凑,见总结残留清单:
|
||||||
|
|
||||||
|
- cloud/approval_timeout.py:57 语句、缺失 8 行(41-42、46、
|
||||||
|
48、62-63、65、109)→ 本文件全覆盖。
|
||||||
|
- cloud/merge_gate_hard.py:89 语句、缺失 12 行(103、108、
|
||||||
|
142-143、147、157-158、201-202、212-215)→ 本文件全覆盖。
|
||||||
|
- cloud/worker_sweep.py:60 语句、缺失 8 行(54、82-83、
|
||||||
|
117-118、131-132、174)→ 本文件覆盖除 174(``__main__``
|
||||||
|
守卫行,import 无法覆盖)外的 7 行。
|
||||||
|
|
||||||
|
真实模型 0 轮,不联网;approval_timeout 与 merge_gate_hard
|
||||||
|
均为纯函数直调,worker_sweep 用假 AuthStore / 假 reap 注入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import cloud.worker_sweep as sweep_mod # noqa: E402
|
||||||
|
from cloud.approval_timeout import ( # noqa: E402
|
||||||
|
approval_timeout_stats,
|
||||||
|
recommend_approval_timeout,
|
||||||
|
timeout_rate,
|
||||||
|
)
|
||||||
|
from cloud.merge_gate_hard import evaluate_hard_merge_gate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _gate(**overrides):
|
||||||
|
body = {
|
||||||
|
"files_changed": ["apps/web/a.py"],
|
||||||
|
"owned_dir": "apps/web",
|
||||||
|
"protection": "protected",
|
||||||
|
"ci_status": "success",
|
||||||
|
"ci_checks": [{"name": "ci", "status": "success"}],
|
||||||
|
"required_checks": ["ci"],
|
||||||
|
"contract_version": "v2",
|
||||||
|
"contract_frozen": True,
|
||||||
|
"coordinator_approved": True,
|
||||||
|
"unresolved_review_comments": 0,
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return evaluate_hard_merge_gate(**body)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalTimeoutBranchTestCase(unittest.TestCase):
|
||||||
|
def test_timeout_rate_non_numeric_none(self):
|
||||||
|
# 41-42:计数无法转 int 时回 None,不是 0%。
|
||||||
|
self.assertIsNone(timeout_rate("abc", 10))
|
||||||
|
self.assertIsNone(timeout_rate(1, "xyz"))
|
||||||
|
|
||||||
|
def test_timeout_rate_clamps(self):
|
||||||
|
# 46:expired 为负时按 0 计;48:expired 超过 decided
|
||||||
|
# 时按 decided 计(比率封顶 1.0)。
|
||||||
|
self.assertEqual(timeout_rate(-3, 10), 0.0)
|
||||||
|
self.assertEqual(timeout_rate(15, 10), 1.0)
|
||||||
|
|
||||||
|
def test_recommend_current_fallbacks(self):
|
||||||
|
# 62-63:current 非法时按默认值 600 计;65:current
|
||||||
|
# <= 0 时同样按默认值计。
|
||||||
|
self.assertEqual(
|
||||||
|
recommend_approval_timeout(0, 10,
|
||||||
|
current_seconds="bad")
|
||||||
|
["current_seconds"], 600)
|
||||||
|
self.assertEqual(
|
||||||
|
recommend_approval_timeout(0, 10, current_seconds=0)
|
||||||
|
["current_seconds"], 600)
|
||||||
|
self.assertEqual(
|
||||||
|
recommend_approval_timeout(0, 10, current_seconds=-5)
|
||||||
|
["current_seconds"], 600)
|
||||||
|
|
||||||
|
def test_stats_empty_project_list(self):
|
||||||
|
# 109:project_ids 为空列表时直接回零统计,不拼 IN 查询。
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
approval_timeout_stats(conn, []),
|
||||||
|
{"decided_count": 0, "expired_count": 0,
|
||||||
|
"pending_count": 0})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MergeGateHardBranchTestCase(unittest.TestCase):
|
||||||
|
def test_ci_check_entry_not_dict(self):
|
||||||
|
# 103 + 147:ci_checks 条目不是 dict 时索引为 None、
|
||||||
|
# 记 ci_check_failed 块。
|
||||||
|
result = _gate(ci_checks=["not-a-dict"])
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn("ci_check_failed", result["blocks"])
|
||||||
|
|
||||||
|
def test_ci_check_entry_bad_name_status(self):
|
||||||
|
# 108 + 147:条目 name 为空时索引为 None、记
|
||||||
|
# ci_check_failed 块。
|
||||||
|
result = _gate(ci_checks=[{"name": "", "status": "success"}])
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn("ci_check_failed", result["blocks"])
|
||||||
|
|
||||||
|
def test_ci_checks_not_list(self):
|
||||||
|
# 142-143:ci_checks 非列表时记 ci_evidence_missing。
|
||||||
|
result = _gate(ci_checks="ci")
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn("ci_evidence_missing", result["blocks"])
|
||||||
|
|
||||||
|
def test_required_checks_not_list(self):
|
||||||
|
# 157-158:required_checks 非列表时记 ci_evidence_missing。
|
||||||
|
result = _gate(required_checks="ci")
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn("ci_evidence_missing", result["blocks"])
|
||||||
|
|
||||||
|
def test_reviews_bool_is_missing_evidence(self):
|
||||||
|
# 201-202:评审意见为 bool 时不是“0 条”,而是无证据。
|
||||||
|
result = _gate(unresolved_review_comments=True)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn("review_evidence_missing", result["blocks"])
|
||||||
|
|
||||||
|
def test_reviews_negative_and_other_type(self):
|
||||||
|
# 212:负数评审意见记 review_evidence_missing;
|
||||||
|
# 214-215:其它类型(字符串)同样记无证据。
|
||||||
|
result = _gate(unresolved_review_comments=-1)
|
||||||
|
self.assertIn("review_evidence_missing", result["blocks"])
|
||||||
|
result = _gate(unresolved_review_comments="many")
|
||||||
|
self.assertIn("review_evidence_missing", result["blocks"])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerSweepBranchTestCase(unittest.TestCase):
|
||||||
|
def _fake_store_cls(self, user_id, audit_raises=False,
|
||||||
|
close_raises=False):
|
||||||
|
class _FakeConn:
|
||||||
|
def close(self):
|
||||||
|
if close_raises:
|
||||||
|
raise RuntimeError("close failed")
|
||||||
|
|
||||||
|
class _FakeStore:
|
||||||
|
def __init__(self, db_path):
|
||||||
|
self.conn = _FakeConn()
|
||||||
|
|
||||||
|
def authenticate(self, api_key):
|
||||||
|
return user_id if api_key else None
|
||||||
|
|
||||||
|
def audit(self, *args, **kwargs):
|
||||||
|
if audit_raises:
|
||||||
|
raise RuntimeError("audit down")
|
||||||
|
|
||||||
|
return _FakeStore
|
||||||
|
|
||||||
|
def test_global_admin_ids_explicit(self):
|
||||||
|
# 54:显式传入 admin_ids 时去空白、去空项。
|
||||||
|
self.assertEqual(
|
||||||
|
sweep_mod.global_admin_ids([" a ", "", "b", " a "]),
|
||||||
|
{"a", "b"})
|
||||||
|
|
||||||
|
def test_denied_audit_failure_swallowed(self):
|
||||||
|
# 82-83:非管理员被拒时,拒绝审计本身抛错也被吞掉,
|
||||||
|
# PermissionError 照常抛出,绝不调 reap。
|
||||||
|
fake = self._fake_store_cls("u1", audit_raises=True)
|
||||||
|
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
sweep_mod.sweep_stale_workers_admin(
|
||||||
|
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||||
|
|
||||||
|
def test_sweep_audit_failure_marks_summary_false(self):
|
||||||
|
# 117-118:汇总审计抛错时 audit_summary 置 False,
|
||||||
|
# 调用仍正常返回(reap 结果不受影响)。
|
||||||
|
fake = self._fake_store_cls("admin1", audit_raises=True)
|
||||||
|
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||||
|
with mock.patch.object(
|
||||||
|
sweep_mod, "reap_stale_workers",
|
||||||
|
return_value=[]):
|
||||||
|
summary = sweep_mod.sweep_stale_workers_admin(
|
||||||
|
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||||
|
self.assertFalse(summary["audit_summary"])
|
||||||
|
self.assertEqual(summary["killed"], [])
|
||||||
|
|
||||||
|
def test_store_close_failure_swallowed(self):
|
||||||
|
# 131-132:finally 里 store.conn.close() 抛错时被吞掉,
|
||||||
|
# 汇总照常返回。
|
||||||
|
fake = self._fake_store_cls("admin1", close_raises=True)
|
||||||
|
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||||
|
with mock.patch.object(
|
||||||
|
sweep_mod, "reap_stale_workers",
|
||||||
|
return_value=[]):
|
||||||
|
summary = sweep_mod.sweep_stale_workers_admin(
|
||||||
|
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||||
|
self.assertTrue(summary["audit_summary"])
|
||||||
|
self.assertEqual(summary["user_id"], "admin1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
"""gapfill2 H1:worker.py 覆盖缺口补测试(生产代码 0 改动)。
|
||||||
|
|
||||||
|
基线(本批自己跑的 CI 同款覆盖率,基底 5d0651e):
|
||||||
|
cloud/worker.py 3493 语句、277 缺失、92%。本文件只加真实断言
|
||||||
|
测试,瞄准基线缺失行里可真实驱动的分支;每组测试头上注明
|
||||||
|
它覆盖的基底行号。需要 Docker / 协调员 / run_one 主循环等重
|
||||||
|
型 harness 的缺失行(run_one、_run_attempt、_docker_task_context、
|
||||||
|
run_loop、run_all 等)不在本文件硬凑,见总结残留清单。
|
||||||
|
|
||||||
|
真实模型 0 轮,不联网;不启动真实 Docker / 子进程,凡需注入
|
||||||
|
处均用假连接、假 runner、假 proc 或方法补丁,且断言均为行为
|
||||||
|
断言(返回值、落盘行、调用记录),不是“跑过即过”。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import cloud.worker as worker_mod # noqa: E402
|
||||||
|
from cloud.server import init_db # noqa: E402
|
||||||
|
from cloud.worker import DockerExecutor, Worker # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _worker(db_path, **attrs):
|
||||||
|
w = Worker.__new__(Worker)
|
||||||
|
w.db_path = db_path
|
||||||
|
w.tenant_isolation = False
|
||||||
|
w.executor = SimpleNamespace()
|
||||||
|
w.logger = logging.getLogger("test_gapfill2_worker")
|
||||||
|
w._cancel_requested = set()
|
||||||
|
w._running = {}
|
||||||
|
w._running_lock = threading.Lock()
|
||||||
|
w._loop_stop = False
|
||||||
|
w.quota_poll_interval_seconds = 30
|
||||||
|
w.process_factory = None
|
||||||
|
w.repos_runner = None
|
||||||
|
for key, value in attrs.items():
|
||||||
|
setattr(w, key, value)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_task(db_path, task_id="T1", status="running", goal="old goal",
|
||||||
|
goal_version=0, applied=0):
|
||||||
|
now = time.time()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||||
|
" idempotency_key, depends_on, priority, created_by,"
|
||||||
|
" created_at, updated_at, last_heartbeat, goal, goal_mode,"
|
||||||
|
" verify_command, goal_max_rounds, goal_version,"
|
||||||
|
" applied_goal_version) VALUES(?, 'p1', ?, ?, NULL, '[]',"
|
||||||
|
" 0, 'tester', ?, ?, NULL, ?, 1, NULL, 0, ?, ?)",
|
||||||
|
(task_id, goal, status, now, now, goal, goal_version,
|
||||||
|
applied))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_row(db_path, task_id="T1"):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return dict(conn.execute(
|
||||||
|
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(state_path, tasks):
|
||||||
|
with open(state_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump({"tasks": tasks}, fh)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalSignalBranchTestCase(unittest.TestCase):
|
||||||
|
"""goal 信号写侧 / 镜像分支(基底缺失 1294 / 1300 / 1306 /
|
||||||
|
1313-1314 / 1339-1340 / 1381-1382 / 1397-1398)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self.state_path = os.path.join(self.tmp.name, "host-state.json")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _signal_worker(self):
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w._host_state_path_for_task = lambda task: self.state_path
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_bad_goal_rejected(self):
|
||||||
|
# 1294:db_goal 为空或非字符串时返回 False,不写信号。
|
||||||
|
w = self._signal_worker()
|
||||||
|
task = {"id": "T1", "project_id": "p1"}
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(task, None, 2))
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(task, "", 2))
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(task, 123, 2))
|
||||||
|
self.assertFalse(os.path.exists(
|
||||||
|
os.path.join(self.tmp.name, "goal-updates")))
|
||||||
|
|
||||||
|
def test_no_state_path_returns_false(self):
|
||||||
|
# 1300:解析不出 state 路径时返回 False,不猜路径。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w._host_state_path_for_task = lambda task: None
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(
|
||||||
|
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||||
|
|
||||||
|
def test_entry_not_dict_returns_false(self):
|
||||||
|
# 1306:state 里本任务条目不是 dict 时返回 False。
|
||||||
|
_write_state(self.state_path, {"T1": "not-a-dict"})
|
||||||
|
w = self._signal_worker()
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(
|
||||||
|
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||||
|
|
||||||
|
def test_corrupt_state_swallowed(self):
|
||||||
|
# 1313-1314:state 文件损坏(json 解析失败)时异常被吞掉,
|
||||||
|
# 返回 False,绝不炸掉调用方。
|
||||||
|
with open(self.state_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("{not json")
|
||||||
|
w = self._signal_worker()
|
||||||
|
self.assertFalse(w._write_goal_signal_if_newer(
|
||||||
|
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||||
|
|
||||||
|
def test_write_signal_success_control(self):
|
||||||
|
# 对照项(非缺失行):版本大于已应用版本时写信号成功,
|
||||||
|
# 证明上面几组失败测试的 harness 本身能走通成功路径。
|
||||||
|
_write_state(self.state_path, {
|
||||||
|
"T1": {"applied_goal_version": 0, "goal_rounds_done": 1}})
|
||||||
|
w = self._signal_worker()
|
||||||
|
self.assertTrue(w._write_goal_signal_if_newer(
|
||||||
|
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||||
|
signal_dir = os.path.join(self.tmp.name, "goal-updates")
|
||||||
|
self.assertTrue(os.path.isdir(signal_dir))
|
||||||
|
self.assertEqual(len(os.listdir(signal_dir)), 1)
|
||||||
|
|
||||||
|
def test_sync_resume_missing_id_swallowed(self):
|
||||||
|
# 1339-1340:续跑认领钩子读库时任务缺 id(KeyError)时
|
||||||
|
# 异常被吞掉,返回 False,续跑本身不受影响。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
self.assertFalse(w._sync_resume_goal_signal({}))
|
||||||
|
|
||||||
|
def test_mirror_bad_applied_ignored(self):
|
||||||
|
# 1381-1382:state 里 applied_goal_version 不是数字时,
|
||||||
|
# 转换失败置 None、跳过回写,不改任务行、不炸镜像。
|
||||||
|
_insert_task(self.db_path, applied=0)
|
||||||
|
_write_state(self.state_path, {"T1": {
|
||||||
|
"goal_rounds_done": 3, "status": "goal-running",
|
||||||
|
"applied_goal_version": "not-a-number",
|
||||||
|
"thread_id": "th-1"}})
|
||||||
|
w = self._signal_worker()
|
||||||
|
self.assertEqual(
|
||||||
|
w._mirror_checkpoint_once({"id": "T1", "project_id": "p1"}), 3)
|
||||||
|
self.assertEqual(_task_row(self.db_path)["applied_goal_version"],
|
||||||
|
0)
|
||||||
|
|
||||||
|
def test_mirror_db_read_failure_skips_signal_only(self):
|
||||||
|
# 1397-1398:镜像内读库(goal/goal_version)失败时只跳过
|
||||||
|
# 反向信号,检查点镜像本身继续、返回轮次,不炸、不回 -1。
|
||||||
|
_insert_task(self.db_path, applied=0)
|
||||||
|
_write_state(self.state_path, {"T1": {
|
||||||
|
"goal_rounds_done": 2, "status": "goal-running",
|
||||||
|
"applied_goal_version": 0, "thread_id": "th-1"}})
|
||||||
|
w = self._signal_worker()
|
||||||
|
real_connect = worker_mod._db_connect
|
||||||
|
|
||||||
|
class _FailingGoalReadConn:
|
||||||
|
def __init__(self, real):
|
||||||
|
self._real = real
|
||||||
|
|
||||||
|
def execute(self, sql, params=()):
|
||||||
|
if "SELECT goal, goal_version" in sql:
|
||||||
|
raise sqlite3.OperationalError(
|
||||||
|
"injected goal read failure")
|
||||||
|
return self._real.execute(sql, params)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._real, name)
|
||||||
|
|
||||||
|
def failing_connect(db_path):
|
||||||
|
return _FailingGoalReadConn(real_connect(db_path))
|
||||||
|
|
||||||
|
with mock.patch.object(worker_mod, "_db_connect",
|
||||||
|
failing_connect):
|
||||||
|
self.assertEqual(
|
||||||
|
w._mirror_checkpoint_once(
|
||||||
|
{"id": "T1", "project_id": "p1"}), 2)
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT round_no FROM task_checkpoints"
|
||||||
|
" WHERE task_id = 'T1'").fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(row, (2,))
|
||||||
|
|
||||||
|
|
||||||
|
class TenantEnvBranchTestCase(unittest.TestCase):
|
||||||
|
"""租户目录 / 环境分支(基底缺失 3062 / 3091 / 3121-3122 /
|
||||||
|
3148-3149 / 3156-3157 / 3181-3182 / 3196)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _tenant_worker(self, **extra):
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||||
|
w.tenant_isolation = True
|
||||||
|
w.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||||
|
w.tenant_template_home = os.path.join(self.tmp.name, "tmpl-home")
|
||||||
|
w.tenant_template_repo = os.path.join(self.tmp.name, "tmpl-repo")
|
||||||
|
w.seed_skills = False
|
||||||
|
w.websearch_mcp = False
|
||||||
|
w.imagegen_mcp = False
|
||||||
|
w.playwright_mcp = False
|
||||||
|
for key, value in extra.items():
|
||||||
|
setattr(w, key, value)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_tenant_dirs_missing_test_repo_template(self):
|
||||||
|
# 3062:test-repo 为空目录、模板不存在时直接抛错,
|
||||||
|
# 不静默播种一个空 test-repo。
|
||||||
|
os.makedirs(self._tenant_worker().tenant_template_home)
|
||||||
|
with open(os.path.join(self.tmp.name, "tmpl-home",
|
||||||
|
"config.toml"), "w") as fh:
|
||||||
|
fh.write("[x]\n")
|
||||||
|
w = self._tenant_worker()
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
w._tenant_dirs("p1")
|
||||||
|
self.assertIn("tmpl-repo", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_tenant_mounts_includes_repo_checkout(self):
|
||||||
|
# 3091:repo_checkout 目录已存在时,挂载列表里追加
|
||||||
|
# repos/<安全项目名> 挂载;不存在时不硬挂载。
|
||||||
|
tmpl_home = os.path.join(self.tmp.name, "tmpl-home")
|
||||||
|
tmpl_repo = os.path.join(self.tmp.name, "tmpl-repo")
|
||||||
|
os.makedirs(tmpl_home)
|
||||||
|
os.makedirs(tmpl_repo)
|
||||||
|
with open(os.path.join(tmpl_home, "config.toml"), "w") as fh:
|
||||||
|
fh.write("[x]\n")
|
||||||
|
with open(os.path.join(tmpl_repo, "sample.py"), "w") as fh:
|
||||||
|
fh.write("# sample\n")
|
||||||
|
w = self._tenant_worker()
|
||||||
|
dirs = w._tenant_dirs("p1")
|
||||||
|
os.makedirs(dirs["repo_checkout"], exist_ok=True)
|
||||||
|
mounts = w._tenant_mounts("p1")
|
||||||
|
self.assertIn(
|
||||||
|
"%s:/var/lib/codex-app-server/repos/p1:rw"
|
||||||
|
% dirs["repo_checkout"], mounts)
|
||||||
|
|
||||||
|
def test_env_host_dir_tenant_docker(self):
|
||||||
|
# 3121-3122:租户 + Docker 时环境宿主目录走
|
||||||
|
# tenants_root/<项目>/envs/<env_id>。
|
||||||
|
w = self._tenant_worker()
|
||||||
|
w.executor = DockerExecutor.__new__(DockerExecutor)
|
||||||
|
result = w._env_host_dir({"project_id": "p1", "id": "env1"})
|
||||||
|
self.assertTrue(result.startswith(w.tenants_root))
|
||||||
|
self.assertTrue(result.endswith(
|
||||||
|
os.path.join("envs", "env1")))
|
||||||
|
|
||||||
|
def test_fetch_environment_db_failure_none(self):
|
||||||
|
# 3148-3149:库连接失败时返回 None,不炸 worker。
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||||
|
"test.db"))
|
||||||
|
self.assertIsNone(w._fetch_environment("e1"))
|
||||||
|
|
||||||
|
def test_fetch_environment_narrow_schema_none_fields(self):
|
||||||
|
# 3156-3157:老库 environments 表缺可选列时,_get 兜底
|
||||||
|
# 回 None,不炸整行;必备列仍正常读出。
|
||||||
|
db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE environments(id TEXT, project_id TEXT,"
|
||||||
|
" name TEXT, status TEXT)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO environments VALUES('e1', 'p1', 'E',"
|
||||||
|
" 'ready')")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
w = _worker(db_path)
|
||||||
|
env = w._fetch_environment("e1")
|
||||||
|
self.assertEqual(env["id"], "e1")
|
||||||
|
self.assertEqual(env["project_id"], "p1")
|
||||||
|
self.assertEqual(env["status"], "ready")
|
||||||
|
self.assertIsNone(env["branch"])
|
||||||
|
self.assertIsNone(env["repo_url"])
|
||||||
|
|
||||||
|
def test_fetch_environment_missing_required_column_none(self):
|
||||||
|
# 3170-3171:environments 行缺必备列(project_id)时,
|
||||||
|
# 外层兜底回 None,不炸 worker。
|
||||||
|
db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE TABLE environments(id TEXT)")
|
||||||
|
conn.execute("INSERT INTO environments VALUES('e1')")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
w = _worker(db_path)
|
||||||
|
self.assertIsNone(w._fetch_environment("e1"))
|
||||||
|
|
||||||
|
def test_run_git_typeerror_fallback(self):
|
||||||
|
# 3181-3182:repos_runner 单参调用抛 TypeError 时,
|
||||||
|
# 回落到 (cmd, timeout=None) 双参调用。
|
||||||
|
calls = []
|
||||||
|
_unset = object()
|
||||||
|
|
||||||
|
def runner(*args, timeout=_unset):
|
||||||
|
calls.append((args, timeout))
|
||||||
|
if timeout is _unset:
|
||||||
|
raise TypeError("single-arg not supported")
|
||||||
|
return SimpleNamespace(returncode=0, stdout="ok",
|
||||||
|
stderr="")
|
||||||
|
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||||
|
w.repos_runner = runner
|
||||||
|
result = w._run_git(["git", "status"])
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(result.stdout, "ok")
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
self.assertIs(calls[1][1], None)
|
||||||
|
|
||||||
|
def test_prepare_environment_no_repo_url(self):
|
||||||
|
# 3196:环境无 repo_url 时直接返回错误文本,不建目录。
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||||
|
self.assertEqual(w._prepare_environment({}),
|
||||||
|
"environment has no repo_url")
|
||||||
|
|
||||||
|
|
||||||
|
class DbFailureBranchTestCase(unittest.TestCase):
|
||||||
|
"""查库失败即回落的分支(基底缺失 3333-3334 / 3339-3340 /
|
||||||
|
3583-3584 / 3604-3605 / 3620-3621 / 3650-3651 / 3665-3666 /
|
||||||
|
3694-3695 / 5343-5344 / 5798-5799)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _bad_db_worker(self):
|
||||||
|
return _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||||
|
"test.db"))
|
||||||
|
|
||||||
|
def test_project_repo_url_db_failure_none(self):
|
||||||
|
# 3333-3334:projects 表查库失败时回 None,不炸派发。
|
||||||
|
self.assertIsNone(self._bad_db_worker()._project_repo_url("p1"))
|
||||||
|
|
||||||
|
def test_project_repo_url_row_missing_key_none(self):
|
||||||
|
# 3339-3340:行里缺 repo_url 键(非 Row 行)时回 None。
|
||||||
|
class _Cursor:
|
||||||
|
def fetchone(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
def execute(self, sql, params=()):
|
||||||
|
return _Cursor()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
with mock.patch.object(worker_mod, "_db_connect",
|
||||||
|
lambda path: _Conn()):
|
||||||
|
self.assertIsNone(w._project_repo_url("p1"))
|
||||||
|
|
||||||
|
def test_claim_followup_db_connect_failure_none(self):
|
||||||
|
# 3583-3584:followup 认领时库连接失败回 None。
|
||||||
|
self.assertIsNone(
|
||||||
|
self._bad_db_worker()._claim_next_followup("t1"))
|
||||||
|
|
||||||
|
def test_claim_followup_missing_table_none(self):
|
||||||
|
# 3604-3605:task_followups 表不存在时回 None,不炸 run_one。
|
||||||
|
fresh = os.path.join(self.tmp.name, "fresh.db")
|
||||||
|
sqlite3.connect(fresh).close()
|
||||||
|
w = _worker(fresh)
|
||||||
|
self.assertIsNone(w._claim_next_followup("t1"))
|
||||||
|
|
||||||
|
def test_finish_followup_db_failure_silent(self):
|
||||||
|
# 3620-3621:结束 followup 时库失败静默,不抛。
|
||||||
|
self._bad_db_worker()._finish_followup("f1", "done")
|
||||||
|
|
||||||
|
def test_convert_to_goal_db_failure_false(self):
|
||||||
|
# 3650-3651:转 goal 时库失败回 False,不悄悄改状态。
|
||||||
|
self.assertFalse(
|
||||||
|
self._bad_db_worker()._convert_running_task_to_goal(
|
||||||
|
"t1", "new goal"))
|
||||||
|
|
||||||
|
def test_restore_followup_db_failure_silent(self):
|
||||||
|
# 3665-3666:放回 followup 时库失败静默,不抛。
|
||||||
|
self._bad_db_worker()._restore_followup("f1")
|
||||||
|
|
||||||
|
def test_queue_self_followup_db_failure_false(self):
|
||||||
|
# 3694-3695:排自有 followup 时库失败回 False。
|
||||||
|
w = self._bad_db_worker()
|
||||||
|
self.assertFalse(w._queue_self_followup(
|
||||||
|
{"id": "t1", "project_id": "p1"}, "body"))
|
||||||
|
|
||||||
|
def test_coord_child_rows_missing_key_skipped(self):
|
||||||
|
# 5343-5344:子任务行缺列(非 Row 行)时跳过该行,
|
||||||
|
# 不炸等齐判断。
|
||||||
|
class _Cursor:
|
||||||
|
def fetchone(self):
|
||||||
|
return {"id": "c1"}
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
def execute(self, sql, params=()):
|
||||||
|
return _Cursor()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
with mock.patch.object(worker_mod, "_db_connect",
|
||||||
|
lambda path: _Conn()):
|
||||||
|
self.assertEqual(w._coord_child_rows(["c1"]), {})
|
||||||
|
|
||||||
|
def test_record_ledger_attempt_outbox_failure_swallowed(self):
|
||||||
|
# 5798-5799:台账写失败后 outbox 追加本身也失败时,
|
||||||
|
# 仍只吞掉、返回 None,不影响任务状态与返回值。
|
||||||
|
w = self._bad_db_worker()
|
||||||
|
w.attempt_ledger_hook = True
|
||||||
|
import cloud.ledger_outbox as outbox_mod # noqa: E402
|
||||||
|
with mock.patch.object(
|
||||||
|
outbox_mod, "append_failure",
|
||||||
|
side_effect=RuntimeError("outbox down")):
|
||||||
|
self.assertIsNone(
|
||||||
|
w._record_ledger_attempt("t1", "fail", True))
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessWatchdogBranchTestCase(unittest.TestCase):
|
||||||
|
"""进程 / 看门狗分支(基底缺失 2505-2506 / 2892-2893 /
|
||||||
|
3853-3854 / 3874-3875 / 3881-3882)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_docker_cancel_watchdog_db_interrupted(self):
|
||||||
|
# 2505-2506:看门狗查库发现任务已 interrupted 时杀容器。
|
||||||
|
killed = []
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w.executor = SimpleNamespace(
|
||||||
|
kill_container=lambda name: killed.append(name))
|
||||||
|
w.get_task = lambda task_id: {"status": "interrupted"}
|
||||||
|
stop_event = threading.Event()
|
||||||
|
w._docker_cancel_watchdog("t1", "c1", stop_event)
|
||||||
|
self.assertEqual(killed, ["c1"])
|
||||||
|
|
||||||
|
def test_execute_via_process_kill_failure_swallowed(self):
|
||||||
|
# 2892-2893:communicate 超时后 proc.kill() 本身抛错时
|
||||||
|
# 被吞掉,第二次 communicate 的结果仍正常返回 completed。
|
||||||
|
calls = {"kill": 0}
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
pid = 999
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def communicate(self, timeout=None):
|
||||||
|
if timeout is not None:
|
||||||
|
raise RuntimeError("communicate timeout")
|
||||||
|
return ("out", "err")
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
calls["kill"] += 1
|
||||||
|
raise RuntimeError("kill failed")
|
||||||
|
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w.process_factory = lambda task: _FakeProc()
|
||||||
|
with mock.patch.object(Worker, "_ensure_gateway",
|
||||||
|
return_value=(None, None)):
|
||||||
|
result = w._execute_via_process({"id": "t1"})
|
||||||
|
self.assertEqual(calls["kill"], 1)
|
||||||
|
self.assertEqual(result["status"], "completed")
|
||||||
|
self.assertEqual(result["stdout"], "out")
|
||||||
|
self.assertEqual(result["stderr"], "err")
|
||||||
|
self.assertEqual(result["returncode"], 0)
|
||||||
|
|
||||||
|
def test_marker_auth_close_failure_swallowed(self):
|
||||||
|
# 3853-3854:权限查完后 store.conn.close() 抛错时被吞掉,
|
||||||
|
# 权限结论(True)不受影响。
|
||||||
|
class _FakeConn:
|
||||||
|
def close(self):
|
||||||
|
raise RuntimeError("close failed")
|
||||||
|
|
||||||
|
class _FakeStore:
|
||||||
|
def __init__(self, db_path):
|
||||||
|
self.conn = _FakeConn()
|
||||||
|
|
||||||
|
def check_permission(self, creator, project_id, action):
|
||||||
|
return True
|
||||||
|
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
with mock.patch.object(worker_mod, "AuthStore", _FakeStore):
|
||||||
|
self.assertEqual(
|
||||||
|
w._marker_auth({"created_by": "u1",
|
||||||
|
"project_id": "p1"}, "read"),
|
||||||
|
(True, ""))
|
||||||
|
|
||||||
|
def test_task_files_worktree_remote_host(self):
|
||||||
|
# 3874-3875:worktree 在别机时直接拒绝,不指错目录。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
path, reason = w._task_files_worktree(
|
||||||
|
{"executor": "local", "worktree_host": "other-host-xyz",
|
||||||
|
"id": "t1"})
|
||||||
|
self.assertIsNone(path)
|
||||||
|
self.assertIn("other-host-xyz", reason)
|
||||||
|
|
||||||
|
def test_task_files_worktree_bad_project_falls_through(self):
|
||||||
|
# 3881-3882:project_id 缺失致安全名解析失败时,
|
||||||
|
# safe_project 置 None、走本地 worktree 分支,不炸。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w.host_py_path = os.path.join(self.tmp.name, "host.py")
|
||||||
|
path, reason = w._task_files_worktree(
|
||||||
|
{"executor": "local", "project_id": None, "id": "t1"})
|
||||||
|
self.assertIsNone(path)
|
||||||
|
self.assertIn("没有本地 worktree", reason)
|
||||||
|
|
||||||
|
|
||||||
|
class PollWaitBranchTestCase(unittest.TestCase):
|
||||||
|
"""轮询 / 等待分支(基底缺失 3711-3712 / 3720-3721 /
|
||||||
|
3728-3729 / 3747 / 3768-3769 / 3774-3775 / 3804-3805 / 3825)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _poll(self, timeout=10):
|
||||||
|
return {"task_id": "T2", "interval_seconds": 1,
|
||||||
|
"timeout_seconds": timeout, "chars": 100}
|
||||||
|
|
||||||
|
def test_poll_cancelled_before_snapshot(self):
|
||||||
|
# 3711-3712 + 3747:父任务已取消时不等快照、直接回
|
||||||
|
# “读取失败”,不挂起。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w._cancel_requested = {"P1"}
|
||||||
|
result = w._poll_task_snapshot(
|
||||||
|
{"id": "P1", "project_id": "p1"}, self._poll())
|
||||||
|
self.assertEqual(result, "轮询任务 T2:读取失败。")
|
||||||
|
|
||||||
|
def test_poll_db_failure_returns_missing(self):
|
||||||
|
# 3720-3721 + 3728-3729:心跳与快照查库都失败时,
|
||||||
|
# 异常被吞掉、回“不存在”,不炸等待循环。
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||||
|
"test.db"))
|
||||||
|
w.get_task = lambda task_id: None
|
||||||
|
result = w._poll_task_snapshot(
|
||||||
|
{"id": "P1", "project_id": "p1"}, self._poll())
|
||||||
|
self.assertEqual(result, "轮询任务 T2:不存在(同项目内也查不到)。")
|
||||||
|
|
||||||
|
def test_wait_cancelled(self):
|
||||||
|
# 3768-3769 + 3825 + 3831:父任务已取消时等待中止,
|
||||||
|
# 未读到的目标记“读取失败”,末尾附取消说明。
|
||||||
|
w = _worker(self.db_path)
|
||||||
|
w._cancel_requested = {"P1"}
|
||||||
|
lines = w._wait_task_targets(
|
||||||
|
{"id": "P1", "project_id": "p1"},
|
||||||
|
{"task_ids": ["T2"], "interval_seconds": 1,
|
||||||
|
"timeout_seconds": 10, "chars": 100})
|
||||||
|
self.assertEqual(lines[0], "[等待 1 个任务]")
|
||||||
|
self.assertIn("等待任务 T2:读取失败。", lines)
|
||||||
|
self.assertIn("说明:等待期间父任务被取消,等待已中止。", lines)
|
||||||
|
|
||||||
|
def test_wait_db_failure_silent(self):
|
||||||
|
# 3774-3775 + 3804-3805:心跳与批量快照查库都失败时,
|
||||||
|
# 异常被吞掉、超时 0 立即收尾,不炸、不挂起。
|
||||||
|
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||||
|
"test.db"))
|
||||||
|
w.get_task = lambda task_id: None
|
||||||
|
lines = w._wait_task_targets(
|
||||||
|
{"id": "P1", "project_id": "p1"},
|
||||||
|
{"task_ids": ["T2"], "interval_seconds": 1,
|
||||||
|
"timeout_seconds": 0, "chars": 100})
|
||||||
|
self.assertEqual(lines[0], "[等待 1 个任务]")
|
||||||
|
self.assertIn("等待任务 T2:读取失败。", lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -504,6 +504,33 @@ class HostLoopTestCase(unittest.TestCase):
|
|||||||
self.assertIn("new goal", prompts[0])
|
self.assertIn("new goal", prompts[0])
|
||||||
self.assertIn("旧目标", prompts[0])
|
self.assertIn("旧目标", prompts[0])
|
||||||
|
|
||||||
|
def test_first_turn_both_pending_note_before_notice(self):
|
||||||
|
# FLAG-3 定序(v2 演练决定):首轮断点备注与改 goal 通知
|
||||||
|
# 同 pending 时,模型可见顺序为备注在前、通知在后。
|
||||||
|
# 注意代码里两次 prepend 的执行顺序与可见顺序相反
|
||||||
|
# (先贴通知、再贴备注),本测试钉住的是可见顺序,
|
||||||
|
# 而非执行顺序。
|
||||||
|
task = self._task()
|
||||||
|
self._write_signal(1, "new goal")
|
||||||
|
prompts = []
|
||||||
|
script = [("completed", "目标已达成", None)]
|
||||||
|
fake = _fake_turn_script(prompts, script)
|
||||||
|
note = ("[审计推回包(第 1 轮)]\n"
|
||||||
|
"- [execution_failed] 证据:boom detail")
|
||||||
|
with mock.patch.object(host_mod, "run_task_turn", fake):
|
||||||
|
result = host_mod.run_goal_loop(
|
||||||
|
SimpleNamespace(), self.state, self.state_path, task,
|
||||||
|
"old goal", None, 0, None, None, None, cfg=None,
|
||||||
|
resume_note=note)
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(result["rounds_done"], 1)
|
||||||
|
self.assertEqual(len(prompts), 1)
|
||||||
|
first = prompts[0]
|
||||||
|
self.assertTrue(first.startswith(note))
|
||||||
|
self.assertLess(first.index(note), first.index("目标已更新"))
|
||||||
|
self.assertIn("new goal", first)
|
||||||
|
self.assertIn("作废", first)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -144,7 +144,14 @@ class TestReexports(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMethodAccounting(unittest.TestCase):
|
class TestMethodAccounting(unittest.TestCase):
|
||||||
def test_total_244_no_duplicates(self):
|
# 演练注:合入后总数 244 -> 247、server.py 自有 150 -> 153,
|
||||||
|
# 增量全是 main 侧方法(_routing_shadow、_resolve_create_
|
||||||
|
# agent_definition、_validate_create_audit_loop),源线方法
|
||||||
|
# 零丢失、mixin 侧零增减;此为源线 34b9a34 同款 re-pin。
|
||||||
|
# 补漏注:codex 原生 UI 接线新增 2 个 server.py 自有方法
|
||||||
|
# (_send_redirect、_serve_console_classic),总数 247 -> 249、
|
||||||
|
# server.py 自有 153 -> 155;mixin 侧零增减,源线方法零丢失。
|
||||||
|
def test_total_249_no_duplicates(self):
|
||||||
seen = {}
|
seen = {}
|
||||||
dups = []
|
dups = []
|
||||||
classes = [CloudControlPlaneHandler]
|
classes = [CloudControlPlaneHandler]
|
||||||
@@ -159,9 +166,9 @@ class TestMethodAccounting(unittest.TestCase):
|
|||||||
dups.append((seen[name], cls.__name__, name))
|
dups.append((seen[name], cls.__name__, name))
|
||||||
seen[name] = cls.__name__
|
seen[name] = cls.__name__
|
||||||
self.assertEqual(dups, [])
|
self.assertEqual(dups, [])
|
||||||
self.assertEqual(total, 244)
|
self.assertEqual(total, 249)
|
||||||
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
||||||
244 - 94)
|
249 - 94)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -48,11 +48,14 @@ EXPECTED_METHODS = {
|
|||||||
("server.py", "CloudControlPlaneHandler._dispatch_task_route"),
|
("server.py", "CloudControlPlaneHandler._dispatch_task_route"),
|
||||||
("server.py", "CloudControlPlaneHandler._dispatch"),
|
("server.py", "CloudControlPlaneHandler._dispatch"),
|
||||||
("server.py", "CloudControlPlaneHandler._dispatch_project"),
|
("server.py", "CloudControlPlaneHandler._dispatch_project"),
|
||||||
|
# 演练注:main 侧 _routing_shadow(233 行)为第 18 个长方法;
|
||||||
|
# 另 2 个 main 侧新增方法均 <=100 行,不进清单。
|
||||||
|
("server.py", "CloudControlPlaneHandler._routing_shadow"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LongFuncInventoryTestCase(unittest.TestCase):
|
class LongFuncInventoryTestCase(unittest.TestCase):
|
||||||
def test_seventeen_long_methods_including_full_auto_execute(self):
|
def test_eighteen_long_methods_including_full_auto_execute(self):
|
||||||
cloud_dir = Path(__file__).resolve().parents[1]
|
cloud_dir = Path(__file__).resolve().parents[1]
|
||||||
methods = set()
|
methods = set()
|
||||||
module_funcs = set()
|
module_funcs = set()
|
||||||
@@ -75,7 +78,7 @@ class LongFuncInventoryTestCase(unittest.TestCase):
|
|||||||
lines = node.end_lineno - node.lineno + 1
|
lines = node.end_lineno - node.lineno + 1
|
||||||
if lines > 100:
|
if lines > 100:
|
||||||
module_funcs.add((path.name, node.name))
|
module_funcs.add((path.name, node.name))
|
||||||
self.assertEqual(len(EXPECTED_METHODS), 17)
|
self.assertEqual(len(EXPECTED_METHODS), 18)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
("handler_fullauto.py", "FullAutoMixin._full_auto_execute"),
|
("handler_fullauto.py", "FullAutoMixin._full_auto_execute"),
|
||||||
EXPECTED_METHODS)
|
EXPECTED_METHODS)
|
||||||
|
|||||||
@@ -250,6 +250,25 @@ class AdapterFakeDriverTestCase(unittest.TestCase):
|
|||||||
self.assertIn("%s", fake.conn.cursor_obj.executed[0][0])
|
self.assertIn("%s", fake.conn.cursor_obj.executed[0][0])
|
||||||
self.assertNotIn("?", fake.conn.cursor_obj.executed[0][0])
|
self.assertNotIn("?", fake.conn.cursor_obj.executed[0][0])
|
||||||
|
|
||||||
|
def test_pragma_info_notnull_flag_matches_sqlite(self):
|
||||||
|
# PG 实证(2026-11-06)抓到的真 bug:适配层把
|
||||||
|
# information_schema.is_nullable 翻反了(NO -> 0),
|
||||||
|
# 与 sqlite PRAGMA table_info 的 notnull 语义相反。
|
||||||
|
# 此前无调用方读 row[3],故只潜伏未炸;ensure 的
|
||||||
|
# outcome 放宽依赖该标志为真,此处钉住。
|
||||||
|
rows = [(1, "task_id", "text", "NO", None, 1),
|
||||||
|
(2, "outcome", "text", "YES", None, 0)]
|
||||||
|
fake = _FakeDriver(_FakeCursor(rows=rows))
|
||||||
|
db = _make_db(fake)
|
||||||
|
conn = db.connect()
|
||||||
|
info = conn.execute(
|
||||||
|
"PRAGMA table_info(task_attempts)").fetchall()
|
||||||
|
by_name = {row[1]: row for row in info}
|
||||||
|
self.assertEqual(by_name["task_id"][3], 1)
|
||||||
|
self.assertEqual(by_name["outcome"][3], 0)
|
||||||
|
self.assertEqual([row[1] for row in info],
|
||||||
|
["task_id", "outcome"])
|
||||||
|
|
||||||
def test_unique_violation_maps_to_integrity_error(self):
|
def test_unique_violation_maps_to_integrity_error(self):
|
||||||
import sqlite3
|
import sqlite3
|
||||||
fake = _FakeDriver(_FakeCursor(fail_sequence=[_FakeUnique("dup")]))
|
fake = _FakeDriver(_FakeCursor(fail_sequence=[_FakeUnique("dup")]))
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Unified task_attempts schema (FLAG-1, user decision 2026-11-05).
|
||||||
|
|
||||||
|
Pins the single-definition rule and the three-shape migration:
|
||||||
|
|
||||||
|
- one DDL: attempt_ledger.TASK_ATTEMPTS_DDL is the same string
|
||||||
|
used by cloud.attempts.CREATE_TABLE_SQL and by the
|
||||||
|
task_attempts entry of cloud.db.POSTGRES_SCHEMA;
|
||||||
|
- fresh database: ensure builds exactly the 12 unified columns;
|
||||||
|
- old resume shape, gap-3 ledger shape, and the rehearsal-era
|
||||||
|
12-column shape all migrate in place with rows intact, and
|
||||||
|
both writers (resume + ledger) work on the migrated table;
|
||||||
|
- aggregation counts ledger rows only: a resume row (outcome
|
||||||
|
NULL) never becomes a counted, defect-free attempt.
|
||||||
|
|
||||||
|
SQLite only, run live. The Postgres upgrade path is NOT proven
|
||||||
|
here (adapter-level DDL text check only, same as the other
|
||||||
|
ledger tests); the batch summary states that explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
|
from cloud.attempt_ledger import ( # noqa: E402
|
||||||
|
PG_DDL,
|
||||||
|
SQLITE_DDL,
|
||||||
|
TASK_ATTEMPTS_DDL,
|
||||||
|
UNIFIED_COLUMNS,
|
||||||
|
_column_notnull,
|
||||||
|
aggregate_attempts,
|
||||||
|
ensure_task_attempts_table,
|
||||||
|
read_attempt_rows,
|
||||||
|
record_attempt as ledger_record,
|
||||||
|
record_correction as ledger_correct,
|
||||||
|
)
|
||||||
|
from cloud.attempts import ( # noqa: E402
|
||||||
|
CREATE_TABLE_SQL,
|
||||||
|
ensure_attempts_table,
|
||||||
|
latest_attempt,
|
||||||
|
record_attempt as resume_record,
|
||||||
|
)
|
||||||
|
from cloud.db import POSTGRES_SCHEMA # noqa: E402
|
||||||
|
from cloud.db import _translate_sql # noqa: E402
|
||||||
|
|
||||||
|
OLD_DDL = (
|
||||||
|
"CREATE TABLE task_attempts("
|
||||||
|
"task_id TEXT, attempt_no INTEGER, kind TEXT, "
|
||||||
|
"interrupted_reason TEXT, checkpoint_round INTEGER, "
|
||||||
|
"result_text TEXT, error_text TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION, "
|
||||||
|
"PRIMARY KEY(task_id, attempt_no))")
|
||||||
|
|
||||||
|
GAP3_DDL = (
|
||||||
|
"CREATE TABLE task_attempts("
|
||||||
|
"task_id TEXT NOT NULL, attempt_no INTEGER NOT NULL, "
|
||||||
|
"outcome TEXT NOT NULL, is_defect INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"defect_category TEXT, created_at DOUBLE PRECISION, "
|
||||||
|
"PRIMARY KEY (task_id, attempt_no))")
|
||||||
|
|
||||||
|
# Rehearsal-era shape: same 12 column names as unified, but the
|
||||||
|
# looser rehearsal types (outcome nullable, is_defect without
|
||||||
|
# NOT NULL, PK columns without NOT NULL spelling).
|
||||||
|
REHEARSAL_DDL = (
|
||||||
|
"CREATE TABLE task_attempts("
|
||||||
|
"task_id TEXT, attempt_no INTEGER, outcome TEXT, "
|
||||||
|
"is_defect INTEGER DEFAULT 0, defect_category TEXT, "
|
||||||
|
"corrects_attempt_no INTEGER, kind TEXT, "
|
||||||
|
"interrupted_reason TEXT, checkpoint_round INTEGER, "
|
||||||
|
"result_text TEXT, error_text TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION, "
|
||||||
|
"PRIMARY KEY(task_id, attempt_no))")
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(conn):
|
||||||
|
return [row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(task_attempts)").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_by_key(conn, task_id, attempt_no):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM task_attempts WHERE task_id = ? "
|
||||||
|
"AND attempt_no = ?", (task_id, attempt_no)).fetchone()
|
||||||
|
cols = [d[0] for d in conn.execute(
|
||||||
|
"SELECT * FROM task_attempts LIMIT 0").description]
|
||||||
|
return dict(zip(cols, row)) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleDefinition(unittest.TestCase):
|
||||||
|
def test_one_ddl_string_everywhere(self):
|
||||||
|
self.assertEqual(SQLITE_DDL, TASK_ATTEMPTS_DDL)
|
||||||
|
self.assertEqual(PG_DDL, TASK_ATTEMPTS_DDL)
|
||||||
|
self.assertEqual(CREATE_TABLE_SQL, TASK_ATTEMPTS_DDL)
|
||||||
|
schema_statements = [s for s in POSTGRES_SCHEMA
|
||||||
|
if "task_attempts" in s]
|
||||||
|
self.assertEqual(len(schema_statements), 1)
|
||||||
|
self.assertEqual(schema_statements[0], TASK_ATTEMPTS_DDL)
|
||||||
|
|
||||||
|
def test_unified_column_set(self):
|
||||||
|
self.assertEqual(tuple(UNIFIED_COLUMNS), (
|
||||||
|
"task_id", "attempt_no", "outcome", "is_defect",
|
||||||
|
"defect_category", "corrects_attempt_no", "kind",
|
||||||
|
"interrupted_reason", "checkpoint_round", "result_text",
|
||||||
|
"error_text", "created_at"))
|
||||||
|
|
||||||
|
def test_pg_ddl_text_is_static_only(self):
|
||||||
|
translated, _kind = _translate_sql(PG_DDL)
|
||||||
|
for col in UNIFIED_COLUMNS:
|
||||||
|
self.assertIn(col, translated)
|
||||||
|
self.assertIn("PRIMARY KEY (task_id, attempt_no)", translated)
|
||||||
|
self.assertNotIn("AUTOINCREMENT", translated)
|
||||||
|
self.assertNotIn("GENERATED", translated)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreshAndMigrations(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = sqlite3.connect(":memory:")
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_fresh_build_has_exact_unified_columns(self):
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||||
|
# Idempotent, rows preserved.
|
||||||
|
ledger_record(self.conn, "t-1", "success")
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
ensure_attempts_table(self.conn)
|
||||||
|
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||||
|
self.assertEqual(self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM task_attempts").fetchone()[0], 1)
|
||||||
|
|
||||||
|
def test_column_notnull_probe(self):
|
||||||
|
# PG 实证(2026-11-06):ensure 的 outcome 放宽依赖
|
||||||
|
# PRAGMA row[3] 的 notnull 语义;SQLite 侧钉住探针本身。
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
flags = _column_notnull(self.conn, "task_attempts")
|
||||||
|
self.assertFalse(flags["outcome"])
|
||||||
|
self.assertTrue(flags["task_id"])
|
||||||
|
self.assertTrue(flags["attempt_no"])
|
||||||
|
self.assertTrue(flags["is_defect"])
|
||||||
|
|
||||||
|
def test_column_notnull_probe_sees_gap3_outcome(self):
|
||||||
|
# gap-3 形态的 outcome 是 NOT NULL;探针必须报 True,
|
||||||
|
# 否则 PG 上的放宽不会触发,恢复写路仍会炸。
|
||||||
|
self.conn.execute(GAP3_DDL)
|
||||||
|
flags = _column_notnull(self.conn, "task_attempts")
|
||||||
|
self.assertTrue(flags["outcome"])
|
||||||
|
# 迁移本身不抛错、列集仍齐;真正的不变量是 ensure
|
||||||
|
# 之后恢复写路与实际的 schema 状态一致,而不是
|
||||||
|
# outcome 的 NOT NULL 位:outcome 放宽了(PG 上
|
||||||
|
# ALTER 生效;SQLite 3.53.0 起也支持 ALTER COLUMN
|
||||||
|
# DROP NOT NULL),resume 风格写行必须能写入并读回
|
||||||
|
# outcome 为 NULL 的行;outcome 仍 NOT NULL 时(更
|
||||||
|
# 早的 SQLite:ALTER 不合法、被 try/except 吞掉),
|
||||||
|
# 该行会被 INSERT OR IGNORE 静默丢弃——版本相关
|
||||||
|
# 边界,不是迁移失败。
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
self.assertEqual(set(_columns(self.conn)),
|
||||||
|
set(UNIFIED_COLUMNS))
|
||||||
|
relaxed = not _column_notnull(
|
||||||
|
self.conn, "task_attempts")["outcome"]
|
||||||
|
resume_record(self.conn, "t-gap", 2, "platform_fresh",
|
||||||
|
"worker_shutdown", -1, "second", None)
|
||||||
|
self.conn.commit()
|
||||||
|
row = _row_by_key(self.conn, "t-gap", 2)
|
||||||
|
if relaxed:
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertIsNone(row["outcome"])
|
||||||
|
latest = latest_attempt(self.conn, "t-gap")
|
||||||
|
self.assertEqual(latest["attempt_no"], 2)
|
||||||
|
else:
|
||||||
|
self.assertIsNone(row)
|
||||||
|
|
||||||
|
def test_old_shape_migrates_with_rows_intact(self):
|
||||||
|
self.conn.execute(OLD_DDL)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO task_attempts(task_id, attempt_no, kind, "
|
||||||
|
"interrupted_reason, checkpoint_round, result_text, "
|
||||||
|
"error_text, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("t-old", 1, "platform_fresh", "worker_shutdown", -1,
|
||||||
|
"partial output", "killed", 1.0))
|
||||||
|
self.conn.commit()
|
||||||
|
before = _row_by_key(self.conn, "t-old", 1)
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
# Migrated tables keep ALTER-append order, not fresh DDL
|
||||||
|
# order; every read path is by column name, so the set
|
||||||
|
# is the invariant, not the order (fresh tables pin order).
|
||||||
|
self.assertEqual(set(_columns(self.conn)),
|
||||||
|
set(UNIFIED_COLUMNS))
|
||||||
|
after = _row_by_key(self.conn, "t-old", 1)
|
||||||
|
for col in ("task_id", "attempt_no", "kind",
|
||||||
|
"interrupted_reason", "checkpoint_round",
|
||||||
|
"result_text", "error_text", "created_at"):
|
||||||
|
self.assertEqual(after[col], before[col], col)
|
||||||
|
self.assertIsNone(after["outcome"])
|
||||||
|
# Both writers now work on the migrated table.
|
||||||
|
resume_record(self.conn, "t-old", 2, "platform_fresh",
|
||||||
|
"worker_shutdown", -1, "second", None)
|
||||||
|
ledger_record(self.conn, "t-led", "failure", is_defect=True,
|
||||||
|
defect_category="contract")
|
||||||
|
self.conn.commit()
|
||||||
|
latest = latest_attempt(self.conn, "t-old")
|
||||||
|
self.assertEqual(latest["attempt_no"], 2)
|
||||||
|
self.assertEqual(latest["kind"], "platform_fresh")
|
||||||
|
# Aggregation counts the ledger row only, never the
|
||||||
|
# resume rows (outcome NULL).
|
||||||
|
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||||
|
self.assertEqual(agg, {"t-led": {
|
||||||
|
"attempts": 1, "defects": 1,
|
||||||
|
"defect_category": "contract"}})
|
||||||
|
|
||||||
|
def test_gap3_shape_migrates_and_corrections_work(self):
|
||||||
|
self.conn.execute(GAP3_DDL)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO task_attempts(task_id, attempt_no, outcome, "
|
||||||
|
"is_defect, defect_category, created_at) "
|
||||||
|
"VALUES('t-gap', 1, 'success', 0, NULL, 1.0)")
|
||||||
|
self.conn.commit()
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
self.assertEqual(set(_columns(self.conn)),
|
||||||
|
set(UNIFIED_COLUMNS))
|
||||||
|
row = _row_by_key(self.conn, "t-gap", 1)
|
||||||
|
self.assertEqual(row["outcome"], "success")
|
||||||
|
ledger_correct(self.conn, "t-gap", 1, is_defect=True,
|
||||||
|
defect_category="contract")
|
||||||
|
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||||
|
self.assertEqual(agg["t-gap"]["defects"], 1)
|
||||||
|
|
||||||
|
def test_rehearsal_shape_migrates_with_rows_intact(self):
|
||||||
|
self.conn.execute(REHEARSAL_DDL)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO task_attempts(task_id, attempt_no, outcome, "
|
||||||
|
"is_defect, defect_category, corrects_attempt_no, kind, "
|
||||||
|
"interrupted_reason, checkpoint_round, result_text, "
|
||||||
|
"error_text, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, "
|
||||||
|
"?, ?, ?, ?)",
|
||||||
|
("t-reh", 1, "failure", 1, "contract", None,
|
||||||
|
"platform_fresh", "worker_shutdown", -1, "out", "err",
|
||||||
|
2.0))
|
||||||
|
self.conn.commit()
|
||||||
|
before = _row_by_key(self.conn, "t-reh", 1)
|
||||||
|
ensure_task_attempts_table(self.conn)
|
||||||
|
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||||
|
after = _row_by_key(self.conn, "t-reh", 1)
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
# Both writers still work after the migration.
|
||||||
|
resume_record(self.conn, "t-reh", 2, "goal_breakpoint",
|
||||||
|
"paused", 3, "r", "e")
|
||||||
|
ledger_record(self.conn, "t-reh2", "success")
|
||||||
|
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||||
|
self.assertEqual(agg["t-reh"]["defects"], 1)
|
||||||
|
self.assertEqual(agg["t-reh2"]["attempts"], 1)
|
||||||
|
|
||||||
|
def test_resume_rows_never_count_in_aggregation(self):
|
||||||
|
# Pure aggregation: a NULL-outcome row is a resume row.
|
||||||
|
agg = aggregate_attempts([
|
||||||
|
{"task_id": "t-1", "attempt_no": 1, "outcome": None,
|
||||||
|
"is_defect": 0, "defect_category": None,
|
||||||
|
"corrects_attempt_no": None},
|
||||||
|
{"task_id": "t-1", "attempt_no": 2, "outcome": "success",
|
||||||
|
"is_defect": 0, "defect_category": None,
|
||||||
|
"corrects_attempt_no": None},
|
||||||
|
])
|
||||||
|
self.assertEqual(agg["t-1"]["attempts"], 1)
|
||||||
|
agg_only_resume = aggregate_attempts([
|
||||||
|
{"task_id": "t-2", "attempt_no": 1, "outcome": None,
|
||||||
|
"is_defect": 0, "defect_category": None,
|
||||||
|
"corrects_attempt_no": None},
|
||||||
|
])
|
||||||
|
self.assertNotIn("t-2", agg_only_resume)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
|
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -15,12 +16,12 @@ from types import SimpleNamespace
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cloud.sandbox import DockerExecutor
|
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
|
||||||
from cloud.server import init_db
|
from cloud.server import init_db
|
||||||
from cloud.worker import Worker
|
from cloud.worker import Worker
|
||||||
except ImportError:
|
except ImportError:
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
from cloud.sandbox import DockerExecutor
|
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
|
||||||
from cloud.server import init_db
|
from cloud.server import init_db
|
||||||
from cloud.worker import Worker
|
from cloud.worker import Worker
|
||||||
|
|
||||||
@@ -118,6 +119,34 @@ class TenantDirsTestCase(unittest.TestCase):
|
|||||||
worker._tenant_dirs("projA")
|
worker._tenant_dirs("projA")
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSubnetCandidatesTestCase(unittest.TestCase):
|
||||||
|
def test_candidates_inside_allowlist_distinct_and_24(self):
|
||||||
|
allow = ipaddress.IPv4Network("172.16.0.0/12")
|
||||||
|
seen = list(tenant_subnet_candidates("codex-p"))
|
||||||
|
self.assertEqual(len(seen), 32)
|
||||||
|
self.assertEqual(len(set(seen)), 32)
|
||||||
|
for item in seen:
|
||||||
|
net = ipaddress.IPv4Network(item)
|
||||||
|
self.assertEqual(net.prefixlen, 24)
|
||||||
|
self.assertTrue(net.subnet_of(allow))
|
||||||
|
|
||||||
|
def test_candidates_deterministic_and_name_dependent(self):
|
||||||
|
first = list(tenant_subnet_candidates("codex-a"))
|
||||||
|
self.assertEqual(first, list(tenant_subnet_candidates("codex-a")))
|
||||||
|
# sha256("codex-a") 首字节 59;顺延一格是 60。
|
||||||
|
self.assertEqual(first[0], "172.16.59.0/24")
|
||||||
|
self.assertEqual(first[1], "172.16.60.0/24")
|
||||||
|
self.assertNotEqual(
|
||||||
|
first[0], list(tenant_subnet_candidates("codex-b"))[0])
|
||||||
|
|
||||||
|
def test_candidates_wrap_around_last_slot(self):
|
||||||
|
# sha256("codex-zz") 首字节 250:槽位到 255 后绕回 0。
|
||||||
|
seen = list(tenant_subnet_candidates("codex-zz"))
|
||||||
|
self.assertEqual(seen[0], "172.16.250.0/24")
|
||||||
|
self.assertEqual(seen[5], "172.16.255.0/24")
|
||||||
|
self.assertEqual(seen[6], "172.16.0.0/24")
|
||||||
|
|
||||||
|
|
||||||
class DockerTenantCommandTestCase(unittest.TestCase):
|
class DockerTenantCommandTestCase(unittest.TestCase):
|
||||||
def test_per_call_mounts_network_and_drop_caps(self):
|
def test_per_call_mounts_network_and_drop_caps(self):
|
||||||
seen = []
|
seen = []
|
||||||
@@ -165,6 +194,68 @@ class DockerTenantCommandTestCase(unittest.TestCase):
|
|||||||
env={"XCM_API_KEY": "test-placeholder"})
|
env={"XCM_API_KEY": "test-placeholder"})
|
||||||
self.assertFalse(ex.ensure_network("codex-p"))
|
self.assertFalse(ex.ensure_network("codex-p"))
|
||||||
|
|
||||||
|
def test_ensure_network_creates_with_pinned_subnet(self):
|
||||||
|
seen = []
|
||||||
|
with mock.patch("cloud.sandbox.shutil.which",
|
||||||
|
return_value="/usr/bin/docker"):
|
||||||
|
ex = DockerExecutor(
|
||||||
|
"/fake/host.py",
|
||||||
|
runner=_ok_runner_factory(seen),
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"})
|
||||||
|
self.assertTrue(ex.ensure_network("codex-p"))
|
||||||
|
self.assertEqual(len(seen), 1)
|
||||||
|
# sha256("codex-p") 首字节 138:首候选 172.16.138.0/24。
|
||||||
|
self.assertEqual(seen[0], ["docker", "network", "create",
|
||||||
|
"--subnet", "172.16.138.0/24",
|
||||||
|
"codex-p"])
|
||||||
|
|
||||||
|
def test_ensure_network_overlap_probes_next_subnet(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def overlap_then_ok(cmd, timeout=None):
|
||||||
|
seen.append(list(cmd))
|
||||||
|
if len(seen) == 1:
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=1, stdout="",
|
||||||
|
stderr="Error response from daemon: Pool overlaps "
|
||||||
|
"with other one on this address space")
|
||||||
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||||
|
with mock.patch("cloud.sandbox.shutil.which",
|
||||||
|
return_value="/usr/bin/docker"):
|
||||||
|
ex = DockerExecutor("/fake/host.py", runner=overlap_then_ok,
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"})
|
||||||
|
self.assertTrue(ex.ensure_network("codex-p"))
|
||||||
|
self.assertEqual(len(seen), 2)
|
||||||
|
self.assertEqual(seen[0][4], "172.16.138.0/24")
|
||||||
|
self.assertEqual(seen[1][4], "172.16.139.0/24")
|
||||||
|
self.assertEqual(seen[1][5], "codex-p")
|
||||||
|
|
||||||
|
def test_ensure_network_overlap_exhausted_is_false(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def always_overlap(cmd, timeout=None):
|
||||||
|
seen.append(list(cmd))
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=1, stdout="",
|
||||||
|
stderr="Error response from daemon: Pool overlaps "
|
||||||
|
"with other one on this address space")
|
||||||
|
with mock.patch("cloud.sandbox.shutil.which",
|
||||||
|
return_value="/usr/bin/docker"):
|
||||||
|
ex = DockerExecutor("/fake/host.py", runner=always_overlap,
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"})
|
||||||
|
self.assertFalse(ex.ensure_network("codex-p"))
|
||||||
|
self.assertEqual(len(seen), 32)
|
||||||
|
|
||||||
|
def test_ensure_network_empty_name_is_false(self):
|
||||||
|
def must_not_run(cmd, timeout=None):
|
||||||
|
raise AssertionError("empty name must not call docker")
|
||||||
|
with mock.patch("cloud.sandbox.shutil.which",
|
||||||
|
return_value="/usr/bin/docker"):
|
||||||
|
ex = DockerExecutor("/fake/host.py", runner=must_not_run,
|
||||||
|
env={"XCM_API_KEY": "test-placeholder"})
|
||||||
|
self.assertFalse(ex.ensure_network(""))
|
||||||
|
self.assertFalse(ex.ensure_network(None))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -223,9 +223,14 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertFalse(out["tables_ready"])
|
self.assertFalse(out["tables_ready"])
|
||||||
|
# Unified schema: the required set is all 12 columns, so a
|
||||||
|
# gap-3-shaped table is missing corrects_attempt_no AND
|
||||||
|
# the five resume columns, not just the one column.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
out["missing"],
|
out["missing"],
|
||||||
["task_attempts missing columns: corrects_attempt_no"])
|
["task_attempts missing columns: corrects_attempt_no, "
|
||||||
|
"kind, interrupted_reason, checkpoint_round, "
|
||||||
|
"result_text, error_text"])
|
||||||
|
|
||||||
def test_verify_marker_missing_column(self):
|
def test_verify_marker_missing_column(self):
|
||||||
conn = self._sqlite(
|
conn = self._sqlite(
|
||||||
@@ -237,9 +242,15 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertFalse(out["tables_ready"])
|
self.assertFalse(out["tables_ready"])
|
||||||
|
# Unified schema: the gap-3-shaped ledger table is also
|
||||||
|
# missing the five resume columns, reported before the
|
||||||
|
# marker table's own missing column.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
out["missing"],
|
out["missing"],
|
||||||
["task_attempt_origin_markers missing columns: queued_at"])
|
["task_attempts missing columns: kind, "
|
||||||
|
"interrupted_reason, checkpoint_round, result_text, "
|
||||||
|
"error_text",
|
||||||
|
"task_attempt_origin_markers missing columns: queued_at"])
|
||||||
|
|
||||||
|
|
||||||
class LedgerOutboxGapTestCase(unittest.TestCase):
|
class LedgerOutboxGapTestCase(unittest.TestCase):
|
||||||
|
|||||||
@@ -374,6 +374,18 @@ class GatewayTestCase(_Base):
|
|||||||
# 2564-2565
|
# 2564-2565
|
||||||
class _BadEnv:
|
class _BadEnv:
|
||||||
def get(self, *a, **k):
|
def get(self, *a, **k):
|
||||||
|
# 演练注:main 侧 Worker.__init__ 新增路由策略
|
||||||
|
# 环境读取(CLOUD_ROUTING_POLICY_FILE/JSON,
|
||||||
|
# 未包在 try 内);假 env 若全键抛错,构造即
|
||||||
|
# 炸、测不到 key_available 本身。故仅对路由
|
||||||
|
# 策略键回 None(= 未配策略),其余键仍抛错,
|
||||||
|
# key_available 的失败回 False 断言意图不变。
|
||||||
|
# 另:__init__ 两处裸 env.get 是否应包 try,
|
||||||
|
# 属 main 侧跟进项,本演练不改生产 __init__。
|
||||||
|
key = a[0] if a else k.get("key")
|
||||||
|
if key in ("CLOUD_ROUTING_POLICY_FILE",
|
||||||
|
"CLOUD_ROUTING_POLICY_JSON"):
|
||||||
|
return None
|
||||||
raise RuntimeError("env down")
|
raise RuntimeError("env down")
|
||||||
|
|
||||||
w = Worker(self.db_path, "/fake/host.py", env=_BadEnv())
|
w = Worker(self.db_path, "/fake/host.py", env=_BadEnv())
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ daemon_auto_start = false
|
|||||||
|
|
||||||
# Skills / 插件:放 ~/.codex/skills 或用 `codex plugin` 管理,
|
# Skills / 插件:放 ~/.codex/skills 或用 `codex plugin` 管理,
|
||||||
# app-server 会在 thread 里按需加载,不用在代码里硬编码。
|
# app-server 会在 thread 里按需加载,不用在代码里硬编码。
|
||||||
|
# 想停用某个技能又不想删文件时,用 [[skills.config]](只写一条
|
||||||
|
# 选择器:path 匹配单个 SKILL.md,name 匹配同名全部技能;多条
|
||||||
|
# 匹配时最后一条胜出):
|
||||||
|
# [[skills.config]]
|
||||||
|
# name = "software-engineering"
|
||||||
|
# enabled = false
|
||||||
|
|
||||||
# 项目指令:每个仓库根放 AGENTS.md,thread 启动时自动合并读取,
|
# 项目指令:每个仓库根放 AGENTS.md,thread 启动时自动合并读取,
|
||||||
# 依次是 ~/.codex/AGENTS.md、仓库根、当前子目录,越往下优先级越高。
|
# 依次是 ~/.codex/AGENTS.md、仓库根、当前子目录,越往下优先级越高。
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Codex 原生 UI ↔ codex-app-server 双向对齐表
|
||||||
|
|
||||||
|
- 基线:Gitea main `37ba6876bf326ebd6b1e01ae7d2c5684dafe439b`(2026-11-06,线上 code.tose.top 生产基线)
|
||||||
|
- 工作区:`~/workspace/codex-app-server-codex-ui`,分支 `feat/codex-native-ui`
|
||||||
|
- UI 栈:`~/workspace/user/downloads/codex-ui-stack/`(zip sha256 `668bcd82…3125e`);`original/` 2026-10-27 自 `/tmp/codex-asar/webview` 原样 vendored(20918 文件,`index.html` sha `85e5c5b4…`),**必须保持字节一致**,改动只发生在 `host/` 响应层与 app-server 侧
|
||||||
|
- 清点依据:A(UI host 清点)、C(codex 0.164.0-alpha.9 全量 150 方法实测探活)、D(37ba687 基线重核:控制面 **179 个端点**,控制台为 `cloud/console-react/` 源码构建的 353KB React 包)。B 路按旧分支 5ba4d35 清点的数字(60 端点、820 行 console、resident 为 0)**在本基线全部作废**,未采用。
|
||||||
|
- codex 二进制:`codex-cli 0.164.0-alpha.9`(`/opt/hatch-image/bin/codex`)
|
||||||
|
- 约定:凡原生 UI 没有对应表面可承载某功能时,本表如实写“原生无对应表面”,不自造页面/组件。
|
||||||
|
|
||||||
|
## 集成架构(结论先行)
|
||||||
|
|
||||||
|
1. **线程共享是集成点**:`host.py` 一个 app-server 进程托管多 thread,线程状态持久化在 `CODEX_HOME`(默认 `~/.codex`)。UI host 的 `app-server-bridge.mjs` 自己 spawn 一个 app-server,但因共享同一 CODEX_HOME,`thread/list` 能看到 host.py 任务的 thread(C 实测:`thread/list` 返回了既有 thread)。因此 UI 不需要直连 host.py 的 app-server 进程,也不需要新增 socket/ws 附着逻辑。
|
||||||
|
2. **入口替换方式**:`GET /console` 改为按需 302 到 UI host(默认 `http://127.0.0.1:8095/`,可配 `CODEX_UI_URL`);UI host 不可达时回落到旧控制台。旧 React 控制台**保留**,新路由 `GET /console-classic` 继续 serve `cloud/console.html`(理由见 §3)。
|
||||||
|
3. **original/ 不进仓**:解压后 650MB / 20918 文件,不可提交。入仓的只有 `host/` 适配层(约 104KB)与启动脚本;`original/` 由 `CODEX_UI_ORIGINAL_ROOT` 指向已下载的栈目录,部署时以 zip sha256 校验(见 §4)。
|
||||||
|
4. **模型走 relay,不走 mock**:栈自带的 `relay-config.json` 里 `baseUrl` 指向本地 mock(`http://127.0.0.1:8095/mockrelay/v1`),真实 Bumian 地址只写在 JSON 里、host 代码无引用,`apiKey` 为空。当前配置下模型调用走确定性 mock 替身。接真实 relay 须把 `baseUrl` 改为 `relayBaseUrl` 并经环境变量给 key(只写 envKey 名,key 值绝不入文件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A 表——UI 要、codex-app-server / 官方 app-server 缺的
|
||||||
|
|
||||||
|
| # | UI 需要的方法/功能 | 缺口现状(实测) | 补齐方案与落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | `thread/stop` | 官方 0.164.0-alpha.9 不存在(`unknown variant`,C 实测;实验 schema 枚举 172 方法里也没有它) | 桥接层仿真:`app-server-bridge.mjs` 拦截 `thread/stop`,转调 `turn/interrupt`(host.py:939 已确认:对齐 Codex App 停止按钮就是发 `turn/interrupt`)。落点:入仓的 `cloud/codex-ui-host/host/app-server-bridge.mjs` |
|
||||||
|
| A2 | `thread/rollback` | 同上,不存在 | 桥接层仿真:转调 `thread/revert`(实测存在)。同上落点 |
|
||||||
|
| A3 | `thread/startAeon` | 同上,不存在 | 桥接层仿真:转调 `thread/start`(Aeon 为实验模式,本二进制无此入口;降级为普通 start,并在方法注释里写明)。同上落点 |
|
||||||
|
| A4 | `interactive/liveSessions/list`、`interactive/session/upload`、`interactive/sessionSandbox/list`、`interactive/sessionSandbox/read`(4 个) | 同上,均不存在 | 桥接层仿真:`list` 类回空列表、`upload`/`read` 回结构化 `-32601 method not found (interactive sessions not available in codex 0.164.0-alpha.9)`。不伪造会话数据。UI 的 live-session 面若调用,即表现为“无 live 会话”,不静默成功。落点同上 |
|
||||||
|
| A5 | 实验协议 29 个方法(UI 清单漏列,二进制实测 schema 里有) | UI 清单只有 150 个;`local-host.js` 按清单动态生成 services,漏列的 29 个 UI 调不到。29 个:`account/bedrock/checkGovCloudRequirements`、`account/sendAddCreditsNudgeEmail`、`account/workspaceMessages/read`、`command/exec/resize|terminate|write`、`environment/add`、`environment/status`、`externalAgentConfig/import/recordHistory`、`plugin/share/checkout`、`server/diagnostics`、`thread/backgroundTerminals/list|terminate`、`thread/decrement_elicitation|increment_elicitation`、`thread/items/read`、`thread/prediction/request`、`thread/readState/update`、`thread/realtime/appendAudio|appendSpeech|appendText|listVoices|start|stop`、`userVerification/cancel|delete|enroll|status|verify` | 把 29 个追加进 `appserver-methods.json`(150 → 179 个,与控制面端点数巧合相同,无关联)。只加清单、不改 `original/`。其中 `realtime/*` 与 `userVerification/*` 在本机未逐个探活,标注“仅 schema 有名,未探活”。落点:入仓的 `cloud/codex-ui-host/host/appserver-methods.json` |
|
||||||
|
| A6 | 桥接在 Linux 上找到 codex 二进制 | `app-server-bridge.mjs` 的默认候选只有 `$CODEX_APP_SERVER_BIN` 与两个 macOS ChatGPT 内路径;在 Linux 上不设环境变量即报 `codex binary not found`(C 实测) | 入仓的 bridge 增加候选:`$CODEX_BIN`(host.py 同款环境变量名)→ `/opt/hatch-image/bin/codex` → PATH 里的 `codex`。落点同 A1 |
|
||||||
|
| A7 | UI 要 `git.request("stable-metadata")`(Send 门控靠它非空) | `local-host.js` 里 git 是 stub,固定回 `{available:false, …}`,其余回 null | 桥接层补只读 git 能力:`local-host.js` 的 `git.request` 改为调 host 新增端点 `POST /__codex_git`(只接受 `stable-metadata` / `branch` / `status` 三种只读 op,在给定 cwd 内跑 `git` 只读命令,拒绝写操作与 cwd 外路径)。落点:入仓的 `cloud/codex-ui-host/host/local-host.js` + `web-server.mjs` |
|
||||||
|
| A8 | UI 要终端面 | `local-host.js:274` 把 `terminal` 服务显式置 null;进程类能力只剩 app-server 的 `process/*` / `thread/shellCommand` | 不补终端面:原生 UI 的终端服务在本栈是 stub,app-server 的 `process/spawn|writeStdin|resizePty|kill` 实测存在(缺参但方法存在),UI 若经 `process/*` 调用即走真实进程。实现时核实 UI 是否调用 `terminal` 服务:若调用,记为“原生终端面在本栈不可用”,不造终端组件。落点:验证阶段核实,不写代码 |
|
||||||
|
| A9 | UI 要账号 / 用量 / 远控 | `account/rateLimits/read`、`account/usage/read`、`plugin/share/list`、`remoteControl/enable|disable|pairing/start` 需 ChatGPT 账号登录(C 实测);当前 provider(relay/apikey)不用 gateway OAuth,`account/gatewayOAuth/login` 不可用 | **不可补**:这是 provider/账号限制,不是代码缺口。UI 的 `mode-decide` 改写已强制跳过登录直进 app,本栈账号面实际不可达。实现不改动,验收时以实测报错为准 |
|
||||||
|
| A10 | UI host 无鉴权 vs 控制面要求 `X-API-Key` | UI host 全部端点同源、无鉴权,只绑 `127.0.0.1:8095`;控制面除 `/health`、`/console`、`/oauth/*`、`/webhooks/*` 外一律要求 key | 不给 UI host 加自造登录面:保持 loopback-only 绑定;`/console` 的 302 只发生在控制面已鉴权(或本机回环)的请求之后——实现方案:`cloud/server.py` 的 `/console` 在 302 前先做一次 UI host 健康探测(`GET /` 2 秒超时),探测失败则回落旧控制台;UI host 本身不暴露到外网。密钥不参与 UI host 鉴权 |
|
||||||
|
| A11 | UI 要 `config/read` 含 relay 覆盖、`model/list` | C 实测均 ok;`config/read` 返回 `model=gpt-6.1-sol`、`model_provider=openkun`(与本机 `~/.codex/config.toml` 一致);`model/list` 仅一条 | 无缺口:桥接的 `-c` 覆盖(`relayArgs`)保持原样;`verify.mjs --deep` 第 13 项即校验此点。实现不改动 |
|
||||||
|
|
||||||
|
## B 表——app-server 有、UI 缺的(控制面 179 个端点 → 原生 UI 表面)
|
||||||
|
|
||||||
|
控制面分组(D 实测,179 = 任务 66 + 项目/环境/上传 17 + 租户配额/管理 11 + OAuth/身份/key 14 + GitHub App/webhook 7 + resident 7 + automations 10 + 记忆/KB/成本/推送 23 + worker/监控/模型 10 + 其它 full-auto 9 + todos 5):
|
||||||
|
|
||||||
|
| # | 控制面功能(端点) | 原生 UI 对应表面 | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | 任务创建 / 列表 / 详情(`POST /tasks`、`GET /tasks`、`GET /tasks/{id}`) | 线程列表(`thread/list`)与线程阅读(`thread/read`、`thread/items/list`)是**线程**,不是**任务**;任务含 prompt/执行器/模型/goal/分支/worktree 等宿主字段,原生 UI 无任务队列面 | 原生无对应表面(任务列表、建任务表单)。任务的线程内容可经 `thread/read`、`thread/items/list` 看到,属部分映射,不等同 |
|
||||||
|
| B2 | 任务操作:取消/暂停/重试/合并/发布/提 PR/分叉/继续(`POST /tasks/{id}/{cancel,pause,retry,merge,release,pr,fork,continue}`) | 取消→`turn/interrupt`(+ A1 的 `thread/stop` 仿真);重试/继续→`thread/resume`、`thread/start`;分叉→`thread/fork` | 部分映射:cancel/retry/fork/continue 有方法级对应;pause/release/merge/pr **原生无对应表面**(合并与 PR 是宿主 Git 流程,原生 UI 无此概念) |
|
||||||
|
| B3 | 任务 trace(`GET /tasks/{id}/trace`、`/trace/stream` SSE)、时间线、成本、结果全文、agents、文件/目录树(`/files`、`/tree`) | `thread/timeline/list`(trace 的部分映射)、`thread/items/list`(内容)、`fs/readDirectory`、`fs/readFile`(文件浏览的部分映射) | 部分映射:trace/时间线/文件有近似方法;成本(`GET /tasks/{id}/cost`)、agents 分工、SSE trace 流 **原生无对应表面** |
|
||||||
|
| B4 | 任务线程管理(`/threads`、`/thread`、`/thread/{rename,archive,unarchive,compact,message}`、`/chat-history`、`/messages`、followups、reviews、approvals、handoff、refs、worktree、audit-loop、goal、sleep/wait) | `thread/name/set`、`thread/archive|unarchive`、`thread/compact/start`、`thread/items/list`、`review/start`、`thread/approveGuardianDeniedAction` | 部分映射:改名/归档/压缩/评审启动/审批有方法级对应;followups、handoff、refs、worktree 快照、audit-loop 裁决/修复、goal 续跑、sleep/wait、任务间 messages **原生无对应表面** |
|
||||||
|
| B5 | 浏览器远控(`/tasks/{id}/browser/{state,stream,input,control}`) | 无。原生 UI 的浏览器面是它自己的渲染面,不接管外部浏览器 | 原生无对应表面 |
|
||||||
|
| B6 | 项目与成员(`POST/GET /projects`、`PATCH /projects/{id}`、`/projects/{id}/members`) | `project/list|read|create|import|update|delete|move` 是 codex 本地工程目录,不是云端多租户项目;成员/角色模型(viewer/contributor/maintainer/owner)原生 UI 没有 | 原生无对应表面(云端项目制与成员管理) |
|
||||||
|
| B7 | 环境(`POST/GET /environments`、`/environments/{id}/archive`) | `environment/info` 是单机执行环境信息,不是“项目仓库命名基准检出” | 原生无对应表面 |
|
||||||
|
| B8 | 租户配额 / 管理(`GET /users/me/quota`、`/admin/quotas`、`/admin/sweep`、`/admin/backup`、`/admin/audit-ledger/*`、`/admin/push/dispatch`、`/admin/cost-by-creator`、`/admin/ops/approval-timeout`、`/users/me/cost`) | `account/usage/read`、`account/rateLimits/read` 需 ChatGPT 登录且不可用(A9);其余无 | 原生无对应表面 |
|
||||||
|
| B9 | OAuth / 身份 / API key(`/oauth/{provider}/*`、`/users/me/keys`、`/users/me/profile`、`/users/me/gateway/rotate`、`/github-app/*`) | `account/login/start`、`account/logout` 存在,但本栈 `mode-decide` 改写跳过登录直进 app,账号面实际不可达;key 管理无 | 原生无对应表面(OAuth 与本站 key 管理) |
|
||||||
|
| B10 | GitHub webhook / Slack webhook(`POST /webhooks/github|slack`)、GitHub 仓库列表(`GET /github/repos`) | 无 | 原生无对应表面 |
|
||||||
|
| B11 | resident 常驻监听(7 个端点,`POST/GET/PATCH /projects/{id}/resident-watches`、pause/resume/end) | 无。控制台 `console-react/src/` 全文 0 处 `resident`,连旧控制台都没有这个面 | 原生无对应表面 |
|
||||||
|
| B12 | automations 与分诊(10 个端点,`/projects/{id}/automations`、bind/heartbeat/cancel、`/triage`) | 无。`automations.py` 的自动化是云端定时建任务;原生 UI 无自动化面(host.py 的本地 automations 是另一套) | 原生无对应表面(bind/heartbeat 连旧控制台都没调) |
|
||||||
|
| B13 | full-auto(9 个端点,`/full-auto/{status,config-plan,validate-action,merge-gate,ci-fix,release-plan,rollback-plan,execute,audit}`) | 无 | 原生无对应表面 |
|
||||||
|
| B14 | 项目记忆 / KB / 成本 / 推送(23 个端点,`/projects/{id}/{memory,kb,cost,push/subscriptions}`、`/users/me/memories`、`/users/me/identity-context`、`/users/me/auto-memory*`) | `memory/status|reset` 是 codex 本地记忆状态,不是项目记忆库;`skills/list` 与项目技能清单(`GET /projects/{id}/skills`)部分对应 | 部分映射:项目技能→`skills/list`;其余(项目记忆/KB/成本/推送订阅/自动记忆)**原生无对应表面** |
|
||||||
|
| B15 | 技能 / 应用 / MCP(`GET /projects/{id}/{skills,apps,mcp-servers}`、`POST …/mcp-servers/reload|oauth-login`) | `skills/list`、`plugin/list|installed`、`app/list|read`、`mcpServerStatus/list`、`mcpServer/tool/call`、`config/mcpServer/reload` | 覆盖:技能/插件/应用/MCP 有方法级对应(`plugin/read` 失败时 host 有离线回落) |
|
||||||
|
| B16 | worker / 监控 / 模型(`GET /workers`、`/workers/heartbeat`、`/metrics`、`/metrics-report`、`/audit-scorecard`、`/routing-shadow`、`/models`、`/relay/models`) | `model/list`、`modelProvider/capabilities/read` 覆盖模型面;其余无 | 部分映射:模型列表有对应;worker 池、指标、评分卡、路由影子 **原生无对应表面** |
|
||||||
|
| B17 | 待办(5 个端点,`/projects/{id}/todos`) | 无 | 原生无对应表面 |
|
||||||
|
| B18 | 模型总览与中转模型列表(`GET /models`、`GET /relay/models`) | `model/list`(C 实测仅一条 `gpt-6.1-sol`) | 部分映射:单模型列表有对应;中转站多模型下拉无原生面(旧控制台用 `/relay/models` 做模型下拉,新 UI 用 `model/list`) |
|
||||||
|
|
||||||
|
## §3 旧 console.html 的去留决定
|
||||||
|
|
||||||
|
**决定:保留旧控制台,改路由为 `GET /console-classic`,`GET /console` 改为新 UI 入口(302 到 UI host,不可达时回落旧控制台)。**
|
||||||
|
|
||||||
|
理由:
|
||||||
|
1. 旧控制台是 React 构建产物(`cloud/console-react/`,46 个源码文件),承载 B 表里大量“原生无对应表面”的管理面(配额/管理、resident、automations、full-auto、审计、成员、待办等)。悄悄删除等于删除功能,违反“不删除较新工作”。
|
||||||
|
2. 原生 UI 是单用户线程界面,无法承载多租户控制面;“替换”指的是默认入口,不是不留后路。
|
||||||
|
3. `test_console.py`(5 个测试)只验壳与路由,不跑浏览器 JS;路由改动须同步更新这 5 个测试(`/console` 无 key 200 出 HTML 的断言要改为 302/回落行为,`/console-classic` 承接原断言),并新增 UI host 相关测试。
|
||||||
|
|
||||||
|
## §4 入仓清单(实现范围)
|
||||||
|
|
||||||
|
1. `cloud/codex-ui-host/`:自 `~/workspace/user/downloads/codex-ui-stack/` 拷入 `host/` 全部 12 个文件 + `start.sh` + `verify.mjs`(原样,改动只在下述适配处;`original/` 不入仓)。
|
||||||
|
- 适配 A1–A4:`app-server-bridge.mjs` 增加 7 个缺失方法的仿真分支。
|
||||||
|
- 适配 A5:`appserver-methods.json` 追加 29 个方法(150 → 179)。
|
||||||
|
- 适配 A6:`app-server-bridge.mjs` 增加 `$CODEX_BIN`、`/opt/hatch-image/bin/codex`、PATH 候选。
|
||||||
|
- 适配 A7:`local-host.js` 的 `git.request` 走新增 `POST /__codex_git`(只读三 op);`web-server.mjs` 增加该端点。
|
||||||
|
- 适配:`relay-config.json` 保持栈自带 Bumian 配置原样(`apiKey` 为空),另加 `relay-config.README.md` 说明 mock 现状与接真实 relay 的两步(`baseUrl` 改为 `relayBaseUrl` + 设 `BUMIAN_API_KEY` 环境变量);不换 provider、不写 key。
|
||||||
|
- 适配:`projectless.mjs` 的默认工作区根在 Linux 上改为 `$CODEX_WEB_WORKSPACE_ROOT` 或 `~/Codex`(原 `~/Documents/Codex` 在服务器上不存在)。
|
||||||
|
2. `cloud/server.py`:`GET /console` 改为 302 到 `CODEX_UI_URL`(默认 `http://127.0.0.1:8095/`),2 秒探测失败回落旧控制台;新增 `GET /console-classic` serve `cloud/console.html`(原 `/console` 行为整体平移)。
|
||||||
|
3. `scripts/run-codex-ui.sh`(新建):启动 UI host 的唯一入口,设置 `CODEX_APP_SERVER_BIN`、`CODEX_UI_ORIGINAL_ROOT`(默认已下载的栈目录),`exec node cloud/codex-ui-host/host/web-server.mjs --port ${UI_PORT:-8095}`。
|
||||||
|
4. 测试:`cloud/tests/test_console.py` 按 §3 更新;新增 `cloud/tests/test_codex_ui_host.py`(Python 侧:方法表计数 179、7 个仿真方法在 bridge 里存在、original/ 未被入仓——即仓内无 `cloud/codex-ui-host/original/`、启动脚本存在且可执行)。
|
||||||
|
5. 验证:`python3 run_tests.py cloud` 相关子集 + `node verify.mjs`(shallow)+ `node verify.mjs --deep`(真实 app-server 可用时)+ 真实联调冒烟(起 :8095,`thread/list` 与等价调用,日志为证)。
|
||||||
|
|
||||||
|
## §4.1 实现注记(实现后补记,与规范同等效力)
|
||||||
|
|
||||||
|
- A5:`appserver-methods.json` 是纯 JSON,加文件头注释会破坏合法性,故注释未写进 JSON 本身;“`realtime/*` 与 `userVerification/*` 仅 schema 有名、未逐个探活”记在这里(本节),与 A5 表格同效。
|
||||||
|
- A4 口径:以本表 A4 为准——`list` 类(`interactive/liveSessions/list` 与 `interactive/sessionSandbox/list`)回空列表;`upload` 与 `sessionSandbox/read` 回结构化 `-32601`。实现初版曾把 `sessionSandbox/list` 划入 `-32601` 组,协调员已按本表纠正。
|
||||||
|
- `web-server.mjs` 的 `--root` 默认值增加了 `CODEX_UI_ORIGINAL_ROOT` 回落(1 行):不加则启动脚本设该变量无效;接受该改动,启动脚本无需改传 `--root`。
|
||||||
|
|
||||||
|
## §5 剩余缺口(实现后仍存在,如实记录)
|
||||||
|
|
||||||
|
- B 表里标记“原生无对应表面”的功能(B1 任务队列、B6 项目制、B7 环境、B8 配额/管理、B9 OAuth/key、B10 webhook、B11 resident、B12 automations、B13 full-auto、B14 项目记忆/KB/成本/推送大部、B17 待办、B5 浏览器远控)**没有**原生界面承载,仍走 `/console-classic`(旧 React 控制台)。这是按用户要求不自造界面的直接结果,不是遗漏。
|
||||||
|
- A9(ChatGPT 登录类方法)不可补;A8 终端面待验证阶段核实。
|
||||||
|
- `original/` 不进仓:部署时须另行提供与 zip sha256 一致的 `original/` 目录,否则 UI host 无法启动;这一步属于部署(须用户点头),不在本轮本地完成范围内。
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 上游对齐:openai/codex 稳定版 0.166.0(核查日 2026-11-05)
|
||||||
|
|
||||||
|
结论先说:**已用 0.166.0 官方二进制重导 schema 并验证通过;本批只改
|
||||||
|
宿主侧 pin、文档、schema 与映射测试,不动任何生产二进制。**
|
||||||
|
|
||||||
|
生产二进制更换(/usr/local/bin/codex、172、170、Docker 镜像内
|
||||||
|
二进制)是部署动作,需要用户另外点头 + 窗口,不在本批;本批的
|
||||||
|
验证边界是本地 scratch 验证(见下文“验证”),不是生产换装实证。
|
||||||
|
|
||||||
|
## 基线与上游位置
|
||||||
|
|
||||||
|
- 本项目此前锚定:`codex-cli 0.164.0-alpha.9`(2026-10-25 实测,
|
||||||
|
`protocol/` 的 schema 就是从它用
|
||||||
|
`codex app-server generate-json-schema` 导出的;见
|
||||||
|
`docs/upstream-164-alignment.md`)。
|
||||||
|
- 上游稳定版:`0.165.0`(2026-10-27)、`0.166.0`(2026-10-29,
|
||||||
|
当前最新稳定版)。本批一次跨两个稳定版锚定到 0.166.0,不
|
||||||
|
在 0.165.0 停留(0.165.0 的内容已全含在 0.166.0 里)。
|
||||||
|
- 上游 alpha 线(不作为目标):0.167.0-alpha.5 与 0.168.0-alpha.1
|
||||||
|
(均 2026-11-03),无 0.167/0.168 稳定版。按 164 文档立过的
|
||||||
|
原则只记录、不跟进;alpha 跟踪办法见批总结,不进本文档。
|
||||||
|
- 上游来源:https://github.com/openai/codex/releases(npm 官方包
|
||||||
|
`@openai/codex@0.166.0` + 平台包 `@openai/codex@0.166.0-linux-x64`,
|
||||||
|
npm integrity sha512 已验)。
|
||||||
|
|
||||||
|
## 稳定版 0.166.0 改动 → 本项目状态
|
||||||
|
|
||||||
|
| 上游改动 | 对宿主层的影响 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| app-server 线程启动/关闭竞态修复、已完成 runtime 及时释放(#57787、#57788) | 宿主 host.py 是长循环、一个 app-server 进程托管多 thread,直接受益 | 升级收益,无需改代码;重测 thread 生命周期一项(已做,见验证) |
|
||||||
|
| resume/fork 时保留已确认的 approval 与权限设置(#57227) | 宿主 resume/fork 流程不再被重置坑绊 | 升级收益,无需改代码 |
|
||||||
|
| 独立 executor 随规范 CLI 与 app-server 发布包一起发(#57837) | 实查:不是新文件,是二进制内 `exec-server` 子命令;三件套文件形态不变,宿主只驱动 `app-server` | 不影响安装形态,无需改代码 |
|
||||||
|
| GPT-6.1 Terra 进入 bundled、Vertex AI、Bedrock 三个目录(#57293);Vertex AI provider(0.165.0 #56265) | 宿主模型走中转路由,引擎版本不卡 Terra 可用性;内置列表回退路径 0.165+ 才含 Terra | 不相关,无需改代码 |
|
||||||
|
| `codex queue --resume`(0.165.0 #57232) | 宿主任务队列自建(host.py 循环 + worker_sweep admin CLI),不依赖上游 queue | 不相关,功能等价但实现无关 |
|
||||||
|
| `codex doctor --network-only`(#57606) | 运维诊断工具,不是宿主运行路径 | 观察项,不接 |
|
||||||
|
| 键盘 transcript 选择、Markdown 渲染一致化、安全拦截说明 | 都是 TUI 交互面,app-server 协议与宿主层不依赖它们 | 不相关,无需改代码 |
|
||||||
|
| memory v2 对新 profile 默认开启 + 外部记忆导入(0.165.0) | 宿主 auto_memory 为自建租户记忆,未对齐上游 memory v2 格式/导入 | 不对齐,见对齐表 A10;本批不动 |
|
||||||
|
| 实验性 attached thread forks(0.165.0 #56351) | 只在 `--experimental` schema 里出现(`ThreadForkParams.attachToSource`),默认 schema 无;且 attached fork 在普通 thread/list 里不可见,与宿主“fork 即新建 worktree 的可见线程”模型冲突 | 不接,评估见批总结;本批不写代码 |
|
||||||
|
| 构建基线 Node.js 24.22.1、Rust 1.95.0 | 与本机 Node 24.22.1 一致;宿主是 Python,不编译 Rust | 不相关 |
|
||||||
|
|
||||||
|
## schema 重导结果(0.166.0 二进制实导,非树对比)
|
||||||
|
|
||||||
|
导出方式:`codex app-server generate-json-schema --out <dir>`,
|
||||||
|
原样取其中两 bundle 替换 `protocol/`(v1 + v2,与旧形态一致,
|
||||||
|
不引入 per-type 散文件)。
|
||||||
|
|
||||||
|
| 对比项 | alpha.9 时代 bundle | 0.166.0 重导 bundle | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| v1 bundle 定义数 | 83 | 83 | 仅 ClientRequest 变(新增 feedback/save 一个方法) |
|
||||||
|
| v2 bundle 定义数 | 665 | 677 | +12 定义、0 删除、16 个定义有改动 |
|
||||||
|
| v2 新增定义 | — | AccountSession、AccountSessionWorkspace(Kind)、ApplicationStorageRequirements、FeedbackSaveParams/Response、ModelProviderError、OtelExporter、OtelHttpProtocol、OtelRequirements、OtelTlsConfig、OtelToolResultLogConfig | 均为新能力/新遥测,无 host 用项 |
|
||||||
|
| v2 改动定义 | — | Account、AdditionalContextEntry、ApplicationRequirements、ClientRequest、CodexErrorInfo、ConfigRequirements、HookMetadata、HookRunSummary、HooksListParams、NetworkRequirements、ProcessExitedNotification、ReviewDelivery、ReviewStartParams、ReviewStartResponse、ThreadItem、ThreadUsage | 除 ReviewDelivery 外全是新增可选字段或描述改动(见下) |
|
||||||
|
| 方法集 | ClientRequest 105 | ClientRequest 106(+feedback/save) | 0 删除;ServerRequest 10→10、通知 85→85 全不变 |
|
||||||
|
| host 方法 params 引用 | — | 21 个 host 方法的 params 引用全部未变 | 零漂移 |
|
||||||
|
|
||||||
|
唯一语义变化:`ReviewDelivery` 枚举从 `["inline", "detached"]`
|
||||||
|
变为 `["inline"]`(detached 已移除),`ReviewStartParams.delivery`
|
||||||
|
与 `ReviewStartResponse.reviewThreadId` 的描述同步改写。宿主早已
|
||||||
|
不发 detached(`host.py start_review` 注释“不发 detached(已废弃)”、
|
||||||
|
`cloud/review.py` 同款说明),故无影响;其余改动如
|
||||||
|
`ThreadItem` 新增 `completedAtMs`/`startedAtMs`(可空)、
|
||||||
|
`ThreadUsage` 新增 `nativeUsageUsdMicros`(可空)、
|
||||||
|
`CodexErrorInfo` 新增 `modelNotFound`/`policyCapacityUnavailable`
|
||||||
|
均为向后兼容加法。
|
||||||
|
|
||||||
|
方法数勘误:2026-11-05 对齐表写“17 个方法/通知”并不准确。
|
||||||
|
实查 host.py 触及 21 个方法字符串:18 个发出请求(含
|
||||||
|
initialize 握手)+ 3 个入站通知;对齐表列出的 18 项漏了
|
||||||
|
initialize、app/list、mcpServerStatus/list 三个。
|
||||||
|
`tests/test_host_protocol_mapping.py` 以实查 21 项为准钉死。
|
||||||
|
|
||||||
|
## 三件套核对(0.166.0 linux-x64 平台包实查)
|
||||||
|
|
||||||
|
- `bin/codex`(319,346,888 字节,`codex-cli 0.166.0`)、
|
||||||
|
`bin/codex-code-mode-host`(77,997,328 字节)、
|
||||||
|
`codex-resources/bwrap`(529,776 字节)三件齐全,成套。
|
||||||
|
- 包内另有 voice bundle(`codex-voice-host` 及 gstreamer 库,
|
||||||
|
语音为刻意不做项,不理会)与 `codex-path/rg`,均不影响三件套。
|
||||||
|
- 0.166.0 发布包不改变安装形态:宿主的 `bin/codex` 优先、
|
||||||
|
`CODEX_BIN` 环境变量、`PATH` 回退三档逻辑(`host.py
|
||||||
|
default_codex_bin`、`start.sh`)无需改动。
|
||||||
|
|
||||||
|
## 验证(本地 scratch 验证,无模型调用)
|
||||||
|
|
||||||
|
- 沙箱 smoke:scratch 三件套布局下 `codex sandbox echo`
|
||||||
|
成功返回,exit 0(bwrap 配对工作)。
|
||||||
|
- app-server smoke:仓库自带 `client.py --list-threads` 对
|
||||||
|
scratch 0.166.0 二进制跑通:initialize 成功(codexHome 为
|
||||||
|
scratch 目录、platform=linux)、thread/list 返回空数据,exit 0。
|
||||||
|
- 技能发现复核:0.166.0 下 `<codex-home>/skills/`(我方播种
|
||||||
|
位置)与 `$HOME/.agents/skills/`(文档 USER 位置)放的测试
|
||||||
|
技能均被 `skills/list` 发现(scope=user、enabled=true),
|
||||||
|
故播种机制无需改动;系统技能(skill-creator 等 5 个)照常出现。
|
||||||
|
- 方法映射重测:`tests/test_host_protocol_mapping.py`(21 项
|
||||||
|
钉死)+ 完整三套件(数见批总结)。
|
||||||
|
|
||||||
|
## 本批实际改动(只改宿主侧,不动官方二进制)
|
||||||
|
|
||||||
|
1. `protocol/`:两 bundle 替换为 0.166.0 二进制原样导出
|
||||||
|
(749,825 + 646,070 字节)。
|
||||||
|
2. `README.md`:实测版本 pin 从 `0.164.0-alpha.9` 改为
|
||||||
|
`0.166.0`(验证通过后才改,见批总结;不是提前改 pin 充数)。
|
||||||
|
3. `start.sh`:三件套注释版本号同步为 0.166.0(仅注释)。
|
||||||
|
4. `config.example.toml`:补 `[[skills.config]]` 禁用语义的
|
||||||
|
注释示例(仅注释,不改任何生效配置)。
|
||||||
|
5. `tests/test_host_protocol_mapping.py`:新增,21 项钉死。
|
||||||
|
6. 本文件:作为 0.166.0 的对齐记录,后续每次上游稳定版发布时
|
||||||
|
按同一张表复核。
|
||||||
|
|
||||||
|
## 明确不做的事(诚实边界)
|
||||||
|
|
||||||
|
- **不换任何生产/服务器二进制。** `/usr/local/bin/codex`、
|
||||||
|
172、170、Docker 镜像内二进制一律不动;本批二进制只活在
|
||||||
|
scratch 目录,批后清理。
|
||||||
|
- **不追 alpha。** 0.167/0.168 alpha 只记录、不采用。
|
||||||
|
- **不改播种机制。** 0.166.0 下 `<codex-home>/skills/` 仍被
|
||||||
|
发现,无需改 `skill_seed.py`。
|
||||||
|
- **不接 attached forks。** 实验性、仅 `--experimental`
|
||||||
|
schema、与宿主可观测模型冲突;重估触发条件见批总结。
|
||||||
|
- **不改阈值、不改 #238 auto_apply、不改 #247 语义、不改
|
||||||
|
v1.1 契约、不改 SKILL.md 第五项。**
|
||||||
|
- **不合并、不部署、不推送。** 本批留在隔离 worktree 的
|
||||||
|
`codex-opensource-align2` 分支上;live 仍是 aba06d9,不碰。
|
||||||
|
|
||||||
|
## 下一步(需要用户定)
|
||||||
|
|
||||||
|
1. 是否把生产二进制从 alpha.9 换到 0.166.0(主机部署动作,
|
||||||
|
需窗口 + 重跑 resume / goal / 成本实弹)?那一步与控制面
|
||||||
|
部署是两件分开的决策,不可悄悄混进一次部署。
|
||||||
|
2. gapfill2 线是否合并(B1)仍待用户;本批不替用户定。
|
||||||
@@ -2915,21 +2915,25 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
|||||||
f"目标:{goal_text}\n"
|
f"目标:{goal_text}\n"
|
||||||
f"验收命令:{verify_text}\n"
|
f"验收命令:{verify_text}\n"
|
||||||
f"上一轮验收输出:\n{last_verify}")
|
f"上一轮验收输出:\n{last_verify}")
|
||||||
|
# FLAG-3 定序(v2 演练决定):首轮两者同 pending 时,
|
||||||
|
# 模型可见顺序为断点备注在前、改 goal 通知在后、再是
|
||||||
|
# 本轮任务提示。注意两次 prepend 的执行顺序与可见顺序
|
||||||
|
# 相反:先贴通知、再贴备注,最终才是备注在前。v1 注释
|
||||||
|
# 声称“先见备注”但代码实际产生通知在前,v2 在此修正,
|
||||||
|
# 并由 test_goal_live_update 的共 pending 测试钉住。
|
||||||
|
# 影响面仅首轮共 pending 时;之后轮次备注块是 no-op,
|
||||||
|
# 通知块行为不变。
|
||||||
|
if pending_goal_notice is not None:
|
||||||
|
# 本轮提示显式写出旧 goal → 新 goal;通知只用一次,
|
||||||
|
# 下一轮提示是重新构造的,不重复贴。
|
||||||
|
prompt = pending_goal_notice + "\n\n" + prompt
|
||||||
|
pending_goal_notice = None
|
||||||
# N-12:断点备注只贴第一轮 prompt(见 _first_turn 注释),
|
# N-12:断点备注只贴第一轮 prompt(见 _first_turn 注释),
|
||||||
# 绝不写回 goal_text——task["goal"] 在循环开始时已按原
|
# 绝不写回 goal_text——task["goal"] 在循环开始时已按原
|
||||||
# goal_text 落盘,备注只是本次续跑的展示前缀。
|
# goal_text 落盘,备注只是本次续跑的展示前缀。
|
||||||
if _first_turn and resume_note:
|
if _first_turn and resume_note:
|
||||||
prompt = "%s\n\n%s" % (resume_note, prompt)
|
prompt = "%s\n\n%s" % (resume_note, prompt)
|
||||||
_first_turn = False
|
_first_turn = False
|
||||||
# 演练注 FLAG:与上同锚点——运行中改 goal 的旧→新通知
|
|
||||||
# 与 N-12 断点备注同属 prompt 前缀;两段独立 flag,
|
|
||||||
# 并集保留(备注在前、改 goal 通知在后);若首轮两者
|
|
||||||
# 同 pending,模型先见备注后见改 goal 通知,顺序待定。
|
|
||||||
if pending_goal_notice is not None:
|
|
||||||
# 本轮提示显式写出旧 goal → 新 goal;通知只用一次,
|
|
||||||
# 下一轮提示是重新构造的,不重复贴。
|
|
||||||
prompt = pending_goal_notice + "\n\n" + prompt
|
|
||||||
pending_goal_notice = None
|
|
||||||
try:
|
try:
|
||||||
result = run_task_turn(server, state, task, prompt,
|
result = run_task_turn(server, state, task, prompt,
|
||||||
model, effort, sandbox, timeout=timeout,
|
model, effort, sandbox, timeout=timeout,
|
||||||
|
|||||||
@@ -2394,6 +2394,30 @@
|
|||||||
"title": "Feedback/uploadRequest",
|
"title": "Feedback/uploadRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/definitions/v2/RequestId"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"enum": [
|
||||||
|
"feedback/save"
|
||||||
|
],
|
||||||
|
"title": "Feedback/saveRequestMethod",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"$ref": "#/definitions/v2/FeedbackSaveParams"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"method",
|
||||||
|
"params"
|
||||||
|
],
|
||||||
|
"title": "Feedback/saveRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6781,6 +6805,13 @@
|
|||||||
],
|
],
|
||||||
"title": "ChatgptAccountType",
|
"title": "ChatgptAccountType",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workspaceName": {
|
||||||
|
"description": "Display name of the selected ChatGPT workspace, when available.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6810,6 +6841,23 @@
|
|||||||
],
|
],
|
||||||
"title": "AmazonBedrockAccount",
|
"title": "AmazonBedrockAccount",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Google Cloud Vertex AI with externally managed credentials.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"gcp"
|
||||||
|
],
|
||||||
|
"title": "GcpAccountType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "GcpAccount",
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -6871,6 +6919,103 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AccountSession": {
|
||||||
|
"properties": {
|
||||||
|
"displayName": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"imageUrl": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"selectedWorkspaceAccountId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/AccountSessionWorkspace"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"isActive",
|
||||||
|
"lastUsedAt",
|
||||||
|
"sessionId",
|
||||||
|
"workspaces"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AccountSessionWorkspace": {
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"imageUrl": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/AccountSessionWorkspaceKind"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accountId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AccountSessionWorkspaceKind": {
|
||||||
|
"enum": [
|
||||||
|
"personal",
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"AccountTokenUsageDailyBucket": {
|
"AccountTokenUsageDailyBucket": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"startDate": {
|
"startDate": {
|
||||||
@@ -6993,6 +7138,13 @@
|
|||||||
"kind": {
|
"kind": {
|
||||||
"$ref": "#/definitions/v2/AdditionalContextKind"
|
"$ref": "#/definitions/v2/AdditionalContextKind"
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Optional name for untrusted context, used to label its tool output.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -7756,6 +7908,42 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/ApplicationStorageRequirements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ApplicationStorageRequirements": {
|
||||||
|
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
|
||||||
|
"properties": {
|
||||||
|
"defaultProjectParent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"projectlessWorkspaceRoot": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -8502,6 +8690,8 @@
|
|||||||
"rateLimitExceeded",
|
"rateLimitExceeded",
|
||||||
"flexUnavailable",
|
"flexUnavailable",
|
||||||
"serverOverloaded",
|
"serverOverloaded",
|
||||||
|
"modelNotFound",
|
||||||
|
"policyCapacityUnavailable",
|
||||||
"cyberPolicy",
|
"cyberPolicy",
|
||||||
"misalignmentPolicyViolation",
|
"misalignmentPolicyViolation",
|
||||||
"tooManyDenials",
|
"tooManyDenials",
|
||||||
@@ -10216,6 +10406,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"otel": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelRequirements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"sqliteHome": {
|
"sqliteHome": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -11807,6 +12007,85 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FeedbackSaveParams": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"properties": {
|
||||||
|
"classification": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"extraLogFiles": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"includeLogs": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"savePath": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Absolute, previously unused ZIP path on the app-server host. The caller owns this file, including after a disconnected RPC."
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"classification",
|
||||||
|
"savePath"
|
||||||
|
],
|
||||||
|
"title": "FeedbackSaveParams",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FeedbackSaveResponse": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"properties": {
|
||||||
|
"promptHash": {
|
||||||
|
"description": "Same prompt hash as feedback/upload; null when prompt metadata is absent.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"savedPath": {
|
||||||
|
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"savedPath",
|
||||||
|
"threadId"
|
||||||
|
],
|
||||||
|
"title": "FeedbackSaveResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"FeedbackUploadParams": {
|
"FeedbackUploadParams": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13455,6 +13734,13 @@
|
|||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"description": "The owning execution environment, if this hook came from its requirements.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"eventName": {
|
"eventName": {
|
||||||
"$ref": "#/definitions/v2/HookEventName"
|
"$ref": "#/definitions/v2/HookEventName"
|
||||||
},
|
},
|
||||||
@@ -13622,7 +13908,7 @@
|
|||||||
"default": "unknown"
|
"default": "unknown"
|
||||||
},
|
},
|
||||||
"sourcePath": {
|
"sourcePath": {
|
||||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||||
},
|
},
|
||||||
"startedAt": {
|
"startedAt": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -13743,11 +14029,18 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
"cwds": {
|
"cwds": {
|
||||||
"description": "When empty, defaults to the current session working directory.",
|
"description": "Working directories to inspect when no thread is specified. An empty list uses the session cwd.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "HooksListParams",
|
"title": "HooksListParams",
|
||||||
@@ -15818,6 +16111,81 @@
|
|||||||
"title": "ModelProviderCapabilitiesReadResponse",
|
"title": "ModelProviderCapabilitiesReadResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ModelProviderError": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"description": "Structured JSON-RPC error data. Unknown errors must retain the client's normal fallback.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"currentModelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderMismatch"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderMismatchv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"currentModelProvider",
|
||||||
|
"modelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderMismatchv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderNotConfigured"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderNotConfiguredv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"modelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderNotConfiguredv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requiredModelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderPolicyConflict"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderPolicyConflictv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"modelProvider",
|
||||||
|
"requiredModelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderPolicyConflictv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "ModelProviderError"
|
||||||
|
},
|
||||||
"ModelRerouteReason": {
|
"ModelRerouteReason": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"highRiskCyberActivity"
|
"highRiskCyberActivity"
|
||||||
@@ -16116,7 +16484,7 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/definitions/v2/NetworkDomainPermission"
|
"$ref": "#/definitions/v2/NetworkDomainPermission"
|
||||||
},
|
},
|
||||||
"description": "Canonical network permission map for `experimental_network`.",
|
"description": "Canonical managed network permission map.",
|
||||||
"type": [
|
"type": [
|
||||||
"object",
|
"object",
|
||||||
"null"
|
"null"
|
||||||
@@ -16143,6 +16511,16 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"reviewDomains": {
|
||||||
|
"description": "Host patterns routed to network approval before ordinary allow rules.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"socksPort": {
|
"socksPort": {
|
||||||
"format": "uint16",
|
"format": "uint16",
|
||||||
"minimum": 0.0,
|
"minimum": 0.0,
|
||||||
@@ -16155,7 +16533,7 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/definitions/v2/NetworkUnixSocketPermission"
|
"$ref": "#/definitions/v2/NetworkUnixSocketPermission"
|
||||||
},
|
},
|
||||||
"description": "Canonical unix socket permission map for `experimental_network`.",
|
"description": "Canonical managed unix socket permission map.",
|
||||||
"type": [
|
"type": [
|
||||||
"object",
|
"object",
|
||||||
"null"
|
"null"
|
||||||
@@ -16229,6 +16607,232 @@
|
|||||||
],
|
],
|
||||||
"title": "Nullable_GetAccountTokenUsageParams"
|
"title": "Nullable_GetAccountTokenUsageParams"
|
||||||
},
|
},
|
||||||
|
"OtelExporter": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"none",
|
||||||
|
"statsig"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"otlp-http": {
|
||||||
|
"properties": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Header names are retained; values are redacted by the server.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"protocol": {
|
||||||
|
"$ref": "#/definitions/v2/OtelHttpProtocol"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelTlsConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"endpoint",
|
||||||
|
"headers",
|
||||||
|
"protocol"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"otlp-http"
|
||||||
|
],
|
||||||
|
"title": "OtlpHttpOtelExporter",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"otlp-grpc": {
|
||||||
|
"properties": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Header names are retained; values are redacted by the server.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelTlsConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"endpoint",
|
||||||
|
"headers"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"otlp-grpc"
|
||||||
|
],
|
||||||
|
"title": "OtlpGrpcOtelExporter",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OtelHttpProtocol": {
|
||||||
|
"enum": [
|
||||||
|
"binary",
|
||||||
|
"json"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"OtelRequirements": {
|
||||||
|
"properties": {
|
||||||
|
"environment": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_agent_responses": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_guardian_assessments": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_user_prompt": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metrics_exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"span_attributes": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tool_result": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelToolResultLogConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trace_exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/v2/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracestate": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"OtelTlsConfig": {
|
||||||
|
"properties": {
|
||||||
|
"ca-certificate": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-certificate": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-private-key": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"OtelToolResultLogConfig": {
|
||||||
|
"properties": {
|
||||||
|
"max_bytes": {
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"max_bytes"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"OverriddenMetadata": {
|
"OverriddenMetadata": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"effectiveValue": true,
|
"effectiveValue": true,
|
||||||
@@ -17824,6 +18428,13 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"outputDrainTimedOut": {
|
||||||
|
"description": "Whether output collection hit its post-exit deadline before seeing EOF or reaching the requested byte cap on either stream. Older servers omit this field.",
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"processHandle": {
|
"processHandle": {
|
||||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -19801,8 +20412,7 @@
|
|||||||
},
|
},
|
||||||
"ReviewDelivery": {
|
"ReviewDelivery": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"inline",
|
"inline"
|
||||||
"detached"
|
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -19819,7 +20429,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": null,
|
"default": null,
|
||||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`). Detached delivery is deprecated and emits `deprecationNotice`. Use `thread/start` followed by an inline review for a separate review thread."
|
"description": "Reviews run inline on the supplied thread. Omit this field or pass `inline`. To review on a separate thread, start a thread first and use its id."
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
"$ref": "#/definitions/v2/ReviewTarget"
|
"$ref": "#/definitions/v2/ReviewTarget"
|
||||||
@@ -19839,7 +20449,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
"reviewThreadId": {
|
"reviewThreadId": {
|
||||||
"description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.",
|
"description": "The thread id supplied to `review/start`.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"turn": {
|
"turn": {
|
||||||
@@ -22135,6 +22745,7 @@
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"ThreadItem": {
|
"ThreadItem": {
|
||||||
|
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -22917,6 +23528,14 @@
|
|||||||
],
|
],
|
||||||
"default": null
|
"default": null
|
||||||
},
|
},
|
||||||
|
"generationId": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Image generation identifier for the generated image, when provided by the image backend.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -23037,8 +23656,29 @@
|
|||||||
"title": "ContextCompactionThreadItem",
|
"title": "ContextCompactionThreadItem",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"completedAtMs": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"startedAtMs": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ThreadItemEntry": {
|
"ThreadItemEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"completedAtMs": {
|
"completedAtMs": {
|
||||||
@@ -25689,6 +26329,14 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"nativeUsageUsdMicros": {
|
||||||
|
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@
|
|||||||
],
|
],
|
||||||
"title": "ChatgptAccountType",
|
"title": "ChatgptAccountType",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workspaceName": {
|
||||||
|
"description": "Display name of the selected ChatGPT workspace, when available.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -69,6 +76,23 @@
|
|||||||
],
|
],
|
||||||
"title": "AmazonBedrockAccount",
|
"title": "AmazonBedrockAccount",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Google Cloud Vertex AI with externally managed credentials.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"gcp"
|
||||||
|
],
|
||||||
|
"title": "GcpAccountType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "GcpAccount",
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -130,6 +154,103 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AccountSession": {
|
||||||
|
"properties": {
|
||||||
|
"displayName": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"imageUrl": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"selectedWorkspaceAccountId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/AccountSessionWorkspace"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"isActive",
|
||||||
|
"lastUsedAt",
|
||||||
|
"sessionId",
|
||||||
|
"workspaces"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AccountSessionWorkspace": {
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"imageUrl": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/AccountSessionWorkspaceKind"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accountId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AccountSessionWorkspaceKind": {
|
||||||
|
"enum": [
|
||||||
|
"personal",
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"AccountTokenUsageDailyBucket": {
|
"AccountTokenUsageDailyBucket": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"startDate": {
|
"startDate": {
|
||||||
@@ -252,6 +373,13 @@
|
|||||||
"kind": {
|
"kind": {
|
||||||
"$ref": "#/definitions/AdditionalContextKind"
|
"$ref": "#/definitions/AdditionalContextKind"
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Optional name for untrusted context, used to label its tool output.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1143,42 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/ApplicationStorageRequirements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ApplicationStorageRequirements": {
|
||||||
|
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
|
||||||
|
"properties": {
|
||||||
|
"defaultProjectParent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"projectlessWorkspaceRoot": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -3967,6 +4131,30 @@
|
|||||||
"title": "Feedback/uploadRequest",
|
"title": "Feedback/uploadRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/definitions/RequestId"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"enum": [
|
||||||
|
"feedback/save"
|
||||||
|
],
|
||||||
|
"title": "Feedback/saveRequestMethod",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"$ref": "#/definitions/FeedbackSaveParams"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"method",
|
||||||
|
"params"
|
||||||
|
],
|
||||||
|
"title": "Feedback/saveRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4318,6 +4506,8 @@
|
|||||||
"rateLimitExceeded",
|
"rateLimitExceeded",
|
||||||
"flexUnavailable",
|
"flexUnavailable",
|
||||||
"serverOverloaded",
|
"serverOverloaded",
|
||||||
|
"modelNotFound",
|
||||||
|
"policyCapacityUnavailable",
|
||||||
"cyberPolicy",
|
"cyberPolicy",
|
||||||
"misalignmentPolicyViolation",
|
"misalignmentPolicyViolation",
|
||||||
"tooManyDenials",
|
"tooManyDenials",
|
||||||
@@ -6032,6 +6222,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"otel": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelRequirements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"sqliteHome": {
|
"sqliteHome": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -7623,6 +7823,85 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FeedbackSaveParams": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"properties": {
|
||||||
|
"classification": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"extraLogFiles": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"includeLogs": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"savePath": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/LegacyAppPathString"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Absolute, previously unused ZIP path on the app-server host. The caller owns this file, including after a disconnected RPC."
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"classification",
|
||||||
|
"savePath"
|
||||||
|
],
|
||||||
|
"title": "FeedbackSaveParams",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FeedbackSaveResponse": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"properties": {
|
||||||
|
"promptHash": {
|
||||||
|
"description": "Same prompt hash as feedback/upload; null when prompt metadata is absent.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"savedPath": {
|
||||||
|
"$ref": "#/definitions/LegacyAppPathString"
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"savedPath",
|
||||||
|
"threadId"
|
||||||
|
],
|
||||||
|
"title": "FeedbackSaveResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"FeedbackUploadParams": {
|
"FeedbackUploadParams": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9382,6 +9661,13 @@
|
|||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"description": "The owning execution environment, if this hook came from its requirements.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"eventName": {
|
"eventName": {
|
||||||
"$ref": "#/definitions/HookEventName"
|
"$ref": "#/definitions/HookEventName"
|
||||||
},
|
},
|
||||||
@@ -9549,7 +9835,7 @@
|
|||||||
"default": "unknown"
|
"default": "unknown"
|
||||||
},
|
},
|
||||||
"sourcePath": {
|
"sourcePath": {
|
||||||
"$ref": "#/definitions/AbsolutePathBuf"
|
"$ref": "#/definitions/LegacyAppPathString"
|
||||||
},
|
},
|
||||||
"startedAt": {
|
"startedAt": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -9670,11 +9956,18 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
"cwds": {
|
"cwds": {
|
||||||
"description": "When empty, defaults to the current session working directory.",
|
"description": "Working directories to inspect when no thread is specified. An empty list uses the session cwd.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
|
},
|
||||||
|
"threadId": {
|
||||||
|
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "HooksListParams",
|
"title": "HooksListParams",
|
||||||
@@ -11810,6 +12103,81 @@
|
|||||||
"title": "ModelProviderCapabilitiesReadResponse",
|
"title": "ModelProviderCapabilitiesReadResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ModelProviderError": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"description": "Structured JSON-RPC error data. Unknown errors must retain the client's normal fallback.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"currentModelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderMismatch"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderMismatchv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"currentModelProvider",
|
||||||
|
"modelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderMismatchv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderNotConfigured"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderNotConfiguredv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"modelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderNotConfiguredv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"modelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requiredModelProvider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"modelProviderPolicyConflict"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderPolicyConflictv2::ModelProviderErrorType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"modelProvider",
|
||||||
|
"requiredModelProvider",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "ModelProviderPolicyConflictv2::ModelProviderError",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "ModelProviderError"
|
||||||
|
},
|
||||||
"ModelRerouteReason": {
|
"ModelRerouteReason": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"highRiskCyberActivity"
|
"highRiskCyberActivity"
|
||||||
@@ -12108,7 +12476,7 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/definitions/NetworkDomainPermission"
|
"$ref": "#/definitions/NetworkDomainPermission"
|
||||||
},
|
},
|
||||||
"description": "Canonical network permission map for `experimental_network`.",
|
"description": "Canonical managed network permission map.",
|
||||||
"type": [
|
"type": [
|
||||||
"object",
|
"object",
|
||||||
"null"
|
"null"
|
||||||
@@ -12135,6 +12503,16 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"reviewDomains": {
|
||||||
|
"description": "Host patterns routed to network approval before ordinary allow rules.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"socksPort": {
|
"socksPort": {
|
||||||
"format": "uint16",
|
"format": "uint16",
|
||||||
"minimum": 0.0,
|
"minimum": 0.0,
|
||||||
@@ -12147,7 +12525,7 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/definitions/NetworkUnixSocketPermission"
|
"$ref": "#/definitions/NetworkUnixSocketPermission"
|
||||||
},
|
},
|
||||||
"description": "Canonical unix socket permission map for `experimental_network`.",
|
"description": "Canonical managed unix socket permission map.",
|
||||||
"type": [
|
"type": [
|
||||||
"object",
|
"object",
|
||||||
"null"
|
"null"
|
||||||
@@ -12221,6 +12599,232 @@
|
|||||||
],
|
],
|
||||||
"title": "Nullable_GetAccountTokenUsageParams"
|
"title": "Nullable_GetAccountTokenUsageParams"
|
||||||
},
|
},
|
||||||
|
"OtelExporter": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"none",
|
||||||
|
"statsig"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"otlp-http": {
|
||||||
|
"properties": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Header names are retained; values are redacted by the server.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"protocol": {
|
||||||
|
"$ref": "#/definitions/OtelHttpProtocol"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelTlsConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"endpoint",
|
||||||
|
"headers",
|
||||||
|
"protocol"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"otlp-http"
|
||||||
|
],
|
||||||
|
"title": "OtlpHttpOtelExporter",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"otlp-grpc": {
|
||||||
|
"properties": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Header names are retained; values are redacted by the server.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelTlsConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"endpoint",
|
||||||
|
"headers"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"otlp-grpc"
|
||||||
|
],
|
||||||
|
"title": "OtlpGrpcOtelExporter",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OtelHttpProtocol": {
|
||||||
|
"enum": [
|
||||||
|
"binary",
|
||||||
|
"json"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"OtelRequirements": {
|
||||||
|
"properties": {
|
||||||
|
"environment": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_agent_responses": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_guardian_assessments": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log_user_prompt": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metrics_exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"span_attributes": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tool_result": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelToolResultLogConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trace_exporter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OtelExporter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracestate": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"OtelTlsConfig": {
|
||||||
|
"properties": {
|
||||||
|
"ca-certificate": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-certificate": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-private-key": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"OtelToolResultLogConfig": {
|
||||||
|
"properties": {
|
||||||
|
"max_bytes": {
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"max_bytes"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"OverriddenMetadata": {
|
"OverriddenMetadata": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"effectiveValue": true,
|
"effectiveValue": true,
|
||||||
@@ -13816,6 +14420,13 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"outputDrainTimedOut": {
|
||||||
|
"description": "Whether output collection hit its post-exit deadline before seeing EOF or reaching the requested byte cap on either stream. Older servers omit this field.",
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"processHandle": {
|
"processHandle": {
|
||||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -15793,8 +16404,7 @@
|
|||||||
},
|
},
|
||||||
"ReviewDelivery": {
|
"ReviewDelivery": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"inline",
|
"inline"
|
||||||
"detached"
|
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -15811,7 +16421,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": null,
|
"default": null,
|
||||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`). Detached delivery is deprecated and emits `deprecationNotice`. Use `thread/start` followed by an inline review for a separate review thread."
|
"description": "Reviews run inline on the supplied thread. Omit this field or pass `inline`. To review on a separate thread, start a thread first and use its id."
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
"$ref": "#/definitions/ReviewTarget"
|
"$ref": "#/definitions/ReviewTarget"
|
||||||
@@ -15831,7 +16441,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
"reviewThreadId": {
|
"reviewThreadId": {
|
||||||
"description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.",
|
"description": "The thread id supplied to `review/start`.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"turn": {
|
"turn": {
|
||||||
@@ -19849,6 +20459,7 @@
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"ThreadItem": {
|
"ThreadItem": {
|
||||||
|
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -20631,6 +21242,14 @@
|
|||||||
],
|
],
|
||||||
"default": null
|
"default": null
|
||||||
},
|
},
|
||||||
|
"generationId": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Image generation identifier for the generated image, when provided by the image backend.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -20751,8 +21370,29 @@
|
|||||||
"title": "ContextCompactionThreadItem",
|
"title": "ContextCompactionThreadItem",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"completedAtMs": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"startedAtMs": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ThreadItemEntry": {
|
"ThreadItemEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"completedAtMs": {
|
"completedAtMs": {
|
||||||
@@ -23403,6 +24043,14 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"nativeUsageUsdMicros": {
|
||||||
|
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the Codex native UI host — the only entry point for the UI host.
|
||||||
|
#
|
||||||
|
# scripts/run-codex-ui.sh UI on :8095
|
||||||
|
# UI_PORT=9000 scripts/run-codex-ui.sh custom port
|
||||||
|
#
|
||||||
|
# Env (all optional, no secrets in this file):
|
||||||
|
# CODEX_APP_SERVER_BIN codex binary for the app-server bridge
|
||||||
|
# (default /opt/hatch-image/bin/codex)
|
||||||
|
# CODEX_UI_ORIGINAL_ROOT vendored original/ webview directory
|
||||||
|
# (default ~/workspace/user/downloads/codex-ui-stack/original;
|
||||||
|
# original/ is never committed — see docs/codex-native-ui-alignment.md §4)
|
||||||
|
# UI_PORT port to listen on (default 8095)
|
||||||
|
# BUMIAN_API_KEY real-relay key, only when relay-config.json points
|
||||||
|
# at the real relay (see host/relay-config.README.md)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
UI_PORT="${UI_PORT:-8095}"
|
||||||
|
|
||||||
|
export CODEX_APP_SERVER_BIN="${CODEX_APP_SERVER_BIN:-/opt/hatch-image/bin/codex}"
|
||||||
|
export CODEX_UI_ORIGINAL_ROOT="${CODEX_UI_ORIGINAL_ROOT:-$HOME/workspace/user/downloads/codex-ui-stack/original}"
|
||||||
|
export CODEX_WEB_PORT="$UI_PORT"
|
||||||
|
|
||||||
|
echo "[codex-ui] UI http://127.0.0.1:${UI_PORT}/"
|
||||||
|
echo "[codex-ui] codex ${CODEX_APP_SERVER_BIN}"
|
||||||
|
echo "[codex-ui] root ${CODEX_UI_ORIGINAL_ROOT}"
|
||||||
|
|
||||||
|
exec node "$REPO_ROOT/cloud/codex-ui-host/host/web-server.mjs" --port "${UI_PORT}"
|
||||||
@@ -21,7 +21,7 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
# 本机 VM 修复:系统里的 codex 是裸二进制,旁边没有
|
# 本机 VM 修复:系统里的 codex 是裸二进制,旁边没有
|
||||||
# codex-code-mode-host 和 codex-resources/bwrap,工具会 fail closed。
|
# codex-code-mode-host 和 codex-resources/bwrap,工具会 fail closed。
|
||||||
# 本项目 bin/ 里是配对好的官方 0.164.0-alpha.9 三件套
|
# 本项目 bin/ 里是配对好的官方 0.166.0 三件套
|
||||||
# (codex + codex-code-mode-host + codex-resources/bwrap),
|
# (codex + codex-code-mode-host + codex-resources/bwrap),
|
||||||
# 优先用它;Mac 上用 npm 正常安装的 codex 自带这三件套,
|
# 优先用它;Mac 上用 npm 正常安装的 codex 自带这三件套,
|
||||||
# 不需要 bin/,直接用 PATH 里的 codex 即可。
|
# 不需要 bin/,直接用 PATH 里的 codex 即可。
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ class StubServer:
|
|||||||
self.created = []
|
self.created = []
|
||||||
|
|
||||||
def create_thread(self, cwd, model=None, sandbox=None,
|
def create_thread(self, cwd, model=None, sandbox=None,
|
||||||
approval_policy=None):
|
approval_policy=None, instructions=None):
|
||||||
|
# 演练注:main 侧 dispatch 传 instructions(developer
|
||||||
|
# 指令,真实 Server.create_thread 已接受);源线 stub
|
||||||
|
# 未跟进接口致 2 项 TypeError,测试桩同步接口,生产
|
||||||
|
# 代码与断言均未动。
|
||||||
self.created.append(cwd)
|
self.created.append(cwd)
|
||||||
return "th-1"
|
return "th-1"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""host.py 方法 ↔ protocol/ schema 映射钉死测试(引擎升级 0.166.0 批)。
|
||||||
|
|
||||||
|
钉死两件事:
|
||||||
|
1. host.py 实际触及的方法集合(发出的请求 + 处理的入站通知)必须
|
||||||
|
等于下表;host.py 新增方法时本测试先红,逼升级者同步更新映射,
|
||||||
|
而不是悄悄带一个 schema 里没有的方法上线。
|
||||||
|
2. 下表每个方法在 protocol/ 两 bundle 里存在,且 params 引用与
|
||||||
|
0.166.0 重导结果一致;上游删方法或改 params 引用时本测试先红。
|
||||||
|
|
||||||
|
方法数勘误:2026-11-05 对齐表写“17 个方法/通知”并不准确——
|
||||||
|
实查 host.py 触及 21 个方法字符串:18 个发出请求(含 initialize
|
||||||
|
握手)+ 3 个入站通知;对齐表列出的 18 项漏了 initialize、
|
||||||
|
app/list、mcpServerStatus/list 三个。本测试以实查 21 项为准。
|
||||||
|
其中 17 个应用请求(不含 initialize)与对齐表原意一致,只是
|
||||||
|
对齐表把其中 app/list、mcpServerStatus/list 两项漏列了。
|
||||||
|
|
||||||
|
本测试只读 host.py 文本与 protocol/ 两 bundle,不 import host,
|
||||||
|
不启动任何 app-server 进程,不做模型调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# method -> v2 ClientRequest 的 params 引用;None 表示该请求的
|
||||||
|
# params 在 schema 里是 {"type": "null"}(config/mcpServer/reload)。
|
||||||
|
EXPECTED_REQUESTS = {
|
||||||
|
"initialize": "InitializeParams",
|
||||||
|
"thread/start": "ThreadStartParams",
|
||||||
|
"thread/resume": "ThreadResumeParams",
|
||||||
|
"thread/fork": "ThreadForkParams",
|
||||||
|
"thread/list": "ThreadListParams",
|
||||||
|
"thread/read": "ThreadReadParams",
|
||||||
|
"thread/name/set": "ThreadSetNameParams",
|
||||||
|
"thread/archive": "ThreadArchiveParams",
|
||||||
|
"thread/unarchive": "ThreadUnarchiveParams",
|
||||||
|
"thread/compact/start": "ThreadCompactStartParams",
|
||||||
|
"turn/start": "TurnStartParams",
|
||||||
|
"turn/interrupt": "TurnInterruptParams",
|
||||||
|
"review/start": "ReviewStartParams",
|
||||||
|
"skills/list": "SkillsListParams",
|
||||||
|
"app/list": "AppsListParams",
|
||||||
|
"mcpServerStatus/list": "ListMcpServerStatusParams",
|
||||||
|
"mcpServer/oauth/login": "McpServerOauthLoginParams",
|
||||||
|
"config/mcpServer/reload": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# method -> (bundle, container, params 引用)。turn/completed 与
|
||||||
|
# thread/tokenUsage/updated 是服务端发来的通知(v2 ServerNotification);
|
||||||
|
# mcpServer/elicitation/request 是服务端发来的请求,活在 v1 bundle
|
||||||
|
# 的 ServerRequest 里(v2 bundle 没有 ServerRequest 定义)。
|
||||||
|
EXPECTED_INBOUND = {
|
||||||
|
"turn/completed": ("v2", "ServerNotification", "TurnCompletedNotification"),
|
||||||
|
"thread/tokenUsage/updated": (
|
||||||
|
"v2", "ServerNotification", "ThreadTokenUsageUpdatedNotification"),
|
||||||
|
"mcpServer/elicitation/request": (
|
||||||
|
"v1", "ServerRequest", "McpServerElicitationRequestParams"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bundle(name):
|
||||||
|
path = os.path.join(ROOT, "protocol", name)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _method_map(bundle, container):
|
||||||
|
out = {}
|
||||||
|
for entry in bundle["definitions"].get(container, {}).get("oneOf", []):
|
||||||
|
props = entry.get("properties", {})
|
||||||
|
method = (props.get("method", {}).get("enum") or [None])[0]
|
||||||
|
if not method:
|
||||||
|
continue
|
||||||
|
params = props.get("params", {})
|
||||||
|
ref = params.get("$ref", "")
|
||||||
|
out[method] = ref.split("/")[-1] if ref else params
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostProtocolMapping(unittest.TestCase):
|
||||||
|
def test_host_method_set_matches_pin(self):
|
||||||
|
with open(os.path.join(ROOT, "host.py"), encoding="utf-8") as f:
|
||||||
|
src = f.read()
|
||||||
|
requests = set(re.findall(r'\.request\(\s*"([^"]+)"', src))
|
||||||
|
inbound = set(
|
||||||
|
re.findall(r'\.get\("method"\)\s*[=!]=\s*"([^"]+)"', src))
|
||||||
|
self.assertEqual(requests, set(EXPECTED_REQUESTS),
|
||||||
|
"host.py 发出的请求集合变了,先更新映射表再合")
|
||||||
|
self.assertEqual(inbound, set(EXPECTED_INBOUND),
|
||||||
|
"host.py 处理的入站通知集合变了,先更新映射表再合")
|
||||||
|
|
||||||
|
def test_requests_in_v2_schema(self):
|
||||||
|
bundle = _load_bundle("codex_app_server_protocol.v2.schemas.json")
|
||||||
|
methods = _method_map(bundle, "ClientRequest")
|
||||||
|
for method, ref in EXPECTED_REQUESTS.items():
|
||||||
|
self.assertIn(method, methods, f"v2 schema 缺少 host 请求 {method}")
|
||||||
|
if ref is None:
|
||||||
|
self.assertEqual(methods[method], {"type": "null"},
|
||||||
|
f"{method} 的 params 应为 null 类型")
|
||||||
|
else:
|
||||||
|
self.assertEqual(methods[method], ref,
|
||||||
|
f"{method} 的 params 引用变了")
|
||||||
|
|
||||||
|
def test_inbound_in_schema(self):
|
||||||
|
bundles = {
|
||||||
|
"v1": _load_bundle("codex_app_server_protocol.schemas.json"),
|
||||||
|
"v2": _load_bundle("codex_app_server_protocol.v2.schemas.json"),
|
||||||
|
}
|
||||||
|
for method, (bundle_name, container, ref) in EXPECTED_INBOUND.items():
|
||||||
|
methods = _method_map(bundles[bundle_name], container)
|
||||||
|
self.assertIn(method, methods,
|
||||||
|
f"{bundle_name} schema {container} 缺少 {method}")
|
||||||
|
self.assertEqual(methods[method], ref,
|
||||||
|
f"{method} 的 params 引用变了")
|
||||||
|
|
||||||
|
def test_bundles_parse(self):
|
||||||
|
for name in ("codex_app_server_protocol.schemas.json",
|
||||||
|
"codex_app_server_protocol.v2.schemas.json"):
|
||||||
|
bundle = _load_bundle(name)
|
||||||
|
self.assertIn("$schema", bundle)
|
||||||
|
self.assertIn("definitions", bundle)
|
||||||
|
self.assertGreater(len(bundle["definitions"]), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user