mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Add host column and recovery improvements for all-host automations
- Flatten automations list to show all hosts in a single table with Host column - Introduce host recovery button component for seamless reconnection - Support runtime storage authority for workspace provenance - Properly fence legacy Self entries while keeping SSH entries view-only - Add priority hold deadline to external manager probe scheduler - Improve automation observation to mark unresolvable runs as unverifiable
This commit is contained in:
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { AutomationDestination } from '../shared/automation-owner-precondition'
|
||||
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
|
||||
import type { SshTargetSummary } from '../shared/ssh-types'
|
||||
import { AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE } from '../shared/protocol-version'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
|
||||
@@ -39,22 +40,16 @@ async function resolveTargetRepo(
|
||||
.repo
|
||||
}
|
||||
|
||||
/**
|
||||
* `undefined` means the authority registers the target but assigns no
|
||||
* generation, which is a host with nothing to fence — not a host to fail
|
||||
* against. A target the authority does not register at all is positive
|
||||
* evidence that the destination is gone.
|
||||
*/
|
||||
async function sshTargetGeneration(
|
||||
client: RuntimeClient,
|
||||
targetId: string
|
||||
): Promise<number | undefined> {
|
||||
async function sshTargetGeneration(client: RuntimeClient, targetId: string): Promise<number> {
|
||||
const targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries'))
|
||||
.result.targets
|
||||
const match = targets.find((candidate) => candidate.id === targetId)
|
||||
if (!match) {
|
||||
throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination)
|
||||
}
|
||||
if (match.generation === undefined) {
|
||||
throw new Error(AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE)
|
||||
}
|
||||
return match.generation
|
||||
}
|
||||
|
||||
@@ -71,7 +66,5 @@ export async function resolveAutomationDestination(
|
||||
return { selector: { kind: 'self' } }
|
||||
}
|
||||
const generation = await sshTargetGeneration(client, connectionId)
|
||||
return generation === undefined
|
||||
? undefined
|
||||
: { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } }
|
||||
return { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } }
|
||||
}
|
||||
|
||||
@@ -177,26 +177,26 @@ describe('CLI automation writes fence the host they land on', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A host that predates SSH generations has no incarnation to capture, so the
|
||||
// write must still land — failing closed there would break a host that is only old.
|
||||
it('omits the destination when the authority registers the target without a generation', async () => {
|
||||
it('refuses an SSH destination that the authority cannot generation-fence', async () => {
|
||||
const { call, writes } = authority({
|
||||
registered: [{ id: 'box-1', label: 'box 1' }],
|
||||
repoConnectionId: 'box-1'
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await AUTOMATION_HANDLERS['automations edit']!({
|
||||
client: { call } as never,
|
||||
cwd: '/tmp',
|
||||
flags: new Map([
|
||||
['id', 'a1'],
|
||||
['repo', 'repo-on-box-1']
|
||||
]),
|
||||
json: true
|
||||
})
|
||||
await expect(
|
||||
AUTOMATION_HANDLERS['automations edit']!({
|
||||
client: { call } as never,
|
||||
cwd: '/tmp',
|
||||
flags: new Map([
|
||||
['id', 'a1'],
|
||||
['repo', 'repo-on-box-1']
|
||||
]),
|
||||
json: true
|
||||
})
|
||||
).rejects.toThrow('Editing automations on this host requires a newer Orca server')
|
||||
|
||||
expect(writes[0]?.params).not.toHaveProperty('destination')
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('sends a self destination for a local project', async () => {
|
||||
|
||||
@@ -71,6 +71,42 @@ describe('ExternalAutomationProbeScheduler', () => {
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a priority hold that its holder never releases', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 })
|
||||
const run = vi.fn(() => Promise.resolve('manager'))
|
||||
|
||||
// The lease of a dispatch that never settles. Without the deadline this
|
||||
// probe — and every later one — waits for the rest of the process.
|
||||
scheduler.beginPriorityWork()
|
||||
const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await expect(probe).resolves.toBe('manager')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps parking probes while a hold is still within its deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 })
|
||||
const run = vi.fn(() => Promise.resolve('manager'))
|
||||
|
||||
scheduler.beginPriorityWork()
|
||||
scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(scheduler.queued).toBe(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels queued and in-flight probes whose scope is no longer selected', async () => {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
|
||||
const never = deferred<string>()
|
||||
|
||||
@@ -35,16 +35,25 @@ type QueuedProbe = {
|
||||
}
|
||||
|
||||
const DEFAULT_PROBE_CONCURRENCY = 4
|
||||
/**
|
||||
* The priority lease is an ordering preference, not a lock. Work that holds it
|
||||
* for an unbounded time — a headless dispatch, a handler whose promise never
|
||||
* settles — would otherwise park every queued probe for the rest of the
|
||||
* process, and a caller waiting on one would never be answered at all.
|
||||
*/
|
||||
const PRIORITY_HOLD_MAX_MS = 30_000
|
||||
|
||||
export class ExternalAutomationProbeScheduler {
|
||||
private readonly concurrency: number
|
||||
private readonly priorityHoldMaxMs: number
|
||||
private readonly queue: QueuedProbe[] = []
|
||||
private readonly active = new Map<string, { scopeKey: string; controller: AbortController }>()
|
||||
private readonly shared = new Map<string, Promise<unknown>>()
|
||||
private priorityHolds = 0
|
||||
|
||||
constructor(options?: { concurrency?: number }) {
|
||||
constructor(options?: { concurrency?: number; priorityHoldMaxMs?: number }) {
|
||||
this.concurrency = Math.max(1, options?.concurrency ?? DEFAULT_PROBE_CONCURRENCY)
|
||||
this.priorityHoldMaxMs = Math.max(0, options?.priorityHoldMaxMs ?? PRIORITY_HOLD_MAX_MS)
|
||||
}
|
||||
|
||||
get inFlight(): number {
|
||||
@@ -90,18 +99,27 @@ export class ExternalAutomationProbeScheduler {
|
||||
return settled as Promise<T>
|
||||
}
|
||||
|
||||
/** Held for the duration of Orca list/mutation work; queued probes wait behind it. */
|
||||
/**
|
||||
* Held for the duration of Orca list/mutation work; queued probes wait behind
|
||||
* it, but only until the deadline — see `PRIORITY_HOLD_MAX_MS`.
|
||||
*/
|
||||
beginPriorityWork(): () => void {
|
||||
this.priorityHolds += 1
|
||||
let released = false
|
||||
return () => {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const release = (): void => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
clearTimeout(timer)
|
||||
this.priorityHolds = Math.max(0, this.priorityHolds - 1)
|
||||
this.pump()
|
||||
}
|
||||
timer = setTimeout(release, this.priorityHoldMaxMs)
|
||||
// Why unref: a lease deadline must never be the reason the process stays up.
|
||||
timer.unref?.()
|
||||
return release
|
||||
}
|
||||
|
||||
/** Drops queued and in-flight probes whose scope is no longer selected. */
|
||||
|
||||
@@ -129,10 +129,9 @@ describe('scoped external automations', () => {
|
||||
})
|
||||
|
||||
it('probes only the selected SSH host', async () => {
|
||||
const mux = relay({ jobs: [], hermesAvailable: true })
|
||||
vi.mocked(getActiveMultiplexer).mockReturnValue(
|
||||
relay({ jobs: [], hermesAvailable: true }) as unknown as ReturnType<
|
||||
typeof getActiveMultiplexer
|
||||
>
|
||||
mux as unknown as ReturnType<typeof getActiveMultiplexer>
|
||||
)
|
||||
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
|
||||
|
||||
@@ -144,6 +143,11 @@ describe('scoped external automations', () => {
|
||||
expect(entry.manager?.id).toBe('hermes:ssh:target-a')
|
||||
expect(getActiveMultiplexer).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveMultiplexer).toHaveBeenCalledWith('target-a')
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'externalAutomations.list',
|
||||
{ provider: 'hermes' },
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on a stale generation without probing', async () => {
|
||||
|
||||
@@ -243,7 +243,8 @@ function remoteRelayErrorMessage(error: unknown): string {
|
||||
|
||||
async function listRemoteManager(
|
||||
target: SshTarget,
|
||||
provider: ExternalAutomationProvider
|
||||
provider: ExternalAutomationProvider,
|
||||
signal: AbortSignal
|
||||
): Promise<ExternalAutomationManager> {
|
||||
const providerLabel = PROVIDER_LABELS[provider]
|
||||
const managerProviderId = `${provider}:ssh:${target.id}`
|
||||
@@ -262,7 +263,7 @@ async function listRemoteManager(
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = (await mux.request('externalAutomations.list', { provider })) as {
|
||||
const result = (await mux.request('externalAutomations.list', { provider }, { signal })) as {
|
||||
jobs?: unknown[]
|
||||
hermesAvailable?: boolean
|
||||
openclawAvailable?: boolean
|
||||
@@ -534,9 +535,9 @@ export function createScopedExternalAutomations(
|
||||
deps.scheduler.schedule({
|
||||
key: externalAutomationManagerCacheKey(key),
|
||||
scopeKey: scope.ownerKey,
|
||||
run: () =>
|
||||
run: (signal) =>
|
||||
scope.sshTarget
|
||||
? listRemoteManager(scope.sshTarget, scope.provider)
|
||||
? listRemoteManager(scope.sshTarget, scope.provider, signal)
|
||||
: listLocalManager(scope.provider)
|
||||
}),
|
||||
{ refresh: request.refresh }
|
||||
|
||||
@@ -85,7 +85,8 @@ describe('headless automation workspace create args', () => {
|
||||
executionTargetId: 'ssh-target-1',
|
||||
projectId: 'project-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:ssh-target-1'
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
storageAuthority: 'runtime'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,12 @@ export function buildHeadlessAutomationWorktreeCreateArgs({
|
||||
startupAgent: automation.agentId,
|
||||
startupPrompt: automation.prompt,
|
||||
telemetrySource: 'unknown',
|
||||
automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt)
|
||||
automationProvenance: buildAutomationWorkspaceProvenance(
|
||||
automation,
|
||||
run,
|
||||
repo,
|
||||
createdAt,
|
||||
'runtime'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('reconciling retained runs against a graph that has not published yet',
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('still closes out a run the ready surface cannot find after the settle window', async () => {
|
||||
it('keeps a run unverifiable when the ready surface cannot find it', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const retained = retainDispatchedRun(store, automation)
|
||||
@@ -241,10 +241,8 @@ describe('reconciling retained runs against a graph that has not published yet',
|
||||
service.setRendererReady()
|
||||
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
|
||||
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('dispatch_failed')
|
||||
expect(readRun(store, automation.id, retained.id).error).toBe(
|
||||
'Orca lost the terminal for this run before it reported completion.'
|
||||
)
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
|
||||
expect(readRun(store, automation.id, retained.id).error).toBeNull()
|
||||
service.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ export type RetainedRunReconcilerDeps = {
|
||||
attach: (run: AutomationRun) => boolean
|
||||
/** False once the run reached a terminal status by some other path. */
|
||||
stillRetained: (run: AutomationRun) => boolean
|
||||
strand: (run: AutomationRun) => void
|
||||
unverifiable: (run: AutomationRun) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +47,15 @@ export class RetainedRunReconciler {
|
||||
this.sweep()
|
||||
}
|
||||
|
||||
/** Rechecks an observation-lost run on the cadence, avoiding a tight stale-handle loop. */
|
||||
defer(run: AutomationRun): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.pending.set(run.id, run)
|
||||
this.arm()
|
||||
}
|
||||
|
||||
/** The authority's terminal surface can now answer pane lookups. */
|
||||
markSurfaceReady(): void {
|
||||
if (this.disposed) {
|
||||
@@ -73,7 +82,7 @@ export class RetainedRunReconciler {
|
||||
}
|
||||
if (strandAt !== null && Date.now() >= strandAt) {
|
||||
this.pending.delete(runId)
|
||||
this.deps.strand(run)
|
||||
this.deps.unverifiable(run)
|
||||
}
|
||||
}
|
||||
if (this.pending.size === 0) {
|
||||
|
||||
@@ -133,12 +133,7 @@ describe('authority-owned automation run completion', () => {
|
||||
service.stop()
|
||||
})
|
||||
|
||||
/**
|
||||
* The throws on this path carry transport tokens, not sentences. Every other
|
||||
* refusal in run history is one fixed sentence, so leaking `terminal_handle_stale`
|
||||
* into the row a user reads breaks the only convention that surface has.
|
||||
*/
|
||||
it('reports a failed observation in the same fixed sentence every other refusal uses', async () => {
|
||||
it('keeps a run unverifiable when terminal observation is lost', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -150,20 +145,15 @@ describe('authority-owned automation run completion', () => {
|
||||
})
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(readRun(store, automation.id, run.id).status).toBe('dispatch_failed')
|
||||
})
|
||||
|
||||
expect(readRun(store, automation.id, run.id).error).toBe(
|
||||
'Orca stopped watching this run before it reported completion.'
|
||||
)
|
||||
// The token is still recoverable where it is actually useful.
|
||||
await vi.waitFor(() => expect(logged).toHaveBeenCalled())
|
||||
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
|
||||
expect(readRun(store, automation.id, run.id).error).toBeNull()
|
||||
expect(logged.mock.calls.flat().map(String).join(' ')).toContain('terminal_handle_stale')
|
||||
logged.mockRestore()
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('reconciles stranded runs on startup without claiming completion', async () => {
|
||||
it('keeps unresolvable retained runs dispatched after startup reconciliation', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const dispatched = store.createAutomationRun(automation, 1_000, 'manual')
|
||||
@@ -193,10 +183,10 @@ describe('authority-owned automation run completion', () => {
|
||||
service.setRendererReady()
|
||||
await vi.advanceTimersByTimeAsync(10 * 60 * 1000)
|
||||
|
||||
expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatch_failed')
|
||||
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed')
|
||||
expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal')
|
||||
expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started')
|
||||
expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatched')
|
||||
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatching')
|
||||
expect(readRun(store, automation.id, dispatched.id).error).toBeNull()
|
||||
expect(readRun(store, automation.id, dispatching.id).error).toBeNull()
|
||||
service.stop()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -30,12 +30,9 @@ export function describeStrandedAutomationRun(run: AutomationRun): string {
|
||||
return 'Orca lost the terminal for this run before it reported completion.'
|
||||
}
|
||||
|
||||
/** Why a fixed sentence: the throws here carry internal transport tokens
|
||||
* (`terminal_handle_stale`, `terminal_not_found`, `request_aborted`), and a run
|
||||
* history row is user copy. The token stays in the log, where it is useful. */
|
||||
function describeObservationError(error: unknown): string {
|
||||
/** Observation failures are transport evidence, so they stay in logs and never finalize a run. */
|
||||
function logObservationError(error: unknown): void {
|
||||
console.error('[automations] run completion observation failed:', error)
|
||||
return 'Orca stopped watching this run before it reported completion.'
|
||||
}
|
||||
|
||||
export class AutomationRunCompletionWatcher {
|
||||
@@ -60,13 +57,11 @@ export class AutomationRunCompletionWatcher {
|
||||
const current = this.readRun(run.automationId, run.id)
|
||||
return Boolean(current && !isFinalAutomationRunStatus(current.status))
|
||||
},
|
||||
strand: (run) => {
|
||||
void this.finalize(run, {
|
||||
status: 'dispatch_failed',
|
||||
error: describeStrandedAutomationRun(run)
|
||||
}).catch((error) => {
|
||||
console.error('[automations] failed to reconcile stranded run:', error)
|
||||
})
|
||||
unverifiable: (run) => {
|
||||
console.warn(
|
||||
'[automations] run completion is unverifiable:',
|
||||
describeStrandedAutomationRun(run)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -101,10 +96,13 @@ export class AutomationRunCompletionWatcher {
|
||||
private startWatch(run: AutomationRun, handle: string): void {
|
||||
const controller = new AbortController()
|
||||
this.watching.set(run.id, controller)
|
||||
void this.observeUntilTerminal(run, handle, controller).finally(() => {
|
||||
void this.observeUntilTerminal(run, handle, controller).then((retry) => {
|
||||
if (this.watching.get(run.id) === controller) {
|
||||
this.watching.delete(run.id)
|
||||
}
|
||||
if (retry) {
|
||||
this.reconciler.defer(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,21 +110,23 @@ export class AutomationRunCompletionWatcher {
|
||||
run: AutomationRun,
|
||||
handle: string,
|
||||
controller: AbortController
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
let observation: AutomationRunCompletionObservation
|
||||
try {
|
||||
observation = await this.observer.observeCompletion(handle, { signal: controller.signal })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
observation = { status: 'dispatch_failed', error: describeObservationError(error) }
|
||||
logObservationError(error)
|
||||
return true
|
||||
}
|
||||
try {
|
||||
await this.finalize(run, observation)
|
||||
} catch (error) {
|
||||
console.error('[automations] failed to persist watched run completion:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
forget(runId: string): void {
|
||||
|
||||
@@ -181,13 +181,13 @@ describe('createRuntimeAutomationRunTerminalObserver', () => {
|
||||
await run.promise
|
||||
})
|
||||
|
||||
it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => {
|
||||
it('makes observation unverifiable after its bounded watch window', async () => {
|
||||
const runtime = createFakeRuntime({ lastAgentStatus: 'working' })
|
||||
const run = observe(runtime)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 + RUNTIME_TUI_IDLE_TIMEOUT_MS)
|
||||
expect(run.settled[0]?.status).toBe('dispatch_failed')
|
||||
expect(run.settled[0]?.error).toContain('without a completion signal')
|
||||
expect(run.settled).toEqual([])
|
||||
expect(run.errors.map(String)).toContain('Error: automation_run_observation_unverifiable')
|
||||
// 6h of 5-minute waits, not an unbounded re-arm.
|
||||
expect(runtime.waitCalls()).toBeLessThanOrEqual(80)
|
||||
await run.promise
|
||||
|
||||
@@ -185,11 +185,7 @@ export function createRuntimeAutomationRunTerminalObserver(
|
||||
throw error
|
||||
}
|
||||
if (Date.now() >= deadlineAt) {
|
||||
return await buildUnobservedObservation(
|
||||
runtime,
|
||||
handle,
|
||||
'Orca stopped watching this run after 6h without a completion signal.'
|
||||
)
|
||||
throw new Error('automation_run_observation_unverifiable')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2316,6 +2316,7 @@ void app.whenReady().then(async () => {
|
||||
reportSecretProtectionGap({
|
||||
dataFile: activeOrcaProfile.dataFile,
|
||||
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
|
||||
})
|
||||
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
|
||||
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own
|
||||
// accept records on every launch.
|
||||
|
||||
@@ -87,7 +87,11 @@ describe('scoped external-manager IPC', () => {
|
||||
provider: 'hermes'
|
||||
})) as { manager: { id: string; target: unknown } | null; error: string | null }
|
||||
|
||||
expect(relay.request).toHaveBeenCalledWith('externalAutomations.list', { provider: 'hermes' })
|
||||
expect(relay.request).toHaveBeenCalledWith(
|
||||
'externalAutomations.list',
|
||||
{ provider: 'hermes' },
|
||||
{ signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(entry.manager?.id).toBe('hermes:ssh:t1')
|
||||
expect(entry.manager?.target).toEqual({ type: 'ssh', connectionId: 't1' })
|
||||
expect(entry.error).toBeNull()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -626,6 +626,7 @@ import {
|
||||
resolveCustomWorktreeVisibilitySources,
|
||||
type WorktreeVisibilitySourceMatcher
|
||||
} from '../../shared/worktree/visibility-sources'
|
||||
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
|
||||
import {
|
||||
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
|
||||
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
|
||||
@@ -3682,6 +3683,7 @@ export class OrcaRuntimeService {
|
||||
private skillCloudService: SkillCloudService | null = null
|
||||
private agentSkillShareInProgress = false
|
||||
private skillUploadSessions: SkillUploadSessionService | null = null
|
||||
private skillUploadSessionsDisposed = false
|
||||
private readonly skillTransactionRecovery: Promise<unknown>
|
||||
private readonly skillInstallOperations = new Map<string, AbortController>()
|
||||
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
|
||||
@@ -5796,12 +5798,22 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private requireSkillUploadSessions(): SkillUploadSessionService {
|
||||
if (this.skillUploadSessionsDisposed) {
|
||||
throw new Error('skill-upload-service-disposed')
|
||||
}
|
||||
this.skillUploadSessions ??= new SkillUploadSessionService(
|
||||
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
|
||||
)
|
||||
return this.skillUploadSessions
|
||||
}
|
||||
|
||||
async disposeSkillUploadSessions(): Promise<void> {
|
||||
this.skillUploadSessionsDisposed = true
|
||||
const sessions = this.skillUploadSessions
|
||||
this.skillUploadSessions = null
|
||||
await sessions?.dispose()
|
||||
}
|
||||
|
||||
getRuntimeId(): string {
|
||||
return this.runtimeId
|
||||
}
|
||||
@@ -23434,7 +23446,8 @@ export class OrcaRuntimeService {
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
const matcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...worktrees.map((worktree) => worktree.path)],
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
|
||||
resolveConfiguredWorktreeBasePaths(repo)
|
||||
)
|
||||
const detected = worktrees.map((worktree) =>
|
||||
this.toRuntimeDetectedWorktree(
|
||||
@@ -23463,7 +23476,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
|
||||
resolveConfiguredWorktreeBasePaths(repo)
|
||||
)
|
||||
const expectedHostId = getRepoExecutionHostId(repo)
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
@@ -23575,7 +23589,8 @@ export class OrcaRuntimeService {
|
||||
repo.id,
|
||||
createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
|
||||
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
|
||||
resolveConfiguredWorktreeBasePaths(repo)
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
@@ -84,7 +84,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
authority: runtime,
|
||||
repoSelector: params.repo,
|
||||
repo,
|
||||
request: params.automationProvenanceRequest
|
||||
request: params.automationProvenanceRequest,
|
||||
storageAuthority: 'runtime'
|
||||
})
|
||||
// Why: provenance tokens are reserved before creation so retries can recover,
|
||||
// but failed create attempts must release the reservation for a safe retry.
|
||||
|
||||
@@ -81,7 +81,11 @@ describe('AutomationHostStatusBadges', () => {
|
||||
it('distinguishes an incompatible authority from a legacy-unscoped contract', () => {
|
||||
render(<AutomationHostStatusBadges entry={entry({ authorityHealth: 'incompatible' })} />)
|
||||
const incompatible = statusIds()
|
||||
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'legacy-unscoped' })} />)
|
||||
render(
|
||||
<AutomationHostStatusBadges
|
||||
entry={entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' })}
|
||||
/>
|
||||
)
|
||||
const legacy = statusIds()
|
||||
|
||||
expect(incompatible).toContain('authority-incompatible')
|
||||
@@ -89,6 +93,22 @@ describe('AutomationHostStatusBadges', () => {
|
||||
expect(incompatible).not.toEqual(legacy)
|
||||
})
|
||||
|
||||
it('shows no View only badge for a connected legacy Runtime + Self entry', () => {
|
||||
render(
|
||||
<AutomationHostStatusBadges
|
||||
entry={entry({
|
||||
stableRef: {
|
||||
authority: { kind: 'runtime', environmentId: 'env-1' },
|
||||
selector: { kind: 'self' }
|
||||
},
|
||||
kind: 'self',
|
||||
querySupport: 'legacy-unscoped'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(statusIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('separates the incompatible query contract from the incompatible authority', () => {
|
||||
render(<AutomationHostStatusBadges entry={entry({ querySupport: 'incompatible' })} />)
|
||||
expect(statusIds('query')).toEqual(['query-incompatible'])
|
||||
|
||||
@@ -19,6 +19,7 @@ let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
@@ -141,14 +142,24 @@ describe('AutomationHostFilterNotice', () => {
|
||||
expect(onRecover).toHaveBeenCalledWith('reconnect')
|
||||
})
|
||||
|
||||
it('offers Update server for a legacy-unscoped contract', () => {
|
||||
it('offers Update server for an entry the unscoped contract leaves view-only', () => {
|
||||
renderNotice(
|
||||
resolution('ghost', { entry: entry({ querySupport: 'legacy-unscoped' }) }),
|
||||
resolution('ghost', {
|
||||
entry: entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' })
|
||||
}),
|
||||
() => undefined
|
||||
)
|
||||
expect(container.querySelector('button')?.textContent).toBe('Update server')
|
||||
})
|
||||
|
||||
it('offers no Update server for a legacy Self entry with no scope gap', () => {
|
||||
renderNotice(
|
||||
resolution('ready', { entry: entry({ querySupport: 'legacy-unscoped' }) }),
|
||||
() => undefined
|
||||
)
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not move focus when the notice appears', () => {
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AutomationHostStatusBadges } from './AutomationHostBadges'
|
||||
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
|
||||
import type { AutomationHostFilterResolution } from './automation-host-filter-resolution'
|
||||
import {
|
||||
automationHostRecoveryActions,
|
||||
recoveryActionLabel,
|
||||
type AutomationHostRecoveryAction
|
||||
} from './automation-host-status-descriptors'
|
||||
|
||||
@@ -60,7 +59,7 @@ export function AutomationHostLoadSummary({
|
||||
|
||||
type AutomationHostFilterNoticeProps = {
|
||||
resolution: AutomationHostFilterResolution
|
||||
onRecover?: (action: AutomationHostRecoveryAction) => void
|
||||
onRecover?: (action: AutomationHostRecoveryAction) => void | Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -132,9 +131,7 @@ export function AutomationHostFilterNotice({
|
||||
<span className="min-w-0 flex-1">{message}</span>
|
||||
{resolution.entry ? <AutomationHostStatusBadges entry={resolution.entry} /> : null}
|
||||
{action && onRecover ? (
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onRecover(action)}>
|
||||
{recoveryActionLabel(action)}
|
||||
</Button>
|
||||
<AutomationHostRecoverButton action={action} onRecover={onRecover} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -54,14 +54,15 @@ function runtimeSelf(environmentId: string, label: string): AutomationHostCatalo
|
||||
|
||||
function render(
|
||||
entries: readonly AutomationHostCatalogEntry[],
|
||||
onSelect: (filter: AutomationHostFilter) => void = () => undefined
|
||||
onSelect: (filter: AutomationHostFilter) => void = () => undefined,
|
||||
selectedStableKey: string | null = null
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<AutomationHostPickerSelect
|
||||
entries={entries}
|
||||
selectedStableKey={null}
|
||||
selectedStableKey={selectedStableKey}
|
||||
triggerLabel="All hosts"
|
||||
pickerLabel="Filter by host"
|
||||
onSelect={onSelect}
|
||||
@@ -140,6 +141,24 @@ describe('AutomationHostPickerSelect authority grouping', () => {
|
||||
expect(groups().map(accessibleGroupName)).toEqual(['This computer'])
|
||||
})
|
||||
|
||||
it('opens below the trigger instead of aligning the selected host', () => {
|
||||
render(
|
||||
[sshEntry('t0', 'web-00'), runtimeSelf('env-1', 'Build box')],
|
||||
() => undefined,
|
||||
'host:runtime:env-1:self'
|
||||
)
|
||||
openWithKeyboard()
|
||||
|
||||
const content = document.querySelector('[data-slot="select-content"]')
|
||||
expect(content).not.toBeNull()
|
||||
expect(content?.getAttribute('data-side')).toBe('bottom')
|
||||
expect(content?.getAttribute('data-align')).toBe('start')
|
||||
const firstItem = document.querySelector(
|
||||
'[data-slot="select-content"] [data-slot="select-item"]'
|
||||
)
|
||||
expect(firstItem?.textContent).toContain('All hosts')
|
||||
})
|
||||
|
||||
it('reports the chosen host as a stable filter', () => {
|
||||
const onSelect = vi.fn()
|
||||
render([sshEntry('t0', 'web-00'), sshEntry('t1', 'web-01')], onSelect)
|
||||
|
||||
@@ -64,13 +64,22 @@ export function AutomationHostPickerSelect({
|
||||
<SelectTrigger
|
||||
aria-label={pickerLabel}
|
||||
data-picker-variant="select"
|
||||
className={cn('h-8 text-xs', className)}
|
||||
className={cn('h-8 w-full text-xs', className)}
|
||||
>
|
||||
{/* Without children Radix names the trigger from the matching item, and a
|
||||
retained-but-unhydrated selection matches only the All hosts item. */}
|
||||
<SelectValue>{selectedStableKey === null ? triggerLabel : undefined}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Why: item-aligned lines the selected host up with the trigger, so a
|
||||
mid-list pick (and the group label above it) clips under the titlebar. */}
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="min-w-[20rem]"
|
||||
>
|
||||
<SelectItem value={ALL_HOSTS_OPTION_VALUE} className="text-xs">
|
||||
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
|
||||
</SelectItem>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, type JSX } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
recoveryActionLabel,
|
||||
type AutomationHostRecoveryAction
|
||||
} from './automation-host-status-descriptors'
|
||||
|
||||
export function AutomationHostRecoverButton({
|
||||
action,
|
||||
onRecover,
|
||||
size = 'xs',
|
||||
className
|
||||
}: {
|
||||
action: AutomationHostRecoveryAction
|
||||
onRecover: (action: AutomationHostRecoveryAction) => void | Promise<void>
|
||||
size?: 'xs' | 'sm'
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
const [busy, setBusy] = useState(false)
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size={size}
|
||||
className={className}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => {
|
||||
const result = onRecover(action)
|
||||
if (result instanceof Promise) {
|
||||
setBusy(true)
|
||||
void result.finally(() => setBusy(false))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
{recoveryActionLabel(action)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
resolveAutomationListEmptyState,
|
||||
type AutomationListEmptyStateInput
|
||||
} from './automation-list-empty-state'
|
||||
import {
|
||||
recoveryActionLabel,
|
||||
type AutomationHostRecoveryAction
|
||||
} from './automation-host-status-descriptors'
|
||||
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
|
||||
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
|
||||
|
||||
/** Renders whichever empty/partial/failure state the list is in; nothing when rows exist. */
|
||||
|
||||
export type AutomationListEmptyViewProps = AutomationListEmptyStateInput & {
|
||||
onRecover?: (action: AutomationHostRecoveryAction) => void
|
||||
onRecover?: (action: AutomationHostRecoveryAction) => void | Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -44,15 +41,12 @@ export function AutomationListEmptyView({
|
||||
</p>
|
||||
) : null}
|
||||
{recovery && onRecover ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<AutomationHostRecoverButton
|
||||
action={recovery}
|
||||
onRecover={onRecover}
|
||||
size="sm"
|
||||
className="mt-1.5"
|
||||
onClick={() => onRecover(recovery)}
|
||||
>
|
||||
{recoveryActionLabel(recovery)}
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -86,7 +86,8 @@ export function AutomationListExternalRows({
|
||||
})
|
||||
const actionDisabled = disabledMessage !== null
|
||||
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
|
||||
const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}`
|
||||
const projectLabel = entry.job.workdir || '—'
|
||||
const hostLabel = entry.manager.targetLabel
|
||||
const nextRunLabel = entry.job.enabled
|
||||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
|
||||
@@ -127,6 +128,9 @@ export function AutomationListExternalRows({
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
|
||||
import { AutomationHostRecoverButton } from './AutomationHostRecoverButton'
|
||||
import {
|
||||
AutomationListLocalRows,
|
||||
type AutomationListLocalRowsProps
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { externalManagersListedForEntry } from './external-automation-scope-gating'
|
||||
import {
|
||||
automationHostRecoveryActions,
|
||||
recoveryActionLabel,
|
||||
type AutomationHostRecoveryAction
|
||||
} from './automation-host-status-descriptors'
|
||||
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
|
||||
@@ -31,7 +30,10 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types
|
||||
export type AutomationListHostGroupsProps = Omit<AutomationListLocalRowsProps, 'rows'> & {
|
||||
groups: readonly AutomationHostVisibleGroup[]
|
||||
searchActive: boolean
|
||||
onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void
|
||||
onRecover: (
|
||||
action: AutomationHostRecoveryAction,
|
||||
entry: AutomationHostCatalogEntry
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
function hostRecoveryAction(
|
||||
@@ -48,7 +50,7 @@ function AutomationHostGroupState({
|
||||
}: {
|
||||
state: AutomationListEmptyState
|
||||
recovery: AutomationHostRecoveryAction | null
|
||||
onRecover: (action: AutomationHostRecoveryAction) => void
|
||||
onRecover: (action: AutomationHostRecoveryAction) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="px-3 pb-1 text-xs text-muted-foreground" data-empty-state={state.kind}>
|
||||
@@ -60,15 +62,7 @@ function AutomationHostGroupState({
|
||||
</div>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
onClick={() => onRecover(recovery)}
|
||||
>
|
||||
{recoveryActionLabel(recovery)}
|
||||
</Button>
|
||||
<AutomationHostRecoverButton action={recovery} onRecover={onRecover} className="mt-1" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
@@ -102,14 +96,10 @@ export function AutomationListHostGroups({
|
||||
<AutomationHostLabel entry={entry} className="min-w-0 flex-1" />
|
||||
<AutomationHostStatusBadges entry={entry} />
|
||||
{action ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onRecover(action, entry)}
|
||||
>
|
||||
{recoveryActionLabel(action)}
|
||||
</Button>
|
||||
<AutomationHostRecoverButton
|
||||
action={action}
|
||||
onRecover={(recovery) => onRecover(recovery, entry)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{state.kind === 'rows' ? (
|
||||
|
||||
@@ -252,4 +252,14 @@ describe('Automation list row selection', () => {
|
||||
expect(onRequestAction).toHaveBeenCalledTimes(1)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the host label in the Host column for local rows', () => {
|
||||
renderLocalRows({ onSelect: vi.fn() })
|
||||
expect(screen.getByText('Local')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the host label in the Host column for external rows', () => {
|
||||
renderExternalRows({ onSelect: vi.fn() })
|
||||
expect(screen.getByText('Local')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -247,6 +247,9 @@ export function AutomationListLocalRows({
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function AutomationListTableHeader({
|
||||
<span>
|
||||
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
|
||||
</span>
|
||||
<span>{translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}</span>
|
||||
<span>
|
||||
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
|
||||
</span>
|
||||
|
||||
@@ -152,3 +152,23 @@ describe('AutomationsListPanel unchecked hosts', () => {
|
||||
expect(container.textContent).not.toContain('could not be checked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomationsListPanel flat table layout', () => {
|
||||
it('renders the Host column in the table header', () => {
|
||||
const row = makeAutomationListRow()
|
||||
renderPanel([row], '')
|
||||
|
||||
expect(container.textContent).toContain('Host')
|
||||
})
|
||||
|
||||
it('renders rows in a flat list without host section headers', () => {
|
||||
const row1 = makeAutomationListRow({ hostLabel: 'Local Mac' })
|
||||
const row2 = makeAutomationListRow({ hostLabel: 'Linux' })
|
||||
renderPanel([row1, row2], '')
|
||||
|
||||
expect(container.textContent).toContain('Local Mac')
|
||||
expect(container.textContent).toContain('Linux')
|
||||
// No section headers grouping by host
|
||||
expect(container.querySelector('[data-host-group]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,6 @@ import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import type { AutomationRowAction } from './automation-captured-owner'
|
||||
import type { AutomationHostTarget } from './automation-host-client'
|
||||
import { clampAutomationListSearchQueryInput } from './automation-list-search'
|
||||
import {
|
||||
getAutomationListArrowNavigationTarget,
|
||||
type AutomationListArrowKey
|
||||
} from './automation-list-keyboard-navigation'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { AutomationPaneTab } from './automation-page-state'
|
||||
import { AutomationListSearchField } from './AutomationListSearchField'
|
||||
@@ -31,8 +27,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent
|
||||
import type { ExternalAutomationScope } from './external-automation-scope-client'
|
||||
import { AutomationListLocalRows } from './AutomationListLocalRows'
|
||||
import { AutomationListExternalRows } from './AutomationListExternalRows'
|
||||
import { AutomationListHostGroups } from './AutomationListHostGroups'
|
||||
import { filterAutomationHostGroups } from './automation-host-list-rows'
|
||||
import { AutomationHostPicker } from './AutomationHostPicker'
|
||||
import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice'
|
||||
import { AutomationListEmptyView } from './AutomationListEmptyView'
|
||||
@@ -42,6 +36,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
|
||||
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
|
||||
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
|
||||
import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery'
|
||||
import { useAutomationListKeyboardNavigation } from './use-automation-list-keyboard-navigation'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -64,7 +59,7 @@ type AutomationsListPanelProps = {
|
||||
onRecoverHost: (
|
||||
action: AutomationHostRecoveryAction,
|
||||
entry?: AutomationHostCatalogEntry | null
|
||||
) => void
|
||||
) => void | Promise<void>
|
||||
filteredRows: readonly AutomationListRow[]
|
||||
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
|
||||
selectedRowKey: string | null
|
||||
@@ -157,62 +152,18 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
} = props
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const pendingKeyboardScrollRef = useRef(false)
|
||||
const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows])
|
||||
const visibleRowKeys = React.useMemo(() => new Set(rowKeys), [rowKeys])
|
||||
const visibleItems = React.useMemo(
|
||||
() => [
|
||||
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
|
||||
...filteredExternalAutomationEntries.map((entry) => ({ kind: 'external' as const, id: entry.key }))
|
||||
],
|
||||
[filteredExternalAutomationEntries, filteredRows]
|
||||
)
|
||||
useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef })
|
||||
const handleSearchArrowNavigate = React.useCallback(
|
||||
(key: AutomationListArrowKey) => {
|
||||
const next = getAutomationListArrowNavigationTarget({
|
||||
items: visibleItems,
|
||||
selectedId: selectedRowKey,
|
||||
selectedExternalKey,
|
||||
key
|
||||
})
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
const alreadySelected =
|
||||
next.kind === 'local'
|
||||
? selectedExternalKey === null && selectedRowKey === next.id
|
||||
: selectedExternalKey === next.id
|
||||
if (alreadySelected) {
|
||||
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
return
|
||||
}
|
||||
pendingKeyboardScrollRef.current = true
|
||||
if (next.kind === 'local') {
|
||||
selectExternalKey(null)
|
||||
selectAutomationRow(next.id)
|
||||
} else {
|
||||
selectAutomationRow(null)
|
||||
selectExternalKey(next.id)
|
||||
setActivePaneTab('overview')
|
||||
}
|
||||
},
|
||||
[
|
||||
selectAutomationRow,
|
||||
selectExternalKey,
|
||||
selectedExternalKey,
|
||||
selectedRowKey,
|
||||
setActivePaneTab,
|
||||
visibleItems
|
||||
]
|
||||
)
|
||||
React.useEffect(() => {
|
||||
if (!pendingKeyboardScrollRef.current) {
|
||||
return
|
||||
}
|
||||
pendingKeyboardScrollRef.current = false
|
||||
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedExternalKey, selectedRowKey])
|
||||
const handleSearchArrowNavigate = useAutomationListKeyboardNavigation({
|
||||
filteredRows,
|
||||
filteredExternalAutomationEntries,
|
||||
selectedRowKey,
|
||||
selectedExternalKey,
|
||||
selectAutomationRow,
|
||||
selectExternalKey,
|
||||
setActivePaneTab,
|
||||
listRef
|
||||
})
|
||||
const emptyStateInput = {
|
||||
resolution: hostCatalog.resolution,
|
||||
...searchCounts,
|
||||
@@ -236,8 +187,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
hostLabelById,
|
||||
isActionEnabled,
|
||||
onSelect: (rowKey: string) => {
|
||||
selectExternalKey(null)
|
||||
selectAutomationRow(rowKey)
|
||||
selectExternalKey(null)
|
||||
setActivePaneTab('overview')
|
||||
onOpenDetail()
|
||||
},
|
||||
onRunNow: runNow,
|
||||
@@ -251,9 +203,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-4 md:px-5"
|
||||
data-contextual-tour-target="automations-list"
|
||||
>
|
||||
<div className="flex shrink-0 items-end justify-between gap-3 pb-4">
|
||||
<div className="flex min-w-0 flex-1 items-end gap-2">
|
||||
<div ref={pickerRef} className="w-48 shrink-0">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div ref={pickerRef} className="w-80 shrink-0">
|
||||
<AutomationHostPicker
|
||||
entries={hostCatalog.entries}
|
||||
resolution={hostCatalog.resolution}
|
||||
@@ -274,22 +226,22 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
aria-label={translate(
|
||||
'auto.components.automations.AutomationsPage.19a6e30eae',
|
||||
'auto.components.automations.AutomationsPage.refreshAutomations',
|
||||
'Refresh automations'
|
||||
)}
|
||||
onClick={onRefresh}
|
||||
onClick={() => onRefresh()}
|
||||
disabled={isRefreshing}
|
||||
className="shrink-0 border border-border bg-background shadow-none hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCw className={cn('size-4', isRefreshing && 'animate-spin')} />
|
||||
<RefreshCw className={cn('size-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.19a6e30eae',
|
||||
'auto.components.automations.AutomationsPage.refreshAutomations',
|
||||
'Refresh automations'
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -326,10 +278,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
'scrollbar-sleek min-h-0 flex-1 overflow-auto',
|
||||
LIST_TABLE_CONTAINER_CLASS
|
||||
)}
|
||||
className={cn('scrollbar-sleek min-h-0 flex-1 overflow-auto', LIST_TABLE_CONTAINER_CLASS)}
|
||||
>
|
||||
{hasFilteredListItems ? (
|
||||
<>
|
||||
@@ -343,6 +292,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
<span>
|
||||
{translate('auto.components.automations.AutomationsPage.tableProject', 'Project')}
|
||||
</span>
|
||||
<span>
|
||||
{translate('auto.components.automations.AutomationsPage.tableHost', 'Host')}
|
||||
</span>
|
||||
<span>
|
||||
{translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')}
|
||||
</span>
|
||||
@@ -360,16 +312,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{hostCatalog.rows.groups.length > 0 ? (
|
||||
<AutomationListHostGroups
|
||||
{...rowProps}
|
||||
groups={filterAutomationHostGroups(hostCatalog.rows.groups, visibleRowKeys)}
|
||||
searchActive={searchCounts.searchActive}
|
||||
onRecover={onRecoverHost}
|
||||
/>
|
||||
) : (
|
||||
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
|
||||
)}
|
||||
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
|
||||
<AutomationListExternalRows
|
||||
entries={filteredExternalAutomationEntries}
|
||||
selectedExternalKey={selectedExternalKey}
|
||||
|
||||
+179
-22
@@ -11,10 +11,10 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
import { hostStableKey } from '../../../../shared/automation-owner-key'
|
||||
import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import {
|
||||
api,
|
||||
installAutomationsPageHarness,
|
||||
@@ -51,6 +51,10 @@ const RUNTIME_SELF_KEY = hostStableKey({
|
||||
authority: { kind: 'runtime', environmentId: RUNTIME_ID },
|
||||
selector: { kind: 'self' }
|
||||
})
|
||||
const RUNTIME_SSH_KEY = hostStableKey({
|
||||
authority: { kind: 'runtime', environmentId: RUNTIME_ID },
|
||||
selector: { kind: 'ssh', targetId: SSH_TARGET_ID }
|
||||
})
|
||||
|
||||
/**
|
||||
* A project the runtime owns. It carries no `connectionId`, so a flat repo
|
||||
@@ -72,8 +76,11 @@ function addRuntimeProject(): void {
|
||||
repoId: RUNTIME_REPO_ID,
|
||||
displayName: 'main',
|
||||
path: '/repos/gpu-orca',
|
||||
branch: 'main'
|
||||
} as Worktree
|
||||
branch: 'main',
|
||||
hostId: `runtime:${RUNTIME_ID}`,
|
||||
runtimeOwnerEnvironmentId: RUNTIME_ID,
|
||||
projectHostSetupId: 'setup-2'
|
||||
} as unknown as Worktree
|
||||
const setup: ProjectHostSetup = {
|
||||
id: 'setup-2',
|
||||
projectId: 'project-2',
|
||||
@@ -81,6 +88,7 @@ function addRuntimeProject(): void {
|
||||
repoId: RUNTIME_REPO_ID,
|
||||
path: '/repos/gpu-orca',
|
||||
displayName: 'gpu-orca',
|
||||
runtimeOwnerEnvironmentId: RUNTIME_ID,
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
@@ -118,6 +126,47 @@ function addSshProject(): void {
|
||||
mocks.repoMap.set(SSH_REPO_ID, repo)
|
||||
}
|
||||
|
||||
function addRuntimeSshProject(): void {
|
||||
const repo = {
|
||||
id: 'repo-runtime-ssh',
|
||||
displayName: 'runtime-builder',
|
||||
path: '/repos/runtime-builder',
|
||||
badgeColor: '#333333',
|
||||
addedAt: 1,
|
||||
connectionId: SSH_TARGET_ID,
|
||||
executionHostId: `runtime:${RUNTIME_ID}`
|
||||
} as Repo
|
||||
const worktree = {
|
||||
id: 'workspace-runtime-ssh',
|
||||
repoId: repo.id,
|
||||
displayName: 'main',
|
||||
path: repo.path,
|
||||
branch: 'main',
|
||||
hostId: `ssh:${SSH_TARGET_ID}`,
|
||||
runtimeOwnerEnvironmentId: RUNTIME_ID
|
||||
} as unknown as Worktree
|
||||
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
|
||||
mocks.state.worktreesByRepo = {
|
||||
...(mocks.state.worktreesByRepo as Record<string, Worktree[]>),
|
||||
[repo.id]: [worktree]
|
||||
}
|
||||
mocks.state.sshStateByEnvironment = new Map([
|
||||
[
|
||||
RUNTIME_ID,
|
||||
{
|
||||
connectionStates: new Map([[SSH_TARGET_ID, { status: 'connected' }]]),
|
||||
targetLabels: new Map([[SSH_TARGET_ID, 'runtime builder']]),
|
||||
targetGenerations: new Map([[SSH_TARGET_ID, 2]]),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
}
|
||||
]
|
||||
])
|
||||
mocks.state.activeWorktreeId = worktree.id
|
||||
mocks.repoMap.set(repo.id, repo)
|
||||
mocks.worktreeMap.set(worktree.id, worktree)
|
||||
}
|
||||
|
||||
/** The runtime answers a create; without this the RPC double returns an empty result. */
|
||||
function runtimeCreateReturns(automation: Automation): void {
|
||||
const previous = mocks.callRuntimeRpc.getMockImplementation()
|
||||
@@ -163,11 +212,12 @@ describe('AutomationsPage create destination', () => {
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' }))
|
||||
addRuntimeProject()
|
||||
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await openCreateDialogFor(REPO_ID, WORKSPACE_ID)
|
||||
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
|
||||
await save()
|
||||
|
||||
// The desktop is the client's own authority, never the selected host.
|
||||
@@ -200,6 +250,7 @@ describe('AutomationsPage create destination', () => {
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' }))
|
||||
addRuntimeProject()
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
@@ -217,12 +268,31 @@ describe('AutomationsPage create destination', () => {
|
||||
mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY)
|
||||
})
|
||||
expect(mocks.editorDialog?.createDestination?.resolution.status).toBe('ready')
|
||||
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
|
||||
await save()
|
||||
|
||||
expect(runtimeCreateCalls()).toHaveLength(1)
|
||||
expect(api.automations.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefills Runtime + SSH from the active qualified workspace', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
addSshHost()
|
||||
addRuntimeSshProject()
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await act(async () => {
|
||||
mocks.listPanel?.openCreateDialog()
|
||||
})
|
||||
|
||||
const resolution = mocks.editorDialog?.createDestination?.resolution
|
||||
expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(RUNTIME_SSH_KEY)
|
||||
expect(resolution?.status === 'ready' && resolution.entry.stableKey).not.toBe(SSH_HOST_KEY)
|
||||
})
|
||||
|
||||
it('offers only the projects the chosen host actually has', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
@@ -243,6 +313,76 @@ describe('AutomationsPage create destination', () => {
|
||||
expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID])
|
||||
})
|
||||
|
||||
it('offers only workspaces verified on the chosen runtime', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
addRuntimeProject()
|
||||
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
|
||||
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
|
||||
const foreign = {
|
||||
...bucket[0],
|
||||
id: 'foreign-workspace',
|
||||
displayName: 'foreign',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: undefined
|
||||
} as Worktree
|
||||
bucket.push(foreign)
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([
|
||||
RUNTIME_WORKSPACE_ID
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an injected workspace from another host before runtime create', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
addRuntimeProject()
|
||||
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
|
||||
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
|
||||
const foreign = {
|
||||
...bucket[0],
|
||||
id: 'foreign-workspace',
|
||||
displayName: 'foreign',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: undefined
|
||||
} as Worktree
|
||||
bucket.push(foreign)
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await openCreateDialogFor(RUNTIME_REPO_ID, foreign.id)
|
||||
await save()
|
||||
|
||||
expect(runtimeCreateCalls()).toHaveLength(0)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Choose an available workspace before saving.')
|
||||
})
|
||||
|
||||
it('does not offer or save remembered workspaces when the host cannot verify them', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
runtimeHost([], [])
|
||||
addRuntimeProject()
|
||||
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
|
||||
mocks.state.fetchWorktrees = vi.fn(async () => false)
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID)
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.editorDialog?.worktrees).toEqual([])
|
||||
await save()
|
||||
expect(runtimeCreateCalls()).toHaveLength(0)
|
||||
expect(mocks.editorDialog?.notice?.message).toContain("couldn't verify workspaces")
|
||||
})
|
||||
|
||||
it('moves a stranded project to one the newly chosen host has', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
@@ -349,6 +489,40 @@ describe('AutomationsPage edit fencing', () => {
|
||||
)
|
||||
}
|
||||
|
||||
it('offers only workspaces verified on the edited automation’s runtime', async () => {
|
||||
const automation = makeAutomation({
|
||||
id: 'a-runtime',
|
||||
projectId: RUNTIME_REPO_ID,
|
||||
workspaceId: RUNTIME_WORKSPACE_ID,
|
||||
workspaceMode: 'existing'
|
||||
})
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([])
|
||||
runtimeHost([automation], [])
|
||||
addRuntimeProject()
|
||||
mocks.state.automationHostFilter = RUNTIME_SELF_FILTER
|
||||
const bucket = (mocks.state.worktreesByRepo as Record<string, Worktree[]>)[RUNTIME_REPO_ID]!
|
||||
bucket.push({
|
||||
...bucket[0],
|
||||
id: 'foreign-workspace',
|
||||
displayName: 'foreign',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: undefined
|
||||
} as Worktree)
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await act(async () => {
|
||||
void mocks.listPanel?.openEditDialog(listedRow(automation.id))
|
||||
})
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([RUNTIME_REPO_ID])
|
||||
expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([
|
||||
RUNTIME_WORKSPACE_ID
|
||||
])
|
||||
})
|
||||
|
||||
it('fences the save even when the record cannot be re-read', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1' })
|
||||
api.automations.list.mockResolvedValue([])
|
||||
@@ -363,21 +537,4 @@ describe('AutomationsPage edit fencing', () => {
|
||||
expect(api.automations.update).toHaveBeenCalled()
|
||||
expect(updatePreconditions()).not.toContain(undefined)
|
||||
})
|
||||
|
||||
it('refuses the save when nothing names the record’s host', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1' })
|
||||
// No host answered, so no owner was ever captured, and the record is gone
|
||||
// from the one list the client can still address.
|
||||
api.automations.listScoped.mockRejectedValue(new Error('offline'))
|
||||
api.automations.list.mockResolvedValue([])
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
// No host answered, so the page never listed a row for it; the pre-catalog
|
||||
// projection is the only key the user could have clicked.
|
||||
await editAndSave(unscopedAutomationListRows([automation])[0]!)
|
||||
|
||||
expect(api.automations.update).not.toHaveBeenCalled()
|
||||
expect(mocks.editorDialog?.notice?.message).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
+68
@@ -17,6 +17,7 @@ import {
|
||||
installAutomationsPageHarness,
|
||||
mocks,
|
||||
renderPage,
|
||||
RUNTIME_ID,
|
||||
runtimeHost,
|
||||
scopedList,
|
||||
SELF_PRECONDITION,
|
||||
@@ -121,4 +122,71 @@ describe('AutomationsPage row actions under a colliding automation id', () => {
|
||||
})
|
||||
expect(runtimeAutomationCalls()).not.toContain('automation.delete')
|
||||
})
|
||||
|
||||
it('routes a runtime orphan history, pause, and delete to its listing authority', async () => {
|
||||
const desktop = makeAutomation({ id: 'a-1', name: 'Desktop nightly' })
|
||||
const runtime = makeAutomation({ id: 'a-1', name: 'Runtime orphan' })
|
||||
runtimeHost([], [])
|
||||
scopedList([desktop])
|
||||
api.automations.list.mockResolvedValue([desktop])
|
||||
mocks.state.selectedAutomationId = 'a-1'
|
||||
mocks.callRuntimeRpc.mockImplementation(async (_target: unknown, method: string) => {
|
||||
if (method === 'automation.list') {
|
||||
return {
|
||||
automations: [runtime],
|
||||
items: [
|
||||
{
|
||||
automationId: runtime.id,
|
||||
selector: { kind: 'orphan', issue: 'missing_repo' }
|
||||
}
|
||||
],
|
||||
orphanCount: 1
|
||||
}
|
||||
}
|
||||
return method === 'automation.runs' ? { runs: [] } : {}
|
||||
})
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
const runtimeRow = mocks.listPanel?.filteredRows.find(
|
||||
(candidate) => candidate.automation.name === 'Runtime orphan'
|
||||
)
|
||||
expect(runtimeRow).toBeDefined()
|
||||
if (!runtimeRow) {
|
||||
throw new Error('runtime orphan row missing')
|
||||
}
|
||||
expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'toggle')).toBe(true)
|
||||
expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'delete')).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
mocks.listPanel?.selectAutomationRow(runtimeRow?.key ?? '')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
expect(mocks.detailPane?.selected?.name).toBe('Runtime orphan')
|
||||
await act(async () => {
|
||||
mocks.listPanel?.toggleAutomation(runtimeRow)
|
||||
})
|
||||
await act(async () => {
|
||||
mocks.listPanel?.requestDeleteAutomation(runtimeRow)
|
||||
})
|
||||
await act(async () => {
|
||||
mocks.deleteDialog?.onConfirm()
|
||||
})
|
||||
|
||||
const calls = mocks.callRuntimeRpc.mock.calls.filter((call) =>
|
||||
['automation.runs', 'automation.update', 'automation.delete'].includes(String(call[1]))
|
||||
)
|
||||
expect(calls.map((call) => call[0])).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'environment', environmentId: RUNTIME_ID },
|
||||
{ kind: 'environment', environmentId: RUNTIME_ID },
|
||||
{ kind: 'environment', environmentId: RUNTIME_ID }
|
||||
])
|
||||
)
|
||||
expect(calls.map((call) => call[1])).toEqual(
|
||||
expect.arrayContaining(['automation.runs', 'automation.update', 'automation.delete'])
|
||||
)
|
||||
expect(api.automations.update).not.toHaveBeenCalled()
|
||||
expect(api.automations.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,6 +99,26 @@ describe('AutomationsPage save visibility', () => {
|
||||
expect(rows(container, 'automation-row')).toEqual(['Sweep'])
|
||||
})
|
||||
|
||||
it('finishes the save while an external-manager probe is still hanging', async () => {
|
||||
desktopStoreHolds([])
|
||||
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
|
||||
api.automations.create.mockResolvedValue(CREATED)
|
||||
|
||||
const { container } = await renderPage()
|
||||
await settleHostQueries()
|
||||
|
||||
desktopStoreHolds([CREATED])
|
||||
// An unreachable host's probe never answers. The save must not wait on it:
|
||||
// waiting is what left the create dialog open over an emptied form.
|
||||
api.automations.listExternalManagerForOwner.mockImplementation(
|
||||
() => new Promise(() => undefined)
|
||||
)
|
||||
await createSweep()
|
||||
|
||||
expect(mocks.editorDialog?.open).toBe(false)
|
||||
expect(rows(container, 'automation-row')).toEqual(['Sweep'])
|
||||
})
|
||||
|
||||
it('lists it after the page is reopened', async () => {
|
||||
desktopStoreHolds([])
|
||||
mocks.state.automationHostFilter = DESKTOP_SELF_FILTER
|
||||
|
||||
@@ -54,15 +54,13 @@ describe('AutomationsPage list rendering', () => {
|
||||
expect(rows(container, 'automation-row')).toEqual(['Nightly', 'Weekly'])
|
||||
})
|
||||
|
||||
it('falls back to the unscoped list until a host has answered', async () => {
|
||||
it('does not expose unscoped bootstrap rows when no host has answered', async () => {
|
||||
api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })])
|
||||
api.automations.listScoped.mockRejectedValue(new Error('offline'))
|
||||
|
||||
const { container } = await renderPage()
|
||||
|
||||
// Why: a host that never answered is not an empty host, and the rows the
|
||||
// page already had are better than none. Step 9 removes this arm.
|
||||
expect(rows(container, 'automation-row')).toEqual(['Unscoped'])
|
||||
expect(rows(container, 'automation-row')).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the empty state when the host has no automations', async () => {
|
||||
@@ -111,7 +109,7 @@ describe('AutomationsPage list rendering', () => {
|
||||
const entry = mocks.listPanel?.hostCatalog.entries[0]
|
||||
api.automations.listScoped.mockClear()
|
||||
await act(async () => {
|
||||
mocks.listPanel?.hostCatalog.recover('retry', entry)
|
||||
await mocks.listPanel?.hostCatalog.recover('retry', entry)
|
||||
})
|
||||
|
||||
// Retry re-queries that host rather than reloading the page.
|
||||
@@ -166,6 +164,40 @@ describe('AutomationsPage list rendering', () => {
|
||||
expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key)
|
||||
})
|
||||
|
||||
it('finishes an external save while a manager probe is still hanging', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
api.automations.listExternalManagerForOwner.mockImplementation(
|
||||
async ({ provider }: { provider: string }) =>
|
||||
provider === 'hermes'
|
||||
? { manager: makeExternalManager(), error: null, updatedAt: 1 }
|
||||
: { manager: null, error: null, updatedAt: 1 }
|
||||
)
|
||||
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
|
||||
|
||||
await renderPage()
|
||||
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
|
||||
if (!entry) {
|
||||
throw new Error('no external entry to edit')
|
||||
}
|
||||
|
||||
// The re-read this save triggers never answers. It must still close its
|
||||
// dialog and report the result, exactly as the Orca save does: the managers
|
||||
// catch up when the probe lands.
|
||||
api.automations.listExternalManagerForOwner.mockImplementation(
|
||||
() => new Promise(() => undefined)
|
||||
)
|
||||
await act(async () => {
|
||||
mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope)
|
||||
})
|
||||
await act(async () => {
|
||||
await mocks.editorDialog?.onSave()
|
||||
})
|
||||
|
||||
expect(api.automations.updateExternalForOwner).toHaveBeenCalled()
|
||||
expect(mocks.editorDialog?.open).toBe(false)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes each authority’s action to its own host when both report hermes:local', async () => {
|
||||
api.automations.list.mockResolvedValue([])
|
||||
api.automations.listExternalManagerForOwner.mockImplementation(
|
||||
@@ -302,6 +334,32 @@ describe('AutomationsPage host filter', () => {
|
||||
})
|
||||
|
||||
describe('AutomationsPage run navigation', () => {
|
||||
it('selects the exact Runtime + SSH storage bucket from workspace provenance', async () => {
|
||||
const catalogRef = {
|
||||
authority: { kind: 'runtime' as const, environmentId: 'gpu' },
|
||||
selector: { kind: 'ssh' as const, targetId: 'builder' }
|
||||
}
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: 'a-1',
|
||||
runId: null,
|
||||
hostId: 'ssh:builder',
|
||||
authority: catalogRef.authority,
|
||||
catalogRef
|
||||
}
|
||||
mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ automations: [] })
|
||||
|
||||
await renderPage()
|
||||
|
||||
expect(mocks.setAutomationHostFilter).toHaveBeenCalledWith({ kind: 'host', host: catalogRef })
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'gpu' },
|
||||
'automation.list',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not leave a run navigation pending when the history read fails', async () => {
|
||||
api.automations.list.mockResolvedValue([
|
||||
makeAutomation({ id: 'a-1' }),
|
||||
@@ -569,23 +627,14 @@ describe('AutomationsPage owner conflicts', () => {
|
||||
expect(mocks.editorDialog?.isEditing).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the unfenced call for a row no host read has answered for', async () => {
|
||||
// The host never answered, so no row carries an owner and none may be
|
||||
// invented; the request goes out unfenced rather than against a guess.
|
||||
it('offers no unfenced action when no host read has answered', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1', enabled: true })
|
||||
api.automations.list.mockResolvedValue([automation])
|
||||
api.automations.listScoped.mockRejectedValue(new Error('offline'))
|
||||
|
||||
await renderPage()
|
||||
await act(async () => {
|
||||
mocks.listPanel?.toggleAutomation(listedRow(automation.id))
|
||||
})
|
||||
|
||||
// Why: Step 9 removes this legacy arm once every row carries a captured owner.
|
||||
expect(api.automations.update).toHaveBeenCalledWith({
|
||||
id: 'a-1',
|
||||
updates: { enabled: false }
|
||||
})
|
||||
expect(mocks.listPanel?.filteredRows).toEqual([])
|
||||
expect(api.automations.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A mixed-host folder workspace is the path that reaches this without a host
|
||||
@@ -622,15 +671,4 @@ describe('AutomationsPage owner conflicts', () => {
|
||||
expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'history')).toBe(true)
|
||||
expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'run-now')).toBe(false)
|
||||
})
|
||||
|
||||
it('greys out the actions on a row no owner could be captured for', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1' })
|
||||
api.automations.list.mockResolvedValue([automation])
|
||||
api.automations.listScoped.mockRejectedValue(new Error('offline'))
|
||||
|
||||
await renderPage()
|
||||
|
||||
// Uncaptured is not blocked: the legacy arm still works, so nothing is greyed.
|
||||
expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,10 +18,6 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import {
|
||||
AUTOMATION_OWNER_CONFLICT_CODES,
|
||||
automationOwnerConflictMessage
|
||||
} from '../../../../shared/automation-owner-conflict'
|
||||
import {
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
@@ -61,7 +57,10 @@ import {
|
||||
} from './automation-setup-decision'
|
||||
import type { AutomationTemplate } from './automation-templates'
|
||||
import { getAutomationTargetAvailability } from './automation-target-availability'
|
||||
import { buildAutomationRunContextForRepo } from './automation-run-context'
|
||||
import {
|
||||
buildAutomationRunContextForDestination,
|
||||
buildAutomationRunContextForRepo
|
||||
} from './automation-run-context'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
@@ -72,16 +71,43 @@ import {
|
||||
} from './use-selected-automation-run-history'
|
||||
|
||||
import {
|
||||
deleteAutomationForTarget,
|
||||
type AutomationHostTarget,
|
||||
getAutomationHostTargetFromKey,
|
||||
getAutomationHostTargetKey,
|
||||
getAutomationTargetFromAuthority,
|
||||
getAutomationTargetFromHostId,
|
||||
listAutomationsForTarget,
|
||||
runAutomationNowForTarget,
|
||||
updateAutomationForTarget
|
||||
listAutomationsForTarget
|
||||
} from './automation-host-client'
|
||||
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
|
||||
|
||||
type PendingAutomationNavigation = NonNullable<
|
||||
ReturnType<typeof useAppStore.getState>['pendingAutomationRunNavigation']
|
||||
>
|
||||
|
||||
function automationNavigationTarget(navigation: PendingAutomationNavigation): AutomationHostTarget {
|
||||
const authority = navigation.catalogRef?.authority ?? navigation.authority
|
||||
return authority
|
||||
? getAutomationTargetFromAuthority(authority)
|
||||
: getAutomationTargetFromHostId(navigation.hostId)
|
||||
}
|
||||
|
||||
function automationNavigationCatalogRef(
|
||||
navigation: PendingAutomationNavigation
|
||||
): StableAutomationCatalogRef {
|
||||
if (navigation.catalogRef) {
|
||||
return navigation.catalogRef
|
||||
}
|
||||
const host = parseExecutionHostId(navigation.hostId)
|
||||
const authority =
|
||||
navigation.authority ??
|
||||
(host?.kind === 'runtime'
|
||||
? { kind: 'runtime' as const, environmentId: host.environmentId }
|
||||
: { kind: 'desktop' as const })
|
||||
return {
|
||||
authority,
|
||||
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
|
||||
}
|
||||
}
|
||||
import {
|
||||
AUTOMATION_DEFAULT_TIME,
|
||||
buildDraftPrecheck,
|
||||
@@ -101,7 +127,6 @@ import { useAutomationListSearch } from './use-automation-list-search'
|
||||
import {
|
||||
automationRepoForRow,
|
||||
automationWorktreeForRow,
|
||||
unscopedAutomationListRows,
|
||||
type AutomationListRow
|
||||
} from './automation-list-row-identity'
|
||||
import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs'
|
||||
@@ -112,6 +137,7 @@ import type {
|
||||
AutomationAuthorityRef,
|
||||
StableAutomationCatalogRef
|
||||
} from '../../../../shared/automation-owner-ref'
|
||||
import { hostStableKey } from '../../../../shared/automation-owner-key'
|
||||
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
|
||||
import {
|
||||
capturedAutomationOwner,
|
||||
@@ -129,11 +155,14 @@ import {
|
||||
resolveExternalAutomationScopeGate
|
||||
} from './external-automation-scope-gating'
|
||||
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
|
||||
import { automationRuntimePairingRevision } from './automation-authority-identity'
|
||||
import {
|
||||
automationRuntimePairingRevision,
|
||||
groupReposByAutomationAuthority
|
||||
} from './automation-authority-identity'
|
||||
import { automationCreateHostStableKey } from './automation-create-destination'
|
||||
automationCreateEligibleProjects,
|
||||
automationCreateEligibleWorktrees,
|
||||
automationCreateHostStableKey,
|
||||
automationCreateWorkspaceRefreshHostId,
|
||||
type AutomationCreateDestination
|
||||
} from './automation-create-destination'
|
||||
import { useAutomationCreateDestination } from './use-automation-create-destination'
|
||||
import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference'
|
||||
import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft'
|
||||
@@ -252,6 +281,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
// Held beside the id: a save fences on the row the user opened, and under All
|
||||
// hosts that id alone names two rows.
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null)
|
||||
const [editingDestination, setEditingDestination] = useState<AutomationCreateDestination | null>(
|
||||
null
|
||||
)
|
||||
const [relativeNow, setRelativeNow] = useState(Date.now())
|
||||
const [activePaneTab, setActivePaneTab] = useState<AutomationPaneTab>('overview')
|
||||
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState<string | null>(
|
||||
@@ -313,6 +345,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const setupDecisionPolicyDefaultRef = useRef<AutomationDraft['setupDecision']>(undefined)
|
||||
const setupDecisionDefaultSignatureRef = useRef<string | null>(null)
|
||||
const setupDecisionTouchedRef = useRef(false)
|
||||
const createDestinationSelectionChangedRef = useRef(false)
|
||||
const automationHookCheckPromisesRef = useRef<
|
||||
Map<string, Promise<{ hooks: OrcaHooks | null; ok: boolean }>>
|
||||
>(new Map())
|
||||
@@ -340,6 +373,19 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
})
|
||||
|
||||
const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys })
|
||||
const automationHostResolution = hostCatalog.resolution
|
||||
const selectAutomationHost = hostCatalog.selectHost
|
||||
useEffect(() => {
|
||||
if (!pendingAutomationRunNavigation) {
|
||||
return
|
||||
}
|
||||
const catalogRef = automationNavigationCatalogRef(pendingAutomationRunNavigation)
|
||||
const current = automationHostResolution.effective
|
||||
if (current.kind === 'host' && hostStableKey(current.host) === hostStableKey(catalogRef)) {
|
||||
return
|
||||
}
|
||||
selectAutomationHost({ kind: 'host', host: catalogRef })
|
||||
}, [automationHostResolution.effective, pendingAutomationRunNavigation, selectAutomationHost])
|
||||
// Probing follows the selection, not the catalog: a Local selection must not
|
||||
// reach out to every SSH host the user happens to have registered.
|
||||
const externalScopeEntries = useMemo(
|
||||
@@ -353,12 +399,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
// Held apart from the view object, which is a fresh literal each render and
|
||||
// would refetch the runs table on every render of this page.
|
||||
const fetchScopedExternalRuns = scopedExternal.fetchRuns
|
||||
// Why the cache wins once it has answered: only its rows carry the host each
|
||||
// record came from, which is what the picker, the groups, and every fenced
|
||||
// action key on. Until then the unscoped list is all the page has, and it has
|
||||
// no host to qualify its rows with.
|
||||
const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations])
|
||||
const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows
|
||||
// Only cache rows carry their listing authority; bootstrap rows stay hidden
|
||||
// because displaying them would expose unfenced actions.
|
||||
const visibleRows = hostCatalog.rows.rows
|
||||
// Picking a row records both: the key names the host's copy, the id is what
|
||||
// the rest of the page (and other pages) still address the record by.
|
||||
const selectAutomationRow = useCallback(
|
||||
@@ -568,7 +611,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
})
|
||||
)
|
||||
}, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap])
|
||||
const worktrees = useMemo(
|
||||
const repoWorktrees = useMemo(
|
||||
() => worktreesByRepo[draft.projectId] ?? [],
|
||||
[draft.projectId, worktreesByRepo]
|
||||
)
|
||||
@@ -592,14 +635,12 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId)
|
||||
}
|
||||
}, [hostCatalog.resolution.entry, runtimeEnvironments])
|
||||
// Scoped per owning authority: a repo ID is unique only inside one, so the
|
||||
// flat map cannot say whether *this* authority holds the project.
|
||||
const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos])
|
||||
const activeWorkspaceHostStableKey = useMemo(() => {
|
||||
const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
|
||||
return worktree
|
||||
? automationCreateHostStableKey(
|
||||
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId))
|
||||
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)),
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
)
|
||||
: null
|
||||
}, [activeWorktreeId, repoMap, worktreeMap])
|
||||
@@ -610,22 +651,123 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
// Non-null only for a concrete host filter, which constrains the destination.
|
||||
filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null,
|
||||
activeWorkspaceStableKey: activeWorkspaceHostStableKey,
|
||||
repoTables,
|
||||
projects: repos
|
||||
projects: repos,
|
||||
projectHostSetups
|
||||
})
|
||||
const editorProjects = createDestination.control.projects
|
||||
const createDestinationControl = createDestination.control
|
||||
const chooseCreateDestination = createDestinationControl.onSelect
|
||||
const selectCreateDestination = useCallback(
|
||||
(stableKey: string): void => {
|
||||
createDestinationSelectionChangedRef.current = true
|
||||
chooseCreateDestination(stableKey)
|
||||
},
|
||||
[chooseCreateDestination]
|
||||
)
|
||||
const editorCreateDestination = useMemo(
|
||||
() => ({ ...createDestinationControl, onSelect: selectCreateDestination }),
|
||||
[createDestinationControl, selectCreateDestination]
|
||||
)
|
||||
const isOrcaCreate = createOpen && editingAutomationId === null && createTarget === 'orca'
|
||||
const editorDestination =
|
||||
isOrcaCreate && createDestination.control.resolution.status === 'ready'
|
||||
? createDestination.control.resolution
|
||||
: editingDestination
|
||||
const editorProjects = editorDestination
|
||||
? automationCreateEligibleProjects(editorDestination, repos, projectHostSetups)
|
||||
: createDestination.control.projects
|
||||
const isOrcaEditor = createOpen && createTarget === 'orca'
|
||||
const [verifiedWorkspaceScopeKey, setVerifiedWorkspaceScopeKey] = useState<string | null>(null)
|
||||
const createWorkspaceScope = useMemo(() => {
|
||||
if (!isOrcaEditor || !editorDestination || !draft.projectId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
key: `${editorDestination.entry.stableKey}\0${draft.projectId}`,
|
||||
projectId: draft.projectId,
|
||||
refreshHostId: automationCreateWorkspaceRefreshHostId(editorDestination),
|
||||
destination: editorDestination
|
||||
}
|
||||
}, [draft.projectId, editorDestination, isOrcaEditor])
|
||||
useEffect(() => {
|
||||
if (!createWorkspaceScope) {
|
||||
setVerifiedWorkspaceScopeKey(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setVerifiedWorkspaceScopeKey(null)
|
||||
setEditorNotice(null)
|
||||
void fetchWorktrees(createWorkspaceScope.projectId, {
|
||||
executionHostId: createWorkspaceScope.refreshHostId,
|
||||
requireAuthoritative: true
|
||||
}).then((authoritative) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (authoritative) {
|
||||
setVerifiedWorkspaceScopeKey(createWorkspaceScope.key)
|
||||
return
|
||||
}
|
||||
setEditorNotice({
|
||||
message: translate(
|
||||
'auto.components.automations.createDestination.workspacesUnverified',
|
||||
"We couldn't verify workspaces on {host}. Check the connection and try again."
|
||||
).replace('{host}', createWorkspaceScope.destination.entry.label),
|
||||
recovery: null,
|
||||
severity: 'owner'
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [createWorkspaceScope, fetchWorktrees])
|
||||
const worktrees = useMemo(() => {
|
||||
if (!isOrcaEditor) {
|
||||
return repoWorktrees
|
||||
}
|
||||
if (!createWorkspaceScope || verifiedWorkspaceScopeKey !== createWorkspaceScope.key) {
|
||||
return []
|
||||
}
|
||||
const repo = editorProjects.find((candidate) => candidate.id === draft.projectId)
|
||||
return automationCreateEligibleWorktrees(
|
||||
createWorkspaceScope.destination,
|
||||
repo,
|
||||
repoWorktrees,
|
||||
projectHostSetups
|
||||
)
|
||||
}, [
|
||||
createWorkspaceScope,
|
||||
draft.projectId,
|
||||
editorProjects,
|
||||
isOrcaEditor,
|
||||
projectHostSetups,
|
||||
repoWorktrees,
|
||||
verifiedWorkspaceScopeKey
|
||||
])
|
||||
// A destination change can strand the chosen project on another host; leaving it
|
||||
// selected only defers the same refusal to submit.
|
||||
useEffect(() => {
|
||||
if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') {
|
||||
if (!createOpen || createTarget !== 'orca') {
|
||||
createDestinationSelectionChangedRef.current = false
|
||||
return
|
||||
}
|
||||
setDraft((current) =>
|
||||
!current.projectId || editorProjects.some((project) => project.id === current.projectId)
|
||||
? current
|
||||
: { ...current, projectId: '', workspaceId: '', baseBranch: '' }
|
||||
)
|
||||
}, [createOpen, createTarget, editingAutomationId, editorProjects])
|
||||
const destinationChanged = createDestinationSelectionChangedRef.current
|
||||
createDestinationSelectionChangedRef.current = false
|
||||
setDraft((current) => {
|
||||
if (
|
||||
!current.projectId ||
|
||||
editorProjects.some((project) => project.id === current.projectId) ||
|
||||
!destinationChanged
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
projectId: editorProjects[0]?.id ?? '',
|
||||
workspaceId: '',
|
||||
baseBranch: ''
|
||||
}
|
||||
})
|
||||
}, [createOpen, createTarget, editorProjects])
|
||||
// The row's own host, from its captured owner. A page-level target cannot
|
||||
// speak for a list spanning authorities, and the legacy arm below it is only
|
||||
// ever reached by rows the desktop's unscoped list produced.
|
||||
@@ -640,8 +782,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
[automationHostTarget, capturedAutomationOwners]
|
||||
)
|
||||
const automationDispatchContext = useMemo(
|
||||
() => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }),
|
||||
[automationAuthority, capturedAutomationOwners]
|
||||
() => ({ capturedOwners: capturedAutomationOwners }),
|
||||
[capturedAutomationOwners]
|
||||
)
|
||||
const notifyAuthorityChange = hostCatalog.notifyAuthorityChange
|
||||
// The list renders the per-host cache, so a write is only visible once that
|
||||
@@ -700,9 +842,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
const pending = pendingAutomationRunNavigation
|
||||
const pendingTargetKey = getAutomationHostTargetKey(
|
||||
getAutomationTargetFromHostId(pending.hostId)
|
||||
)
|
||||
const pendingTargetKey = getAutomationHostTargetKey(automationNavigationTarget(pending))
|
||||
if (automationHostTargetKey !== pendingTargetKey) {
|
||||
return
|
||||
}
|
||||
@@ -909,7 +1049,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
? activeRepo
|
||||
: null
|
||||
const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null
|
||||
const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : []
|
||||
const fallbackWorktrees = fallbackRepo && fallbackRepo.id === draft.projectId ? worktrees : []
|
||||
// Why: automation-created workspaces can be active; new automations should start from
|
||||
// the repo's stable main worktree unless the user explicitly chooses otherwise.
|
||||
const targetWorktree =
|
||||
@@ -920,7 +1060,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
projectId: targetProjectId,
|
||||
workspaceId: targetWorktree?.id ?? ''
|
||||
}
|
||||
}, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo])
|
||||
}, [activeWorktreeId, draft.projectId, editorProjects, repoMap, worktreeMap, worktrees])
|
||||
|
||||
const reloadExternalManagers = scopedExternal.reload
|
||||
|
||||
@@ -931,20 +1071,20 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
// per-host reads have not answered for, and the client's own authority is
|
||||
// the only one it can address without guessing which server is meant.
|
||||
const automationHostTarget: AutomationHostTarget = pendingNavigation
|
||||
? getAutomationTargetFromHostId(pendingNavigation.hostId)
|
||||
? automationNavigationTarget(pendingNavigation)
|
||||
: { kind: 'local' }
|
||||
const authorityKey = automationAuthorityCatalogKey(
|
||||
automationHostTarget.kind === 'environment'
|
||||
? { kind: 'runtime', environmentId: automationHostTarget.environmentId }
|
||||
: { kind: 'desktop' }
|
||||
)
|
||||
// Detached rather than awaited: a manager probe reaches each host over SSH
|
||||
// and can stall indefinitely, and nothing that waits on this refresh — the
|
||||
// save that closes the create dialog above all — may hang behind it. It
|
||||
// publishes its own managers when it lands.
|
||||
void reloadExternalManagers().catch(() => undefined)
|
||||
try {
|
||||
const [nextAutomations] = await Promise.all([
|
||||
listAutomationsForTarget(automationHostTarget),
|
||||
// Managers are per host and failures are per provider, so this settles
|
||||
// on its own and must not be able to fail the automation list with it.
|
||||
reloadExternalManagers().catch(() => undefined)
|
||||
])
|
||||
const nextAutomations = await listAutomationsForTarget(automationHostTarget)
|
||||
// Selection and run history are deliberately not written here: this call
|
||||
// addressed one authority, and the selected row may belong to another.
|
||||
setAutomations(nextAutomations)
|
||||
@@ -964,7 +1104,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
const pendingTargetKey = getAutomationHostTargetKey(
|
||||
getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId)
|
||||
automationNavigationTarget(pendingAutomationRunNavigation)
|
||||
)
|
||||
if (automationHostTargetKey !== pendingTargetKey) {
|
||||
void refresh()
|
||||
@@ -1003,7 +1143,6 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
useSelectedAutomationRunHistory({
|
||||
selected: selectedRow,
|
||||
context: automationDispatchContext,
|
||||
legacyTarget: automationHostTargetFor,
|
||||
navigation: pendingAutomationRunNavigation,
|
||||
reloadToken: runHistoryReloadToken,
|
||||
onSettled: setSelectedAutomationRuns
|
||||
@@ -1049,12 +1188,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
if (!draft.projectId) {
|
||||
return
|
||||
}
|
||||
const available = worktreesByRepo[draft.projectId] ?? []
|
||||
const defaultWorktree = getDefaultWorktree(available)
|
||||
const defaultWorktree = getDefaultWorktree(worktrees)
|
||||
if (!draft.workspaceId && defaultWorktree) {
|
||||
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
|
||||
}
|
||||
}, [draft.projectId, draft.workspaceId, worktreesByRepo])
|
||||
}, [draft.projectId, draft.workspaceId, worktrees])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1132,6 +1270,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
editRequestRef.current += 1
|
||||
const target = getDefaultTarget()
|
||||
setEditingAutomationId(null)
|
||||
setEditingRowKey(null)
|
||||
setEditingDestination(null)
|
||||
setEditingExternalTarget(null)
|
||||
setCreateTarget('orca')
|
||||
const baseDraft: AutomationDraft = {
|
||||
@@ -1176,14 +1316,19 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
setEditingExternalTarget(null)
|
||||
setCreateTarget('orca')
|
||||
const automationId = row.automation.id
|
||||
const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner
|
||||
const rowCatalogRef = row.catalogRef
|
||||
const entry = rowCatalogRef
|
||||
? hostCatalog.entries.find(
|
||||
(candidate) => candidate.stableKey === hostStableKey(rowCatalogRef)
|
||||
)
|
||||
: null
|
||||
// Why: hydrate inside the row's own captured scope, not from whatever the
|
||||
// ambient authority happens to hold under the same automation ID.
|
||||
const reread = await dispatchAutomationReread(
|
||||
automationDispatchContext,
|
||||
{ rowKey: row.key, automationId },
|
||||
async () =>
|
||||
(await window.api.automations.list()).find((entry) => entry.id === automationId) ?? null
|
||||
)
|
||||
const reread = await dispatchAutomationReread(automationDispatchContext, {
|
||||
rowKey: row.key,
|
||||
automationId
|
||||
})
|
||||
if (!reread.ok && reread.notice.severity === 'owner') {
|
||||
setOwnerActionNotice(reread.notice)
|
||||
return
|
||||
@@ -1195,6 +1340,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
setEditingAutomationId(latest.id)
|
||||
setEditingRowKey(row.key)
|
||||
setEditingDestination(
|
||||
owner && entry
|
||||
? { authority: owner.authority, destination: { selector: owner.selector }, entry }
|
||||
: null
|
||||
)
|
||||
const nextDraft = buildAutomationEditDraft(latest)
|
||||
setDraft(nextDraft)
|
||||
setDraftAtOpen(nextDraft)
|
||||
@@ -1225,6 +1375,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
})
|
||||
setEditingAutomationId(null)
|
||||
setEditingRowKey(null)
|
||||
setEditingDestination(null)
|
||||
setEditingExternalTarget({ manager, job, scope })
|
||||
setCreateTarget('hermes')
|
||||
setDraft(nextDraft)
|
||||
@@ -1234,6 +1385,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(projectId: string): void => {
|
||||
if (createOpen && editingAutomationId === null && createTarget === 'orca') {
|
||||
setDraft((current) => ({ ...current, projectId, workspaceId: '', baseBranch: '' }))
|
||||
return
|
||||
}
|
||||
const currentWorktrees = worktreesByRepo[projectId] ?? []
|
||||
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
|
||||
setDraft((current) => ({
|
||||
@@ -1258,7 +1413,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
)
|
||||
})
|
||||
},
|
||||
[fetchWorktrees, worktreesByRepo]
|
||||
[createOpen, createTarget, editingAutomationId, fetchWorktrees, worktreesByRepo]
|
||||
)
|
||||
|
||||
const saveAutomation = async (): Promise<void> => {
|
||||
@@ -1315,10 +1470,79 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
let checkedCreateDestination: AutomationCreateDestination | null = null
|
||||
let checkedEditorDestination = editingDestination
|
||||
let selectedEditorWorktree: Worktree | null = null
|
||||
let editorProjectMatchesDestination = checkedEditorDestination === null
|
||||
if (editingAutomationId === null && !isHermesSave) {
|
||||
const checked = createDestination.check(draft.projectId)
|
||||
if (!checked.ok) {
|
||||
setEditorNotice(checked.notice)
|
||||
return
|
||||
}
|
||||
checkedCreateDestination = checked.destination
|
||||
checkedEditorDestination = checked.destination
|
||||
}
|
||||
if (!isHermesSave && createTarget === 'orca' && !checkedEditorDestination) {
|
||||
setEditorNotice({
|
||||
message: translate(
|
||||
'auto.components.automations.ownerAction.failed',
|
||||
'That automation action did not complete.'
|
||||
),
|
||||
recovery: 'retry',
|
||||
severity: 'owner'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isHermesSave && createTarget === 'orca' && checkedEditorDestination) {
|
||||
const currentState = useAppStore.getState()
|
||||
const repo = automationCreateEligibleProjects(
|
||||
checkedEditorDestination,
|
||||
currentState.repos,
|
||||
currentState.projectHostSetups
|
||||
).find((candidate) => candidate.id === draft.projectId)
|
||||
editorProjectMatchesDestination = repo !== undefined
|
||||
if (draft.workspaceMode === 'existing' && repo) {
|
||||
const authoritative = await fetchWorktrees(draft.projectId, {
|
||||
executionHostId: automationCreateWorkspaceRefreshHostId(checkedEditorDestination),
|
||||
requireAuthoritative: true
|
||||
})
|
||||
if (!authoritative) {
|
||||
setEditorNotice({
|
||||
message: translate(
|
||||
'auto.components.automations.createDestination.workspacesUnverified',
|
||||
"We couldn't verify workspaces on {host}. Check the connection and try again."
|
||||
).replace('{host}', checkedEditorDestination.entry.label),
|
||||
recovery: null,
|
||||
severity: 'owner'
|
||||
})
|
||||
return
|
||||
}
|
||||
setVerifiedWorkspaceScopeKey(
|
||||
`${checkedEditorDestination.entry.stableKey}\0${draft.projectId}`
|
||||
)
|
||||
const refreshedState = useAppStore.getState()
|
||||
selectedEditorWorktree =
|
||||
automationCreateEligibleWorktrees(
|
||||
checkedEditorDestination,
|
||||
repo,
|
||||
refreshedState.worktreesByRepo[draft.projectId] ?? [],
|
||||
refreshedState.projectHostSetups
|
||||
).find((worktree) => worktree.id === draft.workspaceId) ?? null
|
||||
}
|
||||
}
|
||||
const selectedWorkspaceExists =
|
||||
draft.workspaceMode !== 'existing' ||
|
||||
worktrees.some((worktree) => worktree.id === draft.workspaceId)
|
||||
editorProjectMatchesDestination &&
|
||||
(draft.workspaceMode !== 'existing' ||
|
||||
(checkedEditorDestination
|
||||
? selectedEditorWorktree !== null
|
||||
: worktrees.some((worktree) => worktree.id === draft.workspaceId)))
|
||||
if (!selectedWorkspaceExists) {
|
||||
if (checkedEditorDestination) {
|
||||
setDraft((current) =>
|
||||
current.workspaceId === draft.workspaceId ? { ...current, workspaceId: '' } : current
|
||||
)
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.32534e7c9c',
|
||||
@@ -1370,6 +1594,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
if (!editingExternalTarget) {
|
||||
useAppStore.getState().recordFeatureInteraction('automation-created')
|
||||
}
|
||||
// The mutation invalidated this host's managers on the authority, so the
|
||||
// reload `refresh()` starts re-reads them. Not awaited, for the same
|
||||
// reason it is not awaited there: an unreachable host's probe would hold
|
||||
// this dialog open over an emptied form.
|
||||
await refresh()
|
||||
setCreateOpen(false)
|
||||
setEditingExternalTarget(null)
|
||||
@@ -1409,11 +1637,18 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
? Math.max(0, rawMissedRunGraceMinutes)
|
||||
: 720
|
||||
const precheck = buildDraftPrecheck(draft)
|
||||
const runContext = buildAutomationRunContextForRepo({
|
||||
repoId: draft.projectId,
|
||||
repos,
|
||||
projectHostSetups
|
||||
})
|
||||
const runContext = checkedEditorDestination
|
||||
? buildAutomationRunContextForDestination({
|
||||
repoId: draft.projectId,
|
||||
destination: checkedEditorDestination,
|
||||
projectHostSetups,
|
||||
worktree: selectedEditorWorktree
|
||||
})
|
||||
: buildAutomationRunContextForRepo({
|
||||
repoId: draft.projectId,
|
||||
repos,
|
||||
projectHostSetups
|
||||
})
|
||||
let setupDecision = resolveAutomationSetupDecisionForSave({
|
||||
createTarget,
|
||||
workspaceMode: draft.workspaceMode,
|
||||
@@ -1451,14 +1686,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
if (editingAutomationId) {
|
||||
// Why: the conflict check reads the row's own scope, and the legacy read
|
||||
// under it addresses the desktop rather than whichever host was ambient.
|
||||
const reread = await dispatchAutomationReread(
|
||||
automationDispatchContext,
|
||||
{ rowKey: editingRowKey ?? '', automationId: editingAutomationId },
|
||||
async () =>
|
||||
(await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find(
|
||||
(automation) => automation.id === editingAutomationId
|
||||
) ?? null
|
||||
)
|
||||
const reread = await dispatchAutomationReread(automationDispatchContext, {
|
||||
rowKey: editingRowKey ?? '',
|
||||
automationId: editingAutomationId
|
||||
})
|
||||
if (!reread.ok && reread.notice.severity === 'owner') {
|
||||
setEditorNotice(reread.notice)
|
||||
return
|
||||
@@ -1485,41 +1716,33 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
updates.rrule = rrule
|
||||
updates.dtstart = now
|
||||
}
|
||||
const editSource = currentAutomation
|
||||
const saved = editingAutomationId
|
||||
? await dispatchAutomationUpdate(
|
||||
automationDispatchContext,
|
||||
{ rowKey: editingRowKey ?? '', automationId: editingAutomationId },
|
||||
updates,
|
||||
() => {
|
||||
// Nothing names a host: no captured owner and no record to read one
|
||||
// from, so the save is refused rather than sent unfenced.
|
||||
if (!editSource) {
|
||||
throw new Error(
|
||||
automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
|
||||
)
|
||||
}
|
||||
return updateAutomationForTarget(editSource, updates, automationHostTarget)
|
||||
},
|
||||
'save'
|
||||
)
|
||||
: await createDraftAutomation({
|
||||
name: draft.name,
|
||||
prompt: draft.prompt,
|
||||
precheck,
|
||||
agentId: draft.agentId,
|
||||
runContext,
|
||||
projectId: draft.projectId,
|
||||
workspaceMode: draft.workspaceMode,
|
||||
workspaceId: draft.workspaceId,
|
||||
baseBranch: draft.baseBranch.trim() || null,
|
||||
setupDecision,
|
||||
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
|
||||
timezone,
|
||||
rrule,
|
||||
dtstart: now,
|
||||
missedRunGraceMinutes
|
||||
})
|
||||
: await createDraftAutomation(
|
||||
{
|
||||
name: draft.name,
|
||||
prompt: draft.prompt,
|
||||
precheck,
|
||||
agentId: draft.agentId,
|
||||
runContext,
|
||||
projectId: draft.projectId,
|
||||
workspaceMode: draft.workspaceMode,
|
||||
workspaceId: draft.workspaceId,
|
||||
baseBranch: draft.baseBranch.trim() || null,
|
||||
setupDecision,
|
||||
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
|
||||
timezone,
|
||||
rrule,
|
||||
dtstart: now,
|
||||
missedRunGraceMinutes
|
||||
},
|
||||
checkedCreateDestination!
|
||||
)
|
||||
if (!saved.ok) {
|
||||
setEditorNotice(saved.notice)
|
||||
return
|
||||
@@ -1576,21 +1799,14 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
/** Creation states its destination and re-checks it at submit; it never infers one. */
|
||||
const createDraftAutomation = async (
|
||||
input: AutomationCreateInput
|
||||
input: AutomationCreateInput,
|
||||
destination: AutomationCreateDestination
|
||||
): Promise<AutomationDispatchResult<Automation>> => {
|
||||
const checked = createDestination.check(input.projectId)
|
||||
if (!checked.ok) {
|
||||
return { ok: false, notice: checked.notice }
|
||||
}
|
||||
const result = toDispatchResult(
|
||||
await createAutomationAtDestination(
|
||||
checked.destination.authority,
|
||||
input,
|
||||
checked.destination.destination
|
||||
)
|
||||
await createAutomationAtDestination(destination.authority, input, destination.destination)
|
||||
)
|
||||
if (result.ok) {
|
||||
invalidateWrittenHost(checked.destination.entry.stableRef, 'definition')
|
||||
invalidateWrittenHost(destination.entry.stableRef, 'definition')
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1600,13 +1816,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const result = await dispatchAutomationUpdate(
|
||||
automationDispatchContext,
|
||||
{ rowKey: row.key, automationId: automation.id },
|
||||
{ enabled: !automation.enabled },
|
||||
() =>
|
||||
updateAutomationForTarget(
|
||||
automation,
|
||||
{ enabled: !automation.enabled },
|
||||
automationHostTargetFor(row)
|
||||
)
|
||||
{ enabled: !automation.enabled }
|
||||
)
|
||||
setOwnerActionNotice(result.ok ? null : result.notice)
|
||||
if (result.ok) {
|
||||
@@ -1617,11 +1827,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
const deleteAutomation = async (row: AutomationListRow): Promise<void> => {
|
||||
const automation = row.automation
|
||||
const result = await dispatchAutomationDelete(
|
||||
automationDispatchContext,
|
||||
{ rowKey: row.key, automationId: automation.id },
|
||||
() => deleteAutomationForTarget(automation, automationHostTargetFor(row))
|
||||
)
|
||||
const result = await dispatchAutomationDelete(automationDispatchContext, {
|
||||
rowKey: row.key,
|
||||
automationId: automation.id
|
||||
})
|
||||
setOwnerActionNotice(result.ok ? null : result.notice)
|
||||
if (result.ok) {
|
||||
if (selectedRowKey === row.key) {
|
||||
@@ -1681,11 +1890,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
toast.error(availability.message)
|
||||
return
|
||||
}
|
||||
const result = await dispatchAutomationRunNow(
|
||||
automationDispatchContext,
|
||||
{ rowKey: row.key, automationId: automation.id },
|
||||
() => runAutomationNowForTarget(automation, rowHostTarget)
|
||||
)
|
||||
const result = await dispatchAutomationRunNow(automationDispatchContext, {
|
||||
rowKey: row.key,
|
||||
automationId: automation.id
|
||||
})
|
||||
setOwnerActionNotice(result.ok ? null : result.notice)
|
||||
if (!result.ok) {
|
||||
return
|
||||
@@ -1711,11 +1919,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
rerunRunIdsInFlightRef.current.add(runId)
|
||||
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
|
||||
try {
|
||||
const result = await dispatchAutomationRunNow(
|
||||
automationDispatchContext,
|
||||
{ rowKey: row.key, automationId: row.automation.id },
|
||||
() => runAutomationNowForTarget(row.automation, automationHostTargetFor(row))
|
||||
)
|
||||
const result = await dispatchAutomationRunNow(automationDispatchContext, {
|
||||
rowKey: row.key,
|
||||
automationId: row.automation.id
|
||||
})
|
||||
setOwnerActionNotice(result.ok ? null : result.notice)
|
||||
if (!result.ok) {
|
||||
await refresh()
|
||||
@@ -2030,7 +2237,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
worktrees={worktrees}
|
||||
settings={settings}
|
||||
draft={draft}
|
||||
createDestination={createDestination.control}
|
||||
createDestination={editorCreateDestination}
|
||||
notice={editorNotice}
|
||||
onNoticeRecover={() => {
|
||||
setEditorNotice(null)
|
||||
@@ -2097,9 +2304,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
recoverSelectedRuns={(action) => {
|
||||
// Reconnect/Update server act on the selected host; the re-ask is what
|
||||
// brings this automation's history back either way.
|
||||
hostCatalog.recover(action)
|
||||
const recovered = hostCatalog.recover(action)
|
||||
setSelectedAutomationRuns((current) => ({ ...current, notice: null }))
|
||||
setRunHistoryReloadToken((token) => token + 1)
|
||||
return recovered
|
||||
}}
|
||||
activePaneTab={activePaneTab}
|
||||
relativeNow={relativeNow}
|
||||
@@ -2165,10 +2373,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
externalManagersUncheckedNotice={externalManagersUncheckedNotice}
|
||||
onSelectHost={hostCatalog.selectHost}
|
||||
onRecoverHost={(action, entry) => {
|
||||
hostCatalog.recover(action, entry)
|
||||
const recovered = hostCatalog.recover(action, entry)
|
||||
if (action === 'retry') {
|
||||
void refresh()
|
||||
}
|
||||
return recovered
|
||||
}}
|
||||
isActionEnabled={isAutomationRowActionEnabled}
|
||||
filteredRows={filteredRows}
|
||||
|
||||
@@ -12,6 +12,7 @@ function TableRowSkeleton({
|
||||
nameWidthClass,
|
||||
scheduleWidthClass,
|
||||
projectWidthClass,
|
||||
hostWidthClass,
|
||||
nextWidthClass,
|
||||
lastRunWidthClass,
|
||||
statusWidthClass
|
||||
@@ -19,6 +20,7 @@ function TableRowSkeleton({
|
||||
nameWidthClass: string
|
||||
scheduleWidthClass: string
|
||||
projectWidthClass: string
|
||||
hostWidthClass: string
|
||||
nextWidthClass: string
|
||||
lastRunWidthClass: string
|
||||
statusWidthClass: string
|
||||
@@ -28,6 +30,7 @@ function TableRowSkeleton({
|
||||
<SkeletonBar className={cn('h-3.5', nameWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', scheduleWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', projectWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', hostWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', nextWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', lastRunWidthClass)} />
|
||||
<SkeletonBar className={cn('h-3.5', statusWidthClass)} />
|
||||
@@ -43,6 +46,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-28',
|
||||
scheduleWidthClass: 'w-36',
|
||||
projectWidthClass: 'w-28',
|
||||
hostWidthClass: 'w-20',
|
||||
nextWidthClass: 'w-24',
|
||||
lastRunWidthClass: 'w-20',
|
||||
statusWidthClass: 'w-16'
|
||||
@@ -52,6 +56,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-36',
|
||||
scheduleWidthClass: 'w-28',
|
||||
projectWidthClass: 'w-32',
|
||||
hostWidthClass: 'w-24',
|
||||
nextWidthClass: 'w-20',
|
||||
lastRunWidthClass: 'w-24',
|
||||
statusWidthClass: 'w-14'
|
||||
@@ -61,6 +66,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-24',
|
||||
scheduleWidthClass: 'w-40',
|
||||
projectWidthClass: 'w-24',
|
||||
hostWidthClass: 'w-20',
|
||||
nextWidthClass: 'w-28',
|
||||
lastRunWidthClass: 'w-16',
|
||||
statusWidthClass: 'w-16'
|
||||
@@ -70,6 +76,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-32',
|
||||
scheduleWidthClass: 'w-32',
|
||||
projectWidthClass: 'w-36',
|
||||
hostWidthClass: 'w-24',
|
||||
nextWidthClass: 'w-24',
|
||||
lastRunWidthClass: 'w-20',
|
||||
statusWidthClass: 'w-14'
|
||||
@@ -79,6 +86,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-40',
|
||||
scheduleWidthClass: 'w-24',
|
||||
projectWidthClass: 'w-28',
|
||||
hostWidthClass: 'w-20',
|
||||
nextWidthClass: 'w-20',
|
||||
lastRunWidthClass: 'w-24',
|
||||
statusWidthClass: 'w-16'
|
||||
@@ -88,6 +96,7 @@ const TABLE_ROW_SKELETONS = [
|
||||
nameWidthClass: 'w-28',
|
||||
scheduleWidthClass: 'w-36',
|
||||
projectWidthClass: 'w-20',
|
||||
hostWidthClass: 'w-24',
|
||||
nextWidthClass: 'w-24',
|
||||
lastRunWidthClass: 'w-16',
|
||||
statusWidthClass: 'w-14'
|
||||
@@ -107,7 +116,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
|
||||
<div className="flex shrink-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SkeletonBar className="h-8 w-56 shrink-0 rounded-md" />
|
||||
<SkeletonBar className="h-8 w-20 shrink-0 rounded-md" />
|
||||
<SkeletonBar className="h-8 w-80 shrink-0 rounded-md" />
|
||||
<SkeletonBar className="size-8 shrink-0 rounded-md" />
|
||||
</div>
|
||||
<SkeletonBar className="h-8 w-32 shrink-0 rounded-md" />
|
||||
@@ -120,6 +129,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
|
||||
<SkeletonBar className="h-2.5 w-12" />
|
||||
<SkeletonBar className="h-2.5 w-14" />
|
||||
<SkeletonBar className="h-2.5 w-14" />
|
||||
<SkeletonBar className="h-2.5 w-14" />
|
||||
<SkeletonBar className="h-2.5 w-16" />
|
||||
<SkeletonBar className="h-2.5 w-16" />
|
||||
<SkeletonBar className="h-2.5 w-12" />
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Capability gates for owner-qualified automation requests.
|
||||
*
|
||||
* A request fails closed on a *known* missing capability and on nothing else:
|
||||
* an unreachable authority must classify as unavailable and retry, never as an
|
||||
* old server the user is told to upgrade.
|
||||
*/
|
||||
|
||||
import { getRuntimeEnvironmentStatus } from '@/runtime/runtime-rpc-client'
|
||||
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import type {
|
||||
AutomationDestination,
|
||||
AutomationOwnerPrecondition
|
||||
} from '../../../../shared/automation-owner-precondition'
|
||||
import {
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
|
||||
import { automationHostDiagnostics } from './automation-host-diagnostics'
|
||||
|
||||
export const AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
export class AutomationHostScopeUnsupportedError extends Error {
|
||||
readonly code = 'unsupported_host_scope'
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AutomationHostScopeUnsupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe is counted here because it is counted nowhere else: it deliberately
|
||||
* re-fetches on every call and rides outside the scheduler's four-slot pool, so
|
||||
* an instrument that saw only pooled work would report half the relay traffic a
|
||||
* 50-host refresh actually costs.
|
||||
*/
|
||||
export async function assertAuthorityCapability(
|
||||
authority: AutomationAuthorityRef,
|
||||
capability: RuntimeCapability,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
if (authority.kind !== 'runtime') {
|
||||
return
|
||||
}
|
||||
automationHostDiagnostics.recordCapabilityProbe({
|
||||
authorityKey: automationAuthorityCatalogKey(authority)
|
||||
})
|
||||
const status = await getRuntimeEnvironmentStatus(
|
||||
authority.environmentId,
|
||||
AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
if (!status.capabilities?.includes(capability)) {
|
||||
throw new AutomationHostScopeUnsupportedError(message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertOwnerFencingSupported(
|
||||
authority: AutomationAuthorityRef
|
||||
): Promise<void> {
|
||||
await assertAuthorityCapability(
|
||||
authority,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mutation must fail closed on a server without owner fencing.
|
||||
*
|
||||
* Self is exempt: a Self record lives on the answering authority, the request
|
||||
* is already pinned to the pairing revision the row was captured under, and an
|
||||
* id-based mutation is exactly what a pre-fencing server performs today. SSH
|
||||
* and orphan selectors still fail closed — a same-id remove/re-add could
|
||||
* otherwise run or edit on a machine the user never saw. A destination
|
||||
* re-attaches the record, so it always needs the fenced contract: an old server
|
||||
* would ignore the field and silently leave the record where it was.
|
||||
*/
|
||||
export function requiresOwnerFencing(
|
||||
expectedOwner: AutomationOwnerPrecondition,
|
||||
destination?: AutomationDestination
|
||||
): boolean {
|
||||
return expectedOwner.selector.kind !== 'self' || destination !== undefined
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function repoConnectionIdIn(
|
||||
|
||||
const EMPTY_REPO_TABLE: ReadonlyMap<string, RepoConnection> = new Map()
|
||||
|
||||
function repoOwningAuthority(repo: Repo): StableAutomationAuthorityRef {
|
||||
export function automationRepoOwningAuthority(repo: Repo): StableAutomationAuthorityRef {
|
||||
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
// A desktop SSH repo is still desktop-stored; only a runtime host owns its own registry.
|
||||
return host?.kind === 'runtime'
|
||||
@@ -44,7 +44,7 @@ export function groupReposByAutomationAuthority(
|
||||
): AutomationAuthorityRepoTables {
|
||||
const tables = new Map<string, Map<string, RepoConnection>>()
|
||||
for (const repo of repos) {
|
||||
const key = automationAuthorityCatalogKey(repoOwningAuthority(repo))
|
||||
const key = automationAuthorityCatalogKey(automationRepoOwningAuthority(repo))
|
||||
let table = tables.get(key)
|
||||
if (!table) {
|
||||
table = new Map<string, RepoConnection>()
|
||||
|
||||
@@ -63,7 +63,7 @@ function row(overrides: Partial<AutomationHostRow>): AutomationHostRow {
|
||||
describe('captured owner availability', () => {
|
||||
it('lets every action through on an owned row and repeats its precondition', () => {
|
||||
const availability = automationActionAvailability(
|
||||
{ owner: DESKTOP_SELF, selector: { kind: 'self' } },
|
||||
{ authority: DESKTOP_SELF.authority, owner: DESKTOP_SELF, selector: { kind: 'self' } },
|
||||
'run'
|
||||
)
|
||||
expect(availability).toEqual({
|
||||
@@ -74,9 +74,14 @@ describe('captured owner availability', () => {
|
||||
})
|
||||
|
||||
it('keeps delete and pause fenceable on an orphan while refusing to run it', () => {
|
||||
const orphan = { owner: null, selector: { kind: 'orphan' as const, issue: 'gone' } }
|
||||
const orphan = {
|
||||
authority: RUNTIME_SELF.authority,
|
||||
owner: null,
|
||||
selector: { kind: 'orphan' as const, issue: 'gone' }
|
||||
}
|
||||
expect(automationActionAvailability(orphan, 'delete')).toEqual({
|
||||
kind: 'orphan-fenced',
|
||||
authority: RUNTIME_SELF.authority,
|
||||
precondition: { selector: { kind: 'orphan' } }
|
||||
})
|
||||
expect(automationActionAvailability(orphan, 'toggle').kind).toBe('orphan-fenced')
|
||||
@@ -86,25 +91,52 @@ describe('captured owner availability', () => {
|
||||
expect(isAutomationActionEnabled(orphan, 'delete')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks every action on a legacy row and asks for a server update', () => {
|
||||
it('blocks every action on a legacy SSH row and asks for a server update', () => {
|
||||
// A qualified selector with no owner means the authority never sent a generation.
|
||||
const legacy = { owner: null, selector: { kind: 'ssh' as const, targetId: 'box' } }
|
||||
const legacy = {
|
||||
authority: RUNTIME_SELF.authority,
|
||||
owner: null,
|
||||
selector: { kind: 'ssh' as const, targetId: 'box' }
|
||||
}
|
||||
const availability = automationActionAvailability(legacy, 'delete')
|
||||
expect(availability.kind).toBe('blocked')
|
||||
expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server')
|
||||
})
|
||||
|
||||
it('reports a row with no metadata as uncaptured rather than blocked', () => {
|
||||
expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('uncaptured')
|
||||
it('keeps a legacy Self row fully actionable through its captured authority', () => {
|
||||
// The legacy partition captures the answering authority as the Self owner,
|
||||
// so an old server never leaves Runtime + Self view-only.
|
||||
const legacySelf = {
|
||||
authority: RUNTIME_SELF.authority,
|
||||
owner: RUNTIME_SELF,
|
||||
selector: { kind: 'self' as const }
|
||||
}
|
||||
for (const action of ['edit', 'save', 'toggle', 'delete', 'run', 'history'] as const) {
|
||||
expect(isAutomationActionEnabled(legacySelf, action)).toBe(true)
|
||||
}
|
||||
expect(automationActionAvailability(legacySelf, 'run')).toEqual({
|
||||
kind: 'owned',
|
||||
owner: RUNTIME_SELF,
|
||||
precondition: { selector: { kind: 'self' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a row with no captured authority', () => {
|
||||
expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('blocked')
|
||||
expect(capturedAutomationOwner(new Map(), 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER)
|
||||
expect(capturedAutomationOwner(null, 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER)
|
||||
})
|
||||
|
||||
it('keys captured owners by row key', () => {
|
||||
const captured = captureAutomationOwners([
|
||||
{ rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) },
|
||||
{
|
||||
rowKey: 'row|desktop|a-1',
|
||||
authority: DESKTOP_SELF.authority,
|
||||
row: row({ automation: automation({ id: 'a-1' }) })
|
||||
},
|
||||
{
|
||||
rowKey: 'row|desktop|a-2',
|
||||
authority: DESKTOP_SELF.authority,
|
||||
row: row({
|
||||
automation: automation({ id: 'a-2' }),
|
||||
owner: null,
|
||||
@@ -118,9 +150,14 @@ describe('captured owner availability', () => {
|
||||
|
||||
it("keeps both authorities' owners when they hold the same automation ID", () => {
|
||||
const captured = captureAutomationOwners([
|
||||
{ rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) },
|
||||
{
|
||||
rowKey: 'row|desktop|a-1',
|
||||
authority: DESKTOP_SELF.authority,
|
||||
row: row({ automation: automation({ id: 'a-1' }) })
|
||||
},
|
||||
{
|
||||
rowKey: 'row|runtime|a-1',
|
||||
authority: RUNTIME_SELF.authority,
|
||||
row: row({ automation: automation({ id: 'a-1' }), owner: RUNTIME_SELF })
|
||||
}
|
||||
])
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
* works if the request names the incarnation the user actually saw.
|
||||
*
|
||||
* Three outcomes, kept distinct because they mean different things to the user:
|
||||
* an owned row acts normally; an orphan row still deletes and pauses under the
|
||||
* orphan precondition but can never execute; a legacy row proves nothing and
|
||||
* stays view-only until its authority advertises fencing.
|
||||
* an owned row acts normally (a legacy Self row is owned — its authority is the
|
||||
* fence); an orphan row still deletes and pauses under the orphan precondition
|
||||
* but can never execute; a legacy SSH row proves nothing and stays view-only
|
||||
* until its authority advertises fencing.
|
||||
*/
|
||||
|
||||
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
|
||||
import type {
|
||||
AutomationAuthorityRef,
|
||||
AutomationOwnerRef
|
||||
} from '../../../../shared/automation-owner-ref'
|
||||
import { ownerKey } from '../../../../shared/automation-owner-key'
|
||||
import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -34,6 +38,8 @@ export type AutomationActionBlock = {
|
||||
}
|
||||
|
||||
export type AutomationCapturedOwner = {
|
||||
/** The storage authority that listed the row, including its current pairing fence. */
|
||||
authority: AutomationAuthorityRef | null
|
||||
owner: AutomationOwnerRef | null
|
||||
/** How the authority qualified the row; null when no list metadata was captured at all. */
|
||||
selector: AutomationHostRowSelector | null
|
||||
@@ -41,16 +47,18 @@ export type AutomationCapturedOwner = {
|
||||
|
||||
export type AutomationActionAvailability =
|
||||
| { kind: 'owned'; owner: AutomationOwnerRef; precondition: AutomationOwnerPrecondition }
|
||||
| { kind: 'orphan-fenced'; precondition: AutomationOwnerPrecondition }
|
||||
| {
|
||||
kind: 'orphan-fenced'
|
||||
authority: AutomationAuthorityRef
|
||||
precondition: AutomationOwnerPrecondition
|
||||
}
|
||||
| { kind: 'blocked'; block: AutomationActionBlock }
|
||||
/**
|
||||
* No owner metadata exists for this row yet, so the caller keeps its legacy
|
||||
* unfenced path. Removed with the inference in Step 9, never in the same
|
||||
* change — a mixed-version client still depends on it.
|
||||
*/
|
||||
| { kind: 'uncaptured' }
|
||||
|
||||
export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { owner: null, selector: null }
|
||||
export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = {
|
||||
authority: null,
|
||||
owner: null,
|
||||
selector: null
|
||||
}
|
||||
|
||||
/** Delete and pause survive on an orphan; running it would need a host that no longer exists. */
|
||||
const ORPHAN_ACTIONS: ReadonlySet<AutomationRowAction> = new Set(['delete', 'toggle', 'history'])
|
||||
@@ -89,14 +97,21 @@ export function automationActionAvailability(
|
||||
}
|
||||
}
|
||||
if (!captured.selector) {
|
||||
return { kind: 'uncaptured' }
|
||||
return { kind: 'blocked', block: unfencedBlock() }
|
||||
}
|
||||
if (captured.selector.kind === 'orphan') {
|
||||
if (!captured.authority) {
|
||||
return { kind: 'blocked', block: unfencedBlock() }
|
||||
}
|
||||
return ORPHAN_ACTIONS.has(action)
|
||||
? { kind: 'orphan-fenced', precondition: ORPHAN_OWNER_PRECONDITION }
|
||||
? {
|
||||
kind: 'orphan-fenced',
|
||||
authority: captured.authority,
|
||||
precondition: ORPHAN_OWNER_PRECONDITION
|
||||
}
|
||||
: { kind: 'blocked', block: orphanBlock() }
|
||||
}
|
||||
// A qualified selector with no owner is a legacy row: no generation was ever captured.
|
||||
// A qualified selector with no owner is a legacy SSH row: no generation was ever captured.
|
||||
return { kind: 'blocked', block: unfencedBlock() }
|
||||
}
|
||||
|
||||
@@ -112,6 +127,7 @@ export function isAutomationActionEnabled(
|
||||
export type AutomationCapturedRow = {
|
||||
/** `automationListRowKey`: authority-qualified and incarnation-free. */
|
||||
rowKey: string
|
||||
authority: AutomationAuthorityRef
|
||||
row: AutomationHostRow
|
||||
}
|
||||
|
||||
@@ -130,8 +146,8 @@ export function captureAutomationOwners(
|
||||
rows: Iterable<AutomationCapturedRow>
|
||||
): Map<string, AutomationCapturedOwner> {
|
||||
const captured = new Map<string, AutomationCapturedOwner>()
|
||||
for (const { rowKey, row } of rows) {
|
||||
captured.set(rowKey, { owner: row.owner, selector: row.selector })
|
||||
for (const { rowKey, authority, row } of rows) {
|
||||
captured.set(rowKey, { authority, owner: row.owner, selector: row.selector })
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import type {
|
||||
AutomationHostCatalogEntry
|
||||
} from './automation-host-catalog-types'
|
||||
import {
|
||||
automationCreateEligibleProjects,
|
||||
automationCreateEligibleWorktrees,
|
||||
automationCreateHostStableKey,
|
||||
automationCreateProjectMismatch,
|
||||
automationCreateSetupMatchesDestination,
|
||||
automationCreateWorkspaceRefreshHostId,
|
||||
preselectAutomationCreateHost,
|
||||
resolveAutomationCreateDestination,
|
||||
revalidateAutomationCreateDestination,
|
||||
@@ -13,6 +17,8 @@ import {
|
||||
} from './automation-create-destination'
|
||||
import { groupReposByAutomationAuthority } from './automation-authority-identity'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
|
||||
function entry(overrides: Partial<AutomationHostCatalogEntry> = {}): AutomationHostCatalogEntry {
|
||||
return {
|
||||
@@ -141,6 +147,7 @@ describe('create host stable key', () => {
|
||||
// A desktop SSH workspace is still desktop-stored; only the selector differs.
|
||||
expect(automationCreateHostStableKey('ssh:box')).toBe('host:desktop:ssh:box')
|
||||
expect(automationCreateHostStableKey('runtime:gpu')).toBe('host:runtime:gpu:self')
|
||||
expect(automationCreateHostStableKey('ssh:box', 'gpu')).toBe('host:runtime:gpu:ssh:box')
|
||||
expect(automationCreateHostStableKey(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -195,4 +202,150 @@ describe('create project mismatch', () => {
|
||||
// The desktop's own table is a verdict, so its miss does refuse.
|
||||
expect(automationCreateProjectMismatch(tables, desktopSelf, 'missing')).toBe(true)
|
||||
})
|
||||
|
||||
it('offers only concrete repo rows owned by the selected authority and host', () => {
|
||||
const local = repo({ id: 'shared' })
|
||||
const runtime = repo({ id: 'shared', executionHostId: 'runtime:gpu' })
|
||||
const runtimeSsh = repo({
|
||||
id: 'nested',
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'runtime:gpu'
|
||||
})
|
||||
const runtimeSelf = {
|
||||
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
|
||||
destination: { selector: { kind: 'self' as const } },
|
||||
entry: entry()
|
||||
}
|
||||
expect(automationCreateEligibleProjects(runtimeSelf, [local, runtime, runtimeSsh], [])).toEqual(
|
||||
[runtime]
|
||||
)
|
||||
expect(
|
||||
automationCreateEligibleProjects(
|
||||
{
|
||||
...runtimeSelf,
|
||||
destination: {
|
||||
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
|
||||
}
|
||||
},
|
||||
[local, runtime, runtimeSsh],
|
||||
[]
|
||||
)
|
||||
).toEqual([runtimeSsh])
|
||||
})
|
||||
})
|
||||
|
||||
describe('create workspace destination', () => {
|
||||
const runtimeSelf = {
|
||||
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
|
||||
destination: { selector: { kind: 'self' as const } },
|
||||
entry: entry()
|
||||
}
|
||||
const runtimeRepo = {
|
||||
id: 'shared',
|
||||
displayName: 'orca',
|
||||
path: '/runtime/orca',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1,
|
||||
executionHostId: 'runtime:gpu'
|
||||
} as Repo
|
||||
|
||||
function worktree(overrides: Partial<Worktree>): Worktree {
|
||||
return {
|
||||
id: 'shared::/runtime/orca',
|
||||
repoId: 'shared',
|
||||
displayName: 'main',
|
||||
path: '/runtime/orca',
|
||||
branch: 'main',
|
||||
...overrides
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
it('keeps equal repo/workspace ids partitioned by runtime owner', () => {
|
||||
const local = worktree({ hostId: 'local' })
|
||||
const runtime = worktree({
|
||||
hostId: 'runtime:gpu',
|
||||
runtimeOwnerEnvironmentId: 'gpu'
|
||||
})
|
||||
const otherRuntime = worktree({
|
||||
hostId: 'runtime:other',
|
||||
runtimeOwnerEnvironmentId: 'other'
|
||||
})
|
||||
expect(
|
||||
automationCreateEligibleWorktrees(runtimeSelf, runtimeRepo, [local, runtime, otherRuntime])
|
||||
).toEqual([runtime])
|
||||
})
|
||||
|
||||
it('partitions nested SSH work by both authority and target', () => {
|
||||
const destination = {
|
||||
...runtimeSelf,
|
||||
destination: {
|
||||
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
|
||||
}
|
||||
}
|
||||
const repo = { ...runtimeRepo, connectionId: 'builder' }
|
||||
const owned = worktree({
|
||||
hostId: 'ssh:builder',
|
||||
runtimeOwnerEnvironmentId: 'gpu'
|
||||
})
|
||||
const desktop = worktree({ hostId: 'ssh:builder' })
|
||||
expect(automationCreateEligibleWorktrees(destination, repo, [owned, desktop])).toEqual([owned])
|
||||
expect(automationCreateWorkspaceRefreshHostId(destination)).toBe('runtime:gpu')
|
||||
})
|
||||
|
||||
it('matches the run setup to the same destination', () => {
|
||||
const setup = {
|
||||
id: 'setup-runtime',
|
||||
projectId: 'project',
|
||||
repoId: 'shared',
|
||||
hostId: 'runtime:gpu',
|
||||
executionHostId: 'runtime:gpu',
|
||||
runtimeOwnerEnvironmentId: 'gpu',
|
||||
path: '/runtime/orca',
|
||||
displayName: 'orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'cloned',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
} as ProjectHostSetup
|
||||
expect(automationCreateSetupMatchesDestination(setup, runtimeSelf)).toBe(true)
|
||||
expect(
|
||||
automationCreateSetupMatchesDestination(
|
||||
{ ...setup, runtimeOwnerEnvironmentId: 'other', hostId: 'runtime:other' },
|
||||
runtimeSelf
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps folder workspaces pinned to the selected SSH host', () => {
|
||||
const destination = {
|
||||
authority: { kind: 'desktop' as const },
|
||||
destination: {
|
||||
selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 }
|
||||
},
|
||||
entry: entry()
|
||||
}
|
||||
const repo = { ...runtimeRepo, executionHostId: 'local' as const }
|
||||
const setup = {
|
||||
id: 'folder-builder',
|
||||
projectId: 'project',
|
||||
repoId: repo.id,
|
||||
hostId: 'ssh:builder',
|
||||
executionHostId: 'ssh:builder',
|
||||
path: '/builder/orca',
|
||||
displayName: 'orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
} as ProjectHostSetup
|
||||
const folder = worktree({
|
||||
hostId: 'ssh:builder',
|
||||
projectHostSetupId: setup.id,
|
||||
path: '/builder/orca'
|
||||
})
|
||||
expect(automationCreateEligibleProjects(destination, [repo], [setup])).toEqual([repo])
|
||||
expect(automationCreateEligibleWorktrees(destination, repo, [folder], [setup])).toEqual([
|
||||
folder
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,17 +15,30 @@
|
||||
|
||||
import type {
|
||||
AutomationAuthorityRef,
|
||||
AutomationOwnerRef
|
||||
AutomationOwnerRef,
|
||||
StableAutomationAuthorityRef
|
||||
} from '../../../../shared/automation-owner-ref'
|
||||
import type { AutomationDestination } from '../../../../shared/automation-owner-precondition'
|
||||
import { hostStableKey, isSameAutomationOwner } from '../../../../shared/automation-owner-key'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
getWorktreeExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type {
|
||||
AutomationCatalogHydrationEvidence,
|
||||
AutomationHostCatalogEntry
|
||||
} from './automation-host-catalog-types'
|
||||
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
|
||||
import {
|
||||
automationRepoOwningAuthority,
|
||||
repoConnectionIdIn,
|
||||
type AutomationAuthorityRepoTables
|
||||
} from './automation-authority-identity'
|
||||
@@ -121,21 +134,26 @@ export function soleAutomationCreateHost(
|
||||
}
|
||||
|
||||
/** The catalog host a workspace's execution host names, for the All-hosts pre-fill. */
|
||||
export function automationCreateHostStableKey(hostId: string | null | undefined): string | null {
|
||||
export function automationCreateHostStableKey(
|
||||
hostId: string | null | undefined,
|
||||
runtimeOwnerEnvironmentId?: string | null
|
||||
): string | null {
|
||||
const host = parseExecutionHostId(hostId)
|
||||
if (!host) {
|
||||
const runtimeOwner = runtimeOwnerEnvironmentId?.trim()
|
||||
if (!host && !runtimeOwner) {
|
||||
return null
|
||||
}
|
||||
if (host.kind === 'runtime') {
|
||||
if (runtimeOwner || host?.kind === 'runtime') {
|
||||
const environmentId = runtimeOwner || (host?.kind === 'runtime' ? host.environmentId : '')
|
||||
return hostStableKey({
|
||||
authority: { kind: 'runtime', environmentId: host.environmentId },
|
||||
selector: { kind: 'self' }
|
||||
authority: { kind: 'runtime', environmentId },
|
||||
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
|
||||
})
|
||||
}
|
||||
// A desktop SSH workspace is still desktop-stored; only the selector differs.
|
||||
return hostStableKey({
|
||||
authority: { kind: 'desktop' },
|
||||
selector: host.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
|
||||
selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,17 +180,143 @@ export function automationCreateProjectMismatch(
|
||||
return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null
|
||||
}
|
||||
|
||||
function authoritiesMatch(
|
||||
left: StableAutomationAuthorityRef,
|
||||
right: StableAutomationAuthorityRef
|
||||
): boolean {
|
||||
return (
|
||||
left.kind === right.kind &&
|
||||
(left.kind === 'desktop' ||
|
||||
(right.kind === 'runtime' && left.environmentId === right.environmentId))
|
||||
)
|
||||
}
|
||||
|
||||
function connectionMatchesDestination(
|
||||
connectionId: string | null | undefined,
|
||||
destination: AutomationCreateDestination
|
||||
): boolean {
|
||||
const normalized = connectionId?.trim() || null
|
||||
const selector = destination.destination.selector
|
||||
return selector.kind === 'ssh' ? normalized === selector.targetId : normalized === null
|
||||
}
|
||||
|
||||
export function automationCreateRepoMatchesDestination(
|
||||
repo: Repo,
|
||||
destination: AutomationCreateDestination
|
||||
): boolean {
|
||||
return (
|
||||
authoritiesMatch(automationRepoOwningAuthority(repo), destination.authority) &&
|
||||
connectionMatchesDestination(repo.connectionId, destination)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The projects a destination can hold, filtered by the rule submit already
|
||||
* enforces, so the form cannot offer a pairing its own check will refuse.
|
||||
*/
|
||||
export function automationCreateEligibleProjects<TProject extends { id: string }>(
|
||||
tables: AutomationAuthorityRepoTables,
|
||||
export function automationCreateEligibleProjects(
|
||||
destination: AutomationCreateDestination,
|
||||
projects: readonly TProject[]
|
||||
): TProject[] {
|
||||
projects: readonly Repo[],
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
): Repo[] {
|
||||
return projects.filter(
|
||||
(project) => !automationCreateProjectMismatch(tables, destination, project.id)
|
||||
(project) =>
|
||||
automationCreateRepoMatchesDestination(project, destination) ||
|
||||
projectHostSetups.some(
|
||||
(setup) =>
|
||||
setup.repoId === project.id &&
|
||||
setup.setupState === 'ready' &&
|
||||
automationCreateSetupMatchesDestination(setup, destination)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function destinationExecutionHostId(destination: AutomationCreateDestination): ExecutionHostId {
|
||||
const selector = destination.destination.selector
|
||||
if (selector.kind === 'ssh') {
|
||||
return toSshExecutionHostId(selector.targetId)
|
||||
}
|
||||
return destination.authority.kind === 'runtime'
|
||||
? toRuntimeExecutionHostId(destination.authority.environmentId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
/** The host whose workspace catalog the client can authoritatively refresh. */
|
||||
export function automationCreateWorkspaceRefreshHostId(
|
||||
destination: AutomationCreateDestination
|
||||
): ExecutionHostId {
|
||||
return destination.authority.kind === 'runtime'
|
||||
? toRuntimeExecutionHostId(destination.authority.environmentId)
|
||||
: destinationExecutionHostId(destination)
|
||||
}
|
||||
|
||||
function runtimeOwnerMatches(
|
||||
runtimeOwnerEnvironmentId: string | null | undefined,
|
||||
hostId: ExecutionHostId | null,
|
||||
destination: AutomationCreateDestination
|
||||
): boolean {
|
||||
if (destination.authority.kind === 'desktop') {
|
||||
return !runtimeOwnerEnvironmentId && parseExecutionHostId(hostId)?.kind !== 'runtime'
|
||||
}
|
||||
if (runtimeOwnerEnvironmentId) {
|
||||
return runtimeOwnerEnvironmentId === destination.authority.environmentId
|
||||
}
|
||||
const host = parseExecutionHostId(hostId)
|
||||
return host?.kind === 'runtime' && host.environmentId === destination.authority.environmentId
|
||||
}
|
||||
|
||||
export function automationCreateWorktreeMatchesDestination(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
destination: AutomationCreateDestination,
|
||||
projectHostSetups: readonly ProjectHostSetup[] = []
|
||||
): boolean {
|
||||
if (worktree.repoId !== repo.id) {
|
||||
return false
|
||||
}
|
||||
const setup = worktree.projectHostSetupId
|
||||
? projectHostSetups.find(
|
||||
(candidate) =>
|
||||
candidate.id === worktree.projectHostSetupId &&
|
||||
automationCreateSetupMatchesDestination(candidate, destination)
|
||||
)
|
||||
: undefined
|
||||
const hostId =
|
||||
normalizeExecutionHostId(worktree.hostId) ??
|
||||
normalizeExecutionHostId(setup?.executionHostId) ??
|
||||
normalizeExecutionHostId(setup?.hostId) ??
|
||||
getWorktreeExecutionHostId(worktree, repo)
|
||||
return (
|
||||
runtimeOwnerMatches(
|
||||
worktree.runtimeOwnerEnvironmentId ?? setup?.runtimeOwnerEnvironmentId,
|
||||
hostId,
|
||||
destination
|
||||
) && hostId === destinationExecutionHostId(destination)
|
||||
)
|
||||
}
|
||||
|
||||
export function automationCreateEligibleWorktrees(
|
||||
destination: AutomationCreateDestination,
|
||||
repo: Repo | null | undefined,
|
||||
worktrees: readonly Worktree[],
|
||||
projectHostSetups: readonly ProjectHostSetup[] = []
|
||||
): Worktree[] {
|
||||
return repo
|
||||
? worktrees.filter((worktree) =>
|
||||
automationCreateWorktreeMatchesDestination(worktree, repo, destination, projectHostSetups)
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
export function automationCreateSetupMatchesDestination(
|
||||
setup: ProjectHostSetup,
|
||||
destination: AutomationCreateDestination
|
||||
): boolean {
|
||||
const transportHostId = normalizeExecutionHostId(setup.hostId)
|
||||
const executionHostId = normalizeExecutionHostId(setup.executionHostId) ?? transportHostId
|
||||
return (
|
||||
runtimeOwnerMatches(setup.runtimeOwnerEnvironmentId, transportHostId, destination) &&
|
||||
executionHostId === destinationExecutionHostId(destination)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* Turns an authority's answer into cache rows.
|
||||
*
|
||||
* Scoped rows keep the owner they were fetched under so every later action can
|
||||
* fence on it. Legacy rows keep no owner and no usage: the old response proves
|
||||
* neither, and the design forbids compensating with run-history downloads.
|
||||
* fence on it. Legacy SSH and orphan rows keep no owner and no usage: the old
|
||||
* response proves neither, and the design forbids compensating with run-history
|
||||
* downloads. Legacy Self rows do carry an owner — a Self record lives on the
|
||||
* answering authority itself and mutates by id under the pairing-revision
|
||||
* fence, so an old server degrades nothing for it.
|
||||
*/
|
||||
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
@@ -83,9 +86,12 @@ function legacySelectorMatches(
|
||||
|
||||
/**
|
||||
* Fans one unscoped response out to every entry that asked for it, so an old
|
||||
* runtime is queried once per cycle rather than once per selector.
|
||||
* runtime is queried once per cycle rather than once per selector. `authority`
|
||||
* is the incarnation the answer was fetched under; it becomes the captured
|
||||
* owner of the Self rows and of nothing else.
|
||||
*/
|
||||
export function partitionLegacyAutomationHostRows(
|
||||
authority: AutomationAuthorityRef,
|
||||
automations: readonly Automation[],
|
||||
refs: readonly StableAutomationCatalogRef[],
|
||||
context: LegacyAutomationPartitionContext,
|
||||
@@ -100,7 +106,9 @@ export function partitionLegacyAutomationHostRows(
|
||||
.filter((row) => legacySelectorMatches(row.selector, ref))
|
||||
.map((row) => ({
|
||||
automation: row.automation,
|
||||
owner: null,
|
||||
// Self needs no registration generation, so it is fenceable even from
|
||||
// an unscoped answer; SSH and orphan rows stay ownerless and view-only.
|
||||
owner: row.selector.kind === 'self' ? { authority, selector: { kind: 'self' } } : null,
|
||||
selector: row.selector,
|
||||
usageSummary: null,
|
||||
usageKnown: false
|
||||
|
||||
@@ -18,7 +18,7 @@ export type AutomationHostRowSelector = AutomationListItemSelector | LegacyAutom
|
||||
|
||||
export type AutomationHostRow = {
|
||||
automation: Automation
|
||||
/** Null when nothing may be mutated through it: legacy unscoped rows and orphans. */
|
||||
/** Null when nothing may be mutated through it: legacy unscoped SSH rows and orphans. */
|
||||
owner: AutomationOwnerRef | null
|
||||
selector: AutomationHostRowSelector
|
||||
usageSummary: AutomationUsageSummary | null
|
||||
|
||||
@@ -26,7 +26,11 @@ export type AutomationExecutionHealth =
|
||||
| 'unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/** What list/fencing contract this entry can use. `legacy-unscoped` entries are view-only. */
|
||||
/**
|
||||
* What list contract this entry can use. `legacy-unscoped` picks the one
|
||||
* unscoped-list-per-authority transport; whether the entry is view-only is
|
||||
* `scopeGap`'s call — Self stays fully usable on a legacy authority.
|
||||
*/
|
||||
export type AutomationHostQuerySupport = 'scoped' | 'legacy-unscoped' | 'incompatible'
|
||||
|
||||
/**
|
||||
@@ -52,7 +56,7 @@ export type AutomationHostCatalogState = 'authoritative' | 'unhydrated' | 'remov
|
||||
|
||||
export type AutomationHostCatalogEntry = {
|
||||
stableRef: StableAutomationCatalogRef
|
||||
/** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy). */
|
||||
/** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy SSH). */
|
||||
owner: AutomationOwnerRef | null
|
||||
stableKey: string
|
||||
label: string
|
||||
@@ -62,7 +66,7 @@ export type AutomationHostCatalogEntry = {
|
||||
authorityHealth: AutomationAuthorityHealth
|
||||
executionHealth: AutomationExecutionHealth
|
||||
querySupport: AutomationHostQuerySupport
|
||||
/** Present only while `querySupport` is degraded; explains which repair, if any, applies. */
|
||||
/** The reason this entry is view-only, and which repair applies; absent when it is not. */
|
||||
scopeGap?: AutomationHostScopeGap
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('buildAutomationHostCatalog', () => {
|
||||
expect(entry.executionHealth).toBe('unknown')
|
||||
})
|
||||
|
||||
it('keeps a legacy runtime view-only: no generations, no owners', () => {
|
||||
it("keeps only a legacy runtime's SSH entries view-only; Self stays fully usable", () => {
|
||||
const catalog = buildAutomationHostCatalog(
|
||||
buildInput({
|
||||
runtimes: [
|
||||
@@ -273,8 +273,17 @@ describe('buildAutomationHostCatalog', () => {
|
||||
const nested = entryAt(catalog, runtimeSshKey('env-1', 'legacy'))
|
||||
expect(nested.querySupport).toBe('legacy-unscoped')
|
||||
expect(nested.owner).toBeNull()
|
||||
// Self needs no target generation, so it stays owned even on an old server.
|
||||
expect(entryAt(catalog, runtimeSelfKey('env-1')).owner).not.toBeNull()
|
||||
expect(nested.scopeGap).toBe('authority-unscoped')
|
||||
// Self needs no target generation: it keeps its owner and records no scope
|
||||
// gap, so an old server never renders Runtime + Self as View only.
|
||||
const self = entryAt(catalog, runtimeSelfKey('env-1'))
|
||||
expect(self.owner).toEqual({
|
||||
authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 7 },
|
||||
selector: { kind: 'self' }
|
||||
})
|
||||
expect(self.scopeGap).toBeUndefined()
|
||||
// Still the legacy transport: one unscoped list per authority per cycle.
|
||||
expect(self.querySupport).toBe('legacy-unscoped')
|
||||
})
|
||||
|
||||
it('downgrades a scoped entry whose target carries no generation', () => {
|
||||
|
||||
@@ -119,8 +119,10 @@ function projectSelfEntry(ctx: AuthorityProjectionContext): AutomationHostCatalo
|
||||
catalogState: 'authoritative',
|
||||
authorityHealth: ctx.authorityHealth,
|
||||
executionHealth: resolveSelfExecutionHealth(ctx.authorityHealth),
|
||||
querySupport: ctx.querySupport,
|
||||
...withScopeGap(authorityScopeGap(ctx.querySupport))
|
||||
// No scope gap even on a legacy authority: Self records live on the
|
||||
// answering authority and mutate by id under the pairing-revision fence,
|
||||
// so the unscoped contract degrades the list transport, not access.
|
||||
querySupport: ctx.querySupport
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
import {
|
||||
listAutomationRunsForTarget,
|
||||
listAutomationsForTarget,
|
||||
runAutomationNowForTarget,
|
||||
updateAutomationForTarget
|
||||
} from './automation-host-client'
|
||||
import { listAutomationRunsForTarget, listAutomationsForTarget } from './automation-host-client'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
@@ -102,58 +97,4 @@ describe('automation host client', () => {
|
||||
)
|
||||
expect(mockApi.automations.listRuns).toHaveBeenCalledWith({ automationId: 'auto-1' })
|
||||
})
|
||||
|
||||
it('manually runs runtime-host automations through that server', async () => {
|
||||
const automation = makeAutomation()
|
||||
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({
|
||||
run: { id: 'run-1', automationId: automation.id }
|
||||
})
|
||||
|
||||
await runAutomationNowForTarget(automation)
|
||||
|
||||
expect(mockApi.automations.runNow).not.toHaveBeenCalled()
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'gpu' },
|
||||
'automation.runNow',
|
||||
{ id: automation.id },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('updates and manually runs SSH-host automations through the remote server that listed them', async () => {
|
||||
const automation = makeAutomation({
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:devbox',
|
||||
projectHostSetupId: 'setup-devbox',
|
||||
repoId: 'repo-1',
|
||||
path: '/srv/orca'
|
||||
}
|
||||
})
|
||||
const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' }
|
||||
vi.mocked(callRuntimeRpc)
|
||||
.mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } })
|
||||
.mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } })
|
||||
|
||||
await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget)
|
||||
await runAutomationNowForTarget(automation, sourceTarget)
|
||||
|
||||
expect(mockApi.automations.update).not.toHaveBeenCalled()
|
||||
expect(mockApi.automations.runNow).not.toHaveBeenCalled()
|
||||
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
sourceTarget,
|
||||
'automation.update',
|
||||
{ id: automation.id, updates: { name: 'Updated' } },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
sourceTarget,
|
||||
'automation.runNow',
|
||||
{ id: automation.id },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'workspaceId'> & {
|
||||
repo?: string
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type AutomationHostTarget =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'environment'; environmentId: string }
|
||||
@@ -38,25 +30,12 @@ export function getAutomationTargetFromHostId(
|
||||
: { kind: 'local' }
|
||||
}
|
||||
|
||||
export function getAutomationOwnerTarget(
|
||||
automation: Pick<Automation, 'runContext'>,
|
||||
sourceTarget?: AutomationHostTarget | null
|
||||
export function getAutomationTargetFromAuthority(
|
||||
authority: StableAutomationAuthorityRef
|
||||
): AutomationHostTarget {
|
||||
if (sourceTarget?.kind === 'environment') {
|
||||
return sourceTarget
|
||||
}
|
||||
return getAutomationTargetFromHostId(automation.runContext?.hostId)
|
||||
}
|
||||
|
||||
function toRuntimeAutomationUpdateInput(
|
||||
input: AutomationUpdateInput
|
||||
): RuntimeAutomationUpdateInput {
|
||||
const { projectId, workspaceId, ...rest } = input
|
||||
return {
|
||||
...rest,
|
||||
...(projectId !== undefined ? { repo: projectId } : {}),
|
||||
...(workspaceId !== undefined ? { workspace: workspaceId ?? undefined } : {})
|
||||
}
|
||||
return authority.kind === 'runtime'
|
||||
? { kind: 'environment', environmentId: authority.environmentId }
|
||||
: { kind: 'local' }
|
||||
}
|
||||
|
||||
export async function listAutomationsForTarget(
|
||||
@@ -94,50 +73,3 @@ export async function listAutomationRunsForTarget(
|
||||
)
|
||||
return result.runs
|
||||
}
|
||||
|
||||
export async function updateAutomationForTarget(
|
||||
automation: Automation,
|
||||
updates: AutomationUpdateInput,
|
||||
sourceTarget?: AutomationHostTarget | null
|
||||
): Promise<Automation> {
|
||||
const target = getAutomationOwnerTarget(automation, sourceTarget)
|
||||
if (target.kind === 'local') {
|
||||
return await window.api.automations.update({ id: automation.id, updates })
|
||||
}
|
||||
const result = await callRuntimeRpc<{ automation: Automation }>(
|
||||
target,
|
||||
'automation.update',
|
||||
{ id: automation.id, updates: toRuntimeAutomationUpdateInput(updates) },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
return result.automation
|
||||
}
|
||||
|
||||
export async function deleteAutomationForTarget(
|
||||
automation: Automation,
|
||||
sourceTarget?: AutomationHostTarget | null
|
||||
): Promise<void> {
|
||||
const target = getAutomationOwnerTarget(automation, sourceTarget)
|
||||
if (target.kind === 'local') {
|
||||
await window.api.automations.delete({ id: automation.id })
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 })
|
||||
}
|
||||
|
||||
export async function runAutomationNowForTarget(
|
||||
automation: Automation,
|
||||
sourceTarget?: AutomationHostTarget | null
|
||||
): Promise<AutomationRun> {
|
||||
const target = getAutomationOwnerTarget(automation, sourceTarget)
|
||||
if (target.kind === 'local') {
|
||||
return await window.api.automations.runNow({ id: automation.id })
|
||||
}
|
||||
const result = await callRuntimeRpc<{ run: AutomationRun }>(
|
||||
target,
|
||||
'automation.runNow',
|
||||
{ id: automation.id },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
return result.run
|
||||
}
|
||||
|
||||
@@ -121,6 +121,15 @@ export function resolveAutomationHostListRows(
|
||||
const capturedRows: AutomationCapturedRow[] = []
|
||||
let answered = false
|
||||
|
||||
const authorityByKey = new Map(
|
||||
input.catalog.entries.flatMap((entry) => {
|
||||
const authority = entry.owner?.authority
|
||||
return authority
|
||||
? [[automationAuthorityCatalogKey(entry.stableRef.authority), authority] as const]
|
||||
: []
|
||||
})
|
||||
)
|
||||
|
||||
for (const entry of entries) {
|
||||
const cached = input.entry(entry.stableKey)
|
||||
if (cached && cached.fetchedAt !== null) {
|
||||
@@ -143,7 +152,11 @@ export function resolveAutomationHostListRows(
|
||||
hostLabel: entry.label,
|
||||
usageSummary: row.usageSummary ?? null
|
||||
}
|
||||
capturedRows.push({ rowKey: listRow.key, row })
|
||||
const authority = authorityByKey.get(authorityKey)
|
||||
if (!authority) {
|
||||
continue
|
||||
}
|
||||
capturedRows.push({ rowKey: listRow.key, authority, row })
|
||||
rows.push(listRow)
|
||||
hostRows.push(listRow)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connectRuntimeEnvironmentAndRecordStatus: vi.fn(),
|
||||
hydrateRuntimeEnvironmentSshState: vi.fn(),
|
||||
connectRuntimeEnvironmentSshTarget: vi.fn(),
|
||||
resyncRuntimeEnvironmentSshTargets: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetsMetadata: vi.fn(),
|
||||
setRemovedSshTargetLabels: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({
|
||||
connectRuntimeEnvironmentAndRecordStatus: (...args: unknown[]) =>
|
||||
mocks.connectRuntimeEnvironmentAndRecordStatus(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-environment-ssh-state', () => ({
|
||||
connectRuntimeEnvironmentSshTarget: (...args: unknown[]) =>
|
||||
mocks.connectRuntimeEnvironmentSshTarget(...args),
|
||||
hydrateRuntimeEnvironmentSshState: (...args: unknown[]) =>
|
||||
mocks.hydrateRuntimeEnvironmentSshState(...args),
|
||||
resyncRuntimeEnvironmentSshTargets: (...args: unknown[]) =>
|
||||
mocks.resyncRuntimeEnvironmentSshTargets(...args)
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: (...args: unknown[]) => mocks.toastError(...args) }
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
setSshConnectionState: mocks.setSshConnectionState,
|
||||
setSshTargetsMetadata: mocks.setSshTargetsMetadata,
|
||||
setRemovedSshTargetLabels: mocks.setRemovedSshTargetLabels
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import {
|
||||
connectAutomationHostRuntime,
|
||||
connectAutomationHostSshTarget
|
||||
} from './automation-host-recovery-connect'
|
||||
|
||||
function installSshApi(connect: ReturnType<typeof vi.fn>): void {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ssh: {
|
||||
connect,
|
||||
listTargets: vi.fn().mockResolvedValue([]),
|
||||
listRemovedTargetLabels: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('automation host recovery connect', () => {
|
||||
beforeEach(() => {
|
||||
resetSshConnectInFlightForTests()
|
||||
for (const fn of Object.values(mocks)) {
|
||||
fn.mockReset()
|
||||
}
|
||||
mocks.hydrateRuntimeEnvironmentSshState.mockResolvedValue(undefined)
|
||||
mocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue({ status: 'connected' })
|
||||
mocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined)
|
||||
installSshApi(vi.fn().mockResolvedValue({ status: 'connected' }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSshConnectInFlightForTests()
|
||||
})
|
||||
|
||||
it('records runtime status so the catalog can leave Unreachable', async () => {
|
||||
mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(true)
|
||||
|
||||
await connectAutomationHostRuntime('gpu')
|
||||
|
||||
expect(mocks.connectRuntimeEnvironmentAndRecordStatus).toHaveBeenCalledWith('gpu', 15_000)
|
||||
expect(mocks.hydrateRuntimeEnvironmentSshState).toHaveBeenCalledWith('gpu')
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts when the runtime stays unreachable', async () => {
|
||||
mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(false)
|
||||
|
||||
await connectAutomationHostRuntime('gpu')
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Remote host is not reachable')
|
||||
expect(mocks.hydrateRuntimeEnvironmentSshState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the local SSH connect result into the store the catalog reads', async () => {
|
||||
const connect = vi.fn().mockResolvedValue({ status: 'connected', targetId: 't1' })
|
||||
installSshApi(connect)
|
||||
|
||||
await connectAutomationHostSshTarget({ targetId: 't1' })
|
||||
|
||||
expect(connect).toHaveBeenCalledWith({ targetId: 't1' })
|
||||
expect(mocks.setSshConnectionState).toHaveBeenCalledWith('t1', {
|
||||
status: 'connected',
|
||||
targetId: 't1'
|
||||
})
|
||||
expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dials a runtime SSH target through that server, not the local SSH API', async () => {
|
||||
const connect = vi.fn()
|
||||
installSshApi(connect)
|
||||
|
||||
await connectAutomationHostSshTarget({ targetId: 't1', environmentId: 'gpu' })
|
||||
|
||||
expect(mocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith('gpu', 't1')
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts a failed local SSH connect instead of swallowing it', async () => {
|
||||
installSshApi(vi.fn().mockRejectedValue(new Error('SSH target "t1" not found')))
|
||||
|
||||
await connectAutomationHostSshTarget({ targetId: 't1' })
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('SSH target "t1" not found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Performs the transport dials `runAutomationHostRecovery` selected.
|
||||
*
|
||||
* The recovery module only names the host; this is the other half — write the
|
||||
* returned status into the store the catalog actually reads. A fire-and-forget
|
||||
* `ssh.connect` / `runtimeEnvironments.connect` leaves Unreachable on screen
|
||||
* even when the backend came back.
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { connectRuntimeEnvironmentAndRecordStatus } from '@/components/status-bar/runtime-environment-explicit-connect'
|
||||
import {
|
||||
connectRuntimeEnvironmentSshTarget,
|
||||
hydrateRuntimeEnvironmentSshState,
|
||||
resyncRuntimeEnvironmentSshTargets
|
||||
} from '@/runtime/runtime-environment-ssh-state'
|
||||
import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight'
|
||||
import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
|
||||
|
||||
const RUNTIME_RECONNECT_TIMEOUT_MS = 15_000
|
||||
|
||||
const runtimeConnectInFlight = new Set<string>()
|
||||
|
||||
export async function connectAutomationHostRuntime(environmentId: string): Promise<void> {
|
||||
if (runtimeConnectInFlight.has(environmentId)) {
|
||||
return
|
||||
}
|
||||
runtimeConnectInFlight.add(environmentId)
|
||||
try {
|
||||
const reachable = await connectRuntimeEnvironmentAndRecordStatus(
|
||||
environmentId,
|
||||
RUNTIME_RECONNECT_TIMEOUT_MS
|
||||
)
|
||||
if (!reachable) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.automations.hostStatus.action.runtimeConnectFailed',
|
||||
'Remote host is not reachable'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: the catalog projects this environment's SSH targets from the bucket;
|
||||
// a successful ping with a stale empty bucket still looks unverified.
|
||||
await hydrateRuntimeEnvironmentSshState(environmentId).catch(() => {})
|
||||
} finally {
|
||||
runtimeConnectInFlight.delete(environmentId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectAutomationHostSshTarget(args: {
|
||||
targetId: string
|
||||
environmentId?: string
|
||||
}): Promise<void> {
|
||||
const { targetId, environmentId } = args
|
||||
if (isSshConnectInFlight(targetId)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (environmentId) {
|
||||
await trackSshConnect(targetId, connectRuntimeEnvironmentSshTarget(environmentId, targetId))
|
||||
return
|
||||
}
|
||||
const connectState = await withUiConnectTimeout(
|
||||
trackSshConnect(targetId, window.api.ssh.connect({ targetId })),
|
||||
SSH_RECONNECT_UI_TIMEOUT_MS
|
||||
)
|
||||
if (connectState) {
|
||||
// Why: ssh.connect can resolve before the global state-change IPC lands.
|
||||
useAppStore.getState().setSshConnectionState(targetId, connectState)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate(
|
||||
'auto.components.automations.hostStatus.action.sshConnectFailed',
|
||||
'SSH connection failed'
|
||||
)
|
||||
)
|
||||
if (environmentId) {
|
||||
void resyncRuntimeEnvironmentSshTargets(environmentId).catch(() => {})
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const targets = await window.api.ssh.listTargets()
|
||||
useAppStore.getState().setSshTargetsMetadata(targets)
|
||||
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
|
||||
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
|
||||
})().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -44,23 +44,30 @@ const RUNTIME_SSH = entry({
|
||||
})
|
||||
|
||||
describe('automation host recovery', () => {
|
||||
it('retries the host rather than the whole page', () => {
|
||||
it('retries the host rather than the whole page', async () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('retry', DESKTOP_SSH, target)
|
||||
await runAutomationHostRecovery('retry', DESKTOP_SSH, target)
|
||||
expect(target.retry).toHaveBeenCalledWith(DESKTOP_SSH)
|
||||
})
|
||||
|
||||
it('dials the SSH target when the authority is fine', () => {
|
||||
it('dials the SSH target when the authority is fine', async () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('reconnect', DESKTOP_SSH, target)
|
||||
expect(target.connectSshTarget).toHaveBeenCalledWith('t1')
|
||||
await runAutomationHostRecovery('reconnect', DESKTOP_SSH, target)
|
||||
expect(target.connectSshTarget).toHaveBeenCalledWith('t1', undefined)
|
||||
expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dials the runtime first when the server itself is unreachable', () => {
|
||||
it('dials a reachable runtime SSH target on that server, not the local SSH API', async () => {
|
||||
const target = deps()
|
||||
await runAutomationHostRecovery('reconnect', RUNTIME_SSH, target)
|
||||
expect(target.connectSshTarget).toHaveBeenCalledWith('t1', 'gpu')
|
||||
expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dials the runtime first when the server itself is unreachable', async () => {
|
||||
const target = deps()
|
||||
// Why: an unreachable server cannot be asked to dial its own SSH target.
|
||||
runAutomationHostRecovery(
|
||||
await runAutomationHostRecovery(
|
||||
'reconnect',
|
||||
{ ...RUNTIME_SSH, authorityHealth: 'unavailable' },
|
||||
target
|
||||
@@ -69,30 +76,30 @@ describe('automation host recovery', () => {
|
||||
expect(target.connectSshTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-asks a desktop Self host, which has no transport to dial', () => {
|
||||
it('re-asks a desktop Self host, which has no transport to dial', async () => {
|
||||
const target = deps()
|
||||
const desktopSelf = entry({
|
||||
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
|
||||
})
|
||||
runAutomationHostRecovery('reconnect', desktopSelf, target)
|
||||
await runAutomationHostRecovery('reconnect', desktopSelf, target)
|
||||
expect(target.retry).toHaveBeenCalledWith(desktopSelf)
|
||||
})
|
||||
|
||||
it('sends a runtime to the servers pane, since nothing here updates a host', () => {
|
||||
it('sends a runtime to the servers pane, since nothing here updates a host', async () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
|
||||
await runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
|
||||
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
|
||||
})
|
||||
|
||||
it('sends a stale desktop SSH registration to the SSH pane instead', () => {
|
||||
it('sends a stale desktop SSH registration to the SSH pane instead', async () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('update-server', DESKTOP_SSH, target)
|
||||
await runAutomationHostRecovery('update-server', DESKTOP_SSH, target)
|
||||
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'ssh', repoId: null })
|
||||
})
|
||||
|
||||
it('does nothing without an entry to act on', () => {
|
||||
it('does nothing without an entry to act on', async () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('retry', null, target)
|
||||
await runAutomationHostRecovery('retry', null, target)
|
||||
expect(target.retry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,9 +14,10 @@ import type { AutomationHostRecoveryAction } from './automation-host-status-desc
|
||||
|
||||
export type AutomationHostRecoveryDeps = {
|
||||
/** Forces one fresh query for this host, bypassing TTL and retry cooldown. */
|
||||
retry: (entry: AutomationHostCatalogEntry) => void
|
||||
connectSshTarget: (targetId: string) => void
|
||||
connectRuntimeEnvironment: (environmentId: string) => void
|
||||
retry: (entry: AutomationHostCatalogEntry) => void | Promise<void>
|
||||
/** `environmentId` is the owning runtime; omit it for a desktop SSH target. */
|
||||
connectSshTarget: (targetId: string, environmentId?: string) => void | Promise<void>
|
||||
connectRuntimeEnvironment: (environmentId: string) => void | Promise<void>
|
||||
openSettings: (target: SettingsNavigationTarget) => void
|
||||
}
|
||||
|
||||
@@ -30,39 +31,45 @@ function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavig
|
||||
return { pane: entry.stableRef.selector.kind === 'ssh' ? 'ssh' : 'automations', repoId: null }
|
||||
}
|
||||
|
||||
function reconnect(entry: AutomationHostCatalogEntry, deps: AutomationHostRecoveryDeps): void {
|
||||
async function reconnect(
|
||||
entry: AutomationHostCatalogEntry,
|
||||
deps: AutomationHostRecoveryDeps
|
||||
): Promise<void> {
|
||||
const authority = entry.stableRef.authority
|
||||
// Authority first: an unreachable server cannot be asked to dial its own targets.
|
||||
if (authority.kind === 'runtime' && entry.authorityHealth === 'unavailable') {
|
||||
deps.connectRuntimeEnvironment(authority.environmentId)
|
||||
await deps.connectRuntimeEnvironment(authority.environmentId)
|
||||
return
|
||||
}
|
||||
if (entry.stableRef.selector.kind === 'ssh') {
|
||||
deps.connectSshTarget(entry.stableRef.selector.targetId)
|
||||
await deps.connectSshTarget(
|
||||
entry.stableRef.selector.targetId,
|
||||
authority.kind === 'runtime' ? authority.environmentId : undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
if (authority.kind === 'runtime') {
|
||||
deps.connectRuntimeEnvironment(authority.environmentId)
|
||||
await deps.connectRuntimeEnvironment(authority.environmentId)
|
||||
return
|
||||
}
|
||||
// Desktop Self has no transport to dial, so the only honest fallback is to re-ask.
|
||||
deps.retry(entry)
|
||||
await deps.retry(entry)
|
||||
}
|
||||
|
||||
export function runAutomationHostRecovery(
|
||||
export async function runAutomationHostRecovery(
|
||||
action: AutomationHostRecoveryAction,
|
||||
entry: AutomationHostCatalogEntry | null,
|
||||
deps: AutomationHostRecoveryDeps
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
switch (action) {
|
||||
case 'retry':
|
||||
deps.retry(entry)
|
||||
await deps.retry(entry)
|
||||
return
|
||||
case 'reconnect':
|
||||
reconnect(entry, deps)
|
||||
await reconnect(entry, deps)
|
||||
return
|
||||
case 'update-server':
|
||||
deps.openSettings(versionSettingsTarget(entry))
|
||||
|
||||
@@ -360,21 +360,31 @@ describe('automation host scheduler legacy path', () => {
|
||||
expect(listRuns).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves legacy rows unfenced and their usage unavailable', async () => {
|
||||
it('fences legacy Self rows on the answering authority and leaves SSH rows unfenced', async () => {
|
||||
const cache = createCache()
|
||||
const scheduler = createAutomationHostScheduler({
|
||||
cache,
|
||||
legacyPartitionContext: () => LOCAL_REPO,
|
||||
isVisible: () => true,
|
||||
transport: { listLegacy: () => Promise.resolve([automation('a')]) }
|
||||
transport: {
|
||||
listLegacy: () =>
|
||||
Promise.resolve([
|
||||
automation('a'),
|
||||
automation('b', { executionTargetType: 'ssh', executionTargetId: 't1' })
|
||||
])
|
||||
}
|
||||
})
|
||||
const legacy = { querySupport: 'legacy-unscoped' as const }
|
||||
await scheduler.refresh([
|
||||
target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'legacy-unscoped' })
|
||||
target(selfRef(RUNTIME_AUTHORITY), legacy),
|
||||
target(sshRef(RUNTIME_AUTHORITY, 't1'), legacy)
|
||||
])
|
||||
const row = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0]
|
||||
expect(row?.owner).toBeNull()
|
||||
expect(row?.usageKnown).toBe(false)
|
||||
expect(row?.usageSummary).toBeNull()
|
||||
const selfRow = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0]
|
||||
// Self mutates by id on its own authority, so the unscoped answer still fences it.
|
||||
expect(selfRow?.owner).toEqual({ authority: RUNTIME_AUTHORITY, selector: { kind: 'self' } })
|
||||
expect(selfRow?.usageKnown).toBe(false)
|
||||
expect(selfRow?.usageSummary).toBeNull()
|
||||
expect(cache.get(sshRef(RUNTIME_AUTHORITY, 't1'))?.data[0]?.owner).toBeNull()
|
||||
})
|
||||
|
||||
it('records an incompatible host without sending anything', async () => {
|
||||
|
||||
@@ -218,6 +218,7 @@ export function createAutomationHostScheduler(
|
||||
// Attributed to the authority alone: one answer serves every entry in the group.
|
||||
instrument.response(live[0].fence, null, startedAt, automations.length, automations)
|
||||
const partition = partitionLegacyAutomationHostRows(
|
||||
authority,
|
||||
automations,
|
||||
live.map((target) => target.ref),
|
||||
options.legacyPartitionContext(live[0].ref.authority),
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { AutomationHostStatusDescriptor } from './automation-host-status-de
|
||||
/**
|
||||
* Copy for the query-contract axis.
|
||||
*
|
||||
* Every degraded entry shares one consequence — view only — but not one cause.
|
||||
* A removed target, a target we have not verified since the connection dropped,
|
||||
* and a server that advertises no host scoping each get their own sentence, so
|
||||
* the badge never claims a server is old on the strength of missing evidence.
|
||||
* Every recorded scope gap shares one consequence — view only — but not one
|
||||
* cause. A removed target, a target we have not verified since the connection
|
||||
* dropped, and a server that advertises no host scoping each get their own
|
||||
* sentence, so the badge never claims a server is old on the strength of
|
||||
* missing evidence. A degraded transport alone earns no badge: only a gap does.
|
||||
*/
|
||||
|
||||
const VIEW_ONLY_LABEL = (): string =>
|
||||
@@ -67,13 +68,10 @@ function scopeGapDescriptor(gap: AutomationHostScopeGap): AutomationHostStatusDe
|
||||
}
|
||||
}
|
||||
|
||||
/** Null for `scoped`; the degraded contracts stay distinct rather than sharing one badge. */
|
||||
/** Null when nothing is view-only; the degraded contracts stay distinct rather than sharing one badge. */
|
||||
export function hostScopeDescriptor(
|
||||
entry: Pick<AutomationHostCatalogEntry, 'querySupport' | 'scopeGap'>
|
||||
): AutomationHostStatusDescriptor | null {
|
||||
if (entry.querySupport === 'scoped') {
|
||||
return null
|
||||
}
|
||||
if (entry.querySupport === 'incompatible') {
|
||||
return {
|
||||
id: 'query-incompatible',
|
||||
@@ -89,6 +87,7 @@ export function hostScopeDescriptor(
|
||||
isDefault: false
|
||||
}
|
||||
}
|
||||
// An unscoped contract with no recorded cause can only be the authority's own.
|
||||
return scopeGapDescriptor(entry.scopeGap ?? 'authority-unscoped')
|
||||
// A degraded list transport with no recorded gap has no view-only consequence:
|
||||
// Runtime + Self on a legacy server still mutates by id under the pairing fence.
|
||||
return entry.scopeGap ? scopeGapDescriptor(entry.scopeGap) : null
|
||||
}
|
||||
|
||||
@@ -92,10 +92,9 @@ describe('automation host status descriptors', () => {
|
||||
expect(new Set(gapIds).size).toBe(SCOPE_GAPS.length)
|
||||
})
|
||||
|
||||
it('falls back to the authority contract when no cause was recorded', () => {
|
||||
expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))?.id).toBe(
|
||||
'query-legacy-unscoped'
|
||||
)
|
||||
it('shows no view-only badge for a degraded transport with no recorded gap', () => {
|
||||
// Runtime + Self on a legacy server: the list is unscoped but access is not.
|
||||
expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('blames the server only when the server itself answered without host scoping', () => {
|
||||
@@ -168,6 +167,17 @@ describe('automation host recovery actions', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps normal recovery verbs for a legacy authority entry with no scope gap', () => {
|
||||
expect(
|
||||
automationHostRecoveryActions(entry({ querySupport: 'legacy-unscoped' })).authority
|
||||
).toBeNull()
|
||||
expect(
|
||||
automationHostRecoveryActions(
|
||||
entry({ querySupport: 'legacy-unscoped', authorityHealth: 'stale-error' })
|
||||
).authority
|
||||
).toBe('retry')
|
||||
})
|
||||
|
||||
it('reconnects rather than retries when the authority itself is unreachable', () => {
|
||||
expect(automationHostRecoveryActions(entry({ authorityHealth: 'unavailable' })).authority).toBe(
|
||||
'reconnect'
|
||||
|
||||
@@ -212,25 +212,23 @@ function authorityRecoveryAction(
|
||||
// The transport to the authority is down, so retrying the query alone cannot help.
|
||||
return 'reconnect'
|
||||
}
|
||||
if (entry.querySupport !== 'scoped') {
|
||||
// A degraded transport with no gap needs no repair: Runtime + Self on a
|
||||
// legacy server stays fully usable, so its recovery verbs are the normal ones.
|
||||
if (entry.scopeGap) {
|
||||
return scopeGapRecoveryAction(entry.scopeGap)
|
||||
}
|
||||
return entry.authorityHealth === 'stale-error' ? 'retry' : null
|
||||
}
|
||||
|
||||
/** Updating a server only repairs a server that actually answered without host scoping. */
|
||||
function scopeGapRecoveryAction(
|
||||
gap: AutomationHostScopeGap | undefined
|
||||
): AutomationHostRecoveryAction | null {
|
||||
function scopeGapRecoveryAction(gap: AutomationHostScopeGap): AutomationHostRecoveryAction | null {
|
||||
switch (gap) {
|
||||
case 'target-unverified':
|
||||
return 'reconnect'
|
||||
case 'target-removed':
|
||||
case 'target-unregistered':
|
||||
return null
|
||||
// An unscoped contract with no recorded cause can only be the authority's own.
|
||||
case 'authority-unscoped':
|
||||
case undefined:
|
||||
return 'update-server'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,22 +29,18 @@ export type AutomationListRow = {
|
||||
/** Authority-qualified and incarnation-free; see the module note. */
|
||||
key: string
|
||||
automation: Automation
|
||||
/** Storage authority captured with the host query; absent only for the bootstrap list. */
|
||||
/** Storage authority captured with the host query. */
|
||||
catalogRef?: StableAutomationCatalogRef | null
|
||||
/** The host this row actually came from; empty for unscoped legacy rows. */
|
||||
/** The host this row actually came from. */
|
||||
hostLabel: string
|
||||
/** What the owning authority reported; null means unknown, never zero. */
|
||||
usageSummary: AutomationUsageSummary | null
|
||||
}
|
||||
|
||||
const ROW_KEY_NAMESPACE = 'row'
|
||||
/** The pre-catalog list has no host to qualify with, so it says so rather than guessing one. */
|
||||
const UNSCOPED_HOST_KEY = 'unscoped'
|
||||
|
||||
export function automationListRowKey(hostStableKey: string | null, automationId: string): string {
|
||||
return [ROW_KEY_NAMESPACE, hostStableKey ?? UNSCOPED_HOST_KEY, automationId]
|
||||
.map(encodeURIComponent)
|
||||
.join('|')
|
||||
export function automationListRowKey(hostStableKey: string, automationId: string): string {
|
||||
return [ROW_KEY_NAMESPACE, hostStableKey, automationId].map(encodeURIComponent).join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,19 +53,6 @@ export function automationAuthorityRecordKey(authorityKey: string, automationId:
|
||||
return [authorityKey, automationId].map(encodeURIComponent).join('|')
|
||||
}
|
||||
|
||||
/** The unscoped list the page shows before any host has answered. */
|
||||
export function unscopedAutomationListRows(
|
||||
automations: readonly Automation[]
|
||||
): AutomationListRow[] {
|
||||
return automations.map((automation) => ({
|
||||
key: automationListRowKey(null, automation.id),
|
||||
automation,
|
||||
catalogRef: null,
|
||||
hostLabel: '',
|
||||
usageSummary: null
|
||||
}))
|
||||
}
|
||||
|
||||
export function automationRepoForRow(
|
||||
row: AutomationListRow,
|
||||
repos: readonly Repo[],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
stripAutomationOwnerConflictCode,
|
||||
type AutomationOwnerConflictCode
|
||||
} from '../../../../shared/automation-owner-conflict'
|
||||
import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code'
|
||||
import type {
|
||||
AutomationAuthorityRef,
|
||||
AutomationOwnerRef
|
||||
@@ -96,6 +97,15 @@ function classify(error: unknown): AutomationActionOutcome<never> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasRuntimeRpcErrorCode(error, 'selector_not_found')) {
|
||||
return {
|
||||
status: 'failed',
|
||||
message: translate(
|
||||
'auto.components.automations.ownerAction.workspaceNotFound',
|
||||
"We couldn't find that workspace on this host. Choose a workspace that exists here and try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
message:
|
||||
@@ -117,13 +127,10 @@ async function attempt<TValue>(
|
||||
availability: AutomationActionAvailability,
|
||||
owned: (owner: AutomationOwnerRef) => Promise<TValue>,
|
||||
orphanFenced?: () => Promise<TValue>
|
||||
): Promise<AutomationActionOutcome<TValue> | { status: 'uncaptured' }> {
|
||||
): Promise<AutomationActionOutcome<TValue>> {
|
||||
if (availability.kind === 'blocked') {
|
||||
return { status: 'blocked', block: availability.block }
|
||||
}
|
||||
if (availability.kind === 'uncaptured') {
|
||||
return { status: 'uncaptured' }
|
||||
}
|
||||
try {
|
||||
if (availability.kind === 'owned') {
|
||||
return { status: 'ok', value: await owned(availability.owner) }
|
||||
@@ -148,14 +155,10 @@ function orphanUnsupported(): AutomationActionBlock {
|
||||
}
|
||||
}
|
||||
|
||||
export type AutomationActionResult<TValue> =
|
||||
| AutomationActionOutcome<TValue>
|
||||
/** The caller must fall back to its unfenced path; Step 9 deletes this arm. */
|
||||
| { status: 'uncaptured' }
|
||||
export type AutomationActionResult<TValue> = AutomationActionOutcome<TValue>
|
||||
|
||||
export async function updateOwnedAutomation(
|
||||
availability: AutomationActionAvailability,
|
||||
authority: AutomationAuthorityRef,
|
||||
id: string,
|
||||
updates: AutomationUpdateInput,
|
||||
destination?: AutomationDestination
|
||||
@@ -163,19 +166,24 @@ export async function updateOwnedAutomation(
|
||||
return await attempt(
|
||||
availability,
|
||||
(owner) => updateAutomationForOwner(owner, id, updates, destination),
|
||||
() => updateOrphanAutomation(authority, id, updates)
|
||||
() =>
|
||||
availability.kind === 'orphan-fenced'
|
||||
? updateOrphanAutomation(availability.authority, id, updates)
|
||||
: Promise.reject(new Error('automation_owner_uncaptured'))
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteOwnedAutomation(
|
||||
availability: AutomationActionAvailability,
|
||||
authority: AutomationAuthorityRef,
|
||||
id: string
|
||||
): Promise<AutomationActionResult<void>> {
|
||||
return await attempt(
|
||||
availability,
|
||||
(owner) => deleteAutomationForOwner(owner, id),
|
||||
() => deleteOrphanAutomation(authority, id)
|
||||
() =>
|
||||
availability.kind === 'orphan-fenced'
|
||||
? deleteOrphanAutomation(availability.authority, id)
|
||||
: Promise.reject(new Error('automation_owner_uncaptured'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,13 +211,15 @@ export async function showOwnedAutomation(
|
||||
|
||||
export async function listOwnedAutomationRuns(
|
||||
availability: AutomationActionAvailability,
|
||||
authority: AutomationAuthorityRef,
|
||||
automationId: string
|
||||
): Promise<AutomationActionResult<AutomationRun[]>> {
|
||||
return await attempt(
|
||||
availability,
|
||||
(owner) => listAutomationRunsForOwner(owner, automationId),
|
||||
() => listOrphanAutomationRuns(authority, automationId)
|
||||
() =>
|
||||
availability.kind === 'orphan-fenced'
|
||||
? listOrphanAutomationRuns(availability.authority, automationId)
|
||||
: Promise.reject(new Error('automation_owner_uncaptured'))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
/**
|
||||
* One place where a row action decides between its fenced path and its legacy
|
||||
* one, so the page's handlers stay three lines each.
|
||||
*
|
||||
* The order is deliberate. A captured owner always wins. A row the authority
|
||||
* qualified but could not make executable is refused with the reason. Only a
|
||||
* row with no owner metadata at all falls back to the unfenced call, and that
|
||||
* arm exists solely so mixed-version clients keep working until the inference
|
||||
* is removed — it is the legacy path, not a default.
|
||||
* One place where every row action applies its captured owner or fails closed.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -14,7 +7,6 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import {
|
||||
automationActionAvailability,
|
||||
capturedAutomationOwner,
|
||||
@@ -46,7 +38,6 @@ export type AutomationActionNotice = {
|
||||
export type AutomationDispatchContext = {
|
||||
/** Keyed by `AutomationListRow.key`; see {@link AutomationDispatchRow}. */
|
||||
capturedOwners: ReadonlyMap<string, AutomationCapturedOwner>
|
||||
authority: AutomationAuthorityRef
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +60,7 @@ export function dispatched<TValue>(value: TValue): AutomationDispatchResult<TVal
|
||||
}
|
||||
|
||||
export function toDispatchResult<TValue>(
|
||||
outcome: Exclude<AutomationActionResult<TValue>, { status: 'uncaptured' }>
|
||||
outcome: AutomationActionResult<TValue>
|
||||
): AutomationDispatchResult<TValue> {
|
||||
switch (outcome.status) {
|
||||
case 'ok':
|
||||
@@ -92,100 +83,58 @@ async function dispatch<TValue>(
|
||||
action: AutomationRowAction,
|
||||
fenced: (
|
||||
availability: ReturnType<typeof automationActionAvailability>
|
||||
) => Promise<AutomationActionResult<TValue>>,
|
||||
legacy: () => Promise<TValue>
|
||||
) => Promise<AutomationActionResult<TValue>>
|
||||
): Promise<AutomationDispatchResult<TValue>> {
|
||||
const availability = automationActionAvailability(
|
||||
capturedAutomationOwner(context.capturedOwners, row.rowKey),
|
||||
action
|
||||
)
|
||||
const outcome = await fenced(availability)
|
||||
if (outcome.status !== 'uncaptured') {
|
||||
return toDispatchResult(outcome)
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: await legacy() }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
notice: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
recovery: 'retry',
|
||||
severity: 'failure'
|
||||
}
|
||||
}
|
||||
}
|
||||
return toDispatchResult(await fenced(availability))
|
||||
}
|
||||
|
||||
export async function dispatchAutomationUpdate(
|
||||
context: AutomationDispatchContext,
|
||||
row: AutomationDispatchRow,
|
||||
updates: AutomationUpdateInput,
|
||||
legacy: () => Promise<Automation>,
|
||||
action: AutomationRowAction = 'toggle'
|
||||
): Promise<AutomationDispatchResult<Automation>> {
|
||||
return await dispatch(
|
||||
context,
|
||||
row,
|
||||
action,
|
||||
(availability) =>
|
||||
updateOwnedAutomation(availability, context.authority, row.automationId, updates),
|
||||
legacy
|
||||
return await dispatch(context, row, action, (availability) =>
|
||||
updateOwnedAutomation(availability, row.automationId, updates)
|
||||
)
|
||||
}
|
||||
|
||||
export async function dispatchAutomationDelete(
|
||||
context: AutomationDispatchContext,
|
||||
row: AutomationDispatchRow,
|
||||
legacy: () => Promise<void>
|
||||
row: AutomationDispatchRow
|
||||
): Promise<AutomationDispatchResult<void>> {
|
||||
return await dispatch(
|
||||
context,
|
||||
row,
|
||||
'delete',
|
||||
(availability) => deleteOwnedAutomation(availability, context.authority, row.automationId),
|
||||
legacy
|
||||
return await dispatch(context, row, 'delete', (availability) =>
|
||||
deleteOwnedAutomation(availability, row.automationId)
|
||||
)
|
||||
}
|
||||
|
||||
export async function dispatchAutomationRunNow(
|
||||
context: AutomationDispatchContext,
|
||||
row: AutomationDispatchRow,
|
||||
legacy: () => Promise<AutomationRun>
|
||||
row: AutomationDispatchRow
|
||||
): Promise<AutomationDispatchResult<AutomationRun>> {
|
||||
return await dispatch(
|
||||
context,
|
||||
row,
|
||||
'run',
|
||||
(availability) => runOwnedAutomationNow(availability, row.automationId),
|
||||
legacy
|
||||
return await dispatch(context, row, 'run', (availability) =>
|
||||
runOwnedAutomationNow(availability, row.automationId)
|
||||
)
|
||||
}
|
||||
|
||||
export async function dispatchAutomationReread(
|
||||
context: AutomationDispatchContext,
|
||||
row: AutomationDispatchRow,
|
||||
legacy: () => Promise<Automation | null>
|
||||
row: AutomationDispatchRow
|
||||
): Promise<AutomationDispatchResult<Automation | null>> {
|
||||
return await dispatch(
|
||||
context,
|
||||
row,
|
||||
'edit',
|
||||
(availability) => showOwnedAutomation(availability, row.automationId),
|
||||
legacy
|
||||
return await dispatch(context, row, 'edit', (availability) =>
|
||||
showOwnedAutomation(availability, row.automationId)
|
||||
)
|
||||
}
|
||||
|
||||
export async function dispatchAutomationRunHistory(
|
||||
context: AutomationDispatchContext,
|
||||
row: AutomationDispatchRow,
|
||||
legacy: () => Promise<AutomationRun[]>
|
||||
row: AutomationDispatchRow
|
||||
): Promise<AutomationDispatchResult<AutomationRun[]>> {
|
||||
return await dispatch(
|
||||
context,
|
||||
row,
|
||||
'history',
|
||||
(availability) => listOwnedAutomationRuns(availability, context.authority, row.automationId),
|
||||
legacy
|
||||
return await dispatch(context, row, 'history', (availability) =>
|
||||
listOwnedAutomationRuns(availability, row.automationId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { buildAutomationRunContextForRepo } from './automation-run-context'
|
||||
import {
|
||||
buildAutomationRunContextForDestination,
|
||||
buildAutomationRunContextForRepo
|
||||
} from './automation-run-context'
|
||||
import type { AutomationCreateDestination } from './automation-create-destination'
|
||||
|
||||
function repo(id: string, path = `/repos/${id}`): Repo {
|
||||
return {
|
||||
@@ -73,3 +77,39 @@ describe('buildAutomationRunContextForRepo', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAutomationRunContextForDestination', () => {
|
||||
const destination = {
|
||||
authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 },
|
||||
destination: { selector: { kind: 'self' as const } },
|
||||
entry: {} as AutomationCreateDestination['entry']
|
||||
}
|
||||
|
||||
it('does not borrow a same-repo setup from another host', () => {
|
||||
const local = setup({
|
||||
id: 'setup-local',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-builder',
|
||||
path: '/local/orca'
|
||||
})
|
||||
const runtime = setup({
|
||||
id: 'setup-runtime',
|
||||
hostId: 'runtime:gpu',
|
||||
executionHostId: 'runtime:gpu',
|
||||
runtimeOwnerEnvironmentId: 'gpu',
|
||||
repoId: 'repo-builder',
|
||||
path: '/runtime/orca'
|
||||
})
|
||||
expect(
|
||||
buildAutomationRunContextForDestination({
|
||||
repoId: 'repo-builder',
|
||||
destination,
|
||||
projectHostSetups: [local, runtime]
|
||||
})
|
||||
).toMatchObject({
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-runtime',
|
||||
path: '/runtime/orca'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
} from '../../../../shared/task-source-context'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
automationCreateSetupMatchesDestination,
|
||||
type AutomationCreateDestination
|
||||
} from './automation-create-destination'
|
||||
|
||||
export function buildAutomationRunContextForRepo(args: {
|
||||
repoId: string
|
||||
@@ -28,3 +33,30 @@ export function buildAutomationRunContextForRepo(args: {
|
||||
path: setup.path || repo.path
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAutomationRunContextForDestination(args: {
|
||||
repoId: string
|
||||
destination: AutomationCreateDestination
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
worktree?: Worktree | null
|
||||
}): WorkspaceRunContext | null {
|
||||
const candidates = args.projectHostSetups.filter(
|
||||
(candidate) =>
|
||||
candidate.repoId === args.repoId &&
|
||||
candidate.setupState === 'ready' &&
|
||||
automationCreateSetupMatchesDestination(candidate, args.destination)
|
||||
)
|
||||
const setup = args.worktree?.projectHostSetupId
|
||||
? candidates.find((candidate) => candidate.id === args.worktree?.projectHostSetupId)
|
||||
: candidates[0]
|
||||
if (!setup) {
|
||||
return null
|
||||
}
|
||||
return buildWorkspaceRunContext({
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector'
|
||||
|
||||
describe('runtimeAutomationWorkspaceSelector', () => {
|
||||
it('prefixes a stored worktree id the way the CLI does', () => {
|
||||
expect(runtimeAutomationWorkspaceSelector('repo-1::/tmp/orca/feature')).toBe(
|
||||
'id:repo-1::/tmp/orca/feature'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an already-qualified selector alone', () => {
|
||||
expect(runtimeAutomationWorkspaceSelector('id:repo-1::/tmp/orca/feature')).toBe(
|
||||
'id:repo-1::/tmp/orca/feature'
|
||||
)
|
||||
expect(runtimeAutomationWorkspaceSelector('path:/tmp/orca/feature')).toBe(
|
||||
'path:/tmp/orca/feature'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits an empty workspace', () => {
|
||||
expect(runtimeAutomationWorkspaceSelector(undefined)).toBeUndefined()
|
||||
expect(runtimeAutomationWorkspaceSelector(null)).toBeUndefined()
|
||||
expect(runtimeAutomationWorkspaceSelector('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Runtime `automation.create` / `update` resolve `workspace` through
|
||||
* `showManagedWorktree`. The CLI always sends `id:<worktreeId>`; a bare stored
|
||||
* id is a remote-only miss (`selector_not_found`) because local IPC never
|
||||
* parses a selector.
|
||||
*/
|
||||
const WORKTREE_SELECTOR_PREFIX = /^(id|path|name|branch|issue):/
|
||||
|
||||
export function runtimeAutomationWorkspaceSelector(
|
||||
workspaceId: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!workspaceId) {
|
||||
return undefined
|
||||
}
|
||||
return WORKTREE_SELECTOR_PREFIX.test(workspaceId) ? workspaceId : `id:${workspaceId}`
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const SSH_OWNER = {
|
||||
authority: RUNTIME,
|
||||
selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 }
|
||||
} as const
|
||||
const SELF_OWNER = { authority: RUNTIME, selector: { kind: 'self' } } as const
|
||||
|
||||
const ALL_CAPABILITIES = {
|
||||
capabilities: [
|
||||
@@ -135,6 +136,53 @@ describe('listScopedAutomations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAutomationsForOwner', () => {
|
||||
// Rule: never trust a scoped list from a server that ignored the selector.
|
||||
// The Self read degrades to the unscoped list plus a client-side partition.
|
||||
it('falls back to an unscoped Self read on a host without host scoping', async () => {
|
||||
const { listAutomationsForOwner } = await client()
|
||||
getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
|
||||
callRuntimeRpc.mockResolvedValue({
|
||||
automations: [
|
||||
{ id: 'a1', executionTargetType: 'local' },
|
||||
{ id: 'a2', executionTargetType: 'ssh', executionTargetId: 't1' },
|
||||
{ id: 'a3', executionTargetType: 'local', schedulerOwner: 'remote_host_service' }
|
||||
]
|
||||
})
|
||||
const result = await listAutomationsForOwner(SELF_OWNER)
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'automation.list',
|
||||
null,
|
||||
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
|
||||
)
|
||||
expect(result.automations.map((automation) => automation.id)).toEqual(['a1'])
|
||||
expect(result.items).toEqual([{ automationId: 'a1', selector: { kind: 'self' } }])
|
||||
})
|
||||
|
||||
it('keeps failing closed for an SSH owner on a host without host scoping', async () => {
|
||||
const { listAutomationsForOwner, AutomationHostScopeUnsupportedError } = await client()
|
||||
getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
|
||||
await expect(listAutomationsForOwner(SSH_OWNER)).rejects.toBeInstanceOf(
|
||||
AutomationHostScopeUnsupportedError
|
||||
)
|
||||
expect(callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the scoped read on a capable host', async () => {
|
||||
const { listAutomationsForOwner } = await client()
|
||||
getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES)
|
||||
callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 })
|
||||
await listAutomationsForOwner(SELF_OWNER)
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'automation.list',
|
||||
{ selector: { kind: 'self' } },
|
||||
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('owner-fenced mutations', () => {
|
||||
it('sends the captured owner with the mutation', async () => {
|
||||
const { updateAutomationForOwner } = await client()
|
||||
@@ -160,6 +208,104 @@ describe('owner-fenced mutations', () => {
|
||||
expect(callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Self records live on the answering authority and mutate by id under the
|
||||
// pairing-revision fence, so a pre-fencing server stays fully usable for them.
|
||||
it('mutates Self on a runtime without owner fencing instead of failing closed', async () => {
|
||||
const { deleteAutomationForOwner, runAutomationNowForOwner, updateAutomationForOwner } =
|
||||
await client()
|
||||
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' }, run: { id: 'r1' } })
|
||||
await updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false })
|
||||
await deleteAutomationForOwner(SELF_OWNER, 'a1')
|
||||
await runAutomationNowForOwner(SELF_OWNER, 'a1')
|
||||
expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled()
|
||||
expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({
|
||||
id: 'a1',
|
||||
updates: { enabled: false },
|
||||
expectedOwner: { selector: { kind: 'self' } },
|
||||
destination: undefined
|
||||
})
|
||||
expect(callRuntimeRpc.mock.calls.map((call) => call[3])).toEqual(
|
||||
Array.from({ length: 3 }, () =>
|
||||
expect.objectContaining({ expectedEnvironmentPairingRevision: 4 })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// An old server ignores `destination` and would silently leave the record in
|
||||
// place, so a Self move still needs the fenced contract.
|
||||
it('still fails a Self destination move closed on a host without owner fencing', async () => {
|
||||
const { updateAutomationForOwner, AutomationHostScopeUnsupportedError } = await client()
|
||||
getRuntimeEnvironmentStatus.mockResolvedValue({
|
||||
capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY]
|
||||
})
|
||||
await expect(
|
||||
updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false }, { selector: { kind: 'self' } })
|
||||
).rejects.toBeInstanceOf(AutomationHostScopeUnsupportedError)
|
||||
expect(callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates at a Self destination without probing for owner fencing', async () => {
|
||||
const { createAutomationForDestination } = await client()
|
||||
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
|
||||
await createAutomationForDestination(
|
||||
RUNTIME,
|
||||
{ name: 'n', prompt: 'p', projectId: 'repo-1' } as never,
|
||||
{ selector: { kind: 'self' } }
|
||||
)
|
||||
expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled()
|
||||
expect(callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends an existing workspace as an id: selector on the runtime', async () => {
|
||||
const { createAutomationForDestination } = await client()
|
||||
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
|
||||
await createAutomationForDestination(
|
||||
RUNTIME,
|
||||
{
|
||||
name: 'n',
|
||||
prompt: 'p',
|
||||
projectId: 'repo-1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'repo-1::/tmp/orca/feature'
|
||||
} as never,
|
||||
{ selector: { kind: 'self' } }
|
||||
)
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
repo: 'repo-1',
|
||||
workspace: 'id:repo-1::/tmp/orca/feature',
|
||||
destination: { selector: { kind: 'self' } }
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('translates edited project and workspace fields for the runtime RPC', async () => {
|
||||
const { updateAutomationForOwner } = await client()
|
||||
callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } })
|
||||
|
||||
await updateAutomationForOwner(SELF_OWNER, 'a1', {
|
||||
projectId: 'repo-2',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'repo-2::/tmp/orca/other'
|
||||
})
|
||||
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'automation.update',
|
||||
expect.objectContaining({
|
||||
updates: {
|
||||
repo: 'repo-2',
|
||||
workspaceMode: 'existing',
|
||||
workspace: 'id:repo-2::/tmp/orca/other'
|
||||
}
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fences a desktop mutation through IPC with the same precondition', async () => {
|
||||
const { deleteAutomationForOwner, updateAutomationForOwner } = await client()
|
||||
update.mockResolvedValue({ id: 'a1' })
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
* that would attribute other hosts' automations to the selected one.
|
||||
*/
|
||||
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
getRuntimeEnvironmentStatus,
|
||||
type RuntimeClientTarget
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationCreateInput,
|
||||
@@ -25,6 +21,7 @@ import type {
|
||||
AutomationListScopeSelector
|
||||
} from '../../../../shared/automation-list-scope'
|
||||
import { validateAutomationListResponse } from '../../../../shared/automation-list-response'
|
||||
import { partitionLegacyAutomationList } from '../../../../shared/automation-legacy-list-partition'
|
||||
import type {
|
||||
AutomationAuthorityRef,
|
||||
AutomationOwnerRef
|
||||
@@ -37,24 +34,18 @@ import type {
|
||||
} from '../../../../shared/automation-owner-precondition'
|
||||
import {
|
||||
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
|
||||
type RuntimeCapability
|
||||
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
|
||||
import { automationHostDiagnostics } from './automation-host-diagnostics'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
export class AutomationHostScopeUnsupportedError extends Error {
|
||||
readonly code = 'unsupported_host_scope'
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AutomationHostScopeUnsupportedError'
|
||||
}
|
||||
}
|
||||
import {
|
||||
AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS as REQUEST_TIMEOUT_MS,
|
||||
assertAuthorityCapability,
|
||||
assertOwnerFencingSupported,
|
||||
AutomationHostScopeUnsupportedError,
|
||||
requiresOwnerFencing
|
||||
} from './automation-authority-capability'
|
||||
import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector'
|
||||
/** Re-exported so existing call sites keep importing the error from this module. */
|
||||
export { AutomationHostScopeUnsupportedError } from './automation-authority-capability'
|
||||
|
||||
export class AutomationListResponseError extends Error {
|
||||
readonly code = 'invalid_response'
|
||||
@@ -118,41 +109,6 @@ async function callAuthority<TResult>(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed on a missing capability, but only on a *known* absence: an
|
||||
* unreachable authority must classify as unavailable and retry, not as an old
|
||||
* server the user is told to upgrade.
|
||||
*
|
||||
* The probe is counted here because it is counted nowhere else: it deliberately
|
||||
* re-fetches on every call and rides outside the scheduler's four-slot pool, so
|
||||
* an instrument that saw only pooled work would report half the relay traffic a
|
||||
* 50-host refresh actually costs.
|
||||
*/
|
||||
async function assertAuthorityCapability(
|
||||
authority: AutomationAuthorityRef,
|
||||
capability: RuntimeCapability,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
if (authority.kind !== 'runtime') {
|
||||
return
|
||||
}
|
||||
automationHostDiagnostics.recordCapabilityProbe({
|
||||
authorityKey: automationAuthorityCatalogKey(authority)
|
||||
})
|
||||
const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS)
|
||||
if (!status.capabilities?.includes(capability)) {
|
||||
throw new AutomationHostScopeUnsupportedError(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise<void> {
|
||||
await assertAuthorityCapability(
|
||||
authority,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList {
|
||||
const validation = validateAutomationListResponse(raw, selector)
|
||||
if (!validation.ok) {
|
||||
@@ -180,8 +136,9 @@ export async function listScopedAutomations(
|
||||
|
||||
/**
|
||||
* The one unscoped request an old runtime gets per refresh cycle. Its result is
|
||||
* partitioned client-side into every requested entry; it carries no owners and
|
||||
* no usage projection, so those rows stay view-only.
|
||||
* partitioned client-side into every requested entry; it carries no generations
|
||||
* and no usage projection, so its SSH rows stay view-only. The partition may
|
||||
* still assign owners where none are needed on the wire — Self.
|
||||
*/
|
||||
export async function listLegacyAutomations(
|
||||
authority: AutomationAuthorityRef
|
||||
@@ -198,11 +155,51 @@ export async function listLegacyAutomations(
|
||||
return automations as Automation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Self read on a server without host-scoped lists: the unscoped list is asked
|
||||
* for and partitioned client-side, never a scoped request the server would
|
||||
* answer with its whole store. Project evidence lives on the answering
|
||||
* authority, so none is available here and every plausible local record reads
|
||||
* as Self — the same id-keyed trust the legacy server itself applies, and the
|
||||
* caller looks rows up by id.
|
||||
*/
|
||||
async function listLegacySelfAutomations(
|
||||
authority: AutomationAuthorityRef
|
||||
): Promise<ScopedAutomationList> {
|
||||
const automations = await listLegacyAutomations(authority)
|
||||
const partition = partitionLegacyAutomationList(automations, {
|
||||
repoConnectionId: () => null,
|
||||
projectsAuthoritative: false
|
||||
})
|
||||
const selfRows = partition.rows.filter((row) => row.selector.kind === 'self')
|
||||
return {
|
||||
automations: selfRows.map((row) => row.automation),
|
||||
items: selfRows.map((row) => ({
|
||||
automationId: row.automation.id,
|
||||
selector: { kind: 'self' }
|
||||
})),
|
||||
invalidRows: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience wrapper for a row's own host; orphan scopes are requested with the selector form. */
|
||||
export async function listAutomationsForOwner(
|
||||
owner: AutomationOwnerRef
|
||||
): Promise<ScopedAutomationList> {
|
||||
return await listScopedAutomations(owner.authority, scopeSelector(owner))
|
||||
try {
|
||||
return await listScopedAutomations(owner.authority, scopeSelector(owner))
|
||||
} catch (error) {
|
||||
// Only Self may degrade: its records live on the authority the request is
|
||||
// already pinned to. An SSH owner keeps failing closed on an old server.
|
||||
if (
|
||||
owner.authority.kind === 'runtime' &&
|
||||
owner.selector.kind === 'self' &&
|
||||
error instanceof AutomationHostScopeUnsupportedError
|
||||
) {
|
||||
return await listLegacySelfAutomations(owner.authority)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,13 +243,22 @@ async function updateFenced(
|
||||
expectedOwner: AutomationOwnerPrecondition,
|
||||
destination?: AutomationDestination
|
||||
): Promise<Automation> {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
if (requiresOwnerFencing(expectedOwner, destination)) {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
}
|
||||
if (authority.kind === 'desktop') {
|
||||
return await window.api.automations.update({ id, updates, expectedOwner, destination })
|
||||
}
|
||||
const { projectId, workspaceId, ...runtimeUpdates } = updates
|
||||
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.update', {
|
||||
id,
|
||||
updates,
|
||||
updates: {
|
||||
...runtimeUpdates,
|
||||
...('projectId' in updates ? { repo: projectId } : {}),
|
||||
...('workspaceId' in updates
|
||||
? { workspace: runtimeAutomationWorkspaceSelector(workspaceId) }
|
||||
: {})
|
||||
},
|
||||
expectedOwner,
|
||||
destination
|
||||
})
|
||||
@@ -264,7 +270,9 @@ async function deleteFenced(
|
||||
id: string,
|
||||
expectedOwner: AutomationOwnerPrecondition
|
||||
): Promise<void> {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
if (requiresOwnerFencing(expectedOwner)) {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
}
|
||||
if (authority.kind === 'desktop') {
|
||||
await window.api.automations.delete({ id, expectedOwner })
|
||||
return
|
||||
@@ -325,8 +333,10 @@ export async function runAutomationNowForOwner(
|
||||
owner: AutomationOwnerRef,
|
||||
id: string
|
||||
): Promise<AutomationRun> {
|
||||
await assertOwnerFencingSupported(owner.authority)
|
||||
const expectedOwner = ownerPrecondition(owner)
|
||||
if (requiresOwnerFencing(expectedOwner)) {
|
||||
await assertOwnerFencingSupported(owner.authority)
|
||||
}
|
||||
if (owner.authority.kind === 'desktop') {
|
||||
return await window.api.automations.runNow({ id, expectedOwner })
|
||||
}
|
||||
@@ -342,7 +352,11 @@ export async function createAutomationForDestination(
|
||||
input: AutomationCreateInput,
|
||||
destination: AutomationDestination
|
||||
): Promise<Automation> {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
// A Self destination degrades safely on an old server: with the field ignored,
|
||||
// the record still lands on that authority's local store — which is Self.
|
||||
if (destination.selector.kind !== 'self') {
|
||||
await assertOwnerFencingSupported(authority)
|
||||
}
|
||||
if (authority.kind === 'desktop') {
|
||||
return await window.api.automations.create(input, { destination })
|
||||
}
|
||||
@@ -350,7 +364,10 @@ export async function createAutomationForDestination(
|
||||
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
|
||||
...rest,
|
||||
repo: projectId,
|
||||
workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined,
|
||||
workspace:
|
||||
input.workspaceMode === 'existing'
|
||||
? runtimeAutomationWorkspaceSelector(workspaceId)
|
||||
: undefined,
|
||||
destination
|
||||
})
|
||||
return result.automation
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('automation write invalidation', () => {
|
||||
|
||||
it('takes the host from the row owner, not the active authority', () => {
|
||||
const captured: AutomationCapturedOwner = {
|
||||
authority: RUNTIME,
|
||||
owner: {
|
||||
authority: RUNTIME,
|
||||
selector: { kind: 'ssh', targetId: 'target-1', targetGeneration: 3 }
|
||||
@@ -45,18 +46,21 @@ describe('automation write invalidation', () => {
|
||||
|
||||
it('falls back to the captured selector under the acting authority', () => {
|
||||
const captured: AutomationCapturedOwner = {
|
||||
authority: RUNTIME,
|
||||
owner: null,
|
||||
selector: { kind: 'orphan', issue: 'target-missing' }
|
||||
}
|
||||
|
||||
expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({
|
||||
authority: { kind: 'desktop' },
|
||||
authority: { kind: 'runtime', environmentId: 'env-1' },
|
||||
selector: { kind: 'orphan' }
|
||||
})
|
||||
})
|
||||
|
||||
it('names no host for a row that carried no metadata', () => {
|
||||
expect(automationRowCatalogRef({ owner: null, selector: null }, DESKTOP)).toBeNull()
|
||||
expect(
|
||||
automationRowCatalogRef({ authority: null, owner: null, selector: null }, DESKTOP)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('widens to the whole authority rather than guessing a selector', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function automationRowCatalogRef(
|
||||
if (!captured.selector) {
|
||||
return null
|
||||
}
|
||||
const stable = stableAutomationAuthorityRef(authority)
|
||||
const stable = stableAutomationAuthorityRef(captured.authority ?? authority)
|
||||
// Rebuilt per variant rather than spread: the stable ref drops the generation and the
|
||||
// orphan issue, and a widened `kind` would not discriminate.
|
||||
switch (captured.selector.kind) {
|
||||
|
||||
@@ -200,6 +200,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree()
|
||||
const noop = (): void => undefined
|
||||
const fetchWorktrees = async (): Promise<boolean> => true
|
||||
return {
|
||||
state: {
|
||||
repos: [repo],
|
||||
@@ -209,7 +210,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
|
||||
terminalLayoutsByTabId: {},
|
||||
ptyIdsByTabId: {},
|
||||
activeWorktreeId: null,
|
||||
fetchWorktrees: noop,
|
||||
fetchWorktrees,
|
||||
fetchAllWorktrees: noop,
|
||||
startupWorktreeRefreshCompleted: true,
|
||||
updateSettings: noop,
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
|
||||
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
export type ListPanelProps = {
|
||||
filteredAutomations: Automation[]
|
||||
@@ -88,6 +89,7 @@ export type EditorDialogProps = {
|
||||
createDestination?: AutomationCreateDestinationControl
|
||||
notice?: { message: string } | null
|
||||
repos?: { id: string }[]
|
||||
worktrees?: Worktree[]
|
||||
draft?: { projectId: string; workspaceId: string }
|
||||
onSave: () => void
|
||||
onDraftChange: (updater: (current: unknown) => unknown) => void
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
|
||||
describe('AUTOMATIONS_TABLE_GRID_CLASS', () => {
|
||||
it('shares leftover width across text columns instead of capping Project', () => {
|
||||
expect(AUTOMATIONS_TABLE_GRID_CLASS).toContain('min-w-[58rem]')
|
||||
expect(AUTOMATIONS_TABLE_GRID_CLASS).toMatch(/minmax\(8rem,1\.2fr\).*minmax\(6\.5rem,0\.9fr\)/)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Column template for the automations list table; shared chrome lives in @/lib/list-table-layout. */
|
||||
// Name | Schedule | Project | Next run | Last run | Status | Agent | Actions
|
||||
// Name | Schedule | Project | Host | Next run | Last run | Status | Agent | Actions
|
||||
// fr maxes share leftover width; rem maxes dumped it into Name and truncated Project.
|
||||
export const AUTOMATIONS_TABLE_GRID_CLASS =
|
||||
'grid grid-cols-[minmax(0,1.4fr)_minmax(7.5rem,10rem)_minmax(4.5rem,8rem)_minmax(8.5rem,1fr)_minmax(8rem,11rem)_minmax(4.5rem,6rem)_2.5rem_2.5rem]'
|
||||
'grid w-full min-w-[58rem] grid-cols-[minmax(8rem,1.2fr)_minmax(7.5rem,0.95fr)_minmax(6.5rem,0.9fr)_minmax(5rem,0.7fr)_minmax(7rem,0.85fr)_minmax(6.5rem,0.8fr)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]'
|
||||
|
||||
@@ -10,15 +10,14 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type {
|
||||
AutomationHostCatalog,
|
||||
AutomationHostCatalogEntry
|
||||
} from './automation-host-catalog-types'
|
||||
import type { AutomationAuthorityRepoTables } from './automation-authority-identity'
|
||||
import {
|
||||
automationCreateEligibleProjects,
|
||||
automationCreateProjectMismatch,
|
||||
preselectAutomationCreateHost,
|
||||
resolveAutomationCreateDestination,
|
||||
revalidateAutomationCreateDestination,
|
||||
@@ -49,8 +48,8 @@ export type AutomationCreateDestinationInput = {
|
||||
/** Non-null only when the list is filtered to one concrete host. */
|
||||
filterStableKey: string | null
|
||||
activeWorkspaceStableKey: string | null
|
||||
repoTables: AutomationAuthorityRepoTables
|
||||
projects: readonly Repo[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
}
|
||||
|
||||
export type AutomationCreateDestinationState = {
|
||||
@@ -90,16 +89,20 @@ export function useAutomationCreateDestination(
|
||||
entries,
|
||||
filterStableKey,
|
||||
activeWorkspaceStableKey,
|
||||
repoTables,
|
||||
projects
|
||||
projects,
|
||||
projectHostSetups
|
||||
} = input
|
||||
const [captured, setCaptured] = useState<AutomationCreateDestination | null>(null)
|
||||
const [reason, setReason] = useState<AutomationCreateDestinationChoiceReason>('unselected')
|
||||
const [previousOpen, setPreviousOpen] = useState(open)
|
||||
if (previousOpen !== open) {
|
||||
setPreviousOpen(open)
|
||||
setCaptured(null)
|
||||
setReason('unselected')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCaptured(null)
|
||||
setReason('unselected')
|
||||
return
|
||||
}
|
||||
if (captured) {
|
||||
@@ -141,8 +144,10 @@ export function useAutomationCreateDestination(
|
||||
// when the handler was created could be an incarnation old.
|
||||
const entriesRef = useRef(entries)
|
||||
entriesRef.current = entries
|
||||
const repoTablesRef = useRef(repoTables)
|
||||
repoTablesRef.current = repoTables
|
||||
const projectsRef = useRef(projects)
|
||||
projectsRef.current = projects
|
||||
const projectHostSetupsRef = useRef(projectHostSetups)
|
||||
projectHostSetupsRef.current = projectHostSetups
|
||||
|
||||
const check = useCallback(
|
||||
(projectId: string): AutomationCreateDestinationCheck => {
|
||||
@@ -166,7 +171,12 @@ export function useAutomationCreateDestination(
|
||||
if (revalidated.status !== 'ready') {
|
||||
return { ok: false, notice: ownerNotice(choiceMessage(revalidated.reason)) }
|
||||
}
|
||||
if (automationCreateProjectMismatch(repoTablesRef.current, revalidated, projectId)) {
|
||||
const project = automationCreateEligibleProjects(
|
||||
revalidated,
|
||||
projectsRef.current,
|
||||
projectHostSetupsRef.current
|
||||
).find((candidate) => candidate.id === projectId)
|
||||
if (!project) {
|
||||
return {
|
||||
ok: false,
|
||||
notice: ownerNotice(
|
||||
@@ -185,9 +195,9 @@ export function useAutomationCreateDestination(
|
||||
const eligibleProjects = useMemo(
|
||||
() =>
|
||||
resolution.status === 'ready'
|
||||
? automationCreateEligibleProjects(repoTables, resolution, projects)
|
||||
? automationCreateEligibleProjects(resolution, projects, projectHostSetups)
|
||||
: projects,
|
||||
[projects, repoTables, resolution]
|
||||
[projectHostSetups, projects, resolution]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,9 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {} as Record<string, unknown>,
|
||||
callRuntimeRpc: vi.fn(),
|
||||
getRuntimeEnvironmentStatus: vi.fn()
|
||||
getRuntimeEnvironmentStatus: vi.fn(),
|
||||
connectAutomationHostSshTarget: vi.fn(),
|
||||
connectAutomationHostRuntime: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
@@ -37,6 +39,12 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
hasRuntimeRpcErrorCode: () => false
|
||||
}))
|
||||
|
||||
vi.mock('./automation-host-recovery-connect', () => ({
|
||||
connectAutomationHostSshTarget: (...args: unknown[]) =>
|
||||
mocks.connectAutomationHostSshTarget(...args),
|
||||
connectAutomationHostRuntime: (...args: unknown[]) => mocks.connectAutomationHostRuntime(...args)
|
||||
}))
|
||||
|
||||
import { useAutomationHostCatalog } from './use-automation-host-catalog'
|
||||
|
||||
const ENVIRONMENT_ID = 'env-1'
|
||||
@@ -140,6 +148,10 @@ beforeEach(() => {
|
||||
view = null
|
||||
mocks.callRuntimeRpc.mockReset()
|
||||
mocks.getRuntimeEnvironmentStatus.mockReset()
|
||||
mocks.connectAutomationHostSshTarget.mockReset()
|
||||
mocks.connectAutomationHostRuntime.mockReset()
|
||||
mocks.connectAutomationHostSshTarget.mockResolvedValue(undefined)
|
||||
mocks.connectAutomationHostRuntime.mockResolvedValue(undefined)
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ automations: [legacyAutomation()] })
|
||||
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] })
|
||||
Object.assign(window, {
|
||||
@@ -197,3 +209,61 @@ describe('useAutomationHostCatalog legacy partitioning', () => {
|
||||
expect(runtimeSelfAutomationIds()).toEqual(['a-legacy'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAutomationHostCatalog recover', () => {
|
||||
it('reconnects an unreachable runtime through the status-recording helper', async () => {
|
||||
await renderCatalog([])
|
||||
const runtimeSelf = view?.catalog.entries.find(
|
||||
(entry) =>
|
||||
entry.stableRef.authority.kind === 'runtime' && entry.stableRef.selector.kind === 'self'
|
||||
)
|
||||
expect(runtimeSelf).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
await view?.recover('reconnect', {
|
||||
...runtimeSelf!,
|
||||
authorityHealth: 'unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
expect(mocks.connectAutomationHostRuntime).toHaveBeenCalledWith(ENVIRONMENT_ID)
|
||||
expect(mocks.connectAutomationHostSshTarget).not.toHaveBeenCalled()
|
||||
expect(window.api.runtimeEnvironments.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconnects a desktop SSH target through the SSH helper, not a raw ssh.connect', async () => {
|
||||
mocks.state = {
|
||||
...storeState([]),
|
||||
sshTargetLabels: new Map([['t1', 'Linux']]),
|
||||
sshTargetGenerations: new Map([['t1', 1]]),
|
||||
sshConnectionStates: new Map([['t1', { status: 'disconnected' }]])
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(<Harness />)
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const sshEntry = view?.catalog.entries.find(
|
||||
(entry) =>
|
||||
entry.stableRef.selector.kind === 'ssh' && entry.stableRef.selector.targetId === 't1'
|
||||
)
|
||||
expect(sshEntry).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
await view?.recover('reconnect', sshEntry)
|
||||
})
|
||||
|
||||
expect(mocks.connectAutomationHostSshTarget).toHaveBeenCalledWith({
|
||||
targetId: 't1',
|
||||
environmentId: undefined
|
||||
})
|
||||
expect(window.api.ssh.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
runAutomationHostRecovery,
|
||||
type AutomationHostRecoveryDeps
|
||||
} from './automation-host-recovery'
|
||||
import {
|
||||
connectAutomationHostRuntime,
|
||||
connectAutomationHostSshTarget
|
||||
} from './automation-host-recovery-connect'
|
||||
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
|
||||
import {
|
||||
automationAuthorityPartitionContext,
|
||||
@@ -61,7 +65,10 @@ export type AutomationHostCatalogView = {
|
||||
loadCounts: AutomationHostLoadCounts
|
||||
selectHost: (filter: AutomationHostFilter) => void
|
||||
/** Runs the recovery verb the notice or empty state offered for a host. */
|
||||
recover: (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => void
|
||||
recover: (
|
||||
action: AutomationHostRecoveryAction,
|
||||
entry?: AutomationHostCatalogEntry | null
|
||||
) => Promise<void>
|
||||
/** Manual refresh of every host in view, bypassing TTL where reachable. */
|
||||
refreshHosts: () => void
|
||||
/**
|
||||
@@ -217,12 +224,9 @@ export function useAutomationHostCatalog(
|
||||
retry: (entry) => {
|
||||
void controller.scheduler.retry(automationHostFetchTarget(entry, pairingRevision))
|
||||
},
|
||||
connectSshTarget: (targetId) => {
|
||||
void window.api.ssh.connect({ targetId })
|
||||
},
|
||||
connectRuntimeEnvironment: (environmentId) => {
|
||||
void window.api.runtimeEnvironments.connect({ selector: environmentId })
|
||||
},
|
||||
connectSshTarget: (targetId, environmentId) =>
|
||||
connectAutomationHostSshTarget({ targetId, environmentId }),
|
||||
connectRuntimeEnvironment: (environmentId) => connectAutomationHostRuntime(environmentId),
|
||||
openSettings: (target) => {
|
||||
openSettingsPage()
|
||||
openSettingsTarget(target)
|
||||
@@ -232,9 +236,8 @@ export function useAutomationHostCatalog(
|
||||
)
|
||||
|
||||
const recover = useCallback(
|
||||
(action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => {
|
||||
runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps)
|
||||
},
|
||||
(action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) =>
|
||||
runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps),
|
||||
[recoveryDeps, resolution.entry]
|
||||
)
|
||||
const refreshHosts = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import {
|
||||
getAutomationListArrowNavigationTarget,
|
||||
type AutomationListArrowKey
|
||||
} from './automation-list-keyboard-navigation'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { AutomationPaneTab } from './automation-page-state'
|
||||
|
||||
type KeyboardNavigationInput = {
|
||||
filteredRows: readonly AutomationListRow[]
|
||||
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
|
||||
selectedRowKey: string | null
|
||||
selectedExternalKey: string | null
|
||||
selectAutomationRow: (rowKey: string | null) => void
|
||||
selectExternalKey: (externalKey: string | null) => void
|
||||
setActivePaneTab: (tab: AutomationPaneTab) => void
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useAutomationListKeyboardNavigation({
|
||||
filteredRows,
|
||||
filteredExternalAutomationEntries,
|
||||
selectedRowKey,
|
||||
selectedExternalKey,
|
||||
selectAutomationRow,
|
||||
selectExternalKey,
|
||||
setActivePaneTab,
|
||||
listRef
|
||||
}: KeyboardNavigationInput): (key: AutomationListArrowKey) => void {
|
||||
const pendingScrollRef = React.useRef(false)
|
||||
const visibleItems = React.useMemo(
|
||||
() => [
|
||||
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
|
||||
...filteredExternalAutomationEntries.map((entry) => ({
|
||||
kind: 'external' as const,
|
||||
id: entry.key
|
||||
}))
|
||||
],
|
||||
[filteredExternalAutomationEntries, filteredRows]
|
||||
)
|
||||
const scrollCurrentIntoView = React.useCallback(() => {
|
||||
listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [listRef])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingScrollRef.current) {
|
||||
return
|
||||
}
|
||||
pendingScrollRef.current = false
|
||||
scrollCurrentIntoView()
|
||||
}, [scrollCurrentIntoView, selectedExternalKey, selectedRowKey])
|
||||
|
||||
return React.useCallback(
|
||||
(key) => {
|
||||
const next = getAutomationListArrowNavigationTarget({
|
||||
items: visibleItems,
|
||||
selectedId: selectedRowKey,
|
||||
selectedExternalKey,
|
||||
key
|
||||
})
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
const selected =
|
||||
next.kind === 'local'
|
||||
? selectedExternalKey === null && selectedRowKey === next.id
|
||||
: selectedExternalKey === next.id
|
||||
if (selected) {
|
||||
scrollCurrentIntoView()
|
||||
return
|
||||
}
|
||||
pendingScrollRef.current = true
|
||||
if (next.kind === 'local') {
|
||||
selectExternalKey(null)
|
||||
selectAutomationRow(next.id)
|
||||
return
|
||||
}
|
||||
selectAutomationRow(null)
|
||||
selectExternalKey(next.id)
|
||||
setActivePaneTab('overview')
|
||||
},
|
||||
[
|
||||
scrollCurrentIntoView,
|
||||
selectAutomationRow,
|
||||
selectExternalKey,
|
||||
selectedExternalKey,
|
||||
selectedRowKey,
|
||||
setActivePaneTab,
|
||||
visibleItems
|
||||
]
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -46,8 +46,7 @@ function makeInput(
|
||||
): SelectedAutomationRunHistoryInput {
|
||||
return {
|
||||
selected: DESKTOP_ROW,
|
||||
context: { capturedOwners: new Map(), authority: { kind: 'desktop' } },
|
||||
legacyTarget: () => ({ kind: 'local' }),
|
||||
context: { capturedOwners: new Map() },
|
||||
navigation: null,
|
||||
reloadToken: 0,
|
||||
onSettled: vi.fn(),
|
||||
@@ -168,8 +167,9 @@ describe('useSelectedAutomationRunHistory', () => {
|
||||
}
|
||||
const input = makeInput({
|
||||
context: {
|
||||
capturedOwners: new Map([[DESKTOP_ROW.key, { owner, selector: { kind: 'self' } }]]),
|
||||
authority: { kind: 'desktop' }
|
||||
capturedOwners: new Map([
|
||||
[DESKTOP_ROW.key, { authority: owner.authority, owner, selector: { kind: 'self' } }]
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
|
||||
import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import {
|
||||
@@ -17,15 +19,11 @@ import {
|
||||
type AutomationDispatchContext,
|
||||
dispatchAutomationRunHistory
|
||||
} from './automation-row-action-dispatch'
|
||||
import {
|
||||
getAutomationTargetFromHostId,
|
||||
listAutomationRunsForTarget,
|
||||
type AutomationHostTarget
|
||||
} from './automation-host-client'
|
||||
|
||||
export type SelectedAutomationRunNavigation = {
|
||||
automationId: string
|
||||
hostId?: string | null
|
||||
authority?: StableAutomationAuthorityRef
|
||||
} | null
|
||||
|
||||
export type SelectedAutomationRunHistoryOutcome = {
|
||||
@@ -45,8 +43,6 @@ export type SelectedAutomationRunHistoryOutcome = {
|
||||
export type SelectedAutomationRunHistoryInput = {
|
||||
selected: AutomationListRow | null
|
||||
context: AutomationDispatchContext
|
||||
/** The row's own host, used only when no owner was captured for it. */
|
||||
legacyTarget: (row: AutomationListRow) => AutomationHostTarget | null
|
||||
navigation: SelectedAutomationRunNavigation
|
||||
/** Changing this re-asks the host; it is how a failed read offers Retry. */
|
||||
reloadToken: number
|
||||
@@ -60,6 +56,13 @@ function navigationHostId(
|
||||
return navigation?.automationId === automationId ? (navigation.hostId ?? null) : null
|
||||
}
|
||||
|
||||
function navigationAuthority(
|
||||
navigation: SelectedAutomationRunNavigation,
|
||||
automationId: string
|
||||
): StableAutomationAuthorityRef | null {
|
||||
return navigation?.automationId === automationId ? (navigation.authority ?? null) : null
|
||||
}
|
||||
|
||||
export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void {
|
||||
const inputRef = useRef(input)
|
||||
inputRef.current = input
|
||||
@@ -67,17 +70,23 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
|
||||
const automationId = input.selected?.automation.id ?? null
|
||||
const rowKey = input.selected?.key ?? null
|
||||
const reloadToken = input.reloadToken
|
||||
const selectedNavigationAuthority = automationId
|
||||
? navigationAuthority(input.navigation, automationId)
|
||||
: null
|
||||
const fetchKey =
|
||||
automationId && rowKey
|
||||
? [
|
||||
rowKey,
|
||||
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
|
||||
selectedNavigationAuthority
|
||||
? automationAuthorityCatalogKey(selectedNavigationAuthority)
|
||||
: '',
|
||||
navigationHostId(input.navigation, automationId) ?? ''
|
||||
].join('|')
|
||||
: ''
|
||||
|
||||
useEffect(() => {
|
||||
const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current
|
||||
const { selected, context, onSettled } = inputRef.current
|
||||
if (!selected || !automationId || !rowKey) {
|
||||
onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null })
|
||||
return
|
||||
@@ -86,13 +95,7 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
|
||||
const owner = capturedAutomationOwnerKey(
|
||||
capturedAutomationOwner(context.capturedOwners, rowKey)
|
||||
)
|
||||
const navigationHost = navigationHostId(navigation, automationId)
|
||||
const target = navigationHost
|
||||
? getAutomationTargetFromHostId(navigationHost)
|
||||
: (legacyTarget(selected) ?? { kind: 'local' })
|
||||
void dispatchAutomationRunHistory(context, { rowKey, automationId }, () =>
|
||||
listAutomationRunsForTarget(target, automationId)
|
||||
).then((result) => {
|
||||
void dispatchAutomationRunHistory(context, { rowKey, automationId }).then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
@@ -107,6 +110,5 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- fetchKey is the refetch policy; see the module docblock for why the context object is deliberately not a dependency.
|
||||
}, [automationId, rowKey, fetchKey, reloadToken])
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import type * as AutomationHostClient from '@/components/automations/automation-host-client'
|
||||
import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection'
|
||||
|
||||
@@ -50,7 +51,12 @@ async function render(
|
||||
handlers: {
|
||||
onOpenAutomation?: (event: React.MouseEvent) => void
|
||||
onOpenAutomationRun?: (event: React.MouseEvent) => void
|
||||
} = {}
|
||||
} = {},
|
||||
authority: StableAutomationAuthorityRef | null = record.hostId
|
||||
? record.hostId.startsWith('runtime:')
|
||||
? { kind: 'runtime', environmentId: record.hostId.slice('runtime:'.length) }
|
||||
: { kind: 'desktop' }
|
||||
: null
|
||||
): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
@@ -59,7 +65,11 @@ async function render(
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<WorktreeCardAutomationDetailSection provenance={record} {...handlers} />
|
||||
<WorktreeCardAutomationDetailSection
|
||||
provenance={record}
|
||||
authority={authority}
|
||||
{...handlers}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
@@ -93,6 +103,25 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('asks the runtime authority for a runtime-owned SSH workspace', async () => {
|
||||
mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }])
|
||||
mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }])
|
||||
|
||||
await render(
|
||||
provenance({ hostId: 'ssh:builder' }),
|
||||
{},
|
||||
{
|
||||
kind: 'runtime',
|
||||
environmentId: 'gpu'
|
||||
}
|
||||
)
|
||||
|
||||
expect(mocks.listAutomationsForTarget).toHaveBeenCalledWith({
|
||||
kind: 'environment',
|
||||
environmentId: 'gpu'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a miss as uncheckable when no host was ever recorded', async () => {
|
||||
const container = await render(provenance())
|
||||
|
||||
@@ -107,20 +136,4 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => {
|
||||
|
||||
expect(container.textContent).toContain('Automation no longer available.')
|
||||
})
|
||||
|
||||
it('offers both affordances when a record with no recorded host is found anyway', async () => {
|
||||
// The missing host degrades the *miss*, never the find: an automation the
|
||||
// desktop positively returned is evidence, whatever the read was aimed by.
|
||||
mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }])
|
||||
mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }])
|
||||
|
||||
const container = await render(provenance(), {
|
||||
onOpenAutomation: vi.fn(),
|
||||
onOpenAutomationRun: vi.fn()
|
||||
})
|
||||
|
||||
expect(container.querySelector('[aria-label="Open automation"]')).not.toBeNull()
|
||||
expect(container.querySelector('[aria-label="Open run"]')).not.toBeNull()
|
||||
expect(container.textContent).not.toContain('could not be checked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { CalendarClock, PlayCircle } from 'lucide-react'
|
||||
import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import {
|
||||
WorktreeCardDetailSection,
|
||||
WorktreeCardDetailSectionContent
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getAutomationTargetFromHostId,
|
||||
getAutomationTargetFromAuthority,
|
||||
listAutomationRunsForTarget,
|
||||
listAutomationsForTarget
|
||||
} from '@/components/automations/automation-host-client'
|
||||
|
||||
type WorktreeCardAutomationDetailSectionProps = {
|
||||
provenance: AutomationWorkspaceProvenance
|
||||
authority: StableAutomationAuthorityRef | null
|
||||
onOpenAutomation?: (event: React.MouseEvent) => void
|
||||
onOpenAutomationRun?: (event: React.MouseEvent) => void
|
||||
}
|
||||
@@ -27,29 +29,36 @@ type AutomationProvenanceAvailability =
|
||||
|
||||
export function WorktreeCardAutomationDetailSection({
|
||||
provenance,
|
||||
authority,
|
||||
onOpenAutomation,
|
||||
onOpenAutomationRun
|
||||
}: WorktreeCardAutomationDetailSectionProps): React.JSX.Element {
|
||||
const [availability, setAvailability] = React.useState<AutomationProvenanceAvailability>({
|
||||
status: 'checking'
|
||||
})
|
||||
const authorityKind = authority?.kind ?? null
|
||||
const authorityEnvironmentId = authority?.kind === 'runtime' ? authority.environmentId : null
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function resolveAvailability(): Promise<void> {
|
||||
setAvailability({ status: 'checking' })
|
||||
try {
|
||||
// Why not the workspace's host: the automation's host is recorded on the
|
||||
// provenance, and an automation can land a workspace somewhere else
|
||||
// entirely — substituting one for the other asks the wrong machine.
|
||||
const target = getAutomationTargetFromHostId(provenance.hostId)
|
||||
// The caller resolves portable provenance against this client's runtime owner.
|
||||
if (!authorityKind) {
|
||||
setAvailability({ status: 'unavailable' })
|
||||
return
|
||||
}
|
||||
const target = getAutomationTargetFromAuthority(
|
||||
authorityKind === 'runtime'
|
||||
? { kind: 'runtime', environmentId: authorityEnvironmentId! }
|
||||
: { kind: 'desktop' }
|
||||
)
|
||||
const automations = await listAutomationsForTarget(target)
|
||||
const automation = automations.find((entry) => entry.id === provenance.automationId)
|
||||
if (!automation) {
|
||||
if (!cancelled) {
|
||||
// With no recorded host this read went to the desktop on no evidence,
|
||||
// so a miss here is "could not check", never "removed".
|
||||
setAvailability({ status: provenance.hostId ? 'automation-missing' : 'unavailable' })
|
||||
setAvailability({ status: 'automation-missing' })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -71,7 +80,7 @@ export function WorktreeCardAutomationDetailSection({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [provenance.automationId, provenance.automationRunId, provenance.hostId])
|
||||
}, [authorityEnvironmentId, authorityKind, provenance.automationId, provenance.automationRunId])
|
||||
|
||||
const canOpenAutomation = availability.status === 'available'
|
||||
const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable
|
||||
|
||||
@@ -56,6 +56,7 @@ export function WorktreeCardDetailsHover({
|
||||
review,
|
||||
comment,
|
||||
automationProvenance,
|
||||
automationAuthority,
|
||||
cliProvenance,
|
||||
children,
|
||||
branchName,
|
||||
@@ -313,6 +314,7 @@ export function WorktreeCardDetailsHover({
|
||||
{automationProvenance && (
|
||||
<WorktreeCardAutomationDetailSection
|
||||
provenance={automationProvenance}
|
||||
authority={automationAuthority ?? null}
|
||||
onOpenAutomation={onOpenAutomation ? dismissAndRun(onOpenAutomation) : undefined}
|
||||
onOpenAutomationRun={
|
||||
onOpenAutomationRun ? dismissAndRun(onOpenAutomationRun) : undefined
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
|
||||
import { automationWorkspaceStorageCatalogRef } from '../../../../shared/automation-workspace-provenance'
|
||||
import {
|
||||
isRuntimeOwnedSshTargetId,
|
||||
parseExecutionHostId,
|
||||
@@ -79,14 +80,19 @@ export function useWorktreeCardFoundation({
|
||||
const handleOpenAutomation = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const automationId = worktree.automationProvenance?.automationId
|
||||
if (!automationId) {
|
||||
const provenance = worktree.automationProvenance
|
||||
if (!provenance) {
|
||||
return
|
||||
}
|
||||
const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId
|
||||
const hostId = provenance.hostId ?? worktree.hostId
|
||||
const catalogRef = automationWorkspaceStorageCatalogRef(
|
||||
provenance,
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
)
|
||||
setPendingAutomationRunNavigation({
|
||||
automationId,
|
||||
automationId: provenance.automationId,
|
||||
runId: null,
|
||||
...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}),
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
openAutomationsPage()
|
||||
@@ -94,9 +100,9 @@ export function useWorktreeCardFoundation({
|
||||
[
|
||||
openAutomationsPage,
|
||||
setPendingAutomationRunNavigation,
|
||||
worktree.automationProvenance?.automationId,
|
||||
worktree.automationProvenance?.hostId,
|
||||
worktree.hostId
|
||||
worktree.automationProvenance,
|
||||
worktree.hostId,
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
]
|
||||
)
|
||||
|
||||
@@ -108,9 +114,14 @@ export function useWorktreeCardFoundation({
|
||||
return
|
||||
}
|
||||
const hostId = provenance.hostId ?? worktree.hostId
|
||||
const catalogRef = automationWorkspaceStorageCatalogRef(
|
||||
provenance,
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
)
|
||||
setPendingAutomationRunNavigation({
|
||||
automationId: provenance.automationId,
|
||||
runId: provenance.automationRunId,
|
||||
...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}),
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
openAutomationsPage()
|
||||
@@ -119,7 +130,8 @@ export function useWorktreeCardFoundation({
|
||||
openAutomationsPage,
|
||||
setPendingAutomationRunNavigation,
|
||||
worktree.automationProvenance,
|
||||
worktree.hostId
|
||||
worktree.hostId,
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import { automationWorkspaceStorageAuthority } from '../../../../shared/automation-workspace-provenance'
|
||||
import { hasWorktreeCardDetails } from './WorktreeCardMeta'
|
||||
import { usePromptCacheCountdownStartedAt } from './CacheTimer'
|
||||
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
|
||||
@@ -83,6 +84,12 @@ export function useWorktreeCardSecondaryDetails({
|
||||
const metaJiraIssue = showJiraIssue ? hoverJiraIssue : null
|
||||
const metaReview = showPR ? hoverReview : null
|
||||
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
|
||||
const automationAuthority = metaAutomationProvenance
|
||||
? automationWorkspaceStorageAuthority(
|
||||
metaAutomationProvenance,
|
||||
worktree.runtimeOwnerEnvironmentId
|
||||
)
|
||||
: null
|
||||
const metaCliProvenance = showCli ? worktree.cliProvenance : null
|
||||
const metaComment = showComment ? hoverComment : null
|
||||
const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
|
||||
@@ -208,6 +215,7 @@ export function useWorktreeCardSecondaryDetails({
|
||||
metaJiraIssue,
|
||||
metaReview,
|
||||
metaAutomationProvenance,
|
||||
automationAuthority,
|
||||
metaCliProvenance,
|
||||
metaComment,
|
||||
showInlineAgentList,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CliWorkspaceProvenance
|
||||
} from '../../../../shared/worktree/types'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
|
||||
|
||||
export type WorktreeCardIssueDisplay =
|
||||
@@ -37,6 +38,7 @@ export type WorktreeCardMetaBadgesProps = {
|
||||
review: WorktreeCardPrDisplay | null
|
||||
comment: string | null
|
||||
automationProvenance?: AutomationWorkspaceProvenance | null
|
||||
automationAuthority?: StableAutomationAuthorityRef | null
|
||||
cliProvenance?: CliWorkspaceProvenance | null
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
|
||||
metaReview,
|
||||
metaComment,
|
||||
metaAutomationProvenance,
|
||||
automationAuthority,
|
||||
metaCliProvenance,
|
||||
hoverIssue,
|
||||
hoverLinearIssue,
|
||||
@@ -152,6 +153,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
|
||||
review={metaReview}
|
||||
comment={metaComment}
|
||||
automationProvenance={metaAutomationProvenance}
|
||||
automationAuthority={automationAuthority}
|
||||
cliProvenance={metaCliProvenance}
|
||||
branchName={showBranchIdentityHover ? branch : undefined}
|
||||
workspaceTitle={worktree.displayName}
|
||||
@@ -223,6 +225,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
|
||||
review={metaReview}
|
||||
comment={metaComment}
|
||||
automationProvenance={metaAutomationProvenance}
|
||||
automationAuthority={automationAuthority}
|
||||
cliProvenance={metaCliProvenance}
|
||||
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
|
||||
hoverControl={detailsHoverControl}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -13822,7 +13822,11 @@
|
||||
"tableStatus": "ステータス",
|
||||
"tableActions": "操作",
|
||||
"newAutomation": "新しい自動化",
|
||||
"rowActions": "自動化の操作"
|
||||
"rowActions": "自動化の操作",
|
||||
"tableLastRun": "前回の実行",
|
||||
"noListMatches": "一致する自動化はありません。",
|
||||
"tableHost": "ホスト",
|
||||
"refreshAutomations": "自動化を更新"
|
||||
},
|
||||
"CreateFromPicker": {
|
||||
"f061f49e3f": "リポジトリブランチを検索…",
|
||||
|
||||
@@ -13851,7 +13851,11 @@
|
||||
"tableStatus": "상태",
|
||||
"tableActions": "작업",
|
||||
"newAutomation": "새 자동화",
|
||||
"rowActions": "자동화 작업"
|
||||
"rowActions": "자동화 작업",
|
||||
"tableLastRun": "마지막 실행",
|
||||
"noListMatches": "일치하는 자동화가 없습니다.",
|
||||
"tableHost": "호스트",
|
||||
"refreshAutomations": "자동화 새로고침"
|
||||
},
|
||||
"CreateFromPicker": {
|
||||
"f061f49e3f": "repo 브랜치 검색...",
|
||||
|
||||
@@ -13842,7 +13842,11 @@
|
||||
"tableStatus": "状态",
|
||||
"tableActions": "操作",
|
||||
"newAutomation": "新建自动化",
|
||||
"rowActions": "自动化操作"
|
||||
"rowActions": "自动化操作",
|
||||
"tableLastRun": "上次运行",
|
||||
"noListMatches": "没有匹配的自动化。",
|
||||
"tableHost": "主机",
|
||||
"refreshAutomations": "刷新自动化"
|
||||
},
|
||||
"CreateFromPicker": {
|
||||
"f061f49e3f": "搜索仓库分支...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user