From 289119343bc81fee14c0f422d80385c7524efc40 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:26:11 +0800 Subject: [PATCH 1/3] feat(storage): configure creation-time Goal storage targets Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../capability-localization.ts | 4 + .../machine-configuration-settings.tsx | 9 ++ .../local-authority-provider-selection.md | 51 ++++++++ loopx/bootstrap.py | 21 ++++ loopx/capabilities/configuration_ui.py | 6 + .../machine_configuration/builtins.py | 3 + .../coordination/local_authority_defaults.py | 53 ++++++++ .../coordination/local_authority_defaults.ts | 29 +++++ .../coordination/local_authority_provider.ts | 37 ++++-- .../control_plane/effect_runtime_handlers.ts | 2 + .../project_registry_io_manifest_v1.json | 4 +- .../test_new_goal_storage_defaults.py | 114 ++++++++++++++++++ .../local_authority_defaults.test.ts | 27 +++++ .../local_authority_migration.test.ts | 7 ++ tests/test_chat_machine_configuration_api.py | 7 +- 15 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 loopx/control_plane/coordination/local_authority_defaults.py create mode 100644 loopx/control_plane/coordination/local_authority_defaults.ts create mode 100644 tests/control_plane/test_new_goal_storage_defaults.py create mode 100644 tests/control_plane_ts/local_authority_defaults.test.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 191a51e8fb..0756239373 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -12,6 +12,7 @@ type FieldCopy = Record> = { en: { + goal_storage: { displayName: "New Goal storage target", description: "Fixed at creation and used after reviewed promotion. Existing Goals require a separate backed-up migration." }, manager_runtime: { displayName: "Runtime", description: "Selects the persistent host-tool profile used by owner manager conversations.", @@ -77,6 +78,7 @@ const capabilityCopy: Record> = { }, }, "zh-CN": { + goal_storage: { displayName: "新 Goal 的目标存储", description: "创建时固定,审核晋升后生效。已有 Goal 需要单独备份、迁移;更改这里不会迁移数据。" }, manager_runtime: { displayName: "运行环境", description: "选择管家会话持续生效的宿主工具模式。", @@ -145,6 +147,7 @@ const capabilityCopy: Record> = { const fieldCopy: Record = { en: { + new_goal_provider: { label: "New Goal storage target (after promotion)", description: "File or SQLite; this setting does not perform promotion or migration." }, runtime_profile: { label: "Runtime profile", description: "Restricted keeps scoped LoopX reads only. Trusted owner enables normal host tools while protected operations retain separate checks." }, selection_policy: { label: "Selection policy", description: "Preferred allows an explicit user choice; pinned rejects another executor; flexible permits fallback only inside the eligible pool." }, executor_endpoint: { label: "Primary steward executor", description: "The preferred or pinned executor for this machine. In a flexible pool it is tried first when available." }, @@ -171,6 +174,7 @@ const fieldCopy: Record = { enabled_agents: { label: "Enabled Goal Agents", description: "Enter one registered Goal-local Agent id per line. A private binding currently accepts exactly one Agent." }, }, "zh-CN": { + new_goal_provider: { label: "新 Goal 的目标存储(晋升后生效)", description: "选择 File 或 SQLite;保存设置不会自动晋升,也不会迁移已有 Goal。" }, runtime_profile: { label: "运行模式", description: "restricted 仅使用受限 LoopX 读取;trusted_owner 开放常规宿主工具,但受保护操作仍单独校验。" }, selection_policy: { label: "选择策略", description: "preferred 允许用户显式改选;pinned 拒绝其他执行器;flexible 只在已授权资源池内回退。" }, executor_endpoint: { label: "首选管家执行器", description: "本机首选或锁定的执行器;灵活池模式下优先尝试它。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 2fea7972ac..9b0369d86d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -364,6 +364,15 @@ export function MachineConfigurationSettings({ section }: { section: "steward" | + {selected.capability_id === "goal_storage" ? ( +
+ +
{locale === "zh-CN" ? "仅影响此后创建的 Goal" : "Future Goals only"}

{locale === "zh-CN" + ? "创建时固定选择,审核晋升后生效。已有 Goal 不变;迁移需单独备份、停止写入并结算租约。" + : "Fixed at creation and used after reviewed promotion. Existing Goals are unchanged; migration requires a separate backup, stopped writers and settled leases."}

+
+ ) : null} + {selected.capability_id === "periodic_report" ? (
diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index d35881fcef..811ebeff52 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -58,3 +58,54 @@ and PostgreSQL continue to share the provider-neutral transaction conformance contract; PostgreSQL's real-server qualification remains a separate gate. See [reviewed promotion and recovery](reviewed-coordination-promotion.md) for the explicit saved-plan CLI journey. + +## New Goal storage target (machine setting) + +The **New Goal storage target** setting fixes a File or SQLite target at +creation. It is not live inheritance, automatic promotion, or an existing-Goal +migration. Until separately reviewed promotion, the existing legacy source is +still authoritative. After promotion the selected provider serves canonical +Todo/lease state; Run artifacts and other independently owned stores are not +moved by this preference. + +Use **Settings → Capability Center → Device defaults → New Goal storage target** +or the revision-checked CLI: + +```sh +# goal-storage.json: +# {"schema_version":"loopx_goal_storage_defaults_v0","new_goal_provider":"sqlite"} +loopx machine-config preview --namespace goal_storage --config-json goal-storage.json +loopx machine-config apply --namespace goal_storage --config-json goal-storage.json \ + --expected-plan-revision PLAN_REVISION --execute +loopx machine-config inspect +loopx bootstrap --project ./new-project --goal-id new-project --dry-run +``` + +The preview reports `storage_target`; creation reports `storage_selection` with +`promotion_performed=false`. CLI and App creation share the same bootstrap +owner. Creation stores its intent before provider initialization, so retry after +interruption uses the same target even if the machine preference changed. +Reconnecting an existing Goal, including an implicit File Goal, does not adopt +a newer machine default. Importing existing Markdown does not count as a new +empty Goal. Explicit provider selection never falls back on failure. + +Without this namespace, existing behavior remains unchanged. To stop applying +the preference to future Goals, preview `loopx machine-config remove +--namespace goal_storage`, then use its returned plan revision with `--execute`. +Configuration rollback also affects future creation only. Neither operation +switches existing storage or removes data. A File target keeps implicit File +routing until a committed authority exists; it does not create a dangling +identity-bound selector for an empty File document. + +For already-promoted Goals use the [reviewed File/SQLite cutover](file-authority-state-log.md#reviewed-filesqlite-cutover): +stop writers, settle leases, review the saved plan, retain verified backups, +then migrate. Reverse migration must preserve newer writes. New-Goal defaults +and current-provider selection are separate facts. This opt-in setting does +not change the release default or complete D2/D3 qualification. + +### 新 Goal 的目标存储 + +这是创建时固定的目标,审核晋升后才接管 canonical Todo/lease;不是“所有数据 +已经存入 SQLite”。更改默认值只影响此后创建的空 Goal,既有 Goal、重新连接或 +导入已有 Markdown 均不自动切换。创建中断后重试沿用已记录的选择。关闭或回滚 +设置不迁回数据;已有 Goal 需停止写入、结算租约,走独立的备份和审核迁移流程。 diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index aa571d9a6a..9b8fe62a87 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -3,6 +3,7 @@ import re from pathlib import Path +from .control_plane.coordination.local_authority_defaults import new_goal_storage_target, initialize_goal_storage_target from .registry import find_registry_goal from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed from .control_plane.coordination.runtime_shadow_writer_adapter import require_runtime_shadow_capture_prepared, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture @@ -371,6 +372,12 @@ def bootstrap_project( execution_profile=execution_profile, display_name=display_name, ) + previous_goal = find_registry_goal(registry, goal_id) + storage_target = ((previous_goal or {}).get("coordination") or {}).get("storage_target") + if previous_goal is None and not state_file.exists(): + storage_target = new_goal_storage_target(runtime_root) + if storage_target is not None: + goal_entry.setdefault("coordination", {})["storage_target"] = storage_target registry, registry_goal_action = merge_goal(registry, goal_entry, force=force) state_exists = state_file.exists() @@ -496,6 +503,7 @@ def bootstrap_project( "private_boundary_note": "Add .loopx/ and .codex/goals/ to the project .gitignore if the goal state contains private evidence.", "error": str(global_writability.get("error") or "global registry is not writable"), } + storage_selection = None shadow_capture = None shadow_evidence: dict[str, Any] = {} if not dry_run: @@ -509,6 +517,13 @@ def bootstrap_project( ): current_registry = registry_transaction.payload_copy() current_goal = find_registry_goal(current_registry, goal_id) + # A concurrent creator or reconnect owns its frozen target, including absence. + if current_goal is not None or state_file.exists(): + frozen = ((current_goal or {}).get("coordination") or {}).get("storage_target") + goal_entry.setdefault("coordination", {}).pop("storage_target", None) + if frozen is not None: + goal_entry["coordination"]["storage_target"] = frozen + previous_root = resolve_runtime_root(current_registry, None, registry_path=registry_path) if previous_root != runtime_root: for previous_goal in current_registry.get("goals", []): @@ -550,6 +565,10 @@ def bootstrap_project( current_registry["common_runtime_root"] = str(runtime_root) registry, registry_goal_action = merge_goal(current_registry, goal_entry, force=force) registry_transaction.commit(registry) + # Registry intent survives an interrupted initialization. Reconnect retries + # it outside the legacy/registry locks; changing machine defaults cannot + # retarget that Goal. The TS owner refuses replacing an existing provider. + storage_selection = initialize_goal_storage_target(runtime_root, find_registry_goal(registry, goal_id) or {}) if shadow_capture is not None: shadow_evidence = settle_todo_runtime_shadow_capture({}, registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, capture=shadow_capture, emit_disabled=False) @@ -564,6 +583,8 @@ def bootstrap_project( return { **shadow_evidence, + "storage_selection": storage_selection, + "storage_target": (find_registry_goal(registry, goal_id) or {}).get("coordination", {}).get("storage_target"), "ok": True, "dry_run": dry_run, "project": str(project), diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index 16fdb51f96..c568294335 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -83,6 +83,12 @@ def capability_configuration_editor( # effort the owning namespace would reject. steward_endpoints, steward_efforts = _steward_executor_editor_options() definitions: dict[str, dict[str, Any]] = { + "goal_storage": { + "supported_scopes": ["machine"], "writable_scopes": ["machine"], + "fields": [_field("new_goal_provider", "New Goal storage target (after promotion)", "select", + options=["file", "sqlite"], required=True, + description="Fixed at creation. Existing Goals need a separate backed-up migration; this setting does not promote them.")], + }, "todo_replan_cadence": { "supported_scopes": ["machine", "goal"], "writable_scopes": ["machine", "goal"], diff --git a/loopx/capabilities/machine_configuration/builtins.py b/loopx/capabilities/machine_configuration/builtins.py index 1f11913852..acf370943e 100644 --- a/loopx/capabilities/machine_configuration/builtins.py +++ b/loopx/capabilities/machine_configuration/builtins.py @@ -28,8 +28,11 @@ def build_builtin_machine_configuration_registry() -> MachineConfigurationRegist todo_replan_cadence_machine_configuration_namespace, ) + from ...control_plane.coordination.local_authority_defaults import goal_storage_machine_configuration_namespace + return ( MachineConfigurationRegistry() + .register(goal_storage_machine_configuration_namespace()) .register(manager_runtime_machine_configuration_namespace()) .register(periodic_report_machine_configuration_namespace()) .register(todo_replan_cadence_machine_configuration_namespace()) diff --git a/loopx/control_plane/coordination/local_authority_defaults.py b/loopx/control_plane/coordination/local_authority_defaults.py new file mode 100644 index 0000000000..9df7e64328 --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_defaults.py @@ -0,0 +1,53 @@ +"""Machine-configuration and bootstrap transport for the TS storage owner.""" +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ...capabilities.machine_configuration.contract import MachineConfigurationNamespace +from ..effect_runtime import effect_runtime_result + +SCHEMA = "loopx_goal_storage_defaults_v0" +METHOD = "coordination.local_authority.new_goal_storage" + + +def normalize_goal_storage_defaults(raw: Mapping[str, Any]) -> dict[str, Any]: + # Configuration-envelope validation only; target resolution/admission is TS-owned. + if set(raw) != {"schema_version", "new_goal_provider"} or raw.get("schema_version") != SCHEMA: + raise ValueError("goal_storage requires schema_version and new_goal_provider") + if raw.get("new_goal_provider") not in ("file", "sqlite"): + raise ValueError("new_goal_provider must be file or sqlite") + return dict(raw) + + +def goal_storage_machine_configuration_namespace() -> MachineConfigurationNamespace: + return MachineConfigurationNamespace( + namespace="goal_storage", schema_versions=frozenset({SCHEMA}), + normalize=normalize_goal_storage_defaults, project_public=dict, + apply_public_update=lambda _current, update: dict(update), + title="New Goal storage target", + description=("Fixed when a new Goal is created; used after reviewed promotion. " + "Does not promote Goals or migrate existing data. Existing Goals keep their selection."), + default_configuration={"schema_version": SCHEMA, "new_goal_provider": "file"}, + documentation={"path": "docs/reference/local-authority-provider-selection.md", + "url": "https://github.com/loopx-project/loopx/blob/main/docs/reference/local-authority-provider-selection.md"}, + ) + + +def new_goal_storage_target(runtime_root: Path) -> dict[str, Any] | None: + from ...capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from ...capabilities.machine_configuration.store import read_machine_configuration + configuration = read_machine_configuration(runtime_root, registry=build_builtin_machine_configuration_registry()) + raw = (configuration or {}).get("namespaces", {}).get("goal_storage") + if raw is None: + return None + return effect_runtime_result(METHOD, {"action": "resolve", "configuration": raw}) + + +def initialize_goal_storage_target(runtime_root: Path, goal: Mapping[str, Any]) -> dict[str, Any] | None: + target = (goal.get("coordination") or {}).get("storage_target") + if target is None: + return None + return effect_runtime_result(METHOD, {"action": "initialize", "runtime_root": str(runtime_root), + "goal_id": goal["id"], "target": target}) diff --git a/loopx/control_plane/coordination/local_authority_defaults.ts b/loopx/control_plane/coordination/local_authority_defaults.ts new file mode 100644 index 0000000000..24fce95597 --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_defaults.ts @@ -0,0 +1,29 @@ +/** New-Goal target selection; never live inheritance or authority promotion. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {requireAuthorityStoreId} from "./authority_store_codec.ts"; +import {requireLocalAuthorityRuntimeRoot, selectLocalAuthorityTarget} from "./local_authority_provider.ts"; + +const CONFIG = "loopx_goal_storage_defaults_v0"; +const TARGET = "loopx_new_goal_storage_target_v0"; +function provider(value: unknown): "file" | "sqlite" { + if (value !== "file" && value !== "sqlite") throw new Error("New Goal storage must be file or sqlite"); + return value; +} +export async function manageNewGoalStorage(request: JsonObject): Promise { + if (request.action === "resolve") { + const config = requireJsonObject(request.configuration, "Goal storage defaults"); + if (config.schema_version !== CONFIG || Object.keys(config).some(key => !["schema_version", "new_goal_provider"].includes(key))) { + throw new Error("Invalid Goal storage defaults"); + } + return {schema_version: TARGET, provider: provider(config.new_goal_provider)}; + } + if (request.action !== "initialize") throw new Error("Unknown new Goal storage action"); + const target = requireJsonObject(request.target, "New Goal storage target"); + if (target.schema_version !== TARGET || Object.keys(target).some(key => !["schema_version", "provider"].includes(key))) { + throw new Error("Invalid new Goal storage target"); + } + const selected = await selectLocalAuthorityTarget(requireLocalAuthorityRuntimeRoot(request.runtime_root), + requireAuthorityStoreId(request.goal_id, "goal id"), provider(target.provider), true, "creation_retry"); + return {...selected, status: selected.selection_preserved ? "existing_authority_preserved" : "selected", applies_to: "canonical_authority", promotion_performed: false}; +} diff --git a/loopx/control_plane/coordination/local_authority_provider.ts b/loopx/control_plane/coordination/local_authority_provider.ts index 5a654b1808..5000765279 100644 --- a/loopx/control_plane/coordination/local_authority_provider.ts +++ b/loopx/control_plane/coordination/local_authority_provider.ts @@ -261,31 +261,50 @@ export async function openLocalAuthorityStore( } /** Administrative opt-in for an empty, unpromoted goal; no implicit migration. */ -export async function selectLocalSqliteAuthority(root: string, goalId: string, execute: boolean) { +export async function selectLocalAuthorityTarget(root: string, goalId: string, provider: "file" | "sqlite", execute: boolean, + purpose: "explicit_selection" | "creation_retry" = "explicit_selection") { const p = localAuthorityProviderPaths(root, goalId); return withFileMutationLock(shadowMaintenanceLockPath(root, goalId), async () => { + // Creation defaults stop owning selection once shadow/canonical state exists. + // In particular, a reviewed later migration must not be undone on reconnect. + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "failed") throw new Error(fence.reason); + if (purpose === "creation_retry" && fence.status === "loaded") { + return {ok: true, changed: false, executed: false, selection_preserved: true}; + } if (existsSync(p.marker)) { const selected = await openLocalAuthorityStoreHandle(root, goalId); - if (selected.provider !== "sqlite") throw new Error("Provider selection cannot replace an existing authority; use a reviewed migration"); - return {ok: true, provider: "sqlite", changed: false, executed: execute}; + if (selected.provider !== provider) throw new Error("Provider selection cannot replace an existing authority; use a reviewed migration"); + return {ok: true, provider, changed: false, executed: execute}; } - const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status !== "missing") throw new Error("Provider selection requires an unpromoted goal without a writer fence"); const file = new FileAuthorityStore(p.file, goalId, {existingOnly: true}); - if ((await file.loadAuthority()).status !== "missing") throw new Error("Provider selection cannot replace existing file authority"); + const existingFile = await file.loadAuthority(); + if (purpose === "creation_retry" && existingFile.status === "loaded") { + return {ok: true, provider: "file", changed: false, executed: false, selection_preserved: true}; + } + if (existingFile.status !== "missing") throw new Error("Provider selection cannot replace existing file authority"); + // File has no empty database/document to bind. Absence already routes to + // File; the creation intent pins this choice without weakening the rule + // that an explicit File selector must point to an existing authority. + if (provider === "file") return {ok: true, provider, changed: false, executed: execute}; const store = new SqliteAuthorityStore(p.sqlite, goalId); const existing = await store.loadAuthority(); if (existing.status === "failed" || existing.status === "unavailable") throw new Error(existing.reason); - if (existing.status !== "missing") throw new Error("Unselected SQLite authority is not empty"); - if (!execute) return {ok: true, provider: "sqlite", changed: false, executed: false}; + if (existing.status !== "missing") throw new Error("Unselected authority is not empty"); + if (!execute) return {ok: true, provider, changed: false, executed: false}; const identity = await store.storeIdentity(); if (identity.status !== "available") throw new Error(JSON.stringify(identity)); - await durableWriteJson(p.marker, {schema_version: SCHEMA, provider: "sqlite", goal_id: goalId, + await durableWriteJson(p.marker, {schema_version: SCHEMA, provider, goal_id: goalId, store_identity: identity.store_identity}); - return {ok: true, provider: "sqlite", changed: true, executed: true}; + return {ok: true, provider, changed: true, executed: true}; }); } +export async function selectLocalSqliteAuthority(root: string, goalId: string, execute: boolean) { + return selectLocalAuthorityTarget(root, goalId, "sqlite", execute); +} + /** One runtime seam owns provider construction for every local command. */ export async function openRuntimeAuthorityStore( root: string, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index ef844d79c6..acf9d64d5b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,3 +1,4 @@ +import {manageNewGoalStorage} from "./coordination/local_authority_defaults.ts"; import {projectDecisionNotice} from "./presentation/decision_notice.ts"; import {projectTodoSummary} from "./todos/summary_projection.ts"; import {admitAutomationStart, confirmAutomationStart, manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts"; @@ -578,6 +579,7 @@ export function createEffectRuntimeHandlers( ["coordination.runtime_shadow.rollback", withCoordinationSourceTransfer("coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow)], ["coordination.local_authority.promote", promoteLocalCoordinationAuthority], ["coordination.authority_archive.manage", manageLocalAuthorityArchive], + ["coordination.local_authority.new_goal_storage", manageNewGoalStorage], ["coordination.local_authority.promotion_review", withCoordinationSourceTransfer("coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion)], ["coordination.local_authority.promotion_reviewed", executeReviewedCoordinationPromotion], ["coordination.local_authority.todo_continuation", continueLocalTodo], diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 09c271ee70..a2191d427e 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -71,7 +71,7 @@ }, { "site": "loopx/bootstrap.py::.bootstrap_project::codec_transaction:project_registry_transaction#1", - "line": 502, + "line": 510, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -79,7 +79,7 @@ }, { "site": "loopx/bootstrap.py::.read_json_if_exists::codec_read:load_project_registry#1", - "line": 74, + "line": 75, "column": 15, "kind": "codec_read", "api": "load_project_registry", diff --git a/tests/control_plane/test_new_goal_storage_defaults.py b/tests/control_plane/test_new_goal_storage_defaults.py new file mode 100644 index 0000000000..c52bea38e9 --- /dev/null +++ b/tests/control_plane/test_new_goal_storage_defaults.py @@ -0,0 +1,114 @@ +"""New-Goal defaults are creation-time intent, never live provider inheritance.""" +import hashlib +import json +import subprocess +import sys + +import pytest + +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.configuration_ui import build_capability_configuration_catalog +from loopx.control_plane.coordination.local_authority_defaults import normalize_goal_storage_defaults +from loopx.control_plane.effect_runtime import restart_effect_runtime +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime = tmp_path / "runtime" + project = tmp_path / "project" + project.mkdir() + config = runtime / "machine/configuration.json" + config.parent.mkdir(parents=True) + def configure(provider): + config.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": provider}}})) + def bootstrap(goal="first", *extra): + result = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(project / ".loopx/registry.json"), + "--runtime-root", str(runtime), "--format", "json", "bootstrap", "--project", str(project), "--goal-id", goal, + "--objective", "Validate a new project", "--no-global-sync", *extra], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + return json.loads(result.stdout) + def marker(goal="first"): + return runtime / "authority" / f"provider-{hashlib.sha256(goal.encode()).hexdigest()}.json" + yield configure, bootstrap, marker, project, runtime + restart_effect_runtime() + + +def test_creation_freezes_target_and_reconnect_does_not_follow_changed_defaults(environment): + configure, bootstrap, marker, _, _ = environment + configure("sqlite") + preview = bootstrap("first", "--dry-run") + assert preview["storage_target"]["provider"] == "sqlite" + assert not marker().exists() + actual = bootstrap() + assert actual["storage_selection"]["promotion_performed"] is False + assert json.loads(marker().read_text())["provider"] == "sqlite" + configure("file") + assert bootstrap()["storage_selection"]["provider"] == "sqlite" + assert bootstrap("second")["storage_selection"]["provider"] == "file" + assert not marker("second").exists() # An explicit File selector requires a committed head. + + +def test_existing_implicit_file_goal_is_not_retargeted(environment): + configure, bootstrap, marker, _, _ = environment + assert bootstrap()["storage_target"] is None + configure("sqlite") + assert bootstrap()["storage_selection"] is None + assert not marker().exists() + + +def test_pending_creation_uses_frozen_intent_after_machine_default_changes(environment): + configure, bootstrap, marker, project, _ = environment + # Independently model the durable boundary: registry/state published, selector absent. + bootstrap() + registry = project / ".loopx/registry.json" + data = json.loads(registry.read_text()) + data["goals"][0].setdefault("coordination", {})["storage_target"] = { + "schema_version": "loopx_new_goal_storage_target_v0", "provider": "sqlite"} + registry.write_text(json.dumps(data)) + configure("file") + assert bootstrap()["storage_selection"]["provider"] == "sqlite" + assert json.loads(marker().read_text())["provider"] == "sqlite" + + +def test_machine_editor_is_machine_only_and_configuration_rejects_activation(): + namespaces = build_builtin_machine_configuration_registry().public_catalog()["namespaces"] + catalog = build_capability_configuration_catalog(machine_namespaces=namespaces) + item = next(row for row in catalog["capabilities"] if row["capability_id"] == "goal_storage") + assert item["available_scopes"] == ["machine"] + assert item["configuration_editor"]["fields"][0]["options"] == ["file", "sqlite"] + with pytest.raises(ValueError): + normalize_goal_storage_defaults({"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": "sqlite", "promote": True}) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_created_target_is_used_by_reviewed_promotion(environment, provider): + from tests.control_plane.shadow_e2e_fixture import ShadowWorkspace + configure, bootstrap, _, project, runtime = environment + configure(provider) + bootstrap() + registry = project / ".loopx/registry.json" + state = project / ".codex/goals/first/ACTIVE_GOAL_STATE.md" + ws = ShadowWorkspace(registry, runtime, state, "first") + configured = ws.cli("configure-goal", "--coordination-runtime-shadow-file", "--execute") + assert configured["ok"] is True + assert ws.cli("coordination-shadow", "bootstrap", "--execute")["bootstrap"]["status"] == "applied" + for n in range(3): + assert ws.add(f"Qualify creation target {n}")["ok"] is True + assert ws.drain(budget_seconds="60")["ok"] is True + assert bootstrap()["storage_selection"]["changed"] is False + preview = ws.cli("coordination-shadow", "promote", "--handoff-mode-migration", "preserve") + assert preview["promotion"]["status"] == "preview_ready", preview + plan = project / "reviewed.json" + plan.write_text(json.dumps(preview)) + promoted = ws.cli("coordination-shadow", "promote", "--reviewed-plan", str(plan), "--execute") + assert promoted["promotion"]["canonical_authority"] == f"{provider}_v0", promoted + created = ws.add("Continue on selected provider") + assert ws.cli("todo", "list", "--todo-id", created["todo_id"])["authority_read"]["source_authority"] == f"{provider}_v0" + # The initializer must not override a promoted selection. The separate legacy + # bootstrap command still rejects a fenced Goal before reaching this helper. + from loopx.control_plane.coordination.local_authority_defaults import initialize_goal_storage_target + goal = json.loads(registry.read_text())["goals"][0] + assert initialize_goal_storage_target(runtime, goal)["status"] == "existing_authority_preserved" diff --git a/tests/control_plane_ts/local_authority_defaults.test.ts b/tests/control_plane_ts/local_authority_defaults.test.ts new file mode 100644 index 0000000000..3699dccd79 --- /dev/null +++ b/tests/control_plane_ts/local_authority_defaults.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import {mkdtemp,rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {manageNewGoalStorage as manage} from "../../loopx/control_plane/coordination/local_authority_defaults.ts"; +import {openLocalAuthorityStoreHandle as selected} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +const configuration=(provider: string)=>({schema_version:"loopx_goal_storage_defaults_v0",new_goal_provider:provider}); +for(const provider of ["file","sqlite"]){ + test(`new Goal target ${provider} is durable, idempotent and not a promotion`,async t=>{ + const root=await mkdtemp(join(tmpdir(),"goal-storage-"));t.after(()=>rm(root,{recursive:true,force:true})); + const target=await manage({action:"resolve",configuration:configuration(provider)}); + const request={action:"initialize",runtime_root:root,goal_id:"example",target}; + const first=await manage(request);assert.equal(first.changed,provider === "sqlite");assert.equal(first.promotion_performed,false); + const handle=await selected(root,"example");assert.equal(handle.provider,provider); + assert.equal((await handle.store.loadAuthority()).status,"missing"); + assert.equal((await manage(request)).changed,false); + if (provider === "file") return; + const other=await manage({action:"resolve",configuration:configuration(provider==="file"?"sqlite":"file")}); + await assert.rejects(manage({...request,target:other}),/reviewed migration/); + assert.equal((await selected(root,"example")).provider,provider); + }); +} +test("storage target rejects unknown providers and activation fields",async()=>{ + await assert.rejects(manage({action:"resolve",configuration:configuration("postgresql")}),/file or sqlite/); + await assert.rejects(manage({action:"resolve",configuration:{...configuration("sqlite"),promote:true}}),/Invalid/); +}); diff --git a/tests/control_plane_ts/local_authority_migration.test.ts b/tests/control_plane_ts/local_authority_migration.test.ts index 89e90856da..a35e7b2d1f 100644 --- a/tests/control_plane_ts/local_authority_migration.test.ts +++ b/tests/control_plane_ts/local_authority_migration.test.ts @@ -11,6 +11,7 @@ import {legacyCoordinationWriterFencePath, LEGACY_COORDINATION_WRITER_FENCE_SCHE import {withCanonicalWriter} from "../../loopx/control_plane/coordination/local_authority_write.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {manageNewGoalStorage} from "../../loopx/control_plane/coordination/local_authority_defaults.ts"; import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; import {inspectAuthorityFormat} from "../../loopx/control_plane/coordination/authority_format_inspection.ts"; @@ -54,6 +55,10 @@ test("real providers: File → SQLite → File preserves history, receipts and l assert.equal((await selected(root, goal)).provider, "file"); assert.equal((await manage(request(root, first))).status, "migrated"); assert.equal((await selected(root, goal)).provider, "sqlite"); + const retryCreation = (provider: string) => manageNewGoalStorage({action: "initialize", runtime_root: root, goal_id: goal, + target: {schema_version: "loopx_new_goal_storage_target_v0", provider}}); + assert.equal((await retryCreation("file")).status, "existing_authority_preserved"); + assert.equal((await selected(root, goal)).provider, "sqlite"); await append(root, 3); const resumed = await manage(request(root, first)); assert.equal(resumed.status, "already_applied", JSON.stringify(resumed)); @@ -62,6 +67,8 @@ test("real providers: File → SQLite → File preserves history, receipts and l assert.equal((await manage(request(root, back))).status, "migrated"); const active = await selected(root, goal); assert.equal(active.provider, "file"); + assert.equal((await retryCreation("sqlite")).status, "existing_authority_preserved"); + assert.equal((await selected(root, goal)).provider, "file"); assert.equal((await inspectAuthorityFormat(localAuthorityProviderPaths(root, goal).marker)).provider, "file"); const copy = await active.store.scanCommitted(null, 10); assert.equal(copy.status, "page"); diff --git a/tests/test_chat_machine_configuration_api.py b/tests/test_chat_machine_configuration_api.py index 098f9a9a13..083a299fea 100644 --- a/tests/test_chat_machine_configuration_api.py +++ b/tests/test_chat_machine_configuration_api.py @@ -333,6 +333,7 @@ def test_inspection_lists_registered_namespaces_without_local_refs( assert response["status"] == "absent" assert response["available_namespaces"] == [ "change_quality_qualification", + "goal_storage", "manager_runtime", "periodic_report", "pull_request_review", @@ -431,8 +432,8 @@ def test_machine_catalog_discovers_goal_features_without_granting_machine_writes explore_harness_profiles=(), ) # Machine-only capabilities are the ones a Goal cannot override: the - # manager's runtime profile and the steward channel's executor. - assert set(machine) - {"manager_runtime", "steward_executor"} == { + # manager profile, steward executor and creation-time storage target. + assert set(machine) - {"manager_runtime", "steward_executor", "goal_storage"} == { feature["feature_id"] for feature in goal["features"] } assert machine["pull_request_review"]["available_scopes"] == ["machine", "goal"] @@ -448,7 +449,7 @@ def test_machine_catalog_discovers_goal_features_without_granting_machine_writes for capability_id, item in machine.items(): assert "current" not in item assert "commands" not in item - if capability_id in {"manager_runtime", "steward_executor"}: + if capability_id in {"manager_runtime", "steward_executor", "goal_storage"}: assert item["available_scopes"] == ["machine"] assert item["machine_namespace"] == capability_id assert item["configuration_editor"]["writable_scopes"] == ["machine"] From 8551ed21d866459b0cbfa6aa285c93761ceb6952 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:30:28 +0800 Subject: [PATCH 2/3] refactor(storage): keep configuration adapter with its capability owner Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/bootstrap.py | 2 +- .../machine_configuration/builtins.py | 2 +- .../machine_configuration/goal_storage.py} | 22 +++++++++---------- .../coordination/local_authority_defaults.ts | 10 ++++----- .../test_new_goal_storage_defaults.py | 4 ++-- 5 files changed, 20 insertions(+), 20 deletions(-) rename loopx/{control_plane/coordination/local_authority_defaults.py => capabilities/machine_configuration/goal_storage.py} (70%) diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 9b8fe62a87..e388fbf19f 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -3,7 +3,7 @@ import re from pathlib import Path -from .control_plane.coordination.local_authority_defaults import new_goal_storage_target, initialize_goal_storage_target +from .capabilities.machine_configuration.goal_storage import new_goal_storage_target, initialize_goal_storage_target from .registry import find_registry_goal from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed from .control_plane.coordination.runtime_shadow_writer_adapter import require_runtime_shadow_capture_prepared, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture diff --git a/loopx/capabilities/machine_configuration/builtins.py b/loopx/capabilities/machine_configuration/builtins.py index acf370943e..6114e6ba3d 100644 --- a/loopx/capabilities/machine_configuration/builtins.py +++ b/loopx/capabilities/machine_configuration/builtins.py @@ -28,7 +28,7 @@ def build_builtin_machine_configuration_registry() -> MachineConfigurationRegist todo_replan_cadence_machine_configuration_namespace, ) - from ...control_plane.coordination.local_authority_defaults import goal_storage_machine_configuration_namespace + from .goal_storage import goal_storage_machine_configuration_namespace return ( MachineConfigurationRegistry() diff --git a/loopx/control_plane/coordination/local_authority_defaults.py b/loopx/capabilities/machine_configuration/goal_storage.py similarity index 70% rename from loopx/control_plane/coordination/local_authority_defaults.py rename to loopx/capabilities/machine_configuration/goal_storage.py index 9df7e64328..8d3629fba5 100644 --- a/loopx/control_plane/coordination/local_authority_defaults.py +++ b/loopx/capabilities/machine_configuration/goal_storage.py @@ -5,16 +5,16 @@ from pathlib import Path from typing import Any -from ...capabilities.machine_configuration.contract import MachineConfigurationNamespace -from ..effect_runtime import effect_runtime_result +from .contract import MachineConfigurationNamespace +from ...control_plane.effect_runtime import effect_runtime_result -SCHEMA = "loopx_goal_storage_defaults_v0" -METHOD = "coordination.local_authority.new_goal_storage" +GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v0" +NEW_GOAL_STORAGE_METHOD = "coordination.local_authority.new_goal_storage" def normalize_goal_storage_defaults(raw: Mapping[str, Any]) -> dict[str, Any]: # Configuration-envelope validation only; target resolution/admission is TS-owned. - if set(raw) != {"schema_version", "new_goal_provider"} or raw.get("schema_version") != SCHEMA: + if set(raw) != {"schema_version", "new_goal_provider"} or raw.get("schema_version") != GOAL_STORAGE_DEFAULTS_SCHEMA: raise ValueError("goal_storage requires schema_version and new_goal_provider") if raw.get("new_goal_provider") not in ("file", "sqlite"): raise ValueError("new_goal_provider must be file or sqlite") @@ -23,31 +23,31 @@ def normalize_goal_storage_defaults(raw: Mapping[str, Any]) -> dict[str, Any]: def goal_storage_machine_configuration_namespace() -> MachineConfigurationNamespace: return MachineConfigurationNamespace( - namespace="goal_storage", schema_versions=frozenset({SCHEMA}), + namespace="goal_storage", schema_versions=frozenset({GOAL_STORAGE_DEFAULTS_SCHEMA}), normalize=normalize_goal_storage_defaults, project_public=dict, apply_public_update=lambda _current, update: dict(update), title="New Goal storage target", description=("Fixed when a new Goal is created; used after reviewed promotion. " "Does not promote Goals or migrate existing data. Existing Goals keep their selection."), - default_configuration={"schema_version": SCHEMA, "new_goal_provider": "file"}, + default_configuration={"schema_version": GOAL_STORAGE_DEFAULTS_SCHEMA, "new_goal_provider": "file"}, documentation={"path": "docs/reference/local-authority-provider-selection.md", "url": "https://github.com/loopx-project/loopx/blob/main/docs/reference/local-authority-provider-selection.md"}, ) def new_goal_storage_target(runtime_root: Path) -> dict[str, Any] | None: - from ...capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry - from ...capabilities.machine_configuration.store import read_machine_configuration + from .builtins import build_builtin_machine_configuration_registry + from .store import read_machine_configuration configuration = read_machine_configuration(runtime_root, registry=build_builtin_machine_configuration_registry()) raw = (configuration or {}).get("namespaces", {}).get("goal_storage") if raw is None: return None - return effect_runtime_result(METHOD, {"action": "resolve", "configuration": raw}) + return effect_runtime_result(NEW_GOAL_STORAGE_METHOD, {"action": "resolve", "configuration": raw}) def initialize_goal_storage_target(runtime_root: Path, goal: Mapping[str, Any]) -> dict[str, Any] | None: target = (goal.get("coordination") or {}).get("storage_target") if target is None: return None - return effect_runtime_result(METHOD, {"action": "initialize", "runtime_root": str(runtime_root), + return effect_runtime_result(NEW_GOAL_STORAGE_METHOD, {"action": "initialize", "runtime_root": str(runtime_root), "goal_id": goal["id"], "target": target}) diff --git a/loopx/control_plane/coordination/local_authority_defaults.ts b/loopx/control_plane/coordination/local_authority_defaults.ts index 24fce95597..0e11528360 100644 --- a/loopx/control_plane/coordination/local_authority_defaults.ts +++ b/loopx/control_plane/coordination/local_authority_defaults.ts @@ -4,8 +4,8 @@ import {requireJsonObject} from "../runtime_decode.ts"; import {requireAuthorityStoreId} from "./authority_store_codec.ts"; import {requireLocalAuthorityRuntimeRoot, selectLocalAuthorityTarget} from "./local_authority_provider.ts"; -const CONFIG = "loopx_goal_storage_defaults_v0"; -const TARGET = "loopx_new_goal_storage_target_v0"; +const GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v0"; +const NEW_GOAL_STORAGE_TARGET_SCHEMA = "loopx_new_goal_storage_target_v0"; function provider(value: unknown): "file" | "sqlite" { if (value !== "file" && value !== "sqlite") throw new Error("New Goal storage must be file or sqlite"); return value; @@ -13,14 +13,14 @@ function provider(value: unknown): "file" | "sqlite" { export async function manageNewGoalStorage(request: JsonObject): Promise { if (request.action === "resolve") { const config = requireJsonObject(request.configuration, "Goal storage defaults"); - if (config.schema_version !== CONFIG || Object.keys(config).some(key => !["schema_version", "new_goal_provider"].includes(key))) { + if (config.schema_version !== GOAL_STORAGE_DEFAULTS_SCHEMA || Object.keys(config).some(key => !["schema_version", "new_goal_provider"].includes(key))) { throw new Error("Invalid Goal storage defaults"); } - return {schema_version: TARGET, provider: provider(config.new_goal_provider)}; + return {schema_version: NEW_GOAL_STORAGE_TARGET_SCHEMA, provider: provider(config.new_goal_provider)}; } if (request.action !== "initialize") throw new Error("Unknown new Goal storage action"); const target = requireJsonObject(request.target, "New Goal storage target"); - if (target.schema_version !== TARGET || Object.keys(target).some(key => !["schema_version", "provider"].includes(key))) { + if (target.schema_version !== NEW_GOAL_STORAGE_TARGET_SCHEMA || Object.keys(target).some(key => !["schema_version", "provider"].includes(key))) { throw new Error("Invalid new Goal storage target"); } const selected = await selectLocalAuthorityTarget(requireLocalAuthorityRuntimeRoot(request.runtime_root), diff --git a/tests/control_plane/test_new_goal_storage_defaults.py b/tests/control_plane/test_new_goal_storage_defaults.py index c52bea38e9..c475c37699 100644 --- a/tests/control_plane/test_new_goal_storage_defaults.py +++ b/tests/control_plane/test_new_goal_storage_defaults.py @@ -8,7 +8,7 @@ from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry from loopx.capabilities.configuration_ui import build_capability_configuration_catalog -from loopx.control_plane.coordination.local_authority_defaults import normalize_goal_storage_defaults +from loopx.capabilities.machine_configuration.goal_storage import normalize_goal_storage_defaults from loopx.control_plane.effect_runtime import restart_effect_runtime from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -109,6 +109,6 @@ def test_created_target_is_used_by_reviewed_promotion(environment, provider): assert ws.cli("todo", "list", "--todo-id", created["todo_id"])["authority_read"]["source_authority"] == f"{provider}_v0" # The initializer must not override a promoted selection. The separate legacy # bootstrap command still rejects a fenced Goal before reaching this helper. - from loopx.control_plane.coordination.local_authority_defaults import initialize_goal_storage_target + from loopx.capabilities.machine_configuration.goal_storage import initialize_goal_storage_target goal = json.loads(registry.read_text())["goals"][0] assert initialize_goal_storage_target(runtime, goal)["status"] == "existing_authority_preserved" From e8485942f2b6d8810e2dc66f8b7d0aca279573eb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:33:03 +0800 Subject: [PATCH 3/3] fix(storage): align canonical config inventory and registry census Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/semantics/project_registry_io_manifest_v1.json | 2 +- tests/capabilities/test_periodic_report_machine_store.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index bf1af666c1..47e4b85cb4 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -943,7 +943,7 @@ }, { "site": "loopx/contract.py::.check_contract::codec_read:load_registry#1", - "line": 1027, + "line": 1063, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/capabilities/test_periodic_report_machine_store.py b/tests/capabilities/test_periodic_report_machine_store.py index 1f93ff0d44..d4392bc1a0 100644 --- a/tests/capabilities/test_periodic_report_machine_store.py +++ b/tests/capabilities/test_periodic_report_machine_store.py @@ -403,6 +403,7 @@ def test_canonical_machine_config_cli_uses_the_same_store_and_projection( assert catalog["schema_version"] == "machine_configuration_catalog_v0" assert [item["namespace"] for item in catalog["namespaces"]] == [ "change_quality_qualification", + "goal_storage", "manager_runtime", "periodic_report", "pull_request_review",