Skip to content

Commit f2bb6e7

Browse files
Pigbibicodex
andcommitted
feat: expose control plane research attention
Co-Authored-By: Codex <noreply@openai.com>
1 parent 12bc523 commit f2bb6e7

7 files changed

Lines changed: 124 additions & 6 deletions

File tree

docs/qsl_unified_control_console_architecture_v1.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333

3434
新增两层快照契约:各仓只发布自己的 `qsl_control_plane_source_snapshot.v1`;Worker 汇总为给网页读取的 `qsl_control_plane_dashboard.v1`。它汇总候选身份、当前阶段、数据新鲜度、P1/P2/P3 摘要、机器建议和 P6 是否等待所有者决定;不携带原始行情、账户、完整订单、文件路径或密钥。
3535

36+
`data_status=ready` 只说明来源快照已同步,****表示策略已验证、可执行或有订单授权。Worker 另行给出只读 `attention`:任一策略、组合或插件候选处于 `DEFERRED` / `PARKED`、来源产生脱敏错误,或没有候选时为 `attention_required`;缺快照为 `unavailable`;其余为 `research_only`。该字段只帮助监控和界面避免“工作流绿灯即策略绿灯”的误读,绝不提升 P4–P6 权限。
37+
3638
Worker 以**独立于 dispatch token 的同步身份**接收来源快照、按 `source_id` 分开保存在 KV,并只向已登录 allowlist 用户读取聚合结果。这样一个仓库不能覆盖另一个仓库的候选;同名候选会 fail-closed 并记录冲突。快照缺失、过期、未来时间戳或 schema 不符时,页面显示 `unavailable` / `stale`,不伪造全局健康结论。当前日更来源默认 36 小时后变为 stale,适配交易日节奏而不把周末误报为实时故障。
3739

3840
执行状态另用独立的 `qsl_execution_evidence_source_snapshot.v1``qsl_execution_evidence_dashboard.v1` 投影。每一条记录的身份是“固定策略 revision × 目标平台 × 当前执行通道”,显示策略、目标数据和目标执行证据,以及该平台的 shadow / paper 能力。它有独立 KV 前缀和同步 token,不能覆盖 P1–P3 候选快照,也不能创建订单、资金变更或 P6 授权。没有来源时页面保持空状态;在某个平台已有 live 或 paper 记录,也只是一项该目标的证据,不自动推广到别的平台。

