Skip to content

✨ 每个浏览器排自己的派发顺序(服务端持有并应用每端执行目标顺序) - #16

Merged
CodFrm merged 7 commits into
mainfrom
feat/2026-08-14-exec-target-order-ux
Aug 14, 2026
Merged

✨ 每个浏览器排自己的派发顺序(服务端持有并应用每端执行目标顺序)#16
CodFrm merged 7 commits into
mainfrom
feat/2026-08-14-exec-target-order-ux

Conversation

@CodFrm

@CodFrm CodFrm commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

让每个浏览器能排自己这台的派发顺序,并让服务端按调用方的顺序解析派发计划。

规格在兄弟仓库:agentre/docs/specs/2026-08-14-exec-target-order-ux.md(分支 ui/2026-08-14-exec-target-order-ux)。

配套改动在 agentre-hub/agentre PR #53(桌面端把执行目标区收敛成单列表)。
两边必须一起评审:单独合任何一边,web 排序都是断的。

改了什么

  • 新表 device_exec_target_orders,键 (user_id, device_id, agent_sync_id),值是 backend sync_id 的有序数组。含一次真实迁移 202608140001,值得单独看一眼。
  • 派发档带上 backend_sync_id——原先 rank 是位置性的、device_id 不唯一(一台机器可挂多个 backend),没有任何键能表达「这一档排第几」。
  • 取派发计划时按调用方设备的排列重排执行目标链,再走既有的「跳过本机相对引用、取第一个可用」循环。挑选逻辑一行未改。
  • Overview 的 Agent 卡片每档给出上移 / 下移,提交后重新拉取该 Agent 的链。
  • 读写路径对解析失败刻意不同:读不到设备就回落账号顺序、不报错;写不到就拒绝,绝不猜一个 device_id 去写。

收尾两轴复审各修了什么

规格核验轴(4e08ddb):exec_order_repo.DeleteByDevice 有实现、有 sqlmock 测试,却没有生产调用方——device_svc.Revoke 只清了本地路径,浏览器被解除授权后它排的顺序行还留着。按已有的 LocalPathPurger 先例补了对称的 ExecTargetOrderPurger

代码评审轴(fcf0722):internal/api/workspace/ 的包注释声明了 R19 不变式(响应结构体不带项目路径 / CLIPath / EnvJSON),却没有任何东西执行它,而本轮恰好加宽了那几个结构体。补上字段白名单守卫(反射深走,新嵌套结构体也会红)。加之前做过 RED 探针:临时给 ExecTargetItemCLIPath → 4 条断言 FAIL,再还原。

