diff --git a/actions/publish-runtime-target-lifecycle/action.yml b/actions/publish-runtime-target-lifecycle/action.yml new file mode 100644 index 0000000..fa10261 --- /dev/null +++ b/actions/publish-runtime-target-lifecycle/action.yml @@ -0,0 +1,72 @@ +name: Publish runtime target lifecycle +description: Build and sync one bounded, no-order runtime-target lifecycle snapshot. + +inputs: + source-id: + description: Stable, non-sensitive source identity for this exact target. + required: true + target-id: + description: Stable, non-sensitive target identity. + required: true + platform: + description: Exact platform identifier. + required: true + configured-state: + description: enabled or disabled; this action never changes it. + required: true + execution-mode: + description: Intended dry_run, paper, or live lane. + required: true + runtime-guard: + description: Sanitized runtime-guard result. + required: true + execution-heartbeat: + description: Sanitized execution-heartbeat result. + required: true + sync-url: + description: HTTPS Strategy Switch Console base URL. + required: true + +runs: + using: composite + steps: + - name: Build and sync bounded target lifecycle + shell: bash + env: + INPUT_SOURCE_ID: ${{ inputs.source-id }} + INPUT_TARGET_ID: ${{ inputs.target-id }} + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_CONFIGURED_STATE: ${{ inputs.configured-state }} + INPUT_EXECUTION_MODE: ${{ inputs.execution-mode }} + INPUT_RUNTIME_GUARD: ${{ inputs.runtime-guard }} + INPUT_EXECUTION_HEARTBEAT: ${{ inputs.execution-heartbeat }} + INPUT_SYNC_URL: ${{ inputs.sync-url }} + run: | + set -euo pipefail + if [[ -z "${EXECUTION_EVIDENCE_SYNC_TOKEN:-}" ]]; then + echo "EXECUTION_EVIDENCE_SYNC_TOKEN is required." >&2 + exit 2 + fi + if [[ ! "$INPUT_SYNC_URL" =~ ^https://[^/?#]+$ ]]; then + echo "sync-url must be an HTTPS base URL without a path or query." >&2 + exit 2 + fi + work_dir="$(mktemp -d)" + cleanup() { rm -rf "$work_dir"; } + trap cleanup EXIT + snapshot_path="$work_dir/runtime-target-lifecycle.json" + python3 "$GITHUB_ACTION_PATH/../../python/scripts/runtime_target_lifecycle.py" \ + --source-id "$INPUT_SOURCE_ID" \ + --target-id "$INPUT_TARGET_ID" \ + --platform "$INPUT_PLATFORM" \ + --configured-state "$INPUT_CONFIGURED_STATE" \ + --execution-mode "$INPUT_EXECUTION_MODE" \ + --runtime-guard "$INPUT_RUNTIME_GUARD" \ + --execution-heartbeat "$INPUT_EXECUTION_HEARTBEAT" \ + --output "$snapshot_path" + curl --fail --silent --show-error \ + --request POST "${INPUT_SYNC_URL}/api/internal/sync-runtime-target-lifecycle-source" \ + --header "Authorization: Bearer ${EXECUTION_EVIDENCE_SYNC_TOKEN}" \ + --header "Content-Type: application/json" \ + --data-binary "@$snapshot_path" \ + >/dev/null diff --git a/docs/runtime_target_lifecycle.md b/docs/runtime_target_lifecycle.md new file mode 100644 index 0000000..645889f --- /dev/null +++ b/docs/runtime_target_lifecycle.md @@ -0,0 +1,25 @@ +# Runtime target lifecycle snapshot + +`qsl_runtime_target_lifecycle_source_snapshot.v1` records the operational +state of one exact platform target. It is deliberately separate from +`qsl_execution_evidence_source_snapshot.v1`: + +- an **enabled** target continues its runtime guard and execution-heartbeat + monitoring; +- a deliberately **disabled** target remains visible and continues no-order + validation, rather than being misreported as an unhealthy execution target; +- either monitoring failure returns `parked`; it never changes a target's + enabled flag, execution mode, credentials, strategy, or order permissions. + +Every target record has `no_order: true`. The Worker stores only platform, +target identity, intended lane, sanitized monitor states, disposition, and +bounded reason codes. It accepts the same protected +`EXECUTION_EVIDENCE_SYNC_TOKEN` as the existing platform execution-evidence +publisher, but stores these snapshots under a separate KV prefix and exposes +them from `GET /api/runtime-target-lifecycle` only to an allowed signed-in +user. + +Platform workflows call the reusable +`actions/publish-runtime-target-lifecycle` action after their existing checks. +The action only constructs and posts a sanitized status object; it has no +broker SDK, account material, or command to enable a runtime target. diff --git a/python/scripts/runtime_target_lifecycle.py b/python/scripts/runtime_target_lifecycle.py new file mode 100644 index 0000000..9ec2506 --- /dev/null +++ b/python/scripts/runtime_target_lifecycle.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Build a bounded, no-order lifecycle snapshot for one runtime target. + +This contract intentionally tracks configured target state separately from +execution evidence. A target disabled by policy is not an unavailable broker, +and it must not be represented as a paper/live execution result. +""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Mapping + + +SOURCE_SCHEMA_VERSION = "qsl_runtime_target_lifecycle_source_snapshot.v1" +PLATFORMS = frozenset({"alpaca", "binance", "firstrade", "ibkr", "longbridge", "qmt", "schwab"}) +CONFIGURED_STATES = frozenset({"enabled", "disabled"}) +EXECUTION_MODES = frozenset({"dry_run", "paper", "live"}) +CHECK_STATUSES = frozenset({"pass", "attention", "not_due", "not_applicable", "unavailable"}) +DISPOSITIONS = frozenset({"continue_enabled_monitoring", "continue_disabled_validation", "parked"}) +REASON_CODES = frozenset( + { + "none", + "target_intentionally_disabled", + "runtime_guard_attention", + "execution_heartbeat_attention", + "monitoring_unavailable", + } +) +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._=-]{0,127}$") +_TIMESTAMP = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + + +class RuntimeTargetLifecycleError(ValueError): + """Raised when a lifecycle snapshot would be ambiguous or unsafe.""" + + +def _identifier(value: object, field: str) -> str: + text = str(value or "").strip() + if not _IDENTIFIER.fullmatch(text): + raise RuntimeTargetLifecycleError(f"{field} must be a stable non-sensitive identifier") + return text + + +def _choice(value: object, choices: frozenset[str], field: str) -> str: + text = str(value or "").strip() + if text not in choices: + raise RuntimeTargetLifecycleError(f"{field} is unsupported") + return text + + +def _timestamp(value: object | None) -> str: + if value is None: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + text = str(value).strip() + if not _TIMESTAMP.fullmatch(text): + raise RuntimeTargetLifecycleError("observed_at must be an RFC3339 UTC timestamp") + try: + datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise RuntimeTargetLifecycleError("observed_at must be a valid calendar timestamp") from exc + return text + + +def _target_disposition( + *, + configured_state: str, + runtime_guard: str, + execution_heartbeat: str, +) -> tuple[str, str]: + if runtime_guard == "attention": + return "parked", "runtime_guard_attention" + if execution_heartbeat == "attention": + return "parked", "execution_heartbeat_attention" + if runtime_guard == "unavailable" or execution_heartbeat == "unavailable": + return "parked", "monitoring_unavailable" + if configured_state == "disabled": + if execution_heartbeat != "not_applicable": + raise RuntimeTargetLifecycleError( + "disabled targets require a not_applicable execution heartbeat" + ) + return "continue_disabled_validation", "target_intentionally_disabled" + if runtime_guard not in {"pass", "not_due"} or execution_heartbeat not in {"pass", "not_due"}: + raise RuntimeTargetLifecycleError("enabled target monitoring state is incomplete") + return "continue_enabled_monitoring", "none" + + +def build_runtime_target_lifecycle_source_snapshot( + *, + source_id: object, + target_id: object, + platform: object, + configured_state: object, + execution_mode: object, + runtime_guard: object, + execution_heartbeat: object, + observed_at: object | None = None, +) -> dict[str, Any]: + """Create one sanitized target state record without execution authority.""" + normalized_source_id = _identifier(source_id, "source_id") + normalized_target_id = _identifier(target_id, "target_id") + normalized_platform = _choice(platform, PLATFORMS, "platform") + normalized_state = _choice(configured_state, CONFIGURED_STATES, "configured_state") + normalized_mode = _choice(execution_mode, EXECUTION_MODES, "execution_mode") + normalized_guard = _choice(runtime_guard, CHECK_STATUSES, "runtime_guard") + normalized_heartbeat = _choice(execution_heartbeat, CHECK_STATUSES, "execution_heartbeat") + disposition, reason_code = _target_disposition( + configured_state=normalized_state, + runtime_guard=normalized_guard, + execution_heartbeat=normalized_heartbeat, + ) + timestamp = _timestamp(observed_at) + return { + "schema_version": SOURCE_SCHEMA_VERSION, + "source_id": normalized_source_id, + "generated_at": timestamp, + "computed_at": timestamp, + "data_status": "ready", + "targets": [ + { + "target_id": normalized_target_id, + "target": { + "platform": normalized_platform, + "configured_state": normalized_state, + "execution_mode": normalized_mode, + }, + "monitoring": { + "runtime_guard": normalized_guard, + "execution_heartbeat": normalized_heartbeat, + }, + "disposition": {"code": disposition, "reason_code": reason_code}, + "no_order": True, + } + ], + "errors": [], + } + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-id", required=True) + parser.add_argument("--target-id", required=True) + parser.add_argument("--platform", required=True) + parser.add_argument("--configured-state", required=True, choices=sorted(CONFIGURED_STATES)) + parser.add_argument("--execution-mode", required=True, choices=sorted(EXECUTION_MODES)) + parser.add_argument("--runtime-guard", required=True, choices=sorted(CHECK_STATUSES)) + parser.add_argument("--execution-heartbeat", required=True, choices=sorted(CHECK_STATUSES)) + parser.add_argument("--observed-at") + parser.add_argument("--output", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + snapshot = build_runtime_target_lifecycle_source_snapshot( + source_id=args.source_id, + target_id=args.target_id, + platform=args.platform, + configured_state=args.configured_state, + execution_mode=args.execution_mode, + runtime_guard=args.runtime_guard, + execution_heartbeat=args.execution_heartbeat, + observed_at=args.observed_at, + ) + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/tests/test_runtime_target_lifecycle.py b/python/tests/test_runtime_target_lifecycle.py new file mode 100644 index 0000000..2712344 --- /dev/null +++ b/python/tests/test_runtime_target_lifecycle.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[2] +MODULE_PATH = ROOT / "python" / "scripts" / "runtime_target_lifecycle.py" +SPEC = importlib.util.spec_from_file_location("runtime_target_lifecycle", MODULE_PATH) +assert SPEC and SPEC.loader +lifecycle = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = lifecycle +SPEC.loader.exec_module(lifecycle) + + +def _snapshot(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "source_id": "longbridge.sg", + "target_id": "longbridge.sg", + "platform": "longbridge", + "configured_state": "disabled", + "execution_mode": "dry_run", + "runtime_guard": "pass", + "execution_heartbeat": "not_applicable", + "observed_at": "2026-08-30T00:00:00Z", + } + values.update(overrides) + return lifecycle.build_runtime_target_lifecycle_source_snapshot(**values) + + +class RuntimeTargetLifecycleTest(unittest.TestCase): + def test_disabled_target_remains_in_no_order_validation_lane(self) -> None: + snapshot = _snapshot() + + target = snapshot["targets"][0] + self.assertEqual( + snapshot["schema_version"], + "qsl_runtime_target_lifecycle_source_snapshot.v1", + ) + self.assertEqual(target["target"]["configured_state"], "disabled") + self.assertEqual( + target["disposition"], + { + "code": "continue_disabled_validation", + "reason_code": "target_intentionally_disabled", + }, + ) + self.assertIs(target["no_order"], True) + + + def test_enabled_target_continues_monitoring_when_checks_pass(self) -> None: + snapshot = _snapshot( + configured_state="enabled", + execution_mode="paper", + execution_heartbeat="pass", + ) + + self.assertEqual( + snapshot["targets"][0]["disposition"], + { + "code": "continue_enabled_monitoring", + "reason_code": "none", + }, + ) + + + def test_monitoring_failures_park_without_changing_target_state(self) -> None: + cases = [ + ("attention", "pass", "runtime_guard_attention"), + ("pass", "attention", "execution_heartbeat_attention"), + ("unavailable", "pass", "monitoring_unavailable"), + ] + for runtime_guard, execution_heartbeat, reason_code in cases: + with self.subTest( + runtime_guard=runtime_guard, + execution_heartbeat=execution_heartbeat, + ): + snapshot = _snapshot( + configured_state="enabled", + execution_mode="paper", + runtime_guard=runtime_guard, + execution_heartbeat=execution_heartbeat, + ) + + self.assertEqual( + snapshot["targets"][0]["disposition"], + { + "code": "parked", + "reason_code": reason_code, + }, + ) + self.assertIs(snapshot["targets"][0]["no_order"], True) + + + def test_disabled_target_rejects_an_execution_heartbeat_claim(self) -> None: + with self.assertRaisesRegex(lifecycle.RuntimeTargetLifecycleError, "not_applicable"): + _snapshot(execution_heartbeat="pass") diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index e25db87..5e12236 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -2618,6 +2618,57 @@ assert.equal(executionEvidencePayload.policy.execution_evidence_read_only, true) assert.equal(executionEvidencePayload.policy.p6_owner_decision_required, true); assert.equal(executionEvidencePayload.policy.limited_live_canary_active, false); +const runtimeTargetLifecycleSourcePayload = { + schema_version: "qsl_runtime_target_lifecycle_source_snapshot.v1", + source_id: "longbridge.sg", + generated_at: controlNow, + computed_at: controlNow, + data_status: "ready", + targets: [{ + target_id: "longbridge.sg", + target: { platform: "longbridge", configured_state: "disabled", execution_mode: "dry_run" }, + monitoring: { runtime_guard: "pass", execution_heartbeat: "not_applicable" }, + disposition: { code: "continue_disabled_validation", reason_code: "target_intentionally_disabled" }, + no_order: true, + }], + errors: [], +}; +const invalidDisabledTargetLifecycle = await worker.fetch( + new Request("https://switch.example/api/internal/sync-runtime-target-lifecycle-source", { + method: "POST", + headers: { Authorization: `Bearer ${executionEvidenceSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + ...runtimeTargetLifecycleSourcePayload, + targets: [{ + ...runtimeTargetLifecycleSourcePayload.targets[0], + monitoring: { runtime_guard: "pass", execution_heartbeat: "pass" }, + }], + }), + }), + executionEvidenceEnv, +); +assert.equal(invalidDisabledTargetLifecycle.status, 400); +const runtimeTargetLifecycleSync = await worker.fetch( + new Request("https://switch.example/api/internal/sync-runtime-target-lifecycle-source", { + method: "POST", + headers: { Authorization: `Bearer ${executionEvidenceSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify(runtimeTargetLifecycleSourcePayload), + }), + executionEvidenceEnv, +); +assert.equal(runtimeTargetLifecycleSync.status, 200); +assert.equal((await runtimeTargetLifecycleSync.json()).target_count, 1); +const runtimeTargetLifecycleRead = await worker.fetch( + new Request("https://switch.example/api/runtime-target-lifecycle", { headers: executionEvidenceCookieHeaders }), + executionEvidenceEnv, +); +assert.equal(runtimeTargetLifecycleRead.status, 200); +const runtimeTargetLifecyclePayload = await runtimeTargetLifecycleRead.json(); +assert.equal(runtimeTargetLifecyclePayload.data_status, "ready"); +assert.deepEqual(runtimeTargetLifecyclePayload.summary, { target_count: 1, enabled: 0, disabled: 1, attention: 0 }); +assert.equal(runtimeTargetLifecyclePayload.targets[0].target.disposition.code, "continue_disabled_validation"); +assert.equal(runtimeTargetLifecyclePayload.policy.no_order, true); + const researchTaskSyncValue = ["research", "task", "sync"].join("-"); const researchTaskEnv = { ...controlEnv, RESEARCH_TASK_SYNC_TOKEN: researchTaskSyncValue }; const researchTaskCookie = await __test.makeSession("health-user", [], researchTaskEnv); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 601f53a..9150b11 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -141,6 +141,21 @@ const EXECUTION_EVIDENCE_REASON_CODES = [ "none", "target_execution_evidence_missing", "paper_not_supported", "paper_execution_evidence_needed", "policy_not_active", "source_stale", "manual_live_decision_required", ]; +// Runtime target lifecycle is intentionally separate from execution evidence: +// it records whether a target is enabled and whether its no-order monitors are +// healthy. A disabled target is not a missing broker or a paper/live result. +const RUNTIME_TARGET_LIFECYCLE_SOURCE_PREFIX = "runtime_target_lifecycle_source:"; +const RUNTIME_TARGET_LIFECYCLE_SOURCE_SCHEMA_VERSION = "qsl_runtime_target_lifecycle_source_snapshot.v1"; +const RUNTIME_TARGET_LIFECYCLE_DASHBOARD_SCHEMA_VERSION = "qsl_runtime_target_lifecycle_dashboard.v1"; +const RUNTIME_TARGET_LIFECYCLE_MAX_SOURCES = 100; +const RUNTIME_TARGET_LIFECYCLE_MAX_BODY_BYTES = 256 * 1024; +const RUNTIME_TARGET_LIFECYCLE_CONFIGURED_STATES = ["enabled", "disabled"]; +const RUNTIME_TARGET_LIFECYCLE_EXECUTION_MODES = ["dry_run", "paper", "live"]; +const RUNTIME_TARGET_LIFECYCLE_CHECK_STATUSES = ["pass", "attention", "not_due", "not_applicable", "unavailable"]; +const RUNTIME_TARGET_LIFECYCLE_DISPOSITIONS = ["continue_enabled_monitoring", "continue_disabled_validation", "parked"]; +const RUNTIME_TARGET_LIFECYCLE_REASON_CODES = [ + "none", "target_intentionally_disabled", "runtime_guard_attention", "execution_heartbeat_attention", "monitoring_unavailable", +]; // Research tasks are a separate, immutable and no-order index. They do not // share storage or a sync credential with candidate lifecycle snapshots. const RESEARCH_TASK_SOURCE_PREFIX = "research_task_source:"; @@ -316,6 +331,12 @@ export default { if (url.pathname === "/api/execution-evidence" && request.method === "GET") { return await executionEvidenceResponse(request, env); } + if (url.pathname === "/api/internal/sync-runtime-target-lifecycle-source" && request.method === "POST") { + return await syncRuntimeTargetLifecycleSourceResponse(request, env); + } + if (url.pathname === "/api/runtime-target-lifecycle" && request.method === "GET") { + return await runtimeTargetLifecycleResponse(request, env); + } if (url.pathname === "/api/internal/sync-research-task-source" && request.method === "POST") { return await syncResearchTaskSourceResponse(request, env); } @@ -1963,6 +1984,139 @@ function executionEvidenceSourceKey(sourceId) { return `${EXECUTION_EVIDENCE_SOURCE_PREFIX}${sourceId}`; } +async function syncRuntimeTargetLifecycleSourceResponse(request, env) { + // This publisher has the same narrow scope as execution evidence: sanitized + // platform status only, never credentials, accounts, orders, or commands. + requireDedicatedExecutionEvidenceSyncToken(request, env); + if (!hasConfigStore(env)) { + return json({ ok: false, error: "runtime target lifecycle KV is not configured" }, 503); + } + let raw; + try { + raw = await readBoundedJson(request, RUNTIME_TARGET_LIFECYCLE_MAX_BODY_BYTES); + } catch (error) { + return json({ ok: false, error: error.message || "invalid runtime target lifecycle payload" }, error.status || 400); + } + let source; + try { + source = normalizeRuntimeTargetLifecycleSourceSnapshot(raw, "runtime target lifecycle source snapshot"); + } catch (error) { + return json({ ok: false, error: error.message || "invalid runtime target lifecycle payload" }, 400); + } + await writeConfigJson(env, runtimeTargetLifecycleSourceKey(source.source_id), source); + try { + await appendAuditLog(env, { + ts: new Date().toISOString(), + login: "runtime-target-lifecycle-source-sync", + action: "sync_runtime_target_lifecycle_source", + source_id: source.source_id, + schema_version: source.schema_version, + target_count: source.targets.length, + data_status: source.data_status, + }); + } catch { + // A valid no-order snapshot remains useful when convenience audit retention fails. + } + return json({ + ok: true, + source_id: source.source_id, + schema_version: source.schema_version, + target_count: source.targets.length, + generated_at: source.generated_at, + }); +} + +async function runtimeTargetLifecycleResponse(request, env) { + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required" }, 401); + if (!hasConfigStore(env)) return json(emptyRuntimeTargetLifecyclePayload("snapshot_unavailable")); + return json(await aggregateRuntimeTargetLifecycleSources(env)); +} + +async function aggregateRuntimeTargetLifecycleSources(env) { + const sources = await readRuntimeTargetLifecycleSources(env); + if (!sources.length) return emptyRuntimeTargetLifecyclePayload("snapshot_unavailable"); + const ttlSeconds = executionEvidenceStaleTtlSeconds(env); + const now = Date.now(); + const targets = []; + const targetIds = new Set(); + const duplicateTargetIds = new Set(); + const errors = []; + const timestamps = []; + let hasReadySource = false; + let hasStaleSource = false; + for (const source of sources) { + const freshness = controlPlaneSnapshotFreshness(source, ttlSeconds, now); + if (source.generated_at) timestamps.push(source.generated_at); + if (source.computed_at) timestamps.push(source.computed_at); + if (freshness.data_status === "ready") hasReadySource = true; + if (freshness.data_status === "stale") { + hasStaleSource = true; + errors.push("runtime_target_lifecycle_source_stale"); + } + for (const target of source.targets) { + if (targetIds.has(target.target_id)) { + duplicateTargetIds.add(target.target_id); + errors.push("runtime_target_lifecycle_duplicate_target"); + continue; + } + targetIds.add(target.target_id); + targets.push({ source_id: source.source_id, freshness, target }); + } + errors.push(...source.errors); + } + const uniqueTargets = targets.filter((entry) => !duplicateTargetIds.has(entry.target.target_id)); + const dataStatus = hasStaleSource ? "stale" : (hasReadySource ? "ready" : "unavailable"); + return { + schema_version: RUNTIME_TARGET_LIFECYCLE_DASHBOARD_SCHEMA_VERSION, + generated_at: earliestControlPlaneTimestamp(timestamps), + computed_at: earliestControlPlaneTimestamp(timestamps), + data_status: dataStatus, + summary: { + target_count: uniqueTargets.length, + enabled: uniqueTargets.filter((entry) => entry.target.target.configured_state === "enabled").length, + disabled: uniqueTargets.filter((entry) => entry.target.target.configured_state === "disabled").length, + attention: uniqueTargets.filter((entry) => entry.target.disposition.code === "parked").length, + }, + targets: uniqueTargets, + policy: { + lifecycle_status_read_only: true, + no_order: true, + notice: "已启用目标持续监控;已停用目标持续进行无执行验证。该状态不会启用目标或提交订单。", + }, + errors: uniqueStrings(errors), + }; +} + +async function readRuntimeTargetLifecycleSources(env) { + const store = configStore(env); + if (!store || typeof store.list !== "function") return []; + let listing; + try { + listing = await store.list({ prefix: RUNTIME_TARGET_LIFECYCLE_SOURCE_PREFIX, limit: RUNTIME_TARGET_LIFECYCLE_MAX_SOURCES }); + } catch { + return [emptyRuntimeTargetLifecycleSourceSnapshot("runtime_target_lifecycle_source_list_unavailable")]; + } + const keys = Array.isArray(listing?.keys) ? listing.keys : []; + const sources = []; + for (const entry of keys.slice(0, RUNTIME_TARGET_LIFECYCLE_MAX_SOURCES)) { + const key = typeof entry?.name === "string" ? entry.name : ""; + if (!key.startsWith(RUNTIME_TARGET_LIFECYCLE_SOURCE_PREFIX)) continue; + try { + const stored = await readConfigJson(env, key); + if (!stored) continue; + sources.push(normalizeRuntimeTargetLifecycleSourceSnapshot(stored, key)); + } catch { + sources.push(emptyRuntimeTargetLifecycleSourceSnapshot("runtime_target_lifecycle_source_invalid")); + } + } + return sources; +} + +function runtimeTargetLifecycleSourceKey(sourceId) { + return `${RUNTIME_TARGET_LIFECYCLE_SOURCE_PREFIX}${sourceId}`; +} + async function syncResearchTaskSourceResponse(request, env) { requireDedicatedResearchTaskSyncToken(request, env); if (!hasConfigStore(env)) { @@ -4008,6 +4162,81 @@ function normalizeExecutionEvidenceDeployment(value, fieldName) { return normalized; } +function normalizeRuntimeTargetLifecycleSourceSnapshot(payload, fieldName = "runtime target lifecycle source snapshot") { + const source = assertExactFields(payload, [ + "schema_version", "source_id", "generated_at", "computed_at", "data_status", "targets", "errors", + ], fieldName); + if (source.schema_version !== RUNTIME_TARGET_LIFECYCLE_SOURCE_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + const dataStatus = cleanChoice(source.data_status, STRATEGY_HEALTH_DATA_STATUSES, `${fieldName}.data_status`); + if (!Array.isArray(source.targets) || source.targets.length > 1000) { + throw new Error(`${fieldName}.targets must be an array with at most 1000 items`); + } + const targets = []; + const seen = new Set(); + for (const [index, item] of source.targets.entries()) { + const target = normalizeRuntimeTargetLifecycleTarget(item, `${fieldName}.targets[${index}]`); + if (seen.has(target.target_id)) throw new Error(`${fieldName}.targets contains duplicate target_id`); + seen.add(target.target_id); + targets.push(target); + } + if (dataStatus === "unavailable" && targets.length) { + throw new Error(`${fieldName}.targets must be empty when unavailable`); + } + return { + schema_version: RUNTIME_TARGET_LIFECYCLE_SOURCE_SCHEMA_VERSION, + source_id: normalizeControlPlaneIdentifier(source.source_id, `${fieldName}.source_id`, false), + generated_at: normalizeStrategyHealthTimestamp(source.generated_at, `${fieldName}.generated_at`, true), + computed_at: normalizeStrategyHealthTimestamp(source.computed_at, `${fieldName}.computed_at`, true), + data_status: dataStatus, + targets, + errors: normalizeStrategyHealthErrors(source.errors), + }; +} + +function normalizeRuntimeTargetLifecycleTarget(value, fieldName) { + const item = assertExactFields(value, ["target_id", "target", "monitoring", "disposition", "no_order"], fieldName); + const target = assertExactFields(item.target, ["platform", "configured_state", "execution_mode"], `${fieldName}.target`); + const monitoring = assertExactFields(item.monitoring, ["runtime_guard", "execution_heartbeat"], `${fieldName}.monitoring`); + const disposition = assertExactFields(item.disposition, ["code", "reason_code"], `${fieldName}.disposition`); + if (item.no_order !== true) throw new Error(`${fieldName}.no_order must be true`); + const normalized = { + target_id: normalizeControlPlaneIdentifier(item.target_id, `${fieldName}.target_id`, false), + target: { + platform: cleanChoice(target.platform, EXECUTION_EVIDENCE_PLATFORMS, `${fieldName}.target.platform`), + configured_state: cleanChoice(target.configured_state, RUNTIME_TARGET_LIFECYCLE_CONFIGURED_STATES, `${fieldName}.target.configured_state`), + execution_mode: cleanChoice(target.execution_mode, RUNTIME_TARGET_LIFECYCLE_EXECUTION_MODES, `${fieldName}.target.execution_mode`), + }, + monitoring: { + runtime_guard: cleanChoice(monitoring.runtime_guard, RUNTIME_TARGET_LIFECYCLE_CHECK_STATUSES, `${fieldName}.monitoring.runtime_guard`), + execution_heartbeat: cleanChoice(monitoring.execution_heartbeat, RUNTIME_TARGET_LIFECYCLE_CHECK_STATUSES, `${fieldName}.monitoring.execution_heartbeat`), + }, + disposition: { + code: cleanChoice(disposition.code, RUNTIME_TARGET_LIFECYCLE_DISPOSITIONS, `${fieldName}.disposition.code`), + reason_code: cleanChoice(disposition.reason_code, RUNTIME_TARGET_LIFECYCLE_REASON_CODES, `${fieldName}.disposition.reason_code`), + }, + no_order: true, + }; + const guardUnavailable = normalized.monitoring.runtime_guard === "unavailable"; + const heartbeatUnavailable = normalized.monitoring.execution_heartbeat === "unavailable"; + const hasAttention = normalized.monitoring.runtime_guard === "attention" || normalized.monitoring.execution_heartbeat === "attention"; + if (normalized.target.configured_state === "disabled") { + if (normalized.monitoring.execution_heartbeat !== "not_applicable") { + throw new Error(`${fieldName}.disabled target must not claim an execution heartbeat`); + } + if (!hasAttention && !guardUnavailable && normalized.disposition.code !== "continue_disabled_validation") { + throw new Error(`${fieldName}.disabled target requires continue_disabled_validation`); + } + } else if (!hasAttention && !guardUnavailable && !heartbeatUnavailable && normalized.disposition.code !== "continue_enabled_monitoring") { + throw new Error(`${fieldName}.enabled healthy target requires continue_enabled_monitoring`); + } + if (hasAttention || guardUnavailable || heartbeatUnavailable) { + if (normalized.disposition.code !== "parked") throw new Error(`${fieldName}.unhealthy monitoring requires parked`); + } + return normalized; +} + function normalizeExecutionEvidenceSummary(deployments) { return { deployment_count: deployments.length, @@ -4030,6 +4259,35 @@ function emptyExecutionEvidenceSourceSnapshot(errorCode) { }; } +function emptyRuntimeTargetLifecycleSourceSnapshot(errorCode) { + return { + schema_version: RUNTIME_TARGET_LIFECYCLE_SOURCE_SCHEMA_VERSION, + source_id: "unavailable", + generated_at: null, + computed_at: null, + data_status: "unavailable", + targets: [], + errors: [errorCode], + }; +} + +function emptyRuntimeTargetLifecyclePayload(errorCode) { + return { + schema_version: RUNTIME_TARGET_LIFECYCLE_DASHBOARD_SCHEMA_VERSION, + generated_at: null, + computed_at: null, + data_status: "unavailable", + summary: { target_count: 0, enabled: 0, disabled: 0, attention: 0 }, + targets: [], + policy: { + lifecycle_status_read_only: true, + no_order: true, + notice: "运行目标生命周期快照尚不可用;页面不会推断目标已启用。", + }, + errors: [errorCode], + }; +} + function emptyExecutionEvidencePayload(errorCode) { return { schema_version: EXECUTION_EVIDENCE_DASHBOARD_SCHEMA_VERSION,