Author SHA1 Message Date
host 8b2686548c fix: serialize concurrent PG schema init with advisory lock
ci / test (push) Successful in 23m44s
Startup race: concurrent init_db (cloud-server + worker) ran
CREATE TABLE / CREATE INDEX IF NOT EXISTS against a DB where the
object did not exist yet; the check-then-create is not atomic, so
both sessions created it and one died with UniqueViolation on
pg_type_typname_nsp_index (tables) or pg_class_relname_nsp_index
(indexes). Hit on the 37ba687 and 7983567 deploys (7983567 added
idx_tasks_parent); systemd restart healed it every time.

Fix (advisory lock, not catch-duplicate_object): init_postgres_schema
takes pg_advisory_xact_lock (key 20261108, released at its commit),
and server.init_db takes the session-level lock for the whole PG
branch so the _ensure_* objects outside POSTGRES_SCHEMA
(task_continue_messages, user_gateway_keys, task_attempts,
task_audit_loop_state) are covered too. Same idiom as the existing
RATE_LIMIT_SWEEP_ADVISORY_KEY sweep lock; key is distinct.

Tests: two new real-PG regression tests (skipped without
CLOUD_TEST_PG_DSN), 4 processes racing on an empty schema, for
init_postgres_schema and for full init_db; both fail on the
unfixed code and pass with the fix.
2026-11-08 05:49:52 +00:00
host 7983567491 feat: side-thread task kind (Muse role alignment phase 2)
ci / test (push) Successful in 25m2s
Tasks gain kind='main'|'side' (NULL=main). A side thread hangs under a
parent task, defaults to the local executor, never builds a worktree,
branch or container unless explicitly asked, and writes its conclusion
back to the parent via task_followups on terminal transition.

- server: kind validation + side-thread gates (parent required, no
  coordinator_parts/upload_id/environment_id, parent must not be a
  coordinator), local executor default, GET /tasks kind filter,
  /worktree 404 and /fork /merge /pr 409 for side threads.
- worker: side-default shunt in _run_attempt (no-worktree scratch cwd,
  in-memory plan=1 when a repo is present, seed/checkpoint skipped),
  _writeback_side_thread, marker task-create kind field + inheritance,
  side threads excluded from marker spawn counts.
- host/sandbox: --no-worktree plumbing for dispatch/goal/popen
  (Local + Docker), scratch cwd work/side-threads/<task-id>.
- console (classic only): kind filter + [旁聊] rows + '开旁聊' entry,
  side-thread dispatch mode, side-thread list in detail, merge/PR/fork
  hidden for side threads; build exit 0, console.html sha df39db5a.
- tests: new test_sidethread.py (29 tests) + kind backfill tests in
  test_p2_migrations.py; full cloud suite 3900 tests OK (skipped=13).

Design: verification/codex-muse-role-alignment-design-20261108.md
Summary: verification/codex-muse-role-align-p1p2-20261108.md
Local only: not pushed, not merged, not deployed.
2026-11-07 20:51:23 +00:00
host f6df76ee8f feat: unify coordinator semantics (Muse role alignment phase 1)
Platform coordinator.py is the single source of truth; the skill
coordinator template (templates/roles/00-coordinator.md + SKILL.md
appendix 00 + body) now derives from it instead of describing a
second, model-played coordinator (U1-U13).

- server: _create_task accepts parent_task_id (404/400 checks,
  coordinator-under-coordinator rejected); follow-ups to a running
  coordinator return 409 at entry (_create_followup /
  _deliver_to_thread) with documented alternatives (U14/U15).
- tests: 8 new coordinator tests (depth limit, lineage, replay,
  follow-up 409); appendix sync/provenance/transplant suites green.