交付后又处理的三条常驻发现(82dc945

  • 所有错误响应的 msg 一直是空的。 cago i18n 的 DefaultLang 是全小写 "zh-cn",而语言包注册在 "zh-CN";本服务没有设置语言的中间件,于是每一次错误构造都回落到 langs["zh-cn"]——那是 nil map,取回零值空串。影响的是所有错误码、所有客户端,不是某一个码缺文案。新增的 internal/pkg/code/code_test.go 全量遍历语言包守住这条。
  • applyDeviceOrder 把没有 backend sync_id 的档冲到队尾,而浏览器侧把它钉在原位,同一次排序两端结果不同。改为钉住原下标;「未覆盖补到队尾」仍只适用于能被指代却没被排到的档。
  • 新对话弹窗取 Agent 清单不带 device_fingerprint 却渲染 current,标出的是账号顺序的赢家而非这台浏览器真正会派到的那一档,与总览页自相矛盾。

验证

GOWORK=off make test-backend 47 包全 ok、make lint 0 issues.GOWORK=off make test-frontend 42 文件 524 测试,全部 EXIT=0。

运行时验证跑在正式产物 + 真实 MySQL/Redis(专用 e2e 库)上,并把一台隔离桌面经真实设备流配对进来,取得两端同时在线的对照。本仓库范围内 8 条要求 holds:

要求 决定性观察
迁移建出规格 DDL show create table 与规格逐字一致,含 agent_sync_idutf8mb4_0900_bin
无顺序行时回落账号顺序 卡片渲染账号顺序;不带指纹调派发计划同样回落
按调用方顺序解析 同一时刻只改指纹参数:chosenclaudecodecodexCURRENT 随之移动
排列是收敛的 提交含陈旧 id、只覆盖 1/3 档 → 200,解析出 piagent, claudecode, codex
读回落 / 写拒绝 写 → 404/30300 且表内 0 行;读 → 200 success
解除授权连带清理 两设备各一行 → 撤销 device 2 → 只剩 device 1 的行(按设备限定,非一刀切)
账号顺序不可编辑 三次 web 重排后 sync_objectssort_order 仍是 0,1,2
跨端互不影响 桌面显示 Codex/Claude/Pi,web 同时显示 piagent/claudecode/codex

已知未观察

两条 skipped_for_web 相关的子句未取得运行时证据,均有组件层 vitest 覆盖:

  • skipped_for_web 档只读不可移动——该状态要求同步载荷里有一个 agentred_fingerprint 为空的 backend,而桌面配对后会把指纹回填进去,真机路径上转瞬即逝,本次环境造不出来。
  • 提交失败时保持原顺序并就地说明——停掉 server 后验证装置按设计拒绝驱动已停机目标;只在传输层拦截请求就是伪造依赖,未采用。

另有一条未修e2e/webe2e/main.go:77-106cleanupSQL() / residueSQL() 不含 device_exec_target_orders,且它走裸 SQL 删设备、绕过 Revoke 的清理,所以 e2e 跑完会留下孤儿顺序行。产品侧没有对应缺口(生产只有软撤销,没有硬删设备的代码路径)。

CodFrm added 7 commits August 14, 2026 17:46
Agent 卡片上执行目标链的每一档给出上移 / 下移(不引入拖拽库,按钮天然可键盘
操作):一次移动即 POST /v1/workspace/exec-target-order 提交 backend sync_id
排列 + 本机指纹,随后按同一指纹重新拉取该 Agent 的链,「当前」标记当场跟着
真实派发目标改变;提交或重读失败时顺序原封不动,并在这张卡上就地说明。

skipped_for_web 的档只读、不可移动,仍留在链里:它在浏览器语境下永远没有指代
对象(决策 11)。换位只发生在可移动档之间,本机档钉在原位。

取派发计划同样带上本机指纹,服务端据此重排后再挑档,浏览器不自行挑档(R19)。
规格「设备被解除授权或删除时,它的顺序行一并清除」。exec_order_repo
的 DeleteByDevice 已经写好也有仓储单测,但没有任何生产调用方——
device_svc.Revoke 只清了上报的本机路径,浏览器排的顺序在账号里长期残留。

按 LocalPathPurger 的既有接缝补第二个窄接口 ExecTargetOrderPurger:
workspace_svc 出 PurgeDeviceExecTargetOrders(它是 exec_order_repo 的
唯一持有者),bootstrap 注入,Revoke 里与清路径同样 fail-open——清不掉
不回滚已经生效的撤销,只记一条 Warn。账号级的执行目标集合在同步组里,
不属于任何一台设备,不受影响。
internal/api/workspace 的包注释声明「每一个响应结构体都不带项目路径 /
CLIPath / EnvJSON」,但没有任何机械检查兜住它——隔壁 internal/api/follow
有 guard_test.go 锁住名单载荷,workspace 一直空着,而本轮又往
ExecTargetItem / DispatchTierItem 里加了字段。

补 guard_test.go:按类型登记字段白名单,再从四个响应根反射深走,任何新增
字段、新增嵌套结构体或命中禁词(path/cli/env/token/secret/…)的字段名都会
立刻红。DispatchChoiceItem.Cwd 作为主动派活场景下唯一的显式例外写进白名单。
反向核对不可达的登记项,白名单不会随载荷长大而悄悄过期。
(红验证:给 ExecTargetItem 临时加 CLIPath 字段,四条断言全红后还原。)

顺带把本轮新加的三条 workspace_svc 日志正文改回英文:repo 里其余 13 条
日志正文全是英文,docs/observability.md 也要求「short, stable, lowercase
description」,混入中文会让运维按同一条 grep recipe 找不全。
收尾两轴上报但当时在写边界之外的常驻发现,本次一并处理。

- 错误响应的 msg 一直是空的:cago i18n 的 DefaultLang 是全小写 "zh-cn",
  而语言包注册在 "zh-CN";本服务没有设置语言的中间件,于是每一次错误构造
  都回落到 langs["zh-cn"]——那是 nil map,取回零值空串。影响的是所有错误码、
  所有客户端,不是某一个码缺文案。中文包改为同时注册在 i18n.DefaultLang 下。
- applyDeviceOrder 把没有 backend sync_id 的档冲到队尾,而浏览器侧把它钉在
  原位,同一次排序两端结果不同。改为钉住原下标;「未覆盖补到队尾」仍只适用于
  能被指代却没被排到的档。
- 新对话弹窗取 Agent 清单不带 device_fingerprint 却渲染 current,标出的是
  账号顺序的赢家而非这台浏览器真正会派到的那一档,与总览页自相矛盾。
callerDeviceFingerprint 走的是 ensureWebDevice,而 Overview 挂载时无条件调它:
打开总览页 = POST /v1/oauth/device/register = 账号里凭空多一台 kind=web 设备。
e2e「真实 session 打开控制台并呈现隔离用户与真实空态」因此挂了 —— 它先 goto
/overview 再断言 /devices 是空的,而这一跳自己把设备建了出来;「完整设备授权」
那条是连带受害:oracle 读到前一条(含重试)留下的两台 web 设备。

顺序的持有者是设备,但只有用户真排了一次序,这台浏览器才需要有设备身份:

- 读路径(取链、取派发计划)改成纯本地读 getFingerprint(),没有就回落账号
  顺序、不发任何请求。这也正是它原本注释里写的「还没注册时是 null」——
  实现与注释此前是矛盾的。
- 注册移到写路径:saveExecTargetOrder 自己 ensureWebDevice 并返回落库用的
  指纹,调用方据此按自己的顺序重读。
- 排序控件不再按「有没有身份」隐藏,否则没注册过的浏览器永远等不到第一次排序。

读的是 localStorage 的指纹而不是 sessionStorage 的设备 JWT:关标签页丢的是
token,不是设备身份和它排的顺序。
sed 规则内联在 shell 单引号串里,里面的 ' 要写成 '"'"' 才转义得掉,而 ci.yml
里写成了 '"',少一对 "':单引号在 [:space:]" 之后就闭掉、双引号一路吞到行尾,
括号落在引号外,bash 直接 syntax error near unexpected token `)'。

这一步只在 failure() 下跑,所以自它落地起一次都没被执行到,直到第一次 e2e 真
挂掉——而那正是最需要截图和 server.log 的时候,证据却因为这行错误上传不了。

规则改为落到 heredoc 生成的 sed 脚本再 -f 引用:定界符加引号,内容原样落盘,
不再有转义这回事。已用带 DSN 口令 / redis-password / password= 的假 server.log
跑过,三处都正确替换、无关行不动。
@CodFrm
CodFrm merged commit 46f886c into main Aug 14, 2026
4 checks passed
@CodFrm
CodFrm deleted the feat/2026-08-14-exec-target-order-ux branch August 14, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant