From 71b84e98ad8c0592bd51bbd79696ae673215fc1d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 02:17:04 +0800 Subject: [PATCH] chore(codex): purge dormant ticket data (migration 263) Release 1 removed every reader of the retired Codex ticket feature. This forward migration removes what it left in the database: the 244/245 triggers, functions and tables; the two ticket settings; the ticket keys and the codex-runtime projection in accounts.extra; the tickets, routing-demand and proxy-trust state; route bundles, cookie clocks, the seen index and stored validation results; routing leases; and job rows of the retired kinds. Quality-run, validation and send-once ledgers stay; live wire observations lose only their raw cookies member. Account writes drop the retired extra keys, so importing an older export cannot bring them back. The projection helpers and the retired job-kind labels go with the data. Co-Authored-By: Claude Opus 5.5 --- .downstream/codex-quality-diagnostics.md | 2 +- .downstream/native-domains.md | 43 ++- DOWNSTREAM.md | 11 +- .../repository/account_plugin_projection.go | 27 -- backend/internal/repository/account_repo.go | 50 +++- .../account_repo_model_context_test.go | 22 +- ...ticket_purge_migration_integration_test.go | 246 ++++++++++++++++++ .../scheduler_cache_reasoning_policy_test.go | 11 - backend/internal/service/account_job.go | 4 +- backend/internal/service/account_job_test.go | 6 +- .../service/account_test_prompt_test.go | 2 +- .../internal/service/codex_native_contract.go | 5 - .../service/openai_account_scheduler_test.go | 2 +- .../263_purge_retired_codex_ticket_data.sql | 151 +++++++++++ .../account-jobs/AccountOperationProgress.vue | 21 +- .../__tests__/AccountTaskDrawer.spec.ts | 8 +- .../src/i18n/locales/en/admin/accountTasks.ts | 3 - .../src/i18n/locales/zh/admin/accountTasks.ts | 3 - 18 files changed, 511 insertions(+), 106 deletions(-) delete mode 100644 backend/internal/repository/account_plugin_projection.go create mode 100644 backend/internal/repository/codex_ticket_purge_migration_integration_test.go create mode 100644 backend/migrations/263_purge_retired_codex_ticket_data.sql diff --git a/.downstream/codex-quality-diagnostics.md b/.downstream/codex-quality-diagnostics.md index 8869339a441c..fe3969780886 100644 --- a/.downstream/codex-quality-diagnostics.md +++ b/.downstream/codex-quality-diagnostics.md @@ -44,7 +44,7 @@ grant 与 trial header 在 handler 开始时移入私有执行上下文,不转 账本存于 `sub2api_plugin_state`:plugin key `codexrip.codex-runtime`,namespace `codex-routing-private`,key `quality-run.`。新记录显式保存 `model`、`reasoning_effort` 和凭据主体 `owner_identity`;账本不保存 grant、Key、题面、Cookie 或 STATE 值。 -路由资格诊断时期写入的旧记录(无 `model`)按 `gpt-6-astra`/`high` 显示且只读:GET 和 close 仍可用,同一 `run_id` 不能再创建、恢复或发送(409,需换用新 run id)。旧记录的 `scope`、`qualification`、`route_generation`、`route_runtime_generation` 及旧 attempt 字段在重写(如 close)时原样保留;视图只对旧记录只读显示 `route_generation`、`route_expires_at` 和 `connection_fingerprint`,旧 attempt 可能带 `acquire`/`verify` 阶段。关闭旧记录不再清理其遗留的私有 Cookie 状态。 +路由资格诊断时期写入的旧记录(无 `model`)按 `gpt-6-astra`/`high` 显示且只读:GET 和 close 仍可用,同一 `run_id` 不能再创建、恢复或发送(409,需换用新 run id)。旧记录的 `scope`、`qualification`、`route_generation`、`route_runtime_generation` 及旧 attempt 字段在重写(如 close)时原样保留;视图只对旧记录只读显示 `route_generation`、`route_expires_at` 和 `connection_fingerprint`,旧 attempt 可能带 `acquire`/`verify` 阶段。旧记录引用的路由 bundle 与 Cookie 状态(`bundle.*`、`clock.*`、`seen.*`)已由迁移 263 删除,账本本身原样保留。 ## 运维执行器 diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 260323485567..5435074a47ed 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -24,11 +24,12 @@ and not stored again; any other key, or a `request_zstd` that is not a boolean The route acquisition endpoints (`/admin/accounts/codex-tickets/*`, `/admin/accounts/:id/codex-tickets/*`, `/admin/accounts/:id/codex-routing/validate` -and `/admin/settings/openai-codex-ticket/*`) are removed. Jobs of the retired -kinds `codex_ticket_harvest`, `codex_ticket_stop` and `extension_operation` -still list and read with `retry_eligible=false` and -`retry_unavailable_reason=kind_unsupported`; a retry returns HTTP 409, and their -items still pending fail with `kind_unsupported`. Ordinary account bulk editing +and `/admin/settings/openai-codex-ticket/*`) are removed. Migration 263 deletes +the job rows of the retired kinds `codex_ticket_harvest`, `codex_ticket_stop` +and `extension_operation`; their display names are gone. A job whose kind this +version has no executor for still lists and reads with `retry_eligible=false` +and `retry_unavailable_reason=kind_unsupported`; a retry returns HTTP 409, and +its items still pending fail with `kind_unsupported`. Ordinary account bulk editing remains HTTP 202; this change does not replace background tasks with synchronous editing. Existing API-key reveal and account-field protections remain. @@ -94,15 +95,23 @@ the first start advances `runtime_generation` once and records the new configuration hash and version; the encrypted source keeps its old bytes until the next save. -Data written by the retired route acquisition stays in place and is no longer -read: the `openai_codex_ticket_*` settings, the `tickets`, `routing-demand` and -`proxy-trust` state namespaces, the route keys in `codex-routing-private` -(`bundle.*`, `clock.*`, `seen.*`, `spent.*`, `validation.*`; `quality-run.*` and -`wire.*` stay in use), `sub2api_plugin_leases` rows, the account `extra` keys -`codex_turn_ticket:*`, `codex_ticket_runtime:*`, `codex_harvest_proxy_url` and -`plugin_account_projections`, and job rows of the retired kinds. Migrations 244 -and 245 and their triggers are unchanged. A later release removes this data with -a forward migration. +Migration `263_purge_retired_codex_ticket_data.sql` deletes the data written by +the retired route acquisition: the triggers, trigger functions and tables of +migrations 244 and 245; the settings `openai_codex_ticket_enabled` and +`openai_codex_ticket_harvest_proxy_url`; the `codexrip.codex-runtime` state +namespaces `tickets`, `routing-demand` and `proxy-trust` and its +`routing-account` and `tickets` leases; the route and Cookie material in +`codex-routing-private` (`bundle.*`, `clock.*`, `seen.*`, `validation-result.*`); +the account `extra` keys `codex_turn_ticket:*`, `codex_ticket_runtime:*` and +`codex_harvest_proxy_url` and the `codexrip.codex-runtime` entry of +`plugin_account_projections` (the map goes once it is empty); and job rows of +the retired kinds. `codex-routing-private` keeps the ledgers `quality-run.*`, +`validation.*` and `spent.*`, and the `wire.*` observations without the raw +Cookie values (`cookies`) they stored. Accounts without these keys are not +rewritten. Account create, update and bulk edit drop these `extra` keys and that +projection entry, so importing an account export taken before this release does +not bring them back. The encrypted Codex runtime source keeps its bytes until +the next save, as described above. Migration `254_plugin_job_action_digest.sql` preserves full 64-character historical task action digests. History and retries continue to use saved targets; unsupported @@ -125,6 +134,12 @@ runtime generation or delete its native anchor**. A plain image change does not perform this reverse conversion. Such a legacy-host rollback has not been run as part of the native migration and is not an automatic failure path. +Migration 263 has no down path; the data it deletes (listed above) comes back +only from a database dump taken before it. An image that still has the route +acquisition starts without its tickets and routes. Images from +`v0.2.8-codexrip.5` (source `9f7b08dfc`) on never read the 244/245 tables; older +images still query them, so returning to one of those needs that dump. + ## Verification Normal required PR checks validate the native domain packages, account scope, diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index cf35f8909886..9ba1233cfb36 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -32,11 +32,12 @@ request route gates, and their admin endpoints, settings and job kinds. OpenAI OAuth accounts are scheduled without route qualification and send through their ordinary proxy and transport. An OAuth Codex `/responses` reply over HTTP that declares a different model than the request is still rejected. The Codex -runtime setting keeps only request compression. Old jobs of the retired kinds -stay readable but cannot be retried; data the feature wrote stays in place, -unread, until a later release purges it. Quality diagnostics bind an explicit -model and reasoning effort and send through the account's ordinary path; see -[quality diagnostics](.downstream/codex-quality-diagnostics.md). +runtime setting keeps only request compression. Migration 263 deletes the data +the feature wrote, old jobs of the retired kinds included; quality ledgers and +the observed outbound requests stay, without stored Cookie values (see +[native domains](.downstream/native-domains.md)). Quality diagnostics bind an +explicit model and reasoning effort and send through the account's ordinary +path; see [quality diagnostics](.downstream/codex-quality-diagnostics.md). ## Official behavior and downstream contracts diff --git a/backend/internal/repository/account_plugin_projection.go b/backend/internal/repository/account_plugin_projection.go deleted file mode 100644 index e1f73f14aa8f..000000000000 --- a/backend/internal/repository/account_plugin_projection.go +++ /dev/null @@ -1,27 +0,0 @@ -package repository - -import ( - "github.com/Wei-Shaw/sub2api/internal/service" - "maps" -) - -func withoutPluginAccountProjection(extra map[string]any) map[string]any { - if _, exists := extra[service.NativeCodexAccountProjectionKey]; !exists { - return extra - } - copy := maps.Clone(extra) - delete(copy, service.NativeCodexAccountProjectionKey) - return copy -} - -func preservePluginAccountProjection(incoming, current map[string]any) map[string]any { - extra := withoutPluginAccountProjection(incoming) - if value, exists := current[service.NativeCodexAccountProjectionKey]; exists { - extra = maps.Clone(extra) - if extra == nil { - extra = make(map[string]any) - } - extra[service.NativeCodexAccountProjectionKey] = value - } - return extra -} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 632f392e9cc5..a89f678304b9 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -108,6 +108,48 @@ func stripCodexFingerprintSeedFromExtraUpdate(extra map[string]any) map[string]a return stripped } +// isRetiredCodexTicketExtraKey matches the account extra keys of the retired +// Codex route acquisition that migration 263 purged. +func isRetiredCodexTicketExtraKey(key string) bool { + return strings.HasPrefix(key, "codex_turn_ticket:") || + strings.HasPrefix(key, "codex_ticket_runtime:") || + key == "codex_harvest_proxy_url" +} + +// withoutRetiredCodexTicketExtra keeps account writes from storing the data +// migration 263 purged, so importing an account export taken before it cannot +// bring that data back. Like 263 it also drops the codexrip.codex-runtime entry +// of plugin_account_projections, and the map only once no other entry is left. +// extra is returned as is when it holds none of this. +func withoutRetiredCodexTicketExtra(extra map[string]any) map[string]any { + projections, _ := extra["plugin_account_projections"].(map[string]any) + _, projected := projections["codexrip.codex-runtime"] + stripped := make(map[string]any, len(extra)) + for key, value := range extra { + if !isRetiredCodexTicketExtraKey(key) { + stripped[key] = value + } + } + if !projected { + if len(stripped) == len(extra) { + return extra + } + return stripped + } + rest := make(map[string]any, len(projections)) + for key, value := range projections { + if key != "codexrip.codex-runtime" { + rest[key] = value + } + } + if len(rest) == 0 { + delete(stripped, "plugin_account_projections") + } else { + stripped["plugin_account_projections"] = rest + } + return stripped +} + // NewAccountRepository 创建账户仓储实例。 // 这是对外暴露的构造函数,返回接口类型以便于依赖注入。 func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository { @@ -156,7 +198,7 @@ func createAccountRecord(ctx context.Context, client *dbent.Client, account *ser SetPlatform(account.Platform). SetType(account.Type). SetCredentials(normalizeJSONMap(account.Credentials)). - SetExtra(normalizeJSONMap(withoutPluginAccountProjection(account.Extra))). + SetExtra(normalizeJSONMap(withoutRetiredCodexTicketExtra(account.Extra))). SetConcurrency(account.Concurrency). SetPriority(account.Priority). SetStatus(account.Status). @@ -740,7 +782,7 @@ func lockAndMergeAccountProbeExtra( currentExtra = nil } } - extra = preservePluginAccountProjection(extra, currentExtra) + extra = withoutRetiredCodexTicketExtra(extra) // Preserve the system prompt binding read under the row lock. A stale // general account editor cannot overwrite the dedicated binding endpoint. delete(extra, service.AccountExtraSystemPromptKey) @@ -2878,7 +2920,7 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti } func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { - updates = withoutPluginAccountProjection(updates) + updates = withoutRetiredCodexTicketExtra(updates) updates = stripCodexFingerprintSeedFromExtraUpdate(updates) if len(updates) == 0 { return nil @@ -3234,7 +3276,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates return 0, nil } updates.Extra = stripCodexFingerprintSeedFromExtraUpdate(updates.Extra) - updates.Extra = withoutPluginAccountProjection(updates.Extra) + updates.Extra = withoutRetiredCodexTicketExtra(updates.Extra) setClauses := make([]string, 0, 8) args := make([]any, 0, 8) diff --git a/backend/internal/repository/account_repo_model_context_test.go b/backend/internal/repository/account_repo_model_context_test.go index c11a517c9a45..8087940bc94f 100644 --- a/backend/internal/repository/account_repo_model_context_test.go +++ b/backend/internal/repository/account_repo_model_context_test.go @@ -183,8 +183,8 @@ func TestLockAndMergeAccountProbeExtraUsesLockedModelContextValues(t *testing.T) } // This path serves every account update: a non-object stored extra must not -// fail the edit, only skip carrying locked values over. A dormant scheduling -// projection supplied by the editor is still never written. +// fail the edit, only skip carrying locked values over. Data of the retired +// Codex route acquisition supplied by the editor or an import is never written. func TestLockAndMergeAccountExtraDegradesOnUnparsableExtra(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) @@ -195,14 +195,28 @@ func TestLockAndMergeAccountExtraDegradesOnUnparsableExtra(t *testing.T) { WithArgs(int64(41), service.PlatformOpenAI, service.AccountTypeOAuth, `{"access_token":"test"}`, nil). WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). AddRow(true, false, true, nil, nil, nil, nil, nil, nil, nil, nil, []byte(`[1,2,3]`), false, nil, nil)) - account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, Extra: map[string]any{service.NativeCodexAccountProjectionKey: map[string]any{"forged": true}, "new_admin_setting": true}} + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, Extra: map[string]any{"plugin_account_projections": map[string]any{"codexrip.codex-runtime": map[string]any{"forged": true}}, "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "forged"}, "codex_harvest_proxy_url": "http://proxy.example.test:8080", "new_admin_setting": true}} extra, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) require.NoError(t, err, "unparsable extra must not fail the account update") require.Equal(t, true, extra["new_admin_setting"]) - require.NotContains(t, extra, service.NativeCodexAccountProjectionKey) + for _, key := range []string{"plugin_account_projections", "codex_turn_ticket:gpt-6-astra", "codex_harvest_proxy_url"} { + require.NotContains(t, extra, key) + } require.NoError(t, mock.ExpectationsWereMet()) } +// Like migration 263, only the codexrip.codex-runtime projection goes; another +// plugin's entry and a key that merely shares a name stay. +func TestWithoutRetiredCodexTicketExtraKeepsOtherEntries(t *testing.T) { + kept := map[string]any{"codex_turn_ticket_summary": "not the retired prefix", "plugin_account_projections": map[string]any{"acme.other-plugin": true}} + require.Equal(t, kept, withoutRetiredCodexTicketExtra(kept)) + require.Equal(t, kept, withoutRetiredCodexTicketExtra(map[string]any{ + "codex_turn_ticket_summary": "not the retired prefix", + "codex_ticket_runtime:gpt-6-astra": map[string]any{"phase": "ready"}, + "plugin_account_projections": map[string]any{"codexrip.codex-runtime": map[string]any{"identity": "owner"}, "acme.other-plugin": true}, + })) +} + func TestShouldEnqueueSchedulerOutboxForExtraUpdatesModelContextKeys(t *testing.T) { for _, key := range []string{ service.ModelContextOverridesExtraKey, diff --git a/backend/internal/repository/codex_ticket_purge_migration_integration_test.go b/backend/internal/repository/codex_ticket_purge_migration_integration_test.go new file mode 100644 index 000000000000..5add297bd3e2 --- /dev/null +++ b/backend/internal/repository/codex_ticket_purge_migration_integration_test.go @@ -0,0 +1,246 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "strings" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const codexTicketPurgeMigration = "263_purge_retired_codex_ticket_data.sql" + +// TestMain applied every migration, 263 included, to an empty database. The +// test restores the state the previous release left behind (the objects of +// 244/245 and representative dormant rows next to rows that must survive), +// applies 263, checks what was purged and what stayed, and applies it again. +func TestMigration263PurgesRetiredCodexTicketData(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + exec := func(query string, args ...any) { + t.Helper() + _, err := tx.ExecContext(ctx, query, args...) + require.NoError(t, err) + } + text := func(query string, args ...any) string { + t.Helper() + var value string + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(query string, args ...any) int { + t.Helper() + var value int + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + migration := func(name string) string { + t.Helper() + content, err := dbmigrations.FS.ReadFile(name) + require.NoError(t, err) + return string(content) + } + + requireCodexTicketObjects(t, ctx, tx, false) + exec(migration("244_codex_ticket_lifecycle.sql")) + exec(migration("245_codex_ticket_account_status_independent.sql")) + requireCodexTicketObjects(t, ctx, tx, true) + + var userID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO users (email, password_hash, role, status, balance, concurrency) +VALUES ('migration-263@example.com', 'hash', 'admin', 'active', 0, 1) +RETURNING id`).Scan(&userID)) + insertAccount := func(name, platform, extra string) int64 { + t.Helper() + var id int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) VALUES ($1, $2, 'oauth', $3::jsonb) RETURNING id`, + name, platform, extra).Scan(&id)) + return id + } + ticketID := insertAccount("migration-263-ticket", "openai", `{ + "openai_long_context_billing_enabled": true, + "codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111", + "codex_turn_ticket:gpt-6-astra": {"state": "gAAAAA-dormant-ticket", "length": 292}, + "codex_ticket_runtime:gpt-6-astra": {"phase": "ready"}, + "codex_harvest_proxy_url": "http://user:fixture-proxy-secret@proxy.example.test:8080", + "codex_turn_ticket_summary": "kept: not the retired prefix", + "plugin_account_projections": {"codexrip.codex-runtime": {"identity": "owner", "scheduling": {"gpt-6-astra": {"effect": "deny"}}}} + }`) + sharedID := insertAccount("migration-263-shared-projection", "openai", `{ + "openai_long_context_billing_enabled": false, + "plugin_account_projections": {"codexrip.codex-runtime": {"identity": "owner"}, "acme.other-plugin": {"identity": "kept"}} + }`) + cleanID := insertAccount("migration-263-clean", "openai", `{ + "openai_long_context_billing_enabled": true, + "codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222", + "plugin_account_projections": {"acme.other-plugin": {"identity": "kept"}} + }`) + arrayID := insertAccount("migration-263-array-extra", "anthropic", `[1, 2, 3]`) + accountIDs := []int64{ticketID, sharedID, cleanID, arrayID} + + exec(`INSERT INTO settings (key, value) VALUES + ('openai_codex_ticket_enabled', 'true'), + ('openai_codex_ticket_harvest_proxy_url', 'http://user:fixture-proxy-secret@proxy.example.test:8080'), + ('codex_native_runtime_source', 'encrypted-source-kept'), + ('codex_native_runtime_config', '{"version":1,"config_version":3,"config_sha256":"kept"}'), + ('openai_codex_client_version', '0.156.0') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`) + exec(`INSERT INTO openai_codex_ticket_runtime (account_id, model, identity, phase) VALUES ($1, 'gpt-6-astra', 'owner', 'ready')`, ticketID) + exec(`INSERT INTO openai_codex_ticket_proxy_trust (proxy_key, certificates) VALUES ('migration-263', '["fixture-public-certificate"]')`) + + const codex, private = "codexrip.codex-runtime", "codex-routing-private" + insertState := func(plugin, namespace, key, value string) { + t.Helper() + exec(`INSERT INTO sub2api_plugin_state (plugin_key, namespace, state_key, value) VALUES ($1, $2, $3, $4::jsonb)`, + plugin, namespace, key, value) + } + purgedState := [][2]string{ + {"tickets", "m263.ticket"}, + {"routing-demand", "m263.ticket"}, + {"proxy-trust", "m263.proxy"}, + {private, "bundle.m263.live"}, + {private, "bundle.quality.m263.candidate"}, + {private, "clock.m263"}, + {private, "clock.quality.m263"}, + {private, "seen.m263"}, + {private, "validation-result.m263"}, + } + for _, row := range purgedState { + insertState(codex, row[0], row[1], `{"cookies":[{"name":"__cflb","value":"fixture-cookie-value"}]}`) + } + keptState := map[string]string{ + "quality-run.11111111-1111-4111-8111-111111111111": `{"run_id":"11111111-1111-4111-8111-111111111111","qualification":{"bundle":{"key":"bundle.quality.m263.live"}}}`, + "validation.22222222-2222-4222-8222-222222222222": `{"account_id":41,"used":2,"stages":{"gpt-6-astra:http:acquire":true}}`, + "spent.m263": `{"spent":true}`, + "wire.m263-observed": `{"user_agent":"codex_cli_rs/0.156.0","state_present":false}`, + "other.m263-unclassified": `{"kept":true}`, + } + for key, value := range keptState { + insertState(codex, private, key, value) + } + insertState(codex, private, "wire.m263-cookies", `{"user_agent":"codex_cli_rs/0.156.0","state":"opaque-state","cookie_versions":{"__cflb":"0123456789abcdef"},"cookies":[{"name":"__cflb","value":"fixture-cookie-value","routing":true}]}`) + insertState("acme.other-plugin", "tickets", "m263.ticket", `{"kept":true}`) + insertState("acme.other-plugin", private, "bundle.m263.live", `{"kept":true}`) + + insertLease := func(plugin, namespace, key string) { + t.Helper() + exec(`INSERT INTO sub2api_plugin_leases (plugin_key, namespace, lease_key, owner, expires_at) VALUES ($1, $2, $3, 'owner', NOW())`, + plugin, namespace, key) + } + insertLease(codex, "routing-account", "m263") + insertLease(codex, "tickets", "m263.ticket") + insertLease("acme.other-plugin", "routing-account", "m263") + + insertJob := func(kind string) int64 { + t.Helper() + var id int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO admin_account_jobs (created_by, kind, idempotency_key, request_hash, status, target_count, processed_count, failed_count) +VALUES ($1, $2, $3, $4, 'failed', 1, 1, 1) RETURNING id`, + userID, kind, "migration-263-"+kind, strings.Repeat("a", 64)).Scan(&id)) + exec(`INSERT INTO admin_account_job_items (job_id, ordinal, target_account_id, status, metadata) VALUES ($1, 1, $2, 'failed', '{"model_id":"gpt-6-astra"}')`, + id, ticketID) + return id + } + retiredJobs := []int64{insertJob("codex_ticket_harvest"), insertJob("codex_ticket_stop"), insertJob("extension_operation")} + keptJob := insertJob("account_bulk_update") + + ctid := func(table, column string, key any) string { + t.Helper() + return text(`SELECT ctid::text FROM `+table+` WHERE `+column+` = $1`, key) + } + stateCTID := func(key string) string { + t.Helper() + return text(`SELECT ctid::text || '/' || revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = $3`, codex, private, key) + } + untouchedAccounts := map[int64]string{cleanID: ctid("accounts", "id", cleanID), arrayID: ctid("accounts", "id", arrayID)} + untouchedState := map[string]string{} + for key := range keptState { + untouchedState[key] = stateCTID(key) + } + untouchedSettings := map[string]string{} + for _, key := range []string{"codex_native_runtime_source", "codex_native_runtime_config", "openai_codex_client_version"} { + untouchedSettings[key] = ctid("settings", "key", key) + } + outboxBefore := count(`SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs) + wireRevision := count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private) + + exec(migration(codexTicketPurgeMigration)) + + requireCodexTicketObjects(t, ctx, tx, false) + require.Zero(t, count(`SELECT count(*) FROM settings WHERE key IN ('openai_codex_ticket_enabled', 'openai_codex_ticket_harvest_proxy_url')`)) + require.Equal(t, "encrypted-source-kept", text(`SELECT value FROM settings WHERE key = 'codex_native_runtime_source'`)) + for key, before := range untouchedSettings { + require.Equal(t, before, ctid("settings", "key", key), key) + } + + require.JSONEq(t, `{ + "openai_long_context_billing_enabled": true, + "codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111", + "codex_turn_ticket_summary": "kept: not the retired prefix" + }`, text(`SELECT extra::text FROM accounts WHERE id = $1`, ticketID)) + require.JSONEq(t, `{ + "openai_long_context_billing_enabled": false, + "plugin_account_projections": {"acme.other-plugin": {"identity": "kept"}} + }`, text(`SELECT extra::text FROM accounts WHERE id = $1`, sharedID)) + for id, before := range untouchedAccounts { + require.Equal(t, before, ctid("accounts", "id", id), "account %d holds no retired key and is not rewritten", id) + } + require.Equal(t, outboxBefore, count(`SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs)) + + for _, row := range purgedState { + require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = $3`, codex, row[0], row[1]), row) + } + require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace IN ('tickets', 'routing-demand', 'proxy-trust')`, codex)) + for key, before := range untouchedState { + require.Equal(t, before, stateCTID(key), key) + } + require.JSONEq(t, `{"user_agent":"codex_cli_rs/0.156.0","state":"opaque-state","cookie_versions":{"__cflb":"0123456789abcdef"}}`, + text(`SELECT value::text FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) + require.Equal(t, wireRevision+1, count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) + require.Equal(t, 2, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = 'acme.other-plugin' AND state_key IN ('m263.ticket', 'bundle.m263.live')`)) + + require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_leases WHERE plugin_key = $1 AND namespace IN ('routing-account', 'tickets')`, codex)) + require.Equal(t, 1, count(`SELECT count(*) FROM sub2api_plugin_leases WHERE plugin_key = 'acme.other-plugin' AND lease_key = 'm263'`)) + + require.Zero(t, count(`SELECT count(*) FROM admin_account_jobs WHERE id = ANY($1)`, retiredJobs)) + require.Zero(t, count(`SELECT count(*) FROM admin_account_job_items WHERE job_id = ANY($1)`, retiredJobs)) + require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_jobs WHERE id = $1`, keptJob)) + require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_job_items WHERE job_id = $1`, keptJob)) + + // The triggers went before their tables, so account status/credential + // writes and setting writes no longer reach the dropped tables. + exec(`UPDATE accounts SET status = 'disabled', credentials = credentials || '{"chatgpt_account_id":"changed"}'::jsonb WHERE id = $1`, ticketID) + exec(`UPDATE settings SET value = '0.157.0' WHERE key = 'openai_codex_client_version'`) + + ticketCTID := ctid("accounts", "id", ticketID) + exec(migration(codexTicketPurgeMigration)) + requireCodexTicketObjects(t, ctx, tx, false) + require.Equal(t, ticketCTID, ctid("accounts", "id", ticketID), "a second run rewrites no account") + require.Equal(t, wireRevision+1, count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) + require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_jobs WHERE id = $1`, keptJob)) +} + +func requireCodexTicketObjects(t *testing.T, ctx context.Context, tx *sql.Tx, present bool) { + t.Helper() + var relations, triggers, functions int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT + (SELECT count(*) FROM unnest(ARRAY['openai_codex_ticket_runtime', 'openai_codex_ticket_due_idx', + 'openai_codex_ticket_proxy_trust', 'openai_codex_ticket_proxy_generation']) AS name + WHERE to_regclass(name) IS NOT NULL), + (SELECT count(*) FROM pg_trigger WHERE tgname IN ('codex_ticket_account_claim_guard', 'codex_ticket_proxy_trust_guard')), + (SELECT count(*) FROM pg_proc WHERE proname IN ('invalidate_codex_ticket_account_claims', 'invalidate_codex_ticket_proxy_trust')) +`).Scan(&relations, &triggers, &functions)) + if present { + require.Equal(t, []int{4, 2, 2}, []int{relations, triggers, functions}) + return + } + require.Equal(t, []int{0, 0, 0}, []int{relations, triggers, functions}) +} diff --git a/backend/internal/repository/scheduler_cache_reasoning_policy_test.go b/backend/internal/repository/scheduler_cache_reasoning_policy_test.go index 8bfe3ea02478..8a0c92b2a3a2 100644 --- a/backend/internal/repository/scheduler_cache_reasoning_policy_test.go +++ b/backend/internal/repository/scheduler_cache_reasoning_policy_test.go @@ -10,17 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -// The retired route-qualification projection stays dormant in account extra -// and never enters a scheduler snapshot. -func TestSchedulerCacheDropsRetiredCodexProjection(t *testing.T) { - projection := map[string]any{"identity": "owner", "scheduling": map[string]any{"gpt-6-astra": map[string]any{"effect": "deny"}}} - source := map[string]any{service.NativeCodexAccountProjectionKey: map[string]any{"codexrip.codex-runtime": projection}, "openai_quota_status": "ok"} - filtered := filterSchedulerExtra(source) - require.NotContains(t, filtered, service.NativeCodexAccountProjectionKey) - require.Equal(t, "ok", filtered["openai_quota_status"]) - require.Contains(t, source, service.NativeCodexAccountProjectionKey, "the persistent account extra is not rewritten") -} - func TestSchedulerCacheReasoningPolicyProjectionPreservesMissingAndBooleanSemantics(t *testing.T) { keys := []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} for _, source := range []map[string]any{nil, {}, {"unrelated": true}, {"quota_limit": nil}} { diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index 925f02aae73a..51e8f2c2b5b1 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -300,8 +300,8 @@ func (s *AccountJobService) decorateRetryEligibility(ctx context.Context, job *A return } if !validAccountJobKind(job.Kind) { - // A retired kind (route acquisition, renewal stop, legacy extension - // operation) still lists, but it has no executor to retry with. + // A kind this version has no executor for (a row written by another + // version) still lists, but it cannot be retried. job.RetryUnavailableReason = "kind_unsupported" return } diff --git a/backend/internal/service/account_job_test.go b/backend/internal/service/account_job_test.go index e6e78b72d717..3de4bcde2c61 100644 --- a/backend/internal/service/account_job_test.go +++ b/backend/internal/service/account_job_test.go @@ -262,10 +262,10 @@ type accountJobTestExecutor struct { cleanupCalls int } -// Retired kinds (route acquisition, renewal stop, legacy extension operation) -// keep their rows readable, but nothing can submit or retry them. +// A row of a kind this version has no executor for (written by another +// version) stays readable, but nothing can submit or retry that kind. func TestAccountJobRetiredKindsStayReadableButCannotRun(t *testing.T) { - for _, kind := range []string{"codex_ticket_harvest", "codex_ticket_stop", "extension_operation"} { + for _, kind := range []string{"kind_from_another_version"} { t.Run(kind, func(t *testing.T) { repo := newAccountJobTestRepo() jobs := NewAccountJobService(repo, accountJobTestCipher{}) diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go index b7ebb4b581b3..1905bb881c4d 100644 --- a/backend/internal/service/account_test_prompt_test.go +++ b/backend/internal/service/account_test_prompt_test.go @@ -110,7 +110,7 @@ func TestAccountTestPromptOAuthMappedModelReportsFinalWire(t *testing.T) { a.Credentials["model_mapping"] = map[string]any{"alias": "gpt-5.6-sol"} a.Credentials["header_override_enabled"] = true a.Credentials["header_overrides"] = map[string]any{openAICodexTurnStateHeader: "header-override-state"} - a.Extra = map[string]any{"codex_turn_ticket:gpt-5.6-sol": map[string]any{"state": "dormant-state"}, NativeCodexAccountProjectionKey: map[string]any{NativeCodexPluginKey: map[string]any{"scheduling": map[string]any{"gpt-5.6-sol": map[string]any{"effect": "deny"}}}}} + a.Extra = map[string]any{"codex_turn_ticket:gpt-5.6-sol": map[string]any{"state": "dormant-state"}, "plugin_account_projections": map[string]any{NativeCodexPluginKey: map[string]any{"scheduling": map[string]any{"gpt-5.6-sol": map[string]any{"effect": "deny"}}}}} upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.6-sol\"}}\n\n")}} gateway := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, accountRepo: &codexAccountRepositoryFixture{account: a}, nativeCodexRuntime: nativeCodexTestRuntime(t)} svc := &AccountTestService{cfg: cfg, httpUpstream: upstream, openAIGatewayService: gateway} diff --git a/backend/internal/service/codex_native_contract.go b/backend/internal/service/codex_native_contract.go index 1cf01745adec..af8c6527bd64 100644 --- a/backend/internal/service/codex_native_contract.go +++ b/backend/internal/service/codex_native_contract.go @@ -13,11 +13,6 @@ const NativeCodexConfigSettingKey = "codex_native_runtime_config" const NativeCodexSourceSettingKey = "codex_native_runtime_source" const NativeCodexRetirementSettingKey = "deplugin_retired_plugins" -// NativeCodexAccountProjectionKey names the account extra key that held the -// retired route-qualification scheduling projection. Nothing reads it; account -// writes only carry an existing value over unchanged until the data is purged. -const NativeCodexAccountProjectionKey = "plugin_account_projections" - var ErrNativeCodexRuntimeChanged = errors.New("native Codex runtime metadata changed") var ErrNativeCodexRuntimeUnavailable = errors.New("native Codex runtime unavailable") diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 5eaaee67aad6..b07610094f63 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -4091,7 +4091,7 @@ func TestSelectAccountWithSchedulerServesOAuthAccountsWithoutRouteQualification( ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, GroupIDs: []int64{groupID}, Credentials: map[string]any{"access_token": "scheduler-token", "chatgpt_account_id": "scheduler-principal", "model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}}, - Extra: map[string]any{NativeCodexAccountProjectionKey: map[string]any{NativeCodexPluginKey: map[string]any{ + Extra: map[string]any{"plugin_account_projections": map[string]any{NativeCodexPluginKey: map[string]any{ "identity": "dormant", "scheduling": map[string]any{"gpt-6-astra": map[string]any{"model": "gpt-6-astra", "effect": "deny"}}, }}}, } diff --git a/backend/migrations/263_purge_retired_codex_ticket_data.sql b/backend/migrations/263_purge_retired_codex_ticket_data.sql new file mode 100644 index 000000000000..849668b56532 --- /dev/null +++ b/backend/migrations/263_purge_retired_codex_ticket_data.sql @@ -0,0 +1,151 @@ +-- Purge the data of the retired Codex route acquisition ("ticket") feature. +-- The previous release removed every reader and writer of it, so nothing in +-- the application reads what this migration deletes. Evidence names the writer +-- in the last source that had one (git d81cd8be4, paths below +-- backend/internal), or the plugin-era source where noted. +-- +-- Deleted: +-- * The objects of 244/245 (no later migration altered them): the triggers +-- codex_ticket_account_claim_guard (accounts) and +-- codex_ticket_proxy_trust_guard (settings) and their functions +-- invalidate_codex_ticket_account_claims() and +-- invalidate_codex_ticket_proxy_trust() go first, because the account +-- trigger writes openai_codex_ticket_runtime and a function cannot be dropped +-- while a trigger uses it. Then the tables openai_codex_ticket_runtime (with +-- openai_codex_ticket_due_idx), openai_codex_ticket_proxy_trust and +-- openai_codex_ticket_proxy_generation. +-- * settings openai_codex_ticket_enabled and +-- openai_codex_ticket_harvest_proxy_url, the only keys the feature stored +-- (service/domain_constants.go:723,725; openai_codex_ticket_clear_proxy and +-- openai_codex_ticket_harvest_proxy_configured were a request flag and a +-- response field, never stored). +-- * accounts.extra keys codex_turn_ticket:* (service/openai_codex_ticket.go:15), +-- codex_ticket_runtime:* (service/codex_ticket_lifecycle.go:14) and +-- codex_harvest_proxy_url (service/openai_codex_ticket.go:167-171), and the +-- codexrip.codex-runtime entry of plugin_account_projections, which only the +-- Codex runtime wrote (repository/codex_native_state.go:56-66,78-113); the +-- projection map itself goes only when no other entry is left. Rows that hold +-- none of these keys are not written. +-- * sub2api_plugin_state rows of plugin codexrip.codex-runtime in the +-- namespaces tickets (codexruntime/tickets/module.go:494,509), +-- routing-demand (codexruntime/tickets/routing.go:208-227) and proxy-trust +-- (pinned harvest-proxy certificates, codexruntime/tickets/proxy.go:364,423), +-- and the route and Cookie material of codex-routing-private: +-- bundle.* route bundles holding raw Cookie values +-- (service/codex_routing_cookies.go:45-55; keys from +-- service/codex_routing_redaction.go:21 and +-- service/codex_quality_runtime.go:536) +-- clock.* Cookie clocks holding raw Cookie values +-- (service/codex_routing_cookies.go:38-43; keys from +-- service/codex_routing_host.go:154 and +-- service/codex_quality_runtime.go:543) +-- seen.* first-seen index per Cookie value +-- (service/codex_routing_cookies.go:173-216) +-- validation-result.* stored verified route probe with its bundle and +-- connection lease (service/codex_routing_validation.go:168,190-195) +-- * sub2api_plugin_leases rows of codexrip.codex-runtime in routing-account +-- (codexruntime/tickets/routing.go:45, service/codex_quality_route.go:75, +-- service/codex_routing_validation.go:136) and tickets (plugin-era per-ticket +-- leases, git 769361555 plugins/codex-runtime/internal/tickets/module.go:391). +-- * admin_account_jobs of the kinds codex_ticket_harvest, codex_ticket_stop and +-- extension_operation (service/account_job.go:19,23,24); their items follow +-- through ON DELETE CASCADE (232). +-- +-- Kept in codex-routing-private: +-- * quality-run.* quality diagnostic ledger, read by this release; it stores no +-- Cookie or STATE value (service/codex_quality_state.go:104-128). +-- * validation.* route validation budget ledger +-- (service/codex_routing_validation.go:41-45,54-99). +-- * spent.* send-once ledger written before probe IO, value +-- {"spent":true} (service/codex_routing_host.go:136-148). +-- * wire.* last outbound request of an account, read by this release, +-- without its retired "cookies" member: the name/value pairs of the Cookie +-- header as sent (service/codex_routing_fingerprint.go:74-96,120,204). Its +-- other members, digests included, stay. +-- Audit logs, every other setting (codex_native_runtime_source and +-- codex_native_runtime_config included), other plugins' state, account +-- credentials and any certificate other than the harvest-proxy pins above are +-- not touched. +-- +-- accounts has no updated_at trigger. Writing extra fires the long-context +-- billing triggers of 175, which keep the stored flag and therefore queue no +-- scheduler_outbox event; the scheduler rebuild at startup reloads cached +-- account payloads. Every statement is a no-op once its objects or rows are +-- gone, so the migration can run again. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +DROP TRIGGER IF EXISTS codex_ticket_account_claim_guard ON accounts; +DROP TRIGGER IF EXISTS codex_ticket_proxy_trust_guard ON settings; +DROP FUNCTION IF EXISTS invalidate_codex_ticket_account_claims(); +DROP FUNCTION IF EXISTS invalidate_codex_ticket_proxy_trust(); +DROP TABLE IF EXISTS openai_codex_ticket_runtime; +DROP TABLE IF EXISTS openai_codex_ticket_proxy_trust; +DROP TABLE IF EXISTS openai_codex_ticket_proxy_generation; + +DELETE FROM settings +WHERE key IN ('openai_codex_ticket_enabled', 'openai_codex_ticket_harvest_proxy_url'); + +WITH retired AS ( + SELECT a.id, + a.extra - ARRAY( + SELECT k + FROM jsonb_object_keys(a.extra) AS k + WHERE starts_with(k, 'codex_turn_ticket:') + OR starts_with(k, 'codex_ticket_runtime:') + OR k = 'codex_harvest_proxy_url' + ) AS extra, + COALESCE( + jsonb_typeof(a.extra -> 'plugin_account_projections') = 'object' + AND (a.extra -> 'plugin_account_projections') ? 'codexrip.codex-runtime', + FALSE + ) AS projected + FROM accounts AS a + WHERE jsonb_typeof(a.extra) = 'object' +) +UPDATE accounts AS a +SET extra = CASE + WHEN NOT r.projected THEN r.extra + WHEN (r.extra -> 'plugin_account_projections') - 'codexrip.codex-runtime' = '{}'::jsonb + THEN r.extra - 'plugin_account_projections' + ELSE jsonb_set( + r.extra, + '{plugin_account_projections}', + (r.extra -> 'plugin_account_projections') - 'codexrip.codex-runtime' + ) + END +FROM retired AS r +WHERE a.id = r.id + AND (r.projected OR r.extra <> a.extra); + +DELETE FROM sub2api_plugin_state +WHERE plugin_key = 'codexrip.codex-runtime' + AND ( + namespace IN ('tickets', 'routing-demand', 'proxy-trust') + OR ( + namespace = 'codex-routing-private' + AND ( + starts_with(state_key, 'bundle.') + OR starts_with(state_key, 'clock.') + OR starts_with(state_key, 'seen.') + OR starts_with(state_key, 'validation-result.') + ) + ) + ); + +UPDATE sub2api_plugin_state +SET value = value - 'cookies', + revision = revision + 1, + updated_at = NOW() +WHERE plugin_key = 'codexrip.codex-runtime' + AND namespace = 'codex-routing-private' + AND starts_with(state_key, 'wire.') + AND jsonb_typeof(value) = 'object' + AND value ? 'cookies'; + +DELETE FROM sub2api_plugin_leases +WHERE plugin_key = 'codexrip.codex-runtime' + AND namespace IN ('routing-account', 'tickets'); + +DELETE FROM admin_account_jobs +WHERE kind IN ('codex_ticket_harvest', 'codex_ticket_stop', 'extension_operation'); diff --git a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue index e0f7bbf88954..3695b49ceb1b 100644 --- a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue +++ b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue @@ -45,19 +45,11 @@

{{ itemLabel(item) }}

-

{{ item.metadata.model_id }}

-

{{ item.metadata.label }}

{{ statusLabel(item.status) }}

{{ item.metadata.message }}

[{{ item.error_code }}]{{ item.error_message }}

-
- -