From f853dc9f73fa8f15982027655d61646eaaf573d2 Mon Sep 17 00:00:00 2001 From: Huashuai Qu <256761+huashuai@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:54:00 -0700 Subject: [PATCH 1/2] Add typed Goal lifecycle action catalog Signed-off-by: Huashuai Qu <256761+huashuai@users.noreply.github.com> Committed-By-Agent: codex Co-authored-by: codex --- loopx/cli_commands/goal_actions.py | 70 ++++++ loopx/cli_commands/goal_lifecycle.py | 5 + loopx/cli_commands/registry_admin.py | 10 + .../control_plane/effect_runtime_handlers.ts | 2 + .../control_plane/goals/activation_service.py | 40 ++++ loopx/control_plane/goals/operator_actions.py | 109 +++++++++ loopx/control_plane/goals/operator_actions.ts | 139 +++++++++++ .../test_goal_operator_actions.py | 221 ++++++++++++++++++ 8 files changed, 596 insertions(+) create mode 100644 loopx/cli_commands/goal_actions.py create mode 100644 loopx/control_plane/goals/operator_actions.py create mode 100644 loopx/control_plane/goals/operator_actions.ts create mode 100644 tests/control_plane/test_goal_operator_actions.py diff --git a/loopx/cli_commands/goal_actions.py b/loopx/cli_commands/goal_actions.py new file mode 100644 index 0000000000..300ec72baf --- /dev/null +++ b/loopx/cli_commands/goal_actions.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +from collections.abc import Callable +from pathlib import Path + +from ..control_plane.goals.operator_actions import ( + GOAL_ACTION_CATALOG_SCHEMA_VERSION, + build_goal_action_catalog, + render_goal_action_catalog_markdown, +) +from ..review_packet import find_goal, find_queue_item, infer_action_kind +from ..status import collect_status + + +PrintPayload = Callable[ + [dict[str, object], str, Callable[[dict[str, object]], str]], + None, +] + + +def register_goal_actions_command( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: + parser = subparsers.add_parser( + "goal-actions", + help="List fresh typed owner actions for one Goal.", + ) + parser.add_argument( + "--goal-id", required=True, help="Goal id present in the active registry." + ) + + +def handle_goal_actions_command( + args: argparse.Namespace, + *, + registry_path: Path, + print_payload: PrintPayload, +) -> int: + try: + status_payload = collect_status( + registry_path=registry_path, + runtime_root_override=args.runtime_root, + scan_roots=[], + limit=5, + goal_id=args.goal_id, + include_public_boundary_scan=False, + activation_state_filter=None, + ) + queue_item = find_queue_item(status_payload, args.goal_id) + status_goal = find_goal(status_payload, args.goal_id) + operator_gate_required = ( + infer_action_kind(queue_item, status_goal) == "controller" + ) + payload = build_goal_action_catalog( + registry_path=registry_path, + goal_id=args.goal_id, + operator_gate_required=operator_gate_required, + runtime_root_override=args.runtime_root, + ) + except Exception as exc: + payload = { + "ok": False, + "schema_version": GOAL_ACTION_CATALOG_SCHEMA_VERSION, + "goal_id": args.goal_id, + "actions": [], + "error": str(exc), + } + print_payload(payload, args.format, render_goal_action_catalog_markdown) + return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/goal_lifecycle.py b/loopx/cli_commands/goal_lifecycle.py index bb5c152ec6..e13a2f6c54 100644 --- a/loopx/cli_commands/goal_lifecycle.py +++ b/loopx/cli_commands/goal_lifecycle.py @@ -34,6 +34,10 @@ def register_goal_lifecycle_command( help="Stop automatic advancement or restore eligibility.", ) parser.add_argument("--reason", help="Bounded owner-visible transition reason.") + parser.add_argument( + "--expected-state-fingerprint", + help="SHA-256 registry fingerprint from a fresh goal-actions projection.", + ) parser.add_argument( "--execute", action="store_true", @@ -54,6 +58,7 @@ def handle_goal_lifecycle_command( state="stopped" if args.operation == "stop" else "active", reason=args.reason, runtime_root_override=args.runtime_root, + expected_state_fingerprint=args.expected_state_fingerprint, execute=bool(args.execute), ) except Exception as exc: diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index 41d6ebe986..1270b8046d 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -25,6 +25,7 @@ handle_goal_lifecycle_command, register_goal_lifecycle_command, ) +from .goal_actions import handle_goal_actions_command, register_goal_actions_command from .registry_admin_configure import register_configure_goal_command from .registry_admin_lifecycle import ( REGISTRY_LIFECYCLE_COMMANDS, @@ -52,6 +53,7 @@ REGISTRY_ADMIN_COMMANDS = { "configure-goal", "goal-lifecycle", + "goal-actions", "register-agent", "resolve-agent-thread", "bind-agent-thread", @@ -360,6 +362,7 @@ def loop_activation_for_goal( def register_registry_admin_commands(subparsers: argparse._SubParsersAction) -> None: register_configure_goal_command(subparsers) register_goal_lifecycle_command(subparsers) + register_goal_actions_command(subparsers) register_agent_parser = subparsers.add_parser( "register-agent", @@ -429,6 +432,13 @@ def handle_registry_admin_command( print_payload=print_payload, ) + if args.command == "goal-actions": + return handle_goal_actions_command( + args, + registry_path=registry_path, + print_payload=print_payload, + ) + if args.command == "configure-goal": try: agent_work_modes: dict[str, str] = {} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 7a1347fb3a..c3ca8075c5 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -90,6 +90,7 @@ import { buildVisionCheckpoint } from "./goals/vision_checkpoint.ts"; import { projectVisionWaitCoverage } from "./goals/vision_wait_coverage.ts"; import { admitGoalAmendmentProposal } from "./goals/goal_amendment_proposal.ts"; import { projectSharedGoalAlignment } from "./goals/shared_goal_alignment.ts"; +import { projectGoalOperatorActions } from "./goals/operator_actions.ts"; import { evaluateDeliveryRoute, } from "./turn_driver/delivery_continuity.ts"; @@ -445,6 +446,7 @@ export function createEffectRuntimeHandlers( ["goal.vision_checkpoint.evaluate", buildVisionCheckpoint], ["goal.vision_wait.coverage", projectVisionWaitCoverage], ["goal.shared_goal_alignment.project", projectSharedGoalAlignment], + ["goal.operator_actions.project", projectGoalOperatorActions], ["goal.amendment_proposal.admit", admitGoalAmendmentProposal], ["agent.delivery_workspace.evaluate", evaluateDeliveryWorkspace], [ diff --git a/loopx/control_plane/goals/activation_service.py b/loopx/control_plane/goals/activation_service.py index 1c07a1126d..7f82a04c5a 100644 --- a/loopx/control_plane/goals/activation_service.py +++ b/loopx/control_plane/goals/activation_service.py @@ -2,7 +2,9 @@ from dataclasses import dataclass from enum import Enum +import hashlib from pathlib import Path +import re from typing import Any from ...file_lock import exclusive_file_lock @@ -25,6 +27,7 @@ GOAL_ACTIVATION_AUTHORITY_ROUTE_SCHEMA_VERSION = ( "loopx_goal_activation_authority_route_v1" ) +_SHA256 = re.compile(r"^[a-f0-9]{64}$") class GoalActivationAuthorityRouteMode(str, Enum): @@ -207,6 +210,7 @@ def set_goal_activation_state( state: GoalActivationState | str, reason: str | None = None, runtime_root_override: str | None = None, + expected_state_fingerprint: str | None = None, execute: bool = False, ) -> dict[str, Any]: """Preview or apply one reversible Goal activation transition.""" @@ -225,6 +229,12 @@ def set_goal_activation_state( target_registry = authority_route.target_registry sync_runtime_root = authority_route.sync_runtime_root source_goal = _goal(load_registry(source_registry), normalized_goal_id) + normalized_fingerprint = str(expected_state_fingerprint or "").strip() or None + if normalized_fingerprint is not None and not _SHA256.fullmatch( + normalized_fingerprint + ): + raise ValueError("expected state fingerprint must be a SHA-256 digest") + observed_fingerprint = hashlib.sha256(source_registry.read_bytes()).hexdigest() before_state = goal_activation_state(source_goal) changed = before_state is not target_state default_reason = ( @@ -252,6 +262,8 @@ def set_goal_activation_state( "source_registry": str(source_registry), "target_global_registry": str(target_registry), "authority_route": authority_route.public_summary(), + "expected_state_fingerprint": normalized_fingerprint, + "observed_state_fingerprint": observed_fingerprint, "activation": proposed_activation, "readback": { "schema_version": GOAL_ACTIVATION_READBACK_SCHEMA_VERSION, @@ -259,6 +271,18 @@ def set_goal_activation_state( "verified": not changed, }, } + if ( + normalized_fingerprint is not None + and observed_fingerprint != normalized_fingerprint + ): + payload.update( + { + "ok": False, + "error_kind": "goal_action_stale", + "error": "Goal state changed after action projection; refresh actions and retry", + } + ) + return payload if not execute: return payload @@ -294,6 +318,22 @@ def set_goal_activation_state( operation="set_goal_activation_state", ): source_payload = load_registry(source_registry) + locked_fingerprint = hashlib.sha256(source_registry.read_bytes()).hexdigest() + if ( + normalized_fingerprint is not None + and locked_fingerprint != normalized_fingerprint + ): + payload.update( + { + "ok": False, + "error_kind": "goal_action_stale", + "error": ( + "Goal state changed after action projection; refresh actions and retry" + ), + "observed_state_fingerprint": locked_fingerprint, + } + ) + return payload locked_goal = _goal(source_payload, normalized_goal_id) locked_state = goal_activation_state(locked_goal) if locked_state is not before_state: diff --git a/loopx/control_plane/goals/operator_actions.py b/loopx/control_plane/goals/operator_actions.py new file mode 100644 index 0000000000..415f85425a --- /dev/null +++ b/loopx/control_plane/goals/operator_actions.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +import hashlib +import json +from pathlib import Path +from typing import Any + +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from ...registry import registry_goals +from .activation import GoalActivationState, goal_activation_state +from .activation_service import _source_and_target + + +GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION = ( + "loopx_goal_action_projection_request_v1" +) +GOAL_ACTION_CATALOG_SCHEMA_VERSION = "loopx_goal_action_catalog_v1" + + +def _goal(payload: Mapping[str, Any], goal_id: str) -> Mapping[str, Any]: + goal = next( + ( + item + for item in registry_goals(dict(payload)) + if str(item.get("id") or "") == goal_id + ), + None, + ) + if goal is None: + raise ValueError(f"goal id not found in registry: {goal_id}") + return goal + + +def build_goal_action_catalog( + *, + registry_path: Path, + goal_id: str, + operator_gate_required: bool = False, + runtime_root_override: str | None = None, +) -> dict[str, Any]: + """Adapt one stable registry snapshot into the TS-owned action catalog.""" + + normalized_goal_id = str(goal_id or "").strip() + if not normalized_goal_id: + raise ValueError("goal id is required") + requested_registry = Path(registry_path).expanduser().resolve() + requested_payload = json.loads(requested_registry.read_text(encoding="utf-8")) + requested_goal = _goal(requested_payload, normalized_goal_id) + current_state = goal_activation_state(requested_goal) + target_state = ( + GoalActivationState.STOPPED + if current_state is GoalActivationState.ACTIVE + else GoalActivationState.ACTIVE + ) + authority_route = _source_and_target( + registry_path=requested_registry, + goal_id=normalized_goal_id, + target_state=target_state, + runtime_root_override=runtime_root_override, + ) + source_bytes = authority_route.source_registry.read_bytes() + source_payload = json.loads(source_bytes) + source_state = goal_activation_state(_goal(source_payload, normalized_goal_id)) + fingerprint = hashlib.sha256(source_bytes).hexdigest() + try: + result = effect_runtime_result( + "goal.operator_actions.project", + { + "schema_version": GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION, + "goal_id": normalized_goal_id, + "activation_state": source_state.value, + "state_fingerprint": fingerprint, + "operator_gate_required": bool(operator_gate_required), + }, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + if not isinstance(result, Mapping) or ( + result.get("schema_version") != GOAL_ACTION_CATALOG_SCHEMA_VERSION + ): + raise RuntimeError("TypeScript Goal action catalog shape mismatch") + actions = result.get("actions") + if not isinstance(actions, list) or not all( + isinstance(item, Mapping) for item in actions + ): + raise RuntimeError("TypeScript Goal action list shape mismatch") + return dict(result) + + +def render_goal_action_catalog_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# Goal Actions", + "", + f"- ok: `{str(payload.get('ok')).lower()}`", + f"- goal: `{payload.get('goal_id')}`", + f"- activation_state: `{payload.get('activation_state')}`", + ] + actions = payload.get("actions") + if isinstance(actions, list): + lines.extend(["", "## Available actions", ""]) + for action in actions: + if isinstance(action, Mapping): + lines.append( + f"- `{action.get('action_id')}` — {action.get('label')}" + ) + if payload.get("error"): + lines.extend(["", f"Error: {payload.get('error')}"]) + return "\n".join(lines).rstrip() + "\n" diff --git a/loopx/control_plane/goals/operator_actions.ts b/loopx/control_plane/goals/operator_actions.ts new file mode 100644 index 0000000000..e5ba7efec2 --- /dev/null +++ b/loopx/control_plane/goals/operator_actions.ts @@ -0,0 +1,139 @@ +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { + requireBoolean, + requireJsonObject, + requireNonEmptyString, +} from "../runtime_decode.ts"; + +import type { JsonObject } from "../effect_program.ts"; + +export const GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION = + "loopx_goal_action_projection_request_v1"; +export const GOAL_ACTION_CATALOG_SCHEMA_VERSION = + "loopx_goal_action_catalog_v1"; +export const GOAL_ACTION_SCHEMA_VERSION = "loopx_goal_action_v1"; + +const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,200}$/; +const SHA256 = /^[a-f0-9]{64}$/; + +function requireOpaqueId(value: unknown, label: string): string { + const token = requireNonEmptyString(value, label); + if (!OPAQUE_ID.test(token)) { + throw new EffectRuntimeRequestError(`${label} must be a compact opaque id`); + } + return token; +} + +function requireFingerprint(value: unknown): string { + const fingerprint = requireNonEmptyString(value, "goal_action_request.state_fingerprint"); + if (!SHA256.test(fingerprint)) { + throw new EffectRuntimeRequestError( + "goal_action_request.state_fingerprint must be a SHA-256 digest", + ); + } + return fingerprint; +} + +function lifecycleAction( + goalId: string, + activationState: "active" | "stopped", + fingerprint: string, +): JsonObject { + const stopping = activationState === "active"; + const operation = stopping ? "stop" : "resume"; + return { + schema_version: GOAL_ACTION_SCHEMA_VERSION, + action_id: `goal.${operation}`, + action_kind: "goal_lifecycle", + label: stopping ? "Pause Goal" : "Resume Goal", + goal_id: goalId, + requires_confirmation: true, + target_activation_state: stopping ? "stopped" : "active", + target_operator_state: stopping ? "quiet" : "active", + execution: { + expected_state_fingerprint: fingerprint, + argv: [ + "loopx", + "goal-lifecycle", + "--goal-id", + goalId, + "--operation", + operation, + "--expected-state-fingerprint", + fingerprint, + "--execute", + ], + }, + }; +} + +function gateAction(goalId: string, decision: "approve" | "reject" | "defer"): JsonObject { + return { + schema_version: GOAL_ACTION_SCHEMA_VERSION, + action_id: `gate.${decision}`, + action_kind: "operator_gate", + label: decision[0].toUpperCase() + decision.slice(1), + goal_id: goalId, + requires_confirmation: true, + execution: { + argv: [ + "loopx", + "operator-gate", + "--goal-id", + goalId, + "--decision", + decision, + "--reason-summary", + `Operator selected ${decision} from the Goal action catalog.`, + ], + }, + }; +} + +/** + * Project the complete bounded owner action set for one Goal snapshot. + * + * Python supplies only current source facts. This typed reducer owns which + * lifecycle transition is legal and the exact execution argv exposed to UIs. + */ +export function projectGoalOperatorActions(value: unknown): JsonObject { + const request = requireJsonObject(value, "goal_action_request"); + if (request.schema_version !== GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION) { + throw new EffectRuntimeRequestError( + `goal_action_request.schema_version must be ${GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION}`, + ); + } + const goalId = requireOpaqueId(request.goal_id, "goal_action_request.goal_id"); + const activationState = requireNonEmptyString( + request.activation_state, + "goal_action_request.activation_state", + ); + if (activationState !== "active" && activationState !== "stopped") { + throw new EffectRuntimeRequestError( + "goal_action_request.activation_state must be active or stopped", + ); + } + const stateFingerprint = requireFingerprint(request.state_fingerprint); + const operatorGateRequired = requireBoolean( + request.operator_gate_required, + "goal_action_request.operator_gate_required", + ); + const actions: JsonObject[] = []; + if (operatorGateRequired) { + actions.push( + gateAction(goalId, "approve"), + gateAction(goalId, "reject"), + gateAction(goalId, "defer"), + ); + } + actions.push(lifecycleAction(goalId, activationState, stateFingerprint)); + return { + ok: true, + schema_version: GOAL_ACTION_CATALOG_SCHEMA_VERSION, + authority_owner: "typescript_control_plane", + goal_id: goalId, + activation_state: activationState, + state_fingerprint: stateFingerprint, + actions, + }; +} diff --git a/tests/control_plane/test_goal_operator_actions.py b/tests/control_plane/test_goal_operator_actions.py new file mode 100644 index 0000000000..4f1f1aaf41 --- /dev/null +++ b/tests/control_plane/test_goal_operator_actions.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +from typing import Any + +import pytest + +from loopx.control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result +from loopx.history import load_registry +from loopx.registry import registry_goals + + +REPO_ROOT = Path(__file__).resolve().parents[2] +GOAL_ID = "goal-actions-fixture" + + +def _write_registry(tmp_path: Path, *, activation_state: str = "active") -> tuple[Path, Path]: + project = tmp_path / "project" + runtime_root = tmp_path / "runtime" + registry_path = project / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + goal: dict[str, Any] = { + "id": GOAL_ID, + "display_name": "Goal actions fixture", + "repo": str(project), + "quota": {"compute": 1, "allowed_slots": 4, "spent_slots": 0}, + } + if activation_state == "stopped": + goal["activation_state"] = "stopped" + registry_path.write_text( + json.dumps( + { + "schema_version": "0.1", + "common_runtime_root": str(runtime_root), + "goals": [goal], + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return project, registry_path + + +def _run_cli(registry_path: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry_path), + "--format", + "json", + *args, + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + +def test_typescript_projection_owns_legal_lifecycle_action() -> None: + active = effect_runtime_result( + "goal.operator_actions.project", + { + "schema_version": "loopx_goal_action_projection_request_v1", + "goal_id": GOAL_ID, + "activation_state": "active", + "state_fingerprint": "a" * 64, + "operator_gate_required": False, + }, + ) + stopped = effect_runtime_result( + "goal.operator_actions.project", + { + "schema_version": "loopx_goal_action_projection_request_v1", + "goal_id": GOAL_ID, + "activation_state": "stopped", + "state_fingerprint": "b" * 64, + "operator_gate_required": False, + }, + ) + + assert active["schema_version"] == "loopx_goal_action_catalog_v1" + assert [action["action_id"] for action in active["actions"]] == ["goal.stop"] + assert active["actions"][0]["target_activation_state"] == "stopped" + assert active["actions"][0]["target_operator_state"] == "quiet" + assert stopped["activation_state"] == "stopped" + assert [action["action_id"] for action in stopped["actions"]] == ["goal.resume"] + assert stopped["actions"][0]["target_activation_state"] == "active" + + +def test_typescript_projection_rejects_invalid_state_and_fingerprint() -> None: + with pytest.raises(EffectRuntimeRejected): + effect_runtime_result( + "goal.operator_actions.project", + { + "schema_version": "loopx_goal_action_projection_request_v1", + "goal_id": GOAL_ID, + "activation_state": "watching", + "state_fingerprint": "not-a-digest", + "operator_gate_required": False, + }, + ) + + +def test_catalog_adds_gate_actions_without_replacing_lifecycle_action( + tmp_path: Path, +) -> None: + from loopx.control_plane.goals.operator_actions import build_goal_action_catalog + + _project, registry_path = _write_registry(tmp_path) + + packet = build_goal_action_catalog( + registry_path=registry_path, + goal_id=GOAL_ID, + operator_gate_required=True, + ) + + assert [action["action_id"] for action in packet["actions"]] == [ + "gate.approve", + "gate.reject", + "gate.defer", + "goal.stop", + ] + assert all(action["goal_id"] == GOAL_ID for action in packet["actions"]) + assert all(action["requires_confirmation"] is True for action in packet["actions"]) + assert packet["authority_owner"] == "typescript_control_plane" + + +def test_goal_actions_cli_projects_exact_fresh_execution_identity( + tmp_path: Path, +) -> None: + project, registry_path = _write_registry(tmp_path) + + result = _run_cli(registry_path, "goal-actions", "--goal-id", GOAL_ID) + + assert result.returncode == 0, result.stderr + packet = json.loads(result.stdout) + assert packet["ok"] is True + assert packet["goal_id"] == GOAL_ID + assert len(packet["state_fingerprint"]) == 64 + action = next( + item for item in packet["actions"] if item["action_id"] == "goal.stop" + ) + assert action["action_id"] == "goal.stop" + assert action["execution"]["expected_state_fingerprint"] == packet["state_fingerprint"] + assert action["execution"]["argv"] == [ + "loopx", + "goal-lifecycle", + "--goal-id", + GOAL_ID, + "--operation", + "stop", + "--expected-state-fingerprint", + packet["state_fingerprint"], + "--execute", + ] + assert project.exists() + + +def test_projected_lifecycle_action_rejects_stale_registry_without_writing( + tmp_path: Path, +) -> None: + _project, registry_path = _write_registry(tmp_path) + projected = _run_cli(registry_path, "goal-actions", "--goal-id", GOAL_ID) + assert projected.returncode == 0, projected.stderr + action = next( + item + for item in json.loads(projected.stdout)["actions"] + if item["action_id"] == "goal.stop" + ) + before = load_registry(registry_path) + registry_goals(before)[0]["display_name"] = "Changed after projection" + registry_path.write_text( + json.dumps(before, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + changed_bytes = registry_path.read_bytes() + + executed = _run_cli(registry_path, *action["execution"]["argv"][1:]) + + assert executed.returncode == 1 + payload = json.loads(executed.stdout) + assert payload["ok"] is False + assert payload["error_kind"] == "goal_action_stale" + assert payload["written"] is False + assert registry_path.read_bytes() == changed_bytes + + +def test_fresh_projected_lifecycle_action_applies_and_projects_resume( + tmp_path: Path, +) -> None: + _project, registry_path = _write_registry(tmp_path) + projected = _run_cli(registry_path, "goal-actions", "--goal-id", GOAL_ID) + stop = next( + item + for item in json.loads(projected.stdout)["actions"] + if item["action_id"] == "goal.stop" + ) + + executed = _run_cli(registry_path, *stop["execution"]["argv"][1:]) + + assert executed.returncode == 0, executed.stderr + applied = json.loads(executed.stdout) + assert applied["readback"]["verified"] is True + follow_up = _run_cli(registry_path, "goal-actions", "--goal-id", GOAL_ID) + assert follow_up.returncode == 0, follow_up.stderr + resume = next( + item + for item in json.loads(follow_up.stdout)["actions"] + if item["action_id"] == "goal.resume" + ) + assert resume["action_id"] == "goal.resume" + assert resume["execution"]["argv"][-1] == "--execute" From a9fff9ec0c8160bd53fc8d1f581014232a057d9c Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:07:22 +0800 Subject: [PATCH 2/2] fix(goal-actions): bind lifecycle argv to authority Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/goal_actions.py | 17 ---- loopx/control_plane/goals/operator_actions.py | 6 +- loopx/control_plane/goals/operator_actions.ts | 89 +++++++++---------- .../test_goal_operator_actions.py | 71 +++++++++++---- 4 files changed, 98 insertions(+), 85 deletions(-) diff --git a/loopx/cli_commands/goal_actions.py b/loopx/cli_commands/goal_actions.py index 300ec72baf..01fdb5f1de 100644 --- a/loopx/cli_commands/goal_actions.py +++ b/loopx/cli_commands/goal_actions.py @@ -9,8 +9,6 @@ build_goal_action_catalog, render_goal_action_catalog_markdown, ) -from ..review_packet import find_goal, find_queue_item, infer_action_kind -from ..status import collect_status PrintPayload = Callable[ @@ -38,24 +36,9 @@ def handle_goal_actions_command( print_payload: PrintPayload, ) -> int: try: - status_payload = collect_status( - registry_path=registry_path, - runtime_root_override=args.runtime_root, - scan_roots=[], - limit=5, - goal_id=args.goal_id, - include_public_boundary_scan=False, - activation_state_filter=None, - ) - queue_item = find_queue_item(status_payload, args.goal_id) - status_goal = find_goal(status_payload, args.goal_id) - operator_gate_required = ( - infer_action_kind(queue_item, status_goal) == "controller" - ) payload = build_goal_action_catalog( registry_path=registry_path, goal_id=args.goal_id, - operator_gate_required=operator_gate_required, runtime_root_override=args.runtime_root, ) except Exception as exc: diff --git a/loopx/control_plane/goals/operator_actions.py b/loopx/control_plane/goals/operator_actions.py index 415f85425a..294b8558e6 100644 --- a/loopx/control_plane/goals/operator_actions.py +++ b/loopx/control_plane/goals/operator_actions.py @@ -13,7 +13,7 @@ GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION = ( - "loopx_goal_action_projection_request_v1" + "loopx_goal_action_projection_request_v2" ) GOAL_ACTION_CATALOG_SCHEMA_VERSION = "loopx_goal_action_catalog_v1" @@ -36,7 +36,6 @@ def build_goal_action_catalog( *, registry_path: Path, goal_id: str, - operator_gate_required: bool = False, runtime_root_override: str | None = None, ) -> dict[str, Any]: """Adapt one stable registry snapshot into the TS-owned action catalog.""" @@ -69,9 +68,10 @@ def build_goal_action_catalog( { "schema_version": GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION, "goal_id": normalized_goal_id, + "registry_locator": str(requested_registry), + "runtime_root_locator": authority_route.sync_runtime_root, "activation_state": source_state.value, "state_fingerprint": fingerprint, - "operator_gate_required": bool(operator_gate_required), }, ) except EffectRuntimeRejected as exc: diff --git a/loopx/control_plane/goals/operator_actions.ts b/loopx/control_plane/goals/operator_actions.ts index e5ba7efec2..98d9d61d53 100644 --- a/loopx/control_plane/goals/operator_actions.ts +++ b/loopx/control_plane/goals/operator_actions.ts @@ -1,6 +1,6 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { - requireBoolean, + optionalNonEmptyString, requireJsonObject, requireNonEmptyString, } from "../runtime_decode.ts"; @@ -8,7 +8,7 @@ import { import type { JsonObject } from "../effect_program.ts"; export const GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION = - "loopx_goal_action_projection_request_v1"; + "loopx_goal_action_projection_request_v2"; export const GOAL_ACTION_CATALOG_SCHEMA_VERSION = "loopx_goal_action_catalog_v1"; export const GOAL_ACTION_SCHEMA_VERSION = "loopx_goal_action_v1"; @@ -38,9 +38,26 @@ function lifecycleAction( goalId: string, activationState: "active" | "stopped", fingerprint: string, + registryLocator: string, + runtimeRootLocator: string | null, ): JsonObject { const stopping = activationState === "active"; const operation = stopping ? "stop" : "resume"; + const argv = ["loopx", "--registry", registryLocator]; + if (runtimeRootLocator) { + argv.push("--runtime-root", runtimeRootLocator); + } + argv.push("--format", "json"); + argv.push( + "goal-lifecycle", + "--goal-id", + goalId, + "--operation", + operation, + "--expected-state-fingerprint", + fingerprint, + "--execute", + ); return { schema_version: GOAL_ACTION_SCHEMA_VERSION, action_id: `goal.${operation}`, @@ -52,40 +69,7 @@ function lifecycleAction( target_operator_state: stopping ? "quiet" : "active", execution: { expected_state_fingerprint: fingerprint, - argv: [ - "loopx", - "goal-lifecycle", - "--goal-id", - goalId, - "--operation", - operation, - "--expected-state-fingerprint", - fingerprint, - "--execute", - ], - }, - }; -} - -function gateAction(goalId: string, decision: "approve" | "reject" | "defer"): JsonObject { - return { - schema_version: GOAL_ACTION_SCHEMA_VERSION, - action_id: `gate.${decision}`, - action_kind: "operator_gate", - label: decision[0].toUpperCase() + decision.slice(1), - goal_id: goalId, - requires_confirmation: true, - execution: { - argv: [ - "loopx", - "operator-gate", - "--goal-id", - goalId, - "--decision", - decision, - "--reason-summary", - `Operator selected ${decision} from the Goal action catalog.`, - ], + argv, }, }; } @@ -94,7 +78,9 @@ function gateAction(goalId: string, decision: "approve" | "reject" | "defer"): J * Project the complete bounded owner action set for one Goal snapshot. * * Python supplies only current source facts. This typed reducer owns which - * lifecycle transition is legal and the exact execution argv exposed to UIs. + * lifecycle transition is legal and the exact, authority-bound execution argv + * exposed to UIs. Operator-gate decisions stay on their existing command path + * until that path has an equivalent freshness envelope. */ export function projectGoalOperatorActions(value: unknown): JsonObject { const request = requireJsonObject(value, "goal_action_request"); @@ -104,6 +90,14 @@ export function projectGoalOperatorActions(value: unknown): JsonObject { ); } const goalId = requireOpaqueId(request.goal_id, "goal_action_request.goal_id"); + const registryLocator = requireNonEmptyString( + request.registry_locator, + "goal_action_request.registry_locator", + ); + const runtimeRootLocator = optionalNonEmptyString( + request.runtime_root_locator, + "goal_action_request.runtime_root_locator", + ); const activationState = requireNonEmptyString( request.activation_state, "goal_action_request.activation_state", @@ -114,19 +108,16 @@ export function projectGoalOperatorActions(value: unknown): JsonObject { ); } const stateFingerprint = requireFingerprint(request.state_fingerprint); - const operatorGateRequired = requireBoolean( - request.operator_gate_required, - "goal_action_request.operator_gate_required", - ); const actions: JsonObject[] = []; - if (operatorGateRequired) { - actions.push( - gateAction(goalId, "approve"), - gateAction(goalId, "reject"), - gateAction(goalId, "defer"), - ); - } - actions.push(lifecycleAction(goalId, activationState, stateFingerprint)); + actions.push( + lifecycleAction( + goalId, + activationState, + stateFingerprint, + registryLocator, + runtimeRootLocator, + ), + ); return { ok: true, schema_version: GOAL_ACTION_CATALOG_SCHEMA_VERSION, diff --git a/tests/control_plane/test_goal_operator_actions.py b/tests/control_plane/test_goal_operator_actions.py index 4f1f1aaf41..3207cdc6e2 100644 --- a/tests/control_plane/test_goal_operator_actions.py +++ b/tests/control_plane/test_goal_operator_actions.py @@ -2,6 +2,7 @@ import json from pathlib import Path +import os import subprocess import sys from typing import Any @@ -17,6 +18,25 @@ GOAL_ID = "goal-actions-fixture" +def _run_projected_argv(argv: list[str]) -> subprocess.CompletedProcess[str]: + """Run the catalog's complete argv without injecting hidden context.""" + assert argv and argv[0] == "loopx" + # Use the checkout's launcher as the executable while preserving every + # argument emitted by the public action contract verbatim. + command = [str(REPO_ROOT / "scripts" / "loopx"), *argv[1:]] + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + env["LOOPX_PYTHON"] = sys.executable + return subprocess.run( + command, + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + env=env, + ) + + def _write_registry(tmp_path: Path, *, activation_state: str = "active") -> tuple[Path, Path]: project = tmp_path / "project" runtime_root = tmp_path / "runtime" @@ -69,21 +89,23 @@ def test_typescript_projection_owns_legal_lifecycle_action() -> None: active = effect_runtime_result( "goal.operator_actions.project", { - "schema_version": "loopx_goal_action_projection_request_v1", + "schema_version": "loopx_goal_action_projection_request_v2", "goal_id": GOAL_ID, + "registry_locator": "/tmp/registry.json", + "runtime_root_locator": "/tmp/runtime", "activation_state": "active", "state_fingerprint": "a" * 64, - "operator_gate_required": False, }, ) stopped = effect_runtime_result( "goal.operator_actions.project", { - "schema_version": "loopx_goal_action_projection_request_v1", + "schema_version": "loopx_goal_action_projection_request_v2", "goal_id": GOAL_ID, + "registry_locator": "/tmp/registry.json", + "runtime_root_locator": "/tmp/runtime", "activation_state": "stopped", "state_fingerprint": "b" * 64, - "operator_gate_required": False, }, ) @@ -101,16 +123,17 @@ def test_typescript_projection_rejects_invalid_state_and_fingerprint() -> None: effect_runtime_result( "goal.operator_actions.project", { - "schema_version": "loopx_goal_action_projection_request_v1", + "schema_version": "loopx_goal_action_projection_request_v2", "goal_id": GOAL_ID, + "registry_locator": "/tmp/registry.json", + "runtime_root_locator": "/tmp/runtime", "activation_state": "watching", "state_fingerprint": "not-a-digest", - "operator_gate_required": False, }, ) -def test_catalog_adds_gate_actions_without_replacing_lifecycle_action( +def test_catalog_contains_only_fresh_lifecycle_action( tmp_path: Path, ) -> None: from loopx.control_plane.goals.operator_actions import build_goal_action_catalog @@ -120,15 +143,9 @@ def test_catalog_adds_gate_actions_without_replacing_lifecycle_action( packet = build_goal_action_catalog( registry_path=registry_path, goal_id=GOAL_ID, - operator_gate_required=True, ) - assert [action["action_id"] for action in packet["actions"]] == [ - "gate.approve", - "gate.reject", - "gate.defer", - "goal.stop", - ] + assert [action["action_id"] for action in packet["actions"]] == ["goal.stop"] assert all(action["goal_id"] == GOAL_ID for action in packet["actions"]) assert all(action["requires_confirmation"] is True for action in packet["actions"]) assert packet["authority_owner"] == "typescript_control_plane" @@ -153,6 +170,12 @@ def test_goal_actions_cli_projects_exact_fresh_execution_identity( assert action["execution"]["expected_state_fingerprint"] == packet["state_fingerprint"] assert action["execution"]["argv"] == [ "loopx", + "--registry", + str(registry_path), + "--runtime-root", + str(tmp_path / "runtime"), + "--format", + "json", "goal-lifecycle", "--goal-id", GOAL_ID, @@ -165,6 +188,22 @@ def test_goal_actions_cli_projects_exact_fresh_execution_identity( assert project.exists() +def test_projected_action_runs_verbatim_against_non_default_registry( + tmp_path: Path, +) -> None: + _project, registry_path = _write_registry(tmp_path) + projected = _run_cli(registry_path, "goal-actions", "--goal-id", GOAL_ID) + action = json.loads(projected.stdout)["actions"][0] + + executed = _run_projected_argv(action["execution"]["argv"]) + + assert executed.returncode == 0, executed.stderr + payload = json.loads(executed.stdout) + assert payload["ok"] is True + assert payload["written"] is True + assert payload["readback"]["verified"] is True + + def test_projected_lifecycle_action_rejects_stale_registry_without_writing( tmp_path: Path, ) -> None: @@ -184,7 +223,7 @@ def test_projected_lifecycle_action_rejects_stale_registry_without_writing( ) changed_bytes = registry_path.read_bytes() - executed = _run_cli(registry_path, *action["execution"]["argv"][1:]) + executed = _run_projected_argv(action["execution"]["argv"]) assert executed.returncode == 1 payload = json.loads(executed.stdout) @@ -205,7 +244,7 @@ def test_fresh_projected_lifecycle_action_applies_and_projects_resume( if item["action_id"] == "goal.stop" ) - executed = _run_cli(registry_path, *stop["execution"]["argv"][1:]) + executed = _run_projected_argv(stop["execution"]["argv"]) assert executed.returncode == 0, executed.stderr applied = json.loads(executed.stdout)