From fb4f71cc0f34990dacd372b32ecd6be4e6148f34 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:37:21 +0800 Subject: [PATCH 1/2] fix(console): avoid duplicate audit writes for routine heartbeats Co-Authored-By: Codex --- docs/console_information_design.zh-CN.md | 8 +++ tests/strategy_switch_worker_validation.mjs | 66 +++++++++++++++++++++ web/strategy-switch-console/worker.js | 42 +++++++++---- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/docs/console_information_design.zh-CN.md b/docs/console_information_design.zh-CN.md index c93c4e1..f465441 100644 --- a/docs/console_information_design.zh-CN.md +++ b/docs/console_information_design.zh-CN.md @@ -26,3 +26,11 @@ ## 交互与验证 验证默认总览、准确定位账户、配置/实际状态分离、搜索和筛选、空结果、读取失败、刷新时间、研究切页、折叠详情、语言切换以及电脑/手机布局。线上仅检查读取和导航;提交类操作在隔离测试数据中验证,不能为了页面测试触发实盘。 + +## KV 用量与心跳审计 + +运行目标心跳每次仍保存完整最新快照,包括来源时间和实际读回时间;不通过停止刷新或延长有效期节省额度。只有时间更新、其他归一化状态不变时,不再向可选的滚动审计列表追加重复同步记录。首次同步、启停或策略变化、运行异常及恢复、调度读回变化仍追加审计。人工操作和其他类型的审计不受此规则影响。 + +这使状态不变的单次心跳同步从两次 KV 写入降为一次。以十个目标每小时各同步一次估算,这一路径每天从 480 次降为 240 次写入;这不是整个 Cloudflare 账户的实际用量。实际告警类别及总量须查看账户统计,不能从测试计数推断。 + +免费 KV 每日读取额度为 100,000 次,写入、删除、列表请求各为 1,000 次,北京时间 08:00 重置。优先核对触发类别并消除重复操作;套餐升级不属于代码优化的隐含步骤。参见 [Cloudflare KV 定价与额度](https://developers.cloudflare.com/kv/platform/pricing/)。 diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 3cf6222..5ada8f2 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -3290,6 +3290,72 @@ assert.equal(runtimeTargetLifecyclePayload.policy.no_order, true); assert.equal(runtimeTargetLifecyclePayload.policy.execution_observation_read_only, true); assert.equal(runtimeTargetLifecyclePayload.policy.order_or_fill_evidence, "not_collected"); +// Routine heartbeats must refresh their evidence time without rewriting audit_log. +const lifecycleUsageStore = new Map(); +const existingOperatorAudit = { action: "manual_strategy_switch", login: "health-user", ts: controlNow }; +lifecycleUsageStore.set("audit_log", JSON.stringify([existingOperatorAudit])); +const lifecycleWrites = []; +const lifecycleUsageEnv = { + ...executionEvidenceEnv, + STRATEGY_SWITCH_CONFIG: { + ...controlKv, + async get(key) { return lifecycleUsageStore.get(key) || null; }, + async put(key, value) { lifecycleWrites.push(key); lifecycleUsageStore.set(key, value); }, + }, +}; +async function publishCountedLifecycle(source) { + lifecycleWrites.length = 0; + const response = 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(source), + }), lifecycleUsageEnv); + assert.equal(response.status, 200); +} +const usageSourceKey = "runtime_target_lifecycle_source:longbridge.sg"; +const usageSource = structuredClone(runtimeTargetLifecycleSourcePayload); +usageSource.targets[0].deployment = { + runtime_enabled: false, scheduler_state: "paused", strategy_profile: "soxl_soxx_trend_income", + execution_mode: "dry_run", observed_at: new Date(Date.now() - 120000).toISOString(), +}; +await publishCountedLifecycle(usageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +const firstLifecycleAudit = lifecycleUsageStore.get("audit_log"); +assert.deepEqual(JSON.parse(firstLifecycleAudit).at(-1), existingOperatorAudit); +const refreshedUsageSource = structuredClone(usageSource); +refreshedUsageSource.generated_at = new Date(Date.now() - 60000).toISOString(); +refreshedUsageSource.computed_at = refreshedUsageSource.generated_at; +refreshedUsageSource.targets[0].deployment.observed_at = refreshedUsageSource.generated_at; +await publishCountedLifecycle(refreshedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey]); +assert.equal(lifecycleUsageStore.get("audit_log"), firstLifecycleAudit); +const storedRefreshedUsage = JSON.parse(lifecycleUsageStore.get(usageSourceKey)); +assert.equal(storedRefreshedUsage.generated_at, refreshedUsageSource.generated_at); +assert.equal(storedRefreshedUsage.targets[0].deployment.observed_at, refreshedUsageSource.generated_at); + +const changedUsageSource = structuredClone(refreshedUsageSource); +changedUsageSource.targets[0].monitoring.runtime_guard = "attention"; +changedUsageSource.targets[0].disposition = { code: "parked", reason_code: "runtime_guard_attention" }; +await publishCountedLifecycle(changedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +await publishCountedLifecycle(refreshedUsageSource); // Recovery is also a state change. +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +const changedDeploymentSource = structuredClone(refreshedUsageSource); +changedDeploymentSource.targets[0].deployment.scheduler_state = "unknown"; +await publishCountedLifecycle(changedDeploymentSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +lifecycleUsageStore.set(usageSourceKey, "invalid previous snapshot"); +await publishCountedLifecycle(refreshedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +const workingLifecycleGet = lifecycleUsageEnv.STRATEGY_SWITCH_CONFIG.get; +lifecycleUsageEnv.STRATEGY_SWITCH_CONFIG.get = async (key) => { + if (key === usageSourceKey) throw new Error("synthetic previous snapshot read failure"); + return workingLifecycleGet(key); +}; +await publishCountedLifecycle(refreshedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +lifecycleUsageEnv.STRATEGY_SWITCH_CONFIG.get = workingLifecycleGet; + 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 8daf754..55a05a6 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -3147,6 +3147,17 @@ function executionEvidenceSourceKey(sourceId) { return `${EXECUTION_EVIDENCE_SOURCE_PREFIX}${sourceId}`; } +function runtimeTargetLifecycleAuditState(source) { + if (!source) return null; + const state = structuredClone(source); + delete state.generated_at; + delete state.computed_at; + for (const target of state.targets) { + if (target.deployment) delete target.deployment.observed_at; + } + return JSON.stringify(state); +} + 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. @@ -3166,17 +3177,28 @@ async function syncRuntimeTargetLifecycleSourceResponse(request, env) { } catch (error) { return json({ ok: false, error: error.message || "invalid runtime target lifecycle payload" }, 400); } - await writeConfigJson(env, runtimeTargetLifecycleSourceKey(source.source_id), source); + const sourceKey = runtimeTargetLifecycleSourceKey(source.source_id); + let previous = null; 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, - }); + previous = normalizeRuntimeTargetLifecycleSourceSnapshot(await readConfigJson(env, sourceKey)); + } catch { + // Missing/unreadable history must not prevent a fresh, valid observation. + } + // Always retain the new evidence timestamps. Only the optional rolling audit + // skips unchanged polling records; operator actions are audited separately. + await writeConfigJson(env, sourceKey, source); + try { + if (runtimeTargetLifecycleAuditState(previous) !== runtimeTargetLifecycleAuditState(source)) { + 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. } From f550a838704afdcbf2fc4d6cc9ea8f86e95d04dc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:39:46 +0800 Subject: [PATCH 2/2] test(console): preserve audit records when stale observations recover Co-Authored-By: Codex --- docs/console_information_design.zh-CN.md | 2 +- tests/strategy_switch_worker_validation.mjs | 15 ++++++++++++++- web/strategy-switch-console/worker.js | 13 ++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/console_information_design.zh-CN.md b/docs/console_information_design.zh-CN.md index f465441..a7bc00a 100644 --- a/docs/console_information_design.zh-CN.md +++ b/docs/console_information_design.zh-CN.md @@ -29,7 +29,7 @@ ## KV 用量与心跳审计 -运行目标心跳每次仍保存完整最新快照,包括来源时间和实际读回时间;不通过停止刷新或延长有效期节省额度。只有时间更新、其他归一化状态不变时,不再向可选的滚动审计列表追加重复同步记录。首次同步、启停或策略变化、运行异常及恢复、调度读回变化仍追加审计。人工操作和其他类型的审计不受此规则影响。 +运行目标心跳每次仍保存完整最新快照,包括来源时间和实际读回时间;不通过停止刷新或延长有效期节省额度。只有时间更新、其他归一化状态不变时,不再向可选的滚动审计列表追加重复同步记录。首次同步、启停或策略变化、运行异常及恢复、过期读回恢复、调度读回变化仍追加审计。人工操作和其他类型的审计不受此规则影响。 这使状态不变的单次心跳同步从两次 KV 写入降为一次。以十个目标每小时各同步一次估算,这一路径每天从 480 次降为 240 次写入;这不是整个 Cloudflare 账户的实际用量。实际告警类别及总量须查看账户统计,不能从测试计数推断。 diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 5ada8f2..e4abf96 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -3314,9 +3314,11 @@ async function publishCountedLifecycle(source) { } const usageSourceKey = "runtime_target_lifecycle_source:longbridge.sg"; const usageSource = structuredClone(runtimeTargetLifecycleSourcePayload); +usageSource.generated_at = new Date(Date.now() - 120000).toISOString(); +usageSource.computed_at = usageSource.generated_at; usageSource.targets[0].deployment = { runtime_enabled: false, scheduler_state: "paused", strategy_profile: "soxl_soxx_trend_income", - execution_mode: "dry_run", observed_at: new Date(Date.now() - 120000).toISOString(), + execution_mode: "dry_run", observed_at: usageSource.generated_at, }; await publishCountedLifecycle(usageSource); assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); @@ -3355,6 +3357,17 @@ lifecycleUsageEnv.STRATEGY_SWITCH_CONFIG.get = async (key) => { await publishCountedLifecycle(refreshedUsageSource); assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); lifecycleUsageEnv.STRATEGY_SWITCH_CONFIG.get = workingLifecycleGet; +const staleUsageSource = structuredClone(refreshedUsageSource); +staleUsageSource.computed_at = new Date(Date.now() - 48 * 3600000).toISOString(); +staleUsageSource.generated_at = staleUsageSource.computed_at; +lifecycleUsageStore.set(usageSourceKey, JSON.stringify(staleUsageSource)); +await publishCountedLifecycle(refreshedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); +const staleDeploymentSource = structuredClone(refreshedUsageSource); +staleDeploymentSource.targets[0].deployment.observed_at = staleUsageSource.computed_at; +lifecycleUsageStore.set(usageSourceKey, JSON.stringify(staleDeploymentSource)); +await publishCountedLifecycle(refreshedUsageSource); +assert.deepEqual(lifecycleWrites, [usageSourceKey, "audit_log"]); const researchTaskSyncValue = ["research", "task", "sync"].join("-"); const researchTaskEnv = { ...controlEnv, RESEARCH_TASK_SYNC_TOKEN: researchTaskSyncValue }; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 55a05a6..f4ae2b5 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -3147,13 +3147,18 @@ function executionEvidenceSourceKey(sourceId) { return `${EXECUTION_EVIDENCE_SOURCE_PREFIX}${sourceId}`; } -function runtimeTargetLifecycleAuditState(source) { +function runtimeTargetLifecycleAuditState(source, ttlSeconds, now) { if (!source) return null; const state = structuredClone(source); + state.freshness = controlPlaneSnapshotFreshness(source, ttlSeconds, now).data_status; delete state.generated_at; delete state.computed_at; for (const target of state.targets) { - if (target.deployment) delete target.deployment.observed_at; + if (target.deployment?.observed_at) { + target.deployment.observed_at = controlPlaneSnapshotFreshness({ + data_status: "ready", computed_at: target.deployment.observed_at, + }, ttlSeconds, now).data_status; + } } return JSON.stringify(state); } @@ -3188,7 +3193,9 @@ async function syncRuntimeTargetLifecycleSourceResponse(request, env) { // skips unchanged polling records; operator actions are audited separately. await writeConfigJson(env, sourceKey, source); try { - if (runtimeTargetLifecycleAuditState(previous) !== runtimeTargetLifecycleAuditState(source)) { + const ttlSeconds = executionEvidenceStaleTtlSeconds(env); + const now = Date.now(); + if (runtimeTargetLifecycleAuditState(previous, ttlSeconds, now) !== runtimeTargetLifecycleAuditState(source, ttlSeconds, now)) { await appendAuditLog(env, { ts: new Date().toISOString(), login: "runtime-target-lifecycle-source-sync",