schemas/qsl-control-plane-dashboard.v1.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121
"owner_decision_required": { "type": "integer", "minimum": 0, "maximum": 1000 }
2222
}
2323
},
24+
"attention": {
25+
"type": "object",
26+
"description": "Derived research-state attention. It never grants execution authority.",
27+
"additionalProperties": false,
28+
"required": ["status", "reason_codes"],
29+
"properties": {
30+
"status": { "enum": ["research_only", "attention_required", "unavailable"] },
31+
"reason_codes": {
32+
"type": "array",
33+
"maxItems": 20,
34+
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" }
35+
}
36+
}
37+
},
2438
"candidates": {
2539
"type": "array",
2640
"maxItems": 1000,

tests/strategy_switch_worker_validation.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,10 @@ assert.equal(controlRead.status, 200);
20002000
const controlReadPayload = await controlRead.json();
20012001
assert.equal(controlReadPayload.data_status, "ready");
20022002
assert.deepEqual(controlReadPayload.summary, { candidate_count: 1, deferred: 1, parked: 0, owner_decision_required: 0 });
2003+
assert.deepEqual(controlReadPayload.attention, {
2004+
status: "attention_required",
2005+
reason_codes: ["control_plane_candidate_deferred", "safe_notice"],
2006+
});
20032007
assert.equal(controlReadPayload.candidates[0].recommendation.reason, "没有可用的机器建议。");
20042008
assert.equal(controlReadPayload.policy.p6_owner_decision_required, true);
20052009
assert.ok(indexHtml.includes('requestJson("/api/control-plane")'));
@@ -2050,6 +2054,7 @@ const sourceControlRead = await worker.fetch(
20502054
const sourceControlPayload = await sourceControlRead.json();
20512055
assert.equal(sourceControlPayload.data_status, "ready");
20522056
assert.deepEqual(sourceControlPayload.summary, { candidate_count: 1, deferred: 0, parked: 0, owner_decision_required: 0 });
2057+
assert.deepEqual(sourceControlPayload.attention, { status: "research_only", reason_codes: [] });
20532058
assert.equal(sourceControlPayload.candidates[0].candidate_id, "tqqq_core_only_p2_v5");
20542059

20552060
const conflictingSourceSync = await worker.fetch(
@@ -2061,6 +2066,7 @@ const conflictingSourceSync = await worker.fetch(
20612066
controlEnv,
20622067
);
20632068
assert.equal(conflictingSourceSync.status, 200);
2069+
20642070
const conflictingSourceRead = await worker.fetch(
20652071
new Request("https://switch.example/api/control-plane", { headers: controlCookieHeaders }),
20662072
controlEnv,
@@ -2069,6 +2075,39 @@ const conflictingSourcePayload = await conflictingSourceRead.json();
20692075
assert.equal(conflictingSourcePayload.summary.candidate_count, 0);
20702076
assert.ok(conflictingSourcePayload.errors.includes("control_plane_duplicate_candidate"));
20712077

2078+
const parkedSourceSync = await worker.fetch(
2079+
new Request("https://switch.example/api/internal/sync-control-plane-source", {
2080+
method: "POST",
2081+
headers: { Authorization: `Bearer ${controlSyncValue}`, "Content-Type": "application/json" },
2082+
body: JSON.stringify({
2083+
...controlSourcePayload,
2084+
source_id: "uesp.soxl_daily_research",
2085+
candidates: [{
2086+
...controlSourcePayload.candidates[0],
2087+
candidate_id: "soxl_soxx_trend_income",
2088+
lifecycle: { stage: "P3", status: "parked" },
2089+
evidence: { ...controlSourcePayload.candidates[0].evidence, p3_evidence_id: null },
2090+
recommendation: { code: "park", reason: "P3 parked: runtime_internal_failure." },
2091+
}],
2092+
errors: ["p3_parked"],
2093+
}),
2094+
}),
2095+
controlEnv,
2096+
);
2097+
assert.equal(parkedSourceSync.status, 200);
2098+
const parkedControlRead = await worker.fetch(
2099+
new Request("https://switch.example/api/control-plane", { headers: controlCookieHeaders }),
2100+
controlEnv,
2101+
);
2102+
const parkedControlPayload = await parkedControlRead.json();
2103+
assert.equal(parkedControlPayload.data_status, "ready");
2104+
assert.equal(parkedControlPayload.attention.status, "attention_required");
2105+
assert.deepEqual(parkedControlPayload.attention.reason_codes, [
2106+
"control_plane_candidate_parked",
2107+
"control_plane_duplicate_candidate",
2108+
"p3_parked",
2109+
]);
2110+
20722111
const executionEvidenceSyncValue = ["execution", "evidence", "sync"].join("-");
20732112
const executionEvidenceEnv = { ...controlEnv, EXECUTION_EVIDENCE_SYNC_TOKEN: executionEvidenceSyncValue };
20742113
const executionEvidenceCookie = await __test.makeSession("health-user", [], executionEvidenceEnv);

web/strategy-switch-console/README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ GET /api/control-plane
172172

173173
新接入应调用 source 路径并提交 `qsl_control_plane_source_snapshot.v1`:Worker 按 `source_id` 分开保存,再聚合为 `qsl_control_plane_dashboard.v1`,所以一个仓库不会覆盖另一个仓库的候选。旧的完整快照路径仅为兼容保留。来源写入仍只保存候选生命周期、脱敏证据标识、新鲜度和机器建议;它会重新计算计数、丢弃未定义字段,并拒绝不一致的 P6 状态。读取仍要求登录 allowlist;日更来源超过默认 36 小时 TTL 时返回 `stale`
174174

175+
聚合快照的 `data_status=ready` 仅代表快照送达。请同时读取只读 `attention`:任何 `DEFERRED` / `PARKED` 的策略、组合或插件候选、上游错误或空候选都会使其成为 `attention_required`;这用于告警与界面提示,绝不是自动调参、自动修复、paper/shadow/live 或下单授权。
176+
175177
这个接口不是订单、paper、shadow 或 live API。`CONTROL_PLANE_SYNC_TOKEN` 必须与 OAuth、workflow dispatch、策略 root 和任何券商凭证完全分离。部署 workflow 会在 `runtime-strategy-switch` environment 提供该 secret 时,把它同步为 Worker secret;每个来源仓库需要保存同一值到其专用 GitHub Environment,URL 使用只读控制台的 `/api/internal/sync-control-plane-source`
176178

177179
## 策略 Profile 对齐规范

web/strategy-switch-console/app.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -823,14 +823,18 @@
823823
controlParked: "已停车",
824824
controlOwnerDecision: "等待我决定",
825825
controlCandidateBoard: "候选生命周期",
826-
controlDataReady: "快照已加载",
826+
controlDataReady: "快照已同步(不代表策略可运行)",
827827
controlDataStale: "快照已过期",
828828
controlDataUnavailable: "等待可用快照",
829+
controlAttentionResearchOnly: "仅研究,未授予执行权限",
830+
controlAttentionRequired: "研究状态需要关注",
831+
controlAttentionUnavailable: "尚无已验证研究状态",
829832
controlComputedAt: "最近计算:{time}",
830833
controlLoginNotice: "登录后读取私有全局快照;没有快照时不会展示虚构信息。",
831834
controlStaleNotice: "全局快照已超过允许的新鲜度窗口;页面保留原始记录,但不把它作为当前结论。",
832835
controlUnavailableNotice: "还没有可用的全局快照;当前页面保持 fail-closed 空状态。",
833836
controlUpstreamNotice: "快照已加载,但有 {count} 个上游提示;缺失信息不会被补成推断结论。",
837+
controlAttentionNotice: "研究状态需要关注(延期 {deferred}、停车 {parked}、信号 {signals})。快照送达不等于策略验证或执行授权;系统保持不交易。",
834838
controlEmptyCandidates: "暂无可展示的候选快照。",
835839
controlNoRecommendation: "没有可用的机器建议。",
836840
controlStageMeta: "阶段:{stage} · 状态:{status} · 证据:{freshness}",
@@ -1040,14 +1044,18 @@
10401044
controlParked: "Parked",
10411045
controlOwnerDecision: "Needs my decision",
10421046
controlCandidateBoard: "Candidate lifecycle",
1043-
controlDataReady: "Snapshot loaded",
1047+
controlDataReady: "Snapshot synced (not strategy readiness)",
10441048
controlDataStale: "Snapshot is stale",
10451049
controlDataUnavailable: "Waiting for a usable snapshot",
1050+
controlAttentionResearchOnly: "Research only; no execution authority",
1051+
controlAttentionRequired: "Research state needs attention",
1052+
controlAttentionUnavailable: "No verified research state",
10461053
controlComputedAt: "Last computed: {time}",
10471054
controlLoginNotice: "Sign in to read the private global snapshot. Missing data is never invented.",
10481055
controlStaleNotice: "This snapshot is outside its freshness window. The record remains visible, but is not a current conclusion.",
10491056
controlUnavailableNotice: "No usable global snapshot is available yet. This page remains fail-closed and empty.",
10501057
controlUpstreamNotice: "The snapshot loaded with {count} upstream notice(s); missing information is never inferred.",
1058+
controlAttentionNotice: "Research needs attention ({deferred} deferred, {parked} parked, {signals} signal(s)). Snapshot delivery is not strategy validation or execution authority; the system remains no-order.",
10511059
controlEmptyCandidates: "No candidate snapshot is available to display.",
10521060
controlNoRecommendation: "No machine recommendation is available.",
10531061
controlStageMeta: "Stage: {stage} · status: {status} · evidence: {freshness}",
@@ -1294,6 +1302,7 @@
12941302
data_status: "unavailable",
12951303
computed_at: null,
12961304
summary: { candidate_count: 0, deferred: 0, parked: 0, owner_decision_required: 0 },
1305+
attention: { status: "unavailable", reason_codes: ["control_plane_source_unavailable"] },
12971306
candidates: [],
12981307
policy: { p4_p5_automation: "not_configured", p6_owner_decision_required: true },
12991308
errors: [],
@@ -3373,6 +3382,14 @@
33733382
data_status: ["ready", "stale", "unavailable"].includes(payload.data_status) ? payload.data_status : "unavailable",
33743383
computed_at: payload.computed_at || null,
33753384
summary: payload.summary && typeof payload.summary === "object" ? payload.summary : {},
3385+
attention: payload.attention && typeof payload.attention === "object" && !Array.isArray(payload.attention)
3386+
? {
3387+
status: ["research_only", "attention_required", "unavailable"].includes(payload.attention.status)
3388+
? payload.attention.status
3389+
: "unavailable",
3390+
reason_codes: Array.isArray(payload.attention.reason_codes) ? payload.attention.reason_codes : [],
3391+
}
3392+
: { status: "unavailable", reason_codes: ["control_plane_attention_missing"] },
33763393
candidates: candidates.filter((item) => item && typeof item === "object" && item.lifecycle && typeof item.lifecycle === "object"),
33773394
policy: payload.policy && typeof payload.policy === "object" ? payload.policy : {},
33783395
errors: Array.isArray(payload.errors) ? payload.errors : [],
@@ -3385,10 +3402,17 @@
33853402
: (status === "stale" ? t("controlDataStale") : t("controlDataUnavailable"));
33863403
}
33873404

3405+
function controlPlaneAttentionText(attention) {
3406+
const status = attention?.status || "unavailable";
3407+
if (status === "research_only") return t("controlAttentionResearchOnly");
3408+
if (status === "attention_required") return t("controlAttentionRequired");
3409+
return t("controlAttentionUnavailable");
3410+
}
3411+
33883412
function renderControlPlane() {
33893413
const payload = state.controlPlane.payload;
33903414
const summary = payload.summary || {};
3391-
el("control-plane-status").textContent = controlPlaneDataStatusText(payload.data_status);
3415+
el("control-plane-status").textContent = `${controlPlaneDataStatusText(payload.data_status)} · ${controlPlaneAttentionText(payload.attention)}`;
33923416
el("control-plane-computed-at").textContent = payload.computed_at
33933417
? t("controlComputedAt").replace("{time}", new Date(payload.computed_at).toLocaleString())
33943418
: t("controlComputedAt").replace("{time}", "—");
@@ -3404,6 +3428,11 @@
34043428
notice.textContent = t("controlStaleNotice");
34053429
} else if (payload.data_status !== "ready") {
34063430
notice.textContent = t("controlUnavailableNotice");
3431+
} else if (payload.attention?.status === "attention_required") {
3432+
notice.textContent = t("controlAttentionNotice")
3433+
.replace("{deferred}", String(Number(summary.deferred) || 0))
3434+
.replace("{parked}", String(Number(summary.parked) || 0))
3435+
.replace("{signals}", String(payload.attention.reason_codes?.length || 0));
34073436
} else if (payload.errors?.length) {
34083437
notice.textContent = t("controlUpstreamNotice").replace("{count}", payload.errors.length);
34093438
} else {
@@ -3793,6 +3822,7 @@
37933822
data_status: "unavailable",
37943823
computed_at: null,
37953824
summary: { candidate_count: 0, deferred: 0, parked: 0, owner_decision_required: 0 },
3825+
attention: { status: "unavailable", reason_codes: ["control_plane_request_failed"] },
37963826
candidates: [],
37973827
policy: { p4_p5_automation: "not_configured", p6_owner_decision_required: true },
37983828
errors: ["control_plane_request_failed"],

web/strategy-switch-console/app_js.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/strategy-switch-console/worker.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const STRATEGY_HEALTH_DATA_STATUSES = ["ready", "unavailable", "stale"];
4242
const CONTROL_PLANE_SNAPSHOT_KEY = "control_plane_snapshot";
4343
const CONTROL_PLANE_SOURCE_PREFIX = "control_plane_source:";
4444
const CONTROL_PLANE_SOURCE_SCHEMA_VERSION = "qsl_control_plane_source_snapshot.v1";
45+
const CONTROL_PLANE_ATTENTION_STATUSES = ["research_only", "attention_required", "unavailable"];
4546
const CONTROL_PLANE_MAX_SOURCES = 100;
4647
const CONTROL_PLANE_MAX_BODY_BYTES = 256 * 1024;
4748
// The current source is a once-per-trading-day P1/P3 lane, rather than a live
@@ -1663,19 +1664,25 @@ async function aggregateControlPlaneSources(env) {
16631664

16641665
const dataStatus = hasStaleSource ? "stale" : (hasReadySource ? "ready" : "unavailable");
16651666
const uniqueCandidates = candidates.filter((candidate) => !duplicateCandidateIds.has(candidate.candidate_id));
1667+
const uniqueErrors = uniqueStrings(errors);
16661668
return {
16671669
schema_version: "qsl_control_plane_dashboard.v1",
16681670
generated_at: earliestControlPlaneTimestamp(timestamps),
16691671
computed_at: earliestControlPlaneTimestamp(timestamps),
16701672
data_status: dataStatus,
16711673
summary: normalizeControlPlaneSummary(uniqueCandidates),
1674+
attention: normalizeControlPlaneAttention({
1675+
dataStatus,
1676+
candidates: uniqueCandidates,
1677+
errors: uniqueErrors,
1678+
}),
16721679
candidates: uniqueCandidates,
16731680
policy: normalizeControlPlanePolicy({
16741681
p4_p5_automation: "not_configured",
16751682
p6_owner_decision_required: true,
16761683
notice: "P1–P3 自动研究已接入;P4/P5 尚未配置,live 仍需所有者明确决定。",
16771684
}, "aggregated control plane policy"),
1678-
errors: uniqueStrings(errors),
1685+
errors: uniqueErrors,
16791686
};
16801687
}
16811688

@@ -2233,15 +2240,17 @@ function normalizeControlPlaneSnapshot(payload, fieldName = "control plane snaps
22332240
}
22342241
const dataStatus = cleanChoice(payload.data_status || "unavailable", STRATEGY_HEALTH_DATA_STATUSES, `${fieldName}.data_status`);
22352242
const candidates = dataStatus === "unavailable" ? [] : normalizeControlPlaneCandidates(payload.candidates, fieldName);
2243+
const errors = normalizeStrategyHealthErrors(payload.errors);
22362244
return {
22372245
schema_version: "qsl_control_plane_dashboard.v1",
22382246
generated_at: normalizeStrategyHealthTimestamp(payload.generated_at, `${fieldName}.generated_at`, true),
22392247
computed_at: normalizeStrategyHealthTimestamp(payload.computed_at, `${fieldName}.computed_at`, true),
22402248
data_status: dataStatus,
22412249
summary: normalizeControlPlaneSummary(candidates),
2250+
attention: normalizeControlPlaneAttention({ dataStatus, candidates, errors }),
22422251
candidates,
22432252
policy: normalizeControlPlanePolicy(payload.policy, fieldName),
2244-
errors: normalizeStrategyHealthErrors(payload.errors),
2253+
errors,
22452254
};
22462255
}
22472256

@@ -2354,6 +2363,27 @@ function normalizeControlPlaneSummary(candidates) {
23542363
};
23552364
}
23562365

2366+
function normalizeControlPlaneAttention({ dataStatus, candidates, errors }) {
2367+
const normalizedCandidates = Array.isArray(candidates) ? candidates : [];
2368+
const normalizedErrors = uniqueStrings(Array.isArray(errors) ? errors : []);
2369+
const reasonCodes = new Set(normalizedErrors);
2370+
if (dataStatus !== "ready") reasonCodes.add("control_plane_source_unavailable");
2371+
if (!normalizedCandidates.length && dataStatus === "ready") reasonCodes.add("control_plane_candidate_missing");
2372+
if (normalizedCandidates.some((item) => item.lifecycle?.status === "deferred")) {
2373+
reasonCodes.add("control_plane_candidate_deferred");
2374+
}
2375+
if (normalizedCandidates.some((item) => item.lifecycle?.status === "parked")) {
2376+
reasonCodes.add("control_plane_candidate_parked");
2377+
}
2378+
const status = dataStatus !== "ready"
2379+
? "unavailable"
2380+
: (reasonCodes.size ? "attention_required" : "research_only");
2381+
return {
2382+
status: cleanChoice(status, CONTROL_PLANE_ATTENTION_STATUSES, "control plane attention status"),
2383+
reason_codes: [...reasonCodes].sort(),
2384+
};
2385+
}
2386+
23572387
function normalizeControlPlanePolicy(value, fieldName) {
23582388
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
23592389
if (source.p6_owner_decision_required !== true) {
@@ -2514,6 +2544,7 @@ function emptyControlPlanePayload(errorCode) {
25142544
computed_at: null,
25152545
data_status: "unavailable",
25162546
summary: { candidate_count: 0, deferred: 0, parked: 0, owner_decision_required: 0 },
2547+
attention: { status: "unavailable", reason_codes: ["control_plane_source_unavailable", errorCode] },
25172548
candidates: [],
25182549
policy: { p4_p5_automation: "not_configured", p6_owner_decision_required: true, notice: "live 仍需所有者明确决定。" },
25192550
errors: [errorCode],

0 commit comments

Comments
 (0)