From ff08391da826198432a06b51d522898c6d36ef2a Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 16:21:35 -0700 Subject: [PATCH 1/6] fix: restore per-org governance deletion capacity --- developer.md | 16 ++-- reflexio/housekeeping_policy.py | 11 +-- reflexio/models/config_schema.py | 13 ++-- tests/models/test_housekeeping_policy.py | 73 ++++++++++++++++--- .../test_config_storage_contract.py | 23 ++++-- 5 files changed, 99 insertions(+), 37 deletions(-) diff --git a/developer.md b/developer.md index 113c6697b..220a995eb 100644 --- a/developer.md +++ b/developer.md @@ -841,12 +841,18 @@ unchanged. Explicit/manual user leases and pending-run claims retain separate lifetimes and are not controlled by these background discovery settings. -Housekeeping timing and governance deletion batches live in +Housekeeping cadence and lineage timing migration checks live in `reflexio/housekeeping_policy.py`. The former `lineage_gc.poll_interval_seconds` -and `governance_retention.audit_events_delete_batch_limit` settings accept only -legacy defaults (86400 and 500) during migration and are absent from canonical -configuration. Custom values fail validation; review them before upgrading. -Retention ages, grace windows and explicit holds remain supported. +accepts only its legacy default (86400) and is absent from canonical configuration; +custom lineage cadence still requires explicit migration. In contrast, +`governance_retention.audit_events_delete_batch_limit` is supported again: a +positive integer up to PostgreSQL int32, default 500. Enterprise audit cleanup +applies the configured limit separately to audit events and erasure tombstones +in the existing transaction. The earlier retirement did not eliminate this +throughput/transaction-size choice. Stored custom values are preserved; invalid +values fail loading without resetting other saved policy. Retention enablement, +ages, grace windows and holds remain independent. OSS accepts this enterprise +policy without executing audit-event cleanup. ### Session outcome cutovers diff --git a/reflexio/housekeeping_policy.py b/reflexio/housekeeping_policy.py index 8cad4e785..754842e95 100644 --- a/reflexio/housekeeping_policy.py +++ b/reflexio/housekeeping_policy.py @@ -1,4 +1,4 @@ -"""Code-owned housekeeping execution and safe legacy configuration preflight. +"""Housekeeping cadence defaults and safe legacy configuration preflight. No server imports: configuration parsing and image bootstrap use this module. """ @@ -8,13 +8,9 @@ from reflexio.config_migration import ConfigurationMigrationError DAILY_SWEEP_SECONDS = 86_400 -AUDIT_DELETE_BATCH_LIMIT = 500 # Frozen compatibility defaults, independent of future policy calibration. -_RETIRED_FIELDS = ( - ("lineage_gc", "poll_interval_seconds", 86_400), - ("governance_retention", "audit_events_delete_batch_limit", 500), -) +_RETIRED_FIELDS = (("lineage_gc", "poll_interval_seconds", 86_400),) class HousekeepingMigrationError(ConfigurationMigrationError): @@ -44,8 +40,7 @@ def strip_legacy_housekeeping_fields(payload: dict[str, Any]) -> dict[str, Any]: raise HousekeepingMigrationError( "Retired housekeeping settings require explicit migration: " + ", ".join(conflicts) - + ". Remove these fields to adopt code-owned daily sweeps and " - "500-row deletion batches per table." + + ". Remove these fields to adopt code-owned daily sweeps." ) cleaned = dict(payload) for section, field, _ in _RETIRED_FIELDS: diff --git a/reflexio/models/config_schema.py b/reflexio/models/config_schema.py index 3344102bb..30af94267 100644 --- a/reflexio/models/config_schema.py +++ b/reflexio/models/config_schema.py @@ -949,13 +949,12 @@ class GovernanceRetentionConfig(BaseModel): audit_events_retention_enabled: bool = False audit_events_retention_days: int = Field(default=365, gt=0) - @model_validator(mode="before") - @classmethod - def discard_legacy_execution_settings(cls, data: Any) -> Any: - """Accept former defaults, reject custom execution policy inputs.""" - return strip_legacy_housekeeping_fields({"governance_retention": data})[ - "governance_retention" - ] + audit_events_delete_batch_limit: int = Field( + default=500, + gt=0, + le=(1 << 31) - 1, + description="Maximum expired rows deleted per table in one governance sweep.", + ) @dataclass(frozen=True) diff --git a/tests/models/test_housekeeping_policy.py b/tests/models/test_housekeeping_policy.py index d654a020d..4a51cc0e3 100644 --- a/tests/models/test_housekeeping_policy.py +++ b/tests/models/test_housekeeping_policy.py @@ -20,12 +20,6 @@ "section,field,default,model", [ ("lineage_gc", "poll_interval_seconds", 86400, LineageGCConfig), - ( - "governance_retention", - "audit_events_delete_batch_limit", - 500, - GovernanceRetentionConfig, - ), ], ) def test_legacy_defaults_are_accepted_but_not_exposed(section, field, default, model): @@ -41,7 +35,6 @@ def test_legacy_defaults_are_accepted_but_not_exposed(section, field, default, m "section,field", [ ("lineage_gc", "poll_interval_seconds"), - ("governance_retention", "audit_events_delete_batch_limit"), ], ) @pytest.mark.parametrize( @@ -65,7 +58,7 @@ def test_cleanup_preserves_policy_holds_and_opaque_metadata_without_mutating(): "governance_retention": { "audit_events_retention_enabled": False, "audit_events_retention_days": 30, - "audit_events_delete_batch_limit": 500, + "audit_events_delete_batch_limit": 2000, }, "opaque": {"poll_interval_seconds": 60, "audit_events_delete_batch_limit": 1}, } @@ -80,16 +73,13 @@ def test_cleanup_preserves_policy_holds_and_opaque_metadata_without_mutating(): assert cfg.lineage_gc.tombstone_grace_window_days == 120 assert cfg.governance_retention.audit_events_retention_enabled is False assert cfg.governance_retention.audit_events_retention_days == 30 + assert cfg.governance_retention.audit_events_delete_batch_limit == 2000 @pytest.mark.parametrize( "model,payload", [ (LineageGCConfig, {"poll_interval_seconds": "private-legacy-value"}), - ( - GovernanceRetentionConfig, - {"audit_events_delete_batch_limit": "private-legacy-value"}, - ), ( Config, { @@ -106,3 +96,62 @@ def test_migration_error_text_does_not_echo_config_values(model, payload): assert "explicit migration" in str(exc.value) assert "private-legacy-value" not in str(exc.value) assert "private-context" not in str(exc.value) + + +@pytest.mark.parametrize("limit", [1, 2, 500, 2000, (1 << 31) - 1]) +def test_supported_audit_batch_survives_cleanup_and_roundtrip(limit): + from reflexio.models.config_schema import validate_stored_config + + payload = { + "storage_config": None, + "lineage_gc": {"enabled": False}, + "governance_retention": { + "audit_events_retention_enabled": False, + "audit_events_retention_days": 45, + "audit_events_delete_batch_limit": limit, + }, + } + assert housekeeping_config_conflicts(payload) == [] + assert strip_legacy_housekeeping_fields(payload) == payload + cfg = validate_stored_config(payload) + assert cfg.governance_retention.audit_events_delete_batch_limit == limit + assert cfg.governance_retention.audit_events_retention_enabled is False + assert cfg.lineage_gc.enabled is False + assert ( + validate_stored_config( + cfg.model_dump(mode="json") + ).governance_retention.audit_events_delete_batch_limit + == limit + ) + + +@pytest.mark.parametrize( + "limit", + [ + 0, + -1, + 1 << 31, + 2.5, + "private-invalid-limit", + None, + float("inf"), + float("nan"), + {}, + [], + ], +) +def test_invalid_supported_batch_fails_without_disclosing_values(limit): + from reflexio.config_migration import StoredConfigurationError + from reflexio.models.config_schema import validate_stored_config + + payload = { + "storage_config": None, + "governance_retention": {"audit_events_delete_batch_limit": limit}, + } + with pytest.raises(StoredConfigurationError, match="governance_retention") as error: + validate_stored_config(payload) + assert "private-invalid-limit" not in str(error.value) + + +def test_audit_batch_defaults_to_previous_500_rows(): + assert GovernanceRetentionConfig().audit_events_delete_batch_limit == 500 diff --git a/tests/server/services/configurator/test_config_storage_contract.py b/tests/server/services/configurator/test_config_storage_contract.py index 7303c4633..9fa0a2028 100644 --- a/tests/server/services/configurator/test_config_storage_contract.py +++ b/tests/server/services/configurator/test_config_storage_contract.py @@ -261,7 +261,6 @@ def test_legacy_shadow_file_load_patch_save_roundtrip(tmp_path: Path) -> None: ("playbook_optimizer_config", "abort_cooldown_threshold"), ("playbook_optimizer_config", "cooldown_after_aborts_seconds"), ("lineage_gc", "poll_interval_seconds"), - ("governance_retention", "audit_events_delete_batch_limit"), ], ) def test_retired_custom_policy_propagates_without_resetting_file( @@ -295,7 +294,16 @@ def test_retired_custom_policy_propagates_without_resetting_file( assert path.read_bytes() == before -def test_invalid_recognized_policy_preserves_file_and_blocks_cli_fallback(tmp_path): +@pytest.mark.parametrize( + "invalid_policy", + [ + {"audit_events_retention_days": 0}, + {"audit_events_delete_batch_limit": 0}, + ], +) +def test_invalid_recognized_policy_preserves_file_and_blocks_cli_fallback( + tmp_path, invalid_policy +): from reflexio.cli.bootstrap_config import ( load_storage_from_config, save_storage_to_config, @@ -307,7 +315,7 @@ def test_invalid_recognized_policy_preserves_file_and_blocks_cli_fallback(tmp_pa "storage_config": {"type": "sqlite"}, "lineage_gc": {"enabled": False}, "agent_context_prompt": "private-customer-prompt", - "governance_retention": {"audit_events_retention_days": 0}, + "governance_retention": invalid_policy, } path = Path(storage.config_file) path.parent.mkdir(parents=True) @@ -321,10 +329,15 @@ def test_invalid_recognized_policy_preserves_file_and_blocks_cli_fallback(tmp_pa with pytest.raises(StoredConfigurationError, match="governance_retention"): load() assert path.read_bytes() == original - payload["governance_retention"]["audit_events_retention_days"] = 45 + payload["governance_retention"] = { + "audit_events_retention_days": 45, + "audit_events_delete_batch_limit": 2, + } path.write_text(json.dumps(payload)) cfg = storage.load_config() assert cfg.lineage_gc.enabled is False assert cfg.governance_retention.audit_events_retention_days == 45 storage.save_config(cfg) - assert storage.load_config().lineage_gc.enabled is False + reloaded = storage.load_config() + assert reloaded.lineage_gc.enabled is False + assert reloaded.governance_retention.audit_events_delete_batch_limit == 2 From 2452d0b9ea1f8fd4869d5978a8b9db932847a48d Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 16:44:13 -0700 Subject: [PATCH 2/6] fix: preserve complete configuration snapshots in bundled editor --- docs/components/configure/config-editor.tsx | 16 +------- docs/components/configure/sections.tsx | 40 ++++++++++++------ docs/lib/config-schema.ts | 32 +++++++++++++-- docs/scripts/test-correlation.mjs | 45 +++++++++++++++++++++ 4 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/components/configure/config-editor.tsx b/docs/components/configure/config-editor.tsx index 35ee7a325..b8651d789 100644 --- a/docs/components/configure/config-editor.tsx +++ b/docs/components/configure/config-editor.tsx @@ -7,6 +7,7 @@ import { useSettings } from "@/hooks/use-settings"; import { type ReflexioConfig, defaultConfig, + hydrateConfig, serializeConfig, } from "@/lib/config-schema"; import { @@ -29,19 +30,6 @@ type Status = | { kind: "error"; message: string } | { kind: "success"; message: string }; -// Merge server response into our typed defaults. Server may omit optional -// fields; we rely on defaults so subsequent form renders stay stable. -function hydrate(raw: unknown): ReflexioConfig { - const base = defaultConfig(); - if (!raw || typeof raw !== "object") return base; - const incoming = raw as Partial; - return { - ...base, - ...incoming, - agent_success_config: incoming.agent_success_config ?? null, - }; -} - function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; @@ -69,7 +57,7 @@ export function ConfigEditor() { const body = await res.text(); throw new Error(`GET /api/get_config failed (${res.status}): ${body}`); } - return hydrate(await res.json()); + return hydrateConfig(await res.json()); }, [baseUrl, apiKey]); const load = useCallback(async (signal?: AbortSignal) => { diff --git a/docs/components/configure/sections.tsx b/docs/components/configure/sections.tsx index 21e361e14..79ae74f25 100644 --- a/docs/components/configure/sections.tsx +++ b/docs/components/configure/sections.tsx @@ -19,17 +19,17 @@ import { TextAreaField, } from "./primitives"; import { - APIKeyConfig, - AgentSuccessConfig, + type APIKeyConfig, + type AgentSuccessConfig, EXTRACTION_PRESET_LABELS, - ExtractionPreset, - LLMConfig, + type ExtractionPreset, + type LLMConfig, PRESET_VALUES, - ProfileExtractorConfig, - ReflexioConfig, - StorageConfigSQLite, - ToolUseConfig, - UserPlaybookExtractorConfig, + type ProfileExtractorConfig, + type ReflexioConfig, + type StorageConfigSQLite, + type ToolUseConfig, + type UserPlaybookExtractorConfig, defaultAgentSuccess, defaultPlaybookExtractor, defaultProfileExtractor, @@ -38,6 +38,18 @@ import { type SetConfig = (updater: (prev: ReflexioConfig) => ReflexioConfig) => void; +// Names are editable, so row identity belongs to the UI rather than the payload. +const toolItemIds = new WeakMap(); +let nextToolItemId = 0; +function toolItemId(item: ToolUseConfig): number { + let id = toolItemIds.get(item); + if (id === undefined) { + id = nextToolItemId++; + toolItemIds.set(item, id); + } + return id; +} + export function StorageSection({ value, setConfig, @@ -62,7 +74,7 @@ export function StorageSection({ onChange={(v) => setConfig((prev) => ({ ...prev, - storage_config: { db_path: v }, + storage_config: { ...prev.storage_config, db_path: v }, })) } placeholder="(default)" @@ -263,7 +275,7 @@ export function APIKeysSection({ id="key-openai" type="password" value={keys.openai?.api_key ?? null} - onChange={(v) => update({ openai: v ? { api_key: v } : null })} + onChange={(v) => update({ openai: { ...keys.openai, api_key: v } })} placeholder="sk-…" /> @@ -730,7 +742,9 @@ export function ToolsSection({ const updateAt = (idx: number, patch: Partial) => { setConfig((prev) => { const next = [...(prev.tool_can_use ?? [])]; - next[idx] = { ...next[idx], ...patch }; + const previous = next[idx]; + next[idx] = { ...previous, ...patch }; + toolItemIds.set(next[idx], toolItemId(previous)); return { ...prev, tool_can_use: next }; }); }; @@ -761,7 +775,7 @@ export function ToolsSection({ )} {items.map((item, idx) => ( removeAt(idx)} > diff --git a/docs/lib/config-schema.ts b/docs/lib/config-schema.ts index f5343e59e..3fd2a6dd3 100644 --- a/docs/lib/config-schema.ts +++ b/docs/lib/config-schema.ts @@ -14,6 +14,12 @@ export interface StorageConfigSQLite { export interface OpenAIConfig { api_key: string | null; + azure_config?: { + api_key: string; + endpoint: string; + api_version: string; + deployment_name?: string | null; + } | null; } export interface SimpleKeyConfig { @@ -193,6 +199,19 @@ export function defaultTool(): ToolUseConfig { return { tool_name: "", tool_description: "" }; } +// Hydration keeps the complete server snapshot while defaults stabilize the UI. +// The editor renders a subset; its full-config save must retain other policies. +export function hydrateConfig(raw: unknown): ReflexioConfig { + const base = defaultConfig(); + if (!raw || typeof raw !== "object") return base; + const incoming = raw as Partial; + return { + ...base, + ...incoming, + agent_success_config: incoming.agent_success_config ?? null, + }; +} + // Strip empty strings → null and empty arrays → null so the server receives // a clean payload. The Pydantic model rejects empty strings for `NonEmptyStr` // fields, so leaving them in would produce confusing 422s. @@ -219,11 +238,14 @@ export function serializeConfig(config: ReflexioConfig): unknown { const entry = out[provider]; if (!entry || clean(entry.api_key) === null) out[provider] = null; } - if (out.openai && clean(out.openai.api_key) === null) out.openai = null; + if (out.openai && clean(out.openai.api_key) === null) { + out.openai = out.openai.azure_config ? { ...out.openai, api_key: null } : null; + } if (out.dashscope && clean(out.dashscope.api_key) === null) { out.dashscope = null; } else if (out.dashscope) { out.dashscope = { + ...out.dashscope, api_key: out.dashscope.api_key, api_base: clean(out.dashscope.api_base), }; @@ -245,6 +267,7 @@ export function serializeConfig(config: ReflexioConfig): unknown { const cleanLlm = (llm: LLMConfig | null): LLMConfig | null => { if (!llm) return null; const out: LLMConfig = { + ...llm, should_run_model_name: clean(llm.should_run_model_name), generation_model_name: clean(llm.generation_model_name), embedding_model_name: clean(llm.embedding_model_name), @@ -297,9 +320,10 @@ export function serializeConfig(config: ReflexioConfig): unknown { }; return { - storage_config: config.storage_config - ? { db_path: clean(config.storage_config.db_path) } - : null, + ...config, + storage_config: config.storage_config && "db_path" in config.storage_config + ? { ...config.storage_config, db_path: clean(config.storage_config.db_path) } + : config.storage_config, agent_context_prompt: clean(config.agent_context_prompt), tool_can_use: config.tool_can_use?.length ? config.tool_can_use : null, profile_extractor_config: cleanExtractor(config.profile_extractor_config), diff --git a/docs/scripts/test-correlation.mjs b/docs/scripts/test-correlation.mjs index 5da227ea4..2cf2154eb 100644 --- a/docs/scripts/test-correlation.mjs +++ b/docs/scripts/test-correlation.mjs @@ -60,6 +60,51 @@ assert.equal(serialized.agent_success_config.retrieved_learning_sampling_rate, 0 assert.equal(serialized.agent_success_config.request_sources_enabled[0], "api"); assert.equal(serialized.profile_extractor_config.window_size_override, 23); +// A subset editor must preserve the fetched snapshot through full replacement. +const fetched = { + ...configSchema.defaultConfig(), + governance_retention: { + audit_events_retention_enabled: false, + audit_events_retention_days: 45, + audit_events_delete_batch_limit: 2000, + }, + lineage_gc: { enabled: false, tombstone_grace_window_days: 120 }, + future_policy: { enabled: false, budget: 37 }, + storage_config: { db_path: " ", future_storage_option: "keep" }, + llm_config: { generation_model_name: "keep-model", future_llm_option: 37 }, + api_key_config: { + openai: { api_key: null, azure_config: { + api_key: "synthetic-azure-key", endpoint: "https://example.openai.azure.com/", + api_version: "2024-02-15-preview", deployment_name: "keep-deployment", + } }, + dashscope: { api_key: "test-key", api_base: "", future_provider_option: 37 }, + }, +}; +const before = JSON.stringify(fetched); +const edited = configSchema.hydrateConfig(fetched); +edited.agent_context_prompt = "An unrelated edit"; +const roundtrip = JSON.parse(JSON.stringify(configSchema.serializeConfig(edited))); +assert.deepEqual(roundtrip.governance_retention, fetched.governance_retention); +assert.deepEqual(roundtrip.lineage_gc, fetched.lineage_gc); +assert.deepEqual(roundtrip.future_policy, fetched.future_policy); +assert.deepEqual(roundtrip.api_key_config.openai, fetched.api_key_config.openai); +assert.equal(roundtrip.agent_context_prompt, "An unrelated edit"); +assert.equal(roundtrip.storage_config.db_path, null); +assert.equal(roundtrip.storage_config.future_storage_option, "keep"); +assert.equal(roundtrip.llm_config.future_llm_option, 37); +assert.equal(roundtrip.api_key_config.dashscope.future_provider_option, 37); +assert.equal(roundtrip.api_key_config.dashscope.api_base, null); +assert.equal(JSON.stringify(fetched), before); +// A snapshot from another storage implementation must not become SQLite. +const remote = { type: "postgres", db_url: "postgresql://local-test", schema: "tenant" }; +assert.deepEqual( + configSchema.serializeConfig(configSchema.hydrateConfig({ ...fetched, storage_config: remote })).storage_config, + remote, +); +// Explicitly clearing a represented optional section remains a real clear. +edited.llm_config = null; +assert.equal(configSchema.serializeConfig(edited).llm_config, null); + const { generatePythonCode } = load("execution/code-generator.ts"); const { initialParams } = load("execution/default-params.ts"); const { unifiedSearchMethods } = load("methods/unified-search.ts"); From 7b2b084e5c2a660d30a3776090eefebc2e1e1587 Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 16:55:03 -0700 Subject: [PATCH 3/6] refactor: retire unused Supabase HTTP reader configuration --- developer.md | 8 ++++++++ reflexio/models/config_schema.py | 10 ++++++++-- tests/models/test_storage_config_union.py | 22 +++++++++++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/developer.md b/developer.md index 220a995eb..dbc8b951a 100644 --- a/developer.md +++ b/developer.md @@ -803,6 +803,14 @@ instruction. Unprepared extraction keeps its turn-reference contract. Strict source visibility validation, bounded corrective repair, and evidence-carrier limits are unchanged. +### Supabase storage schema compatibility + +`StorageConfigSupabase` accepts legacy `read_url`/`read_key` fields only to discard +them during normalization; they are absent from canonical output and the schema. +Enterprise Supabase reads use native SQL, so these HTTP reader fields did not +select a replica. `StorageConfigPostgres.read_db_url` and reader pool settings +remain supported for actual native replica routing. + ### Stored configuration validation Persisted reads use `validate_stored_config`: removed unknown fields are ignored diff --git a/reflexio/models/config_schema.py b/reflexio/models/config_schema.py index 30af94267..821dd0a4c 100644 --- a/reflexio/models/config_schema.py +++ b/reflexio/models/config_schema.py @@ -402,8 +402,14 @@ class StorageConfigSupabase(BaseModel): key: NonEmptyStr db_url: NonEmptyStr schema_name: str | None = Field(default=None, alias="schema") - read_url: NonEmptyStr | None = None - read_key: NonEmptyStr | None = None + + @model_validator(mode="before") + @classmethod + def retire_http_reader(cls, data: Any) -> Any: + """Old HTTP reader fields no longer affect native SQL read execution.""" + if isinstance(data, dict): + return {k: v for k, v in data.items() if k not in {"read_url", "read_key"}} + return data class StorageConfigPostgres(BaseModel): diff --git a/tests/models/test_storage_config_union.py b/tests/models/test_storage_config_union.py index a338915b7..0e9f2eb70 100644 --- a/tests/models/test_storage_config_union.py +++ b/tests/models/test_storage_config_union.py @@ -247,7 +247,9 @@ def test_serialized_shape_carries_no_type_tag( f"{label}: serializing the tag would rewrite every org's stored " f"config blob at the next boot upgrade" ) - assert set(dumped) == set(payload) | {k for k in dumped if dumped[k] is None} + assert set(dumped) == (set(payload) - {"read_url", "read_key"}) | { + k for k in dumped if dumped[k] is None + } def test_round_trip_preserves_the_backend() -> None: @@ -316,3 +318,21 @@ def test_invalid_current_value_in_legacy_conversion_also_fails_closed(value): assert "private-invalid-merge-budget" not in "".join( traceback.format_exception(error.value) ) + + +@pytest.mark.parametrize( + "read_url,read_key", [(None, None), ("https://unused.example", "unused"), ("", "")] +) +def test_obsolete_http_reader_drops_without_changing_storage_identity( + read_url, read_key +): + payload = {**_PROD_SUPABASE, "read_url": read_url, "read_key": read_key} + before = dict(payload) + config = validate_stored_config({"storage_config": payload}) + assert isinstance(config.storage_config, StorageConfigSupabase) + canonical = config.storage_config.model_dump(mode="json") + assert canonical == {k: v for k, v in payload.items() if not k.startswith("read_")} + assert payload == before + assert _ADAPTER.validate_python(canonical) == config.storage_config + assert "read_url" not in StorageConfigSupabase.model_fields + assert "read_key" not in StorageConfigSupabase.model_fields From 7f908e80be0ce3f8424b432f7b532c5be80dee91 Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 17:14:15 -0700 Subject: [PATCH 4/6] refactor: unify inference endpoint and launch ownership --- README.md | 5 ++ developer.md | 18 ++++ reflexio/cli/README.md | 6 +- reflexio/cli/run_services.py | 13 ++- reflexio/inference_launch.py | 82 +++++++++++++++++++ .../providers/embedding_service_provider.py | 18 +--- tests/test_inference_launch.py | 69 ++++++++++++++++ 7 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 reflexio/inference_launch.py create mode 100644 tests/test_inference_launch.py diff --git a/README.md b/README.md index df177d89a..2ca55657a 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,11 @@ Client (SDK / CLI / HTTP API) └─ UnifiedSearchService → Profiles + user/agent playbooks ``` +The import-light [inference launch resolver](reflexio/inference_launch.py) gives +providers and launchers one endpoint and process-ownership contract. An explicit +service URL attaches to an existing inference process; otherwise the launcher +owns a separate child. See [developer.md](developer.md) for migration and runtime details. + See [developer.md](developer.md) for project structure, supported LLM providers, and development setup. ## Documentation diff --git a/developer.md b/developer.md index dbc8b951a..b322c590f 100644 --- a/developer.md +++ b/developer.md @@ -869,3 +869,21 @@ may mark `SessionJudgmentInputs.requests_ordered` to preserve database ordering before public timestamps are converted to whole seconds. The internal `OutcomePrefixPrecondition.verdict_settled_at` accepts fractional epoch seconds; public outcome timestamps keep their existing integer representation. + + +### Inference endpoint and process ownership + +`reflexio.inference_launch` is the import-light shared resolver for provider URLs, +CLI ordinary-child ownership, and enterprise Docker startup/health gating. An +explicit `REFLEXIO_EMBEDDING_SERVICE_URL`, including localhost, attaches to a +caller-owned process. With no URL, the launcher owns the separate inference +child; API workers never load the local model. The enterprise API-local query +child retains its independent reserved route. + +The legacy daemon hostname and disable-local switch are retired. Custom legacy +hostname-only routes or disabled ordinary startup without an endpoint raise a +value-free `ConfigurationMigrationError`; specify the full URL to migrate. +Hostname-based local-service routes used a 30-second request default, while an +explicit URL selects the existing 2-second internal-service default. Set +`REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS` explicitly when preserving that older +budget. Shadowed legacy settings do not override a canonical URL. diff --git a/reflexio/cli/README.md b/reflexio/cli/README.md index 2f1bc84c5..b3a65a5c2 100644 --- a/reflexio/cli/README.md +++ b/reflexio/cli/README.md @@ -104,8 +104,12 @@ Service shutdown unwinds interrupted output before logging and stopping children including signals received during startup. Repeated shutdown signals do not reenter cleanup or trigger service respawns. +Provider URL resolution and launcher ownership share the pure +`reflexio.inference_launch` resolver. An explicit endpoint, including localhost, +belongs to its existing owner; see `developer.md` for legacy-setting migration. + `services start --only backend` starts the separate `embedding` process unless -`REFLEXIO_EMBEDDING_SERVICE_URL` routes inference to a remote service. This is +`REFLEXIO_EMBEDDING_SERVICE_URL` supplies an explicit endpoint. This is also true when an organization uses cloud embeddings because the service owns reranking. The service exposes `POST /v1/embeddings` and `POST /v1/rerank`, and supports `local/nomic-embed-v1.5`, diff --git a/reflexio/cli/run_services.py b/reflexio/cli/run_services.py index a425a67f4..02895a15d 100644 --- a/reflexio/cli/run_services.py +++ b/reflexio/cli/run_services.py @@ -13,6 +13,7 @@ import reflexio from reflexio.cli.env_loader import load_reflexio_env from reflexio.cli.utils import ServiceConfig, get_env_port, run_services +from reflexio.inference_launch import ordinary_inference_launch logger = logging.getLogger(__name__) @@ -297,13 +298,17 @@ def parse_only_flag(only: str | None, default_services: set[str]) -> set[str]: """ if only: return {s.strip() for s in only.split(",")} - return default_services + return set(default_services) def should_start_local_embedding_service() -> bool: - """Return True unless inference is explicitly routed to a remote service.""" - remote_url = os.environ.get("REFLEXIO_EMBEDDING_SERVICE_URL", "").strip() - return not remote_url + """Return whether the launcher owns the ordinary colocated child.""" + return ( + ordinary_inference_launch( + os.environ, default_port=DEFAULT_OSS_PORTS["embedding"] + ).mode + == "colocated" + ) def _ensure_nextjs_dependencies(project_dir: Path) -> bool: diff --git a/reflexio/inference_launch.py b/reflexio/inference_launch.py new file mode 100644 index 000000000..c4be02b74 --- /dev/null +++ b/reflexio/inference_launch.py @@ -0,0 +1,82 @@ +"""Pure inference endpoint and launcher ownership; no model or server imports.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + +from reflexio.config_migration import ConfigurationMigrationError + +_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL" +_LEGACY_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST" +_LEGACY_DISABLE = "REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON" +_QUERY_URL = "REFLEXIO_QUERY_EMBEDDING_SERVICE_URL" +_API_QUERY_URL = "http://127.0.0.1:8093" + + +@dataclass(frozen=True) +class InferenceLaunchPlan: + """The owned process role and its startup health gate, if any.""" + + mode: Literal["colocated", "attached", "query"] + health_url: str | None + + +def inference_service_url(env: Mapping[str, str], *, default_port: int = 8072) -> str: + """Resolve one endpoint; custom legacy hosts require explicit migration.""" + explicit = env.get(_SERVICE_URL, "").strip() + if explicit: + return explicit.rstrip("/") + if env.get(_LEGACY_HOST, "").strip(): + raise ConfigurationMigrationError( + f"{_LEGACY_HOST} is retired. Configure {_SERVICE_URL} with the full " + "endpoint URL and choose REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS " + "explicitly if retaining the previous local-service request budget." + ) + port = env.get("EMBEDDING_PORT", "").strip() or str(default_port) + return f"http://127.0.0.1:{port}" + + +def ordinary_inference_launch( + env: Mapping[str, str], *, default_port: int = 8072 +) -> InferenceLaunchPlan: + """An explicit endpoint has its own owner; otherwise own the local child.""" + endpoint = inference_service_url(env, default_port=default_port) + if env.get(_SERVICE_URL, "").strip(): + return InferenceLaunchPlan("attached", None) + if env.get(_LEGACY_DISABLE, "").strip().lower() in {"1", "true", "yes", "on"}: + raise ConfigurationMigrationError( + f"{_LEGACY_DISABLE} is retired. To attach to an existing process, " + f"configure {_SERVICE_URL} explicitly, including for localhost. " + "Choose REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS explicitly if " + "retaining the previous local-service request budget." + ) + return InferenceLaunchPlan("colocated", endpoint + "/health") + + +def container_inference_launch(env: Mapping[str, str]) -> InferenceLaunchPlan: + """Resolve Docker's independent query role before ordinary child ownership.""" + if env.get("REFLEXIO_API_QUERY_EMBEDDING_ENABLED", "false") == "true": + if env.get(_QUERY_URL, "") != _API_QUERY_URL: + raise ConfigurationMigrationError( + "API-local query mode requires its reserved loopback URL." + ) + # The ordinary disable switch never selected this independent child. + inference_service_url(env, default_port=8089) + return InferenceLaunchPlan("query", _API_QUERY_URL + "/health/model") + return ordinary_inference_launch(env, default_port=8089) + + +def main() -> None: + """Small shell adapter: fixed mode and health URL, never eval shell code.""" + try: + plan = container_inference_launch(os.environ) + except ConfigurationMigrationError as exc: + raise SystemExit(str(exc)) from None + print(plan.mode, plan.health_url or "-") + + +if __name__ == "__main__": + main() diff --git a/reflexio/server/llm/providers/embedding_service_provider.py b/reflexio/server/llm/providers/embedding_service_provider.py index 010d09706..9d243877e 100644 --- a/reflexio/server/llm/providers/embedding_service_provider.py +++ b/reflexio/server/llm/providers/embedding_service_provider.py @@ -21,6 +21,9 @@ import httpx +from reflexio.inference_launch import ( + inference_service_url as resolve_inference_service_url, +) from reflexio.server.env_utils import env_str, env_truthy, positive_int_env from reflexio.server.search_runtime import ( checkpoint, @@ -41,12 +44,9 @@ _query_contract_cache: dict[tuple[int, str, str], tuple[str, str, int]] = {} _query_contract_lock = threading.Lock() _ENV_TIMEOUT_MS = "REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS" -_ENV_EMBEDDING_PORT = "EMBEDDING_PORT" -_ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST" _ENV_CLAUDE_SMART_LOCAL = "CLAUDE_SMART_USE_LOCAL_EMBEDDING" _ENV_MAX_TEXTS_PER_REQUEST = "REFLEXIO_EMBEDDING_SERVICE_MAX_TEXTS_PER_REQUEST" CUSTOM_EMBEDDING_MODEL = "custom" -_DEFAULT_LOCAL_PORT = 8072 _DEFAULT_INTERNAL_SERVICE_TIMEOUT_MS = 2_000 _DEFAULT_LOCAL_SERVICE_TIMEOUT_MS = 30_000 # Each request embeds its whole ``input`` list in one ``model.encode()`` on the @@ -83,12 +83,6 @@ class InferenceServiceCapabilities: workload: str = "shared" -def _local_service_url() -> str: - host = os.environ.get(_ENV_DAEMON_HOST, "").strip() or "127.0.0.1" - port = os.environ.get(_ENV_EMBEDDING_PORT, str(_DEFAULT_LOCAL_PORT)) - return f"http://{host}:{port}" - - def inference_service_url() -> str: """Return the shared local/internal inference-service base URL. @@ -96,8 +90,7 @@ def inference_service_url() -> str: reranking can share the same daemon even when an organization uses cloud embeddings. """ - configured = os.environ.get(_ENV_SERVICE_URL, "").strip() - return configured.rstrip("/") if configured else _local_service_url() + return resolve_inference_service_url(os.environ) def remote_inference_service_configured() -> bool: @@ -395,9 +388,6 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode: if os.environ.get(_ENV_SERVICE_URL, "").strip(): return "internal_service" - if _uses_embedding_service(model) and os.environ.get(_ENV_DAEMON_HOST, "").strip(): - return "local_service" - if _uses_embedding_service(model): return "local_service" return "cloud" diff --git a/tests/test_inference_launch.py b/tests/test_inference_launch.py new file mode 100644 index 000000000..62ff61bf9 --- /dev/null +++ b/tests/test_inference_launch.py @@ -0,0 +1,69 @@ +"""Endpoint ownership is shared by clients, CLI and container adapters.""" + +import pytest + +from reflexio.config_migration import ConfigurationMigrationError +from reflexio.inference_launch import ( + container_inference_launch, + inference_service_url, + ordinary_inference_launch, +) + + +@pytest.mark.parametrize( + "url", + [ + "https://remote.example/inference/", + "http://127.0.0.1:9999", + "http://localhost:9999", + ], +) +def test_explicit_endpoint_attaches_without_losing_its_address(url): + env = {"REFLEXIO_EMBEDDING_SERVICE_URL": url} + assert inference_service_url(env) == url.rstrip("/") + assert ordinary_inference_launch(env).mode == "attached" + assert ordinary_inference_launch(env).health_url is None + + +@pytest.mark.parametrize("legacy", ["true", "TrUe", "1", "yes", "on"]) +def test_disabling_without_an_endpoint_requires_migration(legacy): + with pytest.raises(ConfigurationMigrationError, match="explicitly"): + ordinary_inference_launch({"REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON": legacy}) + + +@pytest.mark.parametrize("legacy", ["", "false", "0", "no", "off"]) +def test_default_ownership_and_port_context(legacy): + env = {"REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON": legacy, "EMBEDDING_PORT": " "} + assert ordinary_inference_launch(env).health_url == "http://127.0.0.1:8072/health" + assert container_inference_launch(env).health_url == "http://127.0.0.1:8089/health" + env["EMBEDDING_PORT"] = "9999" + assert ordinary_inference_launch(env).health_url == "http://127.0.0.1:9999/health" + + +def test_custom_legacy_host_is_not_silently_dropped_or_printed(): + secret = "private-customer-host.example" + env = {"REFLEXIO_EMBEDDING_DAEMON_HOST": secret} + with pytest.raises(ConfigurationMigrationError) as exc: + inference_service_url(env) + assert secret not in str(exc.value) + assert "TIMEOUT_MS" in str(exc.value) + env.update( + REFLEXIO_EMBEDDING_SERVICE_URL="https://canonical.example", + REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON="true", + ) + assert inference_service_url(env) == "https://canonical.example" + assert ordinary_inference_launch(env).mode == "attached" + + +def test_query_child_remains_independent_of_ordinary_disable_switch(): + env = { + "REFLEXIO_API_QUERY_EMBEDDING_ENABLED": "true", + "REFLEXIO_QUERY_EMBEDDING_SERVICE_URL": "http://127.0.0.1:8093", + "REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON": "true", + } + plan = container_inference_launch(env) + assert plan.mode == "query" + assert plan.health_url == "http://127.0.0.1:8093/health/model" + env["REFLEXIO_QUERY_EMBEDDING_SERVICE_URL"] = "http://remote.example" + with pytest.raises(ConfigurationMigrationError, match="reserved loopback"): + container_inference_launch(env) From c7d72646e8478451d93f29953081e26f1412d68d Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 17:23:04 -0700 Subject: [PATCH 5/6] fix: preserve query routes and remote editor storage identity --- docs/components/configure/sections.tsx | 15 +++++++++++++-- reflexio/inference_launch.py | 2 +- tests/test_inference_launch.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/components/configure/sections.tsx b/docs/components/configure/sections.tsx index 79ae74f25..ccbee7974 100644 --- a/docs/components/configure/sections.tsx +++ b/docs/components/configure/sections.tsx @@ -58,10 +58,20 @@ export function StorageSection({ setConfig: SetConfig; }) { const current = value ?? { db_path: null }; + // Preserve remote/managed snapshots without offering an incompatible SQLite edit. + const editable = Object.entries(current).every( + ([key, field]) => + key === "db_path" || + ((key === "type" || key === "storage_type") && field === "sqlite"), + ); return (
- setConfig((prev) => ({ + editable && setConfig((prev) => ({ ...prev, storage_config: { ...prev.storage_config, db_path: v }, })) diff --git a/reflexio/inference_launch.py b/reflexio/inference_launch.py index c4be02b74..bd611683f 100644 --- a/reflexio/inference_launch.py +++ b/reflexio/inference_launch.py @@ -59,7 +59,7 @@ def ordinary_inference_launch( def container_inference_launch(env: Mapping[str, str]) -> InferenceLaunchPlan: """Resolve Docker's independent query role before ordinary child ownership.""" if env.get("REFLEXIO_API_QUERY_EMBEDDING_ENABLED", "false") == "true": - if env.get(_QUERY_URL, "") != _API_QUERY_URL: + if env.get(_QUERY_URL, "").strip().rstrip("/") != _API_QUERY_URL: raise ConfigurationMigrationError( "API-local query mode requires its reserved loopback URL." ) diff --git a/tests/test_inference_launch.py b/tests/test_inference_launch.py index 62ff61bf9..5efb85885 100644 --- a/tests/test_inference_launch.py +++ b/tests/test_inference_launch.py @@ -67,3 +67,15 @@ def test_query_child_remains_independent_of_ordinary_disable_switch(): env["REFLEXIO_QUERY_EMBEDDING_SERVICE_URL"] = "http://remote.example" with pytest.raises(ConfigurationMigrationError, match="reserved loopback"): container_inference_launch(env) + + +@pytest.mark.parametrize("url", ["http://127.0.0.1:8093", " http://127.0.0.1:8093/ "]) +def test_query_endpoint_normalization_matches_request_routing(url): + plan = container_inference_launch( + { + "REFLEXIO_API_QUERY_EMBEDDING_ENABLED": "true", + "REFLEXIO_QUERY_EMBEDDING_SERVICE_URL": url, + } + ) + assert plan.mode == "query" + assert plan.health_url == "http://127.0.0.1:8093/health/model" From affe14a95d792a9822314161128c7540a82edb15 Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sat, 17 Oct 2026 18:04:05 -0700 Subject: [PATCH 6/6] fix: share boolean migration parsing without server imports --- README.md | 3 +++ reflexio/env_utils.py | 11 +++++++++++ reflexio/inference_launch.py | 3 ++- reflexio/server/env_utils.py | 13 ++----------- tests/server/test_env_utils.py | 5 +++++ tests/test_inference_launch.py | 26 ++++++++++++++++++++++++++ 6 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 reflexio/env_utils.py diff --git a/README.md b/README.md index 2ca55657a..31a1be6ca 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,9 @@ The import-light [inference launch resolver](reflexio/inference_launch.py) gives providers and launchers one endpoint and process-ownership contract. An explicit service URL attaches to an existing inference process; otherwise the launcher owns a separate child. See [developer.md](developer.md) for migration and runtime details. +The shared legacy boolean parser lives in [env_utils.py](reflexio/env_utils.py) +so launchers can reuse it without importing server startup. The existing +`reflexio.server.env_utils.env_truthy` import remains a compatibility export. See [developer.md](developer.md) for project structure, supported LLM providers, and development setup. diff --git a/reflexio/env_utils.py b/reflexio/env_utils.py new file mode 100644 index 000000000..4dec7201d --- /dev/null +++ b/reflexio/env_utils.py @@ -0,0 +1,11 @@ +"""Environment parsing shared by import-light launchers and the server.""" + + +def env_truthy(value: str) -> bool: + """Parse the historical permissive boolean format. + + New boolean settings should use ``server.env_utils.env_bool`` for strict + validation. This parser retains compatibility for existing callers and + migration checks without importing the server's startup side effects. + """ + return value.strip().lower() in {"1", "true", "yes", "on"} diff --git a/reflexio/inference_launch.py b/reflexio/inference_launch.py index bd611683f..e0f235d42 100644 --- a/reflexio/inference_launch.py +++ b/reflexio/inference_launch.py @@ -8,6 +8,7 @@ from typing import Literal from reflexio.config_migration import ConfigurationMigrationError +from reflexio.env_utils import env_truthy _SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL" _LEGACY_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST" @@ -46,7 +47,7 @@ def ordinary_inference_launch( endpoint = inference_service_url(env, default_port=default_port) if env.get(_SERVICE_URL, "").strip(): return InferenceLaunchPlan("attached", None) - if env.get(_LEGACY_DISABLE, "").strip().lower() in {"1", "true", "yes", "on"}: + if env_truthy(env.get(_LEGACY_DISABLE, "")): raise ConfigurationMigrationError( f"{_LEGACY_DISABLE} is retired. To attach to an existing process, " f"configure {_SERVICE_URL} explicitly, including for localhost. " diff --git a/reflexio/server/env_utils.py b/reflexio/server/env_utils.py index 2dc585d58..057184999 100644 --- a/reflexio/server/env_utils.py +++ b/reflexio/server/env_utils.py @@ -6,6 +6,8 @@ import os from collections.abc import Mapping +from reflexio.env_utils import env_truthy as env_truthy + def env_get(env: Mapping[str, str], name: str) -> str: """Return a stripped environment value or an empty string.""" @@ -126,17 +128,6 @@ def env_bool( ) -def env_truthy(value: str) -> bool: - """Return whether a string environment value is truthy. - - Deprecated in favour of :func:`env_bool`, which takes the variable NAME and - can therefore name it when refusing a bad value. Retained because it is a - public export of this package; it keeps the historical permissive set so an - external caller's behaviour does not change silently. - """ - return value.strip().lower() in {"1", "true", "yes", "on"} - - def positive_int_env(name: str, default: int, logger: logging.Logger) -> int: """Resolve a strictly-positive int from environment variable ``name``. diff --git a/tests/server/test_env_utils.py b/tests/server/test_env_utils.py index d2fb95788..37d4b9ac5 100644 --- a/tests/server/test_env_utils.py +++ b/tests/server/test_env_utils.py @@ -2,6 +2,7 @@ import pytest +from reflexio.env_utils import env_truthy as shared_env_truthy from reflexio.server.env_utils import ( EnvBoolError, env_bool, @@ -13,6 +14,10 @@ ) +def test_legacy_parser_export_is_the_shared_parser() -> None: + assert env_truthy is shared_env_truthy + + def test_env_get_strips_values() -> None: assert env_get({"KEY": " value "}, "KEY") == "value" assert env_get({}, "KEY") == "" diff --git a/tests/test_inference_launch.py b/tests/test_inference_launch.py index 5efb85885..204c7a322 100644 --- a/tests/test_inference_launch.py +++ b/tests/test_inference_launch.py @@ -1,5 +1,9 @@ """Endpoint ownership is shared by clients, CLI and container adapters.""" +import subprocess +import sys +from pathlib import Path + import pytest from reflexio.config_migration import ConfigurationMigrationError @@ -10,6 +14,28 @@ ) +def test_launcher_reuses_parser_without_importing_server_or_site_packages(): + package_root = Path(__file__).resolve().parents[1] + script = f""" +import sys +sys.path.insert(0, {str(package_root)!r}) +from reflexio.inference_launch import ordinary_inference_launch +from reflexio.env_utils import env_truthy +assert env_truthy(' ON ') +assert ordinary_inference_launch({{}}).mode == 'colocated' +assert not any(name == 'reflexio.server' or name.startswith('reflexio.server.') + for name in sys.modules) +""" + result = subprocess.run( # noqa: S603 — fixed interpreter and repository-owned probe + [sys.executable, "-I", "-S", "-c", script], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + assert result.returncode == 0, result.stderr + + @pytest.mark.parametrize( "url", [