* feat(cms): add verified desktop touchpoint hosts and Test acceptance * fix(cms): keep Test modal scrim above app chrome * fix(cms): load host styles and support hover packages * fix(cms): report mounted host compatibility for restoration * fix(cms): parse Vela actions in shared manifests without executing them * fix(cms): preserve authorized actions and correct modal and hover behavior (#8003) * fix(cms): harden touchpoint host interactions * fix(cms): preserve modal actions and verify effective close controls * test(cms): cover hover exit and above-anchor placement * fix(cms): hide client test controls by default * fix(cms): persist automatic campaign impressions per account and device * fix(web): remove host campaign close button * fix(cms): suppress campaign modals during onboarding * fix(cms): unify test and production campaign lifecycle (#8118) * fix(cms): enable safe campaign navigation in test and production * fix(cms): enforce server-authorized touchpoint lifetimes * fix(cms): refresh production touchpoints when client language changes * fix(cms): stabilize hover refresh and synchronize Test locale * fix(cms): preserve campaigns on focus and remember Test dismissal * fix(cms): discover Test deployments without reloading * fix(cms): keep granted touchpoint display authority through transport failures A refresh that times out or fails in transport is not a withdrawal, but the shared lifecycle treated every failure the same way: `status = "error"` then `revoke()`, which tore down a lease the server had already granted and was still inside its own deadline. One slow poll therefore blanked a running campaign until the next 30s tick, and a second failure could not restore it because the value had been dropped. `abandonAttempt` now names the invariant: cancel the attempt, keep the lease, and let `armExpiry` retire it at the deadline the server actually granted. The lease length is the bound, so no retry budget is needed — Test's sixty-second contract survives exactly one missed poll, and Production survives as many as its authorization covers. Three cases still clear, and one of them is new: - the failure carries the server's own withdrawal. A 410 is authoritative even when its receipt body is unreadable, so `ProductionTouchpointLoadError` now marks that one case and the lifecycle honours it. Without this, a malformed 410 would have been ridden out as if it were transport noise. - nothing is mounted, or the lease was already fenced by `wake`. Page recovery has no evidence the activity is still live, so it stays withdrawn. - the lease has run out on its own. This does not fix OPEND-3237, which is a new activity failing its FIRST load: there is no prior lease to keep and the display stays empty. It fixes the adjacent class where a mounted activity is erased by a transient refresh. * fix(cms): keep a failed refresh recoverable inside its own poll cycle Three changes to how one refresh cycle behaves when it does not go cleanly. None of them fixes OPEND-3237, whose first-load failure is still unexplained; they remove the ways a working campaign is lost to an ordinary hiccup. Bounded retries. A failed attempt used to get its next chance from the fixed 30s tick. For the sixty-second lease the Test runtime is granted, that chance lands exactly when the lease expires, so a single failure put display on the edge of going blank with nothing able to recover it. Retries at 1s and 3s give a fast failure — transport error, 5xx, DNS — several chances while the lease is still comfortably alive. A retry must finish inside the cycle that spawned it. `refresh` declines to start while a request is in flight, so a chain that ran past the next tick would not merely be late: it would swallow that tick entirely. Worst case was 15+1+15+3+15 = 49s against a 30s interval. Scheduling now requires room for the retry AND its full budget, which is also what makes "retries cover fast failures" true in the code rather than only in a comment — an attempt that burned the whole budget leaves no room by construction and is never retried. The retry budget resets per cycle, not per success: otherwise the first cycle to exhaust its retries would leave every later cycle with none. Request budget 10s -> 15s. One refresh fetches a context and every enabled placement, so the budget covers a round, not a request. A real round of 11.5s whose placements all succeeded was being abandoned by the ten-second budget. It stays well under the 30s interval on purpose. Forward `accept-encoding` through the touchpoint runtime proxy. Decisions carry base64 content and run to megabytes, and the proxy built its upstream headers from scratch, dropping the caller's preference — every refresh pulled the payload uncompressed, measured at 383KB against 214KB for the same decision. Because the body is piped through verbatim, upstream's `content-encoding` has to be handed back with it, or the caller decodes gzip as JSON; both halves land together. Verified end to end through daemon and web: 2,149,685 -> 1,606,678 bytes on the wire, byte-identical after decode. An authoritative withdrawal is never retried: a 410 that took a campaign off the screen must not put it back. * fix(cms): present each Test campaign modal once per account and device Test modal presentations now share the production touchpoint-displayed:v1 impression: recorded once visible, read before any new automatic opening, and persisted across reload, remount, and client restart. * fix(cms): give the Test control readout an explicit ladder weight
34 KiB
Vela Touchpoint「真实时间活动」测试交接
写于 2026-09-14。本文是 OpenDesign 对 Vela 的待实现依赖交接,不是上线记录。
后续用户已批准本地客户端开发:OpenDesign worktree 已接入共享展示生命周期和 Test 授权字段;本文同步记录客户端实现与验证边界。不执行 Vela 远程部署或数据库操作,既有生产服务端协议、不可变发布快照及 deployments/acceptances 历史不因此改变。Vela 客户端与服务端的待交付要求仍保留。
已决定的产品语义
活动 Test 不再由客户端选择「模拟前/中/后/唤醒」时间。测试者选择一份 Test deployment,Vela 以每次 GET 的服务器当前时间重新判定状态;只有服务端确认 active 且授权在实际挂载时仍有效,客户端才能渲染、挂载并回执。
用户已决定:测试入口以真实时间替换模拟场景,不再默认 active。 这不是把旧 active 改名为 wake,也不是给任一模拟分支保留别名。唯一合法选择是 realtime,状态完全由 Vela 当前时钟计算。
不变量:
- context 的唯一 scenario 是
"realtime";旧before、active、after、wake一律拒绝,不兼容映射或静默降级。 GET带 UTC ISO-8601 的serverTime、startsAt、endsAt、authorizationExpiresAt;均由服务端生成,客户端不得模拟时间或自行签发展示授权。- 每次 runtime
GET用同一个serverTime计算状态:serverTime < startsAt为before;startsAt <= serverTime < endsAt为active;否则为ended。三个状态均返回完整 metadata 与 identity 的 HTTP 200,不能由 POST、旧 context 或wake决定。 - acceptance 的
scenario也只能是"realtime";Vela 在写入事务中以服务器当前时间和 Test deployment snapshot 再次核对窗口 active,客户端不能传入/控制判定时间。 - deployment 的
snapshotHash、artifactHash、manifestHash、内容和既有 acceptance/audit 历史仍是不可变证据。时钟是 runtime 投影,不得重写 snapshot 或历史审计。
全球统一时间:北京时间配置,服务端判定
Admin 排期输入和展示固定使用北京时间 Asia/Shanghai(UTC+08:00),不随配置人员电脑时区变化。保存时将北京时间转换为明确的 UTC instant;接口使用带时区的时间,runtime 统一返回 UTC ISO-8601。不能仅填写 timeZone 字段却把无偏移的日期字符串按浏览器本地时区解析。
开始、结束与展示授权均由服务端控制,Test/Production 使用同一全球时间点;客户端所在地、系统时区及夏令时不改变排期,不实现“当地时间到几点才展示”。例如北京时间 2026-09-14 16:00 对应 2026-09-14T08:00:00.000Z,全球客户端都以这一 instant 为开始边界。本地计时仅用于保守撤载和刷新,不自行决定 active。统一时间边界不承诺网络与 polling 下所有设备同一毫秒挂载。
此处明确目标契约;Admin 输入转换与固定北京时间展示仍需按下方清单验证,不视为已完成的实现证明。
已知活动证据(复现材料,不等于上线)
- 活动:
活动A-HYT测试 - activity ID:
yl5cga2linv5ntp4sz0oc7oo - 活动修订:
r5 - Test deployment:
odquc46ptdedpba1c42w5a49 - 排期:2026-09-14 北京时间 15:47–16:00
- 16:01:06 创建 Test;16:15:35 context 仍投影为
active,模拟时间为 15:53:30;16:15:37–16:15:45 有四个 OD 触点回执。
这证明旧模拟 context 可在真实窗口结束后继续生成 active 与回执,正是本次要切断的路径。它不是一次真实时间验收成功的证据,不能据此 promotion 或宣称上线。
OpenDesign 本地验证(非 live Vela 联调)
本轮在实际 Chromium 中使用本地 HTTP 协议 fixture 和正式桌面宿主桥协议 fixture,经过真实客户端入口验证:Test before 零挂载,active 四触点挂载;活动仍 active 时,断网后短期授权到期使四触点全部撤下,deployment 选择保留,界面不把授权过期误报为活动 ended。生产弹窗也经过真实获取入口验证短期授权先于活动结束时撤下。缺少 Test 授权字段的 fixture 零挂载、零回执。共享 owner 的虚拟时钟测试另覆盖续期、响应耗时、本地钟后退、恢复、请求超时与迟到响应。上述是本地客户端证明,不是 live Vela 联调、原生桌面功能或部署证明。
客户端核心要求:Test 与 Production 必须共用一个展示生命周期
OpenDesign worktree 已合并展示生命周期实现。 TestCampaignModal、ProductionCampaignModal、ProductionCampaignBadge、ProductionCampaignHover 均调用 touchpoint-lifecycle.ts 的 useTouchpointLifecycle;旧的各宿主 polling/lease/wake effects 已移除。独立展示组各自持有实例,但调度、授权和代际隔离只有这一套实现,Test 不再绕开它。Test 选择/验收、生产展示频控和布局留在外层。下表保留重构前审计,不能当作当前代码状态。
重构前调用链与差异(历史基线)
下列行号仅定位重构前版本,符号名便于追溯;不是当前行号或 Vela 客户端的实现证明。
| 职责 | Test 重构前实现 | Production 重构前实现 | 基线判断 |
|---|---|---|---|
| 生命周期 owner | apps/web/src/components/TestCampaignModal.tsx:583–832 的 effect |
ProductionCampaignModal.tsx:182–353、ProductionCampaignBadge.tsx:63–204、ProductionCampaignHover.tsx:35–98(均在同一 components 目录) |
独立;生产三个宿主也分别维护控制逻辑。 |
| 获取/刷新 | Test effect 建 context、逐 placement GET,30 秒 polling(:817–821) | production-touchpoint-loader.ts:23 的 loadProductionTouchpointDecision;modal 初始及 focus/online 获取(:238–344),badge:196、hover:96 另有 RECHECK_MS 周期 |
没有统一刷新 owner;生产 loader 只是获取复用,连生产宿主的调度也不统一。 |
| 时间与到期 | realtimeTiming(TestCampaignModal.tsx:484–513)按 serverTime/window 与请求单调时钟计算边界,effect 自己设 timer |
生产宿主各自计算授权截止并设 timer;modal:267–270、322–326 | Test 仅活动窗口不能替代生产授权 lease。 |
| 恢复/断网/迟到响应 | Test effect 自有 generation、AbortController、10 秒超时及 focus/pageshow/visibilitychange fence(:615–723、800–831) | 生产宿主自有 request/authorization/lease generation;modal focus/online 触发获取,加载异常 clear(:328–351) | 恢复策略和请求失效机制尚未合并。 |
| 已共享的宿主/渲染 | useTestRuntime、TestTouchpointMount、recordVisibleTestTouchpoint |
modal/badge/hover 读取 Test store;modal/badge 调 TestTouchpointMount,hover 也调用可见性回执入口 | 仅部分宿主、store 和渲染/回执工具共用。 |
重构前,App 同时接入 Test/Production 组件,生产 modal 在存在 Test session 时退出独立生产获取 effect;这是 Test 接管宿主而非共享生命周期。重构后仍由 Test 选择接管展示,但 Test 的数据适配器与生产适配器均进入 useTouchpointLifecycle;共享 hook 的 references 已覆盖四个入口。
合并目标与准确职责
目标是同一客户端内一个 lifecycle owner + 两个数据适配器,不是跨 Vela/OD 仓库共享源码。Vela 客户端也需满足同一约束,但本次未重新核查其当前调用链,不能宣称它已完成共享。
- 数据获取/适配层:分别处理 Test/Production endpoint、认证、locale、deployment/context 选择及响应验证/适配。输出给同一 controller 的是经过身份与能力校验的 decision、服务器时间/活动窗口、展示授权限制或明确的 no-decision/revocation/error 结果。适配器不得拥有 polling、到期 timer、页面事件 listener 或另一个挂载状态机。
- 唯一共享生命周期:统一拥有 polling/重新获取调度、服务器时间边界、当前授权/可挂载状态、挂载与到期撤载、断网处理、页面恢复、请求取消和迟到响应隔离。Test 的开始边界也必须移入这里,不能继续由 TestCampaignModal 自己驱动,仅抽取一个“共同卸载计时器”仍不合格。
- 展示宿主:modal/badge/hover 保留各自布局、交互与展示频控,但消费同一 controller 的当前授权和挂载结果;不得保留平行的取数/lease effect。授权失效时所有宿主和异步 mount 回调都受同一代际失效约束,不能重新挂回旧内容。
- 生命周期之外:Test 选择面板只改变适配器输入;Test context 创建由 Test 适配器处理。
recordTestAcceptance(TestCampaignModal.tsx:249–297)及recordVisibleTestTouchpoint(:300–332)的验收业务留在外层,仅观察共享生命周期已授权且实际可见的结果;轮询、恢复和无效/过期响应不得产生新验收。外层不得反向维持另一套展示时钟。
OpenDesign 实施位置:touchpoint-lifecycle.ts 统一拥有 30 秒 polling、请求超时、服务器相对授权期限、开始边界重新 GET、页面恢复/可见性、请求取消及代际隔离。各宿主的数据适配器只获取和验证响应;生产继续调用 loadProductionTouchpointDecision,Test 在选择作用域创建 context 并获取 placements。相同 immutable decision 的正常 polling 续期保留挂载身份,不重复展示/验收。授权到期撤下内容,不自行将服务器 active 状态改为 ended。
Test deployment 目录发现(OPEND-3172)
“唯一共享生命周期”指展示授权与挂载的 owner,不代表 deployment 目录只能在初始化读取。目录发现由 test-deployment-selection.ts 独立拥有:前台每 30 秒读取目录,focus、online、pageshow 和恢复可见时立即刷新;单次请求 10 秒超时,单飞并取消过期请求。空目录、未开始和已结束的选中活动均不能终止目录发现。
- 普通客户端沿用服务端最新优先的目录顺序选择第一个受支持 deployment;调试模式保留人工选择,不自动抢选。目录不提供排期授权,未来活动只能在 realtime decision 验证后展示。
- 相同 deployment 与快照保留原 selection 对象,不能因新 JSON 对象重建 adapter、context 或挂载。替换、快照变化和成功目录确认移除才改变选择;目录请求失败、超时、格式错误不能被当成空目录,也不能延长原展示授权。
- 账号变化、卸载和页面隐藏取消旧目录请求,迟到结果不得覆盖新选择。目录选择只输入现有 Test adapter;context、decision、开始边界、续期和到期仍由原共享生命周期负责,不在目录层复制展示时钟。
- 目录刷新不发布临时空 Test session;切换期间由现有未授权 Test session 保持 Test/Production 隔离。旧授权不能随着目录发现或重试被恢复。
生产展示授权约束:必须原样保留上限
生产不是“活动未结束就能一直展示”。共享的 resolveAuthorizationDeadline 为生产保留有效截止:
min(authorizationExpiresAt, endsAt, serverTime + 5 分钟)
- 三个上限都必须进入共享生命周期;不能只取
endsAt,不能因 polling、Test 切换、重新挂载或页面恢复重新起算并延長旧授权。活动尚 active 但授权先到期时,也必须撤载。 - 按服务器时间和请求耗时保守换算本地调度;本地时钟变化、网络延迟或页面挂起不能延长已发授权。恢复时先阻止旧授权继续挂载,再重新验证;旧响应和旧 timer 不能复活已失效 decision。
- 普通窗口
focus不等同于页面恢复:页面仍可见且当前 lease 尚未到期时,立即后台验证并保留原挂载,不改变原授权截止;同一内容的有效响应只续期。visibilitychange、pageshow、online仍走暂停展示的恢复路径,focus 时已过期的 lease 也先撤下。请求失败、超时、匹配撤销及到期的清理规则不变。 - Test 弹窗与 Production 使用同一展示频控:弹窗实际可见即写入 localStorage
touchpoint-displayed:v1:<账号>:<活动ID>,同一账号、活动在本设备只自动弹出一次,跨刷新、重新挂载与客户端重启保持;Test 与 Production 共用该记录。已打开的弹窗可在续期、验证与语言切换中保持;此后同一活动的新 deployment、临时空 decision 或关闭后的重新下发均不再自动弹出。账号角标与 hover 不受该频控影响。 - 生产 loader 的 404 是 no-decision,并不自行撤销有效 lease;只有有效且绑定当前 touchpointDecisionId/deploymentId/activityId/contentVersionId 的 410 receipt 才构成服务端撤销。匹配的撤销清除当前展示,但不停止后续发现新活动。非 Abort 的当前请求失败仍诊断后 clear。恢复期间先暂停旧展示;no-decision 或不匹配撤销只能保留尚未到期的原授权及原截止,不能续期或复活过期授权。
- 用户已确认:Test 也由服务端返回 authorizationExpiresAt,与 Production 使用相同的短期展示授权规则。 OpenDesign Test DTO 已将此字段设为必填;适配器不得伪造授权或 fallback 到仅受 endsAt 限制。Vela 新响应仍待交付验证。
- Vela 当前生产服务端最长授权 60 秒(
services/api/src/touchpoints/persistence.ts:resolveProductionRuntime);Test 每 GET 使用同一 serverTime 生成authorizationExpiresAt = min(endsAt, serverTime + 60 秒)。before/ended 也返回该字段及完整 metadata,但不可挂载;ended 的授权时间可早于 serverTime。客户端 5 分钟安全上限不是服务端授权时长,不能延长 60 秒授权。 - 两种数据源进入同一授权与展示算法:服务器确认 active 且授权有效才能挂载;授权或活动到期取较早者撤载,断网也不续期。只有新的有效服务端响应可以续期;本地计时只能使展示失效,不能自行将 before 激活。
两层契约:API 可不同,展示生命周期相同
Test/Production 保留各自 endpoint、认证、选择方式和响应形状,不要求生产接口改为 Test 三态 200。两个适配器统一输出已验证的 identity/content、服务器窗口及授权限制,交给同一 lifecycle owner;统一 polling、挂载、撤载、恢复和迟到响应隔离。Test 选择与验收外置。生产既有 no-decision、撤销和失败语义按前文保留,不把不同响应混为相同撤销信号。
OpenDesign 本地 DTO 与客户端已按上述分层实现;Vela 侧 DTO、服务端、适配器及 fixtures 仍需迁移并完成真实联调,不能因本地实现完成而宣称两仓已交付。
共享完成的验收要求(两仓整体仍待验收)
- 从 Test 和 Production 真实入口分别追踪到同一 lifecycle owner;旧平行 effects/timers/listeners 已移除,适配器仅取数与适配。代码调用链证据与行为验证都要有。
- 在同一生命周期的验证入口替换两个实际适配器,覆盖 polling、before→active→ended、离线到期、页面恢复和迟到响应;再分别通过两种环境的实际宿主验证挂载/撤载。
- 生产额外覆盖 authorizationExpiresAt 早于 endsAt、5 分钟上限、窗口仍 active 但 lease 已过期、匹配/不匹配 410 receipt、404 不等于撤销;确认 Test 切换和恢复不会延长生产授权。
- Test 面板与验收回执外置;非 active/未授权/未实际挂载不验收,polling 不重复回执。生产原有展示频控与交互也必须保留。
此前 27 个相关 Test/host 测试和本地 Chromium Test fixture 的结果,只证明先前 Test 改动的已验证行为;既不证明 Production 已走同一逻辑,也不证明本节共享目标完成。
Wire 契约
OpenDesign 客户端 DTO 位于 @open-design/contracts/api/touchpointTestRuntime。Vela 与 OpenDesign 按下列 wire 契约对齐,但两个仓库不得互相 import对方的 contracts/package;Vela 应用等价的本地 type/schema,不能另造不同字段或语义。
import type {
TestRuntimeAcceptanceRequest,
TestRuntimeContext,
TestRuntimeContextRequest,
TestRuntimeDecision,
TestRuntimeScenario,
TestRuntimeScheduleState,
TestRuntimeTiming,
} from "@open-design/contracts/api/touchpointTestRuntime";
// TestRuntimeScenario = "realtime"
// TestRuntimeScheduleState = "before" | "active" | "ended"
// TestRuntimeContextRequest = { deploymentId: string; scenario: TestRuntimeScenario }
// TestRuntimeContext = TestRuntimeContextRequest & {
// testerMemberId?: string; updatedAt: string // ISO-8601
// }
// OpenDesign DTO 已同步授权字段;Vela 等价类型与响应仍需实现/验证。
// TestRuntimeTiming = { serverTime: string; startsAt: string; endsAt: string; authorizationExpiresAt: string } // UTC ISO-8601
// TestRuntimeDecision<Content, StaticActions> = {
// deploymentId: string; activityId?: string; snapshotHash?: string;
// artifactHash?: string; manifestHash?: string; placementKey: string;
// requiredCapabilities: string[]; content: Content; staticActions: StaticActions;
// testContext: TestRuntimeContext & { scheduleState: TestRuntimeScheduleState }
// } & TestRuntimeTiming
// TestRuntimeAcceptanceRequest = {
// placementKey: string; hostVersion: string; locale: string;
// scenario: TestRuntimeScenario; evidence: string
// }
simulatedAt 已删除,不得出现在 context、decision、数据库新写入、客户端校验或 acceptance 依据。testerMemberId 由服务端认证得到;客户端不得伪造它。TestRuntimeDecision<Content, StaticActions> 在顶层保留 deployment/activity/snapshot/artifact/manifest identity、placement、requiredCapabilities、content 和 staticActions,并附 timing/context;它是统一 shape,不得将 non-active 做成缺 metadata/content 的变体。
请求与响应范例
继续使用既有 endpoints。
POST /api/v1/touchpoints/runtime/test-context
Content-Type: application/json
{"deploymentId":"odquc46ptdedpba1c42w5a49","scenario":"realtime"}
201 Created
Content-Type: application/json
{
"deploymentId":"odquc46ptdedpba1c42w5a49",
"testerMemberId":"<authenticated-member-id>",
"scenario":"realtime",
"updatedAt":"2026-09-14T08:15:35.000Z"
}
GET /api/v1/touchpoints/runtime/test?deploymentId=odquc46ptdedpba1c42w5a49&placementKey=vela.web.console-overlay&locale=zh-CN
以下是局部说明性响应,只展开本次新增/关键字段;content 保持既有完整 payload shape,不是此处省略字段的替代协议。
200 OK
Content-Type: application/json
{
"deploymentId":"odquc46ptdedpba1c42w5a49",
"activityId":"<existing-activity-id>",
"snapshotHash":"sha256:<existing-64-hex-snapshot-hash>",
"artifactHash":"sha256:<existing-64-hex-artifact-hash>",
"manifestHash":"sha256:<existing-64-hex-manifest-hash>",
"placementKey":"vela.web.console-overlay",
"requiredCapabilities":["hover","static-action"],
"staticActions":[],
"content":{"id":"<existing-content-version-id>","placementKey":"vela.web.console-overlay","...":"existing payload unchanged"},
"testContext":{
"deploymentId":"odquc46ptdedpba1c42w5a49",
"testerMemberId":"<authenticated-member-id>",
"scenario":"realtime",
"updatedAt":"2026-09-14T08:15:35.000Z",
"scheduleState":"ended"
},
"serverTime":"2026-09-14T08:15:45.000Z",
"startsAt":"2026-09-14T07:47:00.000Z",
"endsAt":"2026-09-14T08:00:00.000Z",
"authorizationExpiresAt":"2026-09-14T08:00:00.000Z"
}
startsAt < endsAt 必须成立;serverTime 可以在窗口外。临界点 start 包含、end 排除。Vela 用单一 serverTime 同时生成 response 与 scheduleState,避免同一响应自相矛盾。
同一 serverTime 还用于生成最长 60 秒的 authorizationExpiresAt。active 响应必须满足 serverTime < authorizationExpiresAt <= min(endsAt, serverTime + 60 秒)。缺少/非法授权时间或超出上限不得挂载、回执或续期;before/ended 带完整字段不代表授权展示。before 必须重新 GET 得到 active 且有效的新授权,不能按本地计时启用旧响应。
POST /api/v1/touchpoints/test-deployments/odquc46ptdedpba1c42w5a49/acceptances
Content-Type: application/json
{
"placementKey":"vela.web.console-overlay",
"hostVersion":"<existing-host-version>",
"locale":"zh-CN",
"scenario":"realtime",
"evidence":"https://<existing-approved-evidence-location>",
"hostCompatibility":{"...":"existing optional report shape"}
}
窗口不是 active 时,服务端必须拒绝写入(保持项目现有错误风格;可用明确码 test_schedule_not_active),不创建 acceptance,避免 promotion 将其误认为验收齐全。
Vela 实施定位与改法
以下位置来自 powerformer/vela 检索到的当前实现(commit 7cbaeb015196c8ed4bdd669905b3811250335b59),是待迁移引用,不代表已修改。
| 位置 | 当前职责/旧行为 | Vela 必须改动 |
|---|---|---|
services/api/src/touchpoints/routes.ts:testContextSchema、POST /api/v1/touchpoints/runtime/test-context |
schema 接受 before/active/after/wake,再调 repository.selectTestContext |
改为严格 { deploymentId, scenario: z.literal("realtime") };继续从认证 principal 填 tester;旧值或额外 simulatedAt 返回既有 invalid_test_context 400。 |
同文件:GET /api/v1/touchpoints/runtime/test |
调 repository.resolveTestRuntime({ deploymentId, placementKey, locale, testerMemberId }) 后直接返回 decision |
保留认证、查询、404、413;repository 每 GET 取一次 now,返回完整 decision、顶层 timing 及重算 state。route 不缓存 decision。before/ended 仍 200。 |
同文件:POST /api/v1/touchpoints/test-deployments/:id/acceptances |
接受任意非空 scenario 后调 recordTestAcceptance |
schema 收紧为 z.literal("realtime");repository 写入事务内再判真实 active,不能只作 route 前置检查。 |
services/api/src/touchpoints/persistence.ts:TestScenario、TestContext、TestContextRow、toTestContext、simulatedTimeForScenario、scheduleStateAt、TestRuntimeDecision |
四种模拟场景;context 有 simulated_at;模拟函数生成时刻;decision 无顶层 timing |
scenario 只留 realtime;删 simulatedAt/simulated_at 投影和 simulatedTimeForScenario;scheduleStateAt 可保留为纯函数但输入必须是本次 server instant;decision 顶层加 serverTime/startsAt/endsAt/authorizationExpiresAt。 |
同文件:PostgresTouchpointRepository.selectTestContext、resolveTestRuntime、recordTestAcceptance |
context 写入/更新;resolve 拼 Test snapshot/context;acceptance 写证据 | select 只持久化 realtime 与 updated_at;resolve 每 GET 用 now + snapshot window 构建完整 timing/state 及最长 60 秒授权;acceptance 的 active 判定与写入在同一事务。 |
| touchpoints 的 test-context schema migration(路径/命名遵从 Vela 现有 migration 约定) | 当前 touchpoints.test_contexts 含 scenario 与 simulated_at;API SQL mock 也引用它 |
context 是可重选临时状态,不是 deployment/acceptance 审计。clean cutover 下:停写旧服务后令旧 contexts 失效并清理,DB owner 批准后收紧 schema 到 realtime 并移除 simulated_at。若不能安全清理,可保留 legacy 存储但 runtime 必须拒绝读取/解释它;不得改 deployments、acceptances 或其历史。 |
apps/web/src/components/test-campaign-contract.ts |
TestScenario、VelaTestContext、VelaTestDecision 和 velaTestDecisionMatchesSelection 比较 simulatedAt,并要求 active |
切到 realtime DTO:删旧 scenario/simulatedAt;校验完整授权字段、最长 60 秒上限、ISO timing、startsAt < endsAt、state 与 serverTime/window 一致;保留 snapshot、placement、capabilities、staticActions 绑定。non-active 保留同形 content,但不挂载。 |
apps/web/src/components/test-campaign-console.tsx、apps/web/src/components/production-campaign-float.tsx |
历史检索入口,非本次 Vela 调用链核查结果 | 在 Vela 客户端内核查并合并到同一生命周期;console 保留选择/验收外层,Test/Production 获取适配器负责协议差异。不能仅迁移 realtime DTO 或复用 float 就声称共享,必须提交实际调用链与两环境验收证据。 |
需迁移引用与验证
已检索到的引用:
services/api/test/touchpoints-test-context.test.ts:mocktouchpoints.test_contextsSQL 与 context persistence。services/api/test/touchpoints-browser-runtime-routes.test.ts:runtime routes/依赖注入。services/api/test/touchpoints-activities.test.ts:活动/Test runtime 行为。services/api/test/e2e/touchpoints-restore-lifecycle.test.ts:Test deployment 生命周期与 promotion/restore 边界。apps/web/tests/unit/production-campaign-float.test.ts、apps/web/tests/browser-agent/production-campaign-float.spec.ts:Web 浮层 fixture/消费。apps/web/src/components/test-campaign-console.tsx、apps/web/src/components/production-campaign-float.tsx、apps/web/src/components/test-campaign-contract.ts:Admin/Vela Web 类型、选择校验、渲染入口。services/api/src/touchpoints/promotion.ts与routes.tspromotion path:acceptance_incomplete阻断仍必须只承认真实 active 窗口形成的 acceptance。
实现时对上述导出类型/方法先跑 references,再全量迁移实际命中处;不要臆测或改动未检索到的消费者。
历史 acceptance 有效性:同库与跨库必须同时过滤
Vela review 补充的交付要求:同库 promotion 和跨库 getPromotionEvidence 必须消费同一份有效性判定;仅收紧新 acceptance 写入不能隔离旧证据。本次不声称远程 Vela 已实现该过滤。
- 先过滤,再计算必需 placement 的完整性。有效记录必须为
scenario === "realtime",绑定目标 Test deployment、不可变 snapshot/hash、placement 及既有身份/兼容性要求;可信服务端验收时刻必须位于[startsAt, endsAt)。只有 scenario 字符串不够,缺失可信时间/绑定信息的记录不得计入。 - 旧 before/active/after/wake 或未知 scenario 保留审计但不计入;不得改名 realtime、补造时间或重写历史。判断的是验收发生时刻,不是 promotion 当前时刻;已结束活动中的有效历史 realtime 验收不会仅因现在过期而失效。
- 同库 promotion:过滤后未覆盖所有必需 placement,沿用
acceptance_incomplete阻断。 - 跨库
getPromotionEvidence:导出/聚合前应用同一过滤,不得把原始记录 count 或未过滤的“齐全”布尔值作为证据。跨库消费侧须验证证据来源、绑定和有效性契约,拒绝旧导出或不可验证记录,不能绕过源侧过滤。格式按 Vela 现有跨库协议落地,不在本文臆造字段。 - 两条路径均须验收:仅旧模拟记录失败;旧新混合但有效覆盖不足失败;完整有效 realtime 证据通过;错 snapshot/placement、窗口外或缺少可信时间不计入;跨库导出/消费后结论一致。原始审计记录保持不变。
严格协议切换:Test 维护窗口与旧页面刷新
以下为待另行批准的操作方案,本文不执行。严格协议切换没有新旧兼容期;仅写“先 Vela、后 OD”不足以保护两仓发布之间的 Test 使用者。
- 维护前准备好两仓新协议、各客户端共享生命周期、两条 promotion 证据过滤及可分发构建。确认能暂停 Test 而不影响 Production;若没有现成暂停/版本识别设施,须先补齐,不能假定已有开关。
- 进入 Test 维护窗口:通知测试者关闭旧 Test 页面,暂停选择、展示和验收;服务端受控暂停 Test context/runtime/acceptance 入口及相关 promotion 证据导出/消费。仅隐藏选择面板不够,未刷新的旧页面也必须被拒绝。Production runtime 保持服务。
- 停止旧服务写入后,再由 DB owner 单独批准 migration;Vela schema、persistence、routes、active-only acceptance 和两条证据过滤作为同一发布单元切换。令旧临时 contexts 失效,不允许新旧实例同时按不同语义服务 Test;不重写 deployment、acceptance 历史或 snapshot。
- 升级并刷新客户端:Vela Web 和 OD Web 刷新至新 bundle;OD 打包客户端升级并重启至新构建,仅刷新旧版本不能代替升级。测试者重新选择 deployment,创建 realtime context,不恢复旧模拟 context。未升级/未刷新的页面继续被服务端拒绝,并有明确维护/升级/刷新提示;提示和版本识别是发布前提,不是本文声称已实现的功能。不得静默兼容旧 scenario。
- 维护期间先走受控验收:新链路 before/active/ended、离线到期、页面恢复、迟到响应、生产授权早于活动结束、同库/跨库证据过滤,以及旧页面实际被拒绝。确认每个客户端两条入口进入同一 lifecycle owner 后,再开放 Test。维护窗口结束取决于完整链路验收,不取决于某一个仓库部署完成。
失败时保持 Test 不可用;不得通过恢复模拟 active、重放旧 context 或放松历史证据过滤回滚。以上是操作交接,不构成远程部署或数据库修改授权。
边界
- 未选择 context、deployment 不存在、placement 不匹配、无 runtime 授权保持现有 404/认证失败语义;不得以虚假 active 掩盖。
before/ended不是 404:必须 HTTP 200,含完整既有 identity、snapshot hashes、placement、capabilities、staticActions、context、timing 和同形content。它们供客户端等待开始/停用;客户端/host 非 active 时禁止挂载 content、发 runtime event 或 acceptance,并必须撤下已显示触点。updatedAt是 context 选择更新时间,不替代serverTime,不能判排期。客户端本地钟只可用于刷新/展示,不决定 active。- 本轮仅修改 OpenDesign 本地客户端、契约、构建入口和相关测试/交接记录,不修改 Vela 运行时代码或远程环境。“不改生产”指不借此改变生产服务端协议、展示授权上限及既有推广门槛。历史证据过滤仍由 Vela 补齐;不重写 deployment/acceptance 审计或内容快照,不更改凭证和数据库连接。
Vela 与客户端后续验收清单
-
Admin 在不同电脑时区下配置/回显同一北京时间,保存的 UTC startsAt/endsAt 不变;Test/Production 客户端切换 Asia/Shanghai、UTC、America/Los_Angeles 后,对同一服务器 instant 的开始、结束和授权判定一致,覆盖开始包含、结束排除以及当地夏令时切换。
-
各客户端内 Test/Production 实际入口进入同一生命周期;旧平行调度/lease effects 移除;适配器仅取数、认证、选择和响应适配。
-
生产 authorizationExpiresAt、endsAt、serverTime + 5 分钟上限与 404/410/加载失败语义保留;两环境分别验证 polling、挂载、离线、恢复和迟到响应。
-
Test DTO、服务端、两仓适配器和 fixtures 同步 authorizationExpiresAt;两种数据源均验证活动仍 active 但授权先过期、断网不续期、恢复获取新授权、before 不被本地激活、缺失授权字段严格拒绝。
-
Test 面板与 acceptance 留在生命周期之外;Test fixture 不被当作 Production 共享证据。
-
同库 promotion 与跨库 getPromotionEvidence 使用同一历史验收有效性规则,混合/无效证据不能满足完整性。
-
Test 维护窗口、旧页面刷新/旧版本升级、未升级请求拒绝、失败保持 Test 不可用均已受控演练;Production 不因 Test 切换停服。
-
POST context 只接受严格 body
{deploymentId,scenario:"realtime"};四种旧值、额外simulatedAt、畸形 body 均 400。 -
201 context 无
simulatedAt,仅有 realtime scenario、deployment、认证得到的 tester、updatedAt。 -
同一 deployment 在真实 starts 前、窗口内、ends 后的 GET 都是 HTTP 200,含合法 timing 与完整 metadata/identity,并依次为 before/active/ended;不重 POST context;只有 active 且授权有效才挂载 content。
-
GET 每次由 server clock 判定;旧 context updatedAt/历史模拟时刻不能让结束窗口变 active。
-
decision 的 identity/content/manifest/artifact/snapshot hashes、placement、capabilities、staticActions 保持精确绑定 immutable snapshot。
-
acceptance 只接受 realtime,before/ended 由服务器拒绝且不落库;active 成功写入仍可走既有 promotion。
-
migration 仅在 DB owner 批准后清理临时旧 contexts,或保留 legacy 但让 runtime 拒绝它;不 mutation deployments、acceptances、历史审计或发布 snapshot hash。
-
Vela Web console/float/fixtures 无 simulated fallback;无效时间 response 不展示/不回执,non-active 200 仅等待/停用。
-
运行 Vela API scoped tests(test-context、browser runtime routes、activities、promotion/restore lifecycle)、Vela Web consumer tests,并做真实时钟本地 API smoke。
部署阻碍与交付状态
| 交付面 | 状态 | 可宣称的范围 |
|---|---|---|
| OpenDesign 当前 worktree 客户端与 contracts | 已接入共享生命周期及必填 Test 授权;本地测试与浏览器协议 fixture 验证 | 覆盖本地客户端,不代表 Vela 新协议已部署;真实联调与正式发布仍待完成。 |
| Vela runtime、persistence、migration、acceptance 与 Vela Web 消费者 | 待实现、待部署、待真实窗口验证 | 不能将本文协议、OpenDesign worktree 代码或 A-HYT 的旧模拟回执表述为 Vela 已部署或真实时间活动已上线。 |
剩余上线阻碍:Vela 客户端共享生命周期与服务端授权响应;migration、每 GET realtime、active-only acceptance、同库 promotion 与跨库 getPromotionEvidence 历史过滤;维护窗口、旧页面策略与两仓真实窗口联调。OpenDesign 本地实现不替代这些交付,也不构成部署或数据库修改授权。