TL;DR

把一个浏览器桌面 OS 里最复杂的子系统从主进程里拆出来跑成独立进程,听起来像"挪个目录",实际要重新回答四个问题:谁负责接 HTTP,谁负责排任务,DB 怎么分家,前端怎么跨 bundle 借能力。这篇讲我们怎么回答这四个问题,以及拆离过程踩到的三个真问题。

目录

Part 1 · video 从内置域模型迁移为独立 app

§1.1 项目定位与职责边界变化

tokimo 立项时更像媒体管理系统:视频库、刮削、转码、字幕、播放状态,是产品的主轴。随后系统逐步加入文件管理、SSH、AI、dashcam、docs、自动化、数据库会话等能力,形态从“一个媒体产品”变成“浏览器里的桌面 OS”。在这个语境里,OS 的职责不再是理解每一种内容业务,而是提供窗口、身份、任务、文件、播放、通知、事件这些可复用能力,让 app 在上面运行。

身份漂移的关键不是功能数量变多,而是“谁为谁服务”的方向反了。媒体系统里,文件系统、任务队列、播放器都是为了 video 服务;webOS 里,video 只是众多 app 之一,反过来消费 OS 提供的通用能力。只要主 server 仍然把 TMDB、剧集、字幕当作内置领域,其他 app 就只能围着 video 的历史结构让路,OS 的抽象也会不断被媒体业务拉偏。

flowchart LR
  A[媒体管理系统] --> B[文件 / SSH / AI]
  B --> C[dashcam / docs / automation]
  C --> D[浏览器桌面 OS]
  D --> E[30+ apps]
  A -.早期 core.-> V[video]
  D -.运行于 OS 之上的 app.-> V
维度 媒体管理系统语境 webOS 语境
video 的角色 核心域模型 第三方 app 能力
OS 应该知道什么 TMDB、字幕、剧集、刮削 streamUrl、窗口、任务、权限
失败影响面 产品核心失败 单 app 降级
发布节奏 跟主 server 一起发 可独立迭代

§1.2 video 的职责归属冲突

video 又早又重:它是项目最早、最复杂的子系统之一,占主仓 Rust 代码显著比例,并留下约 101 处 raw SQL。这样的复杂度在“媒体系统”里合理,因为它就是中心;但在“webOS”里会变成反向依赖:OS 需要硬编码 TMDB 刮削、NFO、HLS、字幕、人物关系、剧集季集等业务概念。更直接的问题是,任何 video 崩溃都可能污染主 server 的心智模型:故障归因无法区分 OS 故障与 app 故障。如果回答不清,抽离边界就还没建立。

