From 72ab5fffcf49e5a5e5ddc0db678a5e120bc6b2ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 17 May 2026 21:16:47 +0100 Subject: [PATCH] Extract catalog routes from processor --- notes/fresh-workspace-handoff.md | 1 + state/ledgers.msgpack | Bin 120240 -> 120502 bytes state/map.yaml | 10 +- .../app-server/src/codex_message_processor.rs | 202 +---------------- .../codex_message_processor/catalog_routes.rs | 204 ++++++++++++++++++ 5 files changed, 211 insertions(+), 206 deletions(-) create mode 100644 vendor/codex/codex-rs/app-server/src/codex_message_processor/catalog_routes.rs diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1e94bb11..136b88fa 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -46,6 +46,7 @@ remembers doctrine; the branch remembers the blade. - One-off command execution and command stream write/resize/terminate route handling now live in `codex_message_processor/command_routes.rs`; `CommandExecManager` remains the lifecycle owner, while the root processor is about 7,800 lines. - Git diff-to-origin, fuzzy file search/session routes, feedback upload, and Windows sandbox setup now live in `codex_message_processor/utility_routes.rs`; the root processor is about 7,397 lines. - Realtime conversation routes now live in `codex_message_processor/realtime_routes.rs`, and review-start orchestration now lives in `codex_message_processor/review_routes.rs`; the root processor is about 6,938 lines. +- Model/catalog routes now live in `codex_message_processor/catalog_routes.rs`: model list, collaboration-mode list, experimental feature list, and mock experimental method. The root processor is about 6,738 lines. - The May 2026 foundation migration in `notes/epiphany-ideal-architecture-rebuild-plan.md` is complete enough to defend. Read `notes/epiphany-architectural-teardown.md` before touching Epiphany control-plane code, but do not treat it as an open-ended excuse to keep shaving the altar. The rebuild moved scene projection, freshness derivation, pressure policy, targeted context shards, bounded graph traversal, job/progress view derivation, planning view derivation, reorientation resume/regather verdict policy, pure CRRC recommendation policy, fixed-lane coordinator decision policy, role-board projection policy, role/reorient result interpretation, role self-persistence review policy, and role/reorient acceptance bundle policy into `epiphany-core`; typed acceptance receipts replaced summary-string identity for live accept paths, runtime links are dual-written on launch, and result read-back now prefers runtime links. - Read `notes/epiphany-ideal-architecture-rebuild-plan.md` immediately after the teardown. It defines the smallest coherent replacement architecture: durable `EpiphanyState`, runtime-spine-owned `RuntimeState`, separate role memory/heartbeat state, `epiphany-core` as policy owner, app-server as adapter, Aquarium as reflector, typed acceptance receipts, and derived view lenses instead of a protocol verb zoo. - Do not continue Aquarium UI, bridge, Face, or dogfood expansion until the teardown has a source-grounded cleanup slice plan. Epiphany is the foundation; patches on patches are not a purification rite, they are how the altar becomes load-bearing garbage. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index f8c6251298dd4c38a54751e989efed1a8aadfcca..422f95b7aa269d6c9007f24d4adc41e3f65acaee 100644 GIT binary patch delta 169 zcmdn6ntj_^_6f>N*J?JZPGe+rZr;PVeGen!z01=#U1AhrHnTFBzHm3AcEVxTijtzl zGdDG_Br!)JEj6*E wvi)&2;U{jCyQ|K-Kx_)BCP4sxX`Bm`|U0l~IZn=mx_m0Lr^R82|tP delta 49 zcmdnCmVLu&_6f>NSIai4PGe+rYTm=ReGen!y~|<^TZ{~h%ybP*bq&o!j0~&{O|6U! FqX5%85NiMc diff --git a/state/map.yaml b/state/map.yaml index ef07f7ff..17e4f609 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -286,10 +286,10 @@ rejected_paths: current_status: summary: The Codex starvation pass has five typing receipts, fifty-two processor/foundation cuts, a real physical extraction in `epiphany-codex-bridge`, a root `epiphany-state-model` crate for durable Epiphany state, a source-grounded Codex auth spine inventory, the native `epiphany-openai-adapter` document boundary, a buildable Codex auth/model wrapper in `epiphany-openai-codex-spine`, the first native runtime caller in `epiphany-openai-runtime`, native heartbeat launch planning/job opening in `epiphany-core`, native state-update contract/validation/application in `epiphany-core`, native exact retrieval path matching in `epiphany-core`, and the route-facing mutation/view/coordinator projection services in `epiphany-codex-bridge`. `codex-protocol` no longer defines the durable `Epiphany*` state structs; it re-exports the native model for legacy rollout/API compatibility while `epiphany-core` imports the native model directly. `epiphany-core` now has no direct vendored Codex dependency: pressure derivation accepts native `EpiphanyTokenUsageSnapshot`, Codex rollout replay moved to `codex-core::epiphany_rollout`, and exact retrieval search uses Epiphany-owned workspace walking/path scoring instead of `codex-file-search`. The bridge owns Codex-facing Epiphany adapters for scene, jobs, context/planning/graph query, retrieval/indexing projection, result projection, runtime-spine result adaptation, launch/prompt doctrine, mutation document mechanics, mutation service update/promote/role-accept/reorient-accept/role-launch/reorient-launch/job-launch/job-interrupt application, live-state projection, pressure/pre-compaction rendering, freshness/reorientation projection, CRRC/coordinator/role-board projection, watcher invalidation adaptation, full `thread/epiphany/view` response construction, freshness/context/graph-query response construction, role/reorient result response construction, distill/propose patch-response construction, coordinator status/view construction, and acceptance/evidence signal helpers. Vendored app-server no longer retains root Epiphany modules; `codex_message_processor.rs` still contains route dispatch, JSON-RPC response shaping, watcher registration/snapshotting calls, and notification emission, but its root no longer imports the evacuated Epiphany policy/protocol registry or owns the watcher invalidation manager. Codex product surfaces for apps, skills, plugins, marketplace, and external agent config import are cut from the live app-server/protocol path: list routes return empty lists only where compatibility shells still exist, mutation/detail routes report disabled where they survive, `externalAgentConfig/*` routes and import-completed notification are gone, helper/import modules and stale implementation-shape tests were deleted, runtime feature enablement no longer supports `apps` or `plugins`, config writes no longer emit plugin-toggle analytics, plugin/skill cache invalidation on config writes is gone, plugin startup warmups and app-list refreshes were removed, and app-server no longer directly depends on `codex-plugin`, `codex-chatgpt`, or `codex-core-plugins`. `Config::to_mcp_config` now keeps user-declared MCP servers but no longer accepts `PluginsManager`, folds plugin MCP servers into the runtime, enables Codex apps, enables skill MCP dependency install, or advertises plugin capability summaries; `TurnContext::apps_enabled()` now returns false so model turns cannot awaken Codex Apps through ChatGPT auth or feature flags; session startup and per-turn context no longer load skills, turns receive an empty `SkillLoadOutcome`, internal `Op::ListSkills` is gone from `codex-protocol` / `codex-core` / TUI command routing, the Codex skill watcher has been collapsed to a no-op marker with no file watcher, skill-root registration, cache-clear task, or `SkillsUpdateAvailable` session listener, model turns no longer run skill mention collection, skill env-var prompts, skill MCP dependency installation, skill instruction injection, or implicit skill invocation analytics from shell tools, and the turn prompt/router path no longer loads plugin/app capability summaries, injects available-skill/available-plugin/plugin instruction fragments, parses plugin/app mentions, records app/plugin invocation telemetry, or filters MCP tools through Codex app connectors. Skill mutation/list/notification is gone from the app-server edge too: `skills/list`, `skills/config/write`, `skills/changed`, `Op::ListSkills`, `EventMsg::ListSkillsResponse`, `EventMsg::SkillsUpdateAvailable`, the app-server handlers, TUI slash/startup/list/manage-skill surfaces, generated schema entries, core skill-list suite, and fake live-reload/developer-message tests were deleted. Skill mention plumbing is also gone from the live TUI/protocol edge: no `UserInput::Skill` variant, app-server schema branch, TUI skill popup, `skills_all` cache, composer `set_skill_mentions`, skill mention insertion, `skill://` / `SKILL.md` history decoding, or stale skill popup snapshot remains. The `codex-core-skills` crate, core `skills` re-export shim, no-op `skills_watcher`, session `SkillsManager` service, and per-turn `TurnSkillsContext` are deleted; core/protocol no longer keep legacy `` or `` prompt compatibility, memory usage classification, snapshot normalization, or cleanup paths. Remaining product-shaped skill residue is also cut from memory templates, app-server docs/tests/warnings, TUI tooltips/placeholders/warnings/comments, app-server v1 docs, agent-tool schema text, stale `UserInput::Skill` test arms, and obsolete feature flags/schema entries. App-instruction prompt compatibility is cut too: no `APPS_INSTRUCTIONS` protocol constants, no `` snapshot canonicalization, and MCP fixture tests now use neutral `demo_mcp` / `calendar_mcp` names instead of `codex_apps` / `/api/codex/apps` branding. The TUI app connector product path is also deleted: no `/apps` slash command, app-link view, connector AppEvent variants, app connector cache/popup/refresh loop, app mention insertion, `app://` mention codec support, stale app-popup tests, or TUI browser-opening shim remains. The `app/list` route and `app/list/updated` notification are gone from app-server protocol, generated schemas, app-server dispatch, test helpers, and docs; `AppInfo`/app-list metadata schema structs are deleted; the orphaned `codex-connectors` crate and `core::connectors` shim are deleted from the workspace; Codex app analytics no longer tracks `codex_app_mentioned` / `codex_app_used` facts, events, reducer arms, client APIs, or dedupe cache state; `[apps.*]`, `Feature::Apps`, `include_apps_instructions`, app config/read schemas, app managed requirements, app prompt tests, and app file-MCP tests are gone; `Feature::Plugins`, `Feature::RemotePlugin`, `plugin://` structured mentions, plugin instruction tags, and stale plugin popup snapshots are gone too; `[plugins]` / `[marketplaces]`, `PluginConfig`, `MarketplaceConfig`, marketplace edit helpers, stale plugin/external-agent README route claims, and generated schema entries are removed from config; and the bundled `codex-skills` sample-skill crate plus embedded imagegen/openai-docs/plugin-creator/skill-creator/skill-installer payloads are deleted from the workspace. The vendored `vendor/codex/sdk` subtree is deleted as non-spine app-server client/release scaffolding, including stale generated Python SDK route types for removed product surfaces; the root pnpm workspace no longer lists `sdk/typescript`. Live MCP tool approval is named `McpToolApproval`, not `AppToolApproval`, and remains under MCP server config; live structured user-input mentions are MCP-shaped only. `codex_message_processor.rs` is about 9,482 lines; root `message_processor.rs` is about 1,292 lines after the external-agent/config-product cuts; `epiphany_read_routes.rs` is about 579 lines after the full view and read-response extractions; `epiphany_mutation_routes.rs` is about 694 lines after route-load/notification helper cuts and product-surface starvation; the old vendored `epiphany_state_helpers.rs` rollout-state loader is deleted and its remaining function lives in `epiphany-codex-bridge::state`; and `codex_thread.rs` is about 1,425 lines after state-update law moved native. `epiphany-openai-adapter` defines CultCache `DatabaseEntry` request/event/receipt/auth-status documents with no Codex app-server or generic JSON dependency. `epiphany-openai-codex-spine` owns the native OpenAI model transport path: it exposes `default_codex_home` and `auth_manager`, projects `epiphany-openai-auth-spine` `AuthManager` / `CodexAuth` into typed `EpiphanyOpenAiAdapterStatus`, builds a local serializable OpenAI Responses request body instead of Codex protocol request/item/config types, opens HTTP Responses streams through `codex-client`, parses Responses SSE frames into typed stream events and receipts, and no longer directly or transitively depends on Codex's Responses client, model-provider stack, `codex-protocol`, `codex-api`, `codex-model-provider`, or `codex-model-provider-info`; it intentionally reaches retained vendored `codex-login` only through `epiphany-openai-auth-spine`. Auth identity, credential storage, and ChatGPT token refresh remain owned by the Codex-compatible auth organ; external bearer command auth and agent identity remain retained Codex auth capabilities to expose only if a typed Epiphany surface needs them. `epiphany-openai-runtime` no longer depends directly on `codex-login`; it writes OpenAI status/request/stream-event/receipt records plus runtime sessions/jobs/results/events into `epiphany-core::runtime_spine_cache`, can emit the assistant text artifact for legacy result consumers, is now the default executor for startup-only repo birth specialists, and now has `run-worker` to load a durable `EpiphanyRuntimeWorkerLaunchRequest`, call the typed OpenAI runtime through the spine, persist typed `EpiphanyRuntimeRoleWorkerResult` / `EpiphanyRuntimeReorientWorkerResult` documents, and complete the original heartbeat/specialist runtime job without Codex worker execution; `codex exec` survives only as explicit fallback in repo-birth startup. `epiphany-mvp-coordinator` now invokes `epiphany-openai-runtime run-worker` for launched runtime job ids and no longer creates a second coordinator-owned shadow runtime job/result as a compensator. `epiphany-core::plan_runtime_spine_heartbeat_launch` validates launch contracts/conflicts and projects durable job bindings/runtime links; `epiphany-core::open_runtime_spine_heartbeat_job` owns the initialize-runtime/ensure-session/create-job sequence and now persists `EpiphanyRuntimeWorkerLaunchRequest` CultCache records keyed by runtime job id, with indexed kind/contract fields plus MessagePack-encoded typed launch documents instead of discarding work intent after queue receipt creation; `roleResult` and `reorientResult` now require typed worker result documents; generic job summaries are lifecycle receipts, not reviewable findings; `epiphany-core::EpiphanyStateUpdate`, `epiphany_state_update_validation_errors`, and `apply_epiphany_state_update` own state-update document shape and mutation law while `codex-core` calls them for compatibility. Vendored `CodexThread::epiphany_launch_job` is now mostly revision check, persistence validation, launch request handoff, and rollout/session writeback for the compatibility route. Vendored `codex-core` no longer has `epiphany_distillation`, `epiphany_promotion`, `epiphany_proposal`, or `epiphany_retrieval` husk modules; it re-exports native surfaces from `epiphany-core` directly, and now owns only the real Codex rollout adapter because it is a host seam. `epiphany-runtime-spine` advertises OpenAI adapter status/model-request/stream-event/receipt CultNet schemas and mutation contracts, so the native caller now has a real storage route instead of only JSON-RPC. MCP itself is correctly JSON at its protocol edge; the target is typed Epiphany intent/result/receipt wrappers around MCP, not replacing MCP JSON. The current OpenAI request path no longer pulls `codex-api`; `codex-login` remains deliberately sealed behind `epiphany-openai-auth-spine`, and the next spine decision is another Epiphany-in-vendor evacuation surface unless a concrete typed auth-status/runtime need requires exposing external bearer or agent-identity auth. latest_cut: > - Realtime and review route handling moved out of root + Model/catalog route handling moved out of root `codex_message_processor.rs` into - `codex_message_processor/realtime_routes.rs` and - `codex_message_processor/review_routes.rs`. Voice transport and - review-turn orchestration are no longer inline with thread/auth/MCP/Epiphany - processing; the root processor is about 6,938 lines after the cut. + `codex_message_processor/catalog_routes.rs`. Model list, + collaboration-mode list, experimental feature list, and mock experimental + method handling are split from thread/auth/MCP/Epiphany processing; the + root processor is about 6,738 lines after the cut. next_action: Continue the Codex starvation campaign at the next Epiphany-in-vendor evacuation surface; keep vendored Codex code as host routing/lifecycle glue only, and do not cut the retained `codex-login` auth organ unless replacing it with an explicitly permitted OpenAI path rather than a lookalike. diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs index 14f35ba2..863fe17b 100644 --- a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs @@ -1,4 +1,5 @@ mod auth_routes; +mod catalog_routes; mod command_routes; mod epiphany_automation; mod epiphany_mutation_routes; @@ -4250,207 +4251,6 @@ impl CodexMessageProcessor { Ok((items, next_cursor)) } - async fn list_models( - outgoing: Arc, - thread_manager: Arc, - request_id: ConnectionRequestId, - params: ModelListParams, - ) { - let ModelListParams { - limit, - cursor, - include_hidden, - } = params; - let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; - let total = models.len(); - - if total == 0 { - let response = ModelListResponse { - data: Vec::new(), - next_cursor: None, - }; - outgoing.send_response(request_id, response).await; - return; - } - - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } - }, - None => 0, - }; - - if start > total { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total models {total}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } - - let end = start.saturating_add(effective_limit).min(total); - let items = models[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - let response = ModelListResponse { - data: items, - next_cursor, - }; - outgoing.send_response(request_id, response).await; - } - - async fn list_collaboration_modes( - outgoing: Arc, - thread_manager: Arc, - request_id: ConnectionRequestId, - params: CollaborationModeListParams, - ) { - let CollaborationModeListParams {} = params; - let items = thread_manager - .list_collaboration_modes() - .into_iter() - .map(Into::into) - .collect(); - let response = CollaborationModeListResponse { data: items }; - outgoing.send_response(request_id, response).await; - } - - async fn experimental_feature_list( - &self, - request_id: ConnectionRequestId, - params: ExperimentalFeatureListParams, - ) { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let data = FEATURES - .iter() - .map(|spec| { - let (stage, display_name, description, announcement) = match spec.stage { - Stage::Experimental { - name, - menu_description, - announcement, - } => ( - ApiExperimentalFeatureStage::Beta, - Some(name.to_string()), - Some(menu_description.to_string()), - Some(announcement.to_string()), - ), - Stage::UnderDevelopment => ( - ApiExperimentalFeatureStage::UnderDevelopment, - None, - None, - None, - ), - Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), - Stage::Deprecated => { - (ApiExperimentalFeatureStage::Deprecated, None, None, None) - } - Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), - }; - - ApiExperimentalFeature { - name: spec.key.to_string(), - stage, - display_name, - description, - announcement, - enabled: config.features.enabled(spec.id), - default_enabled: spec.default_enabled, - } - }) - .collect::>(); - - let total = data.len(); - if total == 0 { - self.outgoing - .send_response( - request_id, - ExperimentalFeatureListResponse { - data: Vec::new(), - next_cursor: None, - }, - ) - .await; - return; - } - - // Clamp to 1 so limit=0 cannot return a non-advancing page. - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => { - self.send_invalid_request_error( - request_id, - format!("invalid cursor: {cursor}"), - ) - .await; - return; - } - }, - None => 0, - }; - - if start > total { - self.send_invalid_request_error( - request_id, - format!("cursor {start} exceeds total feature flags {total}"), - ) - .await; - return; - } - - let end = start.saturating_add(effective_limit).min(total); - let data = data[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - - self.outgoing - .send_response( - request_id, - ExperimentalFeatureListResponse { data, next_cursor }, - ) - .await; - } - - async fn mock_experimental_method( - &self, - request_id: ConnectionRequestId, - params: MockExperimentalMethodParams, - ) { - let MockExperimentalMethodParams { value } = params; - let response = MockExperimentalMethodResponse { echoed: value }; - self.outgoing.send_response(request_id, response).await; - } - async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor/catalog_routes.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor/catalog_routes.rs new file mode 100644 index 00000000..e5001bcb --- /dev/null +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor/catalog_routes.rs @@ -0,0 +1,204 @@ +use super::*; + +impl CodexMessageProcessor { + pub(super) async fn list_models( + outgoing: Arc, + thread_manager: Arc, + request_id: ConnectionRequestId, + params: ModelListParams, + ) { + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; + let total = models.len(); + + if total == 0 { + let response = ModelListResponse { + data: Vec::new(), + next_cursor: None, + }; + outgoing.send_response(request_id, response).await; + return; + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + outgoing.send_error(request_id, error).await; + return; + } + }, + None => 0, + }; + + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total models {total}"), + data: None, + }; + outgoing.send_error(request_id, error).await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + let items = models[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + let response = ModelListResponse { + data: items, + next_cursor, + }; + outgoing.send_response(request_id, response).await; + } + + pub(super) async fn list_collaboration_modes( + outgoing: Arc, + thread_manager: Arc, + request_id: ConnectionRequestId, + params: CollaborationModeListParams, + ) { + let CollaborationModeListParams {} = params; + let items = thread_manager + .list_collaboration_modes() + .into_iter() + .map(Into::into) + .collect(); + let response = CollaborationModeListResponse { data: items }; + outgoing.send_response(request_id, response).await; + } + + pub(super) async fn experimental_feature_list( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureListParams, + ) { + let ExperimentalFeatureListParams { cursor, limit } = params; + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let data = FEATURES + .iter() + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ApiExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => ( + ApiExperimentalFeatureStage::UnderDevelopment, + None, + None, + None, + ), + Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => { + (ApiExperimentalFeatureStage::Deprecated, None, None, None) + } + Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), + }; + + ApiExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, + enabled: config.features.enabled(spec.id), + default_enabled: spec.default_enabled, + } + }) + .collect::>(); + + let total = data.len(); + if total == 0 { + self.outgoing + .send_response( + request_id, + ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }, + ) + .await; + return; + } + + // Clamp to 1 so limit=0 cannot return a non-advancing page. + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + self.send_invalid_request_error( + request_id, + format!("invalid cursor: {cursor}"), + ) + .await; + return; + } + }, + None => 0, + }; + + if start > total { + self.send_invalid_request_error( + request_id, + format!("cursor {start} exceeds total feature flags {total}"), + ) + .await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + let data = data[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + self.outgoing + .send_response( + request_id, + ExperimentalFeatureListResponse { data, next_cursor }, + ) + .await; + } + + pub(super) async fn mock_experimental_method( + &self, + request_id: ConnectionRequestId, + params: MockExperimentalMethodParams, + ) { + let MockExperimentalMethodParams { value } = params; + let response = MockExperimentalMethodResponse { echoed: value }; + self.outgoing.send_response(request_id, response).await; + } +}