diff --git a/automations-page.yaml b/automations-page.yaml
new file mode 100644
index 00000000000..ab756f65eab
--- /dev/null
+++ b/automations-page.yaml
@@ -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]
\ No newline at end of file
diff --git a/autos-picker.yaml b/autos-picker.yaml
new file mode 100644
index 00000000000..e3595a195a6
--- /dev/null
+++ b/autos-picker.yaml
@@ -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]
\ No newline at end of file
diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md
index c186cc67c13..32ae02915aa 100644
--- a/docs/automations-all-hosts-design.md
+++ b/docs/automations-all-hosts-design.md
@@ -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.
diff --git a/src/cli/automation-destination.ts b/src/cli/automation-destination.ts
index 18a9b435594..27bef392aca 100644
--- a/src/cli/automation-destination.ts
+++ b/src/cli/automation-destination.ts
@@ -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 {
+async function sshTargetGeneration(client: RuntimeClient, targetId: string): Promise {
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 } }
}
diff --git a/src/cli/handlers/automation-destination-fencing.test.ts b/src/cli/handlers/automation-destination-fencing.test.ts
index ba2947e716b..66ea8d5aee9 100644
--- a/src/cli/handlers/automation-destination-fencing.test.ts
+++ b/src/cli/handlers/automation-destination-fencing.test.ts
@@ -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 () => {
diff --git a/src/main/automations/external-automation-probe-scheduler.test.ts b/src/main/automations/external-automation-probe-scheduler.test.ts
index ff56e9242cb..c970d4ba12d 100644
--- a/src/main/automations/external-automation-probe-scheduler.test.ts
+++ b/src/main/automations/external-automation-probe-scheduler.test.ts
@@ -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()
diff --git a/src/main/automations/external-automation-probe-scheduler.ts b/src/main/automations/external-automation-probe-scheduler.ts
index ad5a33046f5..653450195cc 100644
--- a/src/main/automations/external-automation-probe-scheduler.ts
+++ b/src/main/automations/external-automation-probe-scheduler.ts
@@ -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()
private readonly shared = new Map>()
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
}
- /** 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
+ 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. */
diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts
index dbf6d18dc85..a193c5e0b79 100644
--- a/src/main/automations/external-manager-scoped.test.ts
+++ b/src/main/automations/external-manager-scoped.test.ts
@@ -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
)
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 () => {
diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts
index 3d18fab8657..6badd1ec164 100644
--- a/src/main/automations/external-manager.ts
+++ b/src/main/automations/external-manager.ts
@@ -243,7 +243,8 @@ function remoteRelayErrorMessage(error: unknown): string {
async function listRemoteManager(
target: SshTarget,
- provider: ExternalAutomationProvider
+ provider: ExternalAutomationProvider,
+ signal: AbortSignal
): Promise {
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 }
diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts
index 3e25ad87440..bae582f222c 100644
--- a/src/main/automations/headless-workspace-create.test.ts
+++ b/src/main/automations/headless-workspace-create.test.ts
@@ -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'
}
})
})
diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts
index 2047a105c44..ee99e7a6206 100644
--- a/src/main/automations/headless-workspace-create.ts
+++ b/src/main/automations/headless-workspace-create.ts
@@ -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'
+ )
}
}
diff --git a/src/main/automations/retained-run-reconciliation.test.ts b/src/main/automations/retained-run-reconciliation.test.ts
index aa3cc6d7183..158d020486c 100644
--- a/src/main/automations/retained-run-reconciliation.test.ts
+++ b/src/main/automations/retained-run-reconciliation.test.ts
@@ -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()
})
})
diff --git a/src/main/automations/retained-run-reconciliation.ts b/src/main/automations/retained-run-reconciliation.ts
index eeeb6d39b79..56acfc99855 100644
--- a/src/main/automations/retained-run-reconciliation.ts
+++ b/src/main/automations/retained-run-reconciliation.ts
@@ -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) {
diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts
index 1f82c758879..f3635cb2224 100644
--- a/src/main/automations/run-completion-watcher.test.ts
+++ b/src/main/automations/run-completion-watcher.test.ts
@@ -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()
})
diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts
index 4ac125082fb..fe6a318b998 100644
--- a/src/main/automations/run-completion-watcher.ts
+++ b/src/main/automations/run-completion-watcher.ts
@@ -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 {
+ ): Promise {
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 {
diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts
index a9b307c3298..a8158687135 100644
--- a/src/main/automations/runtime-terminal-run-observer.test.ts
+++ b/src/main/automations/runtime-terminal-run-observer.test.ts
@@ -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
diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts
index 0dc6750e8de..3025ad69106 100644
--- a/src/main/automations/runtime-terminal-run-observer.ts
+++ b/src/main/automations/runtime-terminal-run-observer.ts
@@ -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')
}
}
}
diff --git a/src/main/automations/workspace-provenance.ts b/src/main/automations/workspace-provenance.ts
index 8174ca11293..08b53fae50e 100644
--- a/src/main/automations/workspace-provenance.ts
+++ b/src/main/automations/workspace-provenance.ts
@@ -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(
diff --git a/src/main/index.ts b/src/main/index.ts
index d755aff6e36..0eafc539bcd 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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.
diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts
index 2a50d123a0d..3dcb2c94a8b 100644
--- a/src/main/ipc/automations-external-scope.test.ts
+++ b/src/main/ipc/automations-external-scope.test.ts
@@ -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()
diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts
index 4b4d009024f..7526edeb585 100644
--- a/src/main/ipc/worktrees.ts
+++ b/src/main/ipc/worktrees.ts
@@ -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 {
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 947f4da0087..ef033034e17 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -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
private readonly skillInstallOperations = new Map()
private readonly skillInstallProgress = new Map()
@@ -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 {
+ 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)
)
])
)
diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts
index 7503d65d623..21b240ee446 100644
--- a/src/main/runtime/rpc/methods/worktree.ts
+++ b/src/main/runtime/rpc/methods/worktree.ts
@@ -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.
diff --git a/src/renderer/src/components/automations/AutomationHostBadges.test.tsx b/src/renderer/src/components/automations/AutomationHostBadges.test.tsx
index a9e9c6caef7..059dc66db20 100644
--- a/src/renderer/src/components/automations/AutomationHostBadges.test.tsx
+++ b/src/renderer/src/components/automations/AutomationHostBadges.test.tsx
@@ -81,7 +81,11 @@ describe('AutomationHostStatusBadges', () => {
it('distinguishes an incompatible authority from a legacy-unscoped contract', () => {
render()
const incompatible = statusIds()
- render()
+ render(
+
+ )
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(
+
+ )
+ expect(statusIds()).toEqual([])
+ })
+
it('separates the incompatible query contract from the incompatible authority', () => {
render()
expect(statusIds('query')).toEqual(['query-incompatible'])
diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx
index e4e6903c151..6e5b8a0f53c 100644
--- a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx
+++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx
@@ -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)
diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx
index 2dcdbba8d12..46f3ae119da 100644
--- a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx
+++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx
@@ -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
className?: string
}
@@ -132,9 +131,7 @@ export function AutomationHostFilterNotice({
{message}
{resolution.entry ? : null}
{action && onRecover ? (
-
+
) : null}
) : null}
diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx
index d192465bb7e..a6535587dda 100644
--- a/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx
+++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx
@@ -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(
{
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)
diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx
index 3c320552c9b..78cafcd723b 100644
--- a/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx
+++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx
@@ -64,13 +64,22 @@ export function AutomationHostPickerSelect({
{/* Without children Radix names the trigger from the matching item, and a
retained-but-unhydrated selection matches only the All hosts item. */}
{selectedStableKey === null ? triggerLabel : undefined}
-
+ {/* 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. */}
+
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
diff --git a/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx b/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx
new file mode 100644
index 00000000000..c594000b955
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx
@@ -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
+ size?: 'xs' | 'sm'
+ className?: string
+}): JSX.Element {
+ const [busy, setBusy] = useState(false)
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx
index da200352b7c..2de54a34c3b 100644
--- a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx
+++ b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx
@@ -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)
diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.tsx
index b6950356c03..f7832a8d93c 100644
--- a/src/renderer/src/components/automations/AutomationListEmptyView.tsx
+++ b/src/renderer/src/components/automations/AutomationListEmptyView.tsx
@@ -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
className?: string
}
@@ -44,15 +41,12 @@ export function AutomationListEmptyView({
) : null}
{recovery && onRecover ? (
-
+ />
) : null}
)
diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
index 4a50bd3195f..f977b9b433a 100644
--- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx
+++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
@@ -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({
{projectLabel}
+
+ {hostLabel}
+
{nextRunLabel}
diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.tsx
index ba751d5aec2..34a97ee72b8 100644
--- a/src/renderer/src/components/automations/AutomationListHostGroups.tsx
+++ b/src/renderer/src/components/automations/AutomationListHostGroups.tsx
@@ -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 & {
groups: readonly AutomationHostVisibleGroup[]
searchActive: boolean
- onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void
+ onRecover: (
+ action: AutomationHostRecoveryAction,
+ entry: AutomationHostCatalogEntry
+ ) => void | Promise
}
function hostRecoveryAction(
@@ -48,7 +50,7 @@ function AutomationHostGroupState({
}: {
state: AutomationListEmptyState
recovery: AutomationHostRecoveryAction | null
- onRecover: (action: AutomationHostRecoveryAction) => void
+ onRecover: (action: AutomationHostRecoveryAction) => void | Promise
}): React.JSX.Element {
return (
@@ -60,15 +62,7 @@ function AutomationHostGroupState({
) : null}
{recovery ? (
-
+
) : null}
)
@@ -102,14 +96,10 @@ export function AutomationListHostGroups({
{action ? (
-
+ onRecover(recovery, entry)}
+ />
) : null}
{state.kind === 'rows' ? (
diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx
index dee3ff4dc1f..e25c0dcac1f 100644
--- a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx
+++ b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx
@@ -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()
+ })
})
diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
index e157bd2a15c..6335dd707bd 100644
--- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx
+++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
@@ -247,6 +247,9 @@ export function AutomationListLocalRows({
{projectLabel}
+
+ {hostLabel}
+
{nextRunLabel}
diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx
index 11d412f5412..d0b740590fe 100644
--- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx
+++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx
@@ -27,6 +27,7 @@ export function AutomationListTableHeader({
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
+ {translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx
index b3428780ddc..8c67de8f4ab 100644
--- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx
@@ -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()
+ })
+})
diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx
index 692a257f098..3d6645b29d5 100644
--- a/src/renderer/src/components/automations/AutomationsListPanel.tsx
+++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx
@@ -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
filteredRows: readonly AutomationListRow[]
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
selectedRowKey: string | null
@@ -157,62 +152,18 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
} = props
const listRef = useRef(null)
const pickerRef = useRef(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"
>
-
-
-
+
+
+
-
+
{translate(
- 'auto.components.automations.AutomationsPage.19a6e30eae',
+ 'auto.components.automations.AutomationsPage.refreshAutomations',
'Refresh automations'
)}
@@ -326,10 +278,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
{hasFilteredListItems ? (
<>
@@ -343,6 +292,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
+
+ {translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}
+
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
@@ -360,16 +312,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
- {hostCatalog.rows.groups.length > 0 ? (
-
- ) : (
-
- )}
+
),
+ [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)[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)[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)[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()
- })
})
diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
index 17b783b49d3..015c2743ef8 100644
--- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
@@ -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()
+ })
})
diff --git a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx
index 4581a5fee7e..95e8ad2e788 100644
--- a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx
@@ -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
diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx
index 80bd234cf6b..ef85d5fe232 100644
--- a/src/renderer/src/components/automations/AutomationsPage.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx
@@ -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)
- })
})
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index 9bcf18d1ea3..bf63582d319 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -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['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(null)
+ const [editingDestination, setEditingDestination] = useState(
+ null
+ )
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState(
@@ -313,6 +345,7 @@ export default function AutomationsPage(): React.JSX.Element {
const setupDecisionPolicyDefaultRef = useRef(undefined)
const setupDecisionDefaultSignatureRef = useRef(null)
const setupDecisionTouchedRef = useRef(false)
+ const createDestinationSelectionChangedRef = useRef(false)
const automationHookCheckPromisesRef = useRef<
Map>
>(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(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 => {
@@ -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> => {
- 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 => {
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}
diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
index c2df447b04e..50d14cc4c20 100644
--- a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
+++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
@@ -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({
+
@@ -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 {
-
+
@@ -120,6 +129,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
+
diff --git a/src/renderer/src/components/automations/automation-authority-capability.ts b/src/renderer/src/components/automations/automation-authority-capability.ts
new file mode 100644
index 00000000000..3b290cde656
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-authority-capability.ts
@@ -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
{
+ 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 {
+ 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
+}
diff --git a/src/renderer/src/components/automations/automation-authority-identity.ts b/src/renderer/src/components/automations/automation-authority-identity.ts
index 25b22f368d9..9501619c123 100644
--- a/src/renderer/src/components/automations/automation-authority-identity.ts
+++ b/src/renderer/src/components/automations/automation-authority-identity.ts
@@ -30,7 +30,7 @@ export function repoConnectionIdIn(
const EMPTY_REPO_TABLE: ReadonlyMap = 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>()
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()
diff --git a/src/renderer/src/components/automations/automation-captured-owner.test.ts b/src/renderer/src/components/automations/automation-captured-owner.test.ts
index ffc998a5b36..f0fb496b584 100644
--- a/src/renderer/src/components/automations/automation-captured-owner.test.ts
+++ b/src/renderer/src/components/automations/automation-captured-owner.test.ts
@@ -63,7 +63,7 @@ function row(overrides: Partial): 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 })
}
])
diff --git a/src/renderer/src/components/automations/automation-captured-owner.ts b/src/renderer/src/components/automations/automation-captured-owner.ts
index 761dd787e78..44fec7e8938 100644
--- a/src/renderer/src/components/automations/automation-captured-owner.ts
+++ b/src/renderer/src/components/automations/automation-captured-owner.ts
@@ -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 = 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
): Map {
const captured = new Map()
- 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
}
diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts
index 8e2090be6fd..df6ac9b0243 100644
--- a/src/renderer/src/components/automations/automation-create-destination.test.ts
+++ b/src/renderer/src/components/automations/automation-create-destination.test.ts
@@ -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 {
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 {
+ 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
+ ])
+ })
})
diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts
index c3c4b2a94e9..79f14405042 100644
--- a/src/renderer/src/components/automations/automation-create-destination.ts
+++ b/src/renderer/src/components/automations/automation-create-destination.ts
@@ -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(
- 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)
)
}
diff --git a/src/renderer/src/components/automations/automation-host-cache-rows.ts b/src/renderer/src/components/automations/automation-host-cache-rows.ts
index 9d8f2888efe..fbf2932b203 100644
--- a/src/renderer/src/components/automations/automation-host-cache-rows.ts
+++ b/src/renderer/src/components/automations/automation-host-cache-rows.ts
@@ -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
diff --git a/src/renderer/src/components/automations/automation-host-cache-types.ts b/src/renderer/src/components/automations/automation-host-cache-types.ts
index 29f0eab65f3..d1883d60df0 100644
--- a/src/renderer/src/components/automations/automation-host-cache-types.ts
+++ b/src/renderer/src/components/automations/automation-host-cache-types.ts
@@ -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
diff --git a/src/renderer/src/components/automations/automation-host-catalog-types.ts b/src/renderer/src/components/automations/automation-host-catalog-types.ts
index 4aa37d0617c..7489eefbba0 100644
--- a/src/renderer/src/components/automations/automation-host-catalog-types.ts
+++ b/src/renderer/src/components/automations/automation-host-catalog-types.ts
@@ -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
}
diff --git a/src/renderer/src/components/automations/automation-host-catalog.test.ts b/src/renderer/src/components/automations/automation-host-catalog.test.ts
index 2eae8530b02..c53bd6db04d 100644
--- a/src/renderer/src/components/automations/automation-host-catalog.test.ts
+++ b/src/renderer/src/components/automations/automation-host-catalog.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts
index 71e445e546c..28d3ae5170c 100644
--- a/src/renderer/src/components/automations/automation-host-catalog.ts
+++ b/src/renderer/src/components/automations/automation-host-catalog.ts
@@ -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
}
)
}
diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts
index 7a3aaaa8d49..ac0f31aca55 100644
--- a/src/renderer/src/components/automations/automation-host-client.test.ts
+++ b/src/renderer/src/components/automations/automation-host-client.test.ts
@@ -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 }
- )
- })
})
diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts
index 271d02319e8..933908c14ef 100644
--- a/src/renderer/src/components/automations/automation-host-client.ts
+++ b/src/renderer/src/components/automations/automation-host-client.ts
@@ -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 & {
- 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,
- 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 {
- 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 {
- 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 {
- 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
-}
diff --git a/src/renderer/src/components/automations/automation-host-list-rows.ts b/src/renderer/src/components/automations/automation-host-list-rows.ts
index 68b37e34431..bcc1915ec6b 100644
--- a/src/renderer/src/components/automations/automation-host-list-rows.ts
+++ b/src/renderer/src/components/automations/automation-host-list-rows.ts
@@ -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)
}
diff --git a/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts b/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts
new file mode 100644
index 00000000000..9c59a6ac6c8
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts
@@ -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): 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')
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-host-recovery-connect.ts b/src/renderer/src/components/automations/automation-host-recovery-connect.ts
new file mode 100644
index 00000000000..6a60d246432
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-host-recovery-connect.ts
@@ -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()
+
+export async function connectAutomationHostRuntime(environmentId: string): Promise {
+ 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 {
+ 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(() => {})
+ }
+}
diff --git a/src/renderer/src/components/automations/automation-host-recovery.test.ts b/src/renderer/src/components/automations/automation-host-recovery.test.ts
index 8d6419fb128..801e2b5d567 100644
--- a/src/renderer/src/components/automations/automation-host-recovery.test.ts
+++ b/src/renderer/src/components/automations/automation-host-recovery.test.ts
@@ -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()
})
})
diff --git a/src/renderer/src/components/automations/automation-host-recovery.ts b/src/renderer/src/components/automations/automation-host-recovery.ts
index bc71f08ff18..b942b23effc 100644
--- a/src/renderer/src/components/automations/automation-host-recovery.ts
+++ b/src/renderer/src/components/automations/automation-host-recovery.ts
@@ -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
+ /** `environmentId` is the owning runtime; omit it for a desktop SSH target. */
+ connectSshTarget: (targetId: string, environmentId?: string) => void | Promise
+ connectRuntimeEnvironment: (environmentId: string) => void | Promise
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 {
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 {
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))
diff --git a/src/renderer/src/components/automations/automation-host-scheduler.test.ts b/src/renderer/src/components/automations/automation-host-scheduler.test.ts
index 7942eb49fe8..a473b472f79 100644
--- a/src/renderer/src/components/automations/automation-host-scheduler.test.ts
+++ b/src/renderer/src/components/automations/automation-host-scheduler.test.ts
@@ -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 () => {
diff --git a/src/renderer/src/components/automations/automation-host-scheduler.ts b/src/renderer/src/components/automations/automation-host-scheduler.ts
index 310e2bd0c8b..2658d562f66 100644
--- a/src/renderer/src/components/automations/automation-host-scheduler.ts
+++ b/src/renderer/src/components/automations/automation-host-scheduler.ts
@@ -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),
diff --git a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts
index 1f5fb04e4d1..08bfa4c6d4f 100644
--- a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts
+++ b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts
@@ -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
): 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
}
diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts
index 9a9262b7130..def38143851 100644
--- a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts
+++ b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts
@@ -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'
diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.ts
index c73df4288ca..03ba1836991 100644
--- a/src/renderer/src/components/automations/automation-host-status-descriptors.ts
+++ b/src/renderer/src/components/automations/automation-host-status-descriptors.ts
@@ -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'
}
}
diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts
index 577f323319f..75891fa4f62 100644
--- a/src/renderer/src/components/automations/automation-list-row-identity.ts
+++ b/src/renderer/src/components/automations/automation-list-row-identity.ts
@@ -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[],
diff --git a/src/renderer/src/components/automations/automation-owner-action-runner.ts b/src/renderer/src/components/automations/automation-owner-action-runner.ts
index b25baa16d08..8a3eb1db3ab 100644
--- a/src/renderer/src/components/automations/automation-owner-action-runner.ts
+++ b/src/renderer/src/components/automations/automation-owner-action-runner.ts
@@ -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 {
}
}
}
+ 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(
availability: AutomationActionAvailability,
owned: (owner: AutomationOwnerRef) => Promise,
orphanFenced?: () => Promise
-): Promise | { status: 'uncaptured' }> {
+): Promise> {
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 =
- | AutomationActionOutcome
- /** The caller must fall back to its unfenced path; Step 9 deletes this arm. */
- | { status: 'uncaptured' }
+export type AutomationActionResult = AutomationActionOutcome
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> {
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> {
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'))
)
}
diff --git a/src/renderer/src/components/automations/automation-row-action-dispatch.ts b/src/renderer/src/components/automations/automation-row-action-dispatch.ts
index cf8a619abbd..c2447496115 100644
--- a/src/renderer/src/components/automations/automation-row-action-dispatch.ts
+++ b/src/renderer/src/components/automations/automation-row-action-dispatch.ts
@@ -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
- authority: AutomationAuthorityRef
}
/**
@@ -69,7 +60,7 @@ export function dispatched(value: TValue): AutomationDispatchResult(
- outcome: Exclude, { status: 'uncaptured' }>
+ outcome: AutomationActionResult
): AutomationDispatchResult {
switch (outcome.status) {
case 'ok':
@@ -92,100 +83,58 @@ async function dispatch(
action: AutomationRowAction,
fenced: (
availability: ReturnType
- ) => Promise>,
- legacy: () => Promise
+ ) => Promise>
): Promise> {
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,
action: AutomationRowAction = 'toggle'
): Promise> {
- 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
+ row: AutomationDispatchRow
): Promise> {
- 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
+ row: AutomationDispatchRow
): Promise> {
- 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
+ row: AutomationDispatchRow
): Promise> {
- 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
+ row: AutomationDispatchRow
): Promise> {
- 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)
)
}
diff --git a/src/renderer/src/components/automations/automation-run-context.test.ts b/src/renderer/src/components/automations/automation-run-context.test.ts
index b2f1dc2f4ae..04d2687c19e 100644
--- a/src/renderer/src/components/automations/automation-run-context.test.ts
+++ b/src/renderer/src/components/automations/automation-run-context.test.ts
@@ -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'
+ })
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-run-context.ts b/src/renderer/src/components/automations/automation-run-context.ts
index 49894f78159..7c61fb2dc35 100644
--- a/src/renderer/src/components/automations/automation-run-context.ts
+++ b/src/renderer/src/components/automations/automation-run-context.ts
@@ -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
+ })
+}
diff --git a/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts b/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts
new file mode 100644
index 00000000000..9a952f8685c
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts
@@ -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()
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts b/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts
new file mode 100644
index 00000000000..b2316d869f6
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts
@@ -0,0 +1,16 @@
+/**
+ * Runtime `automation.create` / `update` resolve `workspace` through
+ * `showManagedWorktree`. The CLI always sends `id:`; 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}`
+}
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
index 50e0c5886d8..a249c04e832 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
@@ -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' })
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts
index 6eec4ce430e..1ffc63cb040 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts
@@ -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(
})
}
-/**
- * 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 {
- 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 {
- 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 {
+ 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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
diff --git a/src/renderer/src/components/automations/automation-write-invalidation.test.ts b/src/renderer/src/components/automations/automation-write-invalidation.test.ts
index f1e3d80255a..3a81774dfcf 100644
--- a/src/renderer/src/components/automations/automation-write-invalidation.test.ts
+++ b/src/renderer/src/components/automations/automation-write-invalidation.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/automations/automation-write-invalidation.ts b/src/renderer/src/components/automations/automation-write-invalidation.ts
index 751f237dcf2..0ffb2b3cfbc 100644
--- a/src/renderer/src/components/automations/automation-write-invalidation.ts
+++ b/src/renderer/src/components/automations/automation-write-invalidation.ts
@@ -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) {
diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts
index 85769bf20b0..8310423687f 100644
--- a/src/renderer/src/components/automations/automations-page-fixtures.ts
+++ b/src/renderer/src/components/automations/automations-page-fixtures.ts
@@ -200,6 +200,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
const repo = makeRepo()
const worktree = makeWorktree()
const noop = (): void => undefined
+ const fetchWorktrees = async (): Promise => 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,
diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx
index 8085b46e1e1..bc18c0daf29 100644
--- a/src/renderer/src/components/automations/automations-page-test-harness.tsx
+++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx
@@ -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
diff --git a/src/renderer/src/components/automations/automations-table-layout.test.ts b/src/renderer/src/components/automations/automations-table-layout.test.ts
new file mode 100644
index 00000000000..96e2f8cd9c9
--- /dev/null
+++ b/src/renderer/src/components/automations/automations-table-layout.test.ts
@@ -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\)/)
+ })
+})
diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts
index 0b0db4d5229..f4513bdbae2 100644
--- a/src/renderer/src/components/automations/automations-table-layout.ts
+++ b/src/renderer/src/components/automations/automations-table-layout.ts
@@ -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]'
diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts
index 35521981294..052ce37e5ca 100644
--- a/src/renderer/src/components/automations/use-automation-create-destination.ts
+++ b/src/renderer/src/components/automations/use-automation-create-destination.ts
@@ -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(null)
const [reason, setReason] = useState('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 {
diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx
index 5845d80acef..2d42cd26988 100644
--- a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx
+++ b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx
@@ -21,7 +21,9 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
const mocks = vi.hoisted(() => ({
state: {} as Record,
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()
+ })
+ 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()
+ })
+})
diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts
index ae646a3db9c..9798ed43996 100644
--- a/src/renderer/src/components/automations/use-automation-host-catalog.ts
+++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts
@@ -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
/** 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(() => {
diff --git a/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts b/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts
new file mode 100644
index 00000000000..b675c5c5e21
--- /dev/null
+++ b/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts
@@ -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
+}
+
+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
+ ]
+ )
+}
diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx
index ae5d768293b..1693262773e 100644
--- a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx
+++ b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx
@@ -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' } }]
+ ])
}
})
diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.ts b/src/renderer/src/components/automations/use-selected-automation-run-history.ts
index c9da1bd7ce4..17611bb0161 100644
--- a/src/renderer/src/components/automations/use-selected-automation-run-history.ts
+++ b/src/renderer/src/components/automations/use-selected-automation-run-history.ts
@@ -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])
}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx
index 89ca240cc98..11052a54ccd 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx
@@ -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 {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -59,7 +65,11 @@ async function render(
await act(async () => {
root.render(
-
+
)
})
@@ -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')
- })
})
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx
index 9f2811a54b9..09ac459f285 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx
@@ -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({
status: 'checking'
})
+ const authorityKind = authority?.kind ?? null
+ const authorityEnvironmentId = authority?.kind === 'runtime' ? authority.environmentId : null
React.useEffect(() => {
let cancelled = false
async function resolveAvailability(): Promise {
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
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
index 071d857aed2..21d13f73ba1 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
@@ -56,6 +56,7 @@ export function WorktreeCardDetailsHover({
review,
comment,
automationProvenance,
+ automationAuthority,
cliProvenance,
children,
branchName,
@@ -313,6 +314,7 @@ export function WorktreeCardDetailsHover({
{automationProvenance && (
{
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
]
)
diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts
index 64e9da831a6..dd67207bac2 100644
--- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts
+++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts
@@ -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,
diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts
index 54637f7a50a..2885fda772d 100644
--- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts
@@ -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
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx
index de5c72f43b2..54c0f4195da 100644
--- a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx
@@ -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 ? : null}
hoverControl={detailsHoverControl}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 7ceed9367eb..d87306061d3 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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."
}
},
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 27136599f39..79ec58190d7 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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...",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 1a2f3e9f95a..abfcb3ca16a 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13822,7 +13822,11 @@
"tableStatus": "ステータス",
"tableActions": "操作",
"newAutomation": "新しい自動化",
- "rowActions": "自動化の操作"
+ "rowActions": "自動化の操作",
+ "tableLastRun": "前回の実行",
+ "noListMatches": "一致する自動化はありません。",
+ "tableHost": "ホスト",
+ "refreshAutomations": "自動化を更新"
},
"CreateFromPicker": {
"f061f49e3f": "リポジトリブランチを検索…",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index a360d1331ec..176f792589a 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13851,7 +13851,11 @@
"tableStatus": "상태",
"tableActions": "작업",
"newAutomation": "새 자동화",
- "rowActions": "자동화 작업"
+ "rowActions": "자동화 작업",
+ "tableLastRun": "마지막 실행",
+ "noListMatches": "일치하는 자동화가 없습니다.",
+ "tableHost": "호스트",
+ "refreshAutomations": "자동화 새로고침"
},
"CreateFromPicker": {
"f061f49e3f": "repo 브랜치 검색...",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 1774a4afa4b..c7d8c44f02b 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13842,7 +13842,11 @@
"tableStatus": "状态",
"tableActions": "操作",
"newAutomation": "新建自动化",
- "rowActions": "自动化操作"
+ "rowActions": "自动化操作",
+ "tableLastRun": "上次运行",
+ "noListMatches": "没有匹配的自动化。",
+ "tableHost": "主机",
+ "refreshAutomations": "刷新自动化"
},
"CreateFromPicker": {
"f061f49e3f": "搜索仓库分支...",
diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts
index f8fc1762c42..3edb0845b79 100644
--- a/src/renderer/src/store/slices/runtime-environment-ssh.ts
+++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts
@@ -5,37 +5,22 @@ import type {
SshConnectionStatus,
SshTargetSummary
} from '../../../../shared/ssh-types'
-import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation'
-import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup'
+import {
+ collectSshTargetGenerations,
+ sshConnectionStatesEqual,
+ sshTargetGenerationsEqual,
+ sshTargetLabelsEqual
+} from './ssh-target-cleanup'
-/**
- * SSH state of one remote Orca server's own SSH targets, mirrored on this
- * client. Kept strictly separate from the local `SshSlice` maps so a remote
- * machine's targets can never pollute local SSH settings, pickers, or the
- * execution-host registry — and vice versa (STA-1468, desktop topology).
- */
export type RuntimeEnvironmentSshBucket = {
connectionStates: Map
targetLabels: Map
- /** Durable SSH *registration* generation per target, for automation owner
- * fencing. Absent for targets an older server omitted it for — never
- * defaulted, because a guessed generation would fence against the wrong
- * registration. Cleared with the rest of the bucket when state goes stale. */
targetGenerations: Map
removedTargetLabels: Map
- /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence
- * from `targetLabels` only counts as target removal once a target list
- * actually loaded from that environment (even an empty one). */
targetsHydrated: boolean
}
export type RuntimeEnvironmentSshSlice = {
- /**
- * Per-runtime-environment SSH state buckets, keyed by environment id.
- * Do NOT read this map directly from components — use the
- * `selectRuntimeAwareSsh*` selectors below, which route between the local
- * SSH maps (`environmentId === null`) and the owning environment's bucket.
- */
sshStateByEnvironment: Map
setEnvironmentSshConnectionState: (
environmentId: string,
@@ -53,13 +38,8 @@ export type RuntimeEnvironmentSshSlice = {
labels: Record,
generation?: number
) => void
- /** Transport to the environment dropped: its mirrored SSH state can no
- * longer be trusted (it may hold a pre-drop "connected"). Downgrades the
- * bucket to unhydrated and clears connection states so reads resolve to
- * "unknown" until a reconnect re-hydrates. */
markEnvironmentSshStateStale: (environmentId: string) => void
removeEnvironmentSshState: (environmentId: string) => void
- /** Drops buckets for environments no longer in the saved set. */
retainEnvironmentSshState: (environmentIds: Iterable) => void
}
@@ -71,24 +51,6 @@ const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = {
targetsHydrated: false
}
-function collectTargetGenerations(targets: SshTargetSummary[]): Map {
- const generations = new Map()
- for (const target of targets) {
- const generation = sanitizeSshTargetGeneration(target.generation)
- if (generation !== undefined) {
- generations.set(target.id, generation)
- }
- }
- return generations
-}
-
-function targetGenerationsEqual(current: Map, next: Map): boolean {
- return (
- current.size === next.size &&
- [...next].every(([targetId, generation]) => current.get(targetId) === generation)
- )
-}
-
const stateGenerationByEnvironment = new Map()
const targetConnectionGenerationByEnvironment = new Map()
@@ -198,10 +160,10 @@ export const createRuntimeEnvironmentSshSlice: StateCreator<
const connectionStates = new Map(
Array.from(bucket.connectionStates).filter(([targetId]) => targetIds.has(targetId))
)
- const targetGenerations = collectTargetGenerations(targets)
+ const targetGenerations = collectSshTargetGenerations(targets)
if (
sshTargetLabelsEqual(bucket.targetLabels, targets) &&
- targetGenerationsEqual(bucket.targetGenerations, targetGenerations)
+ sshTargetGenerationsEqual(bucket.targetGenerations, targetGenerations)
) {
// Why: an unchanged (even empty) list is still a successful load — the
// hydration flag must flip on the first fetch of an empty target set.
@@ -318,10 +280,7 @@ export function selectRuntimeAwareSshStatus(
return null
}
const bucket = state.sshStateByEnvironment.get(environmentId)
- if (!bucket?.targetsHydrated) {
- return null
- }
- return bucket.connectionStates.get(targetId)?.status ?? null
+ return bucket?.targetsHydrated ? (bucket.connectionStates.get(targetId)?.status ?? null) : null
}
/**
@@ -343,10 +302,7 @@ export function selectRuntimeAwareSshError(
return null
}
const bucket = state.sshStateByEnvironment.get(environmentId)
- if (!bucket?.targetsHydrated) {
- return null
- }
- return bucket.connectionStates.get(targetId)?.error ?? null
+ return bucket?.targetsHydrated ? (bucket.connectionStates.get(targetId)?.error ?? null) : null
}
export function selectRuntimeAwareSshTargetLabel(
@@ -388,11 +344,9 @@ export function selectRuntimeAwareSshTargetRemoved(
return false
}
const bucket = state.sshStateByEnvironment.get(environmentId)
- if (!bucket) {
- return false
- }
- return (
- bucket.removedTargetLabels.has(targetId) ||
- (bucket.targetsHydrated && !bucket.targetLabels.has(targetId))
+ return Boolean(
+ bucket &&
+ (bucket.removedTargetLabels.has(targetId) ||
+ (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)))
)
}
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 151cbb7e801..43f2458cf39 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -95,6 +95,10 @@ import {
toPersistedAutomationHostFilter,
type AutomationHostFilter
} from '../../../../shared/automation-host-filter'
+import type {
+ StableAutomationAuthorityRef,
+ StableAutomationCatalogRef
+} from '../../../../shared/automation-owner-ref'
import {
normalizeExecutionHostOrder,
normalizeExecutionHostScope,
@@ -760,9 +764,17 @@ export type UISlice = {
automationId: string
runId: string | null
hostId?: ExecutionHostId
+ authority?: StableAutomationAuthorityRef
+ catalogRef?: StableAutomationCatalogRef
} | null
setPendingAutomationRunNavigation: (
- navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null
+ navigation: {
+ automationId: string
+ runId: string | null
+ hostId?: ExecutionHostId
+ authority?: StableAutomationAuthorityRef
+ catalogRef?: StableAutomationCatalogRef
+ } | null
) => void
openAutomationsPage: () => void
closeAutomationsPage: () => void
diff --git a/src/shared/automation-workspace-provenance.test.ts b/src/shared/automation-workspace-provenance.test.ts
new file mode 100644
index 00000000000..75658bb913d
--- /dev/null
+++ b/src/shared/automation-workspace-provenance.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import type { AutomationWorkspaceProvenance } from './worktree/types'
+import {
+ automationWorkspaceStorageAuthority,
+ automationWorkspaceStorageCatalogRef
+} from './automation-workspace-provenance'
+
+const provenance = (overrides: Partial) =>
+ ({ kind: 'created-by-automation', ...overrides }) as AutomationWorkspaceProvenance
+
+describe('automation workspace storage authority', () => {
+ it('keeps Runtime + SSH distinct from Desktop + SSH', () => {
+ expect(
+ automationWorkspaceStorageAuthority(
+ provenance({ hostId: 'ssh:builder', storageAuthority: 'runtime' }),
+ 'gpu'
+ )
+ ).toEqual({ kind: 'runtime', environmentId: 'gpu' })
+ expect(
+ automationWorkspaceStorageAuthority(
+ provenance({ hostId: 'ssh:builder', storageAuthority: 'desktop' }),
+ 'gpu'
+ )
+ ).toEqual({ kind: 'desktop' })
+ })
+
+ it('uses workspace ownership for provenance written by older runtimes', () => {
+ expect(
+ automationWorkspaceStorageAuthority(provenance({ hostId: 'ssh:builder' }), 'gpu')
+ ).toEqual({ kind: 'runtime', environmentId: 'gpu' })
+ })
+
+ it('keeps the SSH selector with its storage authority', () => {
+ expect(
+ automationWorkspaceStorageCatalogRef(
+ provenance({ hostId: 'ssh:builder', storageAuthority: 'runtime' }),
+ 'gpu'
+ )
+ ).toEqual({
+ authority: { kind: 'runtime', environmentId: 'gpu' },
+ selector: { kind: 'ssh', targetId: 'builder' }
+ })
+ })
+})
diff --git a/src/shared/automation-workspace-provenance.ts b/src/shared/automation-workspace-provenance.ts
index 6f1c0f9894e..7ea616e1232 100644
--- a/src/shared/automation-workspace-provenance.ts
+++ b/src/shared/automation-workspace-provenance.ts
@@ -1,7 +1,11 @@
import type { Automation, AutomationRun } from './automations-types'
-import { getRepoExecutionHostId } from './execution-host'
+import { getRepoExecutionHostId, parseExecutionHostId } from './execution-host'
import type { Repo } from './repo-types'
import type { AutomationWorkspaceProvenance } from './worktree/types'
+import type {
+ StableAutomationAuthorityRef,
+ StableAutomationCatalogRef
+} from './automation-owner-ref'
type AutomationProvenanceRun = Pick
@@ -9,7 +13,8 @@ export function buildAutomationWorkspaceProvenance(
automation: Automation,
run: AutomationProvenanceRun,
repo: Repo,
- createdAt = Date.now()
+ createdAt = Date.now(),
+ storageAuthority?: AutomationWorkspaceProvenance['storageAuthority']
): AutomationWorkspaceProvenance {
return {
kind: 'created-by-automation',
@@ -27,6 +32,44 @@ export function buildAutomationWorkspaceProvenance(
: automation.runContext?.repoId
? { repoId: automation.runContext.repoId }
: {}),
- hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo)
+ hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo),
+ ...(storageAuthority ? { storageAuthority } : {})
+ }
+}
+
+/** Resolves the client-relative authority from portable persisted provenance. */
+export function automationWorkspaceStorageAuthority(
+ provenance: AutomationWorkspaceProvenance,
+ runtimeOwnerEnvironmentId?: string | null
+): StableAutomationAuthorityRef | null {
+ if (provenance.storageAuthority === 'desktop') {
+ return { kind: 'desktop' }
+ }
+ const runtimeOwner = runtimeOwnerEnvironmentId?.trim()
+ if (provenance.storageAuthority === 'runtime') {
+ return runtimeOwner ? { kind: 'runtime', environmentId: runtimeOwner } : null
+ }
+ if (runtimeOwner) {
+ return { kind: 'runtime', environmentId: runtimeOwner }
+ }
+ const host = parseExecutionHostId(provenance.hostId)
+ return host?.kind === 'runtime'
+ ? { kind: 'runtime', environmentId: host.environmentId }
+ : { kind: 'desktop' }
+}
+
+/** Resolves the exact storage bucket used by automation navigation. */
+export function automationWorkspaceStorageCatalogRef(
+ provenance: AutomationWorkspaceProvenance,
+ runtimeOwnerEnvironmentId?: string | null
+): StableAutomationCatalogRef | null {
+ const authority = automationWorkspaceStorageAuthority(provenance, runtimeOwnerEnvironmentId)
+ if (!authority) {
+ return null
+ }
+ const host = parseExecutionHostId(provenance.hostId)
+ return {
+ authority,
+ selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
}
}
diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts
index af6252f5c3a..22e094aee3c 100644
--- a/src/shared/worktree/types.ts
+++ b/src/shared/worktree/types.ts
@@ -165,6 +165,8 @@ export type AutomationWorkspaceProvenance = {
projectId: string
repoId?: string
hostId?: ExecutionHostId
+ /** Storage lives on this process role; runtime environment IDs are client-relative. */
+ storageAuthority?: 'desktop' | 'runtime'
}
export type AutomationWorkspaceProvenanceRequest = {