- check_appendix.py: 12 chapters match templates/roles/.
2026-11-07 19:43:03 +00:00
admin 834eced686 Merge pull request 'Docker Netfix B' (#2) from docker-netfix-b into main
ci / test (push) Successful in 31m47s
2026-11-06 23:51:04 +01:00
host 4d3ea048a5 docker: pin tenant networks to 172.16.0.0/16 via explicit --subnet
ci / test (push) Successful in 35m1s
Tenant networks were created bare (docker network create <name>),
letting Docker's default pool hand out subnets outside the iptables
8889 allowlist (172.16.0.0/12). The smoke tenant landed on
192.168.32.0/20, so containers never reached the egress proxy and
every model turn timed out at 600s twice.

Fix (B): derive a deterministic /24 inside 172.16.0.0/16 from
sha256(network name) with linear probing on IPAM overlap, max 32
probes. 172.16.0.0/16 is the one /16 in the allowlist Docker never
auto-assigns (default pool starts at 172.17.0.0/16). No persistent
state; capacity 256 tenant networks per host, explicit failure when
exhausted. Pre-existing out-of-range networks are reused as-is and
need manual removal (see README).

Tests: 7 new (candidate properties incl. wrap-around, pinned argv,
overlap probe, exhaustion, empty name).
2026-11-06 22:00:52 +00:00
admin d5642c6252 Merge pull request 'feat: wire native Codex UI as /console entry, keep classic console at /console-classic' (#1) from feat/codex-native-ui into main
ci / test (push) Successful in 37m47s
2026-11-06 21:19:18 +01:00
host 3e0ff898e0 feat: wire native Codex UI as /console entry, keep classic console at /console-classic
ci / test (push) Successful in 45m25s
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB)
- Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29
  experimental-protocol methods (150 -> 179), Linux binary candidates,
  read-only POST /__codex_git
- GET /console 302s to the UI host (:8095) when reachable, else falls back
  to the classic React console; GET /console-classic always serves it
- Alignment tables: docs/codex-native-ui-alignment.md
- Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
2026-11-06 19:03:35 +00:00
host 41f3985248 console: unify goal mode to no-time-limit only (port of 395dffb onto 37ba687)
ci / test (push) Successful in 40m37s
DispatchSection keeps a single 'goal 模式' checkbox (the former
invincible option): checking it sends goal_mode=1 plus
no_time_limit=1. TaskDetailPanel drops the '无敌版' label.
Ported verbatim from feat-console-goal-unify commit 395dffb
(base 622c51e); console.html rebuilt from cloud/console-react
with npm run build:console (tsc+vite pass) and synced verbatim
from dist/index.html (sha256 daba1941...).
2026-11-06 17:09:34 +00:00
host 37ba6876bf gap-3 test: assert resume-write behaviour, not the NOT NULL bit
ci / test (push) Successful in 55m32s
- test_column_notnull_probe_sees_gap3_outcome now branches on the
  post-ensure probe: when outcome is relaxed (PG; SQLite 3.53+)
  the resume write must land and read back NULL; on older SQLite
  the ALTER is swallowed and INSERT OR IGNORE drops the row
  (version-dependent boundary, not a migration failure).
- attempt_ledger.py comments updated: ALTER COLUMN DROP NOT NULL
  works on Postgres and SQLite 3.53.0+, swallowed before that.
- PG proof summary section 3.9/8 corrected to the same boundary.
2026-11-06 05:24:20 +00:00
host 2fcde375a4 PG proof: fix task_attempts migration on real Postgres 16.15
ci / test (push) Failing after 33m23s
- attempt_ledger.ensure: relax gap-3 outcome NOT NULL on Postgres
  (ALTER COLUMN outcome DROP NOT NULL, probe-gated); without it
  every resume write on a gap-3-migrated PG table fails with
  NotNullViolation. SQLite keeps NOT NULL (no such ALTER).
- db.PgCursor: PRAGMA table_info fake rows had the notnull flag
  inverted (NO -> 0); now matches sqlite semantics (1 = NOT NULL).
  No caller read row[3] before, so this was latent.
- ledger_onboarding: stale 'PG path unverified' notes updated to
  point at the real-PG proof.
- tests: pragma_info notnull pin (fake driver) + _column_notnull
  probe pins (fresh + gap-3 SQLite).

Proof: 28/28 checks on real Postgres 16.15 (see verification/
codex-task-attempts-pg-proof-20261106.md).
2026-11-05 21:11:56 +00:00
host 698398dafb v2 rehearsal: CI checks out the pushed ref instead of the default branch 2026-11-05 02:45:36 +00:00
host adc2a02f6c v2 rehearsal: FLAG-3 order note-before-notice on first-turn co-pending (fix effective order, pin with test) 2026-11-05 02:45:09 +00:00
host 6a70775ad9 FLAG-1 unified task_attempts schema (single canonical DDL + 3-shape migration)
User decision 2026-11-05: unified schema, rejecting the rehearsal
runtime column-union workaround and a ledger rename. One definition
(cloud.attempt_ledger.TASK_ATTEMPTS_DDL, 12 columns) shared by
cloud.attempts and cloud.db.POSTGRES_SCHEMA; ensure builds the
unified table fresh and migrates old resume, gap-3 ledger, and
rehearsal shapes in place with rows intact. aggregate counts
ledger rows only (outcome NULL = resume row, never counted).
SQLite verified by 8 new tests; Postgres real-DB upgrade path
explicitly NOT verified (see batch summary). Local commit only:
no merge, no push, no deploy.
2026-11-05 02:22:47 +00:00
host 6d009af48f rehearsal: ruff production 0 (drop unused agent_def os + server AuthStore imports) and quote 8 unquoted env keys in main-side test tearDowns (latent NameError) 2026-11-04 21:38:42 +00:00
host da027f8628 rehearsal: re-pin handler accounting 244->247 and longfunc inventory 17->18 (main-side _routing_shadow + 2 methods, zero source methods lost; same re-pin precedent as 34b9a34) 2026-11-04 21:36:18 +00:00
host 1eefe17474 rehearsal: union task_attempts schema (main old DDL + gap-3 ledger cols), relax 2 hook no-table asserts to no-rows, gateway-env fake tolerates main routing reads, extend single-writer fixture list by name (test-only inserts) 2026-11-04 21:23:25 +00:00
host f76a566aa8 rehearsal: test stub create_thread accepts instructions (main-side dispatch interface; 2 host errors fixed, no production change) 2026-11-04 21:06:53 +00:00
host ce98aaa448 opensource-align2: engine 0.164.0-alpha.9 -> 0.166.0 (schema re-derived, method map pinned)
- protocol/ bundles re-derived from official 0.166.0 binary
  (generate-json-schema); v1 83 defs, v2 665 -> 677 defs,
  0 removals; all 21 host methods present, params refs unchanged
- README pin + start.sh comment follow the verified binary
- docs/upstream-166-alignment.md: 0.166.0 alignment record
- tests/test_host_protocol_mapping.py: 21 host methods pinned
  against protocol/ bundles (corrects alignment table's 17-count:
  18 requests incl. initialize + 3 inbound notifications)
- config.example.toml: [[skills.config]] disable semantics doc
  comment only (no effective config change)
- No production binary touched; no push, merge, or deploy.
2026-11-04 21:04:07 +00:00
host 6ba822daa2 gapfill2: worker/sub90 coverage tests, G7 rollback branch, G5 caller-contract docs
H1 worker.py 92%->94% (277->210 miss): 35 real-assertion tests
covering goal-signal, tenant/env, followup, poll/wait,
process/watchdog and injected defensive branches; prod code 0 changes.
H2 sub-90 sweep: approval_timeout 100%, merge_gate_hard 100%,
worker_sweep 98% (only __main__ line 174 left); 14 tests.
H3 G7 rollback except:pass (server.py:6615-6616) covered by an
injected rollback-raising connection; original error still propagates,
status stays completed, retry succeeds; no semantics change.
H4 caller contract documented only (docstrings in _audit_scorecard
endpoint, check_baseline_stale, build_scorecard, baseline store):
current_main_head caller-supplied, any string as-is, no format checks,
missing/empty treated as not passed with no stale determination at the
endpoint, always 200, overall never rewritten; no new validation or
rejection, no response-structure change. 3 doc tests pin the wording.
H5 design notes (no decisions, no code changes) live in
verification/code-to10-gapfill2-design-notes.md, referenced by the
batch summary.
No bug found by the new tests; no existing assertion touched.
2026-11-04 21:04:06 +00:00
79 changed files with 8336 additions and 339 deletions

No files matched your search

+3 -1
View File
@@ -10,7 +10,9 @@ jobs:
run: |
rm -rf 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
run: cd src && python3 -m unittest discover -s tests -v
- name: cloud tests
+3 -2
View File
@@ -72,8 +72,9 @@ agent 循环 + 沙箱 + 工具 + MCP + skills + 线程持久化
agent 循环、沙箱、工具执行、上下文压缩、线程存储,全部是官方
codex 二进制里的,不是我复刻的简化版。
实测版本:`codex-cli 0.164.0-alpha.9`,协议定义就是从它
导出的,见 `protocol/`。
实测版本:`codex-cli 0.166.0`,协议定义就是从它
导出的,见 `protocol/`(2026-11-05 由 0.166.0 二进制重导,见
`docs/upstream-166-alignment.md`)。
## 目录
+6 -1
View File
@@ -734,7 +734,12 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
项目的任何文件;同时每个项目进独立的 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
只播种模板的 config.toml(relay 端点与 provider 名,不含
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
-1
View File
@@ -129,7 +129,6 @@ honour that today, and this track says so instead of claiming it:
"""
import json
import os
import re
from pathlib import Path
+235 -52
View File
@@ -67,10 +67,50 @@ Semantics (pinned by tests, not left to each caller):
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
``CREATE TABLE IF NOT EXISTS`` -- idempotent, safe to call on
every write, and a no-op on a database that already has the
table (including one that already has rows).
``CREATE TABLE IF NOT EXISTS`` with the unified DDL, then
migrates a pre-existing legacy table (old resume shape, gap-3
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
``init_postgres_schema`` (deliberately: that schema is covered by
the migrations test asserting ``MIGRATION_TABLES`` equals the
@@ -87,7 +127,18 @@ Persistence notes:
``test_attempt_ledger.py``). Until that manual step runs, the
endpoint treats a missing table exactly like "no ledger rows":
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:
a read-only endpoint must not mutate schema. Table creation
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
always records ``is_defect=False``: it never judges a defect,
so enabling it can never turn a transient retry into a defect.
- Schema evolution note (pre-production, this track):
``corrects_attempt_no`` was added to the DDL after audit gap 3
but BEFORE either DDL reached production (gap 3 is still an
OPEN, unmerged PR), so no production table needs migration:
fresh tables get the column from the DDL below. A database
created from gap 3's DDL alone (temporary/test databases only)
gains the column with one statement --
``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
INTEGER`` -- which is also the documented step if gap 3's DDL
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).
- Schema evolution note (unified schema, this batch):
the old resume DDL, the gap-3 ledger DDL, and the rehearsal
12-column shape are all legacy shapes of the SAME table.
``ensure_task_attempts_table`` migrates any of them to the
unified column set in place (add missing columns only, never
drop or rewrite rows); the read path uses ``SELECT *``
deliberately so rows of any shape still read, and
``aggregate_attempts`` treats a row without an outcome as a
resume row, not as a ledger attempt (see above).
This module imports no ``cloud.*`` module and opens no database
connection itself: callers pass a connection in, so the layer rules
@@ -130,49 +178,174 @@ TABLE = "task_attempts"
OUTCOMES = frozenset({"success", "failure", "interrupted"})
# One DDL serves both backends: every type and the composite key are
# valid in SQLite and in Postgres, and there is no AUTOINCREMENT or
# identity column for either backend's sequence handling to chase.
# ``corrects_attempt_no`` (NULL = a real attempt row; non-NULL = a
# correction row for that attempt_no, never counted as an attempt)
# was added by the lifecycle-hook track; it SUPERSEDES gap 3's DDL
# for any production manual step (gap 3 never merged, so no
# production table exists yet -- see the module docstring).
SQLITE_DDL = (
# THE single canonical definition of task_attempts (unified
# schema, FLAG-1 decision 2026-11-05: "unified schema", rejecting
# both the rehearsal-era runtime column-union workaround and a
# ledger rename). One DDL serves both backends: every type and
# the composite key are valid in SQLite and in Postgres, and
# there is no AUTOINCREMENT or identity column for either
# backend's sequence handling to chase.
#
# 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("
"task_id TEXT NOT NULL, "
"attempt_no INTEGER NOT NULL, "
"outcome TEXT NOT NULL, "
"outcome TEXT, "
"is_defect INTEGER NOT NULL DEFAULT 0, "
"defect_category TEXT, "
"created_at DOUBLE PRECISION, "
"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))"
)
SQLITE_DDL = TASK_ATTEMPTS_DDL
# Production Postgres: run this verbatim by hand and verify the
# table exists before expecting real rates from a Postgres-backed
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
# the module docstring: migrations coverage must not widen silently).
# This is the NEW DDL (with ``corrects_attempt_no``): if gap 3's
# older DDL was ever run first, follow it with
# ``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
# INTEGER`` and verify before expecting corrections to work.
PG_DDL = SQLITE_DDL
# For a legacy-shaped table already on Postgres, add whichever
# unified columns are missing (see UNIFIED_COLUMN_TYPES) and
# relax a NOT NULL outcome if the legacy shape declared one
# (see ensure_task_attempts_table); that Postgres upgrade path
# 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
# ``corrects_attempt_no`` column yet). Valid in both backends.
# Unified column set, in DDL order. The migration in
# ``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 = (
"ALTER TABLE task_attempts "
"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):
"""Create the ledger table if absent. Idempotent; does not
touch existing rows. The caller owns the commit (``record_attempt``
commits; a bare ensure inside a larger transaction does not)."""
conn.execute(SQLITE_DDL)
"""Create the unified table if absent; migrate a legacy
table to it if present. Idempotent; does not touch existing
rows. The caller owns the commit (``record_attempt``
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,
@@ -231,11 +404,11 @@ def record_correction(conn, task_id, attempt_no, is_defect,
correction row, a target whose outcome is ``interrupted``
(interrupted rows are never defects, so there is no defect
judgement to correct), and a defect category without a defect
flag all raise ``ValueError`` and write nothing. On a table
created from gap 3's DDL (no ``corrects_attempt_no`` column),
the INSERT raises the backend's OperationalError loudly --
run ``OLD_TABLE_ALTER`` first (see the module docstring);
a correction must never silently degrade into a plain row.
flag all raise ``ValueError`` and write nothing. A table
created from a legacy DDL is migrated by ``ensure`` first
(above), so the ``corrects_attempt_no`` column is present
before the INSERT runs; a correction must never silently
degrade into a plain row.
Returns the correction row's own ``attempt_no``.
"""
if not task_id:
@@ -284,12 +457,13 @@ def read_attempt_rows(conn):
OperationalError when the table does not exist yet; the
endpoint caller treats that as "no ledger", not as a failure.
``SELECT *`` is deliberate: a table created from gap 3's DDL
has no ``corrects_attempt_no`` column, and naming that column
here would turn those readable rows into an endpoint error.
``SELECT *`` is deliberate: a legacy-shaped table may lack
columns the unified DDL has, and naming a missing column
here would turn readable rows into an endpoint error.
With ``*`` the rows still read, and ``aggregate_attempts``
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(
"SELECT * FROM task_attempts "
@@ -323,10 +497,14 @@ def aggregate_attempts(rows):
Interrupted rows are excluded from both counts, and a task
with only interrupted rows is omitted entirely (no counted
attempt => its rates stay unknown, never 0). 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
attempt => its rates stay unknown, never 0). Resume rows
(``outcome`` NULL -- written by ``cloud.attempts``, which
records an interrupted attempt's history, not a ledger
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
judgement AFTER corrections is a defect, and its
``defect_category`` is the category of its FIRST defect
@@ -351,7 +529,12 @@ def aggregate_attempts(rows):
bool(_field(row, "is_defect")),
_field(row, "defect_category")]
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
entries = judged.setdefault(task_id, {})
entries[_field(row, "attempt_no")] = [
+18 -14
View File
@@ -31,7 +31,13 @@ host.py、不改 codex 线程恢复:
result_text / error_text 各截断 2000 字。记账与回 queued
在同一事务里,记账失败不单独炸恢复循环(外层已兜底),
但同一 (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
在内存副本的 prompt 前贴一段上次尝试说明(原 prompt
不落库,与项目/待办/记忆注入同款只改内存副本),内容
@@ -63,25 +69,23 @@ host.py、不改 codex 线程恢复:
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
PREAMBLE_TEXT_CHARS = 1000
CREATE_TABLE_SQL = (
"CREATE TABLE IF NOT EXISTS 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))")
# 统一 schema:与台账共用同一定义,绝不在此另写一份 DDL
# (历史教训:两份 DDL 同名异构,CREATE TABLE IF NOT EXISTS
# 使后建的一方成空操作、写行报缺列)。别名保留,供旧调用
# 方按名取定义;建表与老表迁移一律走台账的 ensure。
CREATE_TABLE_SQL = TASK_ATTEMPTS_DDL
def ensure_attempts_table(conn):
"""幂等建尝试账本表(server/worker 两支共用同一 DDL)。"""
conn.execute(CREATE_TABLE_SQL)
"""幂等建尝试账本表(server/worker 两支共用同一 DDL):
新库建统一表,老库迁移到统一列集(见 attempt_ledger)。"""
_ensure_unified(conn)
def _trunc(text, limit):
+15 -1
View File
@@ -31,6 +31,9 @@
build_scorecard(..., baseline_head=None) 的 baseline_head 是机器字段,
只能由调用方显式传入当前 main 头;本模块绝不在生产代码里探测
版本控制状态、绝不读版本控制目录、绝不编造一个头。
接受任意字符串、原样记录,不做任何格式校验(不要求形如
commit hash);本模块不验证该值是否真实、可信,可信 main 头
的来源仍是调用方责任。
不传入(或传入空串)时 baseline_head 为 None,并在 missing_items
里记 baseline_head_missing,不把缺失读成“与当前一致”。
check_baseline_stale(scorecard, current_main_head) 是唯一的漂移判定:
@@ -916,6 +919,16 @@ def check_baseline_stale(scorecard, current_main_head):
参数全部由调用方传入:scorecard 是 build_scorecard 的输出 dict,
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 判断,绝不能
只看旧卡的 overall / overall_pass):
@@ -1068,7 +1081,8 @@ def build_scorecard(evidence, thresholds=None, rubric_version=None,
(user_side_items 可选占位),product=build_product_scorecard
(无真实产品轮证据时恒为 not_pass)。新增键不改变旧字段的值。
另新增 rubric_version(见 cloud/scorecard_rubric.py)、
baseline_head(机器字段,见 check_baseline_stale)、
baseline_head(机器字段,见 check_baseline_stale;接受任意
字符串原样记录、不做格式校验,空串按未传入处理)、
evidence_ledger_head 与 evidence_row_ids(见
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"
]
+109
View File
@@ -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();
+417
View File
@@ -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");
})();
+223
View File
@@ -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({});
};
+62
View File
@@ -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"
}
+323
View File
@@ -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}`);
+718
View File
@@ -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, "&quot;")}" />`,
);
// 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);
});
+20
View File
@@ -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"
+160
View File
@@ -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);
+7 -2
View File
@@ -29,14 +29,19 @@ export const paths = {
environmentsCreate: "/environments",
environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive",
tasks: (pid: string) => "/tasks?project_id=" + enc(pid),
tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string) => {
tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string, kind?: string) => {
let u = "/tasks?project_id=" + enc(pid) + "&limit=" + limit + "&offset=" + offset + "&order=desc";
if (status) u += "&status=" + enc(status);
if (executor) u += "&executor=" + enc(executor);
if (q && q.trim()) u += "&q=" + enc(q.trim());
if (kind) u += "&kind=" + enc(kind);
return u;
},
tasksByParent: (pid: string, parent: string, kind?: string) => {
let u = "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent);
if (kind) u += "&kind=" + enc(kind);
return u;
},
tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent),
tasksCreate: "/tasks",
task: (id: string) => "/tasks/" + enc(id),
taskAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/" + kind,
+5
View File
@@ -19,6 +19,9 @@ export interface Task {
turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }> | null; parent_task_id?: string;
coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string;
// 旁聊(Muse 角色对齐 P2):服务端 _task_to_dict 回 kind,
// 缺列/NULL 的老行按 "main" 计。
kind?: "main" | "side";
no_time_limit?: number | boolean;
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
child_model?: string; child_effort?: string; approval?: string;
@@ -71,6 +74,8 @@ export interface DispatchBody {
model?: string; effort?: string; child_model?: string; child_effort?: string;
approval?: string; plan?: number; goal_mode?: number; no_time_limit?: number; upload_id?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }>;
// 旁聊(Muse 角色对齐 P2):kind="side" 必须配 parent_task_id。
kind?: "side"; parent_task_id?: string;
}
export interface ContinueBody {
message: string; model?: string; effort?: string; child_model?: string;
+3
View File
@@ -21,6 +21,7 @@ export interface ConsoleCtx {
models: string[]; modelCustomVisible: boolean;
tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void;
statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void;
kindFilter: string; setKindFilter: (v: string) => void;
filteredTasks: Task[];
taskPage: number; taskTotalPages: number; taskTotal: number; taskStatusCounts: Record<string, number>; goToTaskPage: (p: number) => void;
currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string;
@@ -28,6 +29,8 @@ export interface ConsoleCtx {
forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise<void>; forkMsg: string;
// Continue input is ContinuePanel (ChatInput); its send path is sendChatMessage below.
coordChildren: Task[]; coordText: string; agents: Agent[]; agentsNote: string;
// 旁聊(Muse 角色对齐 P2):当前任务的旁聊列表 + 派发器旁聊模式的父任务。
sideThreads: Task[]; sideParentId: string | null; setSideParentId: (v: string | null) => void;
reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise<void>; submitFindings: (id: string) => void;
traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string;
sendChatMessage: (text: string) => Promise<void>; chatMsg: string;
@@ -23,6 +23,10 @@ export function useConsoleStore(): ConsoleCtx {
const onTasksReload = useCallback(() => { void taskList.refreshTasks(undefined, projectIdRef.current); }, [taskList]);
const detail = useTaskDetail(getRole, onTasksReload);
const [refreshStamp, setRefreshStamp] = useState("--:--:--");
// 旁聊(Muse 角色对齐 P2):派发器的旁聊父任务。列表“开旁聊”
// 点选主聊后填这里,派发器读到即进旁聊模式;提交成功或点
// “取消旁聊模式”后清空。放胶水层,不进 App.tsx。
const [sideParentId, setSideParentId] = useState<string | null>(null);
const selectProject = useCallback((id: string) => {
catalog.setProjectId(id);
@@ -126,6 +130,7 @@ export function useConsoleStore(): ConsoleCtx {
refreshTasks: () => void taskList.refreshTasks(undefined, projectIdRef.current),
statusFilter: taskList.statusFilter, setStatusFilter: taskList.setStatusFilter,
executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter,
kindFilter: taskList.kindFilter, setKindFilter: taskList.setKindFilter,
searchText: taskList.searchText, setSearchText: taskList.setSearchText,
filteredTasks: taskList.filteredTasks,
taskPage: taskList.page, taskTotalPages: taskList.totalPages, taskTotal: taskList.taskTotal,
@@ -135,6 +140,7 @@ export function useConsoleStore(): ConsoleCtx {
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary,
forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg,
coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote,
sideThreads: detail.sideThreads, sideParentId, setSideParentId,
reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError,
refreshReviews: detail.refreshReviews, postReview: detail.postReview, submitFindings: detail.submitFindings,
traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone,
+15 -3
View File
@@ -19,6 +19,9 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
const [forkVisible, setForkVisible] = useState(false);
const [forkMsg, setForkMsg] = useState("");
const [coordChildren, setCoordChildren] = useState<Task[]>([]);
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程(kind=side
// 且 parent_task_id=本任务),与协调员子任务同款拉取方式。
const [sideThreads, setSideThreads] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsNote, setAgentsNote] = useState("");
const [reviews, setReviews] = useState<Review[]>([]);
@@ -71,6 +74,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
} catch { setCoordChildren([]); }
}, []);
const loadSideThreads = useCallback(async (task: Task) => {
try {
const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id, "side"));
setSideThreads(d.tasks || []);
} catch { setSideThreads([]); }
}, []);
const loadCost = useCallback(async (taskId: string) => {
try {
const c = await api<TaskCost>(paths.taskCost(taskId));
@@ -248,6 +258,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void startTrace(taskId, t.status);
void loadAgents(taskId);
void loadCoordChildren(t);
void loadSideThreads(t);
void loadChatHistory(taskId);
void loadFollowups(taskId);
void loadPeerMessages(taskId);
@@ -259,7 +270,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadSideThreads, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
@@ -277,12 +288,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void loadReviews(t.id);
void loadApprovals(t.id);
void loadCoordChildren(t);
void loadSideThreads(t);
void loadAgents(t.id);
void loadPeerMessages(t.id);
void loadThread(t.id);
void loadThreads(t.id);
void loadCost(t.id);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadSideThreads, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
@@ -423,7 +435,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
return {
currentTask, detailBody, actionMsg, actionReasons, costBox, costSummary, forkVisible, setForkVisible, forkMsg,
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
coordChildren, sideThreads, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory,
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
+7 -4
View File
@@ -21,13 +21,14 @@ export function useTaskList() {
const [taskError, setTaskError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [executorFilter, setExecutorFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [page, setPageState] = useState(0);
const [taskTotal, setTaskTotal] = useState(0);
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<string, number>>({});
const pageRef = useRef(0);
const filterRef = useRef({ status: "", executor: "", q: "" });
filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText };
const filterRef = useRef({ status: "", executor: "", q: "", kind: "" });
filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText, kind: kindFilter };
const lastProjectRef = useRef("");
const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => {
@@ -38,7 +39,7 @@ export function useTaskList() {
const f = filterRef.current;
try {
const d = await api<{ tasks: Task[]; total?: number; status_counts?: Record<string, number> }>(
paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q));
paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q, f.kind));
setTasks(d.tasks || []);
setTaskTotal(d.total ?? (d.tasks || []).length);
setTaskStatusCounts(d.status_counts || {});
@@ -66,13 +67,14 @@ export function useTaskList() {
const t = setTimeout(() => { void refreshTasks(lastProjectRef.current, undefined, 0); }, 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, executorFilter, searchText]);
}, [statusFilter, executorFilter, searchText, kindFilter]);
// Filter/project changes reset to page 1: the old page may not
// exist under the new filter, and staying on it would show an
// empty page with a misleading "共 N 个" count.
const setStatusFilterPage = useCallback((v: string) => { setStatusFilter(v); setPage(0); }, [setPage]);
const setExecutorFilterPage = useCallback((v: string) => { setExecutorFilter(v); setPage(0); }, [setPage]);
const setKindFilterPage = useCallback((v: string) => { setKindFilter(v); setPage(0); }, [setPage]);
const setSearchTextPage = useCallback((v: string) => { setSearchText(v); setPage(0); }, [setPage]);
const refreshTasksFirstPage = useCallback(async (pid?: string) => {
setPage(0);
@@ -99,6 +101,7 @@ export function useTaskList() {
return {
tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage,
executorFilter, setExecutorFilter: setExecutorFilterPage,
kindFilter, setKindFilter: setKindFilterPage,
searchText, setSearchText: setSearchTextPage,
filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage,
page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch,
@@ -1,17 +1,22 @@
import { useRef, useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client";
import { gatherAttachments, shortId } from "../api/client";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
const ctx = useConsole();
// 旁聊模式(Muse 角色对齐 P2):列表“开旁聊”点选主聊后,
// sideParentId 即父任务 id;此时隐藏环境/协调员/附件,
// 执行器默认不指定(服务端旁聊默认 local),提交带
// kind="side" + parent_task_id。
const isSide = !!ctx.sideParentId;
const [prompt, setPrompt] = useState("");
const [coordParts, setCoordParts] = useState("");
const [goalMode, setGoalMode] = useState(false);
const [goalInvincible, setGoalInvincible] = useState(false);
const [planMode, setPlanMode] = useState(false);
const [executor, setExecutor] = useState("docker");
const [sideExecutor, setSideExecutor] = useState("");
const [model, setModel] = useState("");
const [modelCustom, setModelCustom] = useState("");
const [effort, setEffort] = useState("");
@@ -29,8 +34,15 @@ export default function DispatchSection() {
if (!ctx.projectId) { setMsg("先选项目"); return; }
if (!prompt.trim()) { setMsg("prompt 必填"); return; }
const body: any = { project_id: ctx.projectId, prompt: prompt.trim() };
if (ctx.envSelect) body.environment_id = ctx.envSelect;
if (executor) body.executor = executor;
if (isSide) {
body.kind = "side";
body.parent_task_id = ctx.sideParentId;
// 旁聊默认不指定执行器(服务端填 local);显式选了才送。
if (sideExecutor) body.executor = sideExecutor;
} else {
if (ctx.envSelect) body.environment_id = ctx.envSelect;
if (executor) body.executor = executor;
}
const m = modelCustom.trim() || model;
if (m) body.model = m;
if (effort) body.effort = effort;
@@ -45,24 +57,28 @@ export default function DispatchSection() {
}
if (planMode) {
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
if (goalMode || goalInvincible) { setMsg("计划模式不能与 goal 模式同用"); return; }
if (goalMode) { setMsg("计划模式不能与 goal 模式同用"); return; }
body.plan = 1;
}
// 无敌版 = goal 模式 + 任务不限时:勾了它就同时发 goal_mode 与
// no_time_limit(服务端已支持该字段);与普通 goal 勾选不冲突,
// 无敌版优先,单独勾普通 goal 时行为不变。
if (goalInvincible) { body.goal_mode = 1; body.no_time_limit = 1; }
else if (goalMode) body.goal_mode = 1;
// 唯一 goal 模式 = 原无敌版:勾选即同时发 goal_mode 与
// no_time_limit(任务不限时);普通 goal 入口已取消,不再
// 单独发不带 no_time_limit 的 goal_mode。
if (goalMode) { body.goal_mode = 1; body.no_time_limit = 1; }
const coordRaw = coordParts.trim();
if (coordRaw) {
if (coordRaw && !isSide) {
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); 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 = isSide ? [] : gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
try {
const result = await ctx.dispatch(body, atts);
setMsg(result);
if (isSide) {
setMsg("旁聊已创建(无 worktree)");
ctx.setSideParentId(null);
} else {
setMsg(result);
}
setPrompt(""); setCoordParts("");
if (filesRef.current) filesRef.current.value = "";
if (dirRef.current) dirRef.current.value = "";
@@ -72,21 +88,35 @@ export default function DispatchSection() {
return (
<Card id="nav-newtask" className="composer" title="新建任务">
{isSide ? (
<div className="muted">旁聊模式:挂在父任务 {shortId(ctx.sideParentId)} 下,不建 worktree/分支/容器;终态后结论回写父级。
{" "}<button type="button" onClick={() => ctx.setSideParentId(null)}>取消旁聊模式</button>
</div>
) : null}
<Bar>
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
<option value="">环境:不指定(走项目仓库)</option>
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
</select>
{isSide ? null : (
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
<option value="">环境:不指定(走项目仓库)</option>
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
)}
{isSide ? (
<select aria-label="执行器" value={sideExecutor} onChange={(e) => setSideExecutor(e.target.value)}>
<option value="">执行器:不指定(默认 local)</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
</select>
) : (
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
</select>
)}
<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>
</Bar>
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
{isSide ? null : (<>
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
</>)}
<Bar>
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
<option value="">模型:默认(跟随配置)</option>
@@ -113,14 +143,15 @@ export default function DispatchSection() {
</select>
</Bar>
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
</Bar>
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止;任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
{isSide ? null : (
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
</Bar>
)}
<Bar>
<button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span>
@@ -7,7 +7,7 @@ import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import DetailActions from "./detail/DetailActions";
import BrowserPanel from "./detail/BrowserPanel";
import ReviewPanel from "./detail/ReviewPanel";
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
import { CoordPanel, SideThreadPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
// Task inspector (redesign 2026-10-30): the old detail stack —
// 概览 + 基本信息 + prompt + 操作 + 协调 + 子代理 + 评审 +
@@ -60,7 +60,7 @@ export default function TaskDetailPanel() {
const fc = firstChar(t);
const cost = ctx.costSummary;
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;
return (
<div className="detail-stack" id="nav-detail">
@@ -134,9 +134,11 @@ export default function TaskDetailPanel() {
<div><dt>权限</dt><dd>{t.approval || "默认"}</dd></div>
<div><dt>项目</dt><dd>{shortId(t.project_id)}</dd></div>
{modeText ? <div><dt>模式</dt><dd>{modeText}</dd></div> : null}
{t.kind === "side" ? <div><dt>类型</dt><dd>旁聊(无 worktree/分支/容器)</dd></div> : null}
{t.forked_from ? <div><dt>分支自</dt><dd>{shortId(t.forked_from)}</dd></div> : null}
{t.coordinator_parts ? <div><dt>协调员</dt><dd>{t.coordinator_parts.length} 个子任务({t.coordinator_parts.map((p) => p.name).join("、")})</dd></div> : null}
{t.parent_task_id ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
{t.parent_task_id && t.kind === "side" ? <div><dt>父会话</dt><dd>父任务 {shortId(t.parent_task_id)}{" "}<button type="button" onClick={() => ctx.showDetail(t.parent_task_id as string)}>跳转到父任务</button></dd></div> : null}
{t.parent_task_id && t.kind !== "side" ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
{(t.attachments && t.attachments.length) ? <div><dt>附件</dt><dd>{t.attachments.join("、")}</dd></div> : null}
{t.pr_url ? <div><dt>PR</dt><dd><a href={t.pr_url} target="_blank" rel="noopener noreferrer">PR #{t.pr_number}</a></dd></div> : null}
</dl>
@@ -151,6 +153,7 @@ export default function TaskDetailPanel() {
</Card>
</>) : tab === "collab" ? (<>
<Card title="协调子任务"><CoordPanel /></Card>
<Card title="旁聊"><SideThreadPanel /></Card>
<Card title="子代理链路"><AgentsPanel /></Card>
<Card title="给其他任务发消息"><PeerPanel /></Card>
</>) : tab === "review" ? (<>
@@ -27,7 +27,17 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
</div>
<div className="list-section-head">
<span>主要聊天</span>
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
<span>
<button
type="button"
aria-label="开旁聊"
title={ctx.currentTask && ctx.currentTask.kind !== "side" && !ctx.currentTask.coordinator_parts ? "在选中主聊下开一个旁聊(不建 worktree)" : "先选中一个主聊任务,才能在它下面开旁聊"}
disabled={!ctx.currentTask || ctx.currentTask.kind === "side" || !!ctx.currentTask.coordinator_parts}
onClick={() => { if (ctx.currentTask) { ctx.setSideParentId(ctx.currentTask.id); onNew(); } }}
>开旁聊</button>
{" "}
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
</span>
</div>
<div className="bar">
<select aria-label="状态筛选" value={ctx.statusFilter} onChange={(e) => ctx.setStatusFilter(e.target.value)}>
@@ -37,6 +47,9 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
</select>
<select aria-label="类型筛选" value={ctx.kindFilter} onChange={(e) => ctx.setKindFilter(e.target.value)}>
<option value="">类型:全部</option><option value="main">主聊</option><option value="side">旁聊</option>
</select>
</div>
<div className="bar">
<button onClick={ctx.refreshTasks}>刷新</button>
@@ -62,7 +75,7 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
<span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<div className="issue-title">
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? (t.kind === "side" ? "[旁聊] " : "[协调子任务] ") : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
</div>
<div className="issue-meta">
{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}{t.updated_at ? " · " + new Date(t.updated_at * 1000).toLocaleString() : ""}
@@ -12,18 +12,23 @@ export default function DetailActions() {
if (!t) return null;
const terminal = TRACE_TERMINAL.includes(t.status);
const retryOk = ["failed", "interrupted", "merge-failed"].includes(t.status);
// 旁聊(Muse 角色对齐 P2):无 worktree/分支,后端 /fork /merge
// /pr 一律 409,这里只留重试/取消/查成本,不渲染那三处入口。
const isSide = t.kind === "side";
return (
<>
<Bar>
<button disabled={!retryOk} title={retryOk ? "" : "仅 failed/interrupted/merge-failed 可重试"} onClick={() => ctx.doAction("retry")}>重试</button>
<button className="danger" disabled={terminal} title={terminal ? "终态任务不可取消" : ""} onClick={() => ctx.doAction("cancel")}>取消</button>
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
{terminal ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
{isSide ? null : (<>
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
</>)}
{terminal && !isSide ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
<button onClick={ctx.taskCost}>查成本</button>
<span className="muted">{ctx.actionMsg} {ctx.actionReasons}</span>
</Bar>
{ctx.forkVisible ? (
{ctx.forkVisible && !isSide ? (
<div>
<input type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style={{ width: "100%", boxSizing: "border-box", margin: "6px 0" }} value={forkMsg} onChange={(e) => setForkMsg(e.target.value)} />
<Bar><button className="seal" onClick={async () => { await ctx.forkTask(forkMsg); setForkMsg(""); }}>创建分支任务</button><span className="muted">{ctx.forkMsg}</span></Bar>
@@ -19,6 +19,23 @@ export function CoordPanel() {
);
}
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程,与协调员
// 子任务列表同款组件复用;点行切到该旁聊的详情。
export function SideThreadPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示旁聊</div>;
if (ctx.sideThreads.length === 0) return <div className="muted">(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)</div>;
return (
<DataListRaw>
{ctx.sideThreads.map((k) => (
<li key={k.id} onClick={() => ctx.showDetail(k.id)} style={{ cursor: "pointer" }}>
<span className="row-main">旁聊 | 状态 <span className={"st-" + k.status}>{k.status}</span> | {shortId(k.id)} | {String(k.prompt || "").slice(0, 60)}</span>
</li>
))}
</DataListRaw>
);
}
export function AgentsPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示子代理链路</div>;
+30 -30
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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 A of y)if(A.type==="childList")for(const M of A.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const A={};return y.integrity&&(A.integrity=y.integrity),y.referrerPolicy&&(A.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?A.credentials="include":y.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function f(y){if(y.ep)return;y.ep=!0;const A=h(y);fetch(y.href,A)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var co={exports:{}},ci={};/**
* @license React
* react-jsx-runtime.production.js
*
@@ -12,7 +12,7 @@
*
* This source code is licensed under the MIT license found in the
* 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 ci;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,A){var M=null;if(A!==void 0&&(M=""+A),y.key!==void 0&&(M=""+y.key),"key"in y){A={};for(var p in y)p!=="key"&&(A[p]=y[p])}else A=y;return y=A.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:A}}return ci.Fragment=r,ci.jsx=h,ci.jsxs=h,ci}var kh;function By(){return kh||(kh=1,co.exports=Hy()),co.exports}var c=By(),so={exports:{}},gt={};/**
* @license React
* react.production.js
*
@@ -20,7 +20,7 @@
*
* This source code is licensed under the MIT license found in the
* 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 Uh;function qy(){if(Uh)return gt;Uh=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"),A=Symbol.for("react.consumer"),M=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Q=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),Z=Symbol.iterator;function W(g){return g===null||typeof g!="object"?null:(g=Z&&g[Z]||g["@@iterator"],typeof g=="function"?g:null)}var ot={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},rt=Object.assign,bt={};function it(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}it.prototype.isReactComponent={},it.prototype.setState=function(g,w){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,w,"setState")},it.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function wt(){}wt.prototype=it.prototype;function L(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}var ut=L.prototype=new wt;ut.constructor=L,rt(ut,it.prototype),ut.isPureReactComponent=!0;var O=Array.isArray;function V(){}var lt={H:null,A:null,T:null,S:null},St=Object.prototype.hasOwnProperty;function Nt(g,w,et){var nt=et.ref;return{$$typeof:i,type:g,key:w,ref:nt!==void 0?nt:null,props:et}}function mt(g,w){return Nt(g.type,w,g.props)}function Tt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function q(g){var w={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(et){return w[et]})}var ht=/\/+/g;function _t(g,w){return typeof g=="object"&&g!==null&&g.key!=null?q(""+g.key):w.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(V,V):(g.status="pending",g.then(function(w){g.status==="pending"&&(g.status="fulfilled",g.value=w)},function(w){g.status==="pending"&&(g.status="rejected",g.reason=w)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function Y(g,w,et,nt,Mt){var Ct=typeof g;(Ct==="undefined"||Ct==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Ct){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Q:return kt=g._init,Y(kt(g._payload),w,et,nt,Mt)}}if(kt)return Mt=Mt(g),kt=nt===""?"."+_t(g,0):nt,O(Mt)?(et="",kt!=null&&(et=kt.replace(ht,"$&/")+"/"),Y(Mt,w,et,"",function(Ne){return Ne})):Mt!=null&&(Tt(Mt)&&(Mt=mt(Mt,et+(Mt.key==null||g&&g.key===Mt.key?"":(""+Mt.key).replace(ht,"$&/")+"/")+kt)),w.push(Mt)),1;kt=0;var P=nt===""?".":nt+":";if(O(g))for(var ft=0;ft<g.length;ft++)nt=g[ft],Ct=P+_t(nt,ft),kt+=Y(nt,w,et,Ct,Mt);else if(ft=W(g),typeof ft=="function")for(g=ft.call(g),ft=0;!(nt=g.next()).done;)nt=nt.value,Ct=P+_t(nt,ft++),kt+=Y(nt,w,et,Ct,Mt);else if(Ct==="object"){if(typeof g.then=="function")return Y(G(g),w,et,nt,Mt);throw w=String(g),Error("Objects are not valid as a React child (found: "+(w==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":w)+"). If you meant to render a collection of children, use an array instead.")}return kt}function z(g,w,et){if(g==null)return g;var nt=[],Mt=0;return Y(g,nt,"","",function(Ct){return w.call(et,Ct,Mt++)}),nt}function vt(g){if(g._status===-1){var w=g._result,et=w();et.then(function(nt){(g._status===0||g._status===-1)&&(g._status=1,g._result=nt,et.status===void 0&&(et.status="fulfilled",et.value=nt))},function(nt){(g._status===0||g._status===-1)&&(g._status=2,g._result=nt,et.status===void 0&&(et.status="rejected",et.reason=nt))}),g._status===-1&&(g._status=0,g._result=et)}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 w=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(w))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function ee(g){var w=lt.T,et={};et.types=w!==null?w.types:null,lt.T=et;try{var nt=g(),Mt=lt.S;Mt!==null&&Mt(et,nt),typeof nt=="object"&&nt!==null&&typeof nt.then=="function"&&nt.then(V,pt)}catch(Ct){pt(Ct)}finally{w!==null&&et.types!==null&&(w.types=et.types),lt.T=w}}function fe(g){var w=lt.T;if(w!==null){var et=w.types;et===null?w.types=[g]:et.indexOf(g)===-1&&et.push(g)}else ee(fe.bind(null,g))}var ne={map:z,forEach:function(g,w,et){z(g,function(){w.apply(this,arguments)},et)},count:function(g){var w=0;return z(g,function(){w++}),w},toALine truncated
* @license React
* scheduler.production.js
*
@@ -28,7 +28,7 @@
*
* This source code is licensed under the MIT license found in the
* 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,Y){var z=G.length;G.push(Y);t:for(;0<z;){var vt=z-1>>>1,pt=G[vt];if(0<y(pt,Y))G[vt]=Y,G[z]=pt,z=vt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],z=G.pop();if(z!==Y){G[0]=z;t:for(var vt=0,pt=G.length,ee=pt>>>1;vt<ee;){var fe=2*(vt+1)-1,ne=G[fe],g=fe+1,w=G[g];if(0>y(ne,z))g<pt&&0>y(w,ne)?(G[vt]=w,G[g]=z,vt=g):(G[vt]=ne,G[fe]=z,vt=fe);else if(g<pt&&0>y(w,z))G[vt]=w,G[g]=z,vt=g;else break t}}return Y}function y(G,Y){var z=G.sortIndex-Y.sortIndex;return z!==0?z:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var A=performance;i.unstable_now=function(){return A.now()}}else{var M=Date,p=M.now();i.unstable_now=function(){return M.now()-p}}var _=[],R=[],Q=1,b=null,H=3,Z=!1,W=!1,ot=!1,rt=!1,bt=typeof setTimeout=="function"?setTimeout:null,it=typeof clearTimeout=="function"?clearTimeout:null,wt=typeof setImmediate<"u"?setImmediate:null;function L(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(_,Y);else break;Y=h(R)}}function ut(G){if(ot=!1,L(G),!W)if(h(_)!==null)W=!0,O||(O=!0,Tt());else{var Y=h(R);Y!==null&&_t(ut,Y.startTime-G)}}var O=!1,V=-1,lt=5,St=-1;function Nt(){return rt?!0:!(i.unstable_now()-St<lt)}function mt(){if(rt=!1,O){var G=i.unstable_now();St=G;var Y=!0;try{t:{W=!1,ot&&(ot=!1,it(V),V=-1),Z=!0;var z=H;try{e:{for(L(G),b=h(_);b!==null&&!(b.expirationTime>G&&Nt());){var vt=b.callback;if(typeof vt=="function"){b.callback=null,H=b.priorityLevel;var pt=vt(b.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){b.callback=pt,L(G),Y=!0;break e}b===h(_)&&f(_),L(G)}else f(_);b=h(_)}if(b!==null)Y=!0;else{var ee=h(R);ee!==null&&_t(ut,ee.startTime-G),Y=!1}}break t}finally{b=null,H=z,Z=!1}Y=void 0}}finally{Y?Tt():O=!1}}}var Tt;if(typeof wt=="function")Tt=function(){wt(mt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ht=q.port2;q.port1.onmessage=mt,Tt=function(){ht.postMessage(null)}}else Tt=function(){bt(mt,0)};function _t(G,Y){V=bt(function(){G(i.unstable_now())},Y)}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 H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var Y=3;break;default:Y=H}var z=H;H=Y;try{return G()}finally{H=z}},i.unstable_requestPaint=function(){rt=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var z=H;H=G;try{return Y()}finally{H=z}},i.unstable_scheduleCallback=function(G,Y,z){var vt=i.unstable_now();switch(typeof z=="object"&&z!==null?(z=z.delay,z=typeof z=="number"&&0<z?vt+z:vt):z=vt,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=z+pt,G={id:Q++,callback:Y,priorityLevel:G,startTime:z,expirationTime:pt,sortIndex:-1},z>vt?(G.sortIndex=z,r(R,G),h(_)===null&&G===h(R)&&(ot?(it(V),V=-1):ot=!0,_t(ut,z-vt))):(G.sortIndex=pt,r(_,G),W||Z||(W=!0,O||(O=!0,Tt()))),G},i.unstable_shouldYield=Nt,i.unstable_wrapCallback=function(G){var Y=H;return function(){var z=H;H=Y;try{return G.apply(this,arguments)}finally{H=z}}}})(fo)),fo}var Bh;function Qy(){return Bh||(Bh=1,oo.exports=Gy()),oo.exports}var mo={exports:{}},ke={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* 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 ke;qh=1;var i=po();function r(Q){var b="https://react.dev/errors/"+Q;if(1<arguments.length){b+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)b+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Q+"; visit "+b+" 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"),A=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function p(Q,b,H){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:Z==null?null:Z===M?M:""+Z,children:Q,containerInfo:b,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Q,b){if(Q==="font")return"";if(typeof b=="string")return b==="use-credentials"?b:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(Q){return{$$typeof:A,_reason:Q}},ke.createPortal=function(Q,b){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!b||b.nodeType!==1&&b.nodeType!==9&&b.nodeType!==11)throw Error(r(299));return p(Q,b,null,H)},ke.flushSync=function(Q){var b=_.T,H=f.p;try{if(_.T=null,f.p=2,Q)return Q()}finally{_.T=b,f.p=H,f.d.f()}},ke.preconnect=function(Q,b){typeof Q=="string"&&(b?(b=b.crossOrigin,b=typeof b=="string"?b==="use-credentials"?b:"":void 0):b=null,f.d.C(Q,b))},ke.prefetchDNS=function(Q){typeof Q=="string"&&f.d.D(Q)},ke.preinit=function(Q,b){if(typeof Q=="string"&&b&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin),W=typeof b.integrity=="string"?b.integrity:void 0,ot=typeof b.fetchPriority=="string"?b.fetchPriority:void 0;H==="style"?f.d.S(Q,typeof b.precedence=="string"?b.precedence:void 0,{crossOrigin:Z,integrity:W,fetchPriority:ot}):H==="script"&&f.d.X(Q,{crossOrigin:Z,integrity:W,fetchPriority:ot,nonce:typeof b.nonce=="string"?b.nonce:void 0})}},ke.preinitModule=function(Q,b){if(typeof Q=="string")if(typeof b=="object"&&b!==null){if(b.as==null||b.as==="script"){var H=R(b.as,b.crossOrigin);f.d.M(Q,{crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}}else b==null&&f.d.M(Q)},ke.preload=function(Q,b){if(typeof Q=="string"&&typeof b=="object"&&b!==null&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin);f.d.L(Q,H,{crossOrigin:Z,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,type:typeof b.type=="string"?b.type:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0,referrerPolicy:typeof b.referrerPolicy=="string"?b.referrerPolicy:void 0,imageSrcSet:typeof b.imageSrcSet=="string"?b.imageSrcSet:void 0,imageSizes:typeof b.imageSizes=="string"?b.imageSizes:void 0,media:typeof b.media=="string"?b.media:void 0})}},ke.preloadModule=function(Q,b){if(typeof Q=="string")if(b){var H=R(b.as,b.crossOrigin);f.d.m(Q,{as:typeof b.as=="string"&&b.as!=="script"?b.as:void 0,crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}else f.d.m(Q)},ke.requestFormReset=function(Q){f.d.r(Q)},ke.unstable_batchedUpdates=function(Q,b){return Q(b)},ke.useFormState=function(Q,b,H){return _.H.useFormState(Q,b,H)},ke.useFormStatus=function(){return _.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Yh;function Ly(){if(Yh)return mo.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(),mo.exports=Xy(),mo.exports}/**
* @license React
* react-dom-client.production.js
*
@@ -44,37 +44,37 @@
*
* This source code is licensed under the MIT license found in the
* 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 si;Gh=1;var i=Qy(),r=po(),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 A(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 p(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(A(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=A(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 Q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Q(t),e!==null)return e;t=t.sibling}return null}function b(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)&&b(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 Z(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 W(t){var e=[null,null],l=H(t);return l===null||ot(e,t,l.child,{foundSelf:!1}),e}function ot(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)&&ot(t,e,l.child,a))return!0;l=l.sibling}return!1}function rt(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 bt=null,it=null;function wt(t,e,l){return t===l?!0:t===e?(bt=t,!0):!1}function L(t,e,l){return t===l?(it=t,!1):t===e?(it!==null&&(bt=t),!0):!1}function ut(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 O(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 V=Object.assign,lt=Symbol.for("react.element"),St=Symbol.for("react.transitional.element"),Nt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Tt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ht=Symbol.for("react.consumer"),_t=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),z=Symbol.for("react.suspense_list"),vt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ee=Symbol.for("react.activity"),fe=Symbol.for("react.legacy_hidden"),ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),w=Symbol.for("react.recoverable"),et=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=et&&t[et]||t["@@iterator"],typeof t=="function"?t:null)}var Mt=Symbol.for("react.client.reference");function Ct(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 q:return"Profiler";case Tt:return"StrictMode";case Y:return"Suspense";case z:return"SuspenseList";case ee:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Nt:return"Portal";case _t:return t.displayName||"Context";case ht: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 vt:return e=t.displayName||null,e!==null?e:Ct(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Ct(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ft=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ne={pending:!1,data:null,method:null,actionLine truncated
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(`
`+xl+t+Dl}var me=!1;function il(t,e){if(!t||me)return"";me=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var k=function(){throw Error()};if(Object.defineProperty(k.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(k,[])}catch(K){var S=K}Reflect.construct(t,[],k)}else{try{k.call()}catch(K){S=K}k=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),k=!0,new t}finally{k&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(K){S=K}(k=t())&&typeof k.catch=="function"&&k.catch(function(){})}}catch(K){if(K&&S&&typeof K.stack=="string")return[K.stack,S.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(`
`);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=`
`+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`
`);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 C=`
`+m[a].replace(" at new "," at ");return t.displayName&&C.includes("<anonymous>")&&(C=C.replace("<anonymous>",t.displayName)),C}while(1<=a&&0<=n);break}}}finally{me=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?de(l):""}function kl(t,e){switch(t.tag){case 26:case 27:case 5:return de(t.type);case 16:return de("Lazy");case 13:return t.child!==e&&e!==null?de("Suspense Fallback"):de("Suspense");case 19:return de("SuspenseList");case 0:case 15:return il(t.type,!1);case 11:return il(t.type.render,!1);case 1:return il(t.type,!0);case 31:return de("Activity");case 30:return de("ViewTransition");default:return""}}function Fl(t){try{var e="",l=null;do e+=kl(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
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
`).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
`;Yt&&(qe+=Yt+`
`),St&&(qe+=(Array.isArray(St)?St.join(`
`):String(St))+`
`),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
`+a.stack}}var ue=Object.prototype.hasOwnProperty,jl=i.unstable_scheduleCallback,Ul=i.unstable_cancelCallback,wl=i.unstable_shouldYield,cl=i.unstable_requestPaint,Xt=i.unstable_now,bl=i.unstable_getCurrentPriorityLevel,Tl=i.unstable_ImmediatePriority,_l=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Wl=i.unstable_LowPriority,$l=i.unstable_IdlePriority,Il=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function sl(t){if(typeof Il=="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 pa=256,Sa=262144,xa=4194304;function B(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 U(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=B(a):(s&=o,s!==0?n=B(s):l||(l=o&~t,l!==0&&(n=B(l))))):(o=a&~u,o!==0?n=B(o):s!==0?n=B(s):l||(l=a&~t,l!==0&&(n=B(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 $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function N(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 X(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 tt(){var t=xa;return xa<<=1,(xa&62914560)===0&&(xa=4194304),t}function Ut(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function yt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Wt(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 C=31-Ae(l),k=1<<C;o[C]=0,m[C]=-1;var S=j[C];if(S!==null)for(j[C]=null,C=0;C<S.length;C++){var E=S[C];E!==null&&(E.lane&=-536870913)}l&=~k}a!==0&&rl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function rl(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 ja(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 oi(t,e){var l=e&-e;return l=(l&42)!==0?1:yu(l),(l&(t.suspendedLanes|e))!==0?0:l}function yu(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 Fa(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function ta(){var t=ft.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function ea(t,e){var l=ft.p;try{return ft.p=t,e()}finally{ft.p=l}}var El=Math.random().toString(36).slice(2),he="__reactFiber$"+El,$t="__reactProps$"+El,xe="__reactContainer$"+El,fi="__reactEvents$"+El,Ph="__reactListeners$"+El,tv="__reactHandles$"+El,jo="__reactResources$"+El,gu="__reactMarker$"+El,di="__reactLoad$"+El;function mi(t){delete t[he],delete t[$t],delete t[Ph],delete t[tv]}function Wa(t){var e;if(e=t[he])return e;for(var l=t.parentNode;l;){if(e=l[xe]||l[he]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[he])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function xn(t){if(t=t[he]||t[xe]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function pu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function jn(t){var e=t[jo];return e||(e=t[jo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function be(t){t[gu]=!0}function bo(t){t[di]=void 0}var To=new Set,_o={};function $a(t,e){bn(t,e),bn(t+"Capture",e)}function bn(t,e)Line truncated
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||_n(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&_n(t,""+a);else return;break;case"className":vi(t,"class",a);break;case"tabIndex":vi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":vi(t,l,a);break;case"style":ko(t,a,u);return;case"data":if(e!=="object"){vi(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=yi(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"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=yi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Hl);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=yi(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),hi(t,"popover",a);break;case"xlinkActuate":la(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":la(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":la(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":la(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":la(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":la(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":la(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":la(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":la(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":hi(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,hi(t,l,a);else return}Bt=!0}function Br(t,e,l,a,n,u){switch(l){case"style":ko(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")_n(t,a);else if(typeof a=="number"||typeof a=="bigint")_n(t,""+a);else return;break;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scroLine truncated
`;Ut&&(Wt+=Ut+`
`),yt&&(Wt+=(Array.isArray(yt)?yt.join(`
`):String(yt))+`
`),Wt+=JSON.stringify(tt,null,2).slice(0,4e3),Qe(Wt),Ne("已返回登录信息,见下方")}catch(tt){Ne("发起失败:"+tt.message)}},[]),xa=d.useCallback(async()=>{const N=W.trim();if(!N){Z("请填要查配额的用户 id");return}try{const X=await F(J.adminQuotaUser(N));Z("用户 "+N.slice(0,8)+":本月用量 "+su(X.used_usd_micros)+" / 配额 "+su(X.quota_usd_micros)+(X.remaining_usd_micros!==null&&X.remaining_usd_micros!==void 0?"(剩余 "+su(X.remaining_usd_micros)+")":"")+" | 月份 "+(X.month||""))}catch(X){Z(kc(X))}},[W]),B=d.useCallback(async(N,X)=>{const tt=N.trim();if(!tt){Z("请填要设置配额的用户 id");return}let Ut;if(!X.trim())Ut=null;else{const yt=Number(X.trim());if(!Number.isFinite(yt)||yt<0){Z("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Ut=Math.round(yt*1e6)}try{const yt=await F(J.adminQuotas,"POST",{user_id:tt,quota_usd_micros:Ut});Z("配额已设置:用户 "+tt.slice(0,8)+":本月用量 "+su(yt.used_usd_micros)+" / 配额 "+su(yt.quota_usd_micros)+(yt.remaining_usd_micros!==null&&yt.remaining_usd_micros!==void 0?"(剩余 "+su(yt.remaining_usd_micros)+")":"")+" | 月份 "+(yt.month||""))}catch(yt){Z(kc(yt))}},[]),U=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const X=(await F(J.adminSweep,"POST",{})).swept||[];Z("sweep 完成,清理 "+X.length+" 个任务"+(X.length?":"+X.join("、"):""))}catch(N){Z(kc(N))}},[]),$=d.useCallback(async()=>{try{const N=await F(J.adminBackup,"POST",{});Z("备份完成:backend="+N.backend+" | 路径 "+N.path+" | 大小 "+N.size_bytes+" 字节 | 任务 "+N.task_count+" 行")}catch(N){Z(kc(N))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Q,adminResult:H,adminUserId:W,setAdminUserId:ot,projects:rt,projectId:it,setProjectId:wt,projectMsg:L,githubRepos:O,workers:lt,workersSummary:Nt,workersError:Tt,members:ht,memberMsg:G,memberError:z,skills:pt,apps:fe,mcpServers:g,skillMsg:et,skillError:Mt,mcpMsg:kt,mcpOauthMsg:ft,mcpOauthBox:Ja,envs:Xe,envCount:Gt,envMsg:Jl,envRepoLine:ya,envRepoBar:Rl,envRepoMsg:ga,envSelect:Dl,setEnvSelect:de,models:me,modelCustomVisible:kl,refreshHealth:Ul,loadQuota:wl,probeAdmin:cl,refreshProjects:Xt,loadGithubRepos:bl,loadRelayModels:Tl,refreshWorkers:_l,refreshEnvs:Se,refreshMembers:Wl,refreshSkills:$l,saveKeyInput:Il,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:sl,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:pa,mcpOauth:Sa,adminQuota:xa,adminSetQuota:B,adminSweep:U,adminBackup:$}}const ho=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(0),[W,ot]=d.useState(0),[rt,bt]=d.useState({}),it=d.useRef(0),wt=d.useRef({status:"",executor:"",q:"",kind:""});wt.current={status:y,executor:M,q:Q,kind:_};const L=d.useRef(""),ut=d.useCallback(async(Y,z,vt)=>{const pt=Y!==void 0?Y:z;if(!pt){r([]),ot(0),bt({});return}L.current=pt;const ee=vt!==void 0?vt:it.current,fe=wt.current;try{const ne=await F(J.tasksPage(pt,ho,ee*ho,fe.status,fe.executor,fe.q,fe.kind));r(ne.tasks||[]),ot(ne.total??(ne.tasks||[]).length),bt(ne.status_counts||{}),f("")}catch(ne){f("加载失败:"+ne.message)}},[]),O=d.useCallback(Y=>{const z=Math.max(0,Y);it.current=z,Z(z)},[]),V=d.useCallback(Y=>{O(Y),ut(L.current||void 0,void 0,Y)},[ut,O]);d.useEffect(()=>{if(!L.current)return;const Y=setTimeout(()=>{ut(L.current,void 0,0)},300);return()=>clearTimeout(Y)},[y,M,Q,_]);const lt=d.useCallback(Y=>{A(Y),O(0)},[O]),St=d.useCallback(Y=>{p(Y),O(0)},[O]),Nt=d.useCallback(Y=>{R(Y),O(0)},[O]),mt=d.useCallback(Y=>{b(Y),O(0)},[O]),Tt=d.useCallback(async Y=>{O(0),await ut(Y,void 0,0)},[ut,O]),q=Math.max(1,Math.ceil(W/ho)),ht=W===0?"共 0 个任务":"共 "+W+" 个任务,第 "+(H+1)+" / "+q+" 页",_t=i,G=d.useCallback(async(Y,z)=>{const vt={...Y};z.length&&(vt.upload_id=await tg(z));const pt=await F(J.tasksCreate,"POST",vt);return"任务已建:"+se(pt.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:lt,executorFilter:M,setExecutorFilter:St,kindFilter:_,setKindFilter:Nt,searchText:Q,setSearchText:mt,filteredTasks:_t,taskCountText:ht,refreshTasks:ut,refreshTasksFirstPage:Tt,page:H,totalPages:q,taskTotal:W,taskStatusCounts:rt,goToPage:V,dispatch:G}}function ng(i,r){const[h,f]=d.useState(null),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(void 0),[W,ot]=d.useState(!1),[rt,bt]=d.useState(""),[it,wt]=d.useState([]),[L,ut]=d.useState([]),[O,V]=d.useState([]),[lt,St]=d.useState(""),[Nt,mt]=d.useState([]),[Tt,q]=d.useState(""),[ht,_t]=d.useState(""),[G,Y]=d.useState([]),[z,vt]=d.useState(""),[pt,ee]=d.useState(""),[fe,ne]=d.useState(""),[g,w]=d.useState(""),[et,nt]=d.useState([]),[Mt,Ct]=d.useState([]),[kt,P]=d.useState(""),[ft,Ne]=d.useState([]),[Ja,Qe]=d.useStatLine truncated
`);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
`).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 ===
`+k.result_text.slice(0,8e3)+`
`),k.error_text&&(I+=`=== error ===
`+k.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 ===
`+k.result_text.slice(0,8e3)+`
`),k.error_text&&(I+=`=== error ===
`+k.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
(链路摘要已截断至 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
`);Fa=ea.pop()||"";for(const El of ea){const he=El.split(`
`).filter($t=>$t.indexOf("data: ")===0).map($t=>$t.slice(6)).join(`
`);if(he)try{const $t=JSON.parse(he);if($t.type==="snapshot")N=$t.entries||[],Object.keys(X).forEach(xe=>delete X[xe]),N.forEach((xe,fi)=>{xe.id&&(X[xe.id]=fi)}),tt=$t.note||"",Y([...N]),vt(tt),Fl(B);else if($t.type==="entry"){const xe=$t.entry;xe.id&&xe.id in X?N[X[xe.id]]=xe:(xe.id&&(X[xe.id]=N.length),N.push(xe)),Y([...N]),Fl(B)}else $t.type==="done"&&(Ut=!0,yt=$t.status,ee(yt))}catch{}}if(Ut)break}}catch(Wt){if(Wt&&Wt.name==="AbortError")return;vt("链路流中断:"+Wt.message);return}Ut&&r()},[il,Fl,r]),Il=d.useCallback(async B=>{il();try{const U=await F(J.task(B));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),A($),b(""),Z(void 0),Ul(B),ot(!1),bt(""),Qe(""),re(""),Wl(U.status);const N=i(U.project_id),X=N==="maintainer"||N==="owner",tt=N==="contributor"||X,Ut=ou.includes(U.status),yt=[];["failed","interrupted","merge-failed"].includes(U.status)&&X||yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(X?"":",且仅 maintainer 可重试")),!Ut&&tt||yt.push("取消:不可用——"+(Ut?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&X||yt.push("合并:不可用——仅 completed 可合并"+(X?"":",且仅 maintainer 可合并")),U.status==="merged"&&X||yt.push("提 PR:不可用——仅 merged 可提 PR"+(X?"":",且仅 maintainer 可提 PR")),R(yt.join(";")),p(""),$l(B,U.status),kl(B),ue(U),jl(U),cl(B),wl(B),Tl(B),_l(B),Se(B),Xt(B),bl(B)}catch(U){f(null),A("加载失败:"+U.message)}},[il,$l,kl,ue,jl,cl,wl,Tl,_l,Se,Xt,bl,Ul,i]),du=d.useCallback(async()=>{const B=me.current;if(B){try{const U=await F(J.task(B.id));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),A($||"(暂无 result/error 输出)")}catch{}wl(B.id),cl(B.id),Xt(B.id),bl(B.id),ue(B),jl(B),kl(B.id),Tl(B.id),_l(B.id),Se(B.id),Ul(B.id)}},[wl,cl,Xt,bl,ue,jl,kl,Tl,_l,Se,Ul]),Pl=d.useCallback(async B=>{const U=me.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const N=await F(J.taskAction(U.id,B),"POST",{});p(B+" 已提交:"+JSON.stringify(N).slice(0,300)),r(),await Il(U.id)}catch(N){p(B+" 失败:"+N.message)}},[Il,r]),Ce=d.useCallback(async()=>{const B=me.current;if(B)try{const U=await F(J.taskCost(B.id));b(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){b("查成本失败:"+U.message)}},[]),sl=d.useCallback(async B=>{const U=me.current;if(U){if(!B.trim()){bt("分支消息必填");return}if(!ou.includes(U.status)){bt("仅终态任务可分支");return}try{const $=await F(J.taskFork(U.id),"POST",{message:B.trim()});bt("分支任务已建:"+$.id.slice(0,8)),r()}catch($){bt("分支失败:"+$.message)}}},[r]),Ae=d.useCallback(async B=>{const U=me.current;if(U)try{await F(J.taskReviews(U.id),"POST",B),q("评审已登记(requested)"),await Xt(U.id)}catch($){q("登记评审需求失败:"+$.message)}},[Xt]),mu=d.useCallback(B=>{const U=me.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&F(J.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void Xt(U.id)).catch(N=>q("写 findings 失败:"+N.message))},[Xt]),hu=d.useCallback((B,U)=>{const $=me.current;if(!$)return;const N=U==="approved"?"approve":"deny";F(J.taskApprovalDecision($.id,B,N),"POST",{}).then(()=>{P("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),bl($.id)}).catch(X=>P("决定失败:"+X.message))},[bl]),vu=d.useCallback(async(B,U,$)=>{const N=me.current;if(N){if(!U.trim()){Qe("目标任务 id 不能为空");return}if(!$.trim()){Qe("消息正文不能为空");return}try{const X=await F(J.taskMessagesTo(U.trim()),"POST",{from_task_id:N.id,body:$.trim(),mode:B});Qe("已发送("+X.delivery+")"),await Tl(N.id)}catch(X){Qe("发送失败:"+X.message)}}},[Tl]),pa=d.useCallback((B,U)=>{const $=me.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){re("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const N={};if(B==="rename"){if(!U.trim()){re("新线程名不能为空");return}N.name=U.trim()}F(J.taskThreadAction($.id,B),"POST",N).then(X=>{re(B+" 已提交:"+JSON.stringify(X).slice(0,300)),_l($.id),Se($.id)}).catch(X=>re(B+" 失败:"+X.message))},[_l,Se]),Sa=d.useCallback(async B=>{const U=me.current;if(!U)return;const $=B.trim();if(!$){Fe("消息不能为空");return}try{ou.includes(U.status)&&U.status!=="merged"?(await F(J.taskContinue(U.id),"POST",{message:$}),Fe("已重新排队,同一会话继续"),r(),await Il(U.id)):["queued","running"].includes(U.status)?(await F(J.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Fe("已直接发送"),await wl(U.id),await cl(U.id)):Fe("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(N){Fe("发送失败:"+N.message)}},[Il,r,wl,cl]),xa=d.useCallback(async()=>{const B=me.current;if(!B){w("先选一个任务");return}try{const U=await F(J.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),N=document.createElement("a");N.href=URL.createObjectURL($),N.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(N),N.click(),N.remove(),setTimeout(()=>URL.revokeObjectURL(N.href),1e3),w("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){w("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>il(),[il]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Q,costSummary:H,forkVisible:W,setForkVisible:ot,forkMsg:rt,coordChildren:it,sideThreads:L,agents:O,agentsNote:lt,reviews:Nt,reviewMsg:Tt,reviewError:ht,traceEntries:G,traceNote:z,traceDone:pt,traceKind:fe,setTraceKind:ne,traceMsg:g,followups:et,approvals:Mt,approvalMsg:kt,peerMessages:ft,peerMsg:Ja,threadText:Xe,threadGateNote:Gt,threadMsg:Jl,threadList:ya,chatMsg:Rl,chatHistory:ga,showDetail:Il,refreshDetailLists:du,doAction:Pl,taskCost:Ce,forkTask:sl,postReview:Ae,submitFindings:mu,decideApproval:hu,postPeer:vu,threadAction:pa,sendChatMessage:Sa,downloadTrace:xa,refreshReviews:()=>void Xt(),refreshApprovals:()=>void bl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[A,M]=d.useState(null),[p,_]=d.useState(""),[R,Q]=d.useState(null),[b,H]=d.useState(""),Z=d.useCallback(async L=>{const ut=L!==void 0?L:i();if(!ut){h([]);return}try{const O=await F(J.projectAutomations(ut));h(O.automations||[])}catch(O){y("自动化Line 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:go(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:go(i)}}function pg(){const i=Yt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(W=>({id:W.id,role:W.role,text:W.text,label:W.label,kind:W.kind}));const H=new Set(_.map(W=>W.id)),Z=i.traceEntries.filter(W=>W.id&&!H.has(W.id)&&!String(W.id).startsWith("fu-")).map((W,ot)=>Lh(W,ot));_=[..._,...Z]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],Z=i.traceEntries.filter(ot=>!String(ot.id||"").startsWith("fu-")).map((ot,rt)=>Lh(ot,rt)),W=i.followups.map((ot,rt)=>({id:"followup-"+rt,role:"user",text:String(ot.body||""),label:"后续消息"}));_=[...H,...Z,...W]}const R=i.traceKind,Q=R?_.filter(H=>H.role==="user"||!H.kind||H.kind===R):_,b=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Q,...b]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=ou.includes(r.status),y=["queued","running"].includes(r.status),A=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",p=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,p!==null&&p>0?c.jsxs("div",{className:"muted",children:["此前有 ",p," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):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:!A,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Yt().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=Yt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=ou.includes(r.status),A=["failed","interrupted","merge-failed"].includes(r.status),M=r.kind==="side";return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!A,title:A?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),M?null:c.jsxs(c.Fragment,{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 可提 Line truncated
`);for(;nt>=0;){const rt=mt.slice(0,nt);mt=mt.slice(nt+2);for(const G of rt.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(`
`);for(;ht>=0;){const _t=mt.slice(0,ht);mt=mt.slice(ht+2);for(const G of _t.split(`
`))if(G.startsWith("data: "))try{const Y=JSON.parse(G.slice(6));Y.waiting?p(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),p("直播中"));const z={};Y.holder&&(z.holder=Y.holder),Y.url&&(z.url=Y.url),Y.title!==void 0&&(z.title=Y.title),Object.keys(z).length>0&&A(vt=>({...vt||{},...z}))}catch{}ht=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
`)}}p("流已断开")}catch(lt){if(lt.name==="AbortError")return;R(lt instanceof Error?lt.message:String(lt)),p("未开始")}finally{b(!1)}},[r]),rt=d.useCallback(()=>{var O;(O=H.current)==null||O.abort(),b(!1),p("已停止")},[]),bt=d.useCallback(async O=>{if(r)try{const V=await F(J.taskBrowserControl(r.id),"POST",{holder:O});A(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),it=d.useCallback(async O=>{if(!r||(y==null?void 0:y.holder)!=="user"||!Z.current)return;const V=Z.current.getBoundingClientRect(),lt=Math.round((O.clientX-V.left)/V.width*1280),St=Math.round((O.clientY-V.top)/V.height*800);try{await F(J.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:lt,y:St})}catch(Nt){R(Nt instanceof Error?Nt.message:String(Nt))}},[r,y]),wt=d.useCallback(async O=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){O.preventDefault();try{O.key.length===1?await F(J.taskBrowserInput(r.id),"POST",{type:"type",text:O.key}):await F(J.taskBrowserInput(r.id),"POST",{type:"key",key:O.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const L=(y==null?void 0:y.holder)||"—",ut=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:["会话 ",ut?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",L]}),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:[Q?c.jsx("button",{type:"button",onClick:rt,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void ot(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void W(),children:"刷新状态"}),L!=="user"?c.jsx("button",{type:"button",onClick:()=>void bt("user"),disabled:!ut,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void bt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,L==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:O=>void it(O),onKeyDown:O=>void wt(O),style:{width:"100%",maxWidth:1280,cursor:L==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ut?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function bg(){const i=Yt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[A,M]=d.useState(""),[p,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.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:R=>y(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:R=>M(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:p,onChange:R=>_(R.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:p.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(Sn,{children:i.reviews.map(R=>{let Q=R.target_type||"";return R.branch&&(Q+=":"+R.branch),R.sha&&(Q+=":"+String(R.sha).slice(0,12)),R.instructions&&(Q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(R.id)," | ",Q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:R.Line 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>
<body>
+70 -13
View File
@@ -65,6 +65,12 @@ import time
from abc import ABC, abstractmethod
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 保持一致的连接级等待;
# PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位
# 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。
@@ -727,7 +733,7 @@ class PgCursor:
info_cur.close()
self._fake_rows = [
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",
"dflt_value", "pk"))
for i, r in enumerate(raw)]
@@ -876,18 +882,11 @@ POSTGRES_SCHEMA = (
"created_at DOUBLE PRECISION)",
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
" ON task_steps(task_id, step_no)",
# 尝试账本(v6,见 cloud/attempts.py):(task_id, attempt_no)
# 唯一;PG 新库一次建齐,老库走 server/worker ensure。
"CREATE TABLE IF NOT EXISTS 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))",
# 尝试账本(统一 schema,见 cloud/attempt_ledger.py):
# (task_id, attempt_no) 唯一;PG 新库一次建齐,老库走
# server/worker ensure 迁移到同一列集。此处是导入的统
# 一定义本身,不是第二份 DDL。
_TASK_ATTEMPTS_DDL,
# 审计循环状态(audit loop):task_id 单行主键;PG 新库一次
# 建齐,老库走 server/worker ensure(见 cloud/audit_loop.py)。
"CREATE TABLE IF NOT EXISTS task_audit_loop_state("
@@ -945,6 +944,7 @@ POSTGRES_SCHEMA = (
"coordinator_parts TEXT, "
"parent_task_id TEXT, "
"coordinator_part TEXT, "
"kind TEXT, "
"goal_resume_pending INTEGER DEFAULT 0, "
"agent_name TEXT, "
"agent_version TEXT, "
@@ -955,6 +955,8 @@ POSTGRES_SCHEMA = (
"audit_loop_packet TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
# POSTGRES_SCHEMA 在每次初始化时全量执行,新表自动补建。
"CREATE TABLE IF NOT EXISTS uploads("
@@ -1163,6 +1165,49 @@ POSTGRES_SCHEMA = (
)
# 启动期 schema 初始化 advisory lock key(Postgres):
# 并发启动(cloud-server 与 worker 同时 init_db)时,CREATE TABLE /
# CREATE INDEX 的 IF NOT EXISTS 检查与建对象不是原子的——两个会话
# 都查到“不存在”后同时建,会撞 pg_type_typname_nsp_index(建表)
# 或 pg_class_relname_nsp_index(建索引)的 UniqueViolation,启动
# 即崩、靠 systemd 重启才自愈(37ba687 与 7983567 两次部署均撞上;
# 7983567 新增的 idx_tasks_parent 是当时的新对象)。
# 持锁串行化整个初始化:init_postgres_schema 内持事务级锁(随本
# 函数末尾的 commit 释放),server.init_db 另持会话级锁覆盖其后
# 的 _ensure_*(task_continue_messages / user_gateway_keys /
# task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA 里,同样
# 会撞)。key 须全项目唯一:限流清理另有
# RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027(cloud/server.py),
# 两处绝不能撞 key。
SCHEMA_INIT_ADVISORY_KEY = 20261108
def acquire_schema_init_lock(conn):
"""取 schema 初始化会话级 advisory 锁(阻塞等,不 try)。
调用方须在初始化全部完成后调 release_schema_init_lock 释放;
连接关闭时也会自动释放。初始化必须做完不能跳过,所以这里用
阻塞取锁,不用 pg_try_advisory_lock。
"""
conn.execute(
"SELECT pg_advisory_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
def release_schema_init_lock(conn):
"""放 schema 初始化会话级 advisory 锁(best-effort)。
事务已 abort 时 SELECT 本身也会失败——吞掉,连接关闭时锁
同样释放,绝不能让放锁失败再挡住启动。
"""
try:
conn.execute(
"SELECT pg_advisory_unlock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
except Exception:
pass
def init_postgres_schema(conn):
"""在给定适配连接上建出全部 Postgres 表(幂等),并提交。
@@ -1176,6 +1221,15 @@ def init_postgres_schema(conn):
库是 no-op。server.init_db 的 Postgres 分支还会再跑一次
migrate_projects_table,两次补列互不冲突(第二次是 no-op)。
"""
# 事务级 advisory 锁:并发 init_postgres_schema 串行执行,
# 随本函数末尾的 commit 释放;独立调用方(如 migrations)
# 同样受保护。server.init_db 另持会话级锁覆盖 _ensure_*。
# 不取结果行:pg_advisory_xact_lock 返回 void,且假连接
# (test_p2_migrations / test_audit_batch1 的 FakeCursor)
# 没有 fetchone——execute 本身已阻塞到取锁成功。
conn.execute(
"SELECT pg_advisory_xact_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
for statement in POSTGRES_SCHEMA:
conn.execute(statement)
cols = {row[1] for row in conn.execute(
@@ -1377,6 +1431,9 @@ __all__ = [
"PgCursor",
"PgRow",
"POSTGRES_SCHEMA",
"SCHEMA_INIT_ADVISORY_KEY",
"acquire_schema_init_lock",
"release_schema_init_lock",
"init_postgres_schema",
"create_database",
"database_from_env",
+2
View File
@@ -199,6 +199,8 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
if _row_get(row, "coordinator_parts") else None),
"parent_task_id": _row_get(row, "parent_task_id"),
"coordinator_part": _row_get(row, "coordinator_part"),
# 旁聊(Muse 角色对齐 P2):NULL = 老行 = 主聊。
"kind": _row_get(row, "kind") or "main",
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
# agent 定义 v2:身份与指令落库后直接回读(老行缺列
# 时为 None;指令是定义文本、非密钥,任务详情里可见)。
+24 -16
View File
@@ -21,16 +21,19 @@
报告:表齐但钩子关 ≠ 就绪,钩子开但表不齐 ≠ 就绪。
3. DDL 清单不另造:清单直接引用 attempt_ledger.PG_DDL
与 attempt_hook.PG_MARKER_DDL 两个常量(本模块不
复制 DDL 文本,复制就会漂移)。另含 gap 3 旧表升级句
attempt_ledger.OLD_TABLE_ALTER(仅当旧 DDL 建过表时
需要;gap 3 未合并,生产尚无旧表,属防御项)。
复制 DDL 文本,复制就会漂移)。老形态表升级走
attempt_ledger.ensure_task_attempts_table 的统一列补齐
(仅当老形态建过表时需要;属防御项,真实 PG 16.15
升级路径已于 2026-11-06 实证,见 verification/
codex-task-attempts-pg-proof-20261106.md)。
4. 校验函数 verify_ledger_ready(conn):只读探针——查两
张表的存在与必需列,不建表、不写行(读路径绝不改
schema,与 metrics-report 端点的只读纪律一致)。
5. 生产 PG 手动 DDL 仍是运维侧动作:本轨无生产 PG、无
本地 PG,PG 证据与此前各轨同款——适配器级(? 占位符
经 cloud.db 翻译、DDL 仅用 PG 合法类型、无自增列),
绝不写成“生产 PG 已执行”。运维执行清单见
5. 生产 PG 手动 DDL 仍是运维侧动作:升级路径本身已在
真实 PG 16.15 上实证(见 verification/
codex-task-attempts-pg-proof-20261106.md),但“生产 PG
已执行”仍须运维在生产库上跑过才算,绝不写成已执行。
运维执行清单见
LEDGER_DDL_CHECKLIST(顺序执行 + 每步验证命令)。
零第三方依赖,只用标准库。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 PG_DDL as _LEDGER_PG_DDL
# 必需列:缺任一列即视为表不齐(旧 DDL 建的表缺
# corrects_attempt_no 时走 OLD_TABLE_ALTER 补列)。
# 必需列:统一 schema 的全部 12 列;缺任一列即视为表
# 不齐(老形态建的表由 ensure_task_attempts_table 补齐
# 缺失列后复核,不再只补 corrects_attempt_no 一列)。
LEDGER_REQUIRED_COLUMNS = (
"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")
@@ -72,12 +78,14 @@ LEDGER_DDL_CHECKLIST = (
},
{
"step": 3,
"action": "仅当表由 gap 3 旧 DDL 建过时:执行"
" attempt_ledger.OLD_TABLE_ALTER 补"
" corrects_attempt_no 列(gap 3 未合并,"
"生产正常路径跳过此步)",
"ddl_source": "cloud.attempt_ledger.OLD_TABLE_ALTER",
"verify": "task_attempts 含 corrects_attempt_no 列",
"action": "仅当表已由老形态(旧恢复 DDL、gap 3 台账 DDL"
" 或演练形态)建过时:经 attempt_ledger."
"ensure_task_attempts_table 补齐缺失的统一列"
"(SQLite 与真实 PG 16.15 均已实测,见 verification/"
"codex-task-attempts-pg-proof-20261106.md;生产 PG"
" 上执行仍是运维侧动作)",
"ddl_source": "cloud.attempt_ledger.TASK_ATTEMPTS_DDL",
"verify": "task_attempts 含统一 schema 全部 12 列",
},
{
"step": 4,
+27 -9
View File
@@ -346,8 +346,15 @@ def extract_task_creates(text):
executor = payload.get("executor")
if executor is not None and executor not in EXECUTORS:
continue
# 旁聊(P2 G7):块里可显式指定 kind("main"|"side"),
# 非法值整块跳过(与 executor 非法同款);不指定时
# worker 按父任务 kind 继承(旁聊父 → 旁聊子)。
kind = payload.get("kind")
if kind is not None and kind not in ("main", "side"):
continue
out.append({"name": name, "prompt": prompt, "model": model,
"effort": effort, "executor": executor})
"effort": effort, "executor": executor,
"kind": kind})
return out
@@ -1011,18 +1018,20 @@ def parent_spawn_generation(conn, parent_id):
只数 parent_task_id 非空且 coordinator_part 为空的祖先链(即
派生链);协调员子任务(coordinator_part 非空)本身不是派生
任务,代数为 0,链到此为止。走 32 步仍未到头时按已数到的算,
绝不死循环。"""
任务,代数为 0,链到此为止;旁聊任务(kind='side')同款——
旁聊不是 marker 派生,链到旁聊为止。走 32 步仍未到头时按已数
到的算,绝不死循环。"""
generation = 0
cur = parent_id
for _ in range(32):
if not cur:
break
row = conn.execute(
"SELECT parent_task_id, coordinator_part FROM tasks"
" WHERE id = ?", (cur,)).fetchone()
"SELECT * FROM tasks WHERE id = ?", (cur,)).fetchone()
if row is None:
break
if row_get(row, "kind") == "side":
break
parent = row_get(row, "parent_task_id")
part = row_get(row, "coordinator_part")
if parent is not None and part is None:
@@ -1034,10 +1043,19 @@ def parent_spawn_generation(conn, parent_id):
def count_direct_spawn_children(conn, parent_id):
"""单个父任务的直接派生子任务数(不含协调员子任务)。"""
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
"""单个父任务的直接派生子任务数(不含协调员子任务与旁聊)。"""
try:
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL"
" AND (kind IS NULL OR kind != 'side')",
(parent_id,)).fetchone()
except Exception:
# 老库/最小测试表缺 kind 列:当时不可能有旁聊行,
# 不带 kind 条件的结果与带条件等价。
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
try:
return int(row[0]) if row is not None else 0
except (TypeError, ValueError, IndexError):
+6 -1
View File
@@ -236,7 +236,12 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
# 的 `or 0` 兜底拦不住,audit_loop=0 的
# 历史任务会被悄悄当成启用(Audit batch 1)。
("audit_loop", "INTEGER"),
("audit_loop_packet", "TEXT")):
("audit_loop_packet", "TEXT"),
# 旁聊(Muse 角色对齐 P2):kind 列须与
# db.py POSTGRES_SCHEMA、server
# migrate_tasks_table 同步;NULL = 老行
# = 主聊,无回填。
("kind", "TEXT")):
if _col not in _existing:
try:
pg_conn.execute(
+92 -20
View File
@@ -13,6 +13,7 @@
- CloudExecutor:占位,未接通云端执行。
"""
import hashlib
import json
import logging
import os
@@ -43,6 +44,39 @@ from cloud.peer_messages import extract_peer_messages as _extract_peer_messages
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():
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
@@ -558,7 +592,7 @@ class LocalExecutor(Executor):
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
def dispatch_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
命令:host.py [--config CFG] [--model M] dispatch --name <host 名>
@@ -571,6 +605,8 @@ class LocalExecutor(Executor):
model 换成 fallback 模型,第二次 dispatch/run 就跑在新模型上)。
seed_dir 非空时追加 --seed-dir:host.py 在建 worktree 后、
跑 turn 前把暂存附件拷进去(run 续跑不走这条路,不重复拷)。
no_worktree(旁聊默认):追加 --no-worktree,host.py 不建
worktree/分支,无 repo 时以 per-task scratch 目录为 cwd。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -591,6 +627,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -639,7 +677,7 @@ class LocalExecutor(Executor):
task["id"], self._run_local(cmd, extra_env))
def goal_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
@@ -686,6 +724,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._goal_result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -833,7 +873,8 @@ class LocalExecutor(Executor):
}
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
extra_env=None, model_provider=None,
no_worktree=False):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
@@ -886,6 +927,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
elif kind == "goal-resume-breakpoint":
# G10 断点:无 --goal-text,不开新循环;host 侧按
# goal_rounds_done 的下一轮接着跑,轮次上限沿用原
@@ -915,6 +958,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
if extra_env:
merged = dict(os.environ)
merged.update(extra_env)
@@ -1471,13 +1516,17 @@ class DockerExecutor(Executor):
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。
seed_host_dir 非空时把暂存目录只读挂载进容器
(/tmp/codex-seed),内层 host.py 用 --seed-dir
/tmp/codex-seed 拷进 worktree;挂载整个目录(不是单个
文件)是为了一包多文件与子目录。"""
文件)是为了一包多文件与子目录。no_worktree(旁聊):
内层命令追加 --no-worktree,容器内 host.py 不建
worktree/分支(worker 只在本地默认旁聊上接线,显式
docker 旁聊走既有语义;此处只做透传)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
@@ -1496,6 +1545,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=mounts or None,
network=network,
@@ -1539,11 +1590,13 @@ class DockerExecutor(Executor):
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
seed_host_dir 语义同 dispatch_task(只读挂载 + 内层
--seed-dir /tmp/codex-seed)。
--seed-dir /tmp/codex-seed)。no_worktree 语义同
dispatch_task(内层命令追加 --no-worktree)。
不走 _run_in_container:它经 _parse_host_result 截头
stdout,而 goal 结论在末尾,截头会把结论截掉;这里直接
@@ -1584,6 +1637,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
cmd = self.build_run_command(task, inner,
extra_mounts=mounts or None,
network=network,
@@ -1957,6 +2012,15 @@ class DockerExecutor(Executor):
def ensure_network(self, name):
"""确保 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 表示网络可用(创建成功或本来就存在),
False 表示创建失败(调用方继续派发,docker run 时会
因网络不存在而明确失败,不在这里静默降级默认网络——
@@ -1966,19 +2030,27 @@ class DockerExecutor(Executor):
"""
if not name:
return False
try:
result = self._call_runner(["docker", "network", "create", name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
return "already exists" in text
except Exception:
return False
for subnet in tenant_subnet_candidates(name):
try:
result = self._call_runner(
["docker", "network", "create",
"--subnet", subnet, name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
if "already exists" in text:
return True
if "overlap" in text.lower():
continue
return False
except Exception:
return False
return False
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。
+3 -1
View File
@@ -13,7 +13,9 @@
行字段:
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
本模块不规定口径内容、不替用户拍板阈值)。
- baseline_head:冻结时调用方传入的 main 头(字符串,非空)。
- baseline_head:冻结时调用方传入的 main 头(字符串,非空;
接受任意字符串原样存、不做格式校验,本模块不验证其真实性,
可信来源仍是调用方责任)。
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
文本存入,读回时还原为 dict。
+239 -34
View File
@@ -54,6 +54,7 @@ from collections import deque
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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 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.attempts import ensure_attempts_table as _ensure_attempts
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
# 移 handler_support)、_MEMBER_ROLES/_coord_parse_parts(用点已随
# split 移走,server.py 内无其余引用,ruff F401 计)。
@@ -88,7 +88,9 @@ from cloud.timeouts import validate_server_timeout as _validate_timeout
from cloud.auth import audit_on_conn as _audit_on_conn
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import acquire_schema_init_lock as _acquire_schema_init_lock
from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import release_schema_init_lock as _release_schema_init_lock
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
@@ -1043,6 +1045,28 @@ def _db_backend_name():
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():
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
@@ -1212,6 +1236,11 @@ def migrate_tasks_table(conn):
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT",
"kind": "TEXT",
# 旁聊(Muse 角色对齐 P2):kind 记任务类型
# ('main' | 'side',NULL = 老行 = 主聊,与 goal_mode
# NULL=0、plan NULL=0 同款惯例);parent_task_id
# 的语义从“协调员/派生父任务”扩为“父会话”。
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
# 重复触发)。与 resume_message 不同:后者是“给旧会
@@ -1640,22 +1669,32 @@ def init_db(db_path):
conn = _connect(db_path)
try:
if _is_postgres_backend():
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_continue_messages_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_gateway_keys(conn)
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
_ensure_audit_loop(conn)
conn.commit()
return
# 会话级 advisory 锁覆盖整个 PG 初始化:_init_postgres_schema
# 内还有事务级锁,但其后的 _ensure_* 建的对象不全在
# POSTGRES_SCHEMA 里(task_continue_messages /
# user_gateway_keys / task_attempts / task_audit_loop_state),
# 只锁前者仍会在后者上撞 UniqueViolation;锁在最终 commit
# 后才放。连接在外层 finally 里关闭,锁至迟随连接释放。
_acquire_schema_init_lock(conn)
try:
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_continue_messages_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_gateway_keys(conn)
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
_ensure_audit_loop(conn)
conn.commit()
return
finally:
_release_schema_init_lock(conn)
conn.execute(
"CREATE TABLE IF NOT EXISTS projects("
"id TEXT PRIMARY KEY, "
@@ -1703,6 +1742,9 @@ def init_db(db_path):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
" ON tasks(status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)")
conn.commit()
finally:
conn.close()
@@ -2298,6 +2340,20 @@ def _goal_signal_status(status):
return "pending_mirror_to_host_loop"
def _filter_tasks_by_kind(rows, kind_f):
"""旁聊(Muse 角色对齐 P2):GET /tasks 的 kind 过滤。
kind_f 为空时原样返回(缺省 = 全部,老行为);否则只留
`_task_to_dict` 的 kind 等于 kind_f 的行。抽成独立函数,
不让 `_list_tasks` 再长(长方法清单 pin 在 18 个,见
test_longfunc_inventory)。
"""
if not kind_f:
return rows
return [r for r in rows
if _task_to_dict(r, [], 0)["kind"] == kind_f]
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -2330,6 +2386,29 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
self._responded = True
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):
try:
length = int(self.headers.get("Content-Length") or 0)
@@ -2617,18 +2696,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"commit": _deployed_commit(),
})
return
if path == "/console" and method == "GET":
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
# localStorage。页面文件与 server.py 同目录,改动随部署走。
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"})
if path in ("/console", "/console-classic") and method == "GET":
# /console:原生 UI 入口(见 docs/codex-native-ui-alignment.md
# §3):UI host 可达时 302 到 CODEX_UI_URL(默认
# http://127.0.0.1:8095/),不可达时回落旧控制台;
# /console-classic 永远 serve 旧控制台。两者都不走鉴权与
# 限流(与 /health 同款 pre-auth 待遇)。
if path == "/console" and _codex_ui_reachable():
self._send_redirect(_codex_ui_url())
else:
self._serve_console_classic()
return
if path.startswith("/oauth/"):
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
@@ -3769,6 +3846,69 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"error": "协调员任务不跑模型回合,不能与"
" goal/plan/验收命令同用"})
return
# 父任务血缘(Muse 角色对齐 P1):创建请求可携带
# parent_task_id;父任务是协调员且本次新建也是协调员
# 时拒绝(深度恒 ≤1)。父不存在 404、跨项目 400。
# 不带 coordinator_parts 时 parent_task_id 仅作血缘记录
# (第二阶段旁聊的父子关系用同一入参)。
parent_task_id = body.get("parent_task_id")
if parent_task_id is not None:
if not isinstance(parent_task_id, str):
self._send_json(400, {
"error": "parent_task_id must be a string"})
return
parent_row = self._fetch_task(parent_task_id)
if parent_row is None:
self._send_json(404, {"error": "parent task not found"})
return
parent_task = _task_to_dict(parent_row)
if parent_task["project_id"] != project_id:
self._send_json(400, {
"error": "parent task belongs to another project"})
return
if coord_parts and parent_task.get("coordinator_parts"):
self._send_json(400, {
"error": "coordinator task cannot be created"
" under a coordinator task"})
return
# 旁聊(Muse 角色对齐 P2):kind 只认 "main" | "side",
# 缺省 = 主聊;旁聊须带 parent_task_id(已在上段校验
# 存在与同项目),不得带 coordinator_parts(协调员
# 子任务与旁聊是两种父子关系),不得带 upload_id /
# environment_id(附件与环境语义都要求 worktree,
# 旁聊默认没有);父任务不得是协调员任务(协调员
# 本身不跑模型回合,旁聊挂上去没有父会话语义,且
# 会污染协调员的子任务同步快照)。
kind = body.get("kind")
if kind is None:
kind = "main"
if kind not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
if kind == "side":
if parent_task_id is None:
self._send_json(400, {
"error": "side thread requires parent_task_id"})
return
if coord_parts:
self._send_json(400, {
"error": "side thread cannot carry coordinator_parts"})
return
if parent_task is not None and parent_task.get(
"coordinator_parts"):
self._send_json(400, {
"error": "side thread parent cannot be a"
" coordinator task"})
return
if body.get("upload_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry upload_id"})
return
if body.get("environment_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry environment_id"})
return
idempotency_key = body.get("idempotency_key")
if idempotency_key is not None and not isinstance(idempotency_key, str):
self._send_json(400, {"error": "idempotency_key must be a string"})
@@ -3850,7 +3990,10 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
# 未指定且无环境继承时默认 docker:空 executor(任意
# worker 可认领)会让任务落到隔离较弱的 local worker,
# 是多用户下的隔离缺口;显式 local/cloud 不受影响。
executor = _default_executor()
# 旁聊例外(P2 G1):旁聊是会话式使用,默认 local、
# 不拉容器;docker/cloud 仅显式要求时才走。
executor = "local" if kind == "side" \
else _default_executor()
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
@@ -3976,19 +4119,19 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part,"
" parent_task_id, coordinator_part, kind,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions,"
" agent_tools, agent_tool_approvals, audit_loop)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval, plan, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None, child_model, child_effort,
parent_task_id, None, kind, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val, agent_tools_val,
agent_tool_approvals_val, audit_loop_val),
@@ -4077,6 +4220,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
parent_ids = query.get("parent_task_id")
if parent_ids:
parent_task_id = parent_ids[0]
# 旁聊(Muse 角色对齐 P2):kind=main|side 可选,缺省 =
# 全部(老行为不变);非法值 400。
kind_f = query["kind"][0] if query.get("kind") else None
if kind_f is not None and kind_f not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
limit = None
offset = 0
if query.get("limit"):
@@ -4113,13 +4263,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
s = r["status"]
status_counts[s] = status_counts.get(s, 0) + 1
if parent_task_id:
filtered = rows
filtered = _filter_tasks_by_kind(rows, kind_f)
else:
filtered = []
for r in rows:
d = _task_to_dict(r, [], 0)
if status_f and d["status"] != status_f:
continue
if kind_f and d["kind"] != kind_f:
continue
if executor_f and (d["executor"] or "") != executor_f:
continue
if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower():
@@ -4397,6 +4549,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota_for_task(user_id, task):
return
# 协调员任务不接受后续消息(Muse 角色对齐 P1):写入侧
# 会 201 成功、消费侧没有任何读点,宁可入口 409 明拒。
# 改分工请取消后重建协调员;给分工追加指令请直接发给
# 该子任务。
if task.get("coordinator_parts"):
self._send_json(409, {
"error": "coordinator tasks do not accept"
" follow-up messages"})
return
text, mode, body_err = _validate_followup_body(body)
if body_err is not None:
self._send_json(body_err[0], body_err[1])
@@ -5879,6 +6040,12 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
(http_code, payload),不直接 _send_json;配额检查
由调用方在调本方法前做。"""
task_id = task["id"]
# 协调员任务不接受后续消息(Muse 角色对齐 P1):与
# _create_followup 同款入口 409,见该处注释。
if task.get("coordinator_parts"):
return 409, {
"error": "coordinator tasks do not accept"
" follow-up messages"}
goal_target = _parse_goal_command(text)
# /goal 前置判定见 _classify_goal_followup(纯计算,与
# _create_followup 共用,但只用于 goal_target 非空时:
@@ -6401,6 +6568,14 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
# 旁聊(P2 G5):旁聊没有 worktree,/worktree 按
# “没有本地 worktree”口径回 404,不回 exists=false
# 的摘要(/files 与 /tree 同款分支已在
# _task_worktree_for_files 里回 404)。
if task.get("kind") == "side":
self._send_json(404, {
"error": "no local worktree for this task"})
return
summary = self._worktree_summary(task)
self._send_json(200, dict(summary, task_id=task_id))
@@ -6696,6 +6871,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota(user_id):
return
# 旁聊(P2 G6):旁聊无 worktree/分支,分叉 thread
# 历史的变体不在首版,直接 409,不等 worker/host 层
# 以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to fork"})
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
@@ -6944,6 +7126,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,合并门禁的五项
# 证据(CI/目录/契约/批准/评审)无一有落点,直接 409,
# 不进门禁检查。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to merge"})
return
_merge_ok, _merge_reason = _merge_state_decision(
task["status"])
if not _merge_ok:
@@ -8777,6 +8966,12 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,建 PR 无分支可推,
# 直接 409,不等 host 层以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch for a PR"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",
@@ -9194,6 +9389,16 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
参数也永远返回 200 + 报告,只是多一个判定字段。
生产代码绝不读 git、不起 subprocess 查版本:main 头
只能由调用方传入,端点本身不探测版本控制状态。
调用方契约(gapfill2 H4,只写文档、不改行为):
current_main_head 由调用方提供,接受任意字符串、原样
使用,不做任何格式校验(不要求形如 commit hash,不
校验长度、字符集、前缀);端点不验证该值是否真实、
是否可信、是否确为 main 头——可信 main 头的来源仍是
调用方责任。缺失(未传该查询参数)与空串一律按未传入
处理:端点不调用判定、不附加 baseline_staleness 字段,
响应与以前完全一致。传入非空值后,端点永远返回 200 +
报告、只附加判定字段:判定为 stale 也不挡查询,绝不改
写报告的 overall / overall_pass。
权限:与 /metrics 同款,任意项目成员即可读(不要求 owner),
但本端点不统计任何项目数据、不写库、不写账本、不改链头、
+89 -17
View File
@@ -367,16 +367,22 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
角色模板的权威来源是本文件末尾的附录。附录随 SKILL.md 一起播种,
所以租户任务内一定能看到。`templates/roles/` 下的文件是编辑用的源文件,
发布前必须核对附录和源文件一致,不一致时以附录为准并先修源文件。
协调员派生子任务时,必须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义。
协调员派生子任务时,主聊人工派生须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义;平台协调员任务的分工 prompt
即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己
写进分工 prompt。
只写一句“按 software-engineering skill 做”是不够的,
子任务不一定会读到本技能。
术语:本技能说的“线程”就是平台的“任务(task)”;“协调员任务”就是
`coordinator_parts` 非空的任务;“分工”就是 `coordinator_parts` 的一项;
“子任务”就是 `parent_task_id` 指向协调员的任务;`forked_from` 链与
`parent_task_id` 是两条血缘,不要混用。
## 协调员派生子任务的必填字段
每个子任务提示词必须包含,缺一项就不派生:
1. 角色模板全文(来自本文件附录,租户任务内可直接引用)。
1. 角色模板内容(主聊人工派生时贴模板全文,来自本文件附录,租户任务内可直接引用;平台协调员任务的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己写进分工 prompt)。
2. 本阶段输入:PRD 哪个版本、契约哪个版本、设计稿哪个版本。
3. 拥有目录:只能写这一个目录;只读目录:契约、他人目录。
4. 分支名:`feat/swe/<阶段>-<角色>-<简称>`,例如 `feat/swe/client-web`、
@@ -400,6 +406,8 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
| 完成定义 | 产出文件清单 + 必须跑过的自测命令 + 必须提交的证据 |
| TODO-UPDATE 要求 | 结尾报告必须带 TODO-UPDATE 小节,格式见“待办机制” |
校验表里的拥有目录 / 分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,平台只校验分工字段(`coordinator.validate_parts`),不校验、不解析这些项;“缺一项就不派生”在平台侧不成立——平台只校验 `validate_parts` 的字段。
子任务精简包(裁剪规则写死,不是“尽量精简”):只给本端相关的
契约章节、设计页、PRD 故事和冻结决策摘要,绝不把 PRD、契约、
设计全文复制给每个线程。精简包由四部分组成,缺一部分就补齐再发:
@@ -442,13 +450,19 @@ TODO-UPDATE
- 冻结前:架构线程可以自由改契约,改完要通知协调员更新版本号。
- 冻结物(G2 必须齐):API 契约、数据模型、agent 协议、
任务状态机、错误 taxonomy、技术栈表、每端能力矩阵。
(7 项齐全由协调员人工核对;平台只校验契约版本号与冻结标志,
不校验冻结物清单、不发回归任务。)
任务状态机至少包含:创建、排队、运行中、等审批、已完成、
已取消、失败、超时;错误 taxonomy 至少区分:模型失败、工具失败、
用户取消、超时、权限拒绝、配额超限。同一错误在各端不得换一个名字。
- 冻结后:任何线程想改契约,必须给协调员发任务间消息,写清:
改什么、为什么、影响哪些客户端、是否向后兼容。
(收件方是扮演协调员的模型/人工;平台协调员任务本身暂不支持
后续指令,见“任务间问答默认策略与决策记录”一节。)
- 协调员批准后:契约版本号加一(v1 -> v2),架构线程改契约,
受影响的客户端各收到一条变更通知和一个回归任务。
(版本号加一与回归任务派发是人工/协调员操作,平台不执行;
平台只校验版本号与冻结标志。)
- 协调员不批准:提议线程按旧契约做,差异记入已知问题,不悄悄分叉。
## 任务间问答默认策略与决策记录(v0.3 新增)
@@ -458,6 +472,7 @@ TODO-UPDATE
协调员在下一轮答复:确认假设、纠正假设(发回归任务)、或升级为契约变更;
答复本身也要记入决策记录。等答复期间线程不阻塞在原地,先做契约内
无争议的部分。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息会被入口直接拒绝(409),因为平台循环内没有消费点。替代方案见附录 00“任务间问答与决策记录”一节。
- 决策记录落盘路径:`docs/decisions.md`,由协调员维护,子线程只在报告里
提议、不直接改。只追加不改写历史:一条决策写清日期、议题、决定、理由、
影响端;推翻旧决策时追加新条写明“取代 D-n”,绝不改旧条原文、不删旧条。
@@ -531,6 +546,8 @@ tests/ # QA 线程专属,含跨端一致性用例
(只改拥有目录,契约变更经批准除外;检查命令与输出文件清单必须
记入合并记录,见“目录与分支规则”);③ 契约版本是冻结版;
④ 协调员评审通过;⑤ 无未解决评审意见。有一项不过就不合并。
协调员批准与契约冻结版本是每次调合并门禁时提交的证据,平台不持久化;
谎报由审计与 PR/Checks 只读核对事后发现。
- CI 怎么查:用只读方式查,例如本平台的 GET /tasks/{id}/pr/checks。
它是只读诊断,绝不重跑检查、不批准、不合并;还没建 PR 时它会 409
(查无可查),这时先补 PR,不要把它当成 CI 通过。
@@ -538,9 +555,8 @@ tests/ # QA 线程专属,含跨端一致性用例
要求 PR、要求 CI 通过、禁止直接推。目标仓库没开保护时,
协调员要在决策记录里写明风险并去开通,不能假装已保护,
不能靠“大家自觉”代替保护。
- 合并门禁(2026-10-30 补,代码在 cloud/merge_gate.py,服务端
POST /tasks/{id}/merge 已接线;2026-10-31 硬门禁轨加
cloud/merge_gate_hard.py):保护状态只能由调用方或环境变量
- 合并门禁(服务端 POST /tasks/{id}/merge 已接线,门禁实现在
cloud/merge_gate_hard.py,默认 hard):保护状态只能由调用方或环境变量
CLOUD_BRANCH_PROTECTION 显式声明,代码不去 GitHub 核实、不声称
已保护。模式只由环境变量 CLOUD_MERGE_GATE_MODE 控制,默认
hard:上面五项全查,缺一 409 且任务状态不变,并写
@@ -624,8 +640,10 @@ tests/ # QA 线程专属,含跨端一致性用例
- G5 缺陷回路:缺陷回对应客户端或架构线程修,修完回 G5 回归,
回归范围按契约变更影响面定,不是每次都全量重测。
- 成本控制:分阶段本身就是成本控制。G4a 先验证,G4b / G4c 复用已验证物;
协调员、产品、架构用较强模型,重复性强的客户端实现和 QA 可用
较经济的模型,具体以项目实际可用模型和配额为准;
模型与强度按平台字段走:分工不写 model/effort 时,继承协调员任务的
child_model/child_effort 或自身模型;“协调员、产品、架构用较强模型,
重复性强的客户端实现和 QA 可用较经济的模型”只是建议,具体以项目
实际可用模型和配额为准;
配额将满时先停新线程,不先透支再解释。
@@ -750,6 +768,8 @@ tests/ # QA 线程专属,含跨端一致性用例
出一份单独签字的审计结论。谁写、谁测、谁审、谁执行、谁签字
五职分开:写是开发线程,测是 QA 线程,审是审计线程,
执行合并/发布是人或宿主操作,签字是审计签字人。
- 平台的 `audit_loop` 是协调员任务的自动重排循环,不是独立审计线程;
独立审计仍须另开线程,两者阻塞关系均为“与”,不可互换。
- 同一人不兼两职,可核查:审计记录的签字人字段必须与任务执行人
字段、协调员批准人字段、QA 测试人字段都不同,有一项相同,
审计结论本身无效,按审计 fail 处理。代码里这就是
@@ -1263,6 +1283,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -1281,12 +1326,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -1298,7 +1348,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -1327,6 +1378,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -1357,6 +1411,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -1376,7 +1435,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -1387,6 +1451,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -1401,13 +1471,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
@@ -14,6 +14,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -32,12 +57,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -49,7 +79,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -78,6 +109,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -108,6 +142,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -127,7 +166,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -138,6 +182,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -152,13 +202,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
+21 -2
View File
@@ -149,6 +149,19 @@ def _table_exists(db_path, table):
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):
"""Fake CLI: dispatch/run always succeed, host.py-style JSON."""
if "dispatch" in cmd:
@@ -466,7 +479,11 @@ class TestWorkerHook(unittest.TestCase):
self.assertFalse(worker.attempt_ledger_hook)
result = worker.run_one()
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(
_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={})
result = worker.run_one()
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):
# Established pattern (test_worker
+1 -1
View File
@@ -288,7 +288,7 @@ class CoordGateLookupFailureTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def test_direct_lookup_raises_instead_of_empty_set(self):
+4 -4
View File
@@ -245,7 +245,7 @@ class PersistedWhitespaceModelTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _policy(self):
@@ -308,7 +308,7 @@ class ExplicitNonSub2apiGateTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _policy(self, mode, capabilities):
@@ -446,7 +446,7 @@ class CoordGateAttributionTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _merge(self, child_error, audit_rows=()):
@@ -555,7 +555,7 @@ class GateResultsCheckedTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def test_direct_call_checked_subset_and_legacy(self):
+3 -3
View File
@@ -197,7 +197,7 @@ class _RunOneCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
@@ -272,7 +272,7 @@ class I1MarkerTestCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _merge(self, child_error, audit_rows=()):
@@ -343,7 +343,7 @@ class _WireCase(unittest.TestCase):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _worker(self, **kwargs):
@@ -43,10 +43,25 @@ from pathlib import Path
_PATTERN = re.compile(r"INSERT\s+INTO\s+audit_log", re.IGNORECASE)
# Fixture files that deliberately insert audit_log rows into
# throwaway test databases (see module docstring).
# 演练注:合入时 main 侧 9 个审计测试文件同样只往临时库插
# audit_log 行(TemporaryDirectory/control.db,逐文件核过),
# 与已列 3 个属同类 fixture,按名补入——生产侧口径不变,
# 仍只允许 cloud/auth.py。若用户要更严口径(测试也一律走
# AuthStore.audit),那是另起一批改 9 个文件的活,本演练
# 不悄悄替用户定(见演练报告 FLAG)。
_FIXTURE_FILES = {
"cloud/tests/test_tamper_ledger.py",
"cloud/tests/test_chat_history.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",
}
+80
View File
@@ -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()
+63 -9
View File
@@ -1,8 +1,14 @@
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
端点负责(已有单测覆盖)。
/console 是原生 UI 入口(见 docs/codex-native-ui-alignment.md §3):
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 os
@@ -10,7 +16,9 @@ import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
try:
from cloud.auth import AuthStore
@@ -32,6 +40,13 @@ class ConsoleTestCase(unittest.TestCase):
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
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):
self.server.shutdown()
@@ -56,10 +71,47 @@ class ConsoleTestCase(unittest.TestCase):
finally:
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")
self.assertEqual(status, 200)
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")
self.assertIn("Codex 命令台", text)
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
@@ -95,9 +147,10 @@ class ConsoleTestCase(unittest.TestCase):
def test_console_does_not_leak_data_without_key(self):
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
_status, _headers, body = self._get("/console")
text = body.decode("utf-8")
self.assertNotIn(self.owner_id, text)
for path in ("/console", "/console-classic"):
_status, _headers, body = self._get(path)
text = body.decode("utf-8")
self.assertNotIn(self.owner_id, text)
def test_console_init_loads_relay_models(self):
# 回访用户 key 已在 localStorage 里:初始化序列必须含
@@ -106,8 +159,9 @@ class ConsoleTestCase(unittest.TestCase):
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
# 有 key 时调 loadRelayModels;断言意图不变,只换成
# React 构建产物里实际存在的标记。
_status, _headers, body = self._get("/console")
# React 构建产物里实际存在的标记。断言对象是旧控制台壳,
# 所以走 /console-classic(/console 只在回落时才出这个壳)。
_status, _headers, body = self._get("/console-classic")
text = body.decode("utf-8")
self.assertIn("loadRelayModels", text)
self.assertIn("/relay/models", text)
+116
View File
@@ -238,6 +238,25 @@ class WorkerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(backend["effort"], "high")
self.assertEqual(backend["status"], "queued")
def test_sync_children_never_carry_coordinator_parts(self):
# 纵深防御(Muse 角色对齐 P1):_coord_sync_children 建出
# 的子任务行 coordinator_parts 恒为 NULL,子任务永为普通任务。
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
worker = self._worker()
task = worker.get_task("coord")
worker._coord_sync_children(task, task["coordinator_parts"])
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT id, coordinator_parts FROM tasks"
" WHERE parent_task_id = ?",
("coord",)).fetchall()
finally:
conn.close()
self.assertEqual(len(rows), 2)
for task_id, parts in rows:
self.assertIsNone(parts, task_id)
def test_sync_requeues_failed_keeps_completed(self):
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
_insert_task(self.db_path, "c-old", status="failed",
@@ -469,6 +488,103 @@ class ServerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def test_create_child_coordinator_under_plain_parent_ok(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, child = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], parent["id"])
self.assertEqual(len(child["coordinator_parts"]), 2)
def test_create_coordinator_under_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 400)
self.assertIn("coordinator task cannot be created under a"
" coordinator task", body["error"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 1)
def test_create_parent_task_checks(self):
status, _body = self._create_coord(
extra={"parent_task_id": "no-such-task"})
self.assertEqual(status, 404)
status, other = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, parent = self._create_task(
extra={"project_id": other["id"]})
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 400)
status, body = self._create_coord(
extra={"parent_task_id": 123})
self.assertEqual(status, 400)
def test_create_lineage_without_coordinator_ok(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, child = self._create_task(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], coord["id"])
def test_create_idempotent_replay_keeps_single_task(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
extra = {"idempotency_key": "idem-parent",
"parent_task_id": parent["id"]}
status, first = self._create_task(extra=extra)
self.assertEqual(status, 201)
status, second = self._create_task(extra=extra)
self.assertEqual(status, 200)
self.assertEqual(second["id"], first["id"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(len(data["tasks"]), 2)
def test_followup_to_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/followups",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/thread/message",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
def test_followup_to_plain_task_ok(self):
status, task = self._create_task()
self.assertEqual(status, 201)
status, _body = self._req(
"POST", "/tasks/" + task["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + task["id"] + "/thread/message",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
if __name__ == "__main__":
unittest.main()
+163
View File
@@ -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()
+61
View File
@@ -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()
+200
View File
@@ -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()
+610
View File
@@ -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()
+27
View File
@@ -504,6 +504,33 @@ class HostLoopTestCase(unittest.TestCase):
self.assertIn("new goal", 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__":
unittest.main()
+10 -3
View File
@@ -144,7 +144,14 @@ class TestReexports(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 = {}
dups = []
classes = [CloudControlPlaneHandler]
@@ -159,9 +166,9 @@ class TestMethodAccounting(unittest.TestCase):
dups.append((seen[name], cls.__name__, name))
seen[name] = cls.__name__
self.assertEqual(dups, [])
self.assertEqual(total, 244)
self.assertEqual(total, 249)
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
244 - 94)
249 - 94)
if __name__ == "__main__":
+5 -2
View File
@@ -48,11 +48,14 @@ EXPECTED_METHODS = {
("server.py", "CloudControlPlaneHandler._dispatch_task_route"),
("server.py", "CloudControlPlaneHandler._dispatch"),
("server.py", "CloudControlPlaneHandler._dispatch_project"),
# 演练注:main 侧 _routing_shadow(233 行)为第 18 个长方法;
# 另 2 个 main 侧新增方法均 <=100 行,不进清单。
("server.py", "CloudControlPlaneHandler._routing_shadow"),
}
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]
methods = set()
module_funcs = set()
@@ -75,7 +78,7 @@ class LongFuncInventoryTestCase(unittest.TestCase):
lines = node.end_lineno - node.lineno + 1
if lines > 100:
module_funcs.add((path.name, node.name))
self.assertEqual(len(EXPECTED_METHODS), 17)
self.assertEqual(len(EXPECTED_METHODS), 18)
self.assertIn(
("handler_fullauto.py", "FullAutoMixin._full_auto_execute"),
EXPECTED_METHODS)
+2 -1
View File
@@ -185,7 +185,8 @@ class LongtaskAutoResumeTestCase(unittest.TestCase):
captured = {}
def fake_execute(task, kind="dispatch", deadline_seconds=None,
seed_host_dir=None, quota_check=None):
seed_host_dir=None, no_worktree=False,
quota_check=None):
captured["deadline"] = deadline_seconds
return {"status": "completed", "stdout": "", "stderr": ""}
+42
View File
@@ -240,6 +240,48 @@ class TestMigrateNewTables(unittest.TestCase):
"result_text", "error_text", "created_at"):
self.assertIn(col, attempts_inserts[0])
def test_kind_column_backfilled_when_missing(self):
# P2:PG 侧 tasks 尚无 kind 列时,迁移必须先 ALTER 补列
# 再直插,且插入列清单里含 kind;kind 已存在时不重复
# ALTER(与 agent_* 列同款 backfill 惯例)。
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertTrue(
any("tasks" in a and "kind" in a for a in fake.alters),
"未发出 tasks.kind 补列 ALTER:%s" % fake.alters)
# 注:插入列清单取自源 SQLite 表的列,夹具源表无 kind
# 列故清单里无 kind;生产源库(init_db 建表)有 kind,
# 迁移时按列原样搬运(与 agent_* 列同款)。
def test_kind_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id", "kind"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertFalse(
any("tasks" in a and "kind" in a for a in fake.alters),
"kind 已存在却仍发补列 ALTER:%s" % fake.alters)
def test_sender_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
+75
View File
@@ -28,7 +28,9 @@ import sqlite3
import sys
import tempfile
import time
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
@@ -209,6 +211,18 @@ class SqliteUpsertTestCase(unittest.TestCase):
conn.close()
def _concurrent_init_db_worker(barrier, queue, wid, env):
"""并发启动子进程:与其它进程同时跑 server.init_db。"""
try:
os.environ.update(env)
barrier.wait(timeout=60)
from cloud.server import init_db
init_db(":memory:")
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class PgInitFixTestCase(unittest.TestCase):
"""真实 PG 测试:无 DSN/驱动时全部 skip。"""
@@ -331,6 +345,67 @@ class PgInitFixTestCase(unittest.TestCase):
finally:
conn.close()
def test_concurrent_init_db_no_unique_violation(self):
"""并发启动回归:4 进程同时 init_db,须全部成功。
覆盖整条启动路径(init_postgres_schema + _ensure_*):
_ensure_* 建的 task_continue_messages / user_gateway_keys /
task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA
里,只锁 schema 函数不够;init_db 持会话级 advisory 锁
覆盖全程。空库上跑,未修复时第 1 轮即 3/4 进程报
UniqueViolation(pg_type_typname_nsp_index)。
"""
env = _pg_env()
if env is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN 或未装驱动,"
"跳过真实 PG 测试")
from cloud.db import PostgresDatabase
db = PostgresDatabase(
host=env["CLOUD_DB_HOST"], port=int(env["CLOUD_DB_PORT"]),
dbname=env["CLOUD_DB_NAME"], user=env["CLOUD_DB_USER"],
password=env["CLOUD_DB_PASSWORD"])
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_init_db_worker,
args=(barrier, queue, i, env))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=180)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发 init_db 有进程失败:%s" % (failures,))
# 初始化完整:schema 表与 _ensure_* 表都在。
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in ("users", "projects", "tasks",
"task_continue_messages", "user_gateway_keys",
"task_attempts", "task_audit_loop_state"):
self.assertIn(table, names)
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+99
View File
@@ -17,7 +17,9 @@
import os
import socket
import sys
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from unittest import mock
from urllib.parse import unquote, urlparse
@@ -250,6 +252,25 @@ class AdapterFakeDriverTestCase(unittest.TestCase):
self.assertIn("%s", 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):
import sqlite3
fake = _FakeDriver(_FakeCursor(fail_sequence=[_FakeUnique("dup")]))
@@ -367,3 +388,81 @@ class RealPostgresSchemaTestCase(unittest.TestCase):
except Exception:
pass
conn.close()
def _concurrent_schema_worker(barrier, queue, wid, params):
"""并发初始化子进程:与其它进程同时跑 init_postgres_schema。"""
try:
host, port, dbname, user, password = params
barrier.wait(timeout=60)
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
init_postgres_schema(conn)
finally:
conn.close()
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class RealPostgresConcurrentInitTestCase(unittest.TestCase):
"""真实 PG 并发初始化:无 DSN 或不可达时 skip。
回归:启动竞态(37ba687 与 7983567 两次部署均撞上)——
cloud-server 与 worker 并发 init 时,CREATE TABLE / CREATE
INDEX 的 IF NOT EXISTS 检查与建对象不是原子的,并发建同名
对象会撞 pg_type_typname_nsp_index(建表)或
pg_class_relname_nsp_index(建索引)的 UniqueViolation。
修法是 schema 初始化持 advisory 锁串行化;本用例在空库上
4 进程同时初始化,必须全部成功、表齐全、无一例异常外泄。
"""
def test_concurrent_init_postgres_schema_no_unique_violation(self):
params = _real_pg_params()
if params is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN,跳过真实 PG 测试")
if not _has_real_driver():
self.skipTest("未装 psycopg 驱动,跳过真实 PG 测试")
host, port, dbname, user, password = params
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_schema_worker,
args=(barrier, queue, i, params))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=120)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发初始化有进程失败:%s" % (failures,))
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in SchemaStatementTestCase.EXPECTED_TABLES:
self.assertIn(table, names)
finally:
conn.close()
+730
View File
@@ -0,0 +1,730 @@
"""旁聊任务类型(Muse 角色对齐 P2)单测:创建/列表/执行分流/回写/恢复。
不跑真实模型回合、不联网:服务端侧本地临时端口 + 临时 SQLite;
worker/sandbox 侧注入假 runner 录命令;host.py dispatch 用假
server + 临时 HERE。
"""
import http.client
import json
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
try:
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
def _recording(calls):
def fake(cmd):
calls.append(list(cmd))
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
return fake
def _insert_task(db_path, task_id, **cols):
base = {"project_id": "p1", "prompt": "do work", "status": "queued",
"created_by": "tester"}
base.update(cols)
keys = ["id"] + list(base.keys())
vals = [task_id] + list(base.values())
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(%s) VALUES(%s)"
% (", ".join(keys), ", ".join("?" * len(keys))), vals)
conn.commit()
finally:
conn.close()
def _task_row(db_path, task_id, columns="id, status, kind, executor,"
" parent_task_id, coordinator_part"):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT %s FROM tasks WHERE id = ?" % columns,
(task_id,)).fetchone()
finally:
conn.close()
return row
def _followups(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT id, body, mode, status, sender_task_id"
" FROM task_followups WHERE task_id = ?"
" ORDER BY created_at, id", (task_id,)).fetchall()
finally:
conn.close()
return [{"id": r[0], "body": r[1], "mode": r[2], "status": r[3],
"sender_task_id": r[4]} for r in rows]
def _set_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class _ServerBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def _create_side(self, parent_id, extra=None):
body = {"project_id": self.project["id"], "prompt": "旁聊一下",
"kind": "side", "parent_task_id": parent_id}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
class ServerSideThreadTestCase(_ServerBase):
def test_create_side_ok_defaults_local(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201, side)
self.assertEqual(side["kind"], "side")
self.assertEqual(side["parent_task_id"], parent["id"])
self.assertEqual(side["executor"], "local")
status, fetched = self._req(
"GET", "/tasks/" + side["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["kind"], "side")
def test_create_side_validation(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
# 缺 parent_task_id
status, body = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x",
"kind": "side"}, self.owner_key)
self.assertEqual(status, 400)
# 父不存在
status, _body = self._create_side("no-such-parent")
self.assertEqual(status, 404)
# kind 非法值
status, _body = self._create_task(
{"kind": "weird", "parent_task_id": parent["id"]})
self.assertEqual(status, 400)
# 带 coordinator_parts
status, _body = self._create_side(parent["id"], {
"coordinator_parts": [{"name": "a", "prompt": "x"}]})
self.assertEqual(status, 400)
# 带 upload_id / environment_id
status, _body = self._create_side(
parent["id"], {"upload_id": "up-nonexistent"})
self.assertEqual(status, 400)
status, _body = self._create_side(
parent["id"], {"environment_id": "env-nonexistent"})
self.assertEqual(status, 400)
def test_create_side_cross_project_rejected(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, other_project = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, other_task = self._req(
"POST", "/tasks",
{"project_id": other_project["id"], "prompt": "别处的事"},
self.owner_key)
self.assertEqual(status, 201)
status, _body = self._create_side(other_task["id"])
self.assertEqual(status, 400)
def test_create_side_under_coordinator_rejected(self):
status, coord = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "协调干活",
"coordinator_parts": [{"name": "a", "prompt": "做 a"}]},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._create_side(coord["id"])
self.assertEqual(status, 400)
self.assertIn("coordinator", body["error"])
def test_create_side_explicit_docker_kept(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(
parent["id"], {"executor": "docker"})
self.assertEqual(status, 201, side)
self.assertEqual(side["executor"], "docker")
self.assertEqual(side["kind"], "side")
def test_create_main_defaults(self):
status, task = self._create_task()
self.assertEqual(status, 201)
self.assertEqual(task["kind"], "main")
status, task2 = self._create_task({"kind": "main"})
self.assertEqual(status, 201)
self.assertEqual(task2["kind"], "main")
def test_list_kind_filter(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
_s, side1 = self._create_side(parent["id"])
_s, side2 = self._create_side(parent["id"])
_s, main_child = self._create_task(
{"parent_task_id": parent["id"]})
self.assertEqual(main_child["kind"], "main")
pid = self.project["id"]
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"] + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=main",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([parent["id"], main_child["id"]]))
status, _body = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=bogus",
key=self.owner_key)
self.assertEqual(status, 400)
def test_side_files_tree_worktree_404(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
for suffix in ("/files", "/tree", "/worktree"):
status, body = self._req(
"GET", "/tasks/" + side["id"] + suffix,
key=self.owner_key)
self.assertEqual(status, 404, suffix)
self.assertIn("no local worktree", body["error"])
def test_side_fork_merge_pr_409(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/fork",
{"message": "分一个"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/merge", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/pr", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
def test_side_continue_and_followups(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, side["id"], "completed")
status, task = self._req(
"POST", "/tasks/" + side["id"] + "/continue",
{"message": "再聊聊"}, self.owner_key)
self.assertEqual(status, 200, task)
self.assertEqual(task["status"], "queued")
self.assertEqual(task["resume_message"], "再聊聊")
status, _body = self._req(
"POST", "/tasks/" + side["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
def test_side_writeback_visible_on_terminal_parent(self):
# 父终态时回写只作记录:followups 可见,父不复活。
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, parent["id"], "completed")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, sender_task_id,"
" created_at, updated_at)"
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, ?, ?, ?)",
("fu-1", parent["id"], self.project["id"],
"[旁聊结论] 查到了", "tester", side["id"],
time.time(), time.time()))
conn.commit()
finally:
conn.close()
status, data = self._req(
"GET", "/tasks/" + parent["id"] + "/followups",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["followups"]), 1)
self.assertEqual(data["followups"][0]["sender_task_id"],
side["id"])
status, fetched = self._req(
"GET", "/tasks/" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["status"], "completed")
class _WorkerBase(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.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, calls):
return Worker(self.db_path, "/fake/host.py",
runner=_recording(calls), env=self.env)
class WorkerSideThreadTestCase(_WorkerBase):
def test_run_one_side_no_repo_no_worktree_and_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("side1"))
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertNotIn("--repo", cmd)
self.assertNotIn("--seed-dir", cmd)
row = _task_row(self.db_path, "side1")
self.assertEqual(row[1], "completed")
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["sender_task_id"], "side1")
self.assertEqual(fus[0]["mode"], "queued")
self.assertEqual(fus[0]["status"], "pending")
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
def test_run_attempt_side_with_repo_forces_plan(self):
# worker 层直接调 _run_attempt:有 repo 的旁聊强制 plan=1,
# argv 同时含 --no-worktree 与 --sandbox read-only。
calls = []
worker = self._worker(calls)
task = {"id": "side-repo", "prompt": "聊聊 repo",
"project_id": "p1", "created_by": "tester",
"kind": "side", "executor": "local",
"repo": "/tmp/fake-repo",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertIn("--sandbox", cmd)
self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
self.assertIn("--repo", cmd)
self.assertEqual(cmd[cmd.index("--repo") + 1], "/tmp/fake-repo")
def test_run_attempt_main_task_untouched(self):
calls = []
worker = self._worker(calls)
task = {"id": "main1", "prompt": "做事",
"project_id": "p1", "created_by": "tester",
"executor": "local",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertNotIn("--no-worktree", dispatch_cmds[0])
self.assertNotIn("--sandbox", dispatch_cmds[0])
def test_writeback_direct_shapes(self):
_insert_task(self.db_path, "parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
task = {"id": "side1", "project_id": "p1",
"created_by": "tester", "kind": "side",
"parent_task_id": "parent1"}
self.assertTrue(worker._writeback_side_thread(
task, "completed", "结论文本"))
self.assertTrue(worker._writeback_side_thread(
task, "failed", "出错了"))
self.assertTrue(worker._writeback_side_thread(
task, "interrupted", ""))
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 3)
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
self.assertTrue(fus[1]["body"].startswith("[旁聊失败]"))
self.assertTrue(fus[2]["body"].startswith("[旁聊中断]"))
# 超长正文截断到 2000 字 + 截断标记。
self.assertTrue(worker._writeback_side_thread(
task, "completed", "x" * 3000))
fus = _followups(self.db_path, "parent1")
self.assertLessEqual(len(fus[3]["body"]), 2010)
self.assertIn("已截断", fus[3]["body"])
# 无父任务不写。
orphan = dict(task, parent_task_id=None)
self.assertFalse(worker._writeback_side_thread(
orphan, "completed", "x"))
def test_parent_running_drain_consumes_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
self.assertTrue(worker._writeback_side_thread(
{"id": "side1", "project_id": "p1", "created_by": "tester",
"kind": "side", "parent_task_id": "parent1"},
"completed", "结论文本"))
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("parent1"))
self.assertEqual(result["status"], "completed")
# 首轮 + 排空一轮 = 两次 dispatch。
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 2)
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["status"], "done")
class MarkerSideThreadTestCase(_WorkerBase):
def _deliver(self, parent_id, creates):
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
worker._deliver_marker_tools(worker.get_task(parent_id), {
"status": "completed", "task_creates": creates,
"task_polls": [], "wait_threads": [], "task_cancels": [],
"task_retries": []})
def test_extract_kind(self):
text = (
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"side\"}\n```\n"
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"weird\"}\n```\n"
"```task-create\n{\"prompt\": \"做子事\"}\n```\n")
creates = extract_task_creates(text)
self.assertEqual(len(creates), 2)
self.assertEqual(creates[0]["kind"], "side")
self.assertIsNone(creates[1]["kind"])
def test_marker_child_inherits_side(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "子旁聊", "prompt": "继续聊",
"model": None, "effort": None, "executor": None,
"kind": None}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind, executor FROM tasks"
" WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "side")
self.assertEqual(child[1], "local")
def test_marker_child_explicit_kind_wins(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "转主聊", "prompt": "正经做",
"model": None, "effort": None, "executor": None,
"kind": "main"}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind FROM tasks WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "main")
def test_marker_counts_exclude_side(self):
_insert_task(self.db_path, "main-parent", executor="local")
_insert_task(self.db_path, "side-kid", kind="side",
parent_task_id="main-parent", executor="local")
_insert_task(self.db_path, "main-kid",
parent_task_id="main-parent", executor="local")
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
self.assertEqual(
count_direct_spawn_children(conn, "main-parent"), 1)
self.assertEqual(
parent_spawn_generation(conn, "side-kid"), 0)
self.assertEqual(
parent_spawn_generation(conn, "main-kid"), 1)
finally:
conn.close()
class MigrateSideThreadTestCase(unittest.TestCase):
def test_migrate_adds_kind_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "old.db")
conn = sqlite3.connect(db_path)
try:
conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY,"
" project_id TEXT, prompt TEXT, status TEXT)")
conn.commit()
migrate_tasks_table(conn)
cols = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertIn("kind", cols)
# 第二次是 no-op,不炸、不重复。
migrate_tasks_table(conn)
cols2 = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertEqual(cols, cols2)
finally:
conn.close()
class SandboxSideThreadTestCase(unittest.TestCase):
def test_local_dispatch_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_local_goal_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.goal_task({"id": "t1", "prompt": "目标"}, no_worktree=True)
self.assertIn("--no-worktree", calls[0])
self.assertIn("goal", calls[0])
def test_local_plan_passthrough_readonly(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi", "plan": 1})
self.assertIn("--sandbox", calls[0])
self.assertEqual(calls[0][calls[0].index("--sandbox") + 1],
"read-only")
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--sandbox", calls[0])
def test_local_popen_no_worktree_flag(self):
calls = []
class FakePopen(object):
def __init__(self, cmd, **kwargs):
calls.append(list(cmd))
self.returncode = 0
def poll(self):
return 0
def communicate(self, timeout=None):
return "", ""
ex = LocalExecutor("/fake/host.py", runner=_recording([]))
import cloud.sandbox as sandbox_mod
with mock.patch.object(sandbox_mod.subprocess, "Popen",
FakePopen):
ex.popen_task({"id": "t1", "prompt": "hi"},
kind="dispatch", no_worktree=True)
ex.popen_task({"id": "t1", "prompt": "目标"},
kind="goal", no_worktree=True)
dispatch_cmds = [c for c in calls if "dispatch" in c]
goal_cmds = [c for c in calls if "goal" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertEqual(len(goal_cmds), 1)
self.assertIn("--no-worktree", dispatch_cmds[0])
self.assertIn("--no-worktree", goal_cmds[0])
def test_docker_dispatch_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_docker_goal_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.goal_task({"id": "t1", "prompt": "目标"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
class _FakeHostServer(object):
def __init__(self):
self.created = []
def create_thread(self, cwd, model=None, sandbox="workspace-write",
approval_policy="never", instructions=None):
self.created.append((cwd, sandbox))
return "thread-1"
class HostSideThreadTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self._old_here = host_mod.HERE
host_mod.HERE = self.tmp.name
def tearDown(self):
host_mod.HERE = self._old_here
self.tmp.cleanup()
def test_dispatch_no_worktree_no_repo_uses_scratch(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-abc", "聊聊", None, None, None,
None, "never", use_worktree=False)
expected = os.path.join(self.tmp.name, "work", "side-threads",
"side-abc")
self.assertEqual(task["cwd"], expected)
self.assertTrue(os.path.isdir(expected))
self.assertFalse(os.path.exists(os.path.join(expected, ".git")))
self.assertIsNone(task["branch"])
self.assertEqual(srv.created[0][0], expected)
# 没有建 host-worktrees 目录。
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "host-worktrees")))
def test_dispatch_no_worktree_with_repo_uses_repo(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-def", "聊聊", "/tmp/some-repo",
None, None, None, "never", use_worktree=False)
self.assertEqual(task["cwd"], "/tmp/some-repo")
self.assertIsNone(task["branch"])
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "side-threads")))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -12,7 +12,7 @@ seeded file -- a dropped middle chapter (01..07) would pass CI silently.
The bidirectional assertion previously discussed on the #209 track is
unmerged, so main does not have it; this file supplies it on the main
base (skill text v0.2, 9 appendix chapters 00..08;
integrated text is v0.5, 11 appendix chapters 00..10).
integrated text is v0.5, 12 appendix chapters 00..11).
What "bidirectional" means here, concretely (all derived from SKILL.md
itself, no new semantics invented):
@@ -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()
+93 -2
View File
@@ -6,6 +6,7 @@
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
"""
import ipaddress
import os
import sys
import tempfile
@@ -15,12 +16,12 @@ from types import SimpleNamespace
from unittest import mock
try:
from cloud.sandbox import DockerExecutor
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
from cloud.server import init_db
from cloud.worker import Worker
except ImportError:
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.worker import Worker
@@ -118,6 +119,34 @@ class TenantDirsTestCase(unittest.TestCase):
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):
def test_per_call_mounts_network_and_drop_caps(self):
seen = []
@@ -165,6 +194,68 @@ class DockerTenantCommandTestCase(unittest.TestCase):
env={"XCM_API_KEY": "test-placeholder"})
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__":
unittest.main()
+13 -2
View File
@@ -223,9 +223,14 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
finally:
conn.close()
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(
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):
conn = self._sqlite(
@@ -237,9 +242,15 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
finally:
conn.close()
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(
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):
+12
View File
@@ -374,6 +374,18 @@ class GatewayTestCase(_Base):
# 2564-2565
class _BadEnv:
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")
w = Worker(self.db_path, "/fake/host.py", env=_BadEnv())
+110 -8
View File
@@ -529,6 +529,9 @@ def _task_from_row(row):
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
"parent_task_id": _col("parent_task_id"),
"coordinator_part": _col("coordinator_part"),
# 旁聊(Muse 角色对齐 P2):老行缺列/NULL 时按 "main"
# 计(与 server 侧 _task_to_dict 同款惯例)。
"kind": _col("kind") or "main",
# agent 定义 v2:老行缺列时 None = 未用定义,不炸。
"agent_name": _col("agent_name"),
"agent_version": _col("agent_version"),
@@ -3701,6 +3704,7 @@ class Worker:
def _execute_via_process(self, task, kind="dispatch",
deadline_seconds=None, seed_host_dir=None,
no_worktree=False,
quota_check=None):
"""进程模式执行一轮:启动、轮询、取消时杀进程组。
@@ -3724,10 +3728,12 @@ class Worker:
else:
# seed 只对 dispatch/goal 首轮有意义:run 续跑不传
# (文件已在 worktree 里);popen_task 内部同样只在
# dispatch/goal 命令上追加 --seed-dir。
# dispatch/goal 命令上追加 --seed-dir。no_worktree
# 同款只对 dispatch/goal 命令追加 --no-worktree。
proc = self.executor.popen_task(
task, kind=kind, seed_dir=seed_host_dir,
extra_env=gateway_env, model_provider=gateway_provider)
extra_env=gateway_env, model_provider=gateway_provider,
no_worktree=no_worktree)
self._running_set(task_id, proc=proc)
try:
self._set_child_pid(task_id, getattr(proc, "pid", None))
@@ -4592,6 +4598,50 @@ class Worker:
except Exception:
return False
def _writeback_side_thread(self, task, final, text):
"""旁聊终态回写父级(P2 W1):旁聊终局后写一条
task_followups 到父任务(sender_task_id=旁聊 id,
mode='queued',status='pending')。父 running 时排空
循环自动消费(带 _peer_prefix 前缀);父 queued 时
下次认领后首轮排空消费;父终态时行只作记录,
GET /tasks/{id}/followups 与 chat-history 可见,
不复活父任务。正文截 2000 字(与协调员合流报告的
单分工上限同款)。回写是尽力而为:失败返回 False,
不改终局、不重试、不炸执行。"""
parent_id = task.get("parent_task_id")
if not parent_id:
return False
text = (text or "").strip()
if final == "completed":
head, body = "[旁聊结论]", text or "(旁聊无结论文本)"
elif final == "failed":
head, body = "[旁聊失败]", text or "(无错误文本)"
else:
head, body = "[旁聊中断]", text or "(无文本)"
full = "%s %s" % (head, body)
if len(full) > 2000:
full = full[:2000] + "…(已截断)"
try:
conn = _db_connect(self.db_path)
try:
_ensure_followups_table(conn)
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, sender_task_id,"
" created_at, updated_at)"
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, ?,"
" ?, ?)",
(uuid.uuid4().hex, parent_id,
task["project_id"], full, task.get("created_by"),
task["id"], time.time(), time.time()),
)
conn.commit()
return True
finally:
conn.close()
except Exception:
return False
def _poll_task_snapshot(self, task, poll):
"""轮询一个目标任务:每 interval 查一次库状态,直到终态、
超时或父任务被取消。等待期间不烧模型费,但占着本 worker
@@ -4889,17 +4939,26 @@ class Worker:
child_effort = (create["effort"]
or task.get("child_effort")
or task.get("effort"))
# 旁聊继承(P2 G7):旁聊内 marker 派生
# 的子任务默认继承父 kind='side'(除非
# 块里显式指定 kind),否则子任务会以
# 主聊身份建 worktree/分支,旁聊的无
# worktree 承诺被绕过;执行器已按
# create["executor"] or 父 executor 继承。
child_kind = create.get("kind") or (
"side" if task.get("kind") == "side"
else None)
conn.execute(
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat, executor,"
" model, effort, environment_id,"
" parent_task_id, coordinator_part,"
" parent_task_id, coordinator_part, kind,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
" ?, ?)",
" ?, ?, ?)",
(child_id, task["project_id"],
create["prompt"],
task.get("priority", 0) or 0,
@@ -4910,6 +4969,7 @@ class Worker:
child_effort,
task.get("environment_id"),
task["id"],
child_kind,
task.get("child_model"),
task.get("child_effort")),
)
@@ -5624,6 +5684,22 @@ class Worker:
task_id = task["id"]
result = None
error = None
# 旁聊默认执行(P2 G4):kind='side' 且执行器是 local
# (旁聊默认,未显式要求 docker/cloud)时不建 worktree/
# 分支;显式 docker/cloud 走既有语义。显式 local 与
# 默认 local 在行上不可区分(都是 local),一律走
# 无 worktree 路径。
_side_default = task.get("kind") == "side" and \
(task.get("executor") or "local") == "local"
if _side_default and (task.get("repo") or
getattr(self.executor, "repo", None)):
# 有 repo 的旁聊:cwd 就是 repo,强制 plan=1
# (只读沙箱),不得把 repo 写脏;plan 经
# dispatch --sandbox read-only 透传到 codex。
# 只改内存里的 task dict,不写库(plan 列是任务
# 创建时的属性,旁聊的只读是执行层强制)。
task = dict(task)
task["plan"] = 1
# goal 模式:goal_mode=1(新语义,prompt 即目标)或带
# verify_command(旧语义)时走 host.py goal 循环,不走
# 普通 dispatch。goal 循环内部已多轮重试,worker 级
@@ -5633,7 +5709,10 @@ class Worker:
# 附件 seed:dispatch/goal 首轮才用;run 续跑不传(文件已
# 在 worktree 里)。cloud 执行器没有 worktree 可 seed,
# 创建时本应已拒绝,这里是兜底防线(直接失败,不静默跑)。
seed_host_dir = self._task_seed_dir(task)
# 旁聊默认执行不 seed(创建时已拒 upload_id,附件行恒为空,
# 这里跳过 _task_seed_dir 的一次查库)。
seed_host_dir = None if _side_default \
else self._task_seed_dir(task)
if seed_host_dir and isinstance(self.executor, CloudExecutor):
return ({"executor": "cloud", "task_id": task_id,
"status": "failed", "stdout": "",
@@ -5678,9 +5757,11 @@ class Worker:
_quota_watchdog_thread = None
# P0 三件其一:goal 任务起断点镜像线程(任何执行器);
# 非 goal 任务没有断点语义,不起。终局镜像在 finally 补。
# 旁聊默认执行不起镜像(旁聊的恢复走 thread 续跑,不走
# 断点镜像;旁聊 goal 同款)。
_mirror_stop = None
_mirror_thread = None
if goal_mode:
if goal_mode and not _side_default:
try:
_mirror_stop = threading.Event()
_mirror_thread = threading.Thread(
@@ -5765,6 +5846,7 @@ class Worker:
task, kind=popen_kind,
deadline_seconds=_effective_task_timeout,
seed_host_dir=seed_host_dir,
no_worktree=_side_default,
quota_check=quota_check)
combined = (result.get("stdout", "") or "") + \
(result.get("stderr", "") or "")
@@ -5899,7 +5981,8 @@ class Worker:
result = self.executor.goal_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.goal_task(task)
else:
@@ -5907,7 +5990,8 @@ class Worker:
result = self.executor.dispatch_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -8968,6 +9052,12 @@ class Worker:
# 只留档);不计数派发(平台/协调员)时为空操作。
self._record_ledger_attempt(task_id, "interrupted",
_ledger_counted)
# 旁聊终态回写(P2 W1):取消也是终态,父级应收到
# 中断说明;回写失败不挡取消返回。
if task.get("kind") == "side":
self._writeback_side_thread(
task, "interrupted",
(result or {}).get("stdout", "") or "cancelled")
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
@@ -9083,6 +9173,18 @@ class Worker:
message="audit loop wire failed: %s" % exc)
except Exception:
pass
# 旁聊终态回写(P2 W1):只在终局真正写回且未被审计
# 推回(回 queued 不是终态)时写一条 task_followups
# 到父任务;父 running 时排空消费,父 queued 时下次
# 认领后首轮排空消费,父终态时只作记录、不复活父。
# final 已是审计接线后的终局(含 completed/failed/
# interrupted;配额中断的 final 也是 interrupted)。
if (task.get("kind") == "side" and final_written
and not audit_loop_pushback
and final in ("completed", "failed", "interrupted")):
self._writeback_side_thread(
task, final,
stdout if final == "completed" else (error or ""))
# 附件暂存清理:任务终局后删 bundle 目录(重试走已存在
# 续跑,文件已在 worktree 里,不再需要暂存)。
self._cleanup_task_seed(task)
+6
View File
@@ -36,6 +36,12 @@ daemon_auto_start = false
# Skills / 插件:放 ~/.codex/skills 或用 `codex plugin` 管理,
# app-server 会在 thread 里按需加载,不用在代码里硬编码。
# 想停用某个技能又不想删文件时,用 [[skills.config]](只写一条
# 选择器:path 匹配单个 SKILL.md,name 匹配同名全部技能;多条
# 匹配时最后一条胜出):
# [[skills.config]]
# name = "software-engineering"
# enabled = false
# 项目指令:每个仓库根放 AGENTS.md,thread 启动时自动合并读取,
# 依次是 ~/.codex/AGENTS.md、仓库根、当前子目录,越往下优先级越高。
+94
View File
@@ -0,0 +1,94 @@
# 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`) |
| B19 | 旁聊会话列表(`kind='side'` 任务:`POST /tasks` 带 `kind` + `parent_task_id`、`GET /tasks?kind=side`、`GET /tasks?parent_task_id=&kind=side`) | 无。原生 UI 只有线程列表,无“主聊 + 旁聊”分组、无 kind 筛选、无“开旁聊”入口 | 原生无对应表面(旁聊会话列表仅 `/console-classic`) |
## §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 无法启动;这一步属于部署(须用户点头),不在本轮本地完成范围内。
+130
View File
@@ -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)仍待用户;本批不替用户定。
+25 -9
View File
@@ -2588,6 +2588,13 @@ def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
branch = None
if use_worktree and repo:
cwd, branch = make_worktree(repo, name)
if not use_worktree and not repo:
# 旁聊无 repo:create_thread 不接受 cwd=None
# (os.path.abspath(None) 抛错),以 per-task scratch
# 目录为 cwd(普通目录,非 git worktree,不建分支;
# host 任务名即任务 id 的安全化形式,目录按任务隔离)。
cwd = os.path.join(HERE, "work", "side-threads", name)
os.makedirs(cwd, exist_ok=True)
if seed_dir:
# 附件只能进 worktree:直接以 repo 为 cwd 时拷进去等于污染
# 仓库,必须 loud fail,绝不静默放行。
@@ -2915,21 +2922,25 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
f"目标:{goal_text}\n"
f"验收命令:{verify_text}\n"
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 注释),
# 绝不写回 goal_text——task["goal"] 在循环开始时已按原
# goal_text 落盘,备注只是本次续跑的展示前缀。
if _first_turn and resume_note:
prompt = "%s\n\n%s" % (resume_note, prompt)
_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:
result = run_task_turn(server, state, task, prompt,
model, effort, sandbox, timeout=timeout,
@@ -3255,6 +3266,10 @@ def main():
p.add_argument("--repo", default=None)
p.add_argument("--seed-dir", default=None,
help="附件暂存目录:派发时拷进 worktree 再跑 turn")
p.add_argument("--no-worktree", action="store_true",
help="不建 worktree/分支:无 repo 时以 per-task"
" scratch 目录为 cwd,有 repo 时以 repo 为"
" cwd(旁聊默认,见 dispatch 同款)")
p.add_argument("--verify", default=None,
help="验收命令;不传时走模型自决:模型在回合末尾"
"单独一行输出“目标已达成”即达标")
@@ -4204,6 +4219,7 @@ def main():
task = dispatch(srv, state, args.name, args.task, args.repo,
args.model, args.effort, args.sandbox,
args.approval_policy,
use_worktree=not args.no_worktree,
seed_dir=args.seed_dir)
save_state(args.state, state)
# 新开循环的 spawn goal 是权威的:上一轮同名循环
+657 -9
View File
@@ -2394,6 +2394,30 @@
"title": "Feedback/uploadRequest",
"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.",
"properties": {
@@ -6781,6 +6805,13 @@
],
"title": "ChatgptAccountType",
"type": "string"
},
"workspaceName": {
"description": "Display name of the selected ChatGPT workspace, when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -6810,6 +6841,23 @@
],
"title": "AmazonBedrockAccount",
"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"
},
"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": {
"properties": {
"startDate": {
@@ -6993,6 +7138,13 @@
"kind": {
"$ref": "#/definitions/v2/AdditionalContextKind"
},
"name": {
"description": "Optional name for untrusted context, used to label its tool output.",
"type": [
"string",
"null"
]
},
"value": {
"type": "string"
}
@@ -7756,6 +7908,42 @@
"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"
@@ -8502,6 +8690,8 @@
"rateLimitExceeded",
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -10216,6 +10406,16 @@
}
]
},
"otel": {
"anyOf": [
{
"$ref": "#/definitions/v2/OtelRequirements"
},
{
"type": "null"
}
]
},
"sqliteHome": {
"type": [
"string",
@@ -11807,6 +12007,85 @@
},
"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": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13455,6 +13734,13 @@
"enabled": {
"type": "boolean"
},
"environmentId": {
"description": "The owning execution environment, if this hook came from its requirements.",
"type": [
"string",
"null"
]
},
"eventName": {
"$ref": "#/definitions/v2/HookEventName"
},
@@ -13622,7 +13908,7 @@
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"startedAt": {
"format": "int64",
@@ -13743,11 +14029,18 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"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": {
"type": "string"
},
"type": "array"
},
"threadId": {
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
"type": [
"string",
"null"
]
}
},
"title": "HooksListParams",
@@ -15818,6 +16111,81 @@
"title": "ModelProviderCapabilitiesReadResponse",
"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": {
"enum": [
"highRiskCyberActivity"
@@ -16116,7 +16484,7 @@
"additionalProperties": {
"$ref": "#/definitions/v2/NetworkDomainPermission"
},
"description": "Canonical network permission map for `experimental_network`.",
"description": "Canonical managed network permission map.",
"type": [
"object",
"null"
@@ -16143,6 +16511,16 @@
"null"
]
},
"reviewDomains": {
"description": "Host patterns routed to network approval before ordinary allow rules.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"socksPort": {
"format": "uint16",
"minimum": 0.0,
@@ -16155,7 +16533,7 @@
"additionalProperties": {
"$ref": "#/definitions/v2/NetworkUnixSocketPermission"
},
"description": "Canonical unix socket permission map for `experimental_network`.",
"description": "Canonical managed unix socket permission map.",
"type": [
"object",
"null"
@@ -16229,6 +16607,232 @@
],
"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": {
"properties": {
"effectiveValue": true,
@@ -17824,6 +18428,13 @@
"format": "int32",
"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": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
@@ -19801,8 +20412,7 @@
},
"ReviewDelivery": {
"enum": [
"inline",
"detached"
"inline"
],
"type": "string"
},
@@ -19819,7 +20429,7 @@
}
],
"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": {
"$ref": "#/definitions/v2/ReviewTarget"
@@ -19839,7 +20449,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"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"
},
"turn": {
@@ -22135,6 +22745,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -22917,6 +23528,14 @@
],
"default": null
},
"generationId": {
"default": null,
"description": "Image generation identifier for the generated image, when provided by the image backend.",
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -23037,7 +23656,28 @@
"title": "ContextCompactionThreadItem",
"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": {
"properties": {
@@ -25689,6 +26329,14 @@
},
"type": "array"
},
"nativeUsageUsdMicros": {
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
@@ -40,6 +40,13 @@
],
"title": "ChatgptAccountType",
"type": "string"
},
"workspaceName": {
"description": "Display name of the selected ChatGPT workspace, when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -69,6 +76,23 @@
],
"title": "AmazonBedrockAccount",
"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"
},
"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": {
"properties": {
"startDate": {
@@ -252,6 +373,13 @@
"kind": {
"$ref": "#/definitions/AdditionalContextKind"
},
"name": {
"description": "Optional name for untrusted context, used to label its tool output.",
"type": [
"string",
"null"
]
},
"value": {
"type": "string"
}
@@ -1015,6 +1143,42 @@
"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"
@@ -3967,6 +4131,30 @@
"title": "Feedback/uploadRequest",
"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.",
"properties": {
@@ -4318,6 +4506,8 @@
"rateLimitExceeded",
"flexUnavailable",
"serverOverloaded",
"modelNotFound",
"policyCapacityUnavailable",
"cyberPolicy",
"misalignmentPolicyViolation",
"tooManyDenials",
@@ -6032,6 +6222,16 @@
}
]
},
"otel": {
"anyOf": [
{
"$ref": "#/definitions/OtelRequirements"
},
{
"type": "null"
}
]
},
"sqliteHome": {
"type": [
"string",
@@ -7623,6 +7823,85 @@
},
"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": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9382,6 +9661,13 @@
"enabled": {
"type": "boolean"
},
"environmentId": {
"description": "The owning execution environment, if this hook came from its requirements.",
"type": [
"string",
"null"
]
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
@@ -9549,7 +9835,7 @@
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"startedAt": {
"format": "int64",
@@ -9670,11 +9956,18 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"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": {
"type": "string"
},
"type": "array"
},
"threadId": {
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
"type": [
"string",
"null"
]
}
},
"title": "HooksListParams",
@@ -11810,6 +12103,81 @@
"title": "ModelProviderCapabilitiesReadResponse",
"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": {
"enum": [
"highRiskCyberActivity"
@@ -12108,7 +12476,7 @@
"additionalProperties": {
"$ref": "#/definitions/NetworkDomainPermission"
},
"description": "Canonical network permission map for `experimental_network`.",
"description": "Canonical managed network permission map.",
"type": [
"object",
"null"
@@ -12135,6 +12503,16 @@
"null"
]
},
"reviewDomains": {
"description": "Host patterns routed to network approval before ordinary allow rules.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"socksPort": {
"format": "uint16",
"minimum": 0.0,
@@ -12147,7 +12525,7 @@
"additionalProperties": {
"$ref": "#/definitions/NetworkUnixSocketPermission"
},
"description": "Canonical unix socket permission map for `experimental_network`.",
"description": "Canonical managed unix socket permission map.",
"type": [
"object",
"null"
@@ -12221,6 +12599,232 @@
],
"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": {
"properties": {
"effectiveValue": true,
@@ -13816,6 +14420,13 @@
"format": "int32",
"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": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
@@ -15793,8 +16404,7 @@
},
"ReviewDelivery": {
"enum": [
"inline",
"detached"
"inline"
],
"type": "string"
},
@@ -15811,7 +16421,7 @@
}
],
"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": {
"$ref": "#/definitions/ReviewTarget"
@@ -15831,7 +16441,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"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"
},
"turn": {
@@ -19849,6 +20459,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -20631,6 +21242,14 @@
],
"default": null
},
"generationId": {
"default": null,
"description": "Image generation identifier for the generated image, when provided by the image backend.",
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -20751,7 +21370,28 @@
"title": "ContextCompactionThreadItem",
"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": {
"properties": {
@@ -23403,6 +24043,14 @@
},
"type": "array"
},
"nativeUsageUsdMicros": {
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
+29
View File
@@ -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}"
+1 -1
View File
@@ -21,7 +21,7 @@ cd "$(dirname "$0")"
# 本机 VM 修复:系统里的 codex 是裸二进制,旁边没有
# 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),
# 优先用它;Mac 上用 npm 正常安装的 codex 自带这三件套,
# 不需要 bin/,直接用 PATH 里的 codex 即可。
+5 -1
View File
@@ -104,7 +104,11 @@ class StubServer:
self.created = []
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)
return "th-1"
+130
View File
@@ -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()