Add host column and recovery improvements for all-host automations

- Flatten automations list to show all hosts in a single table with Host column
- Introduce host recovery button component for seamless reconnection
- Support runtime storage authority for workspace provenance
- Properly fence legacy Self entries while keeping SSH entries view-only
- Add priority hold deadline to external manager probe scheduler
- Improve automation observation to mark unresolvable runs as unverifiable
This commit is contained in:
Jinjing
2026-08-23 15:46:17 -07:00
parent d3ceea9c17
commit ae8206cc0a
105 changed files with 3476 additions and 1009 deletions
+342
View File
@@ -0,0 +1,342 @@
- generic [ref=e1]:
- generic [ref=e3]:
- generic [ref=e6]:
- generic [ref=e7]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic "Orca" [ref=e11] [cursor=pointer]
- button "切换侧边栏" [ref=e13] [cursor=pointer]
- generic [ref=e16]:
- button "返回" [ref=e17] [cursor=pointer]
- button "前进" [disabled] [ref=e20]
- generic [ref=e24]:
- generic [ref=e25]:
- button "任务" [ref=e26] [cursor=pointer]
- button "自动化" [ref=e29] [cursor=pointer]
- button "Orca 手机端" [ref=e36] [cursor=pointer]
- button "搜索工作树和浏览器选项卡" [ref=e40] [cursor=pointer]:
- generic [ref=e41]: 搜索
- generic [ref=e42]:
- generic [ref=e43]: Projects
- generic [ref=e45]:
- button "工作区选项" [ref=e46] [cursor=pointer]
- button "Add Project" [ref=e47] [cursor=pointer]
- button "New workspace" [ref=e48] [cursor=pointer]
- listbox "工作树" [ref=e50]:
- button "personal personal 的分组操作" [expanded] [ref=e51] [cursor=pointer]:
- generic [ref=e52]: personal
- generic:
- button "personal 的分组操作"
- button "AutoGenie AutoGenie 的项目操作 为 AutoGenie 创建新工作树" [expanded] [ref=e62] [cursor=pointer]:
- generic [ref=e63]: AutoGenie
- generic:
- button "AutoGenie 的项目操作"
- button "为 AutoGenie 创建新工作树"
- option "分支 · Unread 未读: main 主要 工作区元数据 Local Mac" [ref=e71]:
- generic [ref=e72]:
- generic [ref=e75] [cursor=pointer]:
- generic [ref=e76]: 分支 · Unread
- generic [ref=e84]:
- generic [ref=e86]:
- generic [ref=e87]:
- generic [ref=e88]: 未读:
- text: main
- generic [ref=e89]: 主要
- generic "工作区元数据" [ref=e92]: 工作区笔记
- generic [ref=e98]: Local Mac
- button
- button "demo-generation demo-generation 的项目操作 为 demo-generation 创建新工作树" [expanded] [ref=e102]:
- generic [ref=e103]: demo-generation
- generic:
- button "demo-generation 的项目操作"
- button "为 demo-generation 创建新工作树"
- option "分支 main 主要 Local Mac" [ref=e111]:
- generic [ref=e112]:
- generic [ref=e115] [cursor=pointer]:
- generic [ref=e116]: 分支
- generic [ref=e123]:
- generic [ref=e125]:
- generic [ref=e126]: main
- generic [ref=e127]: 主要
- generic [ref=e128]: Local Mac
- button
- option "分支 · Unread 未读: demo Local Mac" [ref=e132]:
- generic [ref=e133]:
- generic [ref=e136] [cursor=pointer]:
- generic [ref=e137]: 分支 · Unread
- generic [ref=e145]:
- generic [ref=e148]:
- generic [ref=e149]: 未读:
- text: demo
- generic [ref=e150]: Local Mac
- button
- button "jinjing-work jinjing-work 的项目操作 为 jinjing-work 创建新工作树" [expanded] [ref=e154]:
- generic [ref=e155]: jinjing-work
- generic:
- button "jinjing-work 的项目操作"
- button "为 jinjing-work 创建新工作树"
- option "分支 main 主要 Local Mac" [ref=e163]:
- generic [ref=e164]:
- generic [ref=e167] [cursor=pointer]:
- generic [ref=e168]: 分支
- generic [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]: main
- generic [ref=e179]: 主要
- generic [ref=e180]: Local Mac
- button
- option "分支 improve-agent-session Local Mac" [ref=e184]:
- generic [ref=e185]:
- generic [ref=e188] [cursor=pointer]:
- generic [ref=e189]: 分支
- generic [ref=e196]:
- generic [ref=e197]: improve-agent-session
- generic [ref=e200]: Local Mac
- button
- button "orca orca 的项目操作 为 orca 创建新工作树" [expanded] [ref=e204]:
- generic [ref=e205]: orca
- generic:
- button "orca 的项目操作"
- button "为 orca 创建新工作树"
- option "Inactive orca 主要 4 直播 ports Local Mac Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=e211]:
- generic [ref=e212]:
- generic [ref=e215] [cursor=pointer]:
- generic [ref=e216]: Inactive
- generic [ref=e221]:
- generic [ref=e223]:
- generic [ref=e224]: orca
- generic [ref=e225]: 主要
- button "4 直播 ports" [ref=e228]
- generic [ref=e232]:
- generic [ref=e233]: Local Mac
- generic "Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=e235]: Detached HEAD @ 4fd93ea
- button
- option "Inactive Connect to SSH host openclaw orca 主要 openclaw" [ref=e237]:
- generic [ref=e238]:
- generic [ref=e241] [cursor=pointer]:
- generic [ref=e242]: Inactive
- generic [ref=e247]:
- generic [ref=e249]:
- button "Connect to SSH host openclaw" [ref=e250]
- generic [ref=e251]: orca
- generic [ref=e252]: 主要
- generic [ref=e253]: openclaw
- button
- option "分支 auto-daily-prod-release-scan-run-19-20260822T1740 Local Mac" [ref=e257]:
- generic [ref=e258]:
- generic [ref=e261] [cursor=pointer]:
- generic [ref=e262]: 分支
- generic [ref=e269]:
- generic [ref=e270]: auto-daily-prod-release-scan-run-19-20260822T1740
- generic [ref=e273]: Local Mac
- button
- 'option "PR: Merged fix(automations): localize schedule weekday names and labels Local Mac" [ref=e277]':
- generic [ref=e278]:
- generic [ref=e281] [cursor=pointer]:
- generic [ref=e282]: "PR: Merged"
- generic [ref=e289]:
- generic [ref=e290]: "fix(automations): localize schedule weekday names and labels"
- generic [ref=e293]: Local Mac
- button
- option "分支 fix-rebase-race Local Mac" [ref=e297]:
- generic [ref=e298]:
- generic [ref=e301] [cursor=pointer]:
- generic [ref=e302]: 分支
- generic [ref=e309]:
- generic [ref=e310]: fix-rebase-race
- generic [ref=e313]: Local Mac
- button
- option "分支 allow-create-automations-with-ai Local Mac" [ref=e317]:
- generic [ref=e318]:
- generic [ref=e321] [cursor=pointer]:
- generic [ref=e322]: 分支
- generic [ref=e329]:
- generic [ref=e330]: allow-create-automations-with-ai
- generic [ref=e333]: Local Mac
- button
- option "分支 sta-4846-reconnect 8 直播 ports Local Mac" [ref=e672]:
- generic [ref=e338]:
- generic [ref=e341] [cursor=pointer]:
- generic [ref=e342]: 分支
- generic [ref=e349]:
- generic [ref=e351]:
- generic [ref=e352]: sta-4846-reconnect
- button "8 直播 ports" [ref=e675]
- generic [ref=e353]: Local Mac
- button
- option "分支 sta-4907-review Local Mac" [ref=e357]:
- generic [ref=e358]:
- generic [ref=e361] [cursor=pointer]:
- generic [ref=e362]: 分支
- generic [ref=e369]:
- generic [ref=e370]: sta-4907-review
- generic [ref=e373]: Local Mac
- button
- option "分支 sta-4998-quick-open Local Mac" [ref=e377]:
- generic [ref=e378]:
- generic [ref=e381] [cursor=pointer]:
- generic [ref=e382]: 分支
- generic [ref=e389]:
- generic [ref=e390]: sta-4998-quick-open
- generic [ref=e393]: Local Mac
- button
- option "分支 sta-4999-ssh-scrollback 2 直播 ports Local Mac" [ref=e397]:
- generic [ref=e398]:
- generic [ref=e401] [cursor=pointer]:
- generic [ref=e402]: 分支
- generic [ref=e409]:
- generic [ref=e411]:
- generic [ref=e412]: sta-4999-ssh-scrollback
- button "2 直播 ports" [ref=e415]
- generic [ref=e418]: Local Mac
- button
- option "分支 sta-5000-add-project Local Mac" [ref=e422]:
- generic [ref=e423]:
- generic [ref=e426] [cursor=pointer]:
- generic [ref=e427]: 分支
- generic [ref=e434]:
- generic [ref=e435]: sta-5000-add-project
- generic [ref=e438]: Local Mac
- button
- option "分支 sta-5001-review Local Mac" [ref=e442]:
- generic [ref=e443]:
- generic [ref=e446] [cursor=pointer]:
- generic [ref=e447]: 分支
- generic [ref=e454]:
- generic [ref=e455]: sta-5001-review
- generic [ref=e458]: Local Mac
- button
- option "分支 sta-5097-5098-cold-park Local Mac" [ref=e462]:
- generic [ref=e463]:
- generic [ref=e466] [cursor=pointer]:
- generic [ref=e467]: 分支
- generic [ref=e474]:
- generic [ref=e475]: sta-5097-5098-cold-park
- generic [ref=e478]: Local Mac
- button
- option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 Local Mac" [ref=e482]:
- generic [ref=e483]:
- generic [ref=e486] [cursor=pointer]:
- generic [ref=e487]: 分支
- generic [ref=e494]:
- generic [ref=e495]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500
- generic [ref=e498]: Local Mac
- button
- option "分支 auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 Local Mac" [ref=e502]:
- generic [ref=e503]:
- generic [ref=e506] [cursor=pointer]:
- generic [ref=e507]: 分支
- generic [ref=e514]:
- generic [ref=e515]: auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700
- generic [ref=e518]: Local Mac
- button
- option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 Local Mac" [ref=e522]:
- generic [ref=e523]:
- generic [ref=e526] [cursor=pointer]:
- generic [ref=e527]: 分支
- generic [ref=e534]:
- generic [ref=e535]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300
- generic [ref=e538]: Local Mac
- button
- generic [ref=e544]:
- generic [ref=e546]:
- button "设置" [ref=e547] [cursor=pointer]
- button "帮助" [ref=e548] [cursor=pointer]
- generic [ref=e549]:
- button "显示活动工作区" [ref=e550] [cursor=pointer]
- button "工作区板" [ref=e551] [cursor=pointer]
- generic [ref=e555]:
- generic [ref=e556]:
- text: ···
- main [ref=e557]:
- heading "自动化" [level=1] [ref=e559]
- generic [ref=e560]:
- generic [ref=e561]:
- generic [ref=e562]:
- textbox "搜索自动化" [ref=e564]:
- /placeholder: 搜索...
- combobox "Filter by host" [ref=e566] [cursor=pointer]:
- generic:
- generic:
- generic "Linux"
- generic:
- generic "Unreachable. This host could not be reached, so its automations could not be loaded.": Unreachable
- generic "Not connected. Automations on this host cannot run until the connection is restored.": Not connected
- button "Refresh automations" [ref=e567] [cursor=pointer]
- button "新建自动化" [ref=e568] [cursor=pointer]
- status [ref=e569]
- generic [ref=e570]:
- generic [ref=e571]: Linux could not be reached. Showing the automations last loaded from it.
- generic [ref=e572]:
- generic "Unreachable. This host could not be reached, so its automations could not be loaded." [ref=e573]: Unreachable
- generic "Not connected. Automations on this host cannot run until the connection is restored." [ref=e575]: Not connected
- button "Reconnect" [ref=e577] [cursor=pointer]
- status [ref=e578]: 1 of 3 hosts could not be loaded
- paragraph [ref=e579]: External automation managers are not listed for this host in this release.
- generic [ref=e580]:
- generic [ref=e581]:
- generic [ref=e582]: 名称
- generic [ref=e583]: 日程
- generic [ref=e584]: 项目
- generic [ref=e585]: Host
- generic [ref=e586]: 下次运行
- generic [ref=e587]: Last run
- generic [ref=e588]: 状态
- generic [ref=e589]: 智能体
- generic [ref=e590]: 操作
- button "工作日仓库审计 Weekdays at 9:00 AM orca-internal openclaw Aug 24, 9:00 AM (in 2d) Never 启用 Codex · openclaw · 用量不可用 自动化操作" [ref=e592] [cursor=pointer]:
- generic [ref=e593]: 工作日仓库审计
- generic "Weekdays at 9:00 AM" [ref=e594]
- generic "orca-internal" [ref=e595]
- generic "openclaw" [ref=e596]
- generic "Aug 24, 9:00 AM (in 2d)" [ref=e597]
- generic "Never" [ref=e598]
- generic [ref=e600]: 启用
- generic "Codex · openclaw · 用量不可用" [ref=e602]
- button "自动化操作" [ref=e605]
- button "显示浮动工作区" [ref=e607]
- button
- generic [ref=e608]:
- generic [ref=e609]:
- button "使用量" [ref=e611] [cursor=pointer]:
- generic [ref=e612]: 受限
- generic [ref=e618]:
- generic [ref=e623]: 已用 0% 4h 59m
- generic [ref=e624]: ·
- generic [ref=e625]: 已用 9% 4d 9h
- generic [ref=e626]: 已用 31% 5d 17h
- button "刷新速率限制" [ref=e631] [cursor=pointer]
- generic [ref=e637]:
- button "Caffeinate, Agent · Inactive" [ref=e638] [cursor=pointer]:
- generic [ref=e641]: Agent
- button "宠物菜单" [ref=e643] [cursor=pointer]:
- generic [ref=e644]: Mini Sama hidden
- button "资源管理器,22 个终端会话" [ref=e645] [cursor=pointer]:
- generic [ref=e648]: 1.43 GB
- generic [ref=e649]: ·
- generic [ref=e652]: "22"
- button "端口,109 工作区 ports" [ref=e678] [cursor=pointer]:
- generic [ref=e656]: "109"
- button "远程主机连接状态" [ref=e657] [cursor=pointer]:
- generic [ref=e658]: 0 个主机
- button
- region "Notifications alt+T"
- generic [ref=e668] [cursor=pointer]:
- generic:
- generic:
- generic:
- generic:
- button "Select element"
- generic:
- generic:
- button "Open comments":
- generic:
- generic:
- generic:
- generic:
- generic: "0"
- generic: "0"
- generic:
- generic:
- button "Copy all comments"
- button "Expand toolbar" [ref=e669]
+348
View File
@@ -0,0 +1,348 @@
- generic [ref=f1e1]:
- generic [ref=f1e3]:
- generic [ref=f1e6]:
- generic [ref=f1e7]:
- generic [ref=f1e9]:
- generic [ref=f1e10]:
- generic "Orca" [ref=f1e11] [cursor=pointer]
- button "切换侧边栏" [ref=f1e13] [cursor=pointer]
- generic [ref=f1e16]:
- button "返回" [disabled] [ref=f1e17]
- button "前进" [disabled] [ref=f1e20]
- generic [ref=f1e24]:
- generic [ref=f1e25]:
- button "任务" [ref=f1e26] [cursor=pointer]
- button "自动化" [ref=f1e29] [cursor=pointer]
- button "Orca 手机端" [ref=f1e36] [cursor=pointer]
- button "搜索工作树和浏览器选项卡" [ref=f1e40] [cursor=pointer]:
- generic [ref=f1e41]: 搜索
- generic [ref=f1e42]:
- generic [ref=f1e43]: Projects
- generic [ref=f1e45]:
- button "工作区选项" [ref=f1e46] [cursor=pointer]
- button "Add Project" [ref=f1e47] [cursor=pointer]
- button "New workspace" [ref=f1e48] [cursor=pointer]
- listbox "工作树" [ref=f1e50]:
- button "personal personal 的分组操作" [expanded] [ref=f1e51] [cursor=pointer]:
- generic [ref=f1e52]: personal
- generic:
- button "personal 的分组操作"
- button "AutoGenie AutoGenie 的项目操作 为 AutoGenie 创建新工作树" [expanded] [ref=f1e62] [cursor=pointer]:
- generic [ref=f1e63]: AutoGenie
- generic:
- button "AutoGenie 的项目操作"
- button "为 AutoGenie 创建新工作树"
- option "分支 · Unread 未读: main 主要 工作区元数据 Local Mac" [ref=f1e71]:
- generic [ref=f1e72]:
- generic [ref=f1e75] [cursor=pointer]:
- generic [ref=f1e76]: 分支 · Unread
- generic [ref=f1e84]:
- generic [ref=f1e86]:
- generic [ref=f1e87]:
- generic [ref=f1e88]: 未读:
- text: main
- generic [ref=f1e89]: 主要
- generic "工作区元数据" [ref=f1e92]: 工作区笔记
- generic [ref=f1e98]: Local Mac
- button
- button "demo-generation demo-generation 的项目操作 为 demo-generation 创建新工作树" [expanded] [ref=f1e102]:
- generic [ref=f1e103]: demo-generation
- generic:
- button "demo-generation 的项目操作"
- button "为 demo-generation 创建新工作树"
- option "分支 main 主要 Local Mac" [ref=f1e111]:
- generic [ref=f1e112]:
- generic [ref=f1e115] [cursor=pointer]:
- generic [ref=f1e116]: 分支
- generic [ref=f1e123]:
- generic [ref=f1e125]:
- generic [ref=f1e126]: main
- generic [ref=f1e127]: 主要
- generic [ref=f1e128]: Local Mac
- button
- option "分支 · Unread 未读: demo Local Mac" [ref=f1e132]:
- generic [ref=f1e133]:
- generic [ref=f1e136] [cursor=pointer]:
- generic [ref=f1e137]: 分支 · Unread
- generic [ref=f1e145]:
- generic [ref=f1e148]:
- generic [ref=f1e149]: 未读:
- text: demo
- generic [ref=f1e150]: Local Mac
- button
- button "jinjing-work jinjing-work 的项目操作 为 jinjing-work 创建新工作树" [expanded] [ref=f1e154]:
- generic [ref=f1e155]: jinjing-work
- generic:
- button "jinjing-work 的项目操作"
- button "为 jinjing-work 创建新工作树"
- option "分支 main 主要 Local Mac" [ref=f1e163]:
- generic [ref=f1e164]:
- generic [ref=f1e167] [cursor=pointer]:
- generic [ref=f1e168]: 分支
- generic [ref=f1e175]:
- generic [ref=f1e177]:
- generic [ref=f1e178]: main
- generic [ref=f1e179]: 主要
- generic [ref=f1e180]: Local Mac
- button
- option "分支 improve-agent-session Local Mac" [ref=f1e184]:
- generic [ref=f1e185]:
- generic [ref=f1e188] [cursor=pointer]:
- generic [ref=f1e189]: 分支
- generic [ref=f1e196]:
- generic [ref=f1e197]: improve-agent-session
- generic [ref=f1e200]: Local Mac
- button
- button "orca orca 的项目操作 为 orca 创建新工作树" [expanded] [ref=f1e204]:
- generic [ref=f1e205]: orca
- generic:
- button "orca 的项目操作"
- button "为 orca 创建新工作树"
- option "Inactive orca 主要 4 直播 ports Local Mac Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=f1e211]:
- generic [ref=f1e212]:
- generic [ref=f1e215] [cursor=pointer]:
- generic [ref=f1e216]: Inactive
- generic [ref=f1e221]:
- generic [ref=f1e223]:
- generic [ref=f1e224]: orca
- generic [ref=f1e225]: 主要
- button "4 直播 ports" [ref=f1e228]
- generic [ref=f1e232]:
- generic [ref=f1e233]: Local Mac
- generic "Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=f1e235]: Detached HEAD @ 4fd93ea
- button
- option "Inactive Connect to SSH host openclaw orca 主要 openclaw" [ref=f1e237]:
- generic [ref=f1e238]:
- generic [ref=f1e241] [cursor=pointer]:
- generic [ref=f1e242]: Inactive
- generic [ref=f1e247]:
- generic [ref=f1e249]:
- button "Connect to SSH host openclaw" [ref=f1e250]
- generic [ref=f1e251]: orca
- generic [ref=f1e252]: 主要
- generic [ref=f1e253]: openclaw
- button
- option "分支 auto-daily-prod-release-scan-run-19-20260822T1740 Local Mac" [ref=f1e257]:
- generic [ref=f1e258]:
- generic [ref=f1e261] [cursor=pointer]:
- generic [ref=f1e262]: 分支
- generic [ref=f1e269]:
- generic [ref=f1e270]: auto-daily-prod-release-scan-run-19-20260822T1740
- generic [ref=f1e273]: Local Mac
- button
- 'option "PR: Merged fix(automations): localize schedule weekday names and labels Local Mac" [ref=f1e277]':
- generic [ref=f1e278]:
- generic [ref=f1e281] [cursor=pointer]:
- generic [ref=f1e282]: "PR: Merged"
- generic [ref=f1e289]:
- generic [ref=f1e290]: "fix(automations): localize schedule weekday names and labels"
- generic [ref=f1e293]: Local Mac
- button
- option "分支 fix-rebase-race Local Mac" [ref=f1e297]:
- generic [ref=f1e298]:
- generic [ref=f1e301] [cursor=pointer]:
- generic [ref=f1e302]: 分支
- generic [ref=f1e309]:
- generic [ref=f1e310]: fix-rebase-race
- generic [ref=f1e313]: Local Mac
- button
- option "分支 allow-create-automations-with-ai Local Mac" [ref=f1e317]:
- generic [ref=f1e318]:
- generic [ref=f1e321] [cursor=pointer]:
- generic [ref=f1e322]: 分支
- generic [ref=f1e329]:
- generic [ref=f1e330]: allow-create-automations-with-ai
- generic [ref=f1e333]: Local Mac
- button
- option "分支 sta-4846-reconnect Local Mac" [ref=f1e337]:
- generic [ref=f1e338]:
- generic [ref=f1e341] [cursor=pointer]:
- generic [ref=f1e342]: 分支
- generic [ref=f1e349]:
- generic [ref=f1e350]: sta-4846-reconnect
- generic [ref=f1e353]: Local Mac
- button
- option "分支 sta-4907-review Local Mac" [ref=f1e357]:
- generic [ref=f1e358]:
- generic [ref=f1e361] [cursor=pointer]:
- generic [ref=f1e362]: 分支
- generic [ref=f1e369]:
- generic [ref=f1e370]: sta-4907-review
- generic [ref=f1e373]: Local Mac
- button
- option "分支 sta-4998-quick-open Local Mac" [ref=f1e377]:
- generic [ref=f1e378]:
- generic [ref=f1e381] [cursor=pointer]:
- generic [ref=f1e382]: 分支
- generic [ref=f1e389]:
- generic [ref=f1e390]: sta-4998-quick-open
- generic [ref=f1e393]: Local Mac
- button
- option "分支 sta-4999-ssh-scrollback 2 直播 ports Local Mac" [ref=f1e397]:
- generic [ref=f1e398]:
- generic [ref=f1e401] [cursor=pointer]:
- generic [ref=f1e402]: 分支
- generic [ref=f1e409]:
- generic [ref=f1e411]:
- generic [ref=f1e412]: sta-4999-ssh-scrollback
- button "2 直播 ports" [ref=f1e415]
- generic [ref=f1e418]: Local Mac
- button
- option "分支 sta-5000-add-project Local Mac" [ref=f1e422]:
- generic [ref=f1e423]:
- generic [ref=f1e426] [cursor=pointer]:
- generic [ref=f1e427]: 分支
- generic [ref=f1e434]:
- generic [ref=f1e435]: sta-5000-add-project
- generic [ref=f1e438]: Local Mac
- button
- option "分支 sta-5001-review Local Mac" [ref=f1e442]:
- generic [ref=f1e443]:
- generic [ref=f1e446] [cursor=pointer]:
- generic [ref=f1e447]: 分支
- generic [ref=f1e454]:
- generic [ref=f1e455]: sta-5001-review
- generic [ref=f1e458]: Local Mac
- button
- option "分支 sta-5097-5098-cold-park Local Mac" [ref=f1e462]:
- generic [ref=f1e463]:
- generic [ref=f1e466] [cursor=pointer]:
- generic [ref=f1e467]: 分支
- generic [ref=f1e474]:
- generic [ref=f1e475]: sta-5097-5098-cold-park
- generic [ref=f1e478]: Local Mac
- button
- option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 Local Mac" [ref=f1e482]:
- generic [ref=f1e483]:
- generic [ref=f1e486] [cursor=pointer]:
- generic [ref=f1e487]: 分支
- generic [ref=f1e494]:
- generic [ref=f1e495]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500
- generic [ref=f1e498]: Local Mac
- button
- option "分支 auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 Local Mac" [ref=f1e502]:
- generic [ref=f1e503]:
- generic [ref=f1e506] [cursor=pointer]:
- generic [ref=f1e507]: 分支
- generic [ref=f1e514]:
- generic [ref=f1e515]: auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700
- generic [ref=f1e518]: Local Mac
- button
- option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 Local Mac" [ref=f1e522]:
- generic [ref=f1e523]:
- generic [ref=f1e526] [cursor=pointer]:
- generic [ref=f1e527]: 分支
- generic [ref=f1e534]:
- generic [ref=f1e535]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300
- generic [ref=f1e538]: Local Mac
- button
- generic [ref=f1e544]:
- generic [ref=f1e546]:
- button "设置" [ref=f1e547] [cursor=pointer]
- button "帮助" [ref=f1e548] [cursor=pointer]
- generic [ref=f1e549]:
- button "显示活动工作区" [ref=f1e550] [cursor=pointer]
- button "工作区板" [ref=f1e551] [cursor=pointer]
- generic [ref=f1e555]:
- generic [ref=f1e556]:
- text: ···
- main [ref=f1e557]:
- heading "自动化" [level=1] [ref=f1e559]
- generic [ref=f1e560]:
- generic [ref=f1e561]:
- generic [ref=f1e562]:
- textbox "搜索自动化" [active] [ref=f1e564]:
- /placeholder: 搜索...
- combobox "Filter by host" [ref=f1e566] [cursor=pointer]:
- generic:
- generic:
- generic "Linux"
- generic:
- generic "Unreachable. This host could not be reached, so its automations could not be loaded.": Unreachable
- generic "Not connected. Automations on this host cannot run until the connection is restored.": Not connected
- button "刷新自动化" [ref=f1e567] [cursor=pointer]
- button "新建自动化" [ref=f1e568] [cursor=pointer]
- status [ref=f1e569]
- generic [ref=f1e570]:
- generic [ref=f1e571]: Linux could not be reached. Showing the automations last loaded from it.
- generic [ref=f1e572]:
- generic "Unreachable. This host could not be reached, so its automations could not be loaded." [ref=f1e573]: Unreachable
- generic "Not connected. Automations on this host cannot run until the connection is restored." [ref=f1e575]: Not connected
- button "Reconnect" [ref=f1e577] [cursor=pointer]
- status [ref=f1e578]: 1 of 3 hosts could not be loaded
- paragraph [ref=f1e579]: External automation managers are not listed for this host in this release.
- generic [ref=f1e580]:
- generic [ref=f1e581]:
- generic [ref=f1e582]: 名称
- generic [ref=f1e583]: 日程
- generic [ref=f1e584]: 项目
- generic [ref=f1e585]: 主机
- generic [ref=f1e586]: 下次运行
- generic [ref=f1e587]: 上次运行
- generic [ref=f1e588]: 状态
- generic [ref=f1e589]: 智能体
- generic [ref=f1e590]: 操作
- button "工作日仓库审计 Weekdays at 9:00 AM orca-internal openclaw Aug 24, 9:00 AM (in 2d) Never 启用 Codex · openclaw · 用量不可用 自动化操作" [ref=f1e592] [cursor=pointer]:
- generic [ref=f1e593]: 工作日仓库审计
- generic "Weekdays at 9:00 AM" [ref=f1e594]
- generic "orca-internal" [ref=f1e595]
- generic "openclaw" [ref=f1e596]
- generic "Aug 24, 9:00 AM (in 2d)" [ref=f1e597]
- generic "Never" [ref=f1e598]
- generic [ref=f1e600]: 启用
- generic "Codex · openclaw · 用量不可用" [ref=f1e602]
- button "自动化操作" [ref=f1e605]
- button "显示浮动工作区" [ref=f1e607]
- button
- generic [ref=f1e608]:
- generic [ref=f1e609]:
- button "使用量" [ref=f1e611] [cursor=pointer]:
- generic [ref=f1e612]: 受限
- generic [ref=f1e618]:
- generic [ref=f1e623]: 已用 0% 4h 44m
- generic [ref=f1e624]: ·
- generic [ref=f1e625]: 已用 9% 4d 9h
- generic [ref=f1e626]: 已用 31% 5d 16h
- button "刷新速率限制" [ref=f1e631] [cursor=pointer]
- generic [ref=f1e637]:
- button "Caffeinate, Agent · Inactive" [ref=f1e638] [cursor=pointer]:
- generic [ref=f1e641]: Agent
- button "宠物菜单" [ref=f1e643] [cursor=pointer]:
- generic [ref=f1e644]: Mini Sama hidden
- button "资源管理器,22 个终端会话" [ref=f1e645] [cursor=pointer]:
- generic [ref=f1e648]: 1.91 GB
- generic [ref=f1e649]: ·
- generic [ref=f1e652]: "22"
- button "端口,101 工作区 ports" [ref=f1e653] [cursor=pointer]:
- generic [ref=f1e656]: "101"
- button "远程主机连接状态" [ref=f1e657] [cursor=pointer]:
- generic [ref=f1e658]: 0 个主机
- button
- region "Notifications alt+T":
- list:
- listitem [ref=f1e667]:
- button "Close toast" [ref=f1e668] [cursor=pointer]
- generic [ref=f1e675]:
- generic [ref=f1e676]: macOS permissions may not reach Orca terminals
- generic [ref=f1e677]: Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orca’s Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.
- button "Dismiss" [ref=f1e678] [cursor=pointer]
- button "Open Manage Sessions" [ref=f1e679] [cursor=pointer]
- generic [ref=f1e681] [cursor=pointer]:
- generic:
- generic:
- generic:
- generic:
- button "Select element"
- generic:
- generic:
- button "Open comments":
- generic:
- generic:
- generic:
- generic:
- generic: "0"
- generic: "0"
- generic:
- generic:
- button "Copy all comments"
- button "Expand toolbar" [ref=f1e682]
+11 -18
View File
@@ -149,7 +149,7 @@ Catalog projection rules:
- Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry.
- Project Runtime + Self from saved runtime environments, even while a runtime is offline.
- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only.
- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its SSH entries key on target ID alone and are view-only. Its Self entry is not — see the owner-fencing section.
- Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages.
- Hide runtime-owned ephemeral SSH targets under the existing visibility rule.
- Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone.
@@ -164,9 +164,7 @@ Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale
Persist this optional UI value:
```ts
type AutomationHostFilter =
| { kind: 'all' }
| { kind: 'host'; host: StableAutomationCatalogRef }
type AutomationHostFilter = { kind: 'all' } | { kind: 'host'; host: StableAutomationCatalogRef }
```
Only the stable form is persisted. Restore it after the relevant catalogs settle:
@@ -190,8 +188,7 @@ Add two string capability constants to `src/shared/protocol-version.ts`:
export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY =
'automation.list-host-scope.v1' as const
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY =
'automation.owner-fencing.v1' as const
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const
```
The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions.
@@ -249,7 +246,7 @@ Legacy partitioning is explicit:
The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded.
If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations.
If a runtime lacks owner fencing, its rows split by what the fence actually protects. Runtime + Self stays fully usable — list, edit, pause, delete, Run Now — because a Self record lives on the answering authority itself, needs no registration generation, and mutates by id exactly as a pre-fencing server does today; pairing-revision freshness pins the request to the runtime incarnation the user saw, which is the only identity a Self record has. Runtime + SSH stays view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. For SSH, pairing-revision freshness is not a substitute for the selector fence — a same-id remove/re-add or a record whose target changed server-side since the last refresh would still run on a machine the user never saw. A destination move (re-attaching a record to another host) always requires the fenced contract, even from Self: an old server would ignore the field and silently leave the record in place. The SSH arm mirrors the fail-closed capability assertion the codebase already applies to remote file mutations.
### Owner-fenced operation contract
@@ -257,9 +254,7 @@ For capable runtimes, add this optional precondition to `automation.show`, `auto
```ts
type AutomationOwnerPrecondition = {
selector:
| { kind: 'self' }
| { kind: 'ssh'; targetId: string; targetGeneration: number }
selector: { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number }
}
type AutomationOwnedIdParams = {
@@ -432,14 +427,12 @@ Existing desktop-stored records whose `schedulerOwner` or run context points at
Desktop/runtime version matrix:
| Desktop | Runtime | Behavior |
| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. |
| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. |
| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. |
| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. |
| Desktop | Runtime | Behavior |
| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. |
| New | Old | One unscoped list per authority, defensive partition, neutral usage. Self rows stay fully actionable by id under the pairing-revision fence; SSH rows are view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the SSH row surfaces Update server rather than hanging with no explanation. |
| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. |
| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. |
No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces.
+6 -13
View File
@@ -17,6 +17,7 @@ import {
import type { AutomationDestination } from '../shared/automation-owner-precondition'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
import type { SshTargetSummary } from '../shared/ssh-types'
import { AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE } from '../shared/protocol-version'
import type { Repo } from '../shared/repo-types'
import type { RuntimeClient } from './runtime-client'
@@ -39,22 +40,16 @@ async function resolveTargetRepo(
.repo
}
/**
* `undefined` means the authority registers the target but assigns no
* generation, which is a host with nothing to fence — not a host to fail
* against. A target the authority does not register at all is positive
* evidence that the destination is gone.
*/
async function sshTargetGeneration(
client: RuntimeClient,
targetId: string
): Promise<number | undefined> {
async function sshTargetGeneration(client: RuntimeClient, targetId: string): Promise<number> {
const targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries'))
.result.targets
const match = targets.find((candidate) => candidate.id === targetId)
if (!match) {
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination)
}
if (match.generation === undefined) {
throw new Error(AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE)
}
return match.generation
}
@@ -71,7 +66,5 @@ export async function resolveAutomationDestination(
return { selector: { kind: 'self' } }
}
const generation = await sshTargetGeneration(client, connectionId)
return generation === undefined
? undefined
: { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } }
return { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } }
}
@@ -177,26 +177,26 @@ describe('CLI automation writes fence the host they land on', () => {
})
})
// A host that predates SSH generations has no incarnation to capture, so the
// write must still land — failing closed there would break a host that is only old.
it('omits the destination when the authority registers the target without a generation', async () => {
it('refuses an SSH destination that the authority cannot generation-fence', async () => {
const { call, writes } = authority({
registered: [{ id: 'box-1', label: 'box 1' }],
repoConnectionId: 'box-1'
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
await expect(
AUTOMATION_HANDLERS['automations edit']!({
client: { call } as never,
cwd: '/tmp',
flags: new Map([
['id', 'a1'],
['repo', 'repo-on-box-1']
]),
json: true
})
).rejects.toThrow('Editing automations on this host requires a newer Orca server')
expect(writes[0]?.params).not.toHaveProperty('destination')
expect(writes).toEqual([])
})
it('sends a self destination for a local project', async () => {
@@ -71,6 +71,42 @@ describe('ExternalAutomationProbeScheduler', () => {
expect(run).toHaveBeenCalledTimes(1)
})
it('releases a priority hold that its holder never releases', async () => {
vi.useFakeTimers()
try {
const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 })
const run = vi.fn(() => Promise.resolve('manager'))
// The lease of a dispatch that never settles. Without the deadline this
// probe — and every later one — waits for the rest of the process.
scheduler.beginPriorityWork()
const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
expect(run).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1_000)
await expect(probe).resolves.toBe('manager')
} finally {
vi.useRealTimers()
}
})
it('keeps parking probes while a hold is still within its deadline', async () => {
vi.useFakeTimers()
try {
const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 })
const run = vi.fn(() => Promise.resolve('manager'))
scheduler.beginPriorityWork()
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
await vi.advanceTimersByTimeAsync(999)
expect(run).not.toHaveBeenCalled()
expect(scheduler.queued).toBe(1)
} finally {
vi.useRealTimers()
}
})
it('cancels queued and in-flight probes whose scope is no longer selected', async () => {
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
const never = deferred<string>()
@@ -35,16 +35,25 @@ type QueuedProbe = {
}
const DEFAULT_PROBE_CONCURRENCY = 4
/**
* The priority lease is an ordering preference, not a lock. Work that holds it
* for an unbounded time — a headless dispatch, a handler whose promise never
* settles — would otherwise park every queued probe for the rest of the
* process, and a caller waiting on one would never be answered at all.
*/
const PRIORITY_HOLD_MAX_MS = 30_000
export class ExternalAutomationProbeScheduler {
private readonly concurrency: number
private readonly priorityHoldMaxMs: number
private readonly queue: QueuedProbe[] = []
private readonly active = new Map<string, { scopeKey: string; controller: AbortController }>()
private readonly shared = new Map<string, Promise<unknown>>()
private priorityHolds = 0
constructor(options?: { concurrency?: number }) {
constructor(options?: { concurrency?: number; priorityHoldMaxMs?: number }) {
this.concurrency = Math.max(1, options?.concurrency ?? DEFAULT_PROBE_CONCURRENCY)
this.priorityHoldMaxMs = Math.max(0, options?.priorityHoldMaxMs ?? PRIORITY_HOLD_MAX_MS)
}
get inFlight(): number {
@@ -90,18 +99,27 @@ export class ExternalAutomationProbeScheduler {
return settled as Promise<T>
}
/** Held for the duration of Orca list/mutation work; queued probes wait behind it. */
/**
* Held for the duration of Orca list/mutation work; queued probes wait behind
* it, but only until the deadline — see `PRIORITY_HOLD_MAX_MS`.
*/
beginPriorityWork(): () => void {
this.priorityHolds += 1
let released = false
return () => {
let timer: ReturnType<typeof setTimeout>
const release = (): void => {
if (released) {
return
}
released = true
clearTimeout(timer)
this.priorityHolds = Math.max(0, this.priorityHolds - 1)
this.pump()
}
timer = setTimeout(release, this.priorityHoldMaxMs)
// Why unref: a lease deadline must never be the reason the process stays up.
timer.unref?.()
return release
}
/** Drops queued and in-flight probes whose scope is no longer selected. */
@@ -129,10 +129,9 @@ describe('scoped external automations', () => {
})
it('probes only the selected SSH host', async () => {
const mux = relay({ jobs: [], hermesAvailable: true })
vi.mocked(getActiveMultiplexer).mockReturnValue(
relay({ jobs: [], hermesAvailable: true }) as unknown as ReturnType<
typeof getActiveMultiplexer
>
mux as unknown as ReturnType<typeof getActiveMultiplexer>
)
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
@@ -144,6 +143,11 @@ describe('scoped external automations', () => {
expect(entry.manager?.id).toBe('hermes:ssh:target-a')
expect(getActiveMultiplexer).toHaveBeenCalledTimes(1)
expect(getActiveMultiplexer).toHaveBeenCalledWith('target-a')
expect(mux.request).toHaveBeenCalledWith(
'externalAutomations.list',
{ provider: 'hermes' },
{ signal: expect.any(AbortSignal) }
)
})
it('fails closed on a stale generation without probing', async () => {
+5 -4
View File
@@ -243,7 +243,8 @@ function remoteRelayErrorMessage(error: unknown): string {
async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
provider: ExternalAutomationProvider,
signal: AbortSignal
): Promise<ExternalAutomationManager> {
const providerLabel = PROVIDER_LABELS[provider]
const managerProviderId = `${provider}:ssh:${target.id}`
@@ -262,7 +263,7 @@ async function listRemoteManager(
}
}
try {
const result = (await mux.request('externalAutomations.list', { provider })) as {
const result = (await mux.request('externalAutomations.list', { provider }, { signal })) as {
jobs?: unknown[]
hermesAvailable?: boolean
openclawAvailable?: boolean
@@ -534,9 +535,9 @@ export function createScopedExternalAutomations(
deps.scheduler.schedule({
key: externalAutomationManagerCacheKey(key),
scopeKey: scope.ownerKey,
run: () =>
run: (signal) =>
scope.sshTarget
? listRemoteManager(scope.sshTarget, scope.provider)
? listRemoteManager(scope.sshTarget, scope.provider, signal)
: listLocalManager(scope.provider)
}),
{ refresh: request.refresh }
@@ -85,7 +85,8 @@ describe('headless automation workspace create args', () => {
executionTargetId: 'ssh-target-1',
projectId: 'project-1',
repoId: 'repo-1',
hostId: 'ssh:ssh-target-1'
hostId: 'ssh:ssh-target-1',
storageAuthority: 'runtime'
}
})
})
@@ -42,6 +42,12 @@ export function buildHeadlessAutomationWorktreeCreateArgs({
startupAgent: automation.agentId,
startupPrompt: automation.prompt,
telemetrySource: 'unknown',
automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt)
automationProvenance: buildAutomationWorkspaceProvenance(
automation,
run,
repo,
createdAt,
'runtime'
)
}
}
@@ -230,7 +230,7 @@ describe('reconciling retained runs against a graph that has not published yet',
service.stop()
})
it('still closes out a run the ready surface cannot find after the settle window', async () => {
it('keeps a run unverifiable when the ready surface cannot find it', async () => {
const store = await createStore()
const automation = createAutomation(store)
const retained = retainDispatchedRun(store, automation)
@@ -241,10 +241,8 @@ describe('reconciling retained runs against a graph that has not published yet',
service.setRendererReady()
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
expect(readRun(store, automation.id, retained.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, retained.id).error).toBe(
'Orca lost the terminal for this run before it reported completion.'
)
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
expect(readRun(store, automation.id, retained.id).error).toBeNull()
service.stop()
})
})
@@ -12,7 +12,7 @@ export type RetainedRunReconcilerDeps = {
attach: (run: AutomationRun) => boolean
/** False once the run reached a terminal status by some other path. */
stillRetained: (run: AutomationRun) => boolean
strand: (run: AutomationRun) => void
unverifiable: (run: AutomationRun) => void
}
/**
@@ -47,6 +47,15 @@ export class RetainedRunReconciler {
this.sweep()
}
/** Rechecks an observation-lost run on the cadence, avoiding a tight stale-handle loop. */
defer(run: AutomationRun): void {
if (this.disposed) {
return
}
this.pending.set(run.id, run)
this.arm()
}
/** The authority's terminal surface can now answer pane lookups. */
markSurfaceReady(): void {
if (this.disposed) {
@@ -73,7 +82,7 @@ export class RetainedRunReconciler {
}
if (strandAt !== null && Date.now() >= strandAt) {
this.pending.delete(runId)
this.deps.strand(run)
this.deps.unverifiable(run)
}
}
if (this.pending.size === 0) {
@@ -133,12 +133,7 @@ describe('authority-owned automation run completion', () => {
service.stop()
})
/**
* The throws on this path carry transport tokens, not sentences. Every other
* refusal in run history is one fixed sentence, so leaking `terminal_handle_stale`
* into the row a user reads breaks the only convention that surface has.
*/
it('reports a failed observation in the same fixed sentence every other refusal uses', async () => {
it('keeps a run unverifiable when terminal observation is lost', async () => {
const store = await createStore()
const automation = createAutomation(store)
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -150,20 +145,15 @@ describe('authority-owned automation run completion', () => {
})
const run = await service.runNow(automation.id)
await vi.waitFor(() => {
expect(readRun(store, automation.id, run.id).status).toBe('dispatch_failed')
})
expect(readRun(store, automation.id, run.id).error).toBe(
'Orca stopped watching this run before it reported completion.'
)
// The token is still recoverable where it is actually useful.
await vi.waitFor(() => expect(logged).toHaveBeenCalled())
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
expect(readRun(store, automation.id, run.id).error).toBeNull()
expect(logged.mock.calls.flat().map(String).join(' ')).toContain('terminal_handle_stale')
logged.mockRestore()
service.stop()
})
it('reconciles stranded runs on startup without claiming completion', async () => {
it('keeps unresolvable retained runs dispatched after startup reconciliation', async () => {
const store = await createStore()
const automation = createAutomation(store)
const dispatched = store.createAutomationRun(automation, 1_000, 'manual')
@@ -193,10 +183,10 @@ describe('authority-owned automation run completion', () => {
service.setRendererReady()
await vi.advanceTimersByTimeAsync(10 * 60 * 1000)
expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal')
expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started')
expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatched')
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatching')
expect(readRun(store, automation.id, dispatched.id).error).toBeNull()
expect(readRun(store, automation.id, dispatching.id).error).toBeNull()
service.stop()
vi.useRealTimers()
})
+16 -16
View File
@@ -30,12 +30,9 @@ export function describeStrandedAutomationRun(run: AutomationRun): string {
return 'Orca lost the terminal for this run before it reported completion.'
}
/** Why a fixed sentence: the throws here carry internal transport tokens
* (`terminal_handle_stale`, `terminal_not_found`, `request_aborted`), and a run
* history row is user copy. The token stays in the log, where it is useful. */
function describeObservationError(error: unknown): string {
/** Observation failures are transport evidence, so they stay in logs and never finalize a run. */
function logObservationError(error: unknown): void {
console.error('[automations] run completion observation failed:', error)
return 'Orca stopped watching this run before it reported completion.'
}
export class AutomationRunCompletionWatcher {
@@ -60,13 +57,11 @@ export class AutomationRunCompletionWatcher {
const current = this.readRun(run.automationId, run.id)
return Boolean(current && !isFinalAutomationRunStatus(current.status))
},
strand: (run) => {
void this.finalize(run, {
status: 'dispatch_failed',
error: describeStrandedAutomationRun(run)
}).catch((error) => {
console.error('[automations] failed to reconcile stranded run:', error)
})
unverifiable: (run) => {
console.warn(
'[automations] run completion is unverifiable:',
describeStrandedAutomationRun(run)
)
}
})
}
@@ -101,10 +96,13 @@ export class AutomationRunCompletionWatcher {
private startWatch(run: AutomationRun, handle: string): void {
const controller = new AbortController()
this.watching.set(run.id, controller)
void this.observeUntilTerminal(run, handle, controller).finally(() => {
void this.observeUntilTerminal(run, handle, controller).then((retry) => {
if (this.watching.get(run.id) === controller) {
this.watching.delete(run.id)
}
if (retry) {
this.reconciler.defer(run)
}
})
}
@@ -112,21 +110,23 @@ export class AutomationRunCompletionWatcher {
run: AutomationRun,
handle: string,
controller: AbortController
): Promise<void> {
): Promise<boolean> {
let observation: AutomationRunCompletionObservation
try {
observation = await this.observer.observeCompletion(handle, { signal: controller.signal })
} catch (error) {
if (controller.signal.aborted) {
return
return false
}
observation = { status: 'dispatch_failed', error: describeObservationError(error) }
logObservationError(error)
return true
}
try {
await this.finalize(run, observation)
} catch (error) {
console.error('[automations] failed to persist watched run completion:', error)
}
return false
}
forget(runId: string): void {
@@ -181,13 +181,13 @@ describe('createRuntimeAutomationRunTerminalObserver', () => {
await run.promise
})
it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => {
it('makes observation unverifiable after its bounded watch window', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'working' })
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 + RUNTIME_TUI_IDLE_TIMEOUT_MS)
expect(run.settled[0]?.status).toBe('dispatch_failed')
expect(run.settled[0]?.error).toContain('without a completion signal')
expect(run.settled).toEqual([])
expect(run.errors.map(String)).toContain('Error: automation_run_observation_unverifiable')
// 6h of 5-minute waits, not an unbounded re-arm.
expect(runtime.waitCalls()).toBeLessThanOrEqual(80)
await run.promise
@@ -185,11 +185,7 @@ export function createRuntimeAutomationRunTerminalObserver(
throw error
}
if (Date.now() >= deadlineAt) {
return await buildUnobservedObservation(
runtime,
handle,
'Orca stopped watching this run after 6h without a completion signal.'
)
throw new Error('automation_run_observation_unverifiable')
}
}
}
+3 -2
View File
@@ -37,8 +37,9 @@ export function resolveAutomationWorkspaceProvenance(args: {
repoSelector: string
repo: Repo
request: AutomationWorkspaceProvenanceRequest | undefined
storageAuthority: AutomationWorkspaceProvenance['storageAuthority']
}): AutomationWorkspaceProvenance | undefined {
const { authority, repoSelector, repo, request } = args
const { authority, repoSelector, repo, request, storageAuthority } = args
if (!request) {
return undefined
}
@@ -75,7 +76,7 @@ export function resolveAutomationWorkspaceProvenance(args: {
invalidAutomationProvenanceRequest()
}
return buildAutomationWorkspaceProvenance(automation, run, repo)
return buildAutomationWorkspaceProvenance(automation, run, repo, Date.now(), storageAuthority)
}
export function releaseAutomationWorkspaceProvenanceRequest(
+1
View File
@@ -2316,6 +2316,7 @@ void app.whenReady().then(async () => {
reportSecretProtectionGap({
dataFile: activeOrcaProfile.dataFile,
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
})
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own
// accept records on every launch.
@@ -87,7 +87,11 @@ describe('scoped external-manager IPC', () => {
provider: 'hermes'
})) as { manager: { id: string; target: unknown } | null; error: string | null }
expect(relay.request).toHaveBeenCalledWith('externalAutomations.list', { provider: 'hermes' })
expect(relay.request).toHaveBeenCalledWith(
'externalAutomations.list',
{ provider: 'hermes' },
{ signal: expect.any(AbortSignal) }
)
expect(entry.manager?.id).toBe('hermes:ssh:t1')
expect(entry.manager?.target).toEqual({ type: 'ssh', connectionId: 't1' })
expect(entry.error).toBeNull()
+4 -2
View File
@@ -2263,7 +2263,8 @@ export function registerWorktreeHandlers(
authority: runtime,
repoSelector: args.repoId,
repo,
request: args.automationProvenanceRequest
request: args.automationProvenanceRequest,
storageAuthority: 'desktop'
})
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
...args,
@@ -2330,7 +2331,8 @@ export function registerWorktreeHandlers(
authority: runtime,
repoSelector: args.repoId,
repo,
request: args.automationProvenanceRequest
request: args.automationProvenanceRequest,
storageAuthority: 'desktop'
})
let result: CreateWorktreeResult
try {
+18 -3
View File
@@ -626,6 +626,7 @@ import {
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree/visibility-sources'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
@@ -3682,6 +3683,7 @@ export class OrcaRuntimeService {
private skillCloudService: SkillCloudService | null = null
private agentSkillShareInProgress = false
private skillUploadSessions: SkillUploadSessionService | null = null
private skillUploadSessionsDisposed = false
private readonly skillTransactionRecovery: Promise<unknown>
private readonly skillInstallOperations = new Map<string, AbortController>()
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
@@ -5796,12 +5798,22 @@ export class OrcaRuntimeService {
}
private requireSkillUploadSessions(): SkillUploadSessionService {
if (this.skillUploadSessionsDisposed) {
throw new Error('skill-upload-service-disposed')
}
this.skillUploadSessions ??= new SkillUploadSessionService(
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
)
return this.skillUploadSessions
}
async disposeSkillUploadSessions(): Promise<void> {
this.skillUploadSessionsDisposed = true
const sessions = this.skillUploadSessions
this.skillUploadSessions = null
await sessions?.dispose()
}
getRuntimeId(): string {
return this.runtimeId
}
@@ -23434,7 +23446,8 @@ export class OrcaRuntimeService {
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(
@@ -23463,7 +23476,8 @@ export class OrcaRuntimeService {
}
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
@@ -23575,7 +23589,8 @@ export class OrcaRuntimeService {
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
])
)
+2 -1
View File
@@ -84,7 +84,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
authority: runtime,
repoSelector: params.repo,
repo,
request: params.automationProvenanceRequest
request: params.automationProvenanceRequest,
storageAuthority: 'runtime'
})
// Why: provenance tokens are reserved before creation so retries can recover,
// but failed create attempts must release the reservation for a safe retry.
@@ -81,7 +81,11 @@ describe('AutomationHostStatusBadges', () => {
it('distinguishes an incompatible authority from a legacy-unscoped contract', () => {
render(<AutomationHostStatusBadges entry={entry({ authorityHealth: 'incompatible' })} />)
const incompatible = statusIds()
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'legacy-unscoped' })} />)
render(
<AutomationHostStatusBadges
entry={entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' })}
/>
)
const legacy = statusIds()
expect(incompatible).toContain('authority-incompatible')
@@ -89,6 +93,22 @@ describe('AutomationHostStatusBadges', () => {
expect(incompatible).not.toEqual(legacy)
})
it('shows no View only badge for a connected legacy Runtime + Self entry', () => {
render(
<AutomationHostStatusBadges
entry={entry({
stableRef: {
authority: { kind: 'runtime', environmentId: 'env-1' },
selector: { kind: 'self' }
},
kind: 'self',
querySupport: 'legacy-unscoped'
})}
/>
)
expect(statusIds()).toEqual([])
})
it('separates the incompatible query contract from the incompatible authority', () => {
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'incompatible' })} />)
expect(statusIds('query')).toEqual(['query-incompatible'])
@@ -19,6 +19,7 @@ let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -141,14 +142,24 @@ describe('AutomationHostFilterNotice', () => {
expect(onRecover).toHaveBeenCalledWith('reconnect')
})
it('offers Update server for a legacy-unscoped contract', () => {
it('offers Update server for an entry the unscoped contract leaves view-only', () => {
renderNotice(
resolution('ghost', { entry: entry({ querySupport: 'legacy-unscoped' }) }),
resolution('ghost', {
entry: entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' })
}),
() => undefined
)
expect(container.querySelector('button')?.textContent).toBe('Update server')
})
it('offers no Update server for a legacy Self entry with no scope gap', () => {
renderNotice(
resolution('ready', { entry: entry({ querySupport: 'legacy-unscoped' }) }),
() => undefined
)
expect(container.querySelector('button')).toBeNull()
})
it('does not move focus when the notice appears', () => {
const input = document.createElement('input')
document.body.appendChild(input)
@@ -1,12 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { AutomationHostStatusBadges } from './AutomationHostBadges'
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
import {
automationHostRecoveryActions,
recoveryActionLabel,
type AutomationHostRecoveryAction
} from './automation-host-status-descriptors'
@@ -60,7 +59,7 @@ export function AutomationHostLoadSummary({
type AutomationHostFilterNoticeProps = {
resolution: AutomationHostFilterResolution
onRecover?: (action: AutomationHostRecoveryAction) => void
onRecover?: (action: AutomationHostRecoveryAction) => void | Promise<void>
className?: string
}
@@ -132,9 +131,7 @@ export function AutomationHostFilterNotice({
<span className="min-w-0 flex-1">{message}</span>
{resolution.entry ? <AutomationHostStatusBadges entry={resolution.entry} /> : null}
{action && onRecover ? (
<Button type="button" variant="outline" size="xs" onClick={() => onRecover(action)}>
{recoveryActionLabel(action)}
</Button>
<AutomationHostRecoverButton action={action} onRecover={onRecover} />
) : null}
</div>
) : null}
@@ -54,14 +54,15 @@ function runtimeSelf(environmentId: string, label: string): AutomationHostCatalo
function render(
entries: readonly AutomationHostCatalogEntry[],
onSelect: (filter: AutomationHostFilter) => void = () => undefined
onSelect: (filter: AutomationHostFilter) => void = () => undefined,
selectedStableKey: string | null = null
): void {
act(() => {
root.render(
<TooltipProvider>
<AutomationHostPickerSelect
entries={entries}
selectedStableKey={null}
selectedStableKey={selectedStableKey}
triggerLabel="All hosts"
pickerLabel="Filter by host"
onSelect={onSelect}
@@ -140,6 +141,24 @@ describe('AutomationHostPickerSelect authority grouping', () => {
expect(groups().map(accessibleGroupName)).toEqual(['This computer'])
})
it('opens below the trigger instead of aligning the selected host', () => {
render(
[sshEntry('t0', 'web-00'), runtimeSelf('env-1', 'Build box')],
() => undefined,
'host:runtime:env-1:self'
)
openWithKeyboard()
const content = document.querySelector('[data-slot="select-content"]')
expect(content).not.toBeNull()
expect(content?.getAttribute('data-side')).toBe('bottom')
expect(content?.getAttribute('data-align')).toBe('start')
const firstItem = document.querySelector(
'[data-slot="select-content"] [data-slot="select-item"]'
)
expect(firstItem?.textContent).toContain('All hosts')
})
it('reports the chosen host as a stable filter', () => {
const onSelect = vi.fn()
render([sshEntry('t0', 'web-00'), sshEntry('t1', 'web-01')], onSelect)
@@ -64,13 +64,22 @@ export function AutomationHostPickerSelect({
<SelectTrigger
aria-label={pickerLabel}
data-picker-variant="select"
className={cn('h-8 text-xs', className)}
className={cn('h-8 w-full text-xs', className)}
>
{/* Without children Radix names the trigger from the matching item, and a
retained-but-unhydrated selection matches only the All hosts item. */}
<SelectValue>{selectedStableKey === null ? triggerLabel : undefined}</SelectValue>
</SelectTrigger>
<SelectContent>
{/* Why: item-aligned lines the selected host up with the trigger, so a
mid-list pick (and the group label above it) clips under the titlebar. */}
<SelectContent
position="popper"
side="bottom"
align="start"
sideOffset={4}
collisionPadding={8}
className="min-w-[20rem]"
>
<SelectItem value={ALL_HOSTS_OPTION_VALUE} className="text-xs">
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
</SelectItem>
@@ -0,0 +1,41 @@
import { useState, type JSX } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
recoveryActionLabel,
type AutomationHostRecoveryAction
} from './automation-host-status-descriptors'
export function AutomationHostRecoverButton({
action,
onRecover,
size = 'xs',
className
}: {
action: AutomationHostRecoveryAction
onRecover: (action: AutomationHostRecoveryAction) => void | Promise<void>
size?: 'xs' | 'sm'
className?: string
}): JSX.Element {
const [busy, setBusy] = useState(false)
return (
<Button
type="button"
variant="outline"
size={size}
className={className}
disabled={busy}
aria-busy={busy}
onClick={() => {
const result = onRecover(action)
if (result instanceof Promise) {
setBusy(true)
void result.finally(() => setBusy(false))
}
}}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : null}
{recoveryActionLabel(action)}
</Button>
)
}
@@ -14,6 +14,7 @@ let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -1,19 +1,16 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
resolveAutomationListEmptyState,
type AutomationListEmptyStateInput
} from './automation-list-empty-state'
import {
recoveryActionLabel,
type AutomationHostRecoveryAction
} from './automation-host-status-descriptors'
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
/** Renders whichever empty/partial/failure state the list is in; nothing when rows exist. */
export type AutomationListEmptyViewProps = AutomationListEmptyStateInput & {
onRecover?: (action: AutomationHostRecoveryAction) => void
onRecover?: (action: AutomationHostRecoveryAction) => void | Promise<void>
className?: string
}
@@ -44,15 +41,12 @@ export function AutomationListEmptyView({
</p>
) : null}
{recovery && onRecover ? (
<Button
type="button"
variant="outline"
<AutomationHostRecoverButton
action={recovery}
onRecover={onRecover}
size="sm"
className="mt-1.5"
onClick={() => onRecover(recovery)}
>
{recoveryActionLabel(recovery)}
</Button>
/>
) : null}
</div>
)
@@ -86,7 +86,8 @@ export function AutomationListExternalRows({
})
const actionDisabled = disabledMessage !== null
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}`
const projectLabel = entry.job.workdir || '—'
const hostLabel = entry.manager.targetLabel
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
@@ -127,6 +128,9 @@ export function AutomationListExternalRows({
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
@@ -1,6 +1,6 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
import {
AutomationListLocalRows,
type AutomationListLocalRowsProps
@@ -13,7 +13,6 @@ import {
import { externalManagersListedForEntry } from './external-automation-scope-gating'
import {
automationHostRecoveryActions,
recoveryActionLabel,
type AutomationHostRecoveryAction
} from './automation-host-status-descriptors'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
@@ -31,7 +30,10 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types
export type AutomationListHostGroupsProps = Omit<AutomationListLocalRowsProps, 'rows'> & {
groups: readonly AutomationHostVisibleGroup[]
searchActive: boolean
onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void
onRecover: (
action: AutomationHostRecoveryAction,
entry: AutomationHostCatalogEntry
) => void | Promise<void>
}
function hostRecoveryAction(
@@ -48,7 +50,7 @@ function AutomationHostGroupState({
}: {
state: AutomationListEmptyState
recovery: AutomationHostRecoveryAction | null
onRecover: (action: AutomationHostRecoveryAction) => void
onRecover: (action: AutomationHostRecoveryAction) => void | Promise<void>
}): React.JSX.Element {
return (
<div className="px-3 pb-1 text-xs text-muted-foreground" data-empty-state={state.kind}>
@@ -60,15 +62,7 @@ function AutomationHostGroupState({
</div>
) : null}
{recovery ? (
<Button
type="button"
variant="outline"
size="xs"
className="mt-1"
onClick={() => onRecover(recovery)}
>
{recoveryActionLabel(recovery)}
</Button>
<AutomationHostRecoverButton action={recovery} onRecover={onRecover} className="mt-1" />
) : null}
</div>
)
@@ -102,14 +96,10 @@ export function AutomationListHostGroups({
<AutomationHostLabel entry={entry} className="min-w-0 flex-1" />
<AutomationHostStatusBadges entry={entry} />
{action ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onRecover(action, entry)}
>
{recoveryActionLabel(action)}
</Button>
<AutomationHostRecoverButton
action={action}
onRecover={(recovery) => onRecover(recovery, entry)}
/>
) : null}
</div>
{state.kind === 'rows' ? (
@@ -252,4 +252,14 @@ describe('Automation list row selection', () => {
expect(onRequestAction).toHaveBeenCalledTimes(1)
expect(onSelect).not.toHaveBeenCalled()
})
it('renders the host label in the Host column for local rows', () => {
renderLocalRows({ onSelect: vi.fn() })
expect(screen.getByText('Local')).toBeTruthy()
})
it('renders the host label in the Host column for external rows', () => {
renderExternalRows({ onSelect: vi.fn() })
expect(screen.getByText('Local')).toBeTruthy()
})
})
@@ -247,6 +247,9 @@ export function AutomationListLocalRows({
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
@@ -27,6 +27,7 @@ export function AutomationListTableHeader({
<span>
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
</span>
<span>{translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}</span>
<span>
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
</span>
@@ -152,3 +152,23 @@ describe('AutomationsListPanel unchecked hosts', () => {
expect(container.textContent).not.toContain('could not be checked')
})
})
describe('AutomationsListPanel flat table layout', () => {
it('renders the Host column in the table header', () => {
const row = makeAutomationListRow()
renderPanel([row], '')
expect(container.textContent).toContain('Host')
})
it('renders rows in a flat list without host section headers', () => {
const row1 = makeAutomationListRow({ hostLabel: 'Local Mac' })
const row2 = makeAutomationListRow({ hostLabel: 'Linux' })
renderPanel([row1, row2], '')
expect(container.textContent).toContain('Local Mac')
expect(container.textContent).toContain('Linux')
// No section headers grouping by host
expect(container.querySelector('[data-host-group]')).toBeNull()
})
})
@@ -19,10 +19,6 @@ import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import { clampAutomationListSearchQueryInput } from './automation-list-search'
import {
getAutomationListArrowNavigationTarget,
type AutomationListArrowKey
} from './automation-list-keyboard-navigation'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationPaneTab } from './automation-page-state'
import { AutomationListSearchField } from './AutomationListSearchField'
@@ -31,8 +27,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent
import type { ExternalAutomationScope } from './external-automation-scope-client'
import { AutomationListLocalRows } from './AutomationListLocalRows'
import { AutomationListExternalRows } from './AutomationListExternalRows'
import { AutomationListHostGroups } from './AutomationListHostGroups'
import { filterAutomationHostGroups } from './automation-host-list-rows'
import { AutomationHostPicker } from './AutomationHostPicker'
import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice'
import { AutomationListEmptyView } from './AutomationListEmptyView'
@@ -42,6 +36,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery'
import { useAutomationListKeyboardNavigation } from './use-automation-list-keyboard-navigation'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
import { translate } from '@/i18n/i18n'
@@ -64,7 +59,7 @@ type AutomationsListPanelProps = {
onRecoverHost: (
action: AutomationHostRecoveryAction,
entry?: AutomationHostCatalogEntry | null
) => void
) => void | Promise<void>
filteredRows: readonly AutomationListRow[]
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
selectedRowKey: string | null
@@ -157,62 +152,18 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
} = props
const listRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<HTMLDivElement>(null)
const pendingKeyboardScrollRef = useRef(false)
const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows])
const visibleRowKeys = React.useMemo(() => new Set(rowKeys), [rowKeys])
const visibleItems = React.useMemo(
() => [
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
...filteredExternalAutomationEntries.map((entry) => ({ kind: 'external' as const, id: entry.key }))
],
[filteredExternalAutomationEntries, filteredRows]
)
useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef })
const handleSearchArrowNavigate = React.useCallback(
(key: AutomationListArrowKey) => {
const next = getAutomationListArrowNavigationTarget({
items: visibleItems,
selectedId: selectedRowKey,
selectedExternalKey,
key
})
if (!next) {
return
}
const alreadySelected =
next.kind === 'local'
? selectedExternalKey === null && selectedRowKey === next.id
: selectedExternalKey === next.id
if (alreadySelected) {
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
return
}
pendingKeyboardScrollRef.current = true
if (next.kind === 'local') {
selectExternalKey(null)
selectAutomationRow(next.id)
} else {
selectAutomationRow(null)
selectExternalKey(next.id)
setActivePaneTab('overview')
}
},
[
selectAutomationRow,
selectExternalKey,
selectedExternalKey,
selectedRowKey,
setActivePaneTab,
visibleItems
]
)
React.useEffect(() => {
if (!pendingKeyboardScrollRef.current) {
return
}
pendingKeyboardScrollRef.current = false
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
}, [selectedExternalKey, selectedRowKey])
const handleSearchArrowNavigate = useAutomationListKeyboardNavigation({
filteredRows,
filteredExternalAutomationEntries,
selectedRowKey,
selectedExternalKey,
selectAutomationRow,
selectExternalKey,
setActivePaneTab,
listRef
})
const emptyStateInput = {
resolution: hostCatalog.resolution,
...searchCounts,
@@ -236,8 +187,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
hostLabelById,
isActionEnabled,
onSelect: (rowKey: string) => {
selectExternalKey(null)
selectAutomationRow(rowKey)
selectExternalKey(null)
setActivePaneTab('overview')
onOpenDetail()
},
onRunNow: runNow,
@@ -251,9 +203,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-4 md:px-5"
data-contextual-tour-target="automations-list"
>
<div className="flex shrink-0 items-end justify-between gap-3 pb-4">
<div className="flex min-w-0 flex-1 items-end gap-2">
<div ref={pickerRef} className="w-48 shrink-0">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div ref={pickerRef} className="w-80 shrink-0">
<AutomationHostPicker
entries={hostCatalog.entries}
resolution={hostCatalog.resolution}
@@ -274,22 +226,22 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="outline"
size="icon-sm"
className="shrink-0"
aria-label={translate(
'auto.components.automations.AutomationsPage.19a6e30eae',
'auto.components.automations.AutomationsPage.refreshAutomations',
'Refresh automations'
)}
onClick={onRefresh}
onClick={() => onRefresh()}
disabled={isRefreshing}
className="shrink-0 border border-border bg-background shadow-none hover:bg-muted/50"
>
<RefreshCw className={cn('size-4', isRefreshing && 'animate-spin')} />
<RefreshCw className={cn('size-3.5', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.automations.AutomationsPage.19a6e30eae',
'auto.components.automations.AutomationsPage.refreshAutomations',
'Refresh automations'
)}
</TooltipContent>
@@ -326,10 +278,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
<div
ref={listRef}
className={cn(
'scrollbar-sleek min-h-0 flex-1 overflow-auto',
LIST_TABLE_CONTAINER_CLASS
)}
className={cn('scrollbar-sleek min-h-0 flex-1 overflow-auto', LIST_TABLE_CONTAINER_CLASS)}
>
{hasFilteredListItems ? (
<>
@@ -343,6 +292,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
<span>
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
</span>
<span>
{translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}
</span>
<span>
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
</span>
@@ -360,16 +312,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
</span>
</div>
<div className="divide-y divide-border/50">
{hostCatalog.rows.groups.length > 0 ? (
<AutomationListHostGroups
{...rowProps}
groups={filterAutomationHostGroups(hostCatalog.rows.groups, visibleRowKeys)}
searchActive={searchCounts.searchActive}
onRecover={onRecoverHost}
/>
) : (
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
)}
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
<AutomationListExternalRows
entries={filteredExternalAutomationEntries}
selectedExternalKey={selectedExternalKey}
@@ -11,10 +11,10 @@
*/
import { act } from 'react'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { Automation } from '../../../../shared/automations-types'
import { hostStableKey } from '../../../../shared/automation-owner-key'
import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity'
import type { AutomationListRow } from './automation-list-row-identity'
import {
api,
installAutomationsPageHarness,
@@ -51,6 +51,10 @@ const RUNTIME_SELF_KEY = hostStableKey({
authority: { kind: 'runtime', environmentId: RUNTIME_ID },
selector: { kind: 'self' }
})
const RUNTIME_SSH_KEY = hostStableKey({
authority: { kind: 'runtime', environmentId: RUNTIME_ID },
selector: { kind: 'ssh', targetId: SSH_TARGET_ID }
})
/**
* A project the runtime owns. It carries no `connectionId`, so a flat repo
@@ -72,8 +76,11 @@ function addRuntimeProject(): void {
repoId: RUNTIME_REPO_ID,
displayName: 'main',
path: '/repos/gpu-orca',
branch: 'main'
} as Worktree
branch: 'main',
hostId: `runtime:${RUNTIME_ID}`,
runtimeOwnerEnvironmentId: RUNTIME_ID,
projectHostSetupId: 'setup-2'
} as unknown as Worktree
const setup: ProjectHostSetup = {
id: 'setup-2',
projectId: 'project-2',
@@ -81,6 +88,7 @@ function addRuntimeProject(): void {
repoId: RUNTIME_REPO_ID,
path: '/repos/gpu-orca',
displayName: 'gpu-orca',
runtimeOwnerEnvironmentId: RUNTIME_ID,
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
@@ -118,6 +126,47 @@ function addSshProject(): void {
mocks.repoMap.set(SSH_REPO_ID, repo)
}
function addRuntimeSshProject(): void {
const repo = {
id: 'repo-runtime-ssh',
displayName: 'runtime-builder',
path: '/repos/runtime-builder',
badgeColor: '#333333',
addedAt: 1,
connectionId: SSH_TARGET_ID,
executionHostId: `runtime:${RUNTIME_ID}`
} as Repo
const worktree = {
id: 'workspace-runtime-ssh',
repoId: repo.id,
displayName: 'main',
path: repo.path,
branch: 'main',
hostId: `ssh:${SSH_TARGET_ID}`,
runtimeOwnerEnvironmentId: RUNTIME_ID
} as unknown as Worktree
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
mocks.state.worktreesByRepo = {
...(mocks.state.worktreesByRepo as Record<string, Worktree[]>),
[repo.id]: [worktree]
}
mocks.state.sshStateByEnvironment = new Map([
[
RUNTIME_ID,
{
connectionStates: new Map([[SSH_TARGET_ID, { status: 'connected' }]]),
targetLabels: new Map([[SSH_TARGET_ID, 'runtime builder']]),
targetGenerations: new Map([[SSH_TARGET_ID, 2]]),
removedTargetLabels: new Map(),
targetsHydrated: true
}
]
])
mocks.state.activeWorktreeId = worktree.id
mocks.repoMap.set(repo.id, repo)
mocks.worktreeMap.set(worktree.id, worktree)
}
/** The runtime answers a create; without this the RPC double returns an empty result. */
function runtimeCreateReturns(automation: Automation): void {
const previous = mocks.callRuntimeRpc.getMockImplementation()
@@ -163,11 +212,12 @@ describe('AutomationsPage create destination', () => {
scopedList([])
runtimeHost([], [])
runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' }))
addRuntimeProject()
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
await renderPage()
await settleHostQueries()
await openCreateDialogFor(REPO_ID, WORKSPACE_ID)
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
await save()
// The desktop is the client's own authority, never the selected host.
@@ -200,6 +250,7 @@ describe('AutomationsPage create destination', () => {
scopedList([])
runtimeHost([], [])
runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' }))
addRuntimeProject()
await renderPage()
await settleHostQueries()
@@ -217,12 +268,31 @@ describe('AutomationsPage create destination', () => {
mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY)
})
expect(mocks.editorDialog?.createDestination?.resolution.status).toBe('ready')
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
await save()
expect(runtimeCreateCalls()).toHaveLength(1)
expect(api.automations.create).not.toHaveBeenCalled()
})
it('prefills Runtime + SSH from the active qualified workspace', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
runtimeHost([], [])
addSshHost()
addRuntimeSshProject()
await renderPage()
await settleHostQueries()
await act(async () => {
mocks.listPanel?.openCreateDialog()
})
const resolution = mocks.editorDialog?.createDestination?.resolution
expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(RUNTIME_SSH_KEY)
expect(resolution?.status === 'ready' && resolution.entry.stableKey).not.toBe(SSH_HOST_KEY)
})
it('offers only the projects the chosen host actually has', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
@@ -243,6 +313,76 @@ describe('AutomationsPage create destination', () => {
expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID])
})
it('offers only workspaces verified on the chosen runtime', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
runtimeHost([], [])
addRuntimeProject()
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
const foreign = {
...bucket[0],
id: 'foreign-workspace',
displayName: 'foreign',
hostId: 'local',
runtimeOwnerEnvironmentId: undefined
} as Worktree
bucket.push(foreign)
await renderPage()
await settleHostQueries()
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
await settleHostQueries()
expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([
RUNTIME_WORKSPACE_ID
])
})
it('rejects an injected workspace from another host before runtime create', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
runtimeHost([], [])
addRuntimeProject()
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
const foreign = {
...bucket[0],
id: 'foreign-workspace',
displayName: 'foreign',
hostId: 'local',
runtimeOwnerEnvironmentId: undefined
} as Worktree
bucket.push(foreign)
await renderPage()
await settleHostQueries()
await openCreateDialogFor(RUNTIME_REPO_ID, foreign.id)
await save()
expect(runtimeCreateCalls()).toHaveLength(0)
expect(mocks.toastError).toHaveBeenCalledWith('Choose an available workspace before saving.')
})
it('does not offer or save remembered workspaces when the host cannot verify them', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
runtimeHost([], [])
addRuntimeProject()
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
mocks.state.fetchWorktrees = vi.fn(async () => false)
await renderPage()
await settleHostQueries()
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
await settleHostQueries()
expect(mocks.editorDialog?.worktrees).toEqual([])
await save()
expect(runtimeCreateCalls()).toHaveLength(0)
expect(mocks.editorDialog?.notice?.message).toContain("couldn't verify workspaces")
})
it('moves a stranded project to one the newly chosen host has', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
@@ -349,6 +489,40 @@ describe('AutomationsPage edit fencing', () => {
)
}
it('offers only workspaces verified on the edited automation’s runtime', async () => {
const automation = makeAutomation({
id: 'a-runtime',
projectId: RUNTIME_REPO_ID,
workspaceId: RUNTIME_WORKSPACE_ID,
workspaceMode: 'existing'
})
api.automations.list.mockResolvedValue([])
scopedList([])
runtimeHost([automation], [])
addRuntimeProject()
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
bucket.push({
...bucket[0],
id: 'foreign-workspace',
displayName: 'foreign',
hostId: 'local',
runtimeOwnerEnvironmentId: undefined
} as Worktree)
await renderPage()
await settleHostQueries()
await act(async () => {
void mocks.listPanel?.openEditDialog(listedRow(automation.id))
})
await settleHostQueries()
expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([RUNTIME_REPO_ID])
expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([
RUNTIME_WORKSPACE_ID
])
})
it('fences the save even when the record cannot be re-read', async () => {
const automation = makeAutomation({ id: 'a-1' })
api.automations.list.mockResolvedValue([])
@@ -363,21 +537,4 @@ describe('AutomationsPage edit fencing', () => {
expect(api.automations.update).toHaveBeenCalled()
expect(updatePreconditions()).not.toContain(undefined)
})
it('refuses the save when nothing names the record’s host', async () => {
const automation = makeAutomation({ id: 'a-1' })
// No host answered, so no owner was ever captured, and the record is gone
// from the one list the client can still address.
api.automations.listScoped.mockRejectedValue(new Error('offline'))
api.automations.list.mockResolvedValue([])
await renderPage()
await settleHostQueries()
// No host answered, so the page never listed a row for it; the pre-catalog
// projection is the only key the user could have clicked.
await editAndSave(unscopedAutomationListRows([automation])[0]!)
expect(api.automations.update).not.toHaveBeenCalled()
expect(mocks.editorDialog?.notice?.message).toBeTruthy()
})
})
@@ -17,6 +17,7 @@ import {
installAutomationsPageHarness,
mocks,
renderPage,
RUNTIME_ID,
runtimeHost,
scopedList,
SELF_PRECONDITION,
@@ -121,4 +122,71 @@ describe('AutomationsPage row actions under a colliding automation id', () => {
})
expect(runtimeAutomationCalls()).not.toContain('automation.delete')
})
it('routes a runtime orphan history, pause, and delete to its listing authority', async () => {
const desktop = makeAutomation({ id: 'a-1', name: 'Desktop nightly' })
const runtime = makeAutomation({ id: 'a-1', name: 'Runtime orphan' })
runtimeHost([], [])
scopedList([desktop])
api.automations.list.mockResolvedValue([desktop])
mocks.state.selectedAutomationId = 'a-1'
mocks.callRuntimeRpc.mockImplementation(async (_target: unknown, method: string) => {
if (method === 'automation.list') {
return {
automations: [runtime],
items: [
{
automationId: runtime.id,
selector: { kind: 'orphan', issue: 'missing_repo' }
}
],
orphanCount: 1
}
}
return method === 'automation.runs' ? { runs: [] } : {}
})
await renderPage()
await settleHostQueries()
const runtimeRow = mocks.listPanel?.filteredRows.find(
(candidate) => candidate.automation.name === 'Runtime orphan'
)
expect(runtimeRow).toBeDefined()
if (!runtimeRow) {
throw new Error('runtime orphan row missing')
}
expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'toggle')).toBe(true)
expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'delete')).toBe(true)
await act(async () => {
mocks.listPanel?.selectAutomationRow(runtimeRow?.key ?? '')
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(mocks.detailPane?.selected?.name).toBe('Runtime orphan')
await act(async () => {
mocks.listPanel?.toggleAutomation(runtimeRow)
})
await act(async () => {
mocks.listPanel?.requestDeleteAutomation(runtimeRow)
})
await act(async () => {
mocks.deleteDialog?.onConfirm()
})
const calls = mocks.callRuntimeRpc.mock.calls.filter((call) =>
['automation.runs', 'automation.update', 'automation.delete'].includes(String(call[1]))
)
expect(calls.map((call) => call[0])).toEqual(
expect.arrayContaining([
{ kind: 'environment', environmentId: RUNTIME_ID },
{ kind: 'environment', environmentId: RUNTIME_ID },
{ kind: 'environment', environmentId: RUNTIME_ID }
])
)
expect(calls.map((call) => call[1])).toEqual(
expect.arrayContaining(['automation.runs', 'automation.update', 'automation.delete'])
)
expect(api.automations.update).not.toHaveBeenCalled()
expect(api.automations.delete).not.toHaveBeenCalled()
})
})
@@ -99,6 +99,26 @@ describe('AutomationsPage save visibility', () => {
expect(rows(container, 'automation-row')).toEqual(['Sweep'])
})
it('finishes the save while an external-manager probe is still hanging', async () => {
desktopStoreHolds([])
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
api.automations.create.mockResolvedValue(CREATED)
const { container } = await renderPage()
await settleHostQueries()
desktopStoreHolds([CREATED])
// An unreachable host's probe never answers. The save must not wait on it:
// waiting is what left the create dialog open over an emptied form.
api.automations.listExternalManagerForOwner.mockImplementation(
() => new Promise(() => undefined)
)
await createSweep()
expect(mocks.editorDialog?.open).toBe(false)
expect(rows(container, 'automation-row')).toEqual(['Sweep'])
})
it('lists it after the page is reopened', async () => {
desktopStoreHolds([])
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
@@ -54,15 +54,13 @@ describe('AutomationsPage list rendering', () => {
expect(rows(container, 'automation-row')).toEqual(['Nightly', 'Weekly'])
})
it('falls back to the unscoped list until a host has answered', async () => {
it('does not expose unscoped bootstrap rows when no host has answered', async () => {
api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })])
api.automations.listScoped.mockRejectedValue(new Error('offline'))
const { container } = await renderPage()
// Why: a host that never answered is not an empty host, and the rows the
// page already had are better than none. Step 9 removes this arm.
expect(rows(container, 'automation-row')).toEqual(['Unscoped'])
expect(rows(container, 'automation-row')).toEqual([])
})
it('renders the empty state when the host has no automations', async () => {
@@ -111,7 +109,7 @@ describe('AutomationsPage list rendering', () => {
const entry = mocks.listPanel?.hostCatalog.entries[0]
api.automations.listScoped.mockClear()
await act(async () => {
mocks.listPanel?.hostCatalog.recover('retry', entry)
await mocks.listPanel?.hostCatalog.recover('retry', entry)
})
// Retry re-queries that host rather than reloading the page.
@@ -166,6 +164,40 @@ describe('AutomationsPage list rendering', () => {
expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key)
})
it('finishes an external save while a manager probe is still hanging', async () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
async ({ provider }: { provider: string }) =>
provider === 'hermes'
? { manager: makeExternalManager(), error: null, updatedAt: 1 }
: { manager: null, error: null, updatedAt: 1 }
)
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
if (!entry) {
throw new Error('no external entry to edit')
}
// The re-read this save triggers never answers. It must still close its
// dialog and report the result, exactly as the Orca save does: the managers
// catch up when the probe lands.
api.automations.listExternalManagerForOwner.mockImplementation(
() => new Promise(() => undefined)
)
await act(async () => {
mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope)
})
await act(async () => {
await mocks.editorDialog?.onSave()
})
expect(api.automations.updateExternalForOwner).toHaveBeenCalled()
expect(mocks.editorDialog?.open).toBe(false)
expect(mocks.toastSuccess).toHaveBeenCalled()
})
it('routes each authority’s action to its own host when both report hermes:local', async () => {
api.automations.list.mockResolvedValue([])
api.automations.listExternalManagerForOwner.mockImplementation(
@@ -302,6 +334,32 @@ describe('AutomationsPage host filter', () => {
})
describe('AutomationsPage run navigation', () => {
it('selects the exact Runtime + SSH storage bucket from workspace provenance', async () => {
const catalogRef = {
authority: { kind: 'runtime' as const, environmentId: 'gpu' },
selector: { kind: 'ssh' as const, targetId: 'builder' }
}
mocks.state.pendingAutomationRunNavigation = {
automationId: 'a-1',
runId: null,
hostId: 'ssh:builder',
authority: catalogRef.authority,
catalogRef
}
mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation
mocks.callRuntimeRpc.mockResolvedValue({ automations: [] })
await renderPage()
expect(mocks.setAutomationHostFilter).toHaveBeenCalledWith({ kind: 'host', host: catalogRef })
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'gpu' },
'automation.list',
undefined,
{ timeoutMs: 15_000 }
)
})
it('does not leave a run navigation pending when the history read fails', async () => {
api.automations.list.mockResolvedValue([
makeAutomation({ id: 'a-1' }),
@@ -569,23 +627,14 @@ describe('AutomationsPage owner conflicts', () => {
expect(mocks.editorDialog?.isEditing).toBe(true)
})
it('keeps the unfenced call for a row no host read has answered for', async () => {
// The host never answered, so no row carries an owner and none may be
// invented; the request goes out unfenced rather than against a guess.
it('offers no unfenced action when no host read has answered', async () => {
const automation = makeAutomation({ id: 'a-1', enabled: true })
api.automations.list.mockResolvedValue([automation])
api.automations.listScoped.mockRejectedValue(new Error('offline'))
await renderPage()
await act(async () => {
mocks.listPanel?.toggleAutomation(listedRow(automation.id))
})
// Why: Step 9 removes this legacy arm once every row carries a captured owner.
expect(api.automations.update).toHaveBeenCalledWith({
id: 'a-1',
updates: { enabled: false }
})
expect(mocks.listPanel?.filteredRows).toEqual([])
expect(api.automations.update).not.toHaveBeenCalled()
})
// A mixed-host folder workspace is the path that reaches this without a host
@@ -622,15 +671,4 @@ describe('AutomationsPage owner conflicts', () => {
expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'history')).toBe(true)
expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'run-now')).toBe(false)
})
it('greys out the actions on a row no owner could be captured for', async () => {
const automation = makeAutomation({ id: 'a-1' })
api.automations.list.mockResolvedValue([automation])
api.automations.listScoped.mockRejectedValue(new Error('offline'))
await renderPage()
// Uncaptured is not blocked: the legacy arm still works, so nothing is greyed.
expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true)
})
})
@@ -18,10 +18,6 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import {
AUTOMATION_OWNER_CONFLICT_CODES,
automationOwnerConflictMessage
} from '../../../../shared/automation-owner-conflict'
import {
getLocalExecutionHostLabel,
getRepoExecutionHostId,
@@ -61,7 +57,10 @@ import {
} from './automation-setup-decision'
import type { AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import {
buildAutomationRunContextForDestination,
buildAutomationRunContextForRepo
} from './automation-run-context'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
@@ -72,16 +71,43 @@ import {
} from './use-selected-automation-run-history'
import {
deleteAutomationForTarget,
type AutomationHostTarget,
getAutomationHostTargetFromKey,
getAutomationHostTargetKey,
getAutomationTargetFromAuthority,
getAutomationTargetFromHostId,
listAutomationsForTarget,
runAutomationNowForTarget,
updateAutomationForTarget
listAutomationsForTarget
} from './automation-host-client'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
type PendingAutomationNavigation = NonNullable<
ReturnType<typeof useAppStore.getState>['pendingAutomationRunNavigation']
>
function automationNavigationTarget(navigation: PendingAutomationNavigation): AutomationHostTarget {
const authority = navigation.catalogRef?.authority ?? navigation.authority
return authority
? getAutomationTargetFromAuthority(authority)
: getAutomationTargetFromHostId(navigation.hostId)
}
function automationNavigationCatalogRef(
navigation: PendingAutomationNavigation
): StableAutomationCatalogRef {
if (navigation.catalogRef) {
return navigation.catalogRef
}
const host = parseExecutionHostId(navigation.hostId)
const authority =
navigation.authority ??
(host?.kind === 'runtime'
? { kind: 'runtime' as const, environmentId: host.environmentId }
: { kind: 'desktop' as const })
return {
authority,
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
}
}
import {
AUTOMATION_DEFAULT_TIME,
buildDraftPrecheck,
@@ -101,7 +127,6 @@ import { useAutomationListSearch } from './use-automation-list-search'
import {
automationRepoForRow,
automationWorktreeForRow,
unscopedAutomationListRows,
type AutomationListRow
} from './automation-list-row-identity'
import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs'
@@ -112,6 +137,7 @@ import type {
AutomationAuthorityRef,
StableAutomationCatalogRef
} from '../../../../shared/automation-owner-ref'
import { hostStableKey } from '../../../../shared/automation-owner-key'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import {
capturedAutomationOwner,
@@ -129,11 +155,14 @@ import {
resolveExternalAutomationScopeGate
} from './external-automation-scope-gating'
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
import { automationRuntimePairingRevision } from './automation-authority-identity'
import {
automationRuntimePairingRevision,
groupReposByAutomationAuthority
} from './automation-authority-identity'
import { automationCreateHostStableKey } from './automation-create-destination'
automationCreateEligibleProjects,
automationCreateEligibleWorktrees,
automationCreateHostStableKey,
automationCreateWorkspaceRefreshHostId,
type AutomationCreateDestination
} from './automation-create-destination'
import { useAutomationCreateDestination } from './use-automation-create-destination'
import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference'
import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft'
@@ -252,6 +281,9 @@ export default function AutomationsPage(): React.JSX.Element {
// Held beside the id: a save fences on the row the user opened, and under All
// hosts that id alone names two rows.
const [editingRowKey, setEditingRowKey] = useState<string | null>(null)
const [editingDestination, setEditingDestination] = useState<AutomationCreateDestination | null>(
null
)
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState<AutomationPaneTab>('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState<string | null>(
@@ -313,6 +345,7 @@ export default function AutomationsPage(): React.JSX.Element {
const setupDecisionPolicyDefaultRef = useRef<AutomationDraft['setupDecision']>(undefined)
const setupDecisionDefaultSignatureRef = useRef<string | null>(null)
const setupDecisionTouchedRef = useRef(false)
const createDestinationSelectionChangedRef = useRef(false)
const automationHookCheckPromisesRef = useRef<
Map<string, Promise<{ hooks: OrcaHooks | null; ok: boolean }>>
>(new Map())
@@ -340,6 +373,19 @@ export default function AutomationsPage(): React.JSX.Element {
})
const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys })
const automationHostResolution = hostCatalog.resolution
const selectAutomationHost = hostCatalog.selectHost
useEffect(() => {
if (!pendingAutomationRunNavigation) {
return
}
const catalogRef = automationNavigationCatalogRef(pendingAutomationRunNavigation)
const current = automationHostResolution.effective
if (current.kind === 'host' && hostStableKey(current.host) === hostStableKey(catalogRef)) {
return
}
selectAutomationHost({ kind: 'host', host: catalogRef })
}, [automationHostResolution.effective, pendingAutomationRunNavigation, selectAutomationHost])
// Probing follows the selection, not the catalog: a Local selection must not
// reach out to every SSH host the user happens to have registered.
const externalScopeEntries = useMemo(
@@ -353,12 +399,9 @@ export default function AutomationsPage(): React.JSX.Element {
// Held apart from the view object, which is a fresh literal each render and
// would refetch the runs table on every render of this page.
const fetchScopedExternalRuns = scopedExternal.fetchRuns
// Why the cache wins once it has answered: only its rows carry the host each
// record came from, which is what the picker, the groups, and every fenced
// action key on. Until then the unscoped list is all the page has, and it has
// no host to qualify its rows with.
const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations])
const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows
// Only cache rows carry their listing authority; bootstrap rows stay hidden
// because displaying them would expose unfenced actions.
const visibleRows = hostCatalog.rows.rows
// Picking a row records both: the key names the host's copy, the id is what
// the rest of the page (and other pages) still address the record by.
const selectAutomationRow = useCallback(
@@ -568,7 +611,7 @@ export default function AutomationsPage(): React.JSX.Element {
})
)
}, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap])
const worktrees = useMemo(
const repoWorktrees = useMemo(
() => worktreesByRepo[draft.projectId] ?? [],
[draft.projectId, worktreesByRepo]
)
@@ -592,14 +635,12 @@ export default function AutomationsPage(): React.JSX.Element {
pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId)
}
}, [hostCatalog.resolution.entry, runtimeEnvironments])
// Scoped per owning authority: a repo ID is unique only inside one, so the
// flat map cannot say whether *this* authority holds the project.
const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos])
const activeWorkspaceHostStableKey = useMemo(() => {
const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
return worktree
? automationCreateHostStableKey(
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId))
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)),
worktree.runtimeOwnerEnvironmentId
)
: null
}, [activeWorktreeId, repoMap, worktreeMap])
@@ -610,22 +651,123 @@ export default function AutomationsPage(): React.JSX.Element {
// Non-null only for a concrete host filter, which constrains the destination.
filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null,
activeWorkspaceStableKey: activeWorkspaceHostStableKey,
repoTables,
projects: repos
projects: repos,
projectHostSetups
})
const editorProjects = createDestination.control.projects
const createDestinationControl = createDestination.control
const chooseCreateDestination = createDestinationControl.onSelect
const selectCreateDestination = useCallback(
(stableKey: string): void => {
createDestinationSelectionChangedRef.current = true
chooseCreateDestination(stableKey)
},
[chooseCreateDestination]
)
const editorCreateDestination = useMemo(
() => ({ ...createDestinationControl, onSelect: selectCreateDestination }),
[createDestinationControl, selectCreateDestination]
)
const isOrcaCreate = createOpen && editingAutomationId === null && createTarget === 'orca'
const editorDestination =
isOrcaCreate && createDestination.control.resolution.status === 'ready'
? createDestination.control.resolution
: editingDestination
const editorProjects = editorDestination
? automationCreateEligibleProjects(editorDestination, repos, projectHostSetups)
: createDestination.control.projects
const isOrcaEditor = createOpen && createTarget === 'orca'
const [verifiedWorkspaceScopeKey, setVerifiedWorkspaceScopeKey] = useState<string | null>(null)
const createWorkspaceScope = useMemo(() => {
if (!isOrcaEditor || !editorDestination || !draft.projectId) {
return null
}
return {
key: `${editorDestination.entry.stableKey}\0${draft.projectId}`,
projectId: draft.projectId,
refreshHostId: automationCreateWorkspaceRefreshHostId(editorDestination),
destination: editorDestination
}
}, [draft.projectId, editorDestination, isOrcaEditor])
useEffect(() => {
if (!createWorkspaceScope) {
setVerifiedWorkspaceScopeKey(null)
return
}
let cancelled = false
setVerifiedWorkspaceScopeKey(null)
setEditorNotice(null)
void fetchWorktrees(createWorkspaceScope.projectId, {
executionHostId: createWorkspaceScope.refreshHostId,
requireAuthoritative: true
}).then((authoritative) => {
if (cancelled) {
return
}
if (authoritative) {
setVerifiedWorkspaceScopeKey(createWorkspaceScope.key)
return
}
setEditorNotice({
message: translate(
'auto.components.automations.createDestination.workspacesUnverified',
"We couldn't verify workspaces on {host}. Check the connection and try again."
).replace('{host}', createWorkspaceScope.destination.entry.label),
recovery: null,
severity: 'owner'
})
})
return () => {
cancelled = true
}
}, [createWorkspaceScope, fetchWorktrees])
const worktrees = useMemo(() => {
if (!isOrcaEditor) {
return repoWorktrees
}
if (!createWorkspaceScope || verifiedWorkspaceScopeKey !== createWorkspaceScope.key) {
return []
}
const repo = editorProjects.find((candidate) => candidate.id === draft.projectId)
return automationCreateEligibleWorktrees(
createWorkspaceScope.destination,
repo,
repoWorktrees,
projectHostSetups
)
}, [
createWorkspaceScope,
draft.projectId,
editorProjects,
isOrcaEditor,
projectHostSetups,
repoWorktrees,
verifiedWorkspaceScopeKey
])
// A destination change can strand the chosen project on another host; leaving it
// selected only defers the same refusal to submit.
useEffect(() => {
if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') {
if (!createOpen || createTarget !== 'orca') {
createDestinationSelectionChangedRef.current = false
return
}
setDraft((current) =>
!current.projectId || editorProjects.some((project) => project.id === current.projectId)
? current
: { ...current, projectId: '', workspaceId: '', baseBranch: '' }
)
}, [createOpen, createTarget, editingAutomationId, editorProjects])
const destinationChanged = createDestinationSelectionChangedRef.current
createDestinationSelectionChangedRef.current = false
setDraft((current) => {
if (
!current.projectId ||
editorProjects.some((project) => project.id === current.projectId) ||
!destinationChanged
) {
return current
}
return {
...current,
projectId: editorProjects[0]?.id ?? '',
workspaceId: '',
baseBranch: ''
}
})
}, [createOpen, createTarget, editorProjects])
// The row's own host, from its captured owner. A page-level target cannot
// speak for a list spanning authorities, and the legacy arm below it is only
// ever reached by rows the desktop's unscoped list produced.
@@ -640,8 +782,8 @@ export default function AutomationsPage(): React.JSX.Element {
[automationHostTarget, capturedAutomationOwners]
)
const automationDispatchContext = useMemo(
() => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }),
[automationAuthority, capturedAutomationOwners]
() => ({ capturedOwners: capturedAutomationOwners }),
[capturedAutomationOwners]
)
const notifyAuthorityChange = hostCatalog.notifyAuthorityChange
// The list renders the per-host cache, so a write is only visible once that
@@ -700,9 +842,7 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
const pending = pendingAutomationRunNavigation
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pending.hostId)
)
const pendingTargetKey = getAutomationHostTargetKey(automationNavigationTarget(pending))
if (automationHostTargetKey !== pendingTargetKey) {
return
}
@@ -909,7 +1049,7 @@ export default function AutomationsPage(): React.JSX.Element {
? activeRepo
: null
const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null
const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : []
const fallbackWorktrees = fallbackRepo && fallbackRepo.id === draft.projectId ? worktrees : []
// Why: automation-created workspaces can be active; new automations should start from
// the repo's stable main worktree unless the user explicitly chooses otherwise.
const targetWorktree =
@@ -920,7 +1060,7 @@ export default function AutomationsPage(): React.JSX.Element {
projectId: targetProjectId,
workspaceId: targetWorktree?.id ?? ''
}
}, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo])
}, [activeWorktreeId, draft.projectId, editorProjects, repoMap, worktreeMap, worktrees])
const reloadExternalManagers = scopedExternal.reload
@@ -931,20 +1071,20 @@ export default function AutomationsPage(): React.JSX.Element {
// per-host reads have not answered for, and the client's own authority is
// the only one it can address without guessing which server is meant.
const automationHostTarget: AutomationHostTarget = pendingNavigation
? getAutomationTargetFromHostId(pendingNavigation.hostId)
? automationNavigationTarget(pendingNavigation)
: { kind: 'local' }
const authorityKey = automationAuthorityCatalogKey(
automationHostTarget.kind === 'environment'
? { kind: 'runtime', environmentId: automationHostTarget.environmentId }
: { kind: 'desktop' }
)
// Detached rather than awaited: a manager probe reaches each host over SSH
// and can stall indefinitely, and nothing that waits on this refresh — the
// save that closes the create dialog above all — may hang behind it. It
// publishes its own managers when it lands.
void reloadExternalManagers().catch(() => undefined)
try {
const [nextAutomations] = await Promise.all([
listAutomationsForTarget(automationHostTarget),
// Managers are per host and failures are per provider, so this settles
// on its own and must not be able to fail the automation list with it.
reloadExternalManagers().catch(() => undefined)
])
const nextAutomations = await listAutomationsForTarget(automationHostTarget)
// Selection and run history are deliberately not written here: this call
// addressed one authority, and the selected row may belong to another.
setAutomations(nextAutomations)
@@ -964,7 +1104,7 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId)
automationNavigationTarget(pendingAutomationRunNavigation)
)
if (automationHostTargetKey !== pendingTargetKey) {
void refresh()
@@ -1003,7 +1143,6 @@ export default function AutomationsPage(): React.JSX.Element {
useSelectedAutomationRunHistory({
selected: selectedRow,
context: automationDispatchContext,
legacyTarget: automationHostTargetFor,
navigation: pendingAutomationRunNavigation,
reloadToken: runHistoryReloadToken,
onSettled: setSelectedAutomationRuns
@@ -1049,12 +1188,11 @@ export default function AutomationsPage(): React.JSX.Element {
if (!draft.projectId) {
return
}
const available = worktreesByRepo[draft.projectId] ?? []
const defaultWorktree = getDefaultWorktree(available)
const defaultWorktree = getDefaultWorktree(worktrees)
if (!draft.workspaceId && defaultWorktree) {
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
}
}, [draft.projectId, draft.workspaceId, worktreesByRepo])
}, [draft.projectId, draft.workspaceId, worktrees])
useEffect(() => {
if (
@@ -1132,6 +1270,8 @@ export default function AutomationsPage(): React.JSX.Element {
editRequestRef.current += 1
const target = getDefaultTarget()
setEditingAutomationId(null)
setEditingRowKey(null)
setEditingDestination(null)
setEditingExternalTarget(null)
setCreateTarget('orca')
const baseDraft: AutomationDraft = {
@@ -1176,14 +1316,19 @@ export default function AutomationsPage(): React.JSX.Element {
setEditingExternalTarget(null)
setCreateTarget('orca')
const automationId = row.automation.id
const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner
const rowCatalogRef = row.catalogRef
const entry = rowCatalogRef
? hostCatalog.entries.find(
(candidate) => candidate.stableKey === hostStableKey(rowCatalogRef)
)
: null
// Why: hydrate inside the row's own captured scope, not from whatever the
// ambient authority happens to hold under the same automation ID.
const reread = await dispatchAutomationReread(
automationDispatchContext,
{ rowKey: row.key, automationId },
async () =>
(await window.api.automations.list()).find((entry) => entry.id === automationId) ?? null
)
const reread = await dispatchAutomationReread(automationDispatchContext, {
rowKey: row.key,
automationId
})
if (!reread.ok && reread.notice.severity === 'owner') {
setOwnerActionNotice(reread.notice)
return
@@ -1195,6 +1340,11 @@ export default function AutomationsPage(): React.JSX.Element {
}
setEditingAutomationId(latest.id)
setEditingRowKey(row.key)
setEditingDestination(
owner && entry
? { authority: owner.authority, destination: { selector: owner.selector }, entry }
: null
)
const nextDraft = buildAutomationEditDraft(latest)
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
@@ -1225,6 +1375,7 @@ export default function AutomationsPage(): React.JSX.Element {
})
setEditingAutomationId(null)
setEditingRowKey(null)
setEditingDestination(null)
setEditingExternalTarget({ manager, job, scope })
setCreateTarget('hermes')
setDraft(nextDraft)
@@ -1234,6 +1385,10 @@ export default function AutomationsPage(): React.JSX.Element {
const handleProjectChange = useCallback(
(projectId: string): void => {
if (createOpen && editingAutomationId === null && createTarget === 'orca') {
setDraft((current) => ({ ...current, projectId, workspaceId: '', baseBranch: '' }))
return
}
const currentWorktrees = worktreesByRepo[projectId] ?? []
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
setDraft((current) => ({
@@ -1258,7 +1413,7 @@ export default function AutomationsPage(): React.JSX.Element {
)
})
},
[fetchWorktrees, worktreesByRepo]
[createOpen, createTarget, editingAutomationId, fetchWorktrees, worktreesByRepo]
)
const saveAutomation = async (): Promise<void> => {
@@ -1315,10 +1470,79 @@ export default function AutomationsPage(): React.JSX.Element {
}
setIsSaving(true)
try {
let checkedCreateDestination: AutomationCreateDestination | null = null
let checkedEditorDestination = editingDestination
let selectedEditorWorktree: Worktree | null = null
let editorProjectMatchesDestination = checkedEditorDestination === null
if (editingAutomationId === null && !isHermesSave) {
const checked = createDestination.check(draft.projectId)
if (!checked.ok) {
setEditorNotice(checked.notice)
return
}
checkedCreateDestination = checked.destination
checkedEditorDestination = checked.destination
}
if (!isHermesSave && createTarget === 'orca' && !checkedEditorDestination) {
setEditorNotice({
message: translate(
'auto.components.automations.ownerAction.failed',
'That automation action did not complete.'
),
recovery: 'retry',
severity: 'owner'
})
return
}
if (!isHermesSave && createTarget === 'orca' && checkedEditorDestination) {
const currentState = useAppStore.getState()
const repo = automationCreateEligibleProjects(
checkedEditorDestination,
currentState.repos,
currentState.projectHostSetups
).find((candidate) => candidate.id === draft.projectId)
editorProjectMatchesDestination = repo !== undefined
if (draft.workspaceMode === 'existing' && repo) {
const authoritative = await fetchWorktrees(draft.projectId, {
executionHostId: automationCreateWorkspaceRefreshHostId(checkedEditorDestination),
requireAuthoritative: true
})
if (!authoritative) {
setEditorNotice({
message: translate(
'auto.components.automations.createDestination.workspacesUnverified',
"We couldn't verify workspaces on {host}. Check the connection and try again."
).replace('{host}', checkedEditorDestination.entry.label),
recovery: null,
severity: 'owner'
})
return
}
setVerifiedWorkspaceScopeKey(
`${checkedEditorDestination.entry.stableKey}\0${draft.projectId}`
)
const refreshedState = useAppStore.getState()
selectedEditorWorktree =
automationCreateEligibleWorktrees(
checkedEditorDestination,
repo,
refreshedState.worktreesByRepo[draft.projectId] ?? [],
refreshedState.projectHostSetups
).find((worktree) => worktree.id === draft.workspaceId) ?? null
}
}
const selectedWorkspaceExists =
draft.workspaceMode !== 'existing' ||
worktrees.some((worktree) => worktree.id === draft.workspaceId)
editorProjectMatchesDestination &&
(draft.workspaceMode !== 'existing' ||
(checkedEditorDestination
? selectedEditorWorktree !== null
: worktrees.some((worktree) => worktree.id === draft.workspaceId)))
if (!selectedWorkspaceExists) {
if (checkedEditorDestination) {
setDraft((current) =>
current.workspaceId === draft.workspaceId ? { ...current, workspaceId: '' } : current
)
}
toast.error(
translate(
'auto.components.automations.AutomationsPage.32534e7c9c',
@@ -1370,6 +1594,10 @@ export default function AutomationsPage(): React.JSX.Element {
if (!editingExternalTarget) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
// The mutation invalidated this host's managers on the authority, so the
// reload `refresh()` starts re-reads them. Not awaited, for the same
// reason it is not awaited there: an unreachable host's probe would hold
// this dialog open over an emptied form.
await refresh()
setCreateOpen(false)
setEditingExternalTarget(null)
@@ -1409,11 +1637,18 @@ export default function AutomationsPage(): React.JSX.Element {
? Math.max(0, rawMissedRunGraceMinutes)
: 720
const precheck = buildDraftPrecheck(draft)
const runContext = buildAutomationRunContextForRepo({
repoId: draft.projectId,
repos,
projectHostSetups
})
const runContext = checkedEditorDestination
? buildAutomationRunContextForDestination({
repoId: draft.projectId,
destination: checkedEditorDestination,
projectHostSetups,
worktree: selectedEditorWorktree
})
: buildAutomationRunContextForRepo({
repoId: draft.projectId,
repos,
projectHostSetups
})
let setupDecision = resolveAutomationSetupDecisionForSave({
createTarget,
workspaceMode: draft.workspaceMode,
@@ -1451,14 +1686,10 @@ export default function AutomationsPage(): React.JSX.Element {
if (editingAutomationId) {
// Why: the conflict check reads the row's own scope, and the legacy read
// under it addresses the desktop rather than whichever host was ambient.
const reread = await dispatchAutomationReread(
automationDispatchContext,
{ rowKey: editingRowKey ?? '', automationId: editingAutomationId },
async () =>
(await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find(
(automation) => automation.id === editingAutomationId
) ?? null
)
const reread = await dispatchAutomationReread(automationDispatchContext, {
rowKey: editingRowKey ?? '',
automationId: editingAutomationId
})
if (!reread.ok && reread.notice.severity === 'owner') {
setEditorNotice(reread.notice)
return
@@ -1485,41 +1716,33 @@ export default function AutomationsPage(): React.JSX.Element {
updates.rrule = rrule
updates.dtstart = now
}
const editSource = currentAutomation
const saved = editingAutomationId
? await dispatchAutomationUpdate(
automationDispatchContext,
{ rowKey: editingRowKey ?? '', automationId: editingAutomationId },
updates,
() => {
// Nothing names a host: no captured owner and no record to read one
// from, so the save is refused rather than sent unfenced.
if (!editSource) {
throw new Error(
automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
)
}
return updateAutomationForTarget(editSource, updates, automationHostTarget)
},
'save'
)
: await createDraftAutomation({
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
runContext,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
setupDecision,
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
timezone,
rrule,
dtstart: now,
missedRunGraceMinutes
})
: await createDraftAutomation(
{
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
runContext,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
setupDecision,
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
timezone,
rrule,
dtstart: now,
missedRunGraceMinutes
},
checkedCreateDestination!
)
if (!saved.ok) {
setEditorNotice(saved.notice)
return
@@ -1576,21 +1799,14 @@ export default function AutomationsPage(): React.JSX.Element {
/** Creation states its destination and re-checks it at submit; it never infers one. */
const createDraftAutomation = async (
input: AutomationCreateInput
input: AutomationCreateInput,
destination: AutomationCreateDestination
): Promise<AutomationDispatchResult<Automation>> => {
const checked = createDestination.check(input.projectId)
if (!checked.ok) {
return { ok: false, notice: checked.notice }
}
const result = toDispatchResult(
await createAutomationAtDestination(
checked.destination.authority,
input,
checked.destination.destination
)
await createAutomationAtDestination(destination.authority, input, destination.destination)
)
if (result.ok) {
invalidateWrittenHost(checked.destination.entry.stableRef, 'definition')
invalidateWrittenHost(destination.entry.stableRef, 'definition')
}
return result
}
@@ -1600,13 +1816,7 @@ export default function AutomationsPage(): React.JSX.Element {
const result = await dispatchAutomationUpdate(
automationDispatchContext,
{ rowKey: row.key, automationId: automation.id },
{ enabled: !automation.enabled },
() =>
updateAutomationForTarget(
automation,
{ enabled: !automation.enabled },
automationHostTargetFor(row)
)
{ enabled: !automation.enabled }
)
setOwnerActionNotice(result.ok ? null : result.notice)
if (result.ok) {
@@ -1617,11 +1827,10 @@ export default function AutomationsPage(): React.JSX.Element {
const deleteAutomation = async (row: AutomationListRow): Promise<void> => {
const automation = row.automation
const result = await dispatchAutomationDelete(
automationDispatchContext,
{ rowKey: row.key, automationId: automation.id },
() => deleteAutomationForTarget(automation, automationHostTargetFor(row))
)
const result = await dispatchAutomationDelete(automationDispatchContext, {
rowKey: row.key,
automationId: automation.id
})
setOwnerActionNotice(result.ok ? null : result.notice)
if (result.ok) {
if (selectedRowKey === row.key) {
@@ -1681,11 +1890,10 @@ export default function AutomationsPage(): React.JSX.Element {
toast.error(availability.message)
return
}
const result = await dispatchAutomationRunNow(
automationDispatchContext,
{ rowKey: row.key, automationId: automation.id },
() => runAutomationNowForTarget(automation, rowHostTarget)
)
const result = await dispatchAutomationRunNow(automationDispatchContext, {
rowKey: row.key,
automationId: automation.id
})
setOwnerActionNotice(result.ok ? null : result.notice)
if (!result.ok) {
return
@@ -1711,11 +1919,10 @@ export default function AutomationsPage(): React.JSX.Element {
rerunRunIdsInFlightRef.current.add(runId)
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
try {
const result = await dispatchAutomationRunNow(
automationDispatchContext,
{ rowKey: row.key, automationId: row.automation.id },
() => runAutomationNowForTarget(row.automation, automationHostTargetFor(row))
)
const result = await dispatchAutomationRunNow(automationDispatchContext, {
rowKey: row.key,
automationId: row.automation.id
})
setOwnerActionNotice(result.ok ? null : result.notice)
if (!result.ok) {
await refresh()
@@ -2030,7 +2237,7 @@ export default function AutomationsPage(): React.JSX.Element {
worktrees={worktrees}
settings={settings}
draft={draft}
createDestination={createDestination.control}
createDestination={editorCreateDestination}
notice={editorNotice}
onNoticeRecover={() => {
setEditorNotice(null)
@@ -2097,9 +2304,10 @@ export default function AutomationsPage(): React.JSX.Element {
recoverSelectedRuns={(action) => {
// Reconnect/Update server act on the selected host; the re-ask is what
// brings this automation's history back either way.
hostCatalog.recover(action)
const recovered = hostCatalog.recover(action)
setSelectedAutomationRuns((current) => ({ ...current, notice: null }))
setRunHistoryReloadToken((token) => token + 1)
return recovered
}}
activePaneTab={activePaneTab}
relativeNow={relativeNow}
@@ -2165,10 +2373,11 @@ export default function AutomationsPage(): React.JSX.Element {
externalManagersUncheckedNotice={externalManagersUncheckedNotice}
onSelectHost={hostCatalog.selectHost}
onRecoverHost={(action, entry) => {
hostCatalog.recover(action, entry)
const recovered = hostCatalog.recover(action, entry)
if (action === 'retry') {
void refresh()
}
return recovered
}}
isActionEnabled={isAutomationRowActionEnabled}
filteredRows={filteredRows}
@@ -12,6 +12,7 @@ function TableRowSkeleton({
nameWidthClass,
scheduleWidthClass,
projectWidthClass,
hostWidthClass,
nextWidthClass,
lastRunWidthClass,
statusWidthClass
@@ -19,6 +20,7 @@ function TableRowSkeleton({
nameWidthClass: string
scheduleWidthClass: string
projectWidthClass: string
hostWidthClass: string
nextWidthClass: string
lastRunWidthClass: string
statusWidthClass: string
@@ -28,6 +30,7 @@ function TableRowSkeleton({
<SkeletonBar className={cn('h-3.5', nameWidthClass)} />
<SkeletonBar className={cn('h-3.5', scheduleWidthClass)} />
<SkeletonBar className={cn('h-3.5', projectWidthClass)} />
<SkeletonBar className={cn('h-3.5', hostWidthClass)} />
<SkeletonBar className={cn('h-3.5', nextWidthClass)} />
<SkeletonBar className={cn('h-3.5', lastRunWidthClass)} />
<SkeletonBar className={cn('h-3.5', statusWidthClass)} />
@@ -43,6 +46,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-28',
scheduleWidthClass: 'w-36',
projectWidthClass: 'w-28',
hostWidthClass: 'w-20',
nextWidthClass: 'w-24',
lastRunWidthClass: 'w-20',
statusWidthClass: 'w-16'
@@ -52,6 +56,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-36',
scheduleWidthClass: 'w-28',
projectWidthClass: 'w-32',
hostWidthClass: 'w-24',
nextWidthClass: 'w-20',
lastRunWidthClass: 'w-24',
statusWidthClass: 'w-14'
@@ -61,6 +66,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-24',
scheduleWidthClass: 'w-40',
projectWidthClass: 'w-24',
hostWidthClass: 'w-20',
nextWidthClass: 'w-28',
lastRunWidthClass: 'w-16',
statusWidthClass: 'w-16'
@@ -70,6 +76,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-32',
scheduleWidthClass: 'w-32',
projectWidthClass: 'w-36',
hostWidthClass: 'w-24',
nextWidthClass: 'w-24',
lastRunWidthClass: 'w-20',
statusWidthClass: 'w-14'
@@ -79,6 +86,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-40',
scheduleWidthClass: 'w-24',
projectWidthClass: 'w-28',
hostWidthClass: 'w-20',
nextWidthClass: 'w-20',
lastRunWidthClass: 'w-24',
statusWidthClass: 'w-16'
@@ -88,6 +96,7 @@ const TABLE_ROW_SKELETONS = [
nameWidthClass: 'w-28',
scheduleWidthClass: 'w-36',
projectWidthClass: 'w-20',
hostWidthClass: 'w-24',
nextWidthClass: 'w-24',
lastRunWidthClass: 'w-16',
statusWidthClass: 'w-14'
@@ -107,7 +116,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
<div className="flex shrink-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<SkeletonBar className="h-8 w-56 shrink-0 rounded-md" />
<SkeletonBar className="h-8 w-20 shrink-0 rounded-md" />
<SkeletonBar className="h-8 w-80 shrink-0 rounded-md" />
<SkeletonBar className="size-8 shrink-0 rounded-md" />
</div>
<SkeletonBar className="h-8 w-32 shrink-0 rounded-md" />
@@ -120,6 +129,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
<SkeletonBar className="h-2.5 w-12" />
<SkeletonBar className="h-2.5 w-14" />
<SkeletonBar className="h-2.5 w-14" />
<SkeletonBar className="h-2.5 w-14" />
<SkeletonBar className="h-2.5 w-16" />
<SkeletonBar className="h-2.5 w-16" />
<SkeletonBar className="h-2.5 w-12" />
@@ -0,0 +1,86 @@
/**
* Capability gates for owner-qualified automation requests.
*
* A request fails closed on a *known* missing capability and on nothing else:
* an unreachable authority must classify as unavailable and retry, never as an
* old server the user is told to upgrade.
*/
import { getRuntimeEnvironmentStatus } from '@/runtime/runtime-rpc-client'
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import type {
AutomationDestination,
AutomationOwnerPrecondition
} from '../../../../shared/automation-owner-precondition'
import {
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { automationHostDiagnostics } from './automation-host-diagnostics'
export const AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS = 15_000
export class AutomationHostScopeUnsupportedError extends Error {
readonly code = 'unsupported_host_scope'
constructor(message: string) {
super(message)
this.name = 'AutomationHostScopeUnsupportedError'
}
}
/**
* The probe is counted here because it is counted nowhere else: it deliberately
* re-fetches on every call and rides outside the scheduler's four-slot pool, so
* an instrument that saw only pooled work would report half the relay traffic a
* 50-host refresh actually costs.
*/
export async function assertAuthorityCapability(
authority: AutomationAuthorityRef,
capability: RuntimeCapability,
message: string
): Promise<void> {
if (authority.kind !== 'runtime') {
return
}
automationHostDiagnostics.recordCapabilityProbe({
authorityKey: automationAuthorityCatalogKey(authority)
})
const status = await getRuntimeEnvironmentStatus(
authority.environmentId,
AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS
)
if (!status.capabilities?.includes(capability)) {
throw new AutomationHostScopeUnsupportedError(message)
}
}
export async function assertOwnerFencingSupported(
authority: AutomationAuthorityRef
): Promise<void> {
await assertAuthorityCapability(
authority,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE
)
}
/**
* Whether a mutation must fail closed on a server without owner fencing.
*
* Self is exempt: a Self record lives on the answering authority, the request
* is already pinned to the pairing revision the row was captured under, and an
* id-based mutation is exactly what a pre-fencing server performs today. SSH
* and orphan selectors still fail closed — a same-id remove/re-add could
* otherwise run or edit on a machine the user never saw. A destination
* re-attaches the record, so it always needs the fenced contract: an old server
* would ignore the field and silently leave the record where it was.
*/
export function requiresOwnerFencing(
expectedOwner: AutomationOwnerPrecondition,
destination?: AutomationDestination
): boolean {
return expectedOwner.selector.kind !== 'self' || destination !== undefined
}
@@ -30,7 +30,7 @@ export function repoConnectionIdIn(
const EMPTY_REPO_TABLE: ReadonlyMap<string, RepoConnection> = new Map()
function repoOwningAuthority(repo: Repo): StableAutomationAuthorityRef {
export function automationRepoOwningAuthority(repo: Repo): StableAutomationAuthorityRef {
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
// A desktop SSH repo is still desktop-stored; only a runtime host owns its own registry.
return host?.kind === 'runtime'
@@ -44,7 +44,7 @@ export function groupReposByAutomationAuthority(
): AutomationAuthorityRepoTables {
const tables = new Map<string, Map<string, RepoConnection>>()
for (const repo of repos) {
const key = automationAuthorityCatalogKey(repoOwningAuthority(repo))
const key = automationAuthorityCatalogKey(automationRepoOwningAuthority(repo))
let table = tables.get(key)
if (!table) {
table = new Map<string, RepoConnection>()
@@ -63,7 +63,7 @@ function row(overrides: Partial<AutomationHostRow>): AutomationHostRow {
describe('captured owner availability', () => {
it('lets every action through on an owned row and repeats its precondition', () => {
const availability = automationActionAvailability(
{ owner: DESKTOP_SELF, selector: { kind: 'self' } },
{ authority: DESKTOP_SELF.authority, owner: DESKTOP_SELF, selector: { kind: 'self' } },
'run'
)
expect(availability).toEqual({
@@ -74,9 +74,14 @@ describe('captured owner availability', () => {
})
it('keeps delete and pause fenceable on an orphan while refusing to run it', () => {
const orphan = { owner: null, selector: { kind: 'orphan' as const, issue: 'gone' } }
const orphan = {
authority: RUNTIME_SELF.authority,
owner: null,
selector: { kind: 'orphan' as const, issue: 'gone' }
}
expect(automationActionAvailability(orphan, 'delete')).toEqual({
kind: 'orphan-fenced',
authority: RUNTIME_SELF.authority,
precondition: { selector: { kind: 'orphan' } }
})
expect(automationActionAvailability(orphan, 'toggle').kind).toBe('orphan-fenced')
@@ -86,25 +91,52 @@ describe('captured owner availability', () => {
expect(isAutomationActionEnabled(orphan, 'delete')).toBe(true)
})
it('blocks every action on a legacy row and asks for a server update', () => {
it('blocks every action on a legacy SSH row and asks for a server update', () => {
// A qualified selector with no owner means the authority never sent a generation.
const legacy = { owner: null, selector: { kind: 'ssh' as const, targetId: 'box' } }
const legacy = {
authority: RUNTIME_SELF.authority,
owner: null,
selector: { kind: 'ssh' as const, targetId: 'box' }
}
const availability = automationActionAvailability(legacy, 'delete')
expect(availability.kind).toBe('blocked')
expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server')
})
it('reports a row with no metadata as uncaptured rather than blocked', () => {
expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('uncaptured')
it('keeps a legacy Self row fully actionable through its captured authority', () => {
// The legacy partition captures the answering authority as the Self owner,
// so an old server never leaves Runtime + Self view-only.
const legacySelf = {
authority: RUNTIME_SELF.authority,
owner: RUNTIME_SELF,
selector: { kind: 'self' as const }
}
for (const action of ['edit', 'save', 'toggle', 'delete', 'run', 'history'] as const) {
expect(isAutomationActionEnabled(legacySelf, action)).toBe(true)
}
expect(automationActionAvailability(legacySelf, 'run')).toEqual({
kind: 'owned',
owner: RUNTIME_SELF,
precondition: { selector: { kind: 'self' } }
})
})
it('blocks a row with no captured authority', () => {
expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('blocked')
expect(capturedAutomationOwner(new Map(), 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER)
expect(capturedAutomationOwner(null, 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER)
})
it('keys captured owners by row key', () => {
const captured = captureAutomationOwners([
{ rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) },
{
rowKey: 'row|desktop|a-1',
authority: DESKTOP_SELF.authority,
row: row({ automation: automation({ id: 'a-1' }) })
},
{
rowKey: 'row|desktop|a-2',
authority: DESKTOP_SELF.authority,
row: row({
automation: automation({ id: 'a-2' }),
owner: null,
@@ -118,9 +150,14 @@ describe('captured owner availability', () => {
it("keeps both authorities' owners when they hold the same automation ID", () => {
const captured = captureAutomationOwners([
{ rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) },
{
rowKey: 'row|desktop|a-1',
authority: DESKTOP_SELF.authority,
row: row({ automation: automation({ id: 'a-1' }) })
},
{
rowKey: 'row|runtime|a-1',
authority: RUNTIME_SELF.authority,
row: row({ automation: automation({ id: 'a-1' }), owner: RUNTIME_SELF })
}
])
@@ -8,12 +8,16 @@
* works if the request names the incarnation the user actually saw.
*
* Three outcomes, kept distinct because they mean different things to the user:
* an owned row acts normally; an orphan row still deletes and pauses under the
* orphan precondition but can never execute; a legacy row proves nothing and
* stays view-only until its authority advertises fencing.
* an owned row acts normally (a legacy Self row is owned — its authority is the
* fence); an orphan row still deletes and pauses under the orphan precondition
* but can never execute; a legacy SSH row proves nothing and stays view-only
* until its authority advertises fencing.
*/
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
import type {
AutomationAuthorityRef,
AutomationOwnerRef
} from '../../../../shared/automation-owner-ref'
import { ownerKey } from '../../../../shared/automation-owner-key'
import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition'
import { translate } from '@/i18n/i18n'
@@ -34,6 +38,8 @@ export type AutomationActionBlock = {
}
export type AutomationCapturedOwner = {
/** The storage authority that listed the row, including its current pairing fence. */
authority: AutomationAuthorityRef | null
owner: AutomationOwnerRef | null
/** How the authority qualified the row; null when no list metadata was captured at all. */
selector: AutomationHostRowSelector | null
@@ -41,16 +47,18 @@ export type AutomationCapturedOwner = {
export type AutomationActionAvailability =
| { kind: 'owned'; owner: AutomationOwnerRef; precondition: AutomationOwnerPrecondition }
| { kind: 'orphan-fenced'; precondition: AutomationOwnerPrecondition }
| {
kind: 'orphan-fenced'
authority: AutomationAuthorityRef
precondition: AutomationOwnerPrecondition
}
| { kind: 'blocked'; block: AutomationActionBlock }
/**
* No owner metadata exists for this row yet, so the caller keeps its legacy
* unfenced path. Removed with the inference in Step 9, never in the same
* change — a mixed-version client still depends on it.
*/
| { kind: 'uncaptured' }
export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { owner: null, selector: null }
export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = {
authority: null,
owner: null,
selector: null
}
/** Delete and pause survive on an orphan; running it would need a host that no longer exists. */
const ORPHAN_ACTIONS: ReadonlySet<AutomationRowAction> = new Set(['delete', 'toggle', 'history'])
@@ -89,14 +97,21 @@ export function automationActionAvailability(
}
}
if (!captured.selector) {
return { kind: 'uncaptured' }
return { kind: 'blocked', block: unfencedBlock() }
}
if (captured.selector.kind === 'orphan') {
if (!captured.authority) {
return { kind: 'blocked', block: unfencedBlock() }
}
return ORPHAN_ACTIONS.has(action)
? { kind: 'orphan-fenced', precondition: ORPHAN_OWNER_PRECONDITION }
? {
kind: 'orphan-fenced',
authority: captured.authority,
precondition: ORPHAN_OWNER_PRECONDITION
}
: { kind: 'blocked', block: orphanBlock() }
}
// A qualified selector with no owner is a legacy row: no generation was ever captured.
// A qualified selector with no owner is a legacy SSH row: no generation was ever captured.
return { kind: 'blocked', block: unfencedBlock() }
}
@@ -112,6 +127,7 @@ export function isAutomationActionEnabled(
export type AutomationCapturedRow = {
/** `automationListRowKey`: authority-qualified and incarnation-free. */
rowKey: string
authority: AutomationAuthorityRef
row: AutomationHostRow
}
@@ -130,8 +146,8 @@ export function captureAutomationOwners(
rows: Iterable<AutomationCapturedRow>
): Map<string, AutomationCapturedOwner> {
const captured = new Map<string, AutomationCapturedOwner>()
for (const { rowKey, row } of rows) {
captured.set(rowKey, { owner: row.owner, selector: row.selector })
for (const { rowKey, authority, row } of rows) {
captured.set(rowKey, { authority, owner: row.owner, selector: row.selector })
}
return captured
}
@@ -4,8 +4,12 @@ import type {
AutomationHostCatalogEntry
} from './automation-host-catalog-types'
import {
automationCreateEligibleProjects,
automationCreateEligibleWorktrees,
automationCreateHostStableKey,
automationCreateProjectMismatch,
automationCreateSetupMatchesDestination,
automationCreateWorkspaceRefreshHostId,
preselectAutomationCreateHost,
resolveAutomationCreateDestination,
revalidateAutomationCreateDestination,
@@ -13,6 +17,8 @@ import {
} from './automation-create-destination'
import { groupReposByAutomationAuthority } from './automation-authority-identity'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
function entry(overrides: Partial<AutomationHostCatalogEntry> = {}): AutomationHostCatalogEntry {
return {
@@ -141,6 +147,7 @@ describe('create host stable key', () => {
// A desktop SSH workspace is still desktop-stored; only the selector differs.
expect(automationCreateHostStableKey('ssh:box')).toBe('host:desktop:ssh:box')
expect(automationCreateHostStableKey('runtime:gpu')).toBe('host:runtime:gpu:self')
expect(automationCreateHostStableKey('ssh:box', 'gpu')).toBe('host:runtime:gpu:ssh:box')
expect(automationCreateHostStableKey(null)).toBeNull()
})
})
@@ -195,4 +202,150 @@ describe('create project mismatch', () => {
// The desktop's own table is a verdict, so its miss does refuse.
expect(automationCreateProjectMismatch(tables, desktopSelf, 'missing')).toBe(true)
})
it('offers only concrete repo rows owned by the selected authority and host', () => {
const local = repo({ id: 'shared' })
const runtime = repo({ id: 'shared', executionHostId: 'runtime:gpu' })
const runtimeSsh = repo({
id: 'nested',
connectionId: 'builder',
executionHostId: 'runtime:gpu'
})
const runtimeSelf = {
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
destination: { selector: { kind: 'self' as const } },
entry: entry()
}
expect(automationCreateEligibleProjects(runtimeSelf, [local, runtime, runtimeSsh], [])).toEqual(
[runtime]
)
expect(
automationCreateEligibleProjects(
{
...runtimeSelf,
destination: {
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
}
},
[local, runtime, runtimeSsh],
[]
)
).toEqual([runtimeSsh])
})
})
describe('create workspace destination', () => {
const runtimeSelf = {
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
destination: { selector: { kind: 'self' as const } },
entry: entry()
}
const runtimeRepo = {
id: 'shared',
displayName: 'orca',
path: '/runtime/orca',
badgeColor: '#000000',
addedAt: 1,
executionHostId: 'runtime:gpu'
} as Repo
function worktree(overrides: Partial<Worktree>): Worktree {
return {
id: 'shared::/runtime/orca',
repoId: 'shared',
displayName: 'main',
path: '/runtime/orca',
branch: 'main',
...overrides
} as Worktree
}
it('keeps equal repo/workspace ids partitioned by runtime owner', () => {
const local = worktree({ hostId: 'local' })
const runtime = worktree({
hostId: 'runtime:gpu',
runtimeOwnerEnvironmentId: 'gpu'
})
const otherRuntime = worktree({
hostId: 'runtime:other',
runtimeOwnerEnvironmentId: 'other'
})
expect(
automationCreateEligibleWorktrees(runtimeSelf, runtimeRepo, [local, runtime, otherRuntime])
).toEqual([runtime])
})
it('partitions nested SSH work by both authority and target', () => {
const destination = {
...runtimeSelf,
destination: {
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
}
}
const repo = { ...runtimeRepo, connectionId: 'builder' }
const owned = worktree({
hostId: 'ssh:builder',
runtimeOwnerEnvironmentId: 'gpu'
})
const desktop = worktree({ hostId: 'ssh:builder' })
expect(automationCreateEligibleWorktrees(destination, repo, [owned, desktop])).toEqual([owned])
expect(automationCreateWorkspaceRefreshHostId(destination)).toBe('runtime:gpu')
})
it('matches the run setup to the same destination', () => {
const setup = {
id: 'setup-runtime',
projectId: 'project',
repoId: 'shared',
hostId: 'runtime:gpu',
executionHostId: 'runtime:gpu',
runtimeOwnerEnvironmentId: 'gpu',
path: '/runtime/orca',
displayName: 'orca',
setupState: 'ready',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
} as ProjectHostSetup
expect(automationCreateSetupMatchesDestination(setup, runtimeSelf)).toBe(true)
expect(
automationCreateSetupMatchesDestination(
{ ...setup, runtimeOwnerEnvironmentId: 'other', hostId: 'runtime:other' },
runtimeSelf
)
).toBe(false)
})
it('keeps folder workspaces pinned to the selected SSH host', () => {
const destination = {
authority: { kind: 'desktop' as const },
destination: {
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
},
entry: entry()
}
const repo = { ...runtimeRepo, executionHostId: 'local' as const }
const setup = {
id: 'folder-builder',
projectId: 'project',
repoId: repo.id,
hostId: 'ssh:builder',
executionHostId: 'ssh:builder',
path: '/builder/orca',
displayName: 'orca',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
} as ProjectHostSetup
const folder = worktree({
hostId: 'ssh:builder',
projectHostSetupId: setup.id,
path: '/builder/orca'
})
expect(automationCreateEligibleProjects(destination, [repo], [setup])).toEqual([repo])
expect(automationCreateEligibleWorktrees(destination, repo, [folder], [setup])).toEqual([
folder
])
})
})
@@ -15,17 +15,30 @@
import type {
AutomationAuthorityRef,
AutomationOwnerRef
AutomationOwnerRef,
StableAutomationAuthorityRef
} from '../../../../shared/automation-owner-ref'
import type { AutomationDestination } from '../../../../shared/automation-owner-precondition'
import { hostStableKey, isSameAutomationOwner } from '../../../../shared/automation-owner-key'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import {
getWorktreeExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type {
AutomationCatalogHydrationEvidence,
AutomationHostCatalogEntry
} from './automation-host-catalog-types'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import {
automationRepoOwningAuthority,
repoConnectionIdIn,
type AutomationAuthorityRepoTables
} from './automation-authority-identity'
@@ -121,21 +134,26 @@ export function soleAutomationCreateHost(
}
/** The catalog host a workspace's execution host names, for the All-hosts pre-fill. */
export function automationCreateHostStableKey(hostId: string | null | undefined): string | null {
export function automationCreateHostStableKey(
hostId: string | null | undefined,
runtimeOwnerEnvironmentId?: string | null
): string | null {
const host = parseExecutionHostId(hostId)
if (!host) {
const runtimeOwner = runtimeOwnerEnvironmentId?.trim()
if (!host && !runtimeOwner) {
return null
}
if (host.kind === 'runtime') {
if (runtimeOwner || host?.kind === 'runtime') {
const environmentId = runtimeOwner || (host?.kind === 'runtime' ? host.environmentId : '')
return hostStableKey({
authority: { kind: 'runtime', environmentId: host.environmentId },
selector: { kind: 'self' }
authority: { kind: 'runtime', environmentId },
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
})
}
// A desktop SSH workspace is still desktop-stored; only the selector differs.
return hostStableKey({
authority: { kind: 'desktop' },
selector: host.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
})
}
@@ -162,17 +180,143 @@ export function automationCreateProjectMismatch(
return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null
}
function authoritiesMatch(
left: StableAutomationAuthorityRef,
right: StableAutomationAuthorityRef
): boolean {
return (
left.kind === right.kind &&
(left.kind === 'desktop' ||
(right.kind === 'runtime' && left.environmentId === right.environmentId))
)
}
function connectionMatchesDestination(
connectionId: string | null | undefined,
destination: AutomationCreateDestination
): boolean {
const normalized = connectionId?.trim() || null
const selector = destination.destination.selector
return selector.kind === 'ssh' ? normalized === selector.targetId : normalized === null
}
export function automationCreateRepoMatchesDestination(
repo: Repo,
destination: AutomationCreateDestination
): boolean {
return (
authoritiesMatch(automationRepoOwningAuthority(repo), destination.authority) &&
connectionMatchesDestination(repo.connectionId, destination)
)
}
/**
* The projects a destination can hold, filtered by the rule submit already
* enforces, so the form cannot offer a pairing its own check will refuse.
*/
export function automationCreateEligibleProjects<TProject extends { id: string }>(
tables: AutomationAuthorityRepoTables,
export function automationCreateEligibleProjects(
destination: AutomationCreateDestination,
projects: readonly TProject[]
): TProject[] {
projects: readonly Repo[],
projectHostSetups: readonly ProjectHostSetup[]
): Repo[] {
return projects.filter(
(project) => !automationCreateProjectMismatch(tables, destination, project.id)
(project) =>
automationCreateRepoMatchesDestination(project, destination) ||
projectHostSetups.some(
(setup) =>
setup.repoId === project.id &&
setup.setupState === 'ready' &&
automationCreateSetupMatchesDestination(setup, destination)
)
)
}
function destinationExecutionHostId(destination: AutomationCreateDestination): ExecutionHostId {
const selector = destination.destination.selector
if (selector.kind === 'ssh') {
return toSshExecutionHostId(selector.targetId)
}
return destination.authority.kind === 'runtime'
? toRuntimeExecutionHostId(destination.authority.environmentId)
: LOCAL_EXECUTION_HOST_ID
}
/** The host whose workspace catalog the client can authoritatively refresh. */
export function automationCreateWorkspaceRefreshHostId(
destination: AutomationCreateDestination
): ExecutionHostId {
return destination.authority.kind === 'runtime'
? toRuntimeExecutionHostId(destination.authority.environmentId)
: destinationExecutionHostId(destination)
}
function runtimeOwnerMatches(
runtimeOwnerEnvironmentId: string | null | undefined,
hostId: ExecutionHostId | null,
destination: AutomationCreateDestination
): boolean {
if (destination.authority.kind === 'desktop') {
return !runtimeOwnerEnvironmentId && parseExecutionHostId(hostId)?.kind !== 'runtime'
}
if (runtimeOwnerEnvironmentId) {
return runtimeOwnerEnvironmentId === destination.authority.environmentId
}
const host = parseExecutionHostId(hostId)
return host?.kind === 'runtime' && host.environmentId === destination.authority.environmentId
}
export function automationCreateWorktreeMatchesDestination(
worktree: Worktree,
repo: Repo,
destination: AutomationCreateDestination,
projectHostSetups: readonly ProjectHostSetup[] = []
): boolean {
if (worktree.repoId !== repo.id) {
return false
}
const setup = worktree.projectHostSetupId
? projectHostSetups.find(
(candidate) =>
candidate.id === worktree.projectHostSetupId &&
automationCreateSetupMatchesDestination(candidate, destination)
)
: undefined
const hostId =
normalizeExecutionHostId(worktree.hostId) ??
normalizeExecutionHostId(setup?.executionHostId) ??
normalizeExecutionHostId(setup?.hostId) ??
getWorktreeExecutionHostId(worktree, repo)
return (
runtimeOwnerMatches(
worktree.runtimeOwnerEnvironmentId ?? setup?.runtimeOwnerEnvironmentId,
hostId,
destination
) && hostId === destinationExecutionHostId(destination)
)
}
export function automationCreateEligibleWorktrees(
destination: AutomationCreateDestination,
repo: Repo | null | undefined,
worktrees: readonly Worktree[],
projectHostSetups: readonly ProjectHostSetup[] = []
): Worktree[] {
return repo
? worktrees.filter((worktree) =>
automationCreateWorktreeMatchesDestination(worktree, repo, destination, projectHostSetups)
)
: []
}
export function automationCreateSetupMatchesDestination(
setup: ProjectHostSetup,
destination: AutomationCreateDestination
): boolean {
const transportHostId = normalizeExecutionHostId(setup.hostId)
const executionHostId = normalizeExecutionHostId(setup.executionHostId) ?? transportHostId
return (
runtimeOwnerMatches(setup.runtimeOwnerEnvironmentId, transportHostId, destination) &&
executionHostId === destinationExecutionHostId(destination)
)
}
@@ -2,8 +2,11 @@
* Turns an authority's answer into cache rows.
*
* Scoped rows keep the owner they were fetched under so every later action can
* fence on it. Legacy rows keep no owner and no usage: the old response proves
* neither, and the design forbids compensating with run-history downloads.
* fence on it. Legacy SSH and orphan rows keep no owner and no usage: the old
* response proves neither, and the design forbids compensating with run-history
* downloads. Legacy Self rows do carry an owner — a Self record lives on the
* answering authority itself and mutates by id under the pairing-revision
* fence, so an old server degrades nothing for it.
*/
import type { Automation } from '../../../../shared/automations-types'
@@ -83,9 +86,12 @@ function legacySelectorMatches(
/**
* Fans one unscoped response out to every entry that asked for it, so an old
* runtime is queried once per cycle rather than once per selector.
* runtime is queried once per cycle rather than once per selector. `authority`
* is the incarnation the answer was fetched under; it becomes the captured
* owner of the Self rows and of nothing else.
*/
export function partitionLegacyAutomationHostRows(
authority: AutomationAuthorityRef,
automations: readonly Automation[],
refs: readonly StableAutomationCatalogRef[],
context: LegacyAutomationPartitionContext,
@@ -100,7 +106,9 @@ export function partitionLegacyAutomationHostRows(
.filter((row) => legacySelectorMatches(row.selector, ref))
.map((row) => ({
automation: row.automation,
owner: null,
// Self needs no registration generation, so it is fenceable even from
// an unscoped answer; SSH and orphan rows stay ownerless and view-only.
owner: row.selector.kind === 'self' ? { authority, selector: { kind: 'self' } } : null,
selector: row.selector,
usageSummary: null,
usageKnown: false
@@ -18,7 +18,7 @@ export type AutomationHostRowSelector = AutomationListItemSelector | LegacyAutom
export type AutomationHostRow = {
automation: Automation
/** Null when nothing may be mutated through it: legacy unscoped rows and orphans. */
/** Null when nothing may be mutated through it: legacy unscoped SSH rows and orphans. */
owner: AutomationOwnerRef | null
selector: AutomationHostRowSelector
usageSummary: AutomationUsageSummary | null
@@ -26,7 +26,11 @@ export type AutomationExecutionHealth =
| 'unavailable'
| 'unknown'
/** What list/fencing contract this entry can use. `legacy-unscoped` entries are view-only. */
/**
* What list contract this entry can use. `legacy-unscoped` picks the one
* unscoped-list-per-authority transport; whether the entry is view-only is
* `scopeGap`'s call — Self stays fully usable on a legacy authority.
*/
export type AutomationHostQuerySupport = 'scoped' | 'legacy-unscoped' | 'incompatible'
/**
@@ -52,7 +56,7 @@ export type AutomationHostCatalogState = 'authoritative' | 'unhydrated' | 'remov
export type AutomationHostCatalogEntry = {
stableRef: StableAutomationCatalogRef
/** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy). */
/** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy SSH). */
owner: AutomationOwnerRef | null
stableKey: string
label: string
@@ -62,7 +66,7 @@ export type AutomationHostCatalogEntry = {
authorityHealth: AutomationAuthorityHealth
executionHealth: AutomationExecutionHealth
querySupport: AutomationHostQuerySupport
/** Present only while `querySupport` is degraded; explains which repair, if any, applies. */
/** The reason this entry is view-only, and which repair applies; absent when it is not. */
scopeGap?: AutomationHostScopeGap
}
@@ -259,7 +259,7 @@ describe('buildAutomationHostCatalog', () => {
expect(entry.executionHealth).toBe('unknown')
})
it('keeps a legacy runtime view-only: no generations, no owners', () => {
it("keeps only a legacy runtime's SSH entries view-only; Self stays fully usable", () => {
const catalog = buildAutomationHostCatalog(
buildInput({
runtimes: [
@@ -273,8 +273,17 @@ describe('buildAutomationHostCatalog', () => {
const nested = entryAt(catalog, runtimeSshKey('env-1', 'legacy'))
expect(nested.querySupport).toBe('legacy-unscoped')
expect(nested.owner).toBeNull()
// Self needs no target generation, so it stays owned even on an old server.
expect(entryAt(catalog, runtimeSelfKey('env-1')).owner).not.toBeNull()
expect(nested.scopeGap).toBe('authority-unscoped')
// Self needs no target generation: it keeps its owner and records no scope
// gap, so an old server never renders Runtime + Self as View only.
const self = entryAt(catalog, runtimeSelfKey('env-1'))
expect(self.owner).toEqual({
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 7 },
selector: { kind: 'self' }
})
expect(self.scopeGap).toBeUndefined()
// Still the legacy transport: one unscoped list per authority per cycle.
expect(self.querySupport).toBe('legacy-unscoped')
})
it('downgrades a scoped entry whose target carries no generation', () => {
@@ -119,8 +119,10 @@ function projectSelfEntry(ctx: AuthorityProjectionContext): AutomationHostCatalo
catalogState: 'authoritative',
authorityHealth: ctx.authorityHealth,
executionHealth: resolveSelfExecutionHealth(ctx.authorityHealth),
querySupport: ctx.querySupport,
...withScopeGap(authorityScopeGap(ctx.querySupport))
// No scope gap even on a legacy authority: Self records live on the
// answering authority and mutate by id under the pairing-revision fence,
// so the unscoped contract degrades the list transport, not access.
querySupport: ctx.querySupport
}
)
}
@@ -1,11 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Automation } from '../../../../shared/automations-types'
import {
listAutomationRunsForTarget,
listAutomationsForTarget,
runAutomationNowForTarget,
updateAutomationForTarget
} from './automation-host-client'
import { listAutomationRunsForTarget, listAutomationsForTarget } from './automation-host-client'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
vi.mock('@/runtime/runtime-rpc-client', () => ({
@@ -102,58 +97,4 @@ describe('automation host client', () => {
)
expect(mockApi.automations.listRuns).toHaveBeenCalledWith({ automationId: 'auto-1' })
})
it('manually runs runtime-host automations through that server', async () => {
const automation = makeAutomation()
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({
run: { id: 'run-1', automationId: automation.id }
})
await runAutomationNowForTarget(automation)
expect(mockApi.automations.runNow).not.toHaveBeenCalled()
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'gpu' },
'automation.runNow',
{ id: automation.id },
{ timeoutMs: 15_000 }
)
})
it('updates and manually runs SSH-host automations through the remote server that listed them', async () => {
const automation = makeAutomation({
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'ssh:devbox',
projectHostSetupId: 'setup-devbox',
repoId: 'repo-1',
path: '/srv/orca'
}
})
const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' }
vi.mocked(callRuntimeRpc)
.mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } })
.mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } })
await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget)
await runAutomationNowForTarget(automation, sourceTarget)
expect(mockApi.automations.update).not.toHaveBeenCalled()
expect(mockApi.automations.runNow).not.toHaveBeenCalled()
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
1,
sourceTarget,
'automation.update',
{ id: automation.id, updates: { name: 'Updated' } },
{ timeoutMs: 15_000 }
)
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
2,
sourceTarget,
'automation.runNow',
{ id: automation.id },
{ timeoutMs: 15_000 }
)
})
})
@@ -1,16 +1,8 @@
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type {
Automation,
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { parseExecutionHostId } from '../../../../shared/execution-host'
type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'workspaceId'> & {
repo?: string
workspace?: string
}
export type AutomationHostTarget =
| { kind: 'local' }
| { kind: 'environment'; environmentId: string }
@@ -38,25 +30,12 @@ export function getAutomationTargetFromHostId(
: { kind: 'local' }
}
export function getAutomationOwnerTarget(
automation: Pick<Automation, 'runContext'>,
sourceTarget?: AutomationHostTarget | null
export function getAutomationTargetFromAuthority(
authority: StableAutomationAuthorityRef
): AutomationHostTarget {
if (sourceTarget?.kind === 'environment') {
return sourceTarget
}
return getAutomationTargetFromHostId(automation.runContext?.hostId)
}
function toRuntimeAutomationUpdateInput(
input: AutomationUpdateInput
): RuntimeAutomationUpdateInput {
const { projectId, workspaceId, ...rest } = input
return {
...rest,
...(projectId !== undefined ? { repo: projectId } : {}),
...(workspaceId !== undefined ? { workspace: workspaceId ?? undefined } : {})
}
return authority.kind === 'runtime'
? { kind: 'environment', environmentId: authority.environmentId }
: { kind: 'local' }
}
export async function listAutomationsForTarget(
@@ -94,50 +73,3 @@ export async function listAutomationRunsForTarget(
)
return result.runs
}
export async function updateAutomationForTarget(
automation: Automation,
updates: AutomationUpdateInput,
sourceTarget?: AutomationHostTarget | null
): Promise<Automation> {
const target = getAutomationOwnerTarget(automation, sourceTarget)
if (target.kind === 'local') {
return await window.api.automations.update({ id: automation.id, updates })
}
const result = await callRuntimeRpc<{ automation: Automation }>(
target,
'automation.update',
{ id: automation.id, updates: toRuntimeAutomationUpdateInput(updates) },
{ timeoutMs: 15_000 }
)
return result.automation
}
export async function deleteAutomationForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<void> {
const target = getAutomationOwnerTarget(automation, sourceTarget)
if (target.kind === 'local') {
await window.api.automations.delete({ id: automation.id })
return
}
await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 })
}
export async function runAutomationNowForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<AutomationRun> {
const target = getAutomationOwnerTarget(automation, sourceTarget)
if (target.kind === 'local') {
return await window.api.automations.runNow({ id: automation.id })
}
const result = await callRuntimeRpc<{ run: AutomationRun }>(
target,
'automation.runNow',
{ id: automation.id },
{ timeoutMs: 15_000 }
)
return result.run
}
@@ -121,6 +121,15 @@ export function resolveAutomationHostListRows(
const capturedRows: AutomationCapturedRow[] = []
let answered = false
const authorityByKey = new Map(
input.catalog.entries.flatMap((entry) => {
const authority = entry.owner?.authority
return authority
? [[automationAuthorityCatalogKey(entry.stableRef.authority), authority] as const]
: []
})
)
for (const entry of entries) {
const cached = input.entry(entry.stableKey)
if (cached && cached.fetchedAt !== null) {
@@ -143,7 +152,11 @@ export function resolveAutomationHostListRows(
hostLabel: entry.label,
usageSummary: row.usageSummary ?? null
}
capturedRows.push({ rowKey: listRow.key, row })
const authority = authorityByKey.get(authorityKey)
if (!authority) {
continue
}
capturedRows.push({ rowKey: listRow.key, authority, row })
rows.push(listRow)
hostRows.push(listRow)
}
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight'
const mocks = vi.hoisted(() => ({
connectRuntimeEnvironmentAndRecordStatus: vi.fn(),
hydrateRuntimeEnvironmentSshState: vi.fn(),
connectRuntimeEnvironmentSshTarget: vi.fn(),
resyncRuntimeEnvironmentSshTargets: vi.fn(),
toastError: vi.fn(),
setSshConnectionState: vi.fn(),
setSshTargetsMetadata: vi.fn(),
setRemovedSshTargetLabels: vi.fn()
}))
vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({
connectRuntimeEnvironmentAndRecordStatus: (...args: unknown[]) =>
mocks.connectRuntimeEnvironmentAndRecordStatus(...args)
}))
vi.mock('@/runtime/runtime-environment-ssh-state', () => ({
connectRuntimeEnvironmentSshTarget: (...args: unknown[]) =>
mocks.connectRuntimeEnvironmentSshTarget(...args),
hydrateRuntimeEnvironmentSshState: (...args: unknown[]) =>
mocks.hydrateRuntimeEnvironmentSshState(...args),
resyncRuntimeEnvironmentSshTargets: (...args: unknown[]) =>
mocks.resyncRuntimeEnvironmentSshTargets(...args)
}))
vi.mock('sonner', () => ({
toast: { error: (...args: unknown[]) => mocks.toastError(...args) }
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
setSshConnectionState: mocks.setSshConnectionState,
setSshTargetsMetadata: mocks.setSshTargetsMetadata,
setRemovedSshTargetLabels: mocks.setRemovedSshTargetLabels
})
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
import {
connectAutomationHostRuntime,
connectAutomationHostSshTarget
} from './automation-host-recovery-connect'
function installSshApi(connect: ReturnType<typeof vi.fn>): void {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
connect,
listTargets: vi.fn().mockResolvedValue([]),
listRemovedTargetLabels: vi.fn().mockResolvedValue({})
}
}
})
}
describe('automation host recovery connect', () => {
beforeEach(() => {
resetSshConnectInFlightForTests()
for (const fn of Object.values(mocks)) {
fn.mockReset()
}
mocks.hydrateRuntimeEnvironmentSshState.mockResolvedValue(undefined)
mocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue({ status: 'connected' })
mocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined)
installSshApi(vi.fn().mockResolvedValue({ status: 'connected' }))
})
afterEach(() => {
resetSshConnectInFlightForTests()
})
it('records runtime status so the catalog can leave Unreachable', async () => {
mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(true)
await connectAutomationHostRuntime('gpu')
expect(mocks.connectRuntimeEnvironmentAndRecordStatus).toHaveBeenCalledWith('gpu', 15_000)
expect(mocks.hydrateRuntimeEnvironmentSshState).toHaveBeenCalledWith('gpu')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('toasts when the runtime stays unreachable', async () => {
mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(false)
await connectAutomationHostRuntime('gpu')
expect(mocks.toastError).toHaveBeenCalledWith('Remote host is not reachable')
expect(mocks.hydrateRuntimeEnvironmentSshState).not.toHaveBeenCalled()
})
it('writes the local SSH connect result into the store the catalog reads', async () => {
const connect = vi.fn().mockResolvedValue({ status: 'connected', targetId: 't1' })
installSshApi(connect)
await connectAutomationHostSshTarget({ targetId: 't1' })
expect(connect).toHaveBeenCalledWith({ targetId: 't1' })
expect(mocks.setSshConnectionState).toHaveBeenCalledWith('t1', {
status: 'connected',
targetId: 't1'
})
expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled()
})
it('dials a runtime SSH target through that server, not the local SSH API', async () => {
const connect = vi.fn()
installSshApi(connect)
await connectAutomationHostSshTarget({ targetId: 't1', environmentId: 'gpu' })
expect(mocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith('gpu', 't1')
expect(connect).not.toHaveBeenCalled()
})
it('toasts a failed local SSH connect instead of swallowing it', async () => {
installSshApi(vi.fn().mockRejectedValue(new Error('SSH target "t1" not found')))
await connectAutomationHostSshTarget({ targetId: 't1' })
expect(mocks.toastError).toHaveBeenCalledWith('SSH target "t1" not found')
})
})
@@ -0,0 +1,94 @@
/**
* Performs the transport dials `runAutomationHostRecovery` selected.
*
* The recovery module only names the host; this is the other half — write the
* returned status into the store the catalog actually reads. A fire-and-forget
* `ssh.connect` / `runtimeEnvironments.connect` leaves Unreachable on screen
* even when the backend came back.
*/
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { connectRuntimeEnvironmentAndRecordStatus } from '@/components/status-bar/runtime-environment-explicit-connect'
import {
connectRuntimeEnvironmentSshTarget,
hydrateRuntimeEnvironmentSshState,
resyncRuntimeEnvironmentSshTargets
} from '@/runtime/runtime-environment-ssh-state'
import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight'
import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
const RUNTIME_RECONNECT_TIMEOUT_MS = 15_000
const runtimeConnectInFlight = new Set<string>()
export async function connectAutomationHostRuntime(environmentId: string): Promise<void> {
if (runtimeConnectInFlight.has(environmentId)) {
return
}
runtimeConnectInFlight.add(environmentId)
try {
const reachable = await connectRuntimeEnvironmentAndRecordStatus(
environmentId,
RUNTIME_RECONNECT_TIMEOUT_MS
)
if (!reachable) {
toast.error(
translate(
'auto.components.automations.hostStatus.action.runtimeConnectFailed',
'Remote host is not reachable'
)
)
return
}
// Why: the catalog projects this environment's SSH targets from the bucket;
// a successful ping with a stale empty bucket still looks unverified.
await hydrateRuntimeEnvironmentSshState(environmentId).catch(() => {})
} finally {
runtimeConnectInFlight.delete(environmentId)
}
}
export async function connectAutomationHostSshTarget(args: {
targetId: string
environmentId?: string
}): Promise<void> {
const { targetId, environmentId } = args
if (isSshConnectInFlight(targetId)) {
return
}
try {
if (environmentId) {
await trackSshConnect(targetId, connectRuntimeEnvironmentSshTarget(environmentId, targetId))
return
}
const connectState = await withUiConnectTimeout(
trackSshConnect(targetId, window.api.ssh.connect({ targetId })),
SSH_RECONNECT_UI_TIMEOUT_MS
)
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands.
useAppStore.getState().setSshConnectionState(targetId, connectState)
}
} catch (err) {
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.automations.hostStatus.action.sshConnectFailed',
'SSH connection failed'
)
)
if (environmentId) {
void resyncRuntimeEnvironmentSshTargets(environmentId).catch(() => {})
return
}
void (async () => {
const targets = await window.api.ssh.listTargets()
useAppStore.getState().setSshTargetsMetadata(targets)
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
})().catch(() => {})
}
}
@@ -44,23 +44,30 @@ const RUNTIME_SSH = entry({
})
describe('automation host recovery', () => {
it('retries the host rather than the whole page', () => {
it('retries the host rather than the whole page', async () => {
const target = deps()
runAutomationHostRecovery('retry', DESKTOP_SSH, target)
await runAutomationHostRecovery('retry', DESKTOP_SSH, target)
expect(target.retry).toHaveBeenCalledWith(DESKTOP_SSH)
})
it('dials the SSH target when the authority is fine', () => {
it('dials the SSH target when the authority is fine', async () => {
const target = deps()
runAutomationHostRecovery('reconnect', DESKTOP_SSH, target)
expect(target.connectSshTarget).toHaveBeenCalledWith('t1')
await runAutomationHostRecovery('reconnect', DESKTOP_SSH, target)
expect(target.connectSshTarget).toHaveBeenCalledWith('t1', undefined)
expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled()
})
it('dials the runtime first when the server itself is unreachable', () => {
it('dials a reachable runtime SSH target on that server, not the local SSH API', async () => {
const target = deps()
await runAutomationHostRecovery('reconnect', RUNTIME_SSH, target)
expect(target.connectSshTarget).toHaveBeenCalledWith('t1', 'gpu')
expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled()
})
it('dials the runtime first when the server itself is unreachable', async () => {
const target = deps()
// Why: an unreachable server cannot be asked to dial its own SSH target.
runAutomationHostRecovery(
await runAutomationHostRecovery(
'reconnect',
{ ...RUNTIME_SSH, authorityHealth: 'unavailable' },
target
@@ -69,30 +76,30 @@ describe('automation host recovery', () => {
expect(target.connectSshTarget).not.toHaveBeenCalled()
})
it('re-asks a desktop Self host, which has no transport to dial', () => {
it('re-asks a desktop Self host, which has no transport to dial', async () => {
const target = deps()
const desktopSelf = entry({
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
})
runAutomationHostRecovery('reconnect', desktopSelf, target)
await runAutomationHostRecovery('reconnect', desktopSelf, target)
expect(target.retry).toHaveBeenCalledWith(desktopSelf)
})
it('sends a runtime to the servers pane, since nothing here updates a host', () => {
it('sends a runtime to the servers pane, since nothing here updates a host', async () => {
const target = deps()
runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
await runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
})
it('sends a stale desktop SSH registration to the SSH pane instead', () => {
it('sends a stale desktop SSH registration to the SSH pane instead', async () => {
const target = deps()
runAutomationHostRecovery('update-server', DESKTOP_SSH, target)
await runAutomationHostRecovery('update-server', DESKTOP_SSH, target)
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'ssh', repoId: null })
})
it('does nothing without an entry to act on', () => {
it('does nothing without an entry to act on', async () => {
const target = deps()
runAutomationHostRecovery('retry', null, target)
await runAutomationHostRecovery('retry', null, target)
expect(target.retry).not.toHaveBeenCalled()
})
})
@@ -14,9 +14,10 @@ import type { AutomationHostRecoveryAction } from './automation-host-status-desc
export type AutomationHostRecoveryDeps = {
/** Forces one fresh query for this host, bypassing TTL and retry cooldown. */
retry: (entry: AutomationHostCatalogEntry) => void
connectSshTarget: (targetId: string) => void
connectRuntimeEnvironment: (environmentId: string) => void
retry: (entry: AutomationHostCatalogEntry) => void | Promise<void>
/** `environmentId` is the owning runtime; omit it for a desktop SSH target. */
connectSshTarget: (targetId: string, environmentId?: string) => void | Promise<void>
connectRuntimeEnvironment: (environmentId: string) => void | Promise<void>
openSettings: (target: SettingsNavigationTarget) => void
}
@@ -30,39 +31,45 @@ function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavig
return { pane: entry.stableRef.selector.kind === 'ssh' ? 'ssh' : 'automations', repoId: null }
}
function reconnect(entry: AutomationHostCatalogEntry, deps: AutomationHostRecoveryDeps): void {
async function reconnect(
entry: AutomationHostCatalogEntry,
deps: AutomationHostRecoveryDeps
): Promise<void> {
const authority = entry.stableRef.authority
// Authority first: an unreachable server cannot be asked to dial its own targets.
if (authority.kind === 'runtime' && entry.authorityHealth === 'unavailable') {
deps.connectRuntimeEnvironment(authority.environmentId)
await deps.connectRuntimeEnvironment(authority.environmentId)
return
}
if (entry.stableRef.selector.kind === 'ssh') {
deps.connectSshTarget(entry.stableRef.selector.targetId)
await deps.connectSshTarget(
entry.stableRef.selector.targetId,
authority.kind === 'runtime' ? authority.environmentId : undefined
)
return
}
if (authority.kind === 'runtime') {
deps.connectRuntimeEnvironment(authority.environmentId)
await deps.connectRuntimeEnvironment(authority.environmentId)
return
}
// Desktop Self has no transport to dial, so the only honest fallback is to re-ask.
deps.retry(entry)
await deps.retry(entry)
}
export function runAutomationHostRecovery(
export async function runAutomationHostRecovery(
action: AutomationHostRecoveryAction,
entry: AutomationHostCatalogEntry | null,
deps: AutomationHostRecoveryDeps
): void {
): Promise<void> {
if (!entry) {
return
}
switch (action) {
case 'retry':
deps.retry(entry)
await deps.retry(entry)
return
case 'reconnect':
reconnect(entry, deps)
await reconnect(entry, deps)
return
case 'update-server':
deps.openSettings(versionSettingsTarget(entry))
@@ -360,21 +360,31 @@ describe('automation host scheduler legacy path', () => {
expect(listRuns).not.toHaveBeenCalled()
})
it('leaves legacy rows unfenced and their usage unavailable', async () => {
it('fences legacy Self rows on the answering authority and leaves SSH rows unfenced', async () => {
const cache = createCache()
const scheduler = createAutomationHostScheduler({
cache,
legacyPartitionContext: () => LOCAL_REPO,
isVisible: () => true,
transport: { listLegacy: () => Promise.resolve([automation('a')]) }
transport: {
listLegacy: () =>
Promise.resolve([
automation('a'),
automation('b', { executionTargetType: 'ssh', executionTargetId: 't1' })
])
}
})
const legacy = { querySupport: 'legacy-unscoped' as const }
await scheduler.refresh([
target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'legacy-unscoped' })
target(selfRef(RUNTIME_AUTHORITY), legacy),
target(sshRef(RUNTIME_AUTHORITY, 't1'), legacy)
])
const row = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0]
expect(row?.owner).toBeNull()
expect(row?.usageKnown).toBe(false)
expect(row?.usageSummary).toBeNull()
const selfRow = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0]
// Self mutates by id on its own authority, so the unscoped answer still fences it.
expect(selfRow?.owner).toEqual({ authority: RUNTIME_AUTHORITY, selector: { kind: 'self' } })
expect(selfRow?.usageKnown).toBe(false)
expect(selfRow?.usageSummary).toBeNull()
expect(cache.get(sshRef(RUNTIME_AUTHORITY, 't1'))?.data[0]?.owner).toBeNull()
})
it('records an incompatible host without sending anything', async () => {
@@ -218,6 +218,7 @@ export function createAutomationHostScheduler(
// Attributed to the authority alone: one answer serves every entry in the group.
instrument.response(live[0].fence, null, startedAt, automations.length, automations)
const partition = partitionLegacyAutomationHostRows(
authority,
automations,
live.map((target) => target.ref),
options.legacyPartitionContext(live[0].ref.authority),
@@ -8,10 +8,11 @@ import type { AutomationHostStatusDescriptor } from './automation-host-status-de
/**
* Copy for the query-contract axis.
*
* Every degraded entry shares one consequence — view only — but not one cause.
* A removed target, a target we have not verified since the connection dropped,
* and a server that advertises no host scoping each get their own sentence, so
* the badge never claims a server is old on the strength of missing evidence.
* Every recorded scope gap shares one consequence — view only — but not one
* cause. A removed target, a target we have not verified since the connection
* dropped, and a server that advertises no host scoping each get their own
* sentence, so the badge never claims a server is old on the strength of
* missing evidence. A degraded transport alone earns no badge: only a gap does.
*/
const VIEW_ONLY_LABEL = (): string =>
@@ -67,13 +68,10 @@ function scopeGapDescriptor(gap: AutomationHostScopeGap): AutomationHostStatusDe
}
}
/** Null for `scoped`; the degraded contracts stay distinct rather than sharing one badge. */
/** Null when nothing is view-only; the degraded contracts stay distinct rather than sharing one badge. */
export function hostScopeDescriptor(
entry: Pick<AutomationHostCatalogEntry, 'querySupport' | 'scopeGap'>
): AutomationHostStatusDescriptor | null {
if (entry.querySupport === 'scoped') {
return null
}
if (entry.querySupport === 'incompatible') {
return {
id: 'query-incompatible',
@@ -89,6 +87,7 @@ export function hostScopeDescriptor(
isDefault: false
}
}
// An unscoped contract with no recorded cause can only be the authority's own.
return scopeGapDescriptor(entry.scopeGap ?? 'authority-unscoped')
// A degraded list transport with no recorded gap has no view-only consequence:
// Runtime + Self on a legacy server still mutates by id under the pairing fence.
return entry.scopeGap ? scopeGapDescriptor(entry.scopeGap) : null
}
@@ -92,10 +92,9 @@ describe('automation host status descriptors', () => {
expect(new Set(gapIds).size).toBe(SCOPE_GAPS.length)
})
it('falls back to the authority contract when no cause was recorded', () => {
expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))?.id).toBe(
'query-legacy-unscoped'
)
it('shows no view-only badge for a degraded transport with no recorded gap', () => {
// Runtime + Self on a legacy server: the list is unscoped but access is not.
expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))).toBeNull()
})
it('blames the server only when the server itself answered without host scoping', () => {
@@ -168,6 +167,17 @@ describe('automation host recovery actions', () => {
).toBeNull()
})
it('keeps normal recovery verbs for a legacy authority entry with no scope gap', () => {
expect(
automationHostRecoveryActions(entry({ querySupport: 'legacy-unscoped' })).authority
).toBeNull()
expect(
automationHostRecoveryActions(
entry({ querySupport: 'legacy-unscoped', authorityHealth: 'stale-error' })
).authority
).toBe('retry')
})
it('reconnects rather than retries when the authority itself is unreachable', () => {
expect(automationHostRecoveryActions(entry({ authorityHealth: 'unavailable' })).authority).toBe(
'reconnect'
@@ -212,25 +212,23 @@ function authorityRecoveryAction(
// The transport to the authority is down, so retrying the query alone cannot help.
return 'reconnect'
}
if (entry.querySupport !== 'scoped') {
// A degraded transport with no gap needs no repair: Runtime + Self on a
// legacy server stays fully usable, so its recovery verbs are the normal ones.
if (entry.scopeGap) {
return scopeGapRecoveryAction(entry.scopeGap)
}
return entry.authorityHealth === 'stale-error' ? 'retry' : null
}
/** Updating a server only repairs a server that actually answered without host scoping. */
function scopeGapRecoveryAction(
gap: AutomationHostScopeGap | undefined
): AutomationHostRecoveryAction | null {
function scopeGapRecoveryAction(gap: AutomationHostScopeGap): AutomationHostRecoveryAction | null {
switch (gap) {
case 'target-unverified':
return 'reconnect'
case 'target-removed':
case 'target-unregistered':
return null
// An unscoped contract with no recorded cause can only be the authority's own.
case 'authority-unscoped':
case undefined:
return 'update-server'
}
}
@@ -29,22 +29,18 @@ export type AutomationListRow = {
/** Authority-qualified and incarnation-free; see the module note. */
key: string
automation: Automation
/** Storage authority captured with the host query; absent only for the bootstrap list. */
/** Storage authority captured with the host query. */
catalogRef?: StableAutomationCatalogRef | null
/** The host this row actually came from; empty for unscoped legacy rows. */
/** The host this row actually came from. */
hostLabel: string
/** What the owning authority reported; null means unknown, never zero. */
usageSummary: AutomationUsageSummary | null
}
const ROW_KEY_NAMESPACE = 'row'
/** The pre-catalog list has no host to qualify with, so it says so rather than guessing one. */
const UNSCOPED_HOST_KEY = 'unscoped'
export function automationListRowKey(hostStableKey: string | null, automationId: string): string {
return [ROW_KEY_NAMESPACE, hostStableKey ?? UNSCOPED_HOST_KEY, automationId]
.map(encodeURIComponent)
.join('|')
export function automationListRowKey(hostStableKey: string, automationId: string): string {
return [ROW_KEY_NAMESPACE, hostStableKey, automationId].map(encodeURIComponent).join('|')
}
/**
@@ -57,19 +53,6 @@ export function automationAuthorityRecordKey(authorityKey: string, automationId:
return [authorityKey, automationId].map(encodeURIComponent).join('|')
}
/** The unscoped list the page shows before any host has answered. */
export function unscopedAutomationListRows(
automations: readonly Automation[]
): AutomationListRow[] {
return automations.map((automation) => ({
key: automationListRowKey(null, automation.id),
automation,
catalogRef: null,
hostLabel: '',
usageSummary: null
}))
}
export function automationRepoForRow(
row: AutomationListRow,
repos: readonly Repo[],
@@ -23,6 +23,7 @@ import {
stripAutomationOwnerConflictCode,
type AutomationOwnerConflictCode
} from '../../../../shared/automation-owner-conflict'
import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code'
import type {
AutomationAuthorityRef,
AutomationOwnerRef
@@ -96,6 +97,15 @@ function classify(error: unknown): AutomationActionOutcome<never> {
}
}
}
if (hasRuntimeRpcErrorCode(error, 'selector_not_found')) {
return {
status: 'failed',
message: translate(
'auto.components.automations.ownerAction.workspaceNotFound',
"We couldn't find that workspace on this host. Choose a workspace that exists here and try again."
)
}
}
return {
status: 'failed',
message:
@@ -117,13 +127,10 @@ async function attempt<TValue>(
availability: AutomationActionAvailability,
owned: (owner: AutomationOwnerRef) => Promise<TValue>,
orphanFenced?: () => Promise<TValue>
): Promise<AutomationActionOutcome<TValue> | { status: 'uncaptured' }> {
): Promise<AutomationActionOutcome<TValue>> {
if (availability.kind === 'blocked') {
return { status: 'blocked', block: availability.block }
}
if (availability.kind === 'uncaptured') {
return { status: 'uncaptured' }
}
try {
if (availability.kind === 'owned') {
return { status: 'ok', value: await owned(availability.owner) }
@@ -148,14 +155,10 @@ function orphanUnsupported(): AutomationActionBlock {
}
}
export type AutomationActionResult<TValue> =
| AutomationActionOutcome<TValue>
/** The caller must fall back to its unfenced path; Step 9 deletes this arm. */
| { status: 'uncaptured' }
export type AutomationActionResult<TValue> = AutomationActionOutcome<TValue>
export async function updateOwnedAutomation(
availability: AutomationActionAvailability,
authority: AutomationAuthorityRef,
id: string,
updates: AutomationUpdateInput,
destination?: AutomationDestination
@@ -163,19 +166,24 @@ export async function updateOwnedAutomation(
return await attempt(
availability,
(owner) => updateAutomationForOwner(owner, id, updates, destination),
() => updateOrphanAutomation(authority, id, updates)
() =>
availability.kind === 'orphan-fenced'
? updateOrphanAutomation(availability.authority, id, updates)
: Promise.reject(new Error('automation_owner_uncaptured'))
)
}
export async function deleteOwnedAutomation(
availability: AutomationActionAvailability,
authority: AutomationAuthorityRef,
id: string
): Promise<AutomationActionResult<void>> {
return await attempt(
availability,
(owner) => deleteAutomationForOwner(owner, id),
() => deleteOrphanAutomation(authority, id)
() =>
availability.kind === 'orphan-fenced'
? deleteOrphanAutomation(availability.authority, id)
: Promise.reject(new Error('automation_owner_uncaptured'))
)
}
@@ -203,13 +211,15 @@ export async function showOwnedAutomation(
export async function listOwnedAutomationRuns(
availability: AutomationActionAvailability,
authority: AutomationAuthorityRef,
automationId: string
): Promise<AutomationActionResult<AutomationRun[]>> {
return await attempt(
availability,
(owner) => listAutomationRunsForOwner(owner, automationId),
() => listOrphanAutomationRuns(authority, automationId)
() =>
availability.kind === 'orphan-fenced'
? listOrphanAutomationRuns(availability.authority, automationId)
: Promise.reject(new Error('automation_owner_uncaptured'))
)
}
@@ -1,12 +1,5 @@
/**
* One place where a row action decides between its fenced path and its legacy
* one, so the page's handlers stay three lines each.
*
* The order is deliberate. A captured owner always wins. A row the authority
* qualified but could not make executable is refused with the reason. Only a
* row with no owner metadata at all falls back to the unfenced call, and that
* arm exists solely so mixed-version clients keep working until the inference
* is removed — it is the legacy path, not a default.
* One place where every row action applies its captured owner or fails closed.
*/
import type {
@@ -14,7 +7,6 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import {
automationActionAvailability,
capturedAutomationOwner,
@@ -46,7 +38,6 @@ export type AutomationActionNotice = {
export type AutomationDispatchContext = {
/** Keyed by `AutomationListRow.key`; see {@link AutomationDispatchRow}. */
capturedOwners: ReadonlyMap<string, AutomationCapturedOwner>
authority: AutomationAuthorityRef
}
/**
@@ -69,7 +60,7 @@ export function dispatched<TValue>(value: TValue): AutomationDispatchResult<TVal
}
export function toDispatchResult<TValue>(
outcome: Exclude<AutomationActionResult<TValue>, { status: 'uncaptured' }>
outcome: AutomationActionResult<TValue>
): AutomationDispatchResult<TValue> {
switch (outcome.status) {
case 'ok':
@@ -92,100 +83,58 @@ async function dispatch<TValue>(
action: AutomationRowAction,
fenced: (
availability: ReturnType<typeof automationActionAvailability>
) => Promise<AutomationActionResult<TValue>>,
legacy: () => Promise<TValue>
) => Promise<AutomationActionResult<TValue>>
): Promise<AutomationDispatchResult<TValue>> {
const availability = automationActionAvailability(
capturedAutomationOwner(context.capturedOwners, row.rowKey),
action
)
const outcome = await fenced(availability)
if (outcome.status !== 'uncaptured') {
return toDispatchResult(outcome)
}
try {
return { ok: true, value: await legacy() }
} catch (error) {
return {
ok: false,
notice: {
message: error instanceof Error ? error.message : String(error),
recovery: 'retry',
severity: 'failure'
}
}
}
return toDispatchResult(await fenced(availability))
}
export async function dispatchAutomationUpdate(
context: AutomationDispatchContext,
row: AutomationDispatchRow,
updates: AutomationUpdateInput,
legacy: () => Promise<Automation>,
action: AutomationRowAction = 'toggle'
): Promise<AutomationDispatchResult<Automation>> {
return await dispatch(
context,
row,
action,
(availability) =>
updateOwnedAutomation(availability, context.authority, row.automationId, updates),
legacy
return await dispatch(context, row, action, (availability) =>
updateOwnedAutomation(availability, row.automationId, updates)
)
}
export async function dispatchAutomationDelete(
context: AutomationDispatchContext,
row: AutomationDispatchRow,
legacy: () => Promise<void>
row: AutomationDispatchRow
): Promise<AutomationDispatchResult<void>> {
return await dispatch(
context,
row,
'delete',
(availability) => deleteOwnedAutomation(availability, context.authority, row.automationId),
legacy
return await dispatch(context, row, 'delete', (availability) =>
deleteOwnedAutomation(availability, row.automationId)
)
}
export async function dispatchAutomationRunNow(
context: AutomationDispatchContext,
row: AutomationDispatchRow,
legacy: () => Promise<AutomationRun>
row: AutomationDispatchRow
): Promise<AutomationDispatchResult<AutomationRun>> {
return await dispatch(
context,
row,
'run',
(availability) => runOwnedAutomationNow(availability, row.automationId),
legacy
return await dispatch(context, row, 'run', (availability) =>
runOwnedAutomationNow(availability, row.automationId)
)
}
export async function dispatchAutomationReread(
context: AutomationDispatchContext,
row: AutomationDispatchRow,
legacy: () => Promise<Automation | null>
row: AutomationDispatchRow
): Promise<AutomationDispatchResult<Automation | null>> {
return await dispatch(
context,
row,
'edit',
(availability) => showOwnedAutomation(availability, row.automationId),
legacy
return await dispatch(context, row, 'edit', (availability) =>
showOwnedAutomation(availability, row.automationId)
)
}
export async function dispatchAutomationRunHistory(
context: AutomationDispatchContext,
row: AutomationDispatchRow,
legacy: () => Promise<AutomationRun[]>
row: AutomationDispatchRow
): Promise<AutomationDispatchResult<AutomationRun[]>> {
return await dispatch(
context,
row,
'history',
(availability) => listOwnedAutomationRuns(availability, context.authority, row.automationId),
legacy
return await dispatch(context, row, 'history', (availability) =>
listOwnedAutomationRuns(availability, row.automationId)
)
}
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import {
buildAutomationRunContextForDestination,
buildAutomationRunContextForRepo
} from './automation-run-context'
import type { AutomationCreateDestination } from './automation-create-destination'
function repo(id: string, path = `/repos/${id}`): Repo {
return {
@@ -73,3 +77,39 @@ describe('buildAutomationRunContextForRepo', () => {
).toBeNull()
})
})
describe('buildAutomationRunContextForDestination', () => {
const destination = {
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
destination: { selector: { kind: 'self' as const } },
entry: {} as AutomationCreateDestination['entry']
}
it('does not borrow a same-repo setup from another host', () => {
const local = setup({
id: 'setup-local',
hostId: 'local',
repoId: 'repo-builder',
path: '/local/orca'
})
const runtime = setup({
id: 'setup-runtime',
hostId: 'runtime:gpu',
executionHostId: 'runtime:gpu',
runtimeOwnerEnvironmentId: 'gpu',
repoId: 'repo-builder',
path: '/runtime/orca'
})
expect(
buildAutomationRunContextForDestination({
repoId: 'repo-builder',
destination,
projectHostSetups: [local, runtime]
})
).toMatchObject({
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-runtime',
path: '/runtime/orca'
})
})
})
@@ -4,6 +4,11 @@ import {
} from '../../../../shared/task-source-context'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
automationCreateSetupMatchesDestination,
type AutomationCreateDestination
} from './automation-create-destination'
export function buildAutomationRunContextForRepo(args: {
repoId: string
@@ -28,3 +33,30 @@ export function buildAutomationRunContextForRepo(args: {
path: setup.path || repo.path
})
}
export function buildAutomationRunContextForDestination(args: {
repoId: string
destination: AutomationCreateDestination
projectHostSetups: readonly ProjectHostSetup[]
worktree?: Worktree | null
}): WorkspaceRunContext | null {
const candidates = args.projectHostSetups.filter(
(candidate) =>
candidate.repoId === args.repoId &&
candidate.setupState === 'ready' &&
automationCreateSetupMatchesDestination(candidate, args.destination)
)
const setup = args.worktree?.projectHostSetupId
? candidates.find((candidate) => candidate.id === args.worktree?.projectHostSetupId)
: candidates[0]
if (!setup) {
return null
}
return buildWorkspaceRunContext({
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: setup.path
})
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector'
describe('runtimeAutomationWorkspaceSelector', () => {
it('prefixes a stored worktree id the way the CLI does', () => {
expect(runtimeAutomationWorkspaceSelector('repo-1::/tmp/orca/feature')).toBe(
'id:repo-1::/tmp/orca/feature'
)
})
it('leaves an already-qualified selector alone', () => {
expect(runtimeAutomationWorkspaceSelector('id:repo-1::/tmp/orca/feature')).toBe(
'id:repo-1::/tmp/orca/feature'
)
expect(runtimeAutomationWorkspaceSelector('path:/tmp/orca/feature')).toBe(
'path:/tmp/orca/feature'
)
})
it('omits an empty workspace', () => {
expect(runtimeAutomationWorkspaceSelector(undefined)).toBeUndefined()
expect(runtimeAutomationWorkspaceSelector(null)).toBeUndefined()
expect(runtimeAutomationWorkspaceSelector('')).toBeUndefined()
})
})
@@ -0,0 +1,16 @@
/**
* Runtime `automation.create` / `update` resolve `workspace` through
* `showManagedWorktree`. The CLI always sends `id:<worktreeId>`; a bare stored
* id is a remote-only miss (`selector_not_found`) because local IPC never
* parses a selector.
*/
const WORKTREE_SELECTOR_PREFIX = /^(id|path|name|branch|issue):/
export function runtimeAutomationWorkspaceSelector(
workspaceId: string | null | undefined
): string | undefined {
if (!workspaceId) {
return undefined
}
return WORKTREE_SELECTOR_PREFIX.test(workspaceId) ? workspaceId : `id:${workspaceId}`
}
@@ -28,6 +28,7 @@ const SSH_OWNER = {
authority: RUNTIME,
selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 }
} as const
const SELF_OWNER = { authority: RUNTIME, selector: { kind: 'self' } } as const
const ALL_CAPABILITIES = {
capabilities: [
@@ -135,6 +136,53 @@ describe('listScopedAutomations', () => {
})
})
describe('listAutomationsForOwner', () => {
// Rule: never trust a scoped list from a server that ignored the selector.
// The Self read degrades to the unscoped list plus a client-side partition.
it('falls back to an unscoped Self read on a host without host scoping', async () => {
const { listAutomationsForOwner } = await client()
getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
callRuntimeRpc.mockResolvedValue({
automations: [
{ id: 'a1', executionTargetType: 'local' },
{ id: 'a2', executionTargetType: 'ssh', executionTargetId: 't1' },
{ id: 'a3', executionTargetType: 'local', schedulerOwner: 'remote_host_service' }
]
})
const result = await listAutomationsForOwner(SELF_OWNER)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'automation.list',
null,
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
)
expect(result.automations.map((automation) => automation.id)).toEqual(['a1'])
expect(result.items).toEqual([{ automationId: 'a1', selector: { kind: 'self' } }])
})
it('keeps failing closed for an SSH owner on a host without host scoping', async () => {
const { listAutomationsForOwner, AutomationHostScopeUnsupportedError } = await client()
getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
await expect(listAutomationsForOwner(SSH_OWNER)).rejects.toBeInstanceOf(
AutomationHostScopeUnsupportedError
)
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
it('keeps the scoped read on a capable host', async () => {
const { listAutomationsForOwner } = await client()
getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES)
callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 })
await listAutomationsForOwner(SELF_OWNER)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'automation.list',
{ selector: { kind: 'self' } },
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
)
})
})
describe('owner-fenced mutations', () => {
it('sends the captured owner with the mutation', async () => {
const { updateAutomationForOwner } = await client()
@@ -160,6 +208,104 @@ describe('owner-fenced mutations', () => {
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
// Self records live on the answering authority and mutate by id under the
// pairing-revision fence, so a pre-fencing server stays fully usable for them.
it('mutates Self on a runtime without owner fencing instead of failing closed', async () => {
const { deleteAutomationForOwner, runAutomationNowForOwner, updateAutomationForOwner } =
await client()
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' }, run: { id: 'r1' } })
await updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false })
await deleteAutomationForOwner(SELF_OWNER, 'a1')
await runAutomationNowForOwner(SELF_OWNER, 'a1')
expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled()
expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({
id: 'a1',
updates: { enabled: false },
expectedOwner: { selector: { kind: 'self' } },
destination: undefined
})
expect(callRuntimeRpc.mock.calls.map((call) => call[3])).toEqual(
Array.from({ length: 3 }, () =>
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
)
)
})
// An old server ignores `destination` and would silently leave the record in
// place, so a Self move still needs the fenced contract.
it('still fails a Self destination move closed on a host without owner fencing', async () => {
const { updateAutomationForOwner, AutomationHostScopeUnsupportedError } = await client()
getRuntimeEnvironmentStatus.mockResolvedValue({
capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY]
})
await expect(
updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false }, { selector: { kind: 'self' } })
).rejects.toBeInstanceOf(AutomationHostScopeUnsupportedError)
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
it('creates at a Self destination without probing for owner fencing', async () => {
const { createAutomationForDestination } = await client()
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
await createAutomationForDestination(
RUNTIME,
{ name: 'n', prompt: 'p', projectId: 'repo-1' } as never,
{ selector: { kind: 'self' } }
)
expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled()
expect(callRuntimeRpc).toHaveBeenCalledTimes(1)
})
it('sends an existing workspace as an id: selector on the runtime', async () => {
const { createAutomationForDestination } = await client()
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
await createAutomationForDestination(
RUNTIME,
{
name: 'n',
prompt: 'p',
projectId: 'repo-1',
workspaceMode: 'existing',
workspaceId: 'repo-1::/tmp/orca/feature'
} as never,
{ selector: { kind: 'self' } }
)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'automation.create',
expect.objectContaining({
repo: 'repo-1',
workspace: 'id:repo-1::/tmp/orca/feature',
destination: { selector: { kind: 'self' } }
}),
expect.anything()
)
})
it('translates edited project and workspace fields for the runtime RPC', async () => {
const { updateAutomationForOwner } = await client()
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
await updateAutomationForOwner(SELF_OWNER, 'a1', {
projectId: 'repo-2',
workspaceMode: 'existing',
workspaceId: 'repo-2::/tmp/orca/other'
})
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'automation.update',
expect.objectContaining({
updates: {
repo: 'repo-2',
workspaceMode: 'existing',
workspace: 'id:repo-2::/tmp/orca/other'
}
}),
expect.anything()
)
})
it('fences a desktop mutation through IPC with the same precondition', async () => {
const { deleteAutomationForOwner, updateAutomationForOwner } = await client()
update.mockResolvedValue({ id: 'a1' })
@@ -9,11 +9,7 @@
* that would attribute other hosts' automations to the selected one.
*/
import {
callRuntimeRpc,
getRuntimeEnvironmentStatus,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import type {
Automation,
AutomationCreateInput,
@@ -25,6 +21,7 @@ import type {
AutomationListScopeSelector
} from '../../../../shared/automation-list-scope'
import { validateAutomationListResponse } from '../../../../shared/automation-list-response'
import { partitionLegacyAutomationList } from '../../../../shared/automation-legacy-list-partition'
import type {
AutomationAuthorityRef,
AutomationOwnerRef
@@ -37,24 +34,18 @@ import type {
} from '../../../../shared/automation-owner-precondition'
import {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
type RuntimeCapability
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE
} from '../../../../shared/protocol-version'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { automationHostDiagnostics } from './automation-host-diagnostics'
const REQUEST_TIMEOUT_MS = 15_000
export class AutomationHostScopeUnsupportedError extends Error {
readonly code = 'unsupported_host_scope'
constructor(message: string) {
super(message)
this.name = 'AutomationHostScopeUnsupportedError'
}
}
import {
AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS as REQUEST_TIMEOUT_MS,
assertAuthorityCapability,
assertOwnerFencingSupported,
AutomationHostScopeUnsupportedError,
requiresOwnerFencing
} from './automation-authority-capability'
import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector'
/** Re-exported so existing call sites keep importing the error from this module. */
export { AutomationHostScopeUnsupportedError } from './automation-authority-capability'
export class AutomationListResponseError extends Error {
readonly code = 'invalid_response'
@@ -118,41 +109,6 @@ async function callAuthority<TResult>(
})
}
/**
* Fails closed on a missing capability, but only on a *known* absence: an
* unreachable authority must classify as unavailable and retry, not as an old
* server the user is told to upgrade.
*
* The probe is counted here because it is counted nowhere else: it deliberately
* re-fetches on every call and rides outside the scheduler's four-slot pool, so
* an instrument that saw only pooled work would report half the relay traffic a
* 50-host refresh actually costs.
*/
async function assertAuthorityCapability(
authority: AutomationAuthorityRef,
capability: RuntimeCapability,
message: string
): Promise<void> {
if (authority.kind !== 'runtime') {
return
}
automationHostDiagnostics.recordCapabilityProbe({
authorityKey: automationAuthorityCatalogKey(authority)
})
const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS)
if (!status.capabilities?.includes(capability)) {
throw new AutomationHostScopeUnsupportedError(message)
}
}
async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise<void> {
await assertAuthorityCapability(
authority,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE
)
}
function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList {
const validation = validateAutomationListResponse(raw, selector)
if (!validation.ok) {
@@ -180,8 +136,9 @@ export async function listScopedAutomations(
/**
* The one unscoped request an old runtime gets per refresh cycle. Its result is
* partitioned client-side into every requested entry; it carries no owners and
* no usage projection, so those rows stay view-only.
* partitioned client-side into every requested entry; it carries no generations
* and no usage projection, so its SSH rows stay view-only. The partition may
* still assign owners where none are needed on the wire — Self.
*/
export async function listLegacyAutomations(
authority: AutomationAuthorityRef
@@ -198,11 +155,51 @@ export async function listLegacyAutomations(
return automations as Automation[]
}
/**
* Self read on a server without host-scoped lists: the unscoped list is asked
* for and partitioned client-side, never a scoped request the server would
* answer with its whole store. Project evidence lives on the answering
* authority, so none is available here and every plausible local record reads
* as Self — the same id-keyed trust the legacy server itself applies, and the
* caller looks rows up by id.
*/
async function listLegacySelfAutomations(
authority: AutomationAuthorityRef
): Promise<ScopedAutomationList> {
const automations = await listLegacyAutomations(authority)
const partition = partitionLegacyAutomationList(automations, {
repoConnectionId: () => null,
projectsAuthoritative: false
})
const selfRows = partition.rows.filter((row) => row.selector.kind === 'self')
return {
automations: selfRows.map((row) => row.automation),
items: selfRows.map((row) => ({
automationId: row.automation.id,
selector: { kind: 'self' }
})),
invalidRows: 0
}
}
/** Convenience wrapper for a row's own host; orphan scopes are requested with the selector form. */
export async function listAutomationsForOwner(
owner: AutomationOwnerRef
): Promise<ScopedAutomationList> {
return await listScopedAutomations(owner.authority, scopeSelector(owner))
try {
return await listScopedAutomations(owner.authority, scopeSelector(owner))
} catch (error) {
// Only Self may degrade: its records live on the authority the request is
// already pinned to. An SSH owner keeps failing closed on an old server.
if (
owner.authority.kind === 'runtime' &&
owner.selector.kind === 'self' &&
error instanceof AutomationHostScopeUnsupportedError
) {
return await listLegacySelfAutomations(owner.authority)
}
throw error
}
}
/**
@@ -246,13 +243,22 @@ async function updateFenced(
expectedOwner: AutomationOwnerPrecondition,
destination?: AutomationDestination
): Promise<Automation> {
await assertOwnerFencingSupported(authority)
if (requiresOwnerFencing(expectedOwner, destination)) {
await assertOwnerFencingSupported(authority)
}
if (authority.kind === 'desktop') {
return await window.api.automations.update({ id, updates, expectedOwner, destination })
}
const { projectId, workspaceId, ...runtimeUpdates } = updates
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.update', {
id,
updates,
updates: {
...runtimeUpdates,
...('projectId' in updates ? { repo: projectId } : {}),
...('workspaceId' in updates
? { workspace: runtimeAutomationWorkspaceSelector(workspaceId) }
: {})
},
expectedOwner,
destination
})
@@ -264,7 +270,9 @@ async function deleteFenced(
id: string,
expectedOwner: AutomationOwnerPrecondition
): Promise<void> {
await assertOwnerFencingSupported(authority)
if (requiresOwnerFencing(expectedOwner)) {
await assertOwnerFencingSupported(authority)
}
if (authority.kind === 'desktop') {
await window.api.automations.delete({ id, expectedOwner })
return
@@ -325,8 +333,10 @@ export async function runAutomationNowForOwner(
owner: AutomationOwnerRef,
id: string
): Promise<AutomationRun> {
await assertOwnerFencingSupported(owner.authority)
const expectedOwner = ownerPrecondition(owner)
if (requiresOwnerFencing(expectedOwner)) {
await assertOwnerFencingSupported(owner.authority)
}
if (owner.authority.kind === 'desktop') {
return await window.api.automations.runNow({ id, expectedOwner })
}
@@ -342,7 +352,11 @@ export async function createAutomationForDestination(
input: AutomationCreateInput,
destination: AutomationDestination
): Promise<Automation> {
await assertOwnerFencingSupported(authority)
// A Self destination degrades safely on an old server: with the field ignored,
// the record still lands on that authority's local store — which is Self.
if (destination.selector.kind !== 'self') {
await assertOwnerFencingSupported(authority)
}
if (authority.kind === 'desktop') {
return await window.api.automations.create(input, { destination })
}
@@ -350,7 +364,10 @@ export async function createAutomationForDestination(
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
...rest,
repo: projectId,
workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined,
workspace:
input.workspaceMode === 'existing'
? runtimeAutomationWorkspaceSelector(workspaceId)
: undefined,
destination
})
return result.automation
@@ -30,6 +30,7 @@ describe('automation write invalidation', () => {
it('takes the host from the row owner, not the active authority', () => {
const captured: AutomationCapturedOwner = {
authority: RUNTIME,
owner: {
authority: RUNTIME,
selector: { kind: 'ssh', targetId: 'target-1', targetGeneration: 3 }
@@ -45,18 +46,21 @@ describe('automation write invalidation', () => {
it('falls back to the captured selector under the acting authority', () => {
const captured: AutomationCapturedOwner = {
authority: RUNTIME,
owner: null,
selector: { kind: 'orphan', issue: 'target-missing' }
}
expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({
authority: { kind: 'desktop' },
authority: { kind: 'runtime', environmentId: 'env-1' },
selector: { kind: 'orphan' }
})
})
it('names no host for a row that carried no metadata', () => {
expect(automationRowCatalogRef({ owner: null, selector: null }, DESKTOP)).toBeNull()
expect(
automationRowCatalogRef({ authority: null, owner: null, selector: null }, DESKTOP)
).toBeNull()
})
it('widens to the whole authority rather than guessing a selector', () => {
@@ -44,7 +44,7 @@ export function automationRowCatalogRef(
if (!captured.selector) {
return null
}
const stable = stableAutomationAuthorityRef(authority)
const stable = stableAutomationAuthorityRef(captured.authority ?? authority)
// Rebuilt per variant rather than spread: the stable ref drops the generation and the
// orphan issue, and a widened `kind` would not discriminate.
switch (captured.selector.kind) {
@@ -200,6 +200,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
const repo = makeRepo()
const worktree = makeWorktree()
const noop = (): void => undefined
const fetchWorktrees = async (): Promise<boolean> => true
return {
state: {
repos: [repo],
@@ -209,7 +210,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
activeWorktreeId: null,
fetchWorktrees: noop,
fetchWorktrees,
fetchAllWorktrees: noop,
startupWorktreeRefreshCompleted: true,
updateSettings: noop,
@@ -25,6 +25,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { AutomationListRow } from './automation-list-row-identity'
import type { Worktree } from '../../../../shared/worktree/types'
export type ListPanelProps = {
filteredAutomations: Automation[]
@@ -88,6 +89,7 @@ export type EditorDialogProps = {
createDestination?: AutomationCreateDestinationControl
notice?: { message: string } | null
repos?: { id: string }[]
worktrees?: Worktree[]
draft?: { projectId: string; workspaceId: string }
onSave: () => void
onDraftChange: (updater: (current: unknown) => unknown) => void
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
describe('AUTOMATIONS_TABLE_GRID_CLASS', () => {
it('shares leftover width across text columns instead of capping Project', () => {
expect(AUTOMATIONS_TABLE_GRID_CLASS).toContain('min-w-[58rem]')
expect(AUTOMATIONS_TABLE_GRID_CLASS).toMatch(/minmax\(8rem,1\.2fr\).*minmax\(6\.5rem,0\.9fr\)/)
})
})
@@ -1,4 +1,5 @@
/** Column template for the automations list table; shared chrome lives in @/lib/list-table-layout. */
// Name | Schedule | Project | Next run | Last run | Status | Agent | Actions
// Name | Schedule | Project | Host | Next run | Last run | Status | Agent | Actions
// fr maxes share leftover width; rem maxes dumped it into Name and truncated Project.
export const AUTOMATIONS_TABLE_GRID_CLASS =
'grid grid-cols-[minmax(0,1.4fr)_minmax(7.5rem,10rem)_minmax(4.5rem,8rem)_minmax(8.5rem,1fr)_minmax(8rem,11rem)_minmax(4.5rem,6rem)_2.5rem_2.5rem]'
'grid w-full min-w-[58rem] grid-cols-[minmax(8rem,1.2fr)_minmax(7.5rem,0.95fr)_minmax(6.5rem,0.9fr)_minmax(5rem,0.7fr)_minmax(7rem,0.85fr)_minmax(6.5rem,0.8fr)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]'
@@ -10,15 +10,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { translate } from '@/i18n/i18n'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type {
AutomationHostCatalog,
AutomationHostCatalogEntry
} from './automation-host-catalog-types'
import type { AutomationAuthorityRepoTables } from './automation-authority-identity'
import {
automationCreateEligibleProjects,
automationCreateProjectMismatch,
preselectAutomationCreateHost,
resolveAutomationCreateDestination,
revalidateAutomationCreateDestination,
@@ -49,8 +48,8 @@ export type AutomationCreateDestinationInput = {
/** Non-null only when the list is filtered to one concrete host. */
filterStableKey: string | null
activeWorkspaceStableKey: string | null
repoTables: AutomationAuthorityRepoTables
projects: readonly Repo[]
projectHostSetups: readonly ProjectHostSetup[]
}
export type AutomationCreateDestinationState = {
@@ -90,16 +89,20 @@ export function useAutomationCreateDestination(
entries,
filterStableKey,
activeWorkspaceStableKey,
repoTables,
projects
projects,
projectHostSetups
} = input
const [captured, setCaptured] = useState<AutomationCreateDestination | null>(null)
const [reason, setReason] = useState<AutomationCreateDestinationChoiceReason>('unselected')
const [previousOpen, setPreviousOpen] = useState(open)
if (previousOpen !== open) {
setPreviousOpen(open)
setCaptured(null)
setReason('unselected')
}
useEffect(() => {
if (!open) {
setCaptured(null)
setReason('unselected')
return
}
if (captured) {
@@ -141,8 +144,10 @@ export function useAutomationCreateDestination(
// when the handler was created could be an incarnation old.
const entriesRef = useRef(entries)
entriesRef.current = entries
const repoTablesRef = useRef(repoTables)
repoTablesRef.current = repoTables
const projectsRef = useRef(projects)
projectsRef.current = projects
const projectHostSetupsRef = useRef(projectHostSetups)
projectHostSetupsRef.current = projectHostSetups
const check = useCallback(
(projectId: string): AutomationCreateDestinationCheck => {
@@ -166,7 +171,12 @@ export function useAutomationCreateDestination(
if (revalidated.status !== 'ready') {
return { ok: false, notice: ownerNotice(choiceMessage(revalidated.reason)) }
}
if (automationCreateProjectMismatch(repoTablesRef.current, revalidated, projectId)) {
const project = automationCreateEligibleProjects(
revalidated,
projectsRef.current,
projectHostSetupsRef.current
).find((candidate) => candidate.id === projectId)
if (!project) {
return {
ok: false,
notice: ownerNotice(
@@ -185,9 +195,9 @@ export function useAutomationCreateDestination(
const eligibleProjects = useMemo(
() =>
resolution.status === 'ready'
? automationCreateEligibleProjects(repoTables, resolution, projects)
? automationCreateEligibleProjects(resolution, projects, projectHostSetups)
: projects,
[projects, repoTables, resolution]
[projectHostSetups, projects, resolution]
)
return {
@@ -21,7 +21,9 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
const mocks = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
callRuntimeRpc: vi.fn(),
getRuntimeEnvironmentStatus: vi.fn()
getRuntimeEnvironmentStatus: vi.fn(),
connectAutomationHostSshTarget: vi.fn(),
connectAutomationHostRuntime: vi.fn()
}))
vi.mock('@/store', () => {
@@ -37,6 +39,12 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
hasRuntimeRpcErrorCode: () => false
}))
vi.mock('./automation-host-recovery-connect', () => ({
connectAutomationHostSshTarget: (...args: unknown[]) =>
mocks.connectAutomationHostSshTarget(...args),
connectAutomationHostRuntime: (...args: unknown[]) => mocks.connectAutomationHostRuntime(...args)
}))
import { useAutomationHostCatalog } from './use-automation-host-catalog'
const ENVIRONMENT_ID = 'env-1'
@@ -140,6 +148,10 @@ beforeEach(() => {
view = null
mocks.callRuntimeRpc.mockReset()
mocks.getRuntimeEnvironmentStatus.mockReset()
mocks.connectAutomationHostSshTarget.mockReset()
mocks.connectAutomationHostRuntime.mockReset()
mocks.connectAutomationHostSshTarget.mockResolvedValue(undefined)
mocks.connectAutomationHostRuntime.mockResolvedValue(undefined)
mocks.callRuntimeRpc.mockResolvedValue({ automations: [legacyAutomation()] })
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
Object.assign(window, {
@@ -197,3 +209,61 @@ describe('useAutomationHostCatalog legacy partitioning', () => {
expect(runtimeSelfAutomationIds()).toEqual(['a-legacy'])
})
})
describe('useAutomationHostCatalog recover', () => {
it('reconnects an unreachable runtime through the status-recording helper', async () => {
await renderCatalog([])
const runtimeSelf = view?.catalog.entries.find(
(entry) =>
entry.stableRef.authority.kind === 'runtime' && entry.stableRef.selector.kind === 'self'
)
expect(runtimeSelf).toBeDefined()
await act(async () => {
await view?.recover('reconnect', {
...runtimeSelf!,
authorityHealth: 'unavailable'
})
})
expect(mocks.connectAutomationHostRuntime).toHaveBeenCalledWith(ENVIRONMENT_ID)
expect(mocks.connectAutomationHostSshTarget).not.toHaveBeenCalled()
expect(window.api.runtimeEnvironments.connect).not.toHaveBeenCalled()
})
it('reconnects a desktop SSH target through the SSH helper, not a raw ssh.connect', async () => {
mocks.state = {
...storeState([]),
sshTargetLabels: new Map([['t1', 'Linux']]),
sshTargetGenerations: new Map([['t1', 1]]),
sshConnectionStates: new Map([['t1', { status: 'disconnected' }]])
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<Harness />)
})
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
const sshEntry = view?.catalog.entries.find(
(entry) =>
entry.stableRef.selector.kind === 'ssh' && entry.stableRef.selector.targetId === 't1'
)
expect(sshEntry).toBeDefined()
await act(async () => {
await view?.recover('reconnect', sshEntry)
})
expect(mocks.connectAutomationHostSshTarget).toHaveBeenCalledWith({
targetId: 't1',
environmentId: undefined
})
expect(window.api.ssh.connect).not.toHaveBeenCalled()
})
})
@@ -35,6 +35,10 @@ import {
runAutomationHostRecovery,
type AutomationHostRecoveryDeps
} from './automation-host-recovery'
import {
connectAutomationHostRuntime,
connectAutomationHostSshTarget
} from './automation-host-recovery-connect'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
import {
automationAuthorityPartitionContext,
@@ -61,7 +65,10 @@ export type AutomationHostCatalogView = {
loadCounts: AutomationHostLoadCounts
selectHost: (filter: AutomationHostFilter) => void
/** Runs the recovery verb the notice or empty state offered for a host. */
recover: (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => void
recover: (
action: AutomationHostRecoveryAction,
entry?: AutomationHostCatalogEntry | null
) => Promise<void>
/** Manual refresh of every host in view, bypassing TTL where reachable. */
refreshHosts: () => void
/**
@@ -217,12 +224,9 @@ export function useAutomationHostCatalog(
retry: (entry) => {
void controller.scheduler.retry(automationHostFetchTarget(entry, pairingRevision))
},
connectSshTarget: (targetId) => {
void window.api.ssh.connect({ targetId })
},
connectRuntimeEnvironment: (environmentId) => {
void window.api.runtimeEnvironments.connect({ selector: environmentId })
},
connectSshTarget: (targetId, environmentId) =>
connectAutomationHostSshTarget({ targetId, environmentId }),
connectRuntimeEnvironment: (environmentId) => connectAutomationHostRuntime(environmentId),
openSettings: (target) => {
openSettingsPage()
openSettingsTarget(target)
@@ -232,9 +236,8 @@ export function useAutomationHostCatalog(
)
const recover = useCallback(
(action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => {
runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps)
},
(action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) =>
runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps),
[recoveryDeps, resolution.entry]
)
const refreshHosts = useCallback(() => {
@@ -0,0 +1,93 @@
import React from 'react'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import {
getAutomationListArrowNavigationTarget,
type AutomationListArrowKey
} from './automation-list-keyboard-navigation'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationPaneTab } from './automation-page-state'
type KeyboardNavigationInput = {
filteredRows: readonly AutomationListRow[]
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
selectedRowKey: string | null
selectedExternalKey: string | null
selectAutomationRow: (rowKey: string | null) => void
selectExternalKey: (externalKey: string | null) => void
setActivePaneTab: (tab: AutomationPaneTab) => void
listRef: React.RefObject<HTMLDivElement | null>
}
export function useAutomationListKeyboardNavigation({
filteredRows,
filteredExternalAutomationEntries,
selectedRowKey,
selectedExternalKey,
selectAutomationRow,
selectExternalKey,
setActivePaneTab,
listRef
}: KeyboardNavigationInput): (key: AutomationListArrowKey) => void {
const pendingScrollRef = React.useRef(false)
const visibleItems = React.useMemo(
() => [
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
...filteredExternalAutomationEntries.map((entry) => ({
kind: 'external' as const,
id: entry.key
}))
],
[filteredExternalAutomationEntries, filteredRows]
)
const scrollCurrentIntoView = React.useCallback(() => {
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
}, [listRef])
React.useEffect(() => {
if (!pendingScrollRef.current) {
return
}
pendingScrollRef.current = false
scrollCurrentIntoView()
}, [scrollCurrentIntoView, selectedExternalKey, selectedRowKey])
return React.useCallback(
(key) => {
const next = getAutomationListArrowNavigationTarget({
items: visibleItems,
selectedId: selectedRowKey,
selectedExternalKey,
key
})
if (!next) {
return
}
const selected =
next.kind === 'local'
? selectedExternalKey === null && selectedRowKey === next.id
: selectedExternalKey === next.id
if (selected) {
scrollCurrentIntoView()
return
}
pendingScrollRef.current = true
if (next.kind === 'local') {
selectExternalKey(null)
selectAutomationRow(next.id)
return
}
selectAutomationRow(null)
selectExternalKey(next.id)
setActivePaneTab('overview')
},
[
scrollCurrentIntoView,
selectAutomationRow,
selectExternalKey,
selectedExternalKey,
selectedRowKey,
setActivePaneTab,
visibleItems
]
)
}
@@ -46,8 +46,7 @@ function makeInput(
): SelectedAutomationRunHistoryInput {
return {
selected: DESKTOP_ROW,
context: { capturedOwners: new Map(), authority: { kind: 'desktop' } },
legacyTarget: () => ({ kind: 'local' }),
context: { capturedOwners: new Map() },
navigation: null,
reloadToken: 0,
onSettled: vi.fn(),
@@ -168,8 +167,9 @@ describe('useSelectedAutomationRunHistory', () => {
}
const input = makeInput({
context: {
capturedOwners: new Map([[DESKTOP_ROW.key, { owner, selector: { kind: 'self' } }]]),
authority: { kind: 'desktop' }
capturedOwners: new Map([
[DESKTOP_ROW.key, { authority: owner.authority, owner, selector: { kind: 'self' } }]
])
}
})
@@ -10,6 +10,8 @@
import { useEffect, useRef } from 'react'
import type { AutomationRun } from '../../../../shared/automations-types'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner'
import type { AutomationListRow } from './automation-list-row-identity'
import {
@@ -17,15 +19,11 @@ import {
type AutomationDispatchContext,
dispatchAutomationRunHistory
} from './automation-row-action-dispatch'
import {
getAutomationTargetFromHostId,
listAutomationRunsForTarget,
type AutomationHostTarget
} from './automation-host-client'
export type SelectedAutomationRunNavigation = {
automationId: string
hostId?: string | null
authority?: StableAutomationAuthorityRef
} | null
export type SelectedAutomationRunHistoryOutcome = {
@@ -45,8 +43,6 @@ export type SelectedAutomationRunHistoryOutcome = {
export type SelectedAutomationRunHistoryInput = {
selected: AutomationListRow | null
context: AutomationDispatchContext
/** The row's own host, used only when no owner was captured for it. */
legacyTarget: (row: AutomationListRow) => AutomationHostTarget | null
navigation: SelectedAutomationRunNavigation
/** Changing this re-asks the host; it is how a failed read offers Retry. */
reloadToken: number
@@ -60,6 +56,13 @@ function navigationHostId(
return navigation?.automationId === automationId ? (navigation.hostId ?? null) : null
}
function navigationAuthority(
navigation: SelectedAutomationRunNavigation,
automationId: string
): StableAutomationAuthorityRef | null {
return navigation?.automationId === automationId ? (navigation.authority ?? null) : null
}
export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void {
const inputRef = useRef(input)
inputRef.current = input
@@ -67,17 +70,23 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
const automationId = input.selected?.automation.id ?? null
const rowKey = input.selected?.key ?? null
const reloadToken = input.reloadToken
const selectedNavigationAuthority = automationId
? navigationAuthority(input.navigation, automationId)
: null
const fetchKey =
automationId && rowKey
? [
rowKey,
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
selectedNavigationAuthority
? automationAuthorityCatalogKey(selectedNavigationAuthority)
: '',
navigationHostId(input.navigation, automationId) ?? ''
].join('|')
: ''
useEffect(() => {
const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current
const { selected, context, onSettled } = inputRef.current
if (!selected || !automationId || !rowKey) {
onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null })
return
@@ -86,13 +95,7 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
const owner = capturedAutomationOwnerKey(
capturedAutomationOwner(context.capturedOwners, rowKey)
)
const navigationHost = navigationHostId(navigation, automationId)
const target = navigationHost
? getAutomationTargetFromHostId(navigationHost)
: (legacyTarget(selected) ?? { kind: 'local' })
void dispatchAutomationRunHistory(context, { rowKey, automationId }, () =>
listAutomationRunsForTarget(target, automationId)
).then((result) => {
void dispatchAutomationRunHistory(context, { rowKey, automationId }).then((result) => {
if (cancelled) {
return
}
@@ -107,6 +110,5 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
return () => {
cancelled = true
}
// oxlint-disable-next-line react-hooks/exhaustive-deps -- fetchKey is the refetch policy; see the module docblock for why the context object is deliberately not a dependency.
}, [automationId, rowKey, fetchKey, reloadToken])
}
@@ -12,6 +12,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import type * as AutomationHostClient from '@/components/automations/automation-host-client'
import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection'
@@ -50,7 +51,12 @@ async function render(
handlers: {
onOpenAutomation?: (event: React.MouseEvent) => void
onOpenAutomationRun?: (event: React.MouseEvent) => void
} = {}
} = {},
authority: StableAutomationAuthorityRef | null = record.hostId
? record.hostId.startsWith('runtime:')
? { kind: 'runtime', environmentId: record.hostId.slice('runtime:'.length) }
: { kind: 'desktop' }
: null
): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -59,7 +65,11 @@ async function render(
await act(async () => {
root.render(
<TooltipProvider>
<WorktreeCardAutomationDetailSection provenance={record} {...handlers} />
<WorktreeCardAutomationDetailSection
provenance={record}
authority={authority}
{...handlers}
/>
</TooltipProvider>
)
})
@@ -93,6 +103,25 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => {
})
})
it('asks the runtime authority for a runtime-owned SSH workspace', async () => {
mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }])
mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }])
await render(
provenance({ hostId: 'ssh:builder' }),
{},
{
kind: 'runtime',
environmentId: 'gpu'
}
)
expect(mocks.listAutomationsForTarget).toHaveBeenCalledWith({
kind: 'environment',
environmentId: 'gpu'
})
})
it('reports a miss as uncheckable when no host was ever recorded', async () => {
const container = await render(provenance())
@@ -107,20 +136,4 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => {
expect(container.textContent).toContain('Automation no longer available.')
})
it('offers both affordances when a record with no recorded host is found anyway', async () => {
// The missing host degrades the *miss*, never the find: an automation the
// desktop positively returned is evidence, whatever the read was aimed by.
mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }])
mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }])
const container = await render(provenance(), {
onOpenAutomation: vi.fn(),
onOpenAutomationRun: vi.fn()
})
expect(container.querySelector('[aria-label="Open automation"]')).not.toBeNull()
expect(container.querySelector('[aria-label="Open run"]')).not.toBeNull()
expect(container.textContent).not.toContain('could not be checked')
})
})
@@ -1,6 +1,7 @@
import React from 'react'
import { CalendarClock, PlayCircle } from 'lucide-react'
import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import {
WorktreeCardDetailSection,
WorktreeCardDetailSectionContent
@@ -8,13 +9,14 @@ import {
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
import { translate } from '@/i18n/i18n'
import {
getAutomationTargetFromHostId,
getAutomationTargetFromAuthority,
listAutomationRunsForTarget,
listAutomationsForTarget
} from '@/components/automations/automation-host-client'
type WorktreeCardAutomationDetailSectionProps = {
provenance: AutomationWorkspaceProvenance
authority: StableAutomationAuthorityRef | null
onOpenAutomation?: (event: React.MouseEvent) => void
onOpenAutomationRun?: (event: React.MouseEvent) => void
}
@@ -27,29 +29,36 @@ type AutomationProvenanceAvailability =
export function WorktreeCardAutomationDetailSection({
provenance,
authority,
onOpenAutomation,
onOpenAutomationRun
}: WorktreeCardAutomationDetailSectionProps): React.JSX.Element {
const [availability, setAvailability] = React.useState<AutomationProvenanceAvailability>({
status: 'checking'
})
const authorityKind = authority?.kind ?? null
const authorityEnvironmentId = authority?.kind === 'runtime' ? authority.environmentId : null
React.useEffect(() => {
let cancelled = false
async function resolveAvailability(): Promise<void> {
setAvailability({ status: 'checking' })
try {
// Why not the workspace's host: the automation's host is recorded on the
// provenance, and an automation can land a workspace somewhere else
// entirely — substituting one for the other asks the wrong machine.
const target = getAutomationTargetFromHostId(provenance.hostId)
// The caller resolves portable provenance against this client's runtime owner.
if (!authorityKind) {
setAvailability({ status: 'unavailable' })
return
}
const target = getAutomationTargetFromAuthority(
authorityKind === 'runtime'
? { kind: 'runtime', environmentId: authorityEnvironmentId! }
: { kind: 'desktop' }
)
const automations = await listAutomationsForTarget(target)
const automation = automations.find((entry) => entry.id === provenance.automationId)
if (!automation) {
if (!cancelled) {
// With no recorded host this read went to the desktop on no evidence,
// so a miss here is "could not check", never "removed".
setAvailability({ status: provenance.hostId ? 'automation-missing' : 'unavailable' })
setAvailability({ status: 'automation-missing' })
}
return
}
@@ -71,7 +80,7 @@ export function WorktreeCardAutomationDetailSection({
return () => {
cancelled = true
}
}, [provenance.automationId, provenance.automationRunId, provenance.hostId])
}, [authorityEnvironmentId, authorityKind, provenance.automationId, provenance.automationRunId])
const canOpenAutomation = availability.status === 'available'
const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable
@@ -56,6 +56,7 @@ export function WorktreeCardDetailsHover({
review,
comment,
automationProvenance,
automationAuthority,
cliProvenance,
children,
branchName,
@@ -313,6 +314,7 @@ export function WorktreeCardDetailsHover({
{automationProvenance && (
<WorktreeCardAutomationDetailSection
provenance={automationProvenance}
authority={automationAuthority ?? null}
onOpenAutomation={onOpenAutomation ? dismissAndRun(onOpenAutomation) : undefined}
onOpenAutomationRun={
onOpenAutomationRun ? dismissAndRun(onOpenAutomationRun) : undefined
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
import { automationWorkspaceStorageCatalogRef } from '../../../../shared/automation-workspace-provenance'
import {
isRuntimeOwnedSshTargetId,
parseExecutionHostId,
@@ -79,14 +80,19 @@ export function useWorktreeCardFoundation({
const handleOpenAutomation = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const automationId = worktree.automationProvenance?.automationId
if (!automationId) {
const provenance = worktree.automationProvenance
if (!provenance) {
return
}
const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId
const hostId = provenance.hostId ?? worktree.hostId
const catalogRef = automationWorkspaceStorageCatalogRef(
provenance,
worktree.runtimeOwnerEnvironmentId
)
setPendingAutomationRunNavigation({
automationId,
automationId: provenance.automationId,
runId: null,
...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}),
...(hostId ? { hostId } : {})
})
openAutomationsPage()
@@ -94,9 +100,9 @@ export function useWorktreeCardFoundation({
[
openAutomationsPage,
setPendingAutomationRunNavigation,
worktree.automationProvenance?.automationId,
worktree.automationProvenance?.hostId,
worktree.hostId
worktree.automationProvenance,
worktree.hostId,
worktree.runtimeOwnerEnvironmentId
]
)
@@ -108,9 +114,14 @@ export function useWorktreeCardFoundation({
return
}
const hostId = provenance.hostId ?? worktree.hostId
const catalogRef = automationWorkspaceStorageCatalogRef(
provenance,
worktree.runtimeOwnerEnvironmentId
)
setPendingAutomationRunNavigation({
automationId: provenance.automationId,
runId: provenance.automationRunId,
...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}),
...(hostId ? { hostId } : {})
})
openAutomationsPage()
@@ -119,7 +130,8 @@ export function useWorktreeCardFoundation({
openAutomationsPage,
setPendingAutomationRunNavigation,
worktree.automationProvenance,
worktree.hostId
worktree.hostId,
worktree.runtimeOwnerEnvironmentId
]
)
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import { automationWorkspaceStorageAuthority } from '../../../../shared/automation-workspace-provenance'
import { hasWorktreeCardDetails } from './WorktreeCardMeta'
import { usePromptCacheCountdownStartedAt } from './CacheTimer'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
@@ -83,6 +84,12 @@ export function useWorktreeCardSecondaryDetails({
const metaJiraIssue = showJiraIssue ? hoverJiraIssue : null
const metaReview = showPR ? hoverReview : null
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
const automationAuthority = metaAutomationProvenance
? automationWorkspaceStorageAuthority(
metaAutomationProvenance,
worktree.runtimeOwnerEnvironmentId
)
: null
const metaCliProvenance = showCli ? worktree.cliProvenance : null
const metaComment = showComment ? hoverComment : null
const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
@@ -208,6 +215,7 @@ export function useWorktreeCardSecondaryDetails({
metaJiraIssue,
metaReview,
metaAutomationProvenance,
automationAuthority,
metaCliProvenance,
metaComment,
showInlineAgentList,
@@ -4,6 +4,7 @@ import type {
CliWorkspaceProvenance
} from '../../../../shared/worktree/types'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
export type WorktreeCardIssueDisplay =
@@ -37,6 +38,7 @@ export type WorktreeCardMetaBadgesProps = {
review: WorktreeCardPrDisplay | null
comment: string | null
automationProvenance?: AutomationWorkspaceProvenance | null
automationAuthority?: StableAutomationAuthorityRef | null
cliProvenance?: CliWorkspaceProvenance | null
}
@@ -47,6 +47,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
metaReview,
metaComment,
metaAutomationProvenance,
automationAuthority,
metaCliProvenance,
hoverIssue,
hoverLinearIssue,
@@ -152,6 +153,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
review={metaReview}
comment={metaComment}
automationProvenance={metaAutomationProvenance}
automationAuthority={automationAuthority}
cliProvenance={metaCliProvenance}
branchName={showBranchIdentityHover ? branch : undefined}
workspaceTitle={worktree.displayName}
@@ -223,6 +225,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
review={metaReview}
comment={metaComment}
automationProvenance={metaAutomationProvenance}
automationAuthority={automationAuthority}
cliProvenance={metaCliProvenance}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
hoverControl={detailsHoverControl}
+9 -3
View File
@@ -15226,7 +15226,9 @@
"newAutomation": "New Automation",
"rowActions": "Automation actions",
"tableLastRun": "Last run",
"noListMatches": "No automations match."
"noListMatches": "No automations match.",
"tableHost": "Host",
"refreshAutomations": "Refresh automations"
},
"AutomationsPageSkeleton": {
"55527b7bcf": "Loading automations"
@@ -15465,11 +15467,14 @@
"action": {
"retry": "Retry",
"reconnect": "Reconnect",
"updateServer": "Update server"
"updateServer": "Update server",
"runtimeConnectFailed": "Remote host is not reachable",
"sshConnectFailed": "SSH connection failed"
}
},
"ownerAction": {
"failed": "That automation action did not complete."
"failed": "That automation action did not complete.",
"workspaceNotFound": "We couldn't find that workspace on this host. Choose a workspace that exists here and try again."
},
"externalScope": {
"targetHidden": "This host is managed by Orca, so its external automation managers are not listed in this release.",
@@ -15496,6 +15501,7 @@
"unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.",
"stale": "{host} changed while this form was open. Choose it again before saving.",
"projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.",
"workspacesUnverified": "We couldn't verify workspaces on {host}. Check the connection and try again.",
"noProjects": "No projects are set up on {host}. Add one there, or choose another host."
}
},
+5 -1
View File
@@ -13822,7 +13822,11 @@
"tableStatus": "Estado",
"tableActions": "Acciones",
"newAutomation": "Nueva automatización",
"rowActions": "Acciones de automatización"
"rowActions": "Acciones de automatización",
"tableLastRun": "Última ejecución",
"noListMatches": "Ninguna automatización coincide.",
"tableHost": "Host",
"refreshAutomations": "Actualizar automatizaciones"
},
"CreateFromPicker": {
"f061f49e3f": "Buscar ramas del repositorio...",
+5 -1
View File
@@ -13822,7 +13822,11 @@
"tableStatus": "ステータス",
"tableActions": "操作",
"newAutomation": "新しい自動化",
"rowActions": "自動化の操作"
"rowActions": "自動化の操作",
"tableLastRun": "前回の実行",
"noListMatches": "一致する自動化はありません。",
"tableHost": "ホスト",
"refreshAutomations": "自動化を更新"
},
"CreateFromPicker": {
"f061f49e3f": "リポジトリブランチを検索…",
+5 -1
View File
@@ -13851,7 +13851,11 @@
"tableStatus": "상태",
"tableActions": "작업",
"newAutomation": "새 자동화",
"rowActions": "자동화 작업"
"rowActions": "자동화 작업",
"tableLastRun": "마지막 실행",
"noListMatches": "일치하는 자동화가 없습니다.",
"tableHost": "호스트",
"refreshAutomations": "자동화 새로고침"
},
"CreateFromPicker": {
"f061f49e3f": "repo 브랜치 검색...",
+5 -1
View File
@@ -13842,7 +13842,11 @@
"tableStatus": "状态",
"tableActions": "操作",
"newAutomation": "新建自动化",
"rowActions": "自动化操作"
"rowActions": "自动化操作",
"tableLastRun": "上次运行",
"noListMatches": "没有匹配的自动化。",
"tableHost": "主机",
"refreshAutomations": "刷新自动化"
},
"CreateFromPicker": {
"f061f49e3f": "搜索仓库分支...",

Some files were not shown because too many files have changed in this diff Show More