尴尬还体现在 API 归属上。/api/apps/video/* 从 URL 看已经像 app,但实现却长期散落在主仓 handler、queue handler、前端 app registry、播放器上下文里。URL 先 app 化,进程却仍 monolith,这会制造一种危险错觉:调用者以为边界已经存在,维护者却仍能在主仓里随手 import video 内部类型。抽离 sidecar 后,URL 语义与运行时边界保持一致。

Before
monorepo server
├─ auth / window / vfs / jobs
├─ video: tmdb / hls / subtitle / scrape / raw sql
└─ other apps

After
main server = OS capabilities
video sidecar = business domain + own router + own workers
判断题 留在 OS 放入 video sidecar
用户身份、会话、窗口容器
TMDB lookup / NFO parse
HLS 字节流反代能力 ✅ 数据面 ✅ 业务 handler
字幕选择器 UI
Media Session / controls

§1.3 video 是最关键的一次抽离

dashcam-archive、docs 等 app 已经证明“sidecar 运行模型可用”。但 video 的价值不在首创,而在复杂度:它横跨 Rust handler、队列、DB schema、前端 bundle、播放器扩展、VFS、ffmpeg 路径和崩溃 UX。它触及的是之前简单 app 抽离尚未验证的边界:父子 job、流式数据面、Player 扩展、跨 schema 访问、大量 raw SQL 和长时间后台任务。

关键决策不是“为什么优先拆 video”,而是“为什么不绕过 video,继续抽别的简单 app”。答案是:只要 video 还留在主进程,OS 的 jobs/player/schema 抽象就会继续被媒体业务拉偏。抽完 video,sidecar 平台才不只是能承载轻量 app,而是能承载最复杂的 OS 子系统级业务。

quadrantChart
  title sidecar 抽离的复杂度与平台定型价值
  x-axis 简单 --> 复杂
  y-axis 局部收益 --> 平台契约定型
  quadrant-1 已抽离:常规 app
  quadrant-2 关键抽离:平台基准
  quadrant-3 低优先级
  quadrant-4 复杂但收益不足
  dashcam: [0.42, 0.58]
  docs: [0.32, 0.52]
  music: [0.58, 0.68]
  video: [0.9, 0.95]
方案 优点 缺点 决策
继续抽简单 app 风险低,流程熟 避开 jobs/player/schema 真边界 不选
抽离 video 一次验证最复杂承载能力 迁移面最大 选择
不抽离,保留 feature flag 短期稳定 OS 与媒体业务继续耦合 不选

Part 2 · 设计约束

拆离不是“移动目录”,而是重新划定 OS 与 app 的职责。约束必须先固定,否则后续局部优化会破坏职责边界。这里的约束分两类:硬约束决定系统不变量,不做的事限制迁移范围。前者防止抽离后退化成“另一个进程里的 monolith”,后者防止一次迁移同时引入数据库拆分、协议迁移和 URL 破坏,导致风险无法归因。

因此,抽离策略刻意保持外部形状稳定:URL 不变、DB 实例不变、播放器入口不变;只改变内部运行时归属。这样验收可以集中在进程隔离、协议边界、ownership 和 UX 上,而不是把所有变量混在一起。

flowchart TD
  C[设计约束] --> A[崩溃隔离]
  C --> B[零 API 破坏]
  C --> D[双轨过渡]
  C --> E[身份字段不污染业务字段]
  C --> F[崩溃 UX 明确]
  C --> N1[不拆 DB]
  C --> N2[不上 gRPC]
  C --> N3[不改外部 URL]
类型 约束 含义
硬约束 崩溃隔离 sidecar 死亡不能拖垮主 server、桌面 shell、其他 app
硬约束 零 API 破坏 浏览器外部仍访问 /api/apps/video/*
硬约束 双轨过渡 video-builtin feature 可作为回退,默认走 sidecar
硬约束 身份字段不污染业务字段 user_id/app_id 属于调用上下文,不属于业务 payload
硬约束 崩溃 UX 明确 502 表示上游不可达,504 表示上游超时
不做 不拆 DB 先用 Postgres multi-schema,不引入独立数据库
不做 不上 gRPC 避免工具链和 schema 迁移成本
不做 不改外部 URL 前端和外部调用者不感知进程位置

Part 3 · 四条核心架构边界

§3.1 Bus 通信架构

Bus 是这次抽离的进程边界,但它不是所有 /api/apps/video/* 请求的唯一入口。目标架构里有三条路径,必须分开理解:第一,浏览器访问 /api/apps/video/* 时,main server 的 wildcard 路由直接走 data-plane reverse proxy,把请求转发到 video sidecar 自己的 Axum router;这是绝大多数 video REST API、HLS、图片代理、字幕等浏览器路径。第二,main server 的 queue / handler 在需要把任务派给 video 时,走 bus control plane 调用 sidecar 暴露的 dispatch_* 方法。第三,sidecar 需要 OS 能力时,反向调用 bus local services,例如 jobsvfsplayback_state

这个拆分避免把视频流、Range 请求、大文件 body 塞进控制帧,也避免把内部 RPC 暴露成浏览器通用入口。控制面适合“命令”:job dispatch、能力查询、状态写入;数据面适合“搬运字节”:HLS 切片、Range 请求、图片代理、字幕文件。把两者混在一个 RPC 层里,结果要么 RPC 变成半个 HTTP server,要么视频流量使 broker 成为性能瓶颈。

BusClient 长连接复用,sidecar 启动时注册 service、版本和 data-plane socket。dispatch_app_call + MethodDecl 主要服务 bus local services 的 typed route 和内部控制面能力;浏览器 REST API 主路径则由 data-plane wildcard 交给 sidecar router 自己处理。错误码仍然是边界语言:连接失败、握手失败、连接关闭映射为 502;上游响应超时映射为 504;sidecar 内部业务错误保持自己的响应。

错误码区分直接决定故障定位语义。502 表示“OS 找不到或连不上 app”,处理方向是拉起、重启、检查注册表;504 表示“app 存在但没有及时响应”,处理方向是超时、负载、死锁、慢查询。若都返回 500,Shell 和用户只能看到“服务器错误”,进程隔离的可观测性就丢了。

flowchart TD
  Browser[Browser /api/apps/video/*] --> DP[main server data-plane wildcard]
  DP --> Router[video sidecar Axum router]
  Router --> VideoDB[(video schema)]

  Queue[main server queue / handler] --> CP[bus control plane<br/>rmp-serde + UDS/TCP]
  CP --> Dispatch[sidecar dispatch_* methods]

  Sidecar[video sidecar services] --> Local[bus local services]
  Local --> Jobs[OS jobs]
  Local --> Vfs[OS vfs]
  Local --> Playback[OS playback_state]
协议 协议成本 类型化 跨进程 工具链
tokimo bus 中:自有 broker + DTO 强,Rust/TS 可生成 原生支持 UDS/TCP 项目内可控
gRPC 高:proto、生成、gateway 原生支持 引入新栈
HTTP REST 中,靠约定 支持 简单但易漂移
直接 DB “跨进程”但无协议 破坏边界
let stream = BusStream::connect(socket)
    .await
    .map_err(|e| AppError::BadGateway(format!("app sidecar unreachable: {e}")))?;
let upstream_resp = match timeout(Duration::from_secs(30), sender.send_request(upstream_req)).await {
    Ok(Ok(resp)) => resp,
    Ok(Err(e)) => return Err(AppError::BadGateway(format!("app sidecar request failed: {e}"))),
    Err(_) => return Err(AppError::GatewayTimeout("app sidecar response timeout after 30s".into())),
};

除可达性与超时语义外,bus 还必须承载跨进程调用身份。单进程时代可以依赖请求上下文、thread-local 或 handler 参数一路传递 CallerCtx。跨进程后,身份必须显式进入协议。这里有三种选择:让业务 payload 自带身份、放到 bus 协议头、或者做 TLS/证书级鉴权。最终选择协议头,因为它既能防止业务字段伪造,又不会让每个 DTO 都多出身份字段。

协议头还有一个组织收益:所有 local service 都可以用同一种 caller 结构做鉴权,jobs、vfs、task_queue、media_tools 不需要各自发明“当前 app 是谁”的字段。只要 broker 负责盖章,service 负责校验,业务代码就不必在每个请求里重复解析身份。

flowchart LR
  HTTP[session cookie] --> Bridge[HTTP bridge]
  Bridge --> Ctx[CallerCtx\nuser_id + caller_app_id]
  Ctx --> Header[bus protocol header]
  Header --> Sidecar[sidecar method]
  Sidecar -.业务 payload 不带身份.-> Payload[domain payload]
方案 实现复杂度 防 spoof 业务侵入 性能
payload 自带 userId/appId 弱,易伪造
bus 协议头 强,broker 注入
TLS / mTLS

协议头方案带来一个副作用:appId/userId 成为保留字段。若业务 payload 也有 appId,系统必须决定怎么处理。选择不是“静默覆盖”,而是 fail-fast:一旦业务 payload 里的保留字段与 caller 注入值不一致,立即报错。

fn ensure_reserved_field_matches(value: Option<&JsonValue>, field: &str, expected: &str) -> Result<(), BusError> {
    let Some(value) = value else { return Ok(()); };
    if value.as_str() == Some(expected) { return Ok(()); }
    Err(BusError::BadRequest(format!(
        "payload contains reserved field '{field}' that conflicts with caller-injected value '{expected}'"
    )))
}
反直觉决策 看起来的好处 实际问题 结论
静默覆盖 + warning 兼容旧 payload 业务字段被改写后仍可能显示成功 不选
自动 prefix 业务字段 减少报错 协议层猜业务语义 不选
fail-fast 拒绝 迁移期更吵 在 dev/CI 暴露约定冲突 选择

让违反约定的请求直接失败,比让迁移者改字段名更可靠。字段命名是局部成本,身份污染是平台级成本。

§3.2 Job 系统架构

job 是 OS 能力,不是 video 私有表。原因是:持久化队列、状态机、retry、取消、父子任务、WebSocket 事件,都应该被多个 app 共享。video 定义 library_scanitem_scrapetmdb_lookup 这样的业务 job 类型,但不能绕过 OS 直接写 public.jobs。因此 sidecar 只能通过 bus 调用 jobs.create / update_status / batch_children / query / cancel

这个边界也避免了“表共享即接口共享”的误解。public.jobs 的列名、索引、状态枚举、重试策略都可能随 OS 演进;sidecar 真正需要的是“创建一个属于当前用户和当前 app 的任务”“更新它的状态”“查询自己的任务”。bus endpoint 把需求压缩成窄接口,repo 细节继续留在主 server。

Ownership 模型把 jobs 表的"谁拥有"收敛到当前用户和当前 app。sidecar 创建 job 时,bus service 从 caller 派生 owner = (appId, userId)。query、cancel、update_status、batch_children 各自在 bus service / repo 层按 owner 过滤或校验,业务 payload 不需要自带身份字段。这样 jobs 边界保持单一职责:sidecar 定义业务 job 类型,OS 保留队列能力与 ownership 判定。

sequenceDiagram
  participant UI as video UI
  participant V as video sidecar
  participant B as bus jobs service
  participant J as public.jobs
  UI->>V: start library_scan
  V->>B: jobs.create(type=library_scan)
  B->>J: insert owner = current user + video
  V->>B: jobs.batch_children(item_scrape[])
  B->>J: insert children(parent_job_id)
  V->>B: jobs.update_status(tmdb_lookup)
  UI->>B: jobs.query(type/status)
  B->>J: filter by caller user + app
endpoint 关键校验 防止的问题 数据路径
jobs.create 从 caller 派生 owner 伪造 owner bus → repo insert
jobs.update_status 当前 job 属于 caller 修改他人 job bus → repo update
jobs.query SQL 层 owner 过滤 + 分页 全表扫 / N+1 bus → list_jobs_for_owner
jobs.cancel parent job owner 匹配 取消他人任务 bus → cancel + children
jobs.batch_children parent owner 匹配,children 继承 owner 借父任务混入子任务 bus → batch insert
fn model_owned_by(model: &jobs::Model, user_id: Uuid, app_id: &str) -> bool {
    model.user_id == Some(user_id)
        && model.payload.get("appId").and_then(JsonValue::as_str) == Some(app_id)
}

let result = JobRepo::list_jobs_for_owner(
    &state.db, user_id, &app_id, req.job_type.as_deref(), req.status.as_deref(), &PageInput { page, page_size },
).await?;

父子 job 是 video 抽离里最能暴露边界的场景:library_scan 只是根任务,真正的数据变化发生在大量 item_scrapetmdb_person_scrapeimage_upload 子任务中。若 sidecar 直写 public.jobs,性能可能少一跳,但 OS 将失去 ownership、retry、取消和 WS 统一语义。选择 bus 是为了让“任务系统”继续属于 OS。

§3.3 Player 能力注册架构

播放器最容易被误判成“video 私有能力”。实际需要拆成两层:Shell 保留跨 app 能力,video 注册业务扩展。Shell 层负责 streamUrl、controls、窗口容器、Media Session、当前播放项快照;video bundle 只负责 episode menu、library nav、字幕选择器、下一集推荐等业务 UI。

这条边界直接决定后续 app 能不能复用播放器。music 需要队列和歌词,dashcam 需要时间轴和事件标记,docs 也可能需要嵌入式媒体预览。如果 Player 被 video 私有化,其他 app 会复制一套播放器;如果 Player 完全留在 OS,OS 又会硬编码 video 的剧集概念。注册式扩展让 Shell 拥有“播放能力”,app 拥有“播放语义”。

OS Shell layer
├─ PlayerProvider / Media Session / controls
├─ Window content container
└─ PlayerExtensionRegistry
   ├─ global extensions
   ├─ video namespace: episode picker / next item / taskbar actions
   ├─ music namespace: queue / lyrics
   └─ dashcam namespace: timeline / event markers
能力 OS Shell video bundle 说明
<video> 控制与 Media Session 跨 app 共享
streamUrl 解析入口 ✅ 提供业务源 Shell 调扩展
剧集菜单 video 专属
字幕选择器 video 专属
WindowPortal / container ✅ 使用 容器归 OS,内容归 app

PlayerExtensionRegistry 的核心是 register/unregister/list,并用 app_id namespace 防止多个 app 互相污染。这里有一个 SDK contract 硬约束:所有跨 app 播放源必须在 sourceMetadata 中携带 appId,缺失会退化为对所有 app namespace 的全量 extension 广播,必须视为 SDK contract violation 而非"兼容路径"。bundle unmount 时必须调用 unregister,否则扩展函数会在 app 不存在时继续被播放器调用,形成隐性状态泄漏。

registerExtension = (...args: RegisterArgs): (() => void) => {
  const [appId, ext] = typeof args[0] === "string" ? [args[0], args[1]] : [null, args[0]];
  let appExtensions = this.extensionsByAppId.get(appId ?? "");
  if (!appExtensions) this.extensionsByAppId.set(appId ?? "", (appExtensions = new Set()));
  appExtensions.add(ext);
  return () => {
    appExtensions.delete(ext);
    if (appExtensions.size === 0) this.extensionsByAppId.delete(appId ?? "");
  };
};

在 namespace 与 unregister 已能表达扩展生命周期后,问题就不是引入 Module Federation。tokimo 已经有 manifest 注册、bundle 加载、WindowPortal、SDK shim,真正缺的是运行时能力注册与卸载生命周期,不是"如何加载远程 bundle"。Module Federation 解决的是运行时 remote 模块加载与 shared singleton 依赖协商,与 Player Extension 的 mount/register/unregister 生命周期正交;引入它还会增加 host/remote 版本耦合,包括 webpack runtime、shared singletons 和版本协商。

方案 解决的问题 生命周期能力 版本耦合 决策
注册式 API 运行时能力挂载 强:register/unregister 显式 低:只依赖 SDK contract 选择
直接 import 复用 host 内部代码 弱:host 特判 app 高:路径即 API 不选
Module Federation 运行时 remote 加载 + shared singleton 协商 正交:仍需自建注册生命周期 高:runtime + shared singleton 协商 不选

video bundle 不只是页面,它需要 host 能力:Player、WindowPortal、i18n、Bridge、theme、message、typed API client。若直接 import 主仓内部模块,sidecar UI 就会变成“搬出去的源码目录”。选择注册式 API + namespace:host 暴露能力,bundle 在 mount 时注册扩展,在 unmount 时注销。

UI 边界比后端边界更容易被忽略,因为 TypeScript import 的耦合成本容易被低估。但一旦 remote bundle 直接 import host 内部路径,host 的文件结构就成了 public API,重构成本会比 HTTP API 更高。SDK contract 的价值,是把“可以依赖什么”写成显式对象,而不是让 bundler 解析路径来决定边界。

sequenceDiagram
  participant Host as OS Shell
  participant Loader as loadBundle
  participant Video as video bundle
  participant Reg as PlayerExtensionRegistry
  Host->>Loader: load tokimo-app-video bundle
  Loader->>Video: mount({ shell, i18n, bridge })
  Video->>Reg: register("video", extension)
  Host->>Reg: getExtensions(appId from sourceMetadata)
  Video-->>Reg: unregister on unmount

这里的关键不是“保证 video bundle 可运行”,而是让多个 bundle 能同时扩展 OS。注册式 API 把能力契约固定在 SDK 层,避免一个 app 的 UI 结构反向塑造 host。

§3.4 数据库边界:从隐式漂移到 multiSchema

通信、任务和播放器边界固定后,剩下的核心问题是数据归属。DB 没有被拆成独立实例,而是用 Postgres multi-schema:video 私有表进入 video.*,OS 公共表留在 public.*。sidecar 直接访问 public.jobspublic.vfs 可减少一次调用链路,但会把 schema 变成隐式 API,任何主仓表结构变化都会破坏 sidecar。最终边界是:sidecar 可以直读写自己的 video.*;跨到 public.* 必须走 bus。

不拆 DB 是务实选择:一次性引入独立数据库会让迁移、事务、备份、开发环境和权限模型同时变化。multi-schema 则把“数据归属”先表达出来,同时保留同一个 Postgres 运维面。终态依赖显式边界:video 表在 entity 上标注 #[sea_orm(schema_name = "video")],raw SQL 使用明确 schema 前缀;跨到 OS 公共能力时只走 bus。过渡期保护栏可以存在,但不能成为设计本身。

flowchart TD
  subgraph Sidecar[video sidecar]
    VCode[handlers / services / workers]
  end
  subgraph DB[Postgres]
    VS[(video.*)]
    PS[(public.jobs / public.vfs / users)]
  end
  subgraph OS[main server]
    Bus[bus local services]
  end
  VCode -->|合法:私有业务表| VS
  VCode -->|非法:直连公共表| PS
  VCode -->|合法:跨边界| Bus --> PS
表 / 能力 sidecar 可直读写 必须走 bus 原因
video.videos / video.video_items 业务私有表
video.subtitles / video.playback_sessions video 域模型
public.jobs OS 队列与状态机
public.vfs driver config 含凭据和可见性策略
public.users 身份归 OS

Part 4 · 抽离后的目标架构与可观测收益

bus、jobs、player 与 schema 四条边界收敛后,目标形态可以落到进程、仓库和运行时三层结构。

tokimo/
├─ packages/rust-server/          # OS server: bus, jobs, vfs, shell API
├─ packages/web/                  # desktop shell + PlayerProvider
├─ packages/ui/                   # shared UI primitives
└─ apps/
   └─ tokimo-app-video/           # independent sidecar app
      ├─ src/main.rs              # clap + AppCtx + bus registration
      ├─ src/handlers/            # browse / hls / subtitle / sync
      ├─ src/bus_clients/         # jobs / vfs / playback_state
      ├─ src/db/entities/         # video schema entities
      └─ ui/src/                  # bundle UI + player extension
flowchart LR
  U[用户打开 video 库] --> Shell[Desktop Shell]
  Shell --> Bundle[loadBundle tokimo-app-video]
  Bundle --> API[fetch /api/apps/video/*]
  API --> DP[main server data-plane reverse proxy]
  DP --> Sidecar[video sidecar Axum router]
  Sidecar --> DB[(video.*)]
  Sidecar --> Bus[bus local services for public.*]
指标 抽离前 抽离后 结果
主 server 启动 编译/初始化 video 模块 默认 video-builtin OFF 启动路径变轻
编译影响面 video 改动牵动主仓 sidecar 独立 binary/UI 影响面收缩
崩溃影响 容易被误判为 OS 问题 502/504 限定到 app 隔离更清楚
发布节奏 跟主仓一起 submodule + sidecar 可独立演进

sidecar binary 是 clap CLI + AppCtx 的组合:无命令且存在 TOKIMO_BUS_SOCKET 时进入服务模式,初始化 DB pool,启动 app Axum server,注册 bus service 和 data-plane socket;显式命令则可执行 versionstandalone 等开发入口。

这种入口设计让同一个 binary 同时满足生产和开发:生产由主 server supervisor 拉起并注入 bus 环境;开发可以用 standalone HTTP server 单独调试 handler;CLI 子命令则用于低成本健康检查和运维动作。sidecar 不需要知道自己被哪个窗口打开,只需要在 bus 上声明“当前服务是 video、可用 method 列表以及数据面 socket 位置”。

let db = db::init_pool().await?;
let ctx = Arc::new(AppCtx::new(db, Arc::clone(&client_slot)).await?);
let app_socket = app_server::spawn("video", Arc::clone(&ctx)).await?;
let client = bus_services::video_jobs::register(
    BusClient::builder(cfg).service("video", env!("CARGO_PKG_VERSION")).data_plane(app_socket),
    Arc::clone(&ctx),
).build().await?;
故障场景 sidecar 死 拉起失败 假死 bundle 404 / runtime throw
主 server 仍健康 skip 单 app 504 仍健康
桌面 shell 单窗错误 单 app 缺失 窗口卡住/超时 fallback 占位
其他 app 不受影响 不受影响 不受影响 不受影响
video 自己 502 manifest 找不到 504 加载失败
用户观察 app 不可用 “Library not found” 类提示 等待后超时 “Failed to load”

Part 5 · 验收方法论

这类拆分的验收不能仅以页面可访问作为验收标准。更有效的方法是把系统按风险切成 workstream:后端 bus、性能热路径、前端 parity、Shell SDK contract、submodule 完整性、崩溃隔离,再加主 agent 自查。每条线独立找 P0/P1/P2,再用 rubber-duck 复审找报告盲区。再用威胁模型倒推优先级:若 sidecar 都是可信内置 app,一些 bus 能力可按 P1 处理;若允许第三方 sidecar,同样的问题会升级为 P0。

这种验收方式的核心是避免“局部通过”。例如前端 parity 通过,不代表 bus ownership 安全;HLS 流畅,不代表 job 分页正确;sidecar 能启动,不代表 bundle runtime throw 时有可理解 UX。把问题按威胁模型重新分级,才能知道哪些缺陷只是低风险质量问题,哪些会破坏 sidecar 平台本身。

mindmap
  root((验收))
    WS1 后端 bus
    WS2 性能热路径
    WS3 前端 parity
    WS4 Shell SDK contract
    WS5 submodule 终态
    WS6 崩溃隔离
    WS7 主 agent grep
    rubber-duck 复审
    threat model -> P 级
workstream 关注点 典型输出
后端 bus auth、ownership、协议保留字段 P0/P1 列表
性能热路径 HLS、Range、batch children 是否走数据面
前端 parity 残留 import、URL、fallback 删除/迁移清单
SDK contract Player、WindowPortal、i18n API 边界
崩溃隔离 502/504、单窗错误 隔离矩阵

项目开源地址:tokimo-lab