diff --git a/automations-page.yaml b/automations-page.yaml new file mode 100644 index 00000000000..ab756f65eab --- /dev/null +++ b/automations-page.yaml @@ -0,0 +1,342 @@ +- generic [ref=e1]: + - generic [ref=e3]: + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e9]: + - generic [ref=e10]: + - generic "Orca" [ref=e11] [cursor=pointer] + - button "切换侧边栏" [ref=e13] [cursor=pointer] + - generic [ref=e16]: + - button "返回" [ref=e17] [cursor=pointer] + - button "前进" [disabled] [ref=e20] + - generic [ref=e24]: + - generic [ref=e25]: + - button "任务" [ref=e26] [cursor=pointer] + - button "自动化" [ref=e29] [cursor=pointer] + - button "Orca 手机端" [ref=e36] [cursor=pointer] + - button "搜索工作树和浏览器选项卡" [ref=e40] [cursor=pointer]: + - generic [ref=e41]: 搜索 + - generic [ref=e42]: + - generic [ref=e43]: Projects + - generic [ref=e45]: + - button "工作区选项" [ref=e46] [cursor=pointer] + - button "Add Project" [ref=e47] [cursor=pointer] + - button "New workspace" [ref=e48] [cursor=pointer] + - listbox "工作树" [ref=e50]: + - button "personal personal 的分组操作" [expanded] [ref=e51] [cursor=pointer]: + - generic [ref=e52]: personal + - generic: + - button "personal 的分组操作" + - button "AutoGenie AutoGenie 的项目操作 为 AutoGenie 创建新工作树" [expanded] [ref=e62] [cursor=pointer]: + - generic [ref=e63]: AutoGenie + - generic: + - button "AutoGenie 的项目操作" + - button "为 AutoGenie 创建新工作树" + - option "分支 · Unread 未读: main 主要 工作区元数据 Local Mac" [ref=e71]: + - generic [ref=e72]: + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: 分支 · Unread + - generic [ref=e84]: + - generic [ref=e86]: + - generic [ref=e87]: + - generic [ref=e88]: 未读: + - text: main + - generic [ref=e89]: 主要 + - generic "工作区元数据" [ref=e92]: 工作区笔记 + - generic [ref=e98]: Local Mac + - button + - button "demo-generation demo-generation 的项目操作 为 demo-generation 创建新工作树" [expanded] [ref=e102]: + - generic [ref=e103]: demo-generation + - generic: + - button "demo-generation 的项目操作" + - button "为 demo-generation 创建新工作树" + - option "分支 main 主要 Local Mac" [ref=e111]: + - generic [ref=e112]: + - generic [ref=e115] [cursor=pointer]: + - generic [ref=e116]: 分支 + - generic [ref=e123]: + - generic [ref=e125]: + - generic [ref=e126]: main + - generic [ref=e127]: 主要 + - generic [ref=e128]: Local Mac + - button + - option "分支 · Unread 未读: demo Local Mac" [ref=e132]: + - generic [ref=e133]: + - generic [ref=e136] [cursor=pointer]: + - generic [ref=e137]: 分支 · Unread + - generic [ref=e145]: + - generic [ref=e148]: + - generic [ref=e149]: 未读: + - text: demo + - generic [ref=e150]: Local Mac + - button + - button "jinjing-work jinjing-work 的项目操作 为 jinjing-work 创建新工作树" [expanded] [ref=e154]: + - generic [ref=e155]: jinjing-work + - generic: + - button "jinjing-work 的项目操作" + - button "为 jinjing-work 创建新工作树" + - option "分支 main 主要 Local Mac" [ref=e163]: + - generic [ref=e164]: + - generic [ref=e167] [cursor=pointer]: + - generic [ref=e168]: 分支 + - generic [ref=e175]: + - generic [ref=e177]: + - generic [ref=e178]: main + - generic [ref=e179]: 主要 + - generic [ref=e180]: Local Mac + - button + - option "分支 improve-agent-session Local Mac" [ref=e184]: + - generic [ref=e185]: + - generic [ref=e188] [cursor=pointer]: + - generic [ref=e189]: 分支 + - generic [ref=e196]: + - generic [ref=e197]: improve-agent-session + - generic [ref=e200]: Local Mac + - button + - button "orca orca 的项目操作 为 orca 创建新工作树" [expanded] [ref=e204]: + - generic [ref=e205]: orca + - generic: + - button "orca 的项目操作" + - button "为 orca 创建新工作树" + - option "Inactive orca 主要 4 直播 ports Local Mac Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=e211]: + - generic [ref=e212]: + - generic [ref=e215] [cursor=pointer]: + - generic [ref=e216]: Inactive + - generic [ref=e221]: + - generic [ref=e223]: + - generic [ref=e224]: orca + - generic [ref=e225]: 主要 + - button "4 直播 ports" [ref=e228] + - generic [ref=e232]: + - generic [ref=e233]: Local Mac + - generic "Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=e235]: Detached HEAD @ 4fd93ea + - button + - option "Inactive Connect to SSH host openclaw orca 主要 openclaw" [ref=e237]: + - generic [ref=e238]: + - generic [ref=e241] [cursor=pointer]: + - generic [ref=e242]: Inactive + - generic [ref=e247]: + - generic [ref=e249]: + - button "Connect to SSH host openclaw" [ref=e250] + - generic [ref=e251]: orca + - generic [ref=e252]: 主要 + - generic [ref=e253]: openclaw + - button + - option "分支 auto-daily-prod-release-scan-run-19-20260822T1740 Local Mac" [ref=e257]: + - generic [ref=e258]: + - generic [ref=e261] [cursor=pointer]: + - generic [ref=e262]: 分支 + - generic [ref=e269]: + - generic [ref=e270]: auto-daily-prod-release-scan-run-19-20260822T1740 + - generic [ref=e273]: Local Mac + - button + - 'option "PR: Merged fix(automations): localize schedule weekday names and labels Local Mac" [ref=e277]': + - generic [ref=e278]: + - generic [ref=e281] [cursor=pointer]: + - generic [ref=e282]: "PR: Merged" + - generic [ref=e289]: + - generic [ref=e290]: "fix(automations): localize schedule weekday names and labels" + - generic [ref=e293]: Local Mac + - button + - option "分支 fix-rebase-race Local Mac" [ref=e297]: + - generic [ref=e298]: + - generic [ref=e301] [cursor=pointer]: + - generic [ref=e302]: 分支 + - generic [ref=e309]: + - generic [ref=e310]: fix-rebase-race + - generic [ref=e313]: Local Mac + - button + - option "分支 allow-create-automations-with-ai Local Mac" [ref=e317]: + - generic [ref=e318]: + - generic [ref=e321] [cursor=pointer]: + - generic [ref=e322]: 分支 + - generic [ref=e329]: + - generic [ref=e330]: allow-create-automations-with-ai + - generic [ref=e333]: Local Mac + - button + - option "分支 sta-4846-reconnect 8 直播 ports Local Mac" [ref=e672]: + - generic [ref=e338]: + - generic [ref=e341] [cursor=pointer]: + - generic [ref=e342]: 分支 + - generic [ref=e349]: + - generic [ref=e351]: + - generic [ref=e352]: sta-4846-reconnect + - button "8 直播 ports" [ref=e675] + - generic [ref=e353]: Local Mac + - button + - option "分支 sta-4907-review Local Mac" [ref=e357]: + - generic [ref=e358]: + - generic [ref=e361] [cursor=pointer]: + - generic [ref=e362]: 分支 + - generic [ref=e369]: + - generic [ref=e370]: sta-4907-review + - generic [ref=e373]: Local Mac + - button + - option "分支 sta-4998-quick-open Local Mac" [ref=e377]: + - generic [ref=e378]: + - generic [ref=e381] [cursor=pointer]: + - generic [ref=e382]: 分支 + - generic [ref=e389]: + - generic [ref=e390]: sta-4998-quick-open + - generic [ref=e393]: Local Mac + - button + - option "分支 sta-4999-ssh-scrollback 2 直播 ports Local Mac" [ref=e397]: + - generic [ref=e398]: + - generic [ref=e401] [cursor=pointer]: + - generic [ref=e402]: 分支 + - generic [ref=e409]: + - generic [ref=e411]: + - generic [ref=e412]: sta-4999-ssh-scrollback + - button "2 直播 ports" [ref=e415] + - generic [ref=e418]: Local Mac + - button + - option "分支 sta-5000-add-project Local Mac" [ref=e422]: + - generic [ref=e423]: + - generic [ref=e426] [cursor=pointer]: + - generic [ref=e427]: 分支 + - generic [ref=e434]: + - generic [ref=e435]: sta-5000-add-project + - generic [ref=e438]: Local Mac + - button + - option "分支 sta-5001-review Local Mac" [ref=e442]: + - generic [ref=e443]: + - generic [ref=e446] [cursor=pointer]: + - generic [ref=e447]: 分支 + - generic [ref=e454]: + - generic [ref=e455]: sta-5001-review + - generic [ref=e458]: Local Mac + - button + - option "分支 sta-5097-5098-cold-park Local Mac" [ref=e462]: + - generic [ref=e463]: + - generic [ref=e466] [cursor=pointer]: + - generic [ref=e467]: 分支 + - generic [ref=e474]: + - generic [ref=e475]: sta-5097-5098-cold-park + - generic [ref=e478]: Local Mac + - button + - option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 Local Mac" [ref=e482]: + - generic [ref=e483]: + - generic [ref=e486] [cursor=pointer]: + - generic [ref=e487]: 分支 + - generic [ref=e494]: + - generic [ref=e495]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 + - generic [ref=e498]: Local Mac + - button + - option "分支 auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 Local Mac" [ref=e502]: + - generic [ref=e503]: + - generic [ref=e506] [cursor=pointer]: + - generic [ref=e507]: 分支 + - generic [ref=e514]: + - generic [ref=e515]: auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 + - generic [ref=e518]: Local Mac + - button + - option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 Local Mac" [ref=e522]: + - generic [ref=e523]: + - generic [ref=e526] [cursor=pointer]: + - generic [ref=e527]: 分支 + - generic [ref=e534]: + - generic [ref=e535]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 + - generic [ref=e538]: Local Mac + - button + - generic [ref=e544]: + - generic [ref=e546]: + - button "设置" [ref=e547] [cursor=pointer] + - button "帮助" [ref=e548] [cursor=pointer] + - generic [ref=e549]: + - button "显示活动工作区" [ref=e550] [cursor=pointer] + - button "工作区板" [ref=e551] [cursor=pointer] + - generic [ref=e555]: + - generic [ref=e556]: + - text: ··· + - main [ref=e557]: + - heading "自动化" [level=1] [ref=e559] + - generic [ref=e560]: + - generic [ref=e561]: + - generic [ref=e562]: + - textbox "搜索自动化" [ref=e564]: + - /placeholder: 搜索... + - combobox "Filter by host" [ref=e566] [cursor=pointer]: + - generic: + - generic: + - generic "Linux" + - generic: + - generic "Unreachable. This host could not be reached, so its automations could not be loaded.": Unreachable + - generic "Not connected. Automations on this host cannot run until the connection is restored.": Not connected + - button "Refresh automations" [ref=e567] [cursor=pointer] + - button "新建自动化" [ref=e568] [cursor=pointer] + - status [ref=e569] + - generic [ref=e570]: + - generic [ref=e571]: Linux could not be reached. Showing the automations last loaded from it. + - generic [ref=e572]: + - generic "Unreachable. This host could not be reached, so its automations could not be loaded." [ref=e573]: Unreachable + - generic "Not connected. Automations on this host cannot run until the connection is restored." [ref=e575]: Not connected + - button "Reconnect" [ref=e577] [cursor=pointer] + - status [ref=e578]: 1 of 3 hosts could not be loaded + - paragraph [ref=e579]: External automation managers are not listed for this host in this release. + - generic [ref=e580]: + - generic [ref=e581]: + - generic [ref=e582]: 名称 + - generic [ref=e583]: 日程 + - generic [ref=e584]: 项目 + - generic [ref=e585]: Host + - generic [ref=e586]: 下次运行 + - generic [ref=e587]: Last run + - generic [ref=e588]: 状态 + - generic [ref=e589]: 智能体 + - generic [ref=e590]: 操作 + - button "工作日仓库审计 Weekdays at 9:00 AM orca-internal openclaw Aug 24, 9:00 AM (in 2d) Never 启用 Codex · openclaw · 用量不可用 自动化操作" [ref=e592] [cursor=pointer]: + - generic [ref=e593]: 工作日仓库审计 + - generic "Weekdays at 9:00 AM" [ref=e594] + - generic "orca-internal" [ref=e595] + - generic "openclaw" [ref=e596] + - generic "Aug 24, 9:00 AM (in 2d)" [ref=e597] + - generic "Never" [ref=e598] + - generic [ref=e600]: 启用 + - generic "Codex · openclaw · 用量不可用" [ref=e602] + - button "自动化操作" [ref=e605] + - button "显示浮动工作区" [ref=e607] + - button + - generic [ref=e608]: + - generic [ref=e609]: + - button "使用量" [ref=e611] [cursor=pointer]: + - generic [ref=e612]: 受限 + - generic [ref=e618]: + - generic [ref=e623]: 已用 0% 4h 59m + - generic [ref=e624]: · + - generic [ref=e625]: 已用 9% 4d 9h + - generic [ref=e626]: 已用 31% 5d 17h + - button "刷新速率限制" [ref=e631] [cursor=pointer] + - generic [ref=e637]: + - button "Caffeinate, Agent · Inactive" [ref=e638] [cursor=pointer]: + - generic [ref=e641]: Agent + - button "宠物菜单" [ref=e643] [cursor=pointer]: + - generic [ref=e644]: Mini Sama hidden + - button "资源管理器,22 个终端会话" [ref=e645] [cursor=pointer]: + - generic [ref=e648]: 1.43 GB + - generic [ref=e649]: · + - generic [ref=e652]: "22" + - button "端口,109 工作区 ports" [ref=e678] [cursor=pointer]: + - generic [ref=e656]: "109" + - button "远程主机连接状态" [ref=e657] [cursor=pointer]: + - generic [ref=e658]: 0 个主机 + - button + - region "Notifications alt+T" + - generic [ref=e668] [cursor=pointer]: + - generic: + - generic: + - generic: + - generic: + - button "Select element" + - generic: + - generic: + - button "Open comments": + - generic: + - generic: + - generic: + - generic: + - generic: "0" + - generic: "0" + - generic: + - generic: + - button "Copy all comments" + - button "Expand toolbar" [ref=e669] \ No newline at end of file diff --git a/autos-picker.yaml b/autos-picker.yaml new file mode 100644 index 00000000000..e3595a195a6 --- /dev/null +++ b/autos-picker.yaml @@ -0,0 +1,348 @@ +- generic [ref=f1e1]: + - generic [ref=f1e3]: + - generic [ref=f1e6]: + - generic [ref=f1e7]: + - generic [ref=f1e9]: + - generic [ref=f1e10]: + - generic "Orca" [ref=f1e11] [cursor=pointer] + - button "切换侧边栏" [ref=f1e13] [cursor=pointer] + - generic [ref=f1e16]: + - button "返回" [disabled] [ref=f1e17] + - button "前进" [disabled] [ref=f1e20] + - generic [ref=f1e24]: + - generic [ref=f1e25]: + - button "任务" [ref=f1e26] [cursor=pointer] + - button "自动化" [ref=f1e29] [cursor=pointer] + - button "Orca 手机端" [ref=f1e36] [cursor=pointer] + - button "搜索工作树和浏览器选项卡" [ref=f1e40] [cursor=pointer]: + - generic [ref=f1e41]: 搜索 + - generic [ref=f1e42]: + - generic [ref=f1e43]: Projects + - generic [ref=f1e45]: + - button "工作区选项" [ref=f1e46] [cursor=pointer] + - button "Add Project" [ref=f1e47] [cursor=pointer] + - button "New workspace" [ref=f1e48] [cursor=pointer] + - listbox "工作树" [ref=f1e50]: + - button "personal personal 的分组操作" [expanded] [ref=f1e51] [cursor=pointer]: + - generic [ref=f1e52]: personal + - generic: + - button "personal 的分组操作" + - button "AutoGenie AutoGenie 的项目操作 为 AutoGenie 创建新工作树" [expanded] [ref=f1e62] [cursor=pointer]: + - generic [ref=f1e63]: AutoGenie + - generic: + - button "AutoGenie 的项目操作" + - button "为 AutoGenie 创建新工作树" + - option "分支 · Unread 未读: main 主要 工作区元数据 Local Mac" [ref=f1e71]: + - generic [ref=f1e72]: + - generic [ref=f1e75] [cursor=pointer]: + - generic [ref=f1e76]: 分支 · Unread + - generic [ref=f1e84]: + - generic [ref=f1e86]: + - generic [ref=f1e87]: + - generic [ref=f1e88]: 未读: + - text: main + - generic [ref=f1e89]: 主要 + - generic "工作区元数据" [ref=f1e92]: 工作区笔记 + - generic [ref=f1e98]: Local Mac + - button + - button "demo-generation demo-generation 的项目操作 为 demo-generation 创建新工作树" [expanded] [ref=f1e102]: + - generic [ref=f1e103]: demo-generation + - generic: + - button "demo-generation 的项目操作" + - button "为 demo-generation 创建新工作树" + - option "分支 main 主要 Local Mac" [ref=f1e111]: + - generic [ref=f1e112]: + - generic [ref=f1e115] [cursor=pointer]: + - generic [ref=f1e116]: 分支 + - generic [ref=f1e123]: + - generic [ref=f1e125]: + - generic [ref=f1e126]: main + - generic [ref=f1e127]: 主要 + - generic [ref=f1e128]: Local Mac + - button + - option "分支 · Unread 未读: demo Local Mac" [ref=f1e132]: + - generic [ref=f1e133]: + - generic [ref=f1e136] [cursor=pointer]: + - generic [ref=f1e137]: 分支 · Unread + - generic [ref=f1e145]: + - generic [ref=f1e148]: + - generic [ref=f1e149]: 未读: + - text: demo + - generic [ref=f1e150]: Local Mac + - button + - button "jinjing-work jinjing-work 的项目操作 为 jinjing-work 创建新工作树" [expanded] [ref=f1e154]: + - generic [ref=f1e155]: jinjing-work + - generic: + - button "jinjing-work 的项目操作" + - button "为 jinjing-work 创建新工作树" + - option "分支 main 主要 Local Mac" [ref=f1e163]: + - generic [ref=f1e164]: + - generic [ref=f1e167] [cursor=pointer]: + - generic [ref=f1e168]: 分支 + - generic [ref=f1e175]: + - generic [ref=f1e177]: + - generic [ref=f1e178]: main + - generic [ref=f1e179]: 主要 + - generic [ref=f1e180]: Local Mac + - button + - option "分支 improve-agent-session Local Mac" [ref=f1e184]: + - generic [ref=f1e185]: + - generic [ref=f1e188] [cursor=pointer]: + - generic [ref=f1e189]: 分支 + - generic [ref=f1e196]: + - generic [ref=f1e197]: improve-agent-session + - generic [ref=f1e200]: Local Mac + - button + - button "orca orca 的项目操作 为 orca 创建新工作树" [expanded] [ref=f1e204]: + - generic [ref=f1e205]: orca + - generic: + - button "orca 的项目操作" + - button "为 orca 创建新工作树" + - option "Inactive orca 主要 4 直播 ports Local Mac Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=f1e211]: + - generic [ref=f1e212]: + - generic [ref=f1e215] [cursor=pointer]: + - generic [ref=f1e216]: Inactive + - generic [ref=f1e221]: + - generic [ref=f1e223]: + - generic [ref=f1e224]: orca + - generic [ref=f1e225]: 主要 + - button "4 直播 ports" [ref=f1e228] + - generic [ref=f1e232]: + - generic [ref=f1e233]: Local Mac + - generic "Detached HEAD at 4fd93ea. You are viewing a commit, not a branch." [ref=f1e235]: Detached HEAD @ 4fd93ea + - button + - option "Inactive Connect to SSH host openclaw orca 主要 openclaw" [ref=f1e237]: + - generic [ref=f1e238]: + - generic [ref=f1e241] [cursor=pointer]: + - generic [ref=f1e242]: Inactive + - generic [ref=f1e247]: + - generic [ref=f1e249]: + - button "Connect to SSH host openclaw" [ref=f1e250] + - generic [ref=f1e251]: orca + - generic [ref=f1e252]: 主要 + - generic [ref=f1e253]: openclaw + - button + - option "分支 auto-daily-prod-release-scan-run-19-20260822T1740 Local Mac" [ref=f1e257]: + - generic [ref=f1e258]: + - generic [ref=f1e261] [cursor=pointer]: + - generic [ref=f1e262]: 分支 + - generic [ref=f1e269]: + - generic [ref=f1e270]: auto-daily-prod-release-scan-run-19-20260822T1740 + - generic [ref=f1e273]: Local Mac + - button + - 'option "PR: Merged fix(automations): localize schedule weekday names and labels Local Mac" [ref=f1e277]': + - generic [ref=f1e278]: + - generic [ref=f1e281] [cursor=pointer]: + - generic [ref=f1e282]: "PR: Merged" + - generic [ref=f1e289]: + - generic [ref=f1e290]: "fix(automations): localize schedule weekday names and labels" + - generic [ref=f1e293]: Local Mac + - button + - option "分支 fix-rebase-race Local Mac" [ref=f1e297]: + - generic [ref=f1e298]: + - generic [ref=f1e301] [cursor=pointer]: + - generic [ref=f1e302]: 分支 + - generic [ref=f1e309]: + - generic [ref=f1e310]: fix-rebase-race + - generic [ref=f1e313]: Local Mac + - button + - option "分支 allow-create-automations-with-ai Local Mac" [ref=f1e317]: + - generic [ref=f1e318]: + - generic [ref=f1e321] [cursor=pointer]: + - generic [ref=f1e322]: 分支 + - generic [ref=f1e329]: + - generic [ref=f1e330]: allow-create-automations-with-ai + - generic [ref=f1e333]: Local Mac + - button + - option "分支 sta-4846-reconnect Local Mac" [ref=f1e337]: + - generic [ref=f1e338]: + - generic [ref=f1e341] [cursor=pointer]: + - generic [ref=f1e342]: 分支 + - generic [ref=f1e349]: + - generic [ref=f1e350]: sta-4846-reconnect + - generic [ref=f1e353]: Local Mac + - button + - option "分支 sta-4907-review Local Mac" [ref=f1e357]: + - generic [ref=f1e358]: + - generic [ref=f1e361] [cursor=pointer]: + - generic [ref=f1e362]: 分支 + - generic [ref=f1e369]: + - generic [ref=f1e370]: sta-4907-review + - generic [ref=f1e373]: Local Mac + - button + - option "分支 sta-4998-quick-open Local Mac" [ref=f1e377]: + - generic [ref=f1e378]: + - generic [ref=f1e381] [cursor=pointer]: + - generic [ref=f1e382]: 分支 + - generic [ref=f1e389]: + - generic [ref=f1e390]: sta-4998-quick-open + - generic [ref=f1e393]: Local Mac + - button + - option "分支 sta-4999-ssh-scrollback 2 直播 ports Local Mac" [ref=f1e397]: + - generic [ref=f1e398]: + - generic [ref=f1e401] [cursor=pointer]: + - generic [ref=f1e402]: 分支 + - generic [ref=f1e409]: + - generic [ref=f1e411]: + - generic [ref=f1e412]: sta-4999-ssh-scrollback + - button "2 直播 ports" [ref=f1e415] + - generic [ref=f1e418]: Local Mac + - button + - option "分支 sta-5000-add-project Local Mac" [ref=f1e422]: + - generic [ref=f1e423]: + - generic [ref=f1e426] [cursor=pointer]: + - generic [ref=f1e427]: 分支 + - generic [ref=f1e434]: + - generic [ref=f1e435]: sta-5000-add-project + - generic [ref=f1e438]: Local Mac + - button + - option "分支 sta-5001-review Local Mac" [ref=f1e442]: + - generic [ref=f1e443]: + - generic [ref=f1e446] [cursor=pointer]: + - generic [ref=f1e447]: 分支 + - generic [ref=f1e454]: + - generic [ref=f1e455]: sta-5001-review + - generic [ref=f1e458]: Local Mac + - button + - option "分支 sta-5097-5098-cold-park Local Mac" [ref=f1e462]: + - generic [ref=f1e463]: + - generic [ref=f1e466] [cursor=pointer]: + - generic [ref=f1e467]: 分支 + - generic [ref=f1e474]: + - generic [ref=f1e475]: sta-5097-5098-cold-park + - generic [ref=f1e478]: Local Mac + - button + - option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 Local Mac" [ref=f1e482]: + - generic [ref=f1e483]: + - generic [ref=f1e486] [cursor=pointer]: + - generic [ref=f1e487]: 分支 + - generic [ref=f1e494]: + - generic [ref=f1e495]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T1500 + - generic [ref=f1e498]: Local Mac + - button + - option "分支 auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 Local Mac" [ref=f1e502]: + - generic [ref=f1e503]: + - generic [ref=f1e506] [cursor=pointer]: + - generic [ref=f1e507]: 分支 + - generic [ref=f1e514]: + - generic [ref=f1e515]: auto-e2e-tests-autofix-scheduled-ci-1h-run-9-20260822T0700 + - generic [ref=f1e518]: Local Mac + - button + - option "分支 auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 Local Mac" [ref=f1e522]: + - generic [ref=f1e523]: + - generic [ref=f1e526] [cursor=pointer]: + - generic [ref=f1e527]: 分支 + - generic [ref=f1e534]: + - generic [ref=f1e535]: auto-continuous-readiness-checklist-8a-12p-4p-20260822T0300 + - generic [ref=f1e538]: Local Mac + - button + - generic [ref=f1e544]: + - generic [ref=f1e546]: + - button "设置" [ref=f1e547] [cursor=pointer] + - button "帮助" [ref=f1e548] [cursor=pointer] + - generic [ref=f1e549]: + - button "显示活动工作区" [ref=f1e550] [cursor=pointer] + - button "工作区板" [ref=f1e551] [cursor=pointer] + - generic [ref=f1e555]: + - generic [ref=f1e556]: + - text: ··· + - main [ref=f1e557]: + - heading "自动化" [level=1] [ref=f1e559] + - generic [ref=f1e560]: + - generic [ref=f1e561]: + - generic [ref=f1e562]: + - textbox "搜索自动化" [active] [ref=f1e564]: + - /placeholder: 搜索... + - combobox "Filter by host" [ref=f1e566] [cursor=pointer]: + - generic: + - generic: + - generic "Linux" + - generic: + - generic "Unreachable. This host could not be reached, so its automations could not be loaded.": Unreachable + - generic "Not connected. Automations on this host cannot run until the connection is restored.": Not connected + - button "刷新自动化" [ref=f1e567] [cursor=pointer] + - button "新建自动化" [ref=f1e568] [cursor=pointer] + - status [ref=f1e569] + - generic [ref=f1e570]: + - generic [ref=f1e571]: Linux could not be reached. Showing the automations last loaded from it. + - generic [ref=f1e572]: + - generic "Unreachable. This host could not be reached, so its automations could not be loaded." [ref=f1e573]: Unreachable + - generic "Not connected. Automations on this host cannot run until the connection is restored." [ref=f1e575]: Not connected + - button "Reconnect" [ref=f1e577] [cursor=pointer] + - status [ref=f1e578]: 1 of 3 hosts could not be loaded + - paragraph [ref=f1e579]: External automation managers are not listed for this host in this release. + - generic [ref=f1e580]: + - generic [ref=f1e581]: + - generic [ref=f1e582]: 名称 + - generic [ref=f1e583]: 日程 + - generic [ref=f1e584]: 项目 + - generic [ref=f1e585]: 主机 + - generic [ref=f1e586]: 下次运行 + - generic [ref=f1e587]: 上次运行 + - generic [ref=f1e588]: 状态 + - generic [ref=f1e589]: 智能体 + - generic [ref=f1e590]: 操作 + - button "工作日仓库审计 Weekdays at 9:00 AM orca-internal openclaw Aug 24, 9:00 AM (in 2d) Never 启用 Codex · openclaw · 用量不可用 自动化操作" [ref=f1e592] [cursor=pointer]: + - generic [ref=f1e593]: 工作日仓库审计 + - generic "Weekdays at 9:00 AM" [ref=f1e594] + - generic "orca-internal" [ref=f1e595] + - generic "openclaw" [ref=f1e596] + - generic "Aug 24, 9:00 AM (in 2d)" [ref=f1e597] + - generic "Never" [ref=f1e598] + - generic [ref=f1e600]: 启用 + - generic "Codex · openclaw · 用量不可用" [ref=f1e602] + - button "自动化操作" [ref=f1e605] + - button "显示浮动工作区" [ref=f1e607] + - button + - generic [ref=f1e608]: + - generic [ref=f1e609]: + - button "使用量" [ref=f1e611] [cursor=pointer]: + - generic [ref=f1e612]: 受限 + - generic [ref=f1e618]: + - generic [ref=f1e623]: 已用 0% 4h 44m + - generic [ref=f1e624]: · + - generic [ref=f1e625]: 已用 9% 4d 9h + - generic [ref=f1e626]: 已用 31% 5d 16h + - button "刷新速率限制" [ref=f1e631] [cursor=pointer] + - generic [ref=f1e637]: + - button "Caffeinate, Agent · Inactive" [ref=f1e638] [cursor=pointer]: + - generic [ref=f1e641]: Agent + - button "宠物菜单" [ref=f1e643] [cursor=pointer]: + - generic [ref=f1e644]: Mini Sama hidden + - button "资源管理器,22 个终端会话" [ref=f1e645] [cursor=pointer]: + - generic [ref=f1e648]: 1.91 GB + - generic [ref=f1e649]: · + - generic [ref=f1e652]: "22" + - button "端口,101 工作区 ports" [ref=f1e653] [cursor=pointer]: + - generic [ref=f1e656]: "101" + - button "远程主机连接状态" [ref=f1e657] [cursor=pointer]: + - generic [ref=f1e658]: 0 个主机 + - button + - region "Notifications alt+T": + - list: + - listitem [ref=f1e667]: + - button "Close toast" [ref=f1e668] [cursor=pointer] + - generic [ref=f1e675]: + - generic [ref=f1e676]: macOS permissions may not reach Orca terminals + - generic [ref=f1e677]: Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orca’s Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals. + - button "Dismiss" [ref=f1e678] [cursor=pointer] + - button "Open Manage Sessions" [ref=f1e679] [cursor=pointer] + - generic [ref=f1e681] [cursor=pointer]: + - generic: + - generic: + - generic: + - generic: + - button "Select element" + - generic: + - generic: + - button "Open comments": + - generic: + - generic: + - generic: + - generic: + - generic: "0" + - generic: "0" + - generic: + - generic: + - button "Copy all comments" + - button "Expand toolbar" [ref=f1e682] \ No newline at end of file diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md index c186cc67c13..32ae02915aa 100644 --- a/docs/automations-all-hosts-design.md +++ b/docs/automations-all-hosts-design.md @@ -149,7 +149,7 @@ Catalog projection rules: - Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry. - Project Runtime + Self from saved runtime environments, even while a runtime is offline. -- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only. +- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its SSH entries key on target ID alone and are view-only. Its Self entry is not — see the owner-fencing section. - Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages. - Hide runtime-owned ephemeral SSH targets under the existing visibility rule. - Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone. @@ -164,9 +164,7 @@ Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale Persist this optional UI value: ```ts -type AutomationHostFilter = - | { kind: 'all' } - | { kind: 'host'; host: StableAutomationCatalogRef } +type AutomationHostFilter = { kind: 'all' } | { kind: 'host'; host: StableAutomationCatalogRef } ``` Only the stable form is persisted. Restore it after the relevant catalogs settle: @@ -190,8 +188,7 @@ Add two string capability constants to `src/shared/protocol-version.ts`: export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = 'automation.list-host-scope.v1' as const -export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = - 'automation.owner-fencing.v1' as const +export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const ``` The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions. @@ -249,7 +246,7 @@ Legacy partitioning is explicit: The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded. -If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations. +If a runtime lacks owner fencing, its rows split by what the fence actually protects. Runtime + Self stays fully usable — list, edit, pause, delete, Run Now — because a Self record lives on the answering authority itself, needs no registration generation, and mutates by id exactly as a pre-fencing server does today; pairing-revision freshness pins the request to the runtime incarnation the user saw, which is the only identity a Self record has. Runtime + SSH stays view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. For SSH, pairing-revision freshness is not a substitute for the selector fence — a same-id remove/re-add or a record whose target changed server-side since the last refresh would still run on a machine the user never saw. A destination move (re-attaching a record to another host) always requires the fenced contract, even from Self: an old server would ignore the field and silently leave the record in place. The SSH arm mirrors the fail-closed capability assertion the codebase already applies to remote file mutations. ### Owner-fenced operation contract @@ -257,9 +254,7 @@ For capable runtimes, add this optional precondition to `automation.show`, `auto ```ts type AutomationOwnerPrecondition = { - selector: - | { kind: 'self' } - | { kind: 'ssh'; targetId: string; targetGeneration: number } + selector: { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number } } type AutomationOwnedIdParams = { @@ -432,14 +427,12 @@ Existing desktop-stored records whose `schedulerOwner` or run context points at Desktop/runtime version matrix: - -| Desktop | Runtime | Behavior | -| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. | -| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. | -| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. | -| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. | - +| Desktop | Runtime | Behavior | +| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. | +| New | Old | One unscoped list per authority, defensive partition, neutral usage. Self rows stay fully actionable by id under the pairing-revision fence; SSH rows are view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the SSH row surfaces Update server rather than hanging with no explanation. | +| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. | +| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. | No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces. diff --git a/src/cli/automation-destination.ts b/src/cli/automation-destination.ts index 18a9b435594..27bef392aca 100644 --- a/src/cli/automation-destination.ts +++ b/src/cli/automation-destination.ts @@ -17,6 +17,7 @@ import { import type { AutomationDestination } from '../shared/automation-owner-precondition' import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { SshTargetSummary } from '../shared/ssh-types' +import { AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE } from '../shared/protocol-version' import type { Repo } from '../shared/repo-types' import type { RuntimeClient } from './runtime-client' @@ -39,22 +40,16 @@ async function resolveTargetRepo( .repo } -/** - * `undefined` means the authority registers the target but assigns no - * generation, which is a host with nothing to fence — not a host to fail - * against. A target the authority does not register at all is positive - * evidence that the destination is gone. - */ -async function sshTargetGeneration( - client: RuntimeClient, - targetId: string -): Promise { +async function sshTargetGeneration(client: RuntimeClient, targetId: string): Promise { const targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')) .result.targets const match = targets.find((candidate) => candidate.id === targetId) if (!match) { throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination) } + if (match.generation === undefined) { + throw new Error(AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE) + } return match.generation } @@ -71,7 +66,5 @@ export async function resolveAutomationDestination( return { selector: { kind: 'self' } } } const generation = await sshTargetGeneration(client, connectionId) - return generation === undefined - ? undefined - : { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } } + return { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } } } diff --git a/src/cli/handlers/automation-destination-fencing.test.ts b/src/cli/handlers/automation-destination-fencing.test.ts index ba2947e716b..66ea8d5aee9 100644 --- a/src/cli/handlers/automation-destination-fencing.test.ts +++ b/src/cli/handlers/automation-destination-fencing.test.ts @@ -177,26 +177,26 @@ describe('CLI automation writes fence the host they land on', () => { }) }) - // A host that predates SSH generations has no incarnation to capture, so the - // write must still land — failing closed there would break a host that is only old. - it('omits the destination when the authority registers the target without a generation', async () => { + it('refuses an SSH destination that the authority cannot generation-fence', async () => { const { call, writes } = authority({ registered: [{ id: 'box-1', label: 'box 1' }], repoConnectionId: 'box-1' }) vi.spyOn(console, 'log').mockImplementation(() => undefined) - await AUTOMATION_HANDLERS['automations edit']!({ - client: { call } as never, - cwd: '/tmp', - flags: new Map([ - ['id', 'a1'], - ['repo', 'repo-on-box-1'] - ]), - json: true - }) + await expect( + AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['repo', 'repo-on-box-1'] + ]), + json: true + }) + ).rejects.toThrow('Editing automations on this host requires a newer Orca server') - expect(writes[0]?.params).not.toHaveProperty('destination') + expect(writes).toEqual([]) }) it('sends a self destination for a local project', async () => { diff --git a/src/main/automations/external-automation-probe-scheduler.test.ts b/src/main/automations/external-automation-probe-scheduler.test.ts index ff56e9242cb..c970d4ba12d 100644 --- a/src/main/automations/external-automation-probe-scheduler.test.ts +++ b/src/main/automations/external-automation-probe-scheduler.test.ts @@ -71,6 +71,42 @@ describe('ExternalAutomationProbeScheduler', () => { expect(run).toHaveBeenCalledTimes(1) }) + it('releases a priority hold that its holder never releases', async () => { + vi.useFakeTimers() + try { + const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 }) + const run = vi.fn(() => Promise.resolve('manager')) + + // The lease of a dispatch that never settles. Without the deadline this + // probe — and every later one — waits for the rest of the process. + scheduler.beginPriorityWork() + const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }) + expect(run).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + await expect(probe).resolves.toBe('manager') + } finally { + vi.useRealTimers() + } + }) + + it('keeps parking probes while a hold is still within its deadline', async () => { + vi.useFakeTimers() + try { + const scheduler = new ExternalAutomationProbeScheduler({ priorityHoldMaxMs: 1_000 }) + const run = vi.fn(() => Promise.resolve('manager')) + + scheduler.beginPriorityWork() + scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }) + + await vi.advanceTimersByTimeAsync(999) + expect(run).not.toHaveBeenCalled() + expect(scheduler.queued).toBe(1) + } finally { + vi.useRealTimers() + } + }) + it('cancels queued and in-flight probes whose scope is no longer selected', async () => { const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 }) const never = deferred() diff --git a/src/main/automations/external-automation-probe-scheduler.ts b/src/main/automations/external-automation-probe-scheduler.ts index ad5a33046f5..653450195cc 100644 --- a/src/main/automations/external-automation-probe-scheduler.ts +++ b/src/main/automations/external-automation-probe-scheduler.ts @@ -35,16 +35,25 @@ type QueuedProbe = { } const DEFAULT_PROBE_CONCURRENCY = 4 +/** + * The priority lease is an ordering preference, not a lock. Work that holds it + * for an unbounded time — a headless dispatch, a handler whose promise never + * settles — would otherwise park every queued probe for the rest of the + * process, and a caller waiting on one would never be answered at all. + */ +const PRIORITY_HOLD_MAX_MS = 30_000 export class ExternalAutomationProbeScheduler { private readonly concurrency: number + private readonly priorityHoldMaxMs: number private readonly queue: QueuedProbe[] = [] private readonly active = new Map() private readonly shared = new Map>() private priorityHolds = 0 - constructor(options?: { concurrency?: number }) { + constructor(options?: { concurrency?: number; priorityHoldMaxMs?: number }) { this.concurrency = Math.max(1, options?.concurrency ?? DEFAULT_PROBE_CONCURRENCY) + this.priorityHoldMaxMs = Math.max(0, options?.priorityHoldMaxMs ?? PRIORITY_HOLD_MAX_MS) } get inFlight(): number { @@ -90,18 +99,27 @@ export class ExternalAutomationProbeScheduler { return settled as Promise } - /** Held for the duration of Orca list/mutation work; queued probes wait behind it. */ + /** + * Held for the duration of Orca list/mutation work; queued probes wait behind + * it, but only until the deadline — see `PRIORITY_HOLD_MAX_MS`. + */ beginPriorityWork(): () => void { this.priorityHolds += 1 let released = false - return () => { + let timer: ReturnType + const release = (): void => { if (released) { return } released = true + clearTimeout(timer) this.priorityHolds = Math.max(0, this.priorityHolds - 1) this.pump() } + timer = setTimeout(release, this.priorityHoldMaxMs) + // Why unref: a lease deadline must never be the reason the process stays up. + timer.unref?.() + return release } /** Drops queued and in-flight probes whose scope is no longer selected. */ diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts index dbf6d18dc85..a193c5e0b79 100644 --- a/src/main/automations/external-manager-scoped.test.ts +++ b/src/main/automations/external-manager-scoped.test.ts @@ -129,10 +129,9 @@ describe('scoped external automations', () => { }) it('probes only the selected SSH host', async () => { + const mux = relay({ jobs: [], hermesAvailable: true }) vi.mocked(getActiveMultiplexer).mockReturnValue( - relay({ jobs: [], hermesAvailable: true }) as unknown as ReturnType< - typeof getActiveMultiplexer - > + mux as unknown as ReturnType ) const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })]) @@ -144,6 +143,11 @@ describe('scoped external automations', () => { expect(entry.manager?.id).toBe('hermes:ssh:target-a') expect(getActiveMultiplexer).toHaveBeenCalledTimes(1) expect(getActiveMultiplexer).toHaveBeenCalledWith('target-a') + expect(mux.request).toHaveBeenCalledWith( + 'externalAutomations.list', + { provider: 'hermes' }, + { signal: expect.any(AbortSignal) } + ) }) it('fails closed on a stale generation without probing', async () => { diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 3d18fab8657..6badd1ec164 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -243,7 +243,8 @@ function remoteRelayErrorMessage(error: unknown): string { async function listRemoteManager( target: SshTarget, - provider: ExternalAutomationProvider + provider: ExternalAutomationProvider, + signal: AbortSignal ): Promise { const providerLabel = PROVIDER_LABELS[provider] const managerProviderId = `${provider}:ssh:${target.id}` @@ -262,7 +263,7 @@ async function listRemoteManager( } } try { - const result = (await mux.request('externalAutomations.list', { provider })) as { + const result = (await mux.request('externalAutomations.list', { provider }, { signal })) as { jobs?: unknown[] hermesAvailable?: boolean openclawAvailable?: boolean @@ -534,9 +535,9 @@ export function createScopedExternalAutomations( deps.scheduler.schedule({ key: externalAutomationManagerCacheKey(key), scopeKey: scope.ownerKey, - run: () => + run: (signal) => scope.sshTarget - ? listRemoteManager(scope.sshTarget, scope.provider) + ? listRemoteManager(scope.sshTarget, scope.provider, signal) : listLocalManager(scope.provider) }), { refresh: request.refresh } diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts index 3e25ad87440..bae582f222c 100644 --- a/src/main/automations/headless-workspace-create.test.ts +++ b/src/main/automations/headless-workspace-create.test.ts @@ -85,7 +85,8 @@ describe('headless automation workspace create args', () => { executionTargetId: 'ssh-target-1', projectId: 'project-1', repoId: 'repo-1', - hostId: 'ssh:ssh-target-1' + hostId: 'ssh:ssh-target-1', + storageAuthority: 'runtime' } }) }) diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index 2047a105c44..ee99e7a6206 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -42,6 +42,12 @@ export function buildHeadlessAutomationWorktreeCreateArgs({ startupAgent: automation.agentId, startupPrompt: automation.prompt, telemetrySource: 'unknown', - automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt) + automationProvenance: buildAutomationWorkspaceProvenance( + automation, + run, + repo, + createdAt, + 'runtime' + ) } } diff --git a/src/main/automations/retained-run-reconciliation.test.ts b/src/main/automations/retained-run-reconciliation.test.ts index aa3cc6d7183..158d020486c 100644 --- a/src/main/automations/retained-run-reconciliation.test.ts +++ b/src/main/automations/retained-run-reconciliation.test.ts @@ -230,7 +230,7 @@ describe('reconciling retained runs against a graph that has not published yet', service.stop() }) - it('still closes out a run the ready surface cannot find after the settle window', async () => { + it('keeps a run unverifiable when the ready surface cannot find it', async () => { const store = await createStore() const automation = createAutomation(store) const retained = retainDispatchedRun(store, automation) @@ -241,10 +241,8 @@ describe('reconciling retained runs against a graph that has not published yet', service.setRendererReady() await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS) - expect(readRun(store, automation.id, retained.id).status).toBe('dispatch_failed') - expect(readRun(store, automation.id, retained.id).error).toBe( - 'Orca lost the terminal for this run before it reported completion.' - ) + expect(readRun(store, automation.id, retained.id).status).toBe('dispatched') + expect(readRun(store, automation.id, retained.id).error).toBeNull() service.stop() }) }) diff --git a/src/main/automations/retained-run-reconciliation.ts b/src/main/automations/retained-run-reconciliation.ts index eeeb6d39b79..56acfc99855 100644 --- a/src/main/automations/retained-run-reconciliation.ts +++ b/src/main/automations/retained-run-reconciliation.ts @@ -12,7 +12,7 @@ export type RetainedRunReconcilerDeps = { attach: (run: AutomationRun) => boolean /** False once the run reached a terminal status by some other path. */ stillRetained: (run: AutomationRun) => boolean - strand: (run: AutomationRun) => void + unverifiable: (run: AutomationRun) => void } /** @@ -47,6 +47,15 @@ export class RetainedRunReconciler { this.sweep() } + /** Rechecks an observation-lost run on the cadence, avoiding a tight stale-handle loop. */ + defer(run: AutomationRun): void { + if (this.disposed) { + return + } + this.pending.set(run.id, run) + this.arm() + } + /** The authority's terminal surface can now answer pane lookups. */ markSurfaceReady(): void { if (this.disposed) { @@ -73,7 +82,7 @@ export class RetainedRunReconciler { } if (strandAt !== null && Date.now() >= strandAt) { this.pending.delete(runId) - this.deps.strand(run) + this.deps.unverifiable(run) } } if (this.pending.size === 0) { diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 1f82c758879..f3635cb2224 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -133,12 +133,7 @@ describe('authority-owned automation run completion', () => { service.stop() }) - /** - * The throws on this path carry transport tokens, not sentences. Every other - * refusal in run history is one fixed sentence, so leaking `terminal_handle_stale` - * into the row a user reads breaks the only convention that surface has. - */ - it('reports a failed observation in the same fixed sentence every other refusal uses', async () => { + it('keeps a run unverifiable when terminal observation is lost', async () => { const store = await createStore() const automation = createAutomation(store) const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -150,20 +145,15 @@ describe('authority-owned automation run completion', () => { }) const run = await service.runNow(automation.id) - await vi.waitFor(() => { - expect(readRun(store, automation.id, run.id).status).toBe('dispatch_failed') - }) - - expect(readRun(store, automation.id, run.id).error).toBe( - 'Orca stopped watching this run before it reported completion.' - ) - // The token is still recoverable where it is actually useful. + await vi.waitFor(() => expect(logged).toHaveBeenCalled()) + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + expect(readRun(store, automation.id, run.id).error).toBeNull() expect(logged.mock.calls.flat().map(String).join(' ')).toContain('terminal_handle_stale') logged.mockRestore() service.stop() }) - it('reconciles stranded runs on startup without claiming completion', async () => { + it('keeps unresolvable retained runs dispatched after startup reconciliation', async () => { const store = await createStore() const automation = createAutomation(store) const dispatched = store.createAutomationRun(automation, 1_000, 'manual') @@ -193,10 +183,10 @@ describe('authority-owned automation run completion', () => { service.setRendererReady() await vi.advanceTimersByTimeAsync(10 * 60 * 1000) - expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatch_failed') - expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed') - expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal') - expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started') + expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatched') + expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatching') + expect(readRun(store, automation.id, dispatched.id).error).toBeNull() + expect(readRun(store, automation.id, dispatching.id).error).toBeNull() service.stop() vi.useRealTimers() }) diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts index 4ac125082fb..fe6a318b998 100644 --- a/src/main/automations/run-completion-watcher.ts +++ b/src/main/automations/run-completion-watcher.ts @@ -30,12 +30,9 @@ export function describeStrandedAutomationRun(run: AutomationRun): string { return 'Orca lost the terminal for this run before it reported completion.' } -/** Why a fixed sentence: the throws here carry internal transport tokens - * (`terminal_handle_stale`, `terminal_not_found`, `request_aborted`), and a run - * history row is user copy. The token stays in the log, where it is useful. */ -function describeObservationError(error: unknown): string { +/** Observation failures are transport evidence, so they stay in logs and never finalize a run. */ +function logObservationError(error: unknown): void { console.error('[automations] run completion observation failed:', error) - return 'Orca stopped watching this run before it reported completion.' } export class AutomationRunCompletionWatcher { @@ -60,13 +57,11 @@ export class AutomationRunCompletionWatcher { const current = this.readRun(run.automationId, run.id) return Boolean(current && !isFinalAutomationRunStatus(current.status)) }, - strand: (run) => { - void this.finalize(run, { - status: 'dispatch_failed', - error: describeStrandedAutomationRun(run) - }).catch((error) => { - console.error('[automations] failed to reconcile stranded run:', error) - }) + unverifiable: (run) => { + console.warn( + '[automations] run completion is unverifiable:', + describeStrandedAutomationRun(run) + ) } }) } @@ -101,10 +96,13 @@ export class AutomationRunCompletionWatcher { private startWatch(run: AutomationRun, handle: string): void { const controller = new AbortController() this.watching.set(run.id, controller) - void this.observeUntilTerminal(run, handle, controller).finally(() => { + void this.observeUntilTerminal(run, handle, controller).then((retry) => { if (this.watching.get(run.id) === controller) { this.watching.delete(run.id) } + if (retry) { + this.reconciler.defer(run) + } }) } @@ -112,21 +110,23 @@ export class AutomationRunCompletionWatcher { run: AutomationRun, handle: string, controller: AbortController - ): Promise { + ): Promise { let observation: AutomationRunCompletionObservation try { observation = await this.observer.observeCompletion(handle, { signal: controller.signal }) } catch (error) { if (controller.signal.aborted) { - return + return false } - observation = { status: 'dispatch_failed', error: describeObservationError(error) } + logObservationError(error) + return true } try { await this.finalize(run, observation) } catch (error) { console.error('[automations] failed to persist watched run completion:', error) } + return false } forget(runId: string): void { diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts index a9b307c3298..a8158687135 100644 --- a/src/main/automations/runtime-terminal-run-observer.test.ts +++ b/src/main/automations/runtime-terminal-run-observer.test.ts @@ -181,13 +181,13 @@ describe('createRuntimeAutomationRunTerminalObserver', () => { await run.promise }) - it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => { + it('makes observation unverifiable after its bounded watch window', async () => { const runtime = createFakeRuntime({ lastAgentStatus: 'working' }) const run = observe(runtime) await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 + RUNTIME_TUI_IDLE_TIMEOUT_MS) - expect(run.settled[0]?.status).toBe('dispatch_failed') - expect(run.settled[0]?.error).toContain('without a completion signal') + expect(run.settled).toEqual([]) + expect(run.errors.map(String)).toContain('Error: automation_run_observation_unverifiable') // 6h of 5-minute waits, not an unbounded re-arm. expect(runtime.waitCalls()).toBeLessThanOrEqual(80) await run.promise diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts index 0dc6750e8de..3025ad69106 100644 --- a/src/main/automations/runtime-terminal-run-observer.ts +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -185,11 +185,7 @@ export function createRuntimeAutomationRunTerminalObserver( throw error } if (Date.now() >= deadlineAt) { - return await buildUnobservedObservation( - runtime, - handle, - 'Orca stopped watching this run after 6h without a completion signal.' - ) + throw new Error('automation_run_observation_unverifiable') } } } diff --git a/src/main/automations/workspace-provenance.ts b/src/main/automations/workspace-provenance.ts index 8174ca11293..08b53fae50e 100644 --- a/src/main/automations/workspace-provenance.ts +++ b/src/main/automations/workspace-provenance.ts @@ -37,8 +37,9 @@ export function resolveAutomationWorkspaceProvenance(args: { repoSelector: string repo: Repo request: AutomationWorkspaceProvenanceRequest | undefined + storageAuthority: AutomationWorkspaceProvenance['storageAuthority'] }): AutomationWorkspaceProvenance | undefined { - const { authority, repoSelector, repo, request } = args + const { authority, repoSelector, repo, request, storageAuthority } = args if (!request) { return undefined } @@ -75,7 +76,7 @@ export function resolveAutomationWorkspaceProvenance(args: { invalidAutomationProvenanceRequest() } - return buildAutomationWorkspaceProvenance(automation, run, repo) + return buildAutomationWorkspaceProvenance(automation, run, repo, Date.now(), storageAuthority) } export function releaseAutomationWorkspaceProvenanceRequest( diff --git a/src/main/index.ts b/src/main/index.ts index d755aff6e36..0eafc539bcd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2316,6 +2316,7 @@ void app.whenReady().then(async () => { reportSecretProtectionGap({ dataFile: activeOrcaProfile.dataFile, force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1' + }) // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults // it. Left unbound it reports nothing trusted, which is safe but silently discards our own // accept records on every launch. diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts index 2a50d123a0d..3dcb2c94a8b 100644 --- a/src/main/ipc/automations-external-scope.test.ts +++ b/src/main/ipc/automations-external-scope.test.ts @@ -87,7 +87,11 @@ describe('scoped external-manager IPC', () => { provider: 'hermes' })) as { manager: { id: string; target: unknown } | null; error: string | null } - expect(relay.request).toHaveBeenCalledWith('externalAutomations.list', { provider: 'hermes' }) + expect(relay.request).toHaveBeenCalledWith( + 'externalAutomations.list', + { provider: 'hermes' }, + { signal: expect.any(AbortSignal) } + ) expect(entry.manager?.id).toBe('hermes:ssh:t1') expect(entry.manager?.target).toEqual({ type: 'ssh', connectionId: 't1' }) expect(entry.error).toBeNull() diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..7526edeb585 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -2263,7 +2263,8 @@ export function registerWorktreeHandlers( authority: runtime, repoSelector: args.repoId, repo, - request: args.automationProvenanceRequest + request: args.automationProvenanceRequest, + storageAuthority: 'desktop' }) const createArgs: CreateWorktreeArgsWithSystemProvenance = { ...args, @@ -2330,7 +2331,8 @@ export function registerWorktreeHandlers( authority: runtime, repoSelector: args.repoId, repo, - request: args.automationProvenanceRequest + request: args.automationProvenanceRequest, + storageAuthority: 'desktop' }) let result: CreateWorktreeResult try { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 947f4da0087..ef033034e17 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -626,6 +626,7 @@ import { resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatcher } from '../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, @@ -3682,6 +3683,7 @@ export class OrcaRuntimeService { private skillCloudService: SkillCloudService | null = null private agentSkillShareInProgress = false private skillUploadSessions: SkillUploadSessionService | null = null + private skillUploadSessionsDisposed = false private readonly skillTransactionRecovery: Promise private readonly skillInstallOperations = new Map() private readonly skillInstallProgress = new Map() @@ -5796,12 +5798,22 @@ export class OrcaRuntimeService { } private requireSkillUploadSessions(): SkillUploadSessionService { + if (this.skillUploadSessionsDisposed) { + throw new Error('skill-upload-service-disposed') + } this.skillUploadSessions ??= new SkillUploadSessionService( join(app.getPath('userData'), 'skill-installs', 'remote-uploads') ) return this.skillUploadSessions } + async disposeSkillUploadSessions(): Promise { + this.skillUploadSessionsDisposed = true + const sessions = this.skillUploadSessions + this.skillUploadSessions = null + await sessions?.dispose() + } + getRuntimeId(): string { return this.runtimeId } @@ -23434,7 +23446,8 @@ export class OrcaRuntimeService { const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length const matcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) ) const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree( @@ -23463,7 +23476,8 @@ export class OrcaRuntimeService { } const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) ) const expectedHostId = getRepoExecutionHostId(repo) const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length @@ -23575,7 +23589,8 @@ export class OrcaRuntimeService { repo.id, createWorktreeVisibilitySourceMatcher( [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) ) ]) ) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 7503d65d623..21b240ee446 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -84,7 +84,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ authority: runtime, repoSelector: params.repo, repo, - request: params.automationProvenanceRequest + request: params.automationProvenanceRequest, + storageAuthority: 'runtime' }) // Why: provenance tokens are reserved before creation so retries can recover, // but failed create attempts must release the reservation for a safe retry. diff --git a/src/renderer/src/components/automations/AutomationHostBadges.test.tsx b/src/renderer/src/components/automations/AutomationHostBadges.test.tsx index a9e9c6caef7..059dc66db20 100644 --- a/src/renderer/src/components/automations/AutomationHostBadges.test.tsx +++ b/src/renderer/src/components/automations/AutomationHostBadges.test.tsx @@ -81,7 +81,11 @@ describe('AutomationHostStatusBadges', () => { it('distinguishes an incompatible authority from a legacy-unscoped contract', () => { render() const incompatible = statusIds() - render() + render( + + ) const legacy = statusIds() expect(incompatible).toContain('authority-incompatible') @@ -89,6 +93,22 @@ describe('AutomationHostStatusBadges', () => { expect(incompatible).not.toEqual(legacy) }) + it('shows no View only badge for a connected legacy Runtime + Self entry', () => { + render( + + ) + expect(statusIds()).toEqual([]) + }) + it('separates the incompatible query contract from the incompatible authority', () => { render() expect(statusIds('query')).toEqual(['query-incompatible']) diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx index e4e6903c151..6e5b8a0f53c 100644 --- a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx @@ -19,6 +19,7 @@ let container: HTMLDivElement let root: Root beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -141,14 +142,24 @@ describe('AutomationHostFilterNotice', () => { expect(onRecover).toHaveBeenCalledWith('reconnect') }) - it('offers Update server for a legacy-unscoped contract', () => { + it('offers Update server for an entry the unscoped contract leaves view-only', () => { renderNotice( - resolution('ghost', { entry: entry({ querySupport: 'legacy-unscoped' }) }), + resolution('ghost', { + entry: entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' }) + }), () => undefined ) expect(container.querySelector('button')?.textContent).toBe('Update server') }) + it('offers no Update server for a legacy Self entry with no scope gap', () => { + renderNotice( + resolution('ready', { entry: entry({ querySupport: 'legacy-unscoped' }) }), + () => undefined + ) + expect(container.querySelector('button')).toBeNull() + }) + it('does not move focus when the notice appears', () => { const input = document.createElement('input') document.body.appendChild(input) diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx index 2dcdbba8d12..46f3ae119da 100644 --- a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx @@ -1,12 +1,11 @@ import React, { useEffect, useRef, useState } from 'react' -import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import { AutomationHostStatusBadges } from './AutomationHostBadges' +import { AutomationHostRecoverButton } from './AutomationHostRecoverButton' import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' import { automationHostRecoveryActions, - recoveryActionLabel, type AutomationHostRecoveryAction } from './automation-host-status-descriptors' @@ -60,7 +59,7 @@ export function AutomationHostLoadSummary({ type AutomationHostFilterNoticeProps = { resolution: AutomationHostFilterResolution - onRecover?: (action: AutomationHostRecoveryAction) => void + onRecover?: (action: AutomationHostRecoveryAction) => void | Promise className?: string } @@ -132,9 +131,7 @@ export function AutomationHostFilterNotice({ {message} {resolution.entry ? : null} {action && onRecover ? ( - + ) : null} ) : null} diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx index d192465bb7e..a6535587dda 100644 --- a/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx +++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx @@ -54,14 +54,15 @@ function runtimeSelf(environmentId: string, label: string): AutomationHostCatalo function render( entries: readonly AutomationHostCatalogEntry[], - onSelect: (filter: AutomationHostFilter) => void = () => undefined + onSelect: (filter: AutomationHostFilter) => void = () => undefined, + selectedStableKey: string | null = null ): void { act(() => { root.render( { expect(groups().map(accessibleGroupName)).toEqual(['This computer']) }) + it('opens below the trigger instead of aligning the selected host', () => { + render( + [sshEntry('t0', 'web-00'), runtimeSelf('env-1', 'Build box')], + () => undefined, + 'host:runtime:env-1:self' + ) + openWithKeyboard() + + const content = document.querySelector('[data-slot="select-content"]') + expect(content).not.toBeNull() + expect(content?.getAttribute('data-side')).toBe('bottom') + expect(content?.getAttribute('data-align')).toBe('start') + const firstItem = document.querySelector( + '[data-slot="select-content"] [data-slot="select-item"]' + ) + expect(firstItem?.textContent).toContain('All hosts') + }) + it('reports the chosen host as a stable filter', () => { const onSelect = vi.fn() render([sshEntry('t0', 'web-00'), sshEntry('t1', 'web-01')], onSelect) diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx index 3c320552c9b..78cafcd723b 100644 --- a/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx +++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx @@ -64,13 +64,22 @@ export function AutomationHostPickerSelect({ {/* Without children Radix names the trigger from the matching item, and a retained-but-unhydrated selection matches only the All hosts item. */} {selectedStableKey === null ? triggerLabel : undefined} - + {/* Why: item-aligned lines the selected host up with the trigger, so a + mid-list pick (and the group label above it) clips under the titlebar. */} + {translate('auto.components.automations.hostPicker.allHosts', 'All hosts')} diff --git a/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx b/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx new file mode 100644 index 00000000000..c594000b955 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostRecoverButton.tsx @@ -0,0 +1,41 @@ +import { useState, type JSX } from 'react' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +export function AutomationHostRecoverButton({ + action, + onRecover, + size = 'xs', + className +}: { + action: AutomationHostRecoveryAction + onRecover: (action: AutomationHostRecoveryAction) => void | Promise + size?: 'xs' | 'sm' + className?: string +}): JSX.Element { + const [busy, setBusy] = useState(false) + return ( + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx index da200352b7c..2de54a34c3b 100644 --- a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx +++ b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx @@ -14,6 +14,7 @@ let container: HTMLDivElement let root: Root beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.tsx index b6950356c03..f7832a8d93c 100644 --- a/src/renderer/src/components/automations/AutomationListEmptyView.tsx +++ b/src/renderer/src/components/automations/AutomationListEmptyView.tsx @@ -1,19 +1,16 @@ import React from 'react' -import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { resolveAutomationListEmptyState, type AutomationListEmptyStateInput } from './automation-list-empty-state' -import { - recoveryActionLabel, - type AutomationHostRecoveryAction -} from './automation-host-status-descriptors' +import { AutomationHostRecoverButton } from './AutomationHostRecoverButton' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' /** Renders whichever empty/partial/failure state the list is in; nothing when rows exist. */ export type AutomationListEmptyViewProps = AutomationListEmptyStateInput & { - onRecover?: (action: AutomationHostRecoveryAction) => void + onRecover?: (action: AutomationHostRecoveryAction) => void | Promise className?: string } @@ -44,15 +41,12 @@ export function AutomationListEmptyView({

) : null} {recovery && onRecover ? ( - + /> ) : null} ) diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index 4a50bd3195f..f977b9b433a 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -86,7 +86,8 @@ export function AutomationListExternalRows({ }) const actionDisabled = disabledMessage !== null const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label - const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}` + const projectLabel = entry.job.workdir || '—' + const hostLabel = entry.manager.targetLabel const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') @@ -127,6 +128,9 @@ export function AutomationListExternalRows({ {projectLabel} + + {hostLabel} + {nextRunLabel} diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.tsx index ba751d5aec2..34a97ee72b8 100644 --- a/src/renderer/src/components/automations/AutomationListHostGroups.tsx +++ b/src/renderer/src/components/automations/AutomationListHostGroups.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { Button } from '@/components/ui/button' import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' +import { AutomationHostRecoverButton } from './AutomationHostRecoverButton' import { AutomationListLocalRows, type AutomationListLocalRowsProps @@ -13,7 +13,6 @@ import { import { externalManagersListedForEntry } from './external-automation-scope-gating' import { automationHostRecoveryActions, - recoveryActionLabel, type AutomationHostRecoveryAction } from './automation-host-status-descriptors' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' @@ -31,7 +30,10 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types export type AutomationListHostGroupsProps = Omit & { groups: readonly AutomationHostVisibleGroup[] searchActive: boolean - onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void + onRecover: ( + action: AutomationHostRecoveryAction, + entry: AutomationHostCatalogEntry + ) => void | Promise } function hostRecoveryAction( @@ -48,7 +50,7 @@ function AutomationHostGroupState({ }: { state: AutomationListEmptyState recovery: AutomationHostRecoveryAction | null - onRecover: (action: AutomationHostRecoveryAction) => void + onRecover: (action: AutomationHostRecoveryAction) => void | Promise }): React.JSX.Element { return (
@@ -60,15 +62,7 @@ function AutomationHostGroupState({
) : null} {recovery ? ( - + ) : null} ) @@ -102,14 +96,10 @@ export function AutomationListHostGroups({ {action ? ( - + onRecover(recovery, entry)} + /> ) : null} {state.kind === 'rows' ? ( diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx index dee3ff4dc1f..e25c0dcac1f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx @@ -252,4 +252,14 @@ describe('Automation list row selection', () => { expect(onRequestAction).toHaveBeenCalledTimes(1) expect(onSelect).not.toHaveBeenCalled() }) + + it('renders the host label in the Host column for local rows', () => { + renderLocalRows({ onSelect: vi.fn() }) + expect(screen.getByText('Local')).toBeTruthy() + }) + + it('renders the host label in the Host column for external rows', () => { + renderExternalRows({ onSelect: vi.fn() }) + expect(screen.getByText('Local')).toBeTruthy() + }) }) diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index e157bd2a15c..6335dd707bd 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -247,6 +247,9 @@ export function AutomationListLocalRows({ {projectLabel} + + {hostLabel} + {nextRunLabel} diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx index 11d412f5412..d0b740590fe 100644 --- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx +++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx @@ -27,6 +27,7 @@ export function AutomationListTableHeader({ {translate('auto.components.automations.AutomationsPage.tableProject', 'Project')} + {translate('auto.components.automations.AutomationsPage.tableHost', 'Host')} {translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx index b3428780ddc..8c67de8f4ab 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -152,3 +152,23 @@ describe('AutomationsListPanel unchecked hosts', () => { expect(container.textContent).not.toContain('could not be checked') }) }) + +describe('AutomationsListPanel flat table layout', () => { + it('renders the Host column in the table header', () => { + const row = makeAutomationListRow() + renderPanel([row], '') + + expect(container.textContent).toContain('Host') + }) + + it('renders rows in a flat list without host section headers', () => { + const row1 = makeAutomationListRow({ hostLabel: 'Local Mac' }) + const row2 = makeAutomationListRow({ hostLabel: 'Linux' }) + renderPanel([row1, row2], '') + + expect(container.textContent).toContain('Local Mac') + expect(container.textContent).toContain('Linux') + // No section headers grouping by host + expect(container.querySelector('[data-host-group]')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 692a257f098..3d6645b29d5 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -19,10 +19,6 @@ import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' import { clampAutomationListSearchQueryInput } from './automation-list-search' -import { - getAutomationListArrowNavigationTarget, - type AutomationListArrowKey -} from './automation-list-keyboard-navigation' import type { AutomationListRow } from './automation-list-row-identity' import type { AutomationPaneTab } from './automation-page-state' import { AutomationListSearchField } from './AutomationListSearchField' @@ -31,8 +27,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent import type { ExternalAutomationScope } from './external-automation-scope-client' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' -import { AutomationListHostGroups } from './AutomationListHostGroups' -import { filterAutomationHostGroups } from './automation-host-list-rows' import { AutomationHostPicker } from './AutomationHostPicker' import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice' import { AutomationListEmptyView } from './AutomationListEmptyView' @@ -42,6 +36,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery' +import { useAutomationListKeyboardNavigation } from './use-automation-list-keyboard-navigation' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout' import { translate } from '@/i18n/i18n' @@ -64,7 +59,7 @@ type AutomationsListPanelProps = { onRecoverHost: ( action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null - ) => void + ) => void | Promise filteredRows: readonly AutomationListRow[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] selectedRowKey: string | null @@ -157,62 +152,18 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS } = props const listRef = useRef(null) const pickerRef = useRef(null) - const pendingKeyboardScrollRef = useRef(false) const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows]) - const visibleRowKeys = React.useMemo(() => new Set(rowKeys), [rowKeys]) - const visibleItems = React.useMemo( - () => [ - ...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })), - ...filteredExternalAutomationEntries.map((entry) => ({ kind: 'external' as const, id: entry.key })) - ], - [filteredExternalAutomationEntries, filteredRows] - ) useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef }) - const handleSearchArrowNavigate = React.useCallback( - (key: AutomationListArrowKey) => { - const next = getAutomationListArrowNavigationTarget({ - items: visibleItems, - selectedId: selectedRowKey, - selectedExternalKey, - key - }) - if (!next) { - return - } - const alreadySelected = - next.kind === 'local' - ? selectedExternalKey === null && selectedRowKey === next.id - : selectedExternalKey === next.id - if (alreadySelected) { - listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) - return - } - pendingKeyboardScrollRef.current = true - if (next.kind === 'local') { - selectExternalKey(null) - selectAutomationRow(next.id) - } else { - selectAutomationRow(null) - selectExternalKey(next.id) - setActivePaneTab('overview') - } - }, - [ - selectAutomationRow, - selectExternalKey, - selectedExternalKey, - selectedRowKey, - setActivePaneTab, - visibleItems - ] - ) - React.useEffect(() => { - if (!pendingKeyboardScrollRef.current) { - return - } - pendingKeyboardScrollRef.current = false - listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) - }, [selectedExternalKey, selectedRowKey]) + const handleSearchArrowNavigate = useAutomationListKeyboardNavigation({ + filteredRows, + filteredExternalAutomationEntries, + selectedRowKey, + selectedExternalKey, + selectAutomationRow, + selectExternalKey, + setActivePaneTab, + listRef + }) const emptyStateInput = { resolution: hostCatalog.resolution, ...searchCounts, @@ -236,8 +187,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS hostLabelById, isActionEnabled, onSelect: (rowKey: string) => { - selectExternalKey(null) selectAutomationRow(rowKey) + selectExternalKey(null) + setActivePaneTab('overview') onOpenDetail() }, onRunNow: runNow, @@ -251,9 +203,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-4 md:px-5" data-contextual-tour-target="automations-list" > -
-
-
+
+
+
- + {translate( - 'auto.components.automations.AutomationsPage.19a6e30eae', + 'auto.components.automations.AutomationsPage.refreshAutomations', 'Refresh automations' )} @@ -326,10 +278,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
{hasFilteredListItems ? ( <> @@ -343,6 +292,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS {translate('auto.components.automations.AutomationsPage.tableProject', 'Project')} + + {translate('auto.components.automations.AutomationsPage.tableHost', 'Host')} + {translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')} @@ -360,16 +312,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
- {hostCatalog.rows.groups.length > 0 ? ( - - ) : ( - - )} + ), + [repo.id]: [worktree] + } + mocks.state.sshStateByEnvironment = new Map([ + [ + RUNTIME_ID, + { + connectionStates: new Map([[SSH_TARGET_ID, { status: 'connected' }]]), + targetLabels: new Map([[SSH_TARGET_ID, 'runtime builder']]), + targetGenerations: new Map([[SSH_TARGET_ID, 2]]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]) + mocks.state.activeWorktreeId = worktree.id + mocks.repoMap.set(repo.id, repo) + mocks.worktreeMap.set(worktree.id, worktree) +} + /** The runtime answers a create; without this the RPC double returns an empty result. */ function runtimeCreateReturns(automation: Automation): void { const previous = mocks.callRuntimeRpc.getMockImplementation() @@ -163,11 +212,12 @@ describe('AutomationsPage create destination', () => { scopedList([]) runtimeHost([], []) runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() mocks.state.automationHostFilter = RUNTIME_SELF_FILTER await renderPage() await settleHostQueries() - await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) await save() // The desktop is the client's own authority, never the selected host. @@ -200,6 +250,7 @@ describe('AutomationsPage create destination', () => { scopedList([]) runtimeHost([], []) runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() await renderPage() await settleHostQueries() @@ -217,12 +268,31 @@ describe('AutomationsPage create destination', () => { mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) }) expect(mocks.editorDialog?.createDestination?.resolution.status).toBe('ready') + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) await save() expect(runtimeCreateCalls()).toHaveLength(1) expect(api.automations.create).not.toHaveBeenCalled() }) + it('prefills Runtime + SSH from the active qualified workspace', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addSshHost() + addRuntimeSshProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + + const resolution = mocks.editorDialog?.createDestination?.resolution + expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(RUNTIME_SSH_KEY) + expect(resolution?.status === 'ready' && resolution.entry.stableKey).not.toBe(SSH_HOST_KEY) + }) + it('offers only the projects the chosen host actually has', async () => { api.automations.list.mockResolvedValue([]) scopedList([]) @@ -243,6 +313,76 @@ describe('AutomationsPage create destination', () => { expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID]) }) + it('offers only workspaces verified on the chosen runtime', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + const bucket = (mocks.state.worktreesByRepo as Record)[RUNTIME_REPO_ID]! + const foreign = { + ...bucket[0], + id: 'foreign-workspace', + displayName: 'foreign', + hostId: 'local', + runtimeOwnerEnvironmentId: undefined + } as Worktree + bucket.push(foreign) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + await settleHostQueries() + + expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([ + RUNTIME_WORKSPACE_ID + ]) + }) + + it('rejects an injected workspace from another host before runtime create', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + const bucket = (mocks.state.worktreesByRepo as Record)[RUNTIME_REPO_ID]! + const foreign = { + ...bucket[0], + id: 'foreign-workspace', + displayName: 'foreign', + hostId: 'local', + runtimeOwnerEnvironmentId: undefined + } as Worktree + bucket.push(foreign) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, foreign.id) + await save() + + expect(runtimeCreateCalls()).toHaveLength(0) + expect(mocks.toastError).toHaveBeenCalledWith('Choose an available workspace before saving.') + }) + + it('does not offer or save remembered workspaces when the host cannot verify them', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + mocks.state.fetchWorktrees = vi.fn(async () => false) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + await settleHostQueries() + + expect(mocks.editorDialog?.worktrees).toEqual([]) + await save() + expect(runtimeCreateCalls()).toHaveLength(0) + expect(mocks.editorDialog?.notice?.message).toContain("couldn't verify workspaces") + }) + it('moves a stranded project to one the newly chosen host has', async () => { api.automations.list.mockResolvedValue([]) scopedList([]) @@ -349,6 +489,40 @@ describe('AutomationsPage edit fencing', () => { ) } + it('offers only workspaces verified on the edited automation’s runtime', async () => { + const automation = makeAutomation({ + id: 'a-runtime', + projectId: RUNTIME_REPO_ID, + workspaceId: RUNTIME_WORKSPACE_ID, + workspaceMode: 'existing' + }) + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([automation], []) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + const bucket = (mocks.state.worktreesByRepo as Record)[RUNTIME_REPO_ID]! + bucket.push({ + ...bucket[0], + id: 'foreign-workspace', + displayName: 'foreign', + hostId: 'local', + runtimeOwnerEnvironmentId: undefined + } as Worktree) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await settleHostQueries() + + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([RUNTIME_REPO_ID]) + expect(mocks.editorDialog?.worktrees?.map((worktree) => worktree.id)).toEqual([ + RUNTIME_WORKSPACE_ID + ]) + }) + it('fences the save even when the record cannot be re-read', async () => { const automation = makeAutomation({ id: 'a-1' }) api.automations.list.mockResolvedValue([]) @@ -363,21 +537,4 @@ describe('AutomationsPage edit fencing', () => { expect(api.automations.update).toHaveBeenCalled() expect(updatePreconditions()).not.toContain(undefined) }) - - it('refuses the save when nothing names the record’s host', async () => { - const automation = makeAutomation({ id: 'a-1' }) - // No host answered, so no owner was ever captured, and the record is gone - // from the one list the client can still address. - api.automations.listScoped.mockRejectedValue(new Error('offline')) - api.automations.list.mockResolvedValue([]) - - await renderPage() - await settleHostQueries() - // No host answered, so the page never listed a row for it; the pre-catalog - // projection is the only key the user could have clicked. - await editAndSave(unscopedAutomationListRows([automation])[0]!) - - expect(api.automations.update).not.toHaveBeenCalled() - expect(mocks.editorDialog?.notice?.message).toBeTruthy() - }) }) diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx index 17b783b49d3..015c2743ef8 100644 --- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -17,6 +17,7 @@ import { installAutomationsPageHarness, mocks, renderPage, + RUNTIME_ID, runtimeHost, scopedList, SELF_PRECONDITION, @@ -121,4 +122,71 @@ describe('AutomationsPage row actions under a colliding automation id', () => { }) expect(runtimeAutomationCalls()).not.toContain('automation.delete') }) + + it('routes a runtime orphan history, pause, and delete to its listing authority', async () => { + const desktop = makeAutomation({ id: 'a-1', name: 'Desktop nightly' }) + const runtime = makeAutomation({ id: 'a-1', name: 'Runtime orphan' }) + runtimeHost([], []) + scopedList([desktop]) + api.automations.list.mockResolvedValue([desktop]) + mocks.state.selectedAutomationId = 'a-1' + mocks.callRuntimeRpc.mockImplementation(async (_target: unknown, method: string) => { + if (method === 'automation.list') { + return { + automations: [runtime], + items: [ + { + automationId: runtime.id, + selector: { kind: 'orphan', issue: 'missing_repo' } + } + ], + orphanCount: 1 + } + } + return method === 'automation.runs' ? { runs: [] } : {} + }) + + await renderPage() + await settleHostQueries() + const runtimeRow = mocks.listPanel?.filteredRows.find( + (candidate) => candidate.automation.name === 'Runtime orphan' + ) + expect(runtimeRow).toBeDefined() + if (!runtimeRow) { + throw new Error('runtime orphan row missing') + } + expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'toggle')).toBe(true) + expect(mocks.listPanel?.isActionEnabled(runtimeRow, 'delete')).toBe(true) + + await act(async () => { + mocks.listPanel?.selectAutomationRow(runtimeRow?.key ?? '') + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(mocks.detailPane?.selected?.name).toBe('Runtime orphan') + await act(async () => { + mocks.listPanel?.toggleAutomation(runtimeRow) + }) + await act(async () => { + mocks.listPanel?.requestDeleteAutomation(runtimeRow) + }) + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + const calls = mocks.callRuntimeRpc.mock.calls.filter((call) => + ['automation.runs', 'automation.update', 'automation.delete'].includes(String(call[1])) + ) + expect(calls.map((call) => call[0])).toEqual( + expect.arrayContaining([ + { kind: 'environment', environmentId: RUNTIME_ID }, + { kind: 'environment', environmentId: RUNTIME_ID }, + { kind: 'environment', environmentId: RUNTIME_ID } + ]) + ) + expect(calls.map((call) => call[1])).toEqual( + expect.arrayContaining(['automation.runs', 'automation.update', 'automation.delete']) + ) + expect(api.automations.update).not.toHaveBeenCalled() + expect(api.automations.delete).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx index 4581a5fee7e..95e8ad2e788 100644 --- a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx @@ -99,6 +99,26 @@ describe('AutomationsPage save visibility', () => { expect(rows(container, 'automation-row')).toEqual(['Sweep']) }) + it('finishes the save while an external-manager probe is still hanging', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage() + await settleHostQueries() + + desktopStoreHolds([CREATED]) + // An unreachable host's probe never answers. The save must not wait on it: + // waiting is what left the create dialog open over an emptied form. + api.automations.listExternalManagerForOwner.mockImplementation( + () => new Promise(() => undefined) + ) + await createSweep() + + expect(mocks.editorDialog?.open).toBe(false) + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + it('lists it after the page is reopened', async () => { desktopStoreHolds([]) mocks.state.automationHostFilter = DESKTOP_SELF_FILTER diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx index 80bd234cf6b..ef85d5fe232 100644 --- a/src/renderer/src/components/automations/AutomationsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -54,15 +54,13 @@ describe('AutomationsPage list rendering', () => { expect(rows(container, 'automation-row')).toEqual(['Nightly', 'Weekly']) }) - it('falls back to the unscoped list until a host has answered', async () => { + it('does not expose unscoped bootstrap rows when no host has answered', async () => { api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })]) api.automations.listScoped.mockRejectedValue(new Error('offline')) const { container } = await renderPage() - // Why: a host that never answered is not an empty host, and the rows the - // page already had are better than none. Step 9 removes this arm. - expect(rows(container, 'automation-row')).toEqual(['Unscoped']) + expect(rows(container, 'automation-row')).toEqual([]) }) it('renders the empty state when the host has no automations', async () => { @@ -111,7 +109,7 @@ describe('AutomationsPage list rendering', () => { const entry = mocks.listPanel?.hostCatalog.entries[0] api.automations.listScoped.mockClear() await act(async () => { - mocks.listPanel?.hostCatalog.recover('retry', entry) + await mocks.listPanel?.hostCatalog.recover('retry', entry) }) // Retry re-queries that host rather than reloading the page. @@ -166,6 +164,40 @@ describe('AutomationsPage list rendering', () => { expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key) }) + it('finishes an external save while a manager probe is still hanging', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.updateExternalForOwner.mockResolvedValue(undefined) + + await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to edit') + } + + // The re-read this save triggers never answers. It must still close its + // dialog and report the result, exactly as the Orca save does: the managers + // catch up when the probe lands. + api.automations.listExternalManagerForOwner.mockImplementation( + () => new Promise(() => undefined) + ) + await act(async () => { + mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + + expect(api.automations.updateExternalForOwner).toHaveBeenCalled() + expect(mocks.editorDialog?.open).toBe(false) + expect(mocks.toastSuccess).toHaveBeenCalled() + }) + it('routes each authority’s action to its own host when both report hermes:local', async () => { api.automations.list.mockResolvedValue([]) api.automations.listExternalManagerForOwner.mockImplementation( @@ -302,6 +334,32 @@ describe('AutomationsPage host filter', () => { }) describe('AutomationsPage run navigation', () => { + it('selects the exact Runtime + SSH storage bucket from workspace provenance', async () => { + const catalogRef = { + authority: { kind: 'runtime' as const, environmentId: 'gpu' }, + selector: { kind: 'ssh' as const, targetId: 'builder' } + } + mocks.state.pendingAutomationRunNavigation = { + automationId: 'a-1', + runId: null, + hostId: 'ssh:builder', + authority: catalogRef.authority, + catalogRef + } + mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation + mocks.callRuntimeRpc.mockResolvedValue({ automations: [] }) + + await renderPage() + + expect(mocks.setAutomationHostFilter).toHaveBeenCalledWith({ kind: 'host', host: catalogRef }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'gpu' }, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + }) + it('does not leave a run navigation pending when the history read fails', async () => { api.automations.list.mockResolvedValue([ makeAutomation({ id: 'a-1' }), @@ -569,23 +627,14 @@ describe('AutomationsPage owner conflicts', () => { expect(mocks.editorDialog?.isEditing).toBe(true) }) - it('keeps the unfenced call for a row no host read has answered for', async () => { - // The host never answered, so no row carries an owner and none may be - // invented; the request goes out unfenced rather than against a guess. + it('offers no unfenced action when no host read has answered', async () => { const automation = makeAutomation({ id: 'a-1', enabled: true }) api.automations.list.mockResolvedValue([automation]) api.automations.listScoped.mockRejectedValue(new Error('offline')) await renderPage() - await act(async () => { - mocks.listPanel?.toggleAutomation(listedRow(automation.id)) - }) - - // Why: Step 9 removes this legacy arm once every row carries a captured owner. - expect(api.automations.update).toHaveBeenCalledWith({ - id: 'a-1', - updates: { enabled: false } - }) + expect(mocks.listPanel?.filteredRows).toEqual([]) + expect(api.automations.update).not.toHaveBeenCalled() }) // A mixed-host folder workspace is the path that reaches this without a host @@ -622,15 +671,4 @@ describe('AutomationsPage owner conflicts', () => { expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'history')).toBe(true) expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'run-now')).toBe(false) }) - - it('greys out the actions on a row no owner could be captured for', async () => { - const automation = makeAutomation({ id: 'a-1' }) - api.automations.list.mockResolvedValue([automation]) - api.automations.listScoped.mockRejectedValue(new Error('offline')) - - await renderPage() - - // Uncaptured is not blocked: the legacy arm still works, so nothing is greyed. - expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true) - }) }) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 9bcf18d1ea3..bf63582d319 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -18,10 +18,6 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' -import { - AUTOMATION_OWNER_CONFLICT_CODES, - automationOwnerConflictMessage -} from '../../../../shared/automation-owner-conflict' import { getLocalExecutionHostLabel, getRepoExecutionHostId, @@ -61,7 +57,10 @@ import { } from './automation-setup-decision' import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' -import { buildAutomationRunContextForRepo } from './automation-run-context' +import { + buildAutomationRunContextForDestination, + buildAutomationRunContextForRepo +} from './automation-run-context' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' @@ -72,16 +71,43 @@ import { } from './use-selected-automation-run-history' import { - deleteAutomationForTarget, type AutomationHostTarget, getAutomationHostTargetFromKey, getAutomationHostTargetKey, + getAutomationTargetFromAuthority, getAutomationTargetFromHostId, - listAutomationsForTarget, - runAutomationNowForTarget, - updateAutomationForTarget + listAutomationsForTarget } from './automation-host-client' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' + +type PendingAutomationNavigation = NonNullable< + ReturnType['pendingAutomationRunNavigation'] +> + +function automationNavigationTarget(navigation: PendingAutomationNavigation): AutomationHostTarget { + const authority = navigation.catalogRef?.authority ?? navigation.authority + return authority + ? getAutomationTargetFromAuthority(authority) + : getAutomationTargetFromHostId(navigation.hostId) +} + +function automationNavigationCatalogRef( + navigation: PendingAutomationNavigation +): StableAutomationCatalogRef { + if (navigation.catalogRef) { + return navigation.catalogRef + } + const host = parseExecutionHostId(navigation.hostId) + const authority = + navigation.authority ?? + (host?.kind === 'runtime' + ? { kind: 'runtime' as const, environmentId: host.environmentId } + : { kind: 'desktop' as const }) + return { + authority, + selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } + } +} import { AUTOMATION_DEFAULT_TIME, buildDraftPrecheck, @@ -101,7 +127,6 @@ import { useAutomationListSearch } from './use-automation-list-search' import { automationRepoForRow, automationWorktreeForRow, - unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' @@ -112,6 +137,7 @@ import type { AutomationAuthorityRef, StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { hostStableKey } from '../../../../shared/automation-owner-key' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' import { capturedAutomationOwner, @@ -129,11 +155,14 @@ import { resolveExternalAutomationScopeGate } from './external-automation-scope-gating' import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts' +import { automationRuntimePairingRevision } from './automation-authority-identity' import { - automationRuntimePairingRevision, - groupReposByAutomationAuthority -} from './automation-authority-identity' -import { automationCreateHostStableKey } from './automation-create-destination' + automationCreateEligibleProjects, + automationCreateEligibleWorktrees, + automationCreateHostStableKey, + automationCreateWorkspaceRefreshHostId, + type AutomationCreateDestination +} from './automation-create-destination' import { useAutomationCreateDestination } from './use-automation-create-destination' import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference' import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft' @@ -252,6 +281,9 @@ export default function AutomationsPage(): React.JSX.Element { // Held beside the id: a save fences on the row the user opened, and under All // hosts that id alone names two rows. const [editingRowKey, setEditingRowKey] = useState(null) + const [editingDestination, setEditingDestination] = useState( + null + ) const [relativeNow, setRelativeNow] = useState(Date.now()) const [activePaneTab, setActivePaneTab] = useState('overview') const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState( @@ -313,6 +345,7 @@ export default function AutomationsPage(): React.JSX.Element { const setupDecisionPolicyDefaultRef = useRef(undefined) const setupDecisionDefaultSignatureRef = useRef(null) const setupDecisionTouchedRef = useRef(false) + const createDestinationSelectionChangedRef = useRef(false) const automationHookCheckPromisesRef = useRef< Map> >(new Map()) @@ -340,6 +373,19 @@ export default function AutomationsPage(): React.JSX.Element { }) const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys }) + const automationHostResolution = hostCatalog.resolution + const selectAutomationHost = hostCatalog.selectHost + useEffect(() => { + if (!pendingAutomationRunNavigation) { + return + } + const catalogRef = automationNavigationCatalogRef(pendingAutomationRunNavigation) + const current = automationHostResolution.effective + if (current.kind === 'host' && hostStableKey(current.host) === hostStableKey(catalogRef)) { + return + } + selectAutomationHost({ kind: 'host', host: catalogRef }) + }, [automationHostResolution.effective, pendingAutomationRunNavigation, selectAutomationHost]) // Probing follows the selection, not the catalog: a Local selection must not // reach out to every SSH host the user happens to have registered. const externalScopeEntries = useMemo( @@ -353,12 +399,9 @@ export default function AutomationsPage(): React.JSX.Element { // Held apart from the view object, which is a fresh literal each render and // would refetch the runs table on every render of this page. const fetchScopedExternalRuns = scopedExternal.fetchRuns - // Why the cache wins once it has answered: only its rows carry the host each - // record came from, which is what the picker, the groups, and every fenced - // action key on. Until then the unscoped list is all the page has, and it has - // no host to qualify its rows with. - const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations]) - const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows + // Only cache rows carry their listing authority; bootstrap rows stay hidden + // because displaying them would expose unfenced actions. + const visibleRows = hostCatalog.rows.rows // Picking a row records both: the key names the host's copy, the id is what // the rest of the page (and other pages) still address the record by. const selectAutomationRow = useCallback( @@ -568,7 +611,7 @@ export default function AutomationsPage(): React.JSX.Element { }) ) }, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap]) - const worktrees = useMemo( + const repoWorktrees = useMemo( () => worktreesByRepo[draft.projectId] ?? [], [draft.projectId, worktreesByRepo] ) @@ -592,14 +635,12 @@ export default function AutomationsPage(): React.JSX.Element { pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId) } }, [hostCatalog.resolution.entry, runtimeEnvironments]) - // Scoped per owning authority: a repo ID is unique only inside one, so the - // flat map cannot say whether *this* authority holds the project. - const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) const activeWorkspaceHostStableKey = useMemo(() => { const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null return worktree ? automationCreateHostStableKey( - getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)) + getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)), + worktree.runtimeOwnerEnvironmentId ) : null }, [activeWorktreeId, repoMap, worktreeMap]) @@ -610,22 +651,123 @@ export default function AutomationsPage(): React.JSX.Element { // Non-null only for a concrete host filter, which constrains the destination. filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null, activeWorkspaceStableKey: activeWorkspaceHostStableKey, - repoTables, - projects: repos + projects: repos, + projectHostSetups }) - const editorProjects = createDestination.control.projects + const createDestinationControl = createDestination.control + const chooseCreateDestination = createDestinationControl.onSelect + const selectCreateDestination = useCallback( + (stableKey: string): void => { + createDestinationSelectionChangedRef.current = true + chooseCreateDestination(stableKey) + }, + [chooseCreateDestination] + ) + const editorCreateDestination = useMemo( + () => ({ ...createDestinationControl, onSelect: selectCreateDestination }), + [createDestinationControl, selectCreateDestination] + ) + const isOrcaCreate = createOpen && editingAutomationId === null && createTarget === 'orca' + const editorDestination = + isOrcaCreate && createDestination.control.resolution.status === 'ready' + ? createDestination.control.resolution + : editingDestination + const editorProjects = editorDestination + ? automationCreateEligibleProjects(editorDestination, repos, projectHostSetups) + : createDestination.control.projects + const isOrcaEditor = createOpen && createTarget === 'orca' + const [verifiedWorkspaceScopeKey, setVerifiedWorkspaceScopeKey] = useState(null) + const createWorkspaceScope = useMemo(() => { + if (!isOrcaEditor || !editorDestination || !draft.projectId) { + return null + } + return { + key: `${editorDestination.entry.stableKey}\0${draft.projectId}`, + projectId: draft.projectId, + refreshHostId: automationCreateWorkspaceRefreshHostId(editorDestination), + destination: editorDestination + } + }, [draft.projectId, editorDestination, isOrcaEditor]) + useEffect(() => { + if (!createWorkspaceScope) { + setVerifiedWorkspaceScopeKey(null) + return + } + let cancelled = false + setVerifiedWorkspaceScopeKey(null) + setEditorNotice(null) + void fetchWorktrees(createWorkspaceScope.projectId, { + executionHostId: createWorkspaceScope.refreshHostId, + requireAuthoritative: true + }).then((authoritative) => { + if (cancelled) { + return + } + if (authoritative) { + setVerifiedWorkspaceScopeKey(createWorkspaceScope.key) + return + } + setEditorNotice({ + message: translate( + 'auto.components.automations.createDestination.workspacesUnverified', + "We couldn't verify workspaces on {host}. Check the connection and try again." + ).replace('{host}', createWorkspaceScope.destination.entry.label), + recovery: null, + severity: 'owner' + }) + }) + return () => { + cancelled = true + } + }, [createWorkspaceScope, fetchWorktrees]) + const worktrees = useMemo(() => { + if (!isOrcaEditor) { + return repoWorktrees + } + if (!createWorkspaceScope || verifiedWorkspaceScopeKey !== createWorkspaceScope.key) { + return [] + } + const repo = editorProjects.find((candidate) => candidate.id === draft.projectId) + return automationCreateEligibleWorktrees( + createWorkspaceScope.destination, + repo, + repoWorktrees, + projectHostSetups + ) + }, [ + createWorkspaceScope, + draft.projectId, + editorProjects, + isOrcaEditor, + projectHostSetups, + repoWorktrees, + verifiedWorkspaceScopeKey + ]) // A destination change can strand the chosen project on another host; leaving it // selected only defers the same refusal to submit. useEffect(() => { - if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') { + if (!createOpen || createTarget !== 'orca') { + createDestinationSelectionChangedRef.current = false return } - setDraft((current) => - !current.projectId || editorProjects.some((project) => project.id === current.projectId) - ? current - : { ...current, projectId: '', workspaceId: '', baseBranch: '' } - ) - }, [createOpen, createTarget, editingAutomationId, editorProjects]) + const destinationChanged = createDestinationSelectionChangedRef.current + createDestinationSelectionChangedRef.current = false + setDraft((current) => { + if ( + !current.projectId || + editorProjects.some((project) => project.id === current.projectId) || + !destinationChanged + ) { + return current + } + return { + ...current, + projectId: editorProjects[0]?.id ?? '', + workspaceId: '', + baseBranch: '' + } + }) + }, [createOpen, createTarget, editorProjects]) // The row's own host, from its captured owner. A page-level target cannot // speak for a list spanning authorities, and the legacy arm below it is only // ever reached by rows the desktop's unscoped list produced. @@ -640,8 +782,8 @@ export default function AutomationsPage(): React.JSX.Element { [automationHostTarget, capturedAutomationOwners] ) const automationDispatchContext = useMemo( - () => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }), - [automationAuthority, capturedAutomationOwners] + () => ({ capturedOwners: capturedAutomationOwners }), + [capturedAutomationOwners] ) const notifyAuthorityChange = hostCatalog.notifyAuthorityChange // The list renders the per-host cache, so a write is only visible once that @@ -700,9 +842,7 @@ export default function AutomationsPage(): React.JSX.Element { return } const pending = pendingAutomationRunNavigation - const pendingTargetKey = getAutomationHostTargetKey( - getAutomationTargetFromHostId(pending.hostId) - ) + const pendingTargetKey = getAutomationHostTargetKey(automationNavigationTarget(pending)) if (automationHostTargetKey !== pendingTargetKey) { return } @@ -909,7 +1049,7 @@ export default function AutomationsPage(): React.JSX.Element { ? activeRepo : null const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null - const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : [] + const fallbackWorktrees = fallbackRepo && fallbackRepo.id === draft.projectId ? worktrees : [] // Why: automation-created workspaces can be active; new automations should start from // the repo's stable main worktree unless the user explicitly chooses otherwise. const targetWorktree = @@ -920,7 +1060,7 @@ export default function AutomationsPage(): React.JSX.Element { projectId: targetProjectId, workspaceId: targetWorktree?.id ?? '' } - }, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo]) + }, [activeWorktreeId, draft.projectId, editorProjects, repoMap, worktreeMap, worktrees]) const reloadExternalManagers = scopedExternal.reload @@ -931,20 +1071,20 @@ export default function AutomationsPage(): React.JSX.Element { // per-host reads have not answered for, and the client's own authority is // the only one it can address without guessing which server is meant. const automationHostTarget: AutomationHostTarget = pendingNavigation - ? getAutomationTargetFromHostId(pendingNavigation.hostId) + ? automationNavigationTarget(pendingNavigation) : { kind: 'local' } const authorityKey = automationAuthorityCatalogKey( automationHostTarget.kind === 'environment' ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } : { kind: 'desktop' } ) + // Detached rather than awaited: a manager probe reaches each host over SSH + // and can stall indefinitely, and nothing that waits on this refresh — the + // save that closes the create dialog above all — may hang behind it. It + // publishes its own managers when it lands. + void reloadExternalManagers().catch(() => undefined) try { - const [nextAutomations] = await Promise.all([ - listAutomationsForTarget(automationHostTarget), - // Managers are per host and failures are per provider, so this settles - // on its own and must not be able to fail the automation list with it. - reloadExternalManagers().catch(() => undefined) - ]) + const nextAutomations = await listAutomationsForTarget(automationHostTarget) // Selection and run history are deliberately not written here: this call // addressed one authority, and the selected row may belong to another. setAutomations(nextAutomations) @@ -964,7 +1104,7 @@ export default function AutomationsPage(): React.JSX.Element { return } const pendingTargetKey = getAutomationHostTargetKey( - getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + automationNavigationTarget(pendingAutomationRunNavigation) ) if (automationHostTargetKey !== pendingTargetKey) { void refresh() @@ -1003,7 +1143,6 @@ export default function AutomationsPage(): React.JSX.Element { useSelectedAutomationRunHistory({ selected: selectedRow, context: automationDispatchContext, - legacyTarget: automationHostTargetFor, navigation: pendingAutomationRunNavigation, reloadToken: runHistoryReloadToken, onSettled: setSelectedAutomationRuns @@ -1049,12 +1188,11 @@ export default function AutomationsPage(): React.JSX.Element { if (!draft.projectId) { return } - const available = worktreesByRepo[draft.projectId] ?? [] - const defaultWorktree = getDefaultWorktree(available) + const defaultWorktree = getDefaultWorktree(worktrees) if (!draft.workspaceId && defaultWorktree) { setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id })) } - }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + }, [draft.projectId, draft.workspaceId, worktrees]) useEffect(() => { if ( @@ -1132,6 +1270,8 @@ export default function AutomationsPage(): React.JSX.Element { editRequestRef.current += 1 const target = getDefaultTarget() setEditingAutomationId(null) + setEditingRowKey(null) + setEditingDestination(null) setEditingExternalTarget(null) setCreateTarget('orca') const baseDraft: AutomationDraft = { @@ -1176,14 +1316,19 @@ export default function AutomationsPage(): React.JSX.Element { setEditingExternalTarget(null) setCreateTarget('orca') const automationId = row.automation.id + const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner + const rowCatalogRef = row.catalogRef + const entry = rowCatalogRef + ? hostCatalog.entries.find( + (candidate) => candidate.stableKey === hostStableKey(rowCatalogRef) + ) + : null // Why: hydrate inside the row's own captured scope, not from whatever the // ambient authority happens to hold under the same automation ID. - const reread = await dispatchAutomationReread( - automationDispatchContext, - { rowKey: row.key, automationId }, - async () => - (await window.api.automations.list()).find((entry) => entry.id === automationId) ?? null - ) + const reread = await dispatchAutomationReread(automationDispatchContext, { + rowKey: row.key, + automationId + }) if (!reread.ok && reread.notice.severity === 'owner') { setOwnerActionNotice(reread.notice) return @@ -1195,6 +1340,11 @@ export default function AutomationsPage(): React.JSX.Element { } setEditingAutomationId(latest.id) setEditingRowKey(row.key) + setEditingDestination( + owner && entry + ? { authority: owner.authority, destination: { selector: owner.selector }, entry } + : null + ) const nextDraft = buildAutomationEditDraft(latest) setDraft(nextDraft) setDraftAtOpen(nextDraft) @@ -1225,6 +1375,7 @@ export default function AutomationsPage(): React.JSX.Element { }) setEditingAutomationId(null) setEditingRowKey(null) + setEditingDestination(null) setEditingExternalTarget({ manager, job, scope }) setCreateTarget('hermes') setDraft(nextDraft) @@ -1234,6 +1385,10 @@ export default function AutomationsPage(): React.JSX.Element { const handleProjectChange = useCallback( (projectId: string): void => { + if (createOpen && editingAutomationId === null && createTarget === 'orca') { + setDraft((current) => ({ ...current, projectId, workspaceId: '', baseBranch: '' })) + return + } const currentWorktrees = worktreesByRepo[projectId] ?? [] const currentDefaultWorktree = getDefaultWorktree(currentWorktrees) setDraft((current) => ({ @@ -1258,7 +1413,7 @@ export default function AutomationsPage(): React.JSX.Element { ) }) }, - [fetchWorktrees, worktreesByRepo] + [createOpen, createTarget, editingAutomationId, fetchWorktrees, worktreesByRepo] ) const saveAutomation = async (): Promise => { @@ -1315,10 +1470,79 @@ export default function AutomationsPage(): React.JSX.Element { } setIsSaving(true) try { + let checkedCreateDestination: AutomationCreateDestination | null = null + let checkedEditorDestination = editingDestination + let selectedEditorWorktree: Worktree | null = null + let editorProjectMatchesDestination = checkedEditorDestination === null + if (editingAutomationId === null && !isHermesSave) { + const checked = createDestination.check(draft.projectId) + if (!checked.ok) { + setEditorNotice(checked.notice) + return + } + checkedCreateDestination = checked.destination + checkedEditorDestination = checked.destination + } + if (!isHermesSave && createTarget === 'orca' && !checkedEditorDestination) { + setEditorNotice({ + message: translate( + 'auto.components.automations.ownerAction.failed', + 'That automation action did not complete.' + ), + recovery: 'retry', + severity: 'owner' + }) + return + } + if (!isHermesSave && createTarget === 'orca' && checkedEditorDestination) { + const currentState = useAppStore.getState() + const repo = automationCreateEligibleProjects( + checkedEditorDestination, + currentState.repos, + currentState.projectHostSetups + ).find((candidate) => candidate.id === draft.projectId) + editorProjectMatchesDestination = repo !== undefined + if (draft.workspaceMode === 'existing' && repo) { + const authoritative = await fetchWorktrees(draft.projectId, { + executionHostId: automationCreateWorkspaceRefreshHostId(checkedEditorDestination), + requireAuthoritative: true + }) + if (!authoritative) { + setEditorNotice({ + message: translate( + 'auto.components.automations.createDestination.workspacesUnverified', + "We couldn't verify workspaces on {host}. Check the connection and try again." + ).replace('{host}', checkedEditorDestination.entry.label), + recovery: null, + severity: 'owner' + }) + return + } + setVerifiedWorkspaceScopeKey( + `${checkedEditorDestination.entry.stableKey}\0${draft.projectId}` + ) + const refreshedState = useAppStore.getState() + selectedEditorWorktree = + automationCreateEligibleWorktrees( + checkedEditorDestination, + repo, + refreshedState.worktreesByRepo[draft.projectId] ?? [], + refreshedState.projectHostSetups + ).find((worktree) => worktree.id === draft.workspaceId) ?? null + } + } const selectedWorkspaceExists = - draft.workspaceMode !== 'existing' || - worktrees.some((worktree) => worktree.id === draft.workspaceId) + editorProjectMatchesDestination && + (draft.workspaceMode !== 'existing' || + (checkedEditorDestination + ? selectedEditorWorktree !== null + : worktrees.some((worktree) => worktree.id === draft.workspaceId))) if (!selectedWorkspaceExists) { + if (checkedEditorDestination) { + setDraft((current) => + current.workspaceId === draft.workspaceId ? { ...current, workspaceId: '' } : current + ) + } toast.error( translate( 'auto.components.automations.AutomationsPage.32534e7c9c', @@ -1370,6 +1594,10 @@ export default function AutomationsPage(): React.JSX.Element { if (!editingExternalTarget) { useAppStore.getState().recordFeatureInteraction('automation-created') } + // The mutation invalidated this host's managers on the authority, so the + // reload `refresh()` starts re-reads them. Not awaited, for the same + // reason it is not awaited there: an unreachable host's probe would hold + // this dialog open over an emptied form. await refresh() setCreateOpen(false) setEditingExternalTarget(null) @@ -1409,11 +1637,18 @@ export default function AutomationsPage(): React.JSX.Element { ? Math.max(0, rawMissedRunGraceMinutes) : 720 const precheck = buildDraftPrecheck(draft) - const runContext = buildAutomationRunContextForRepo({ - repoId: draft.projectId, - repos, - projectHostSetups - }) + const runContext = checkedEditorDestination + ? buildAutomationRunContextForDestination({ + repoId: draft.projectId, + destination: checkedEditorDestination, + projectHostSetups, + worktree: selectedEditorWorktree + }) + : buildAutomationRunContextForRepo({ + repoId: draft.projectId, + repos, + projectHostSetups + }) let setupDecision = resolveAutomationSetupDecisionForSave({ createTarget, workspaceMode: draft.workspaceMode, @@ -1451,14 +1686,10 @@ export default function AutomationsPage(): React.JSX.Element { if (editingAutomationId) { // Why: the conflict check reads the row's own scope, and the legacy read // under it addresses the desktop rather than whichever host was ambient. - const reread = await dispatchAutomationReread( - automationDispatchContext, - { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, - async () => - (await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find( - (automation) => automation.id === editingAutomationId - ) ?? null - ) + const reread = await dispatchAutomationReread(automationDispatchContext, { + rowKey: editingRowKey ?? '', + automationId: editingAutomationId + }) if (!reread.ok && reread.notice.severity === 'owner') { setEditorNotice(reread.notice) return @@ -1485,41 +1716,33 @@ export default function AutomationsPage(): React.JSX.Element { updates.rrule = rrule updates.dtstart = now } - const editSource = currentAutomation const saved = editingAutomationId ? await dispatchAutomationUpdate( automationDispatchContext, { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, updates, - () => { - // Nothing names a host: no captured owner and no record to read one - // from, so the save is refused rather than sent unfenced. - if (!editSource) { - throw new Error( - automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) - ) - } - return updateAutomationForTarget(editSource, updates, automationHostTarget) - }, 'save' ) - : await createDraftAutomation({ - name: draft.name, - prompt: draft.prompt, - precheck, - agentId: draft.agentId, - runContext, - projectId: draft.projectId, - workspaceMode: draft.workspaceMode, - workspaceId: draft.workspaceId, - baseBranch: draft.baseBranch.trim() || null, - setupDecision, - reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, - timezone, - rrule, - dtstart: now, - missedRunGraceMinutes - }) + : await createDraftAutomation( + { + name: draft.name, + prompt: draft.prompt, + precheck, + agentId: draft.agentId, + runContext, + projectId: draft.projectId, + workspaceMode: draft.workspaceMode, + workspaceId: draft.workspaceId, + baseBranch: draft.baseBranch.trim() || null, + setupDecision, + reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, + timezone, + rrule, + dtstart: now, + missedRunGraceMinutes + }, + checkedCreateDestination! + ) if (!saved.ok) { setEditorNotice(saved.notice) return @@ -1576,21 +1799,14 @@ export default function AutomationsPage(): React.JSX.Element { /** Creation states its destination and re-checks it at submit; it never infers one. */ const createDraftAutomation = async ( - input: AutomationCreateInput + input: AutomationCreateInput, + destination: AutomationCreateDestination ): Promise> => { - const checked = createDestination.check(input.projectId) - if (!checked.ok) { - return { ok: false, notice: checked.notice } - } const result = toDispatchResult( - await createAutomationAtDestination( - checked.destination.authority, - input, - checked.destination.destination - ) + await createAutomationAtDestination(destination.authority, input, destination.destination) ) if (result.ok) { - invalidateWrittenHost(checked.destination.entry.stableRef, 'definition') + invalidateWrittenHost(destination.entry.stableRef, 'definition') } return result } @@ -1600,13 +1816,7 @@ export default function AutomationsPage(): React.JSX.Element { const result = await dispatchAutomationUpdate( automationDispatchContext, { rowKey: row.key, automationId: automation.id }, - { enabled: !automation.enabled }, - () => - updateAutomationForTarget( - automation, - { enabled: !automation.enabled }, - automationHostTargetFor(row) - ) + { enabled: !automation.enabled } ) setOwnerActionNotice(result.ok ? null : result.notice) if (result.ok) { @@ -1617,11 +1827,10 @@ export default function AutomationsPage(): React.JSX.Element { const deleteAutomation = async (row: AutomationListRow): Promise => { const automation = row.automation - const result = await dispatchAutomationDelete( - automationDispatchContext, - { rowKey: row.key, automationId: automation.id }, - () => deleteAutomationForTarget(automation, automationHostTargetFor(row)) - ) + const result = await dispatchAutomationDelete(automationDispatchContext, { + rowKey: row.key, + automationId: automation.id + }) setOwnerActionNotice(result.ok ? null : result.notice) if (result.ok) { if (selectedRowKey === row.key) { @@ -1681,11 +1890,10 @@ export default function AutomationsPage(): React.JSX.Element { toast.error(availability.message) return } - const result = await dispatchAutomationRunNow( - automationDispatchContext, - { rowKey: row.key, automationId: automation.id }, - () => runAutomationNowForTarget(automation, rowHostTarget) - ) + const result = await dispatchAutomationRunNow(automationDispatchContext, { + rowKey: row.key, + automationId: automation.id + }) setOwnerActionNotice(result.ok ? null : result.notice) if (!result.ok) { return @@ -1711,11 +1919,10 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - const result = await dispatchAutomationRunNow( - automationDispatchContext, - { rowKey: row.key, automationId: row.automation.id }, - () => runAutomationNowForTarget(row.automation, automationHostTargetFor(row)) - ) + const result = await dispatchAutomationRunNow(automationDispatchContext, { + rowKey: row.key, + automationId: row.automation.id + }) setOwnerActionNotice(result.ok ? null : result.notice) if (!result.ok) { await refresh() @@ -2030,7 +2237,7 @@ export default function AutomationsPage(): React.JSX.Element { worktrees={worktrees} settings={settings} draft={draft} - createDestination={createDestination.control} + createDestination={editorCreateDestination} notice={editorNotice} onNoticeRecover={() => { setEditorNotice(null) @@ -2097,9 +2304,10 @@ export default function AutomationsPage(): React.JSX.Element { recoverSelectedRuns={(action) => { // Reconnect/Update server act on the selected host; the re-ask is what // brings this automation's history back either way. - hostCatalog.recover(action) + const recovered = hostCatalog.recover(action) setSelectedAutomationRuns((current) => ({ ...current, notice: null })) setRunHistoryReloadToken((token) => token + 1) + return recovered }} activePaneTab={activePaneTab} relativeNow={relativeNow} @@ -2165,10 +2373,11 @@ export default function AutomationsPage(): React.JSX.Element { externalManagersUncheckedNotice={externalManagersUncheckedNotice} onSelectHost={hostCatalog.selectHost} onRecoverHost={(action, entry) => { - hostCatalog.recover(action, entry) + const recovered = hostCatalog.recover(action, entry) if (action === 'retry') { void refresh() } + return recovered }} isActionEnabled={isAutomationRowActionEnabled} filteredRows={filteredRows} diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx index c2df447b04e..50d14cc4c20 100644 --- a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx +++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx @@ -12,6 +12,7 @@ function TableRowSkeleton({ nameWidthClass, scheduleWidthClass, projectWidthClass, + hostWidthClass, nextWidthClass, lastRunWidthClass, statusWidthClass @@ -19,6 +20,7 @@ function TableRowSkeleton({ nameWidthClass: string scheduleWidthClass: string projectWidthClass: string + hostWidthClass: string nextWidthClass: string lastRunWidthClass: string statusWidthClass: string @@ -28,6 +30,7 @@ function TableRowSkeleton({ + @@ -43,6 +46,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-28', scheduleWidthClass: 'w-36', projectWidthClass: 'w-28', + hostWidthClass: 'w-20', nextWidthClass: 'w-24', lastRunWidthClass: 'w-20', statusWidthClass: 'w-16' @@ -52,6 +56,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-36', scheduleWidthClass: 'w-28', projectWidthClass: 'w-32', + hostWidthClass: 'w-24', nextWidthClass: 'w-20', lastRunWidthClass: 'w-24', statusWidthClass: 'w-14' @@ -61,6 +66,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-24', scheduleWidthClass: 'w-40', projectWidthClass: 'w-24', + hostWidthClass: 'w-20', nextWidthClass: 'w-28', lastRunWidthClass: 'w-16', statusWidthClass: 'w-16' @@ -70,6 +76,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-32', scheduleWidthClass: 'w-32', projectWidthClass: 'w-36', + hostWidthClass: 'w-24', nextWidthClass: 'w-24', lastRunWidthClass: 'w-20', statusWidthClass: 'w-14' @@ -79,6 +86,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-40', scheduleWidthClass: 'w-24', projectWidthClass: 'w-28', + hostWidthClass: 'w-20', nextWidthClass: 'w-20', lastRunWidthClass: 'w-24', statusWidthClass: 'w-16' @@ -88,6 +96,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-28', scheduleWidthClass: 'w-36', projectWidthClass: 'w-20', + hostWidthClass: 'w-24', nextWidthClass: 'w-24', lastRunWidthClass: 'w-16', statusWidthClass: 'w-14' @@ -107,7 +116,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
- +
@@ -120,6 +129,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element { + diff --git a/src/renderer/src/components/automations/automation-authority-capability.ts b/src/renderer/src/components/automations/automation-authority-capability.ts new file mode 100644 index 00000000000..3b290cde656 --- /dev/null +++ b/src/renderer/src/components/automations/automation-authority-capability.ts @@ -0,0 +1,86 @@ +/** + * Capability gates for owner-qualified automation requests. + * + * A request fails closed on a *known* missing capability and on nothing else: + * an unreachable authority must classify as unavailable and retry, never as an + * old server the user is told to upgrade. + */ + +import { getRuntimeEnvironmentStatus } from '@/runtime/runtime-rpc-client' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { + AutomationDestination, + AutomationOwnerPrecondition +} from '../../../../shared/automation-owner-precondition' +import { + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { automationHostDiagnostics } from './automation-host-diagnostics' + +export const AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS = 15_000 + +export class AutomationHostScopeUnsupportedError extends Error { + readonly code = 'unsupported_host_scope' + + constructor(message: string) { + super(message) + this.name = 'AutomationHostScopeUnsupportedError' + } +} + +/** + * The probe is counted here because it is counted nowhere else: it deliberately + * re-fetches on every call and rides outside the scheduler's four-slot pool, so + * an instrument that saw only pooled work would report half the relay traffic a + * 50-host refresh actually costs. + */ +export async function assertAuthorityCapability( + authority: AutomationAuthorityRef, + capability: RuntimeCapability, + message: string +): Promise { + if (authority.kind !== 'runtime') { + return + } + automationHostDiagnostics.recordCapabilityProbe({ + authorityKey: automationAuthorityCatalogKey(authority) + }) + const status = await getRuntimeEnvironmentStatus( + authority.environmentId, + AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS + ) + if (!status.capabilities?.includes(capability)) { + throw new AutomationHostScopeUnsupportedError(message) + } +} + +export async function assertOwnerFencingSupported( + authority: AutomationAuthorityRef +): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE + ) +} + +/** + * Whether a mutation must fail closed on a server without owner fencing. + * + * Self is exempt: a Self record lives on the answering authority, the request + * is already pinned to the pairing revision the row was captured under, and an + * id-based mutation is exactly what a pre-fencing server performs today. SSH + * and orphan selectors still fail closed — a same-id remove/re-add could + * otherwise run or edit on a machine the user never saw. A destination + * re-attaches the record, so it always needs the fenced contract: an old server + * would ignore the field and silently leave the record where it was. + */ +export function requiresOwnerFencing( + expectedOwner: AutomationOwnerPrecondition, + destination?: AutomationDestination +): boolean { + return expectedOwner.selector.kind !== 'self' || destination !== undefined +} diff --git a/src/renderer/src/components/automations/automation-authority-identity.ts b/src/renderer/src/components/automations/automation-authority-identity.ts index 25b22f368d9..9501619c123 100644 --- a/src/renderer/src/components/automations/automation-authority-identity.ts +++ b/src/renderer/src/components/automations/automation-authority-identity.ts @@ -30,7 +30,7 @@ export function repoConnectionIdIn( const EMPTY_REPO_TABLE: ReadonlyMap = new Map() -function repoOwningAuthority(repo: Repo): StableAutomationAuthorityRef { +export function automationRepoOwningAuthority(repo: Repo): StableAutomationAuthorityRef { const host = parseExecutionHostId(getRepoExecutionHostId(repo)) // A desktop SSH repo is still desktop-stored; only a runtime host owns its own registry. return host?.kind === 'runtime' @@ -44,7 +44,7 @@ export function groupReposByAutomationAuthority( ): AutomationAuthorityRepoTables { const tables = new Map>() for (const repo of repos) { - const key = automationAuthorityCatalogKey(repoOwningAuthority(repo)) + const key = automationAuthorityCatalogKey(automationRepoOwningAuthority(repo)) let table = tables.get(key) if (!table) { table = new Map() diff --git a/src/renderer/src/components/automations/automation-captured-owner.test.ts b/src/renderer/src/components/automations/automation-captured-owner.test.ts index ffc998a5b36..f0fb496b584 100644 --- a/src/renderer/src/components/automations/automation-captured-owner.test.ts +++ b/src/renderer/src/components/automations/automation-captured-owner.test.ts @@ -63,7 +63,7 @@ function row(overrides: Partial): AutomationHostRow { describe('captured owner availability', () => { it('lets every action through on an owned row and repeats its precondition', () => { const availability = automationActionAvailability( - { owner: DESKTOP_SELF, selector: { kind: 'self' } }, + { authority: DESKTOP_SELF.authority, owner: DESKTOP_SELF, selector: { kind: 'self' } }, 'run' ) expect(availability).toEqual({ @@ -74,9 +74,14 @@ describe('captured owner availability', () => { }) it('keeps delete and pause fenceable on an orphan while refusing to run it', () => { - const orphan = { owner: null, selector: { kind: 'orphan' as const, issue: 'gone' } } + const orphan = { + authority: RUNTIME_SELF.authority, + owner: null, + selector: { kind: 'orphan' as const, issue: 'gone' } + } expect(automationActionAvailability(orphan, 'delete')).toEqual({ kind: 'orphan-fenced', + authority: RUNTIME_SELF.authority, precondition: { selector: { kind: 'orphan' } } }) expect(automationActionAvailability(orphan, 'toggle').kind).toBe('orphan-fenced') @@ -86,25 +91,52 @@ describe('captured owner availability', () => { expect(isAutomationActionEnabled(orphan, 'delete')).toBe(true) }) - it('blocks every action on a legacy row and asks for a server update', () => { + it('blocks every action on a legacy SSH row and asks for a server update', () => { // A qualified selector with no owner means the authority never sent a generation. - const legacy = { owner: null, selector: { kind: 'ssh' as const, targetId: 'box' } } + const legacy = { + authority: RUNTIME_SELF.authority, + owner: null, + selector: { kind: 'ssh' as const, targetId: 'box' } + } const availability = automationActionAvailability(legacy, 'delete') expect(availability.kind).toBe('blocked') expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server') }) - it('reports a row with no metadata as uncaptured rather than blocked', () => { - expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('uncaptured') + it('keeps a legacy Self row fully actionable through its captured authority', () => { + // The legacy partition captures the answering authority as the Self owner, + // so an old server never leaves Runtime + Self view-only. + const legacySelf = { + authority: RUNTIME_SELF.authority, + owner: RUNTIME_SELF, + selector: { kind: 'self' as const } + } + for (const action of ['edit', 'save', 'toggle', 'delete', 'run', 'history'] as const) { + expect(isAutomationActionEnabled(legacySelf, action)).toBe(true) + } + expect(automationActionAvailability(legacySelf, 'run')).toEqual({ + kind: 'owned', + owner: RUNTIME_SELF, + precondition: { selector: { kind: 'self' } } + }) + }) + + it('blocks a row with no captured authority', () => { + expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('blocked') expect(capturedAutomationOwner(new Map(), 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) expect(capturedAutomationOwner(null, 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) }) it('keys captured owners by row key', () => { const captured = captureAutomationOwners([ - { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|desktop|a-1', + authority: DESKTOP_SELF.authority, + row: row({ automation: automation({ id: 'a-1' }) }) + }, { rowKey: 'row|desktop|a-2', + authority: DESKTOP_SELF.authority, row: row({ automation: automation({ id: 'a-2' }), owner: null, @@ -118,9 +150,14 @@ describe('captured owner availability', () => { it("keeps both authorities' owners when they hold the same automation ID", () => { const captured = captureAutomationOwners([ - { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|desktop|a-1', + authority: DESKTOP_SELF.authority, + row: row({ automation: automation({ id: 'a-1' }) }) + }, { rowKey: 'row|runtime|a-1', + authority: RUNTIME_SELF.authority, row: row({ automation: automation({ id: 'a-1' }), owner: RUNTIME_SELF }) } ]) diff --git a/src/renderer/src/components/automations/automation-captured-owner.ts b/src/renderer/src/components/automations/automation-captured-owner.ts index 761dd787e78..44fec7e8938 100644 --- a/src/renderer/src/components/automations/automation-captured-owner.ts +++ b/src/renderer/src/components/automations/automation-captured-owner.ts @@ -8,12 +8,16 @@ * works if the request names the incarnation the user actually saw. * * Three outcomes, kept distinct because they mean different things to the user: - * an owned row acts normally; an orphan row still deletes and pauses under the - * orphan precondition but can never execute; a legacy row proves nothing and - * stays view-only until its authority advertises fencing. + * an owned row acts normally (a legacy Self row is owned — its authority is the + * fence); an orphan row still deletes and pauses under the orphan precondition + * but can never execute; a legacy SSH row proves nothing and stays view-only + * until its authority advertises fencing. */ -import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' import { ownerKey } from '../../../../shared/automation-owner-key' import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' import { translate } from '@/i18n/i18n' @@ -34,6 +38,8 @@ export type AutomationActionBlock = { } export type AutomationCapturedOwner = { + /** The storage authority that listed the row, including its current pairing fence. */ + authority: AutomationAuthorityRef | null owner: AutomationOwnerRef | null /** How the authority qualified the row; null when no list metadata was captured at all. */ selector: AutomationHostRowSelector | null @@ -41,16 +47,18 @@ export type AutomationCapturedOwner = { export type AutomationActionAvailability = | { kind: 'owned'; owner: AutomationOwnerRef; precondition: AutomationOwnerPrecondition } - | { kind: 'orphan-fenced'; precondition: AutomationOwnerPrecondition } + | { + kind: 'orphan-fenced' + authority: AutomationAuthorityRef + precondition: AutomationOwnerPrecondition + } | { kind: 'blocked'; block: AutomationActionBlock } - /** - * No owner metadata exists for this row yet, so the caller keeps its legacy - * unfenced path. Removed with the inference in Step 9, never in the same - * change — a mixed-version client still depends on it. - */ - | { kind: 'uncaptured' } -export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { owner: null, selector: null } +export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { + authority: null, + owner: null, + selector: null +} /** Delete and pause survive on an orphan; running it would need a host that no longer exists. */ const ORPHAN_ACTIONS: ReadonlySet = new Set(['delete', 'toggle', 'history']) @@ -89,14 +97,21 @@ export function automationActionAvailability( } } if (!captured.selector) { - return { kind: 'uncaptured' } + return { kind: 'blocked', block: unfencedBlock() } } if (captured.selector.kind === 'orphan') { + if (!captured.authority) { + return { kind: 'blocked', block: unfencedBlock() } + } return ORPHAN_ACTIONS.has(action) - ? { kind: 'orphan-fenced', precondition: ORPHAN_OWNER_PRECONDITION } + ? { + kind: 'orphan-fenced', + authority: captured.authority, + precondition: ORPHAN_OWNER_PRECONDITION + } : { kind: 'blocked', block: orphanBlock() } } - // A qualified selector with no owner is a legacy row: no generation was ever captured. + // A qualified selector with no owner is a legacy SSH row: no generation was ever captured. return { kind: 'blocked', block: unfencedBlock() } } @@ -112,6 +127,7 @@ export function isAutomationActionEnabled( export type AutomationCapturedRow = { /** `automationListRowKey`: authority-qualified and incarnation-free. */ rowKey: string + authority: AutomationAuthorityRef row: AutomationHostRow } @@ -130,8 +146,8 @@ export function captureAutomationOwners( rows: Iterable ): Map { const captured = new Map() - for (const { rowKey, row } of rows) { - captured.set(rowKey, { owner: row.owner, selector: row.selector }) + for (const { rowKey, authority, row } of rows) { + captured.set(rowKey, { authority, owner: row.owner, selector: row.selector }) } return captured } diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts index 8e2090be6fd..df6ac9b0243 100644 --- a/src/renderer/src/components/automations/automation-create-destination.test.ts +++ b/src/renderer/src/components/automations/automation-create-destination.test.ts @@ -4,8 +4,12 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { + automationCreateEligibleProjects, + automationCreateEligibleWorktrees, automationCreateHostStableKey, automationCreateProjectMismatch, + automationCreateSetupMatchesDestination, + automationCreateWorkspaceRefreshHostId, preselectAutomationCreateHost, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -13,6 +17,8 @@ import { } from './automation-create-destination' import { groupReposByAutomationAuthority } from './automation-authority-identity' import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ProjectHostSetup } from '../../../../shared/project-types' function entry(overrides: Partial = {}): AutomationHostCatalogEntry { return { @@ -141,6 +147,7 @@ describe('create host stable key', () => { // A desktop SSH workspace is still desktop-stored; only the selector differs. expect(automationCreateHostStableKey('ssh:box')).toBe('host:desktop:ssh:box') expect(automationCreateHostStableKey('runtime:gpu')).toBe('host:runtime:gpu:self') + expect(automationCreateHostStableKey('ssh:box', 'gpu')).toBe('host:runtime:gpu:ssh:box') expect(automationCreateHostStableKey(null)).toBeNull() }) }) @@ -195,4 +202,150 @@ describe('create project mismatch', () => { // The desktop's own table is a verdict, so its miss does refuse. expect(automationCreateProjectMismatch(tables, desktopSelf, 'missing')).toBe(true) }) + + it('offers only concrete repo rows owned by the selected authority and host', () => { + const local = repo({ id: 'shared' }) + const runtime = repo({ id: 'shared', executionHostId: 'runtime:gpu' }) + const runtimeSsh = repo({ + id: 'nested', + connectionId: 'builder', + executionHostId: 'runtime:gpu' + }) + const runtimeSelf = { + authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 }, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + expect(automationCreateEligibleProjects(runtimeSelf, [local, runtime, runtimeSsh], [])).toEqual( + [runtime] + ) + expect( + automationCreateEligibleProjects( + { + ...runtimeSelf, + destination: { + selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 } + } + }, + [local, runtime, runtimeSsh], + [] + ) + ).toEqual([runtimeSsh]) + }) +}) + +describe('create workspace destination', () => { + const runtimeSelf = { + authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 }, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + const runtimeRepo = { + id: 'shared', + displayName: 'orca', + path: '/runtime/orca', + badgeColor: '#000000', + addedAt: 1, + executionHostId: 'runtime:gpu' + } as Repo + + function worktree(overrides: Partial): Worktree { + return { + id: 'shared::/runtime/orca', + repoId: 'shared', + displayName: 'main', + path: '/runtime/orca', + branch: 'main', + ...overrides + } as Worktree + } + + it('keeps equal repo/workspace ids partitioned by runtime owner', () => { + const local = worktree({ hostId: 'local' }) + const runtime = worktree({ + hostId: 'runtime:gpu', + runtimeOwnerEnvironmentId: 'gpu' + }) + const otherRuntime = worktree({ + hostId: 'runtime:other', + runtimeOwnerEnvironmentId: 'other' + }) + expect( + automationCreateEligibleWorktrees(runtimeSelf, runtimeRepo, [local, runtime, otherRuntime]) + ).toEqual([runtime]) + }) + + it('partitions nested SSH work by both authority and target', () => { + const destination = { + ...runtimeSelf, + destination: { + selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 } + } + } + const repo = { ...runtimeRepo, connectionId: 'builder' } + const owned = worktree({ + hostId: 'ssh:builder', + runtimeOwnerEnvironmentId: 'gpu' + }) + const desktop = worktree({ hostId: 'ssh:builder' }) + expect(automationCreateEligibleWorktrees(destination, repo, [owned, desktop])).toEqual([owned]) + expect(automationCreateWorkspaceRefreshHostId(destination)).toBe('runtime:gpu') + }) + + it('matches the run setup to the same destination', () => { + const setup = { + id: 'setup-runtime', + projectId: 'project', + repoId: 'shared', + hostId: 'runtime:gpu', + executionHostId: 'runtime:gpu', + runtimeOwnerEnvironmentId: 'gpu', + path: '/runtime/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } as ProjectHostSetup + expect(automationCreateSetupMatchesDestination(setup, runtimeSelf)).toBe(true) + expect( + automationCreateSetupMatchesDestination( + { ...setup, runtimeOwnerEnvironmentId: 'other', hostId: 'runtime:other' }, + runtimeSelf + ) + ).toBe(false) + }) + + it('keeps folder workspaces pinned to the selected SSH host', () => { + const destination = { + authority: { kind: 'desktop' as const }, + destination: { + selector: { kind: 'ssh' as const, targetId: 'builder', targetGeneration: 2 } + }, + entry: entry() + } + const repo = { ...runtimeRepo, executionHostId: 'local' as const } + const setup = { + id: 'folder-builder', + projectId: 'project', + repoId: repo.id, + hostId: 'ssh:builder', + executionHostId: 'ssh:builder', + path: '/builder/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } as ProjectHostSetup + const folder = worktree({ + hostId: 'ssh:builder', + projectHostSetupId: setup.id, + path: '/builder/orca' + }) + expect(automationCreateEligibleProjects(destination, [repo], [setup])).toEqual([repo]) + expect(automationCreateEligibleWorktrees(destination, repo, [folder], [setup])).toEqual([ + folder + ]) + }) }) diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts index c3c4b2a94e9..79f14405042 100644 --- a/src/renderer/src/components/automations/automation-create-destination.ts +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -15,17 +15,30 @@ import type { AutomationAuthorityRef, - AutomationOwnerRef + AutomationOwnerRef, + StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' import { hostStableKey, isSameAutomationOwner } from '../../../../shared/automation-owner-key' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + getWorktreeExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' import type { AutomationCatalogHydrationEvidence, AutomationHostCatalogEntry } from './automation-host-catalog-types' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' import { + automationRepoOwningAuthority, repoConnectionIdIn, type AutomationAuthorityRepoTables } from './automation-authority-identity' @@ -121,21 +134,26 @@ export function soleAutomationCreateHost( } /** The catalog host a workspace's execution host names, for the All-hosts pre-fill. */ -export function automationCreateHostStableKey(hostId: string | null | undefined): string | null { +export function automationCreateHostStableKey( + hostId: string | null | undefined, + runtimeOwnerEnvironmentId?: string | null +): string | null { const host = parseExecutionHostId(hostId) - if (!host) { + const runtimeOwner = runtimeOwnerEnvironmentId?.trim() + if (!host && !runtimeOwner) { return null } - if (host.kind === 'runtime') { + if (runtimeOwner || host?.kind === 'runtime') { + const environmentId = runtimeOwner || (host?.kind === 'runtime' ? host.environmentId : '') return hostStableKey({ - authority: { kind: 'runtime', environmentId: host.environmentId }, - selector: { kind: 'self' } + authority: { kind: 'runtime', environmentId }, + selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } }) } // A desktop SSH workspace is still desktop-stored; only the selector differs. return hostStableKey({ authority: { kind: 'desktop' }, - selector: host.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } + selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } }) } @@ -162,17 +180,143 @@ export function automationCreateProjectMismatch( return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null } +function authoritiesMatch( + left: StableAutomationAuthorityRef, + right: StableAutomationAuthorityRef +): boolean { + return ( + left.kind === right.kind && + (left.kind === 'desktop' || + (right.kind === 'runtime' && left.environmentId === right.environmentId)) + ) +} + +function connectionMatchesDestination( + connectionId: string | null | undefined, + destination: AutomationCreateDestination +): boolean { + const normalized = connectionId?.trim() || null + const selector = destination.destination.selector + return selector.kind === 'ssh' ? normalized === selector.targetId : normalized === null +} + +export function automationCreateRepoMatchesDestination( + repo: Repo, + destination: AutomationCreateDestination +): boolean { + return ( + authoritiesMatch(automationRepoOwningAuthority(repo), destination.authority) && + connectionMatchesDestination(repo.connectionId, destination) + ) +} + /** * The projects a destination can hold, filtered by the rule submit already * enforces, so the form cannot offer a pairing its own check will refuse. */ -export function automationCreateEligibleProjects( - tables: AutomationAuthorityRepoTables, +export function automationCreateEligibleProjects( destination: AutomationCreateDestination, - projects: readonly TProject[] -): TProject[] { + projects: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[] +): Repo[] { return projects.filter( - (project) => !automationCreateProjectMismatch(tables, destination, project.id) + (project) => + automationCreateRepoMatchesDestination(project, destination) || + projectHostSetups.some( + (setup) => + setup.repoId === project.id && + setup.setupState === 'ready' && + automationCreateSetupMatchesDestination(setup, destination) + ) + ) +} + +function destinationExecutionHostId(destination: AutomationCreateDestination): ExecutionHostId { + const selector = destination.destination.selector + if (selector.kind === 'ssh') { + return toSshExecutionHostId(selector.targetId) + } + return destination.authority.kind === 'runtime' + ? toRuntimeExecutionHostId(destination.authority.environmentId) + : LOCAL_EXECUTION_HOST_ID +} + +/** The host whose workspace catalog the client can authoritatively refresh. */ +export function automationCreateWorkspaceRefreshHostId( + destination: AutomationCreateDestination +): ExecutionHostId { + return destination.authority.kind === 'runtime' + ? toRuntimeExecutionHostId(destination.authority.environmentId) + : destinationExecutionHostId(destination) +} + +function runtimeOwnerMatches( + runtimeOwnerEnvironmentId: string | null | undefined, + hostId: ExecutionHostId | null, + destination: AutomationCreateDestination +): boolean { + if (destination.authority.kind === 'desktop') { + return !runtimeOwnerEnvironmentId && parseExecutionHostId(hostId)?.kind !== 'runtime' + } + if (runtimeOwnerEnvironmentId) { + return runtimeOwnerEnvironmentId === destination.authority.environmentId + } + const host = parseExecutionHostId(hostId) + return host?.kind === 'runtime' && host.environmentId === destination.authority.environmentId +} + +export function automationCreateWorktreeMatchesDestination( + worktree: Worktree, + repo: Repo, + destination: AutomationCreateDestination, + projectHostSetups: readonly ProjectHostSetup[] = [] +): boolean { + if (worktree.repoId !== repo.id) { + return false + } + const setup = worktree.projectHostSetupId + ? projectHostSetups.find( + (candidate) => + candidate.id === worktree.projectHostSetupId && + automationCreateSetupMatchesDestination(candidate, destination) + ) + : undefined + const hostId = + normalizeExecutionHostId(worktree.hostId) ?? + normalizeExecutionHostId(setup?.executionHostId) ?? + normalizeExecutionHostId(setup?.hostId) ?? + getWorktreeExecutionHostId(worktree, repo) + return ( + runtimeOwnerMatches( + worktree.runtimeOwnerEnvironmentId ?? setup?.runtimeOwnerEnvironmentId, + hostId, + destination + ) && hostId === destinationExecutionHostId(destination) + ) +} + +export function automationCreateEligibleWorktrees( + destination: AutomationCreateDestination, + repo: Repo | null | undefined, + worktrees: readonly Worktree[], + projectHostSetups: readonly ProjectHostSetup[] = [] +): Worktree[] { + return repo + ? worktrees.filter((worktree) => + automationCreateWorktreeMatchesDestination(worktree, repo, destination, projectHostSetups) + ) + : [] +} + +export function automationCreateSetupMatchesDestination( + setup: ProjectHostSetup, + destination: AutomationCreateDestination +): boolean { + const transportHostId = normalizeExecutionHostId(setup.hostId) + const executionHostId = normalizeExecutionHostId(setup.executionHostId) ?? transportHostId + return ( + runtimeOwnerMatches(setup.runtimeOwnerEnvironmentId, transportHostId, destination) && + executionHostId === destinationExecutionHostId(destination) ) } diff --git a/src/renderer/src/components/automations/automation-host-cache-rows.ts b/src/renderer/src/components/automations/automation-host-cache-rows.ts index 9d8f2888efe..fbf2932b203 100644 --- a/src/renderer/src/components/automations/automation-host-cache-rows.ts +++ b/src/renderer/src/components/automations/automation-host-cache-rows.ts @@ -2,8 +2,11 @@ * Turns an authority's answer into cache rows. * * Scoped rows keep the owner they were fetched under so every later action can - * fence on it. Legacy rows keep no owner and no usage: the old response proves - * neither, and the design forbids compensating with run-history downloads. + * fence on it. Legacy SSH and orphan rows keep no owner and no usage: the old + * response proves neither, and the design forbids compensating with run-history + * downloads. Legacy Self rows do carry an owner — a Self record lives on the + * answering authority itself and mutates by id under the pairing-revision + * fence, so an old server degrades nothing for it. */ import type { Automation } from '../../../../shared/automations-types' @@ -83,9 +86,12 @@ function legacySelectorMatches( /** * Fans one unscoped response out to every entry that asked for it, so an old - * runtime is queried once per cycle rather than once per selector. + * runtime is queried once per cycle rather than once per selector. `authority` + * is the incarnation the answer was fetched under; it becomes the captured + * owner of the Self rows and of nothing else. */ export function partitionLegacyAutomationHostRows( + authority: AutomationAuthorityRef, automations: readonly Automation[], refs: readonly StableAutomationCatalogRef[], context: LegacyAutomationPartitionContext, @@ -100,7 +106,9 @@ export function partitionLegacyAutomationHostRows( .filter((row) => legacySelectorMatches(row.selector, ref)) .map((row) => ({ automation: row.automation, - owner: null, + // Self needs no registration generation, so it is fenceable even from + // an unscoped answer; SSH and orphan rows stay ownerless and view-only. + owner: row.selector.kind === 'self' ? { authority, selector: { kind: 'self' } } : null, selector: row.selector, usageSummary: null, usageKnown: false diff --git a/src/renderer/src/components/automations/automation-host-cache-types.ts b/src/renderer/src/components/automations/automation-host-cache-types.ts index 29f0eab65f3..d1883d60df0 100644 --- a/src/renderer/src/components/automations/automation-host-cache-types.ts +++ b/src/renderer/src/components/automations/automation-host-cache-types.ts @@ -18,7 +18,7 @@ export type AutomationHostRowSelector = AutomationListItemSelector | LegacyAutom export type AutomationHostRow = { automation: Automation - /** Null when nothing may be mutated through it: legacy unscoped rows and orphans. */ + /** Null when nothing may be mutated through it: legacy unscoped SSH rows and orphans. */ owner: AutomationOwnerRef | null selector: AutomationHostRowSelector usageSummary: AutomationUsageSummary | null diff --git a/src/renderer/src/components/automations/automation-host-catalog-types.ts b/src/renderer/src/components/automations/automation-host-catalog-types.ts index 4aa37d0617c..7489eefbba0 100644 --- a/src/renderer/src/components/automations/automation-host-catalog-types.ts +++ b/src/renderer/src/components/automations/automation-host-catalog-types.ts @@ -26,7 +26,11 @@ export type AutomationExecutionHealth = | 'unavailable' | 'unknown' -/** What list/fencing contract this entry can use. `legacy-unscoped` entries are view-only. */ +/** + * What list contract this entry can use. `legacy-unscoped` picks the one + * unscoped-list-per-authority transport; whether the entry is view-only is + * `scopeGap`'s call — Self stays fully usable on a legacy authority. + */ export type AutomationHostQuerySupport = 'scoped' | 'legacy-unscoped' | 'incompatible' /** @@ -52,7 +56,7 @@ export type AutomationHostCatalogState = 'authoritative' | 'unhydrated' | 'remov export type AutomationHostCatalogEntry = { stableRef: StableAutomationCatalogRef - /** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy). */ + /** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy SSH). */ owner: AutomationOwnerRef | null stableKey: string label: string @@ -62,7 +66,7 @@ export type AutomationHostCatalogEntry = { authorityHealth: AutomationAuthorityHealth executionHealth: AutomationExecutionHealth querySupport: AutomationHostQuerySupport - /** Present only while `querySupport` is degraded; explains which repair, if any, applies. */ + /** The reason this entry is view-only, and which repair applies; absent when it is not. */ scopeGap?: AutomationHostScopeGap } diff --git a/src/renderer/src/components/automations/automation-host-catalog.test.ts b/src/renderer/src/components/automations/automation-host-catalog.test.ts index 2eae8530b02..c53bd6db04d 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.test.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.test.ts @@ -259,7 +259,7 @@ describe('buildAutomationHostCatalog', () => { expect(entry.executionHealth).toBe('unknown') }) - it('keeps a legacy runtime view-only: no generations, no owners', () => { + it("keeps only a legacy runtime's SSH entries view-only; Self stays fully usable", () => { const catalog = buildAutomationHostCatalog( buildInput({ runtimes: [ @@ -273,8 +273,17 @@ describe('buildAutomationHostCatalog', () => { const nested = entryAt(catalog, runtimeSshKey('env-1', 'legacy')) expect(nested.querySupport).toBe('legacy-unscoped') expect(nested.owner).toBeNull() - // Self needs no target generation, so it stays owned even on an old server. - expect(entryAt(catalog, runtimeSelfKey('env-1')).owner).not.toBeNull() + expect(nested.scopeGap).toBe('authority-unscoped') + // Self needs no target generation: it keeps its owner and records no scope + // gap, so an old server never renders Runtime + Self as View only. + const self = entryAt(catalog, runtimeSelfKey('env-1')) + expect(self.owner).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 7 }, + selector: { kind: 'self' } + }) + expect(self.scopeGap).toBeUndefined() + // Still the legacy transport: one unscoped list per authority per cycle. + expect(self.querySupport).toBe('legacy-unscoped') }) it('downgrades a scoped entry whose target carries no generation', () => { diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts index 71e445e546c..28d3ae5170c 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -119,8 +119,10 @@ function projectSelfEntry(ctx: AuthorityProjectionContext): AutomationHostCatalo catalogState: 'authoritative', authorityHealth: ctx.authorityHealth, executionHealth: resolveSelfExecutionHealth(ctx.authorityHealth), - querySupport: ctx.querySupport, - ...withScopeGap(authorityScopeGap(ctx.querySupport)) + // No scope gap even on a legacy authority: Self records live on the + // answering authority and mutate by id under the pairing-revision fence, + // so the unscoped contract degrades the list transport, not access. + querySupport: ctx.querySupport } ) } diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index 7a3aaaa8d49..ac0f31aca55 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Automation } from '../../../../shared/automations-types' -import { - listAutomationRunsForTarget, - listAutomationsForTarget, - runAutomationNowForTarget, - updateAutomationForTarget -} from './automation-host-client' +import { listAutomationRunsForTarget, listAutomationsForTarget } from './automation-host-client' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' vi.mock('@/runtime/runtime-rpc-client', () => ({ @@ -102,58 +97,4 @@ describe('automation host client', () => { ) expect(mockApi.automations.listRuns).toHaveBeenCalledWith({ automationId: 'auto-1' }) }) - - it('manually runs runtime-host automations through that server', async () => { - const automation = makeAutomation() - vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ - run: { id: 'run-1', automationId: automation.id } - }) - - await runAutomationNowForTarget(automation) - - expect(mockApi.automations.runNow).not.toHaveBeenCalled() - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'gpu' }, - 'automation.runNow', - { id: automation.id }, - { timeoutMs: 15_000 } - ) - }) - - it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { - const automation = makeAutomation({ - runContext: { - kind: 'workspace-run', - projectId: 'github:stablyai/orca', - hostId: 'ssh:devbox', - projectHostSetupId: 'setup-devbox', - repoId: 'repo-1', - path: '/srv/orca' - } - }) - const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' } - vi.mocked(callRuntimeRpc) - .mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } }) - .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) - - await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget) - await runAutomationNowForTarget(automation, sourceTarget) - - expect(mockApi.automations.update).not.toHaveBeenCalled() - expect(mockApi.automations.runNow).not.toHaveBeenCalled() - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 1, - sourceTarget, - 'automation.update', - { id: automation.id, updates: { name: 'Updated' } }, - { timeoutMs: 15_000 } - ) - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 2, - sourceTarget, - 'automation.runNow', - { id: automation.id }, - { timeoutMs: 15_000 } - ) - }) }) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 271d02319e8..933908c14ef 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -1,16 +1,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import type { - Automation, - AutomationRun, - AutomationUpdateInput -} from '../../../../shared/automations-types' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { parseExecutionHostId } from '../../../../shared/execution-host' -type RuntimeAutomationUpdateInput = Omit & { - repo?: string - workspace?: string -} - export type AutomationHostTarget = | { kind: 'local' } | { kind: 'environment'; environmentId: string } @@ -38,25 +30,12 @@ export function getAutomationTargetFromHostId( : { kind: 'local' } } -export function getAutomationOwnerTarget( - automation: Pick, - sourceTarget?: AutomationHostTarget | null +export function getAutomationTargetFromAuthority( + authority: StableAutomationAuthorityRef ): AutomationHostTarget { - if (sourceTarget?.kind === 'environment') { - return sourceTarget - } - return getAutomationTargetFromHostId(automation.runContext?.hostId) -} - -function toRuntimeAutomationUpdateInput( - input: AutomationUpdateInput -): RuntimeAutomationUpdateInput { - const { projectId, workspaceId, ...rest } = input - return { - ...rest, - ...(projectId !== undefined ? { repo: projectId } : {}), - ...(workspaceId !== undefined ? { workspace: workspaceId ?? undefined } : {}) - } + return authority.kind === 'runtime' + ? { kind: 'environment', environmentId: authority.environmentId } + : { kind: 'local' } } export async function listAutomationsForTarget( @@ -94,50 +73,3 @@ export async function listAutomationRunsForTarget( ) return result.runs } - -export async function updateAutomationForTarget( - automation: Automation, - updates: AutomationUpdateInput, - sourceTarget?: AutomationHostTarget | null -): Promise { - const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - return await window.api.automations.update({ id: automation.id, updates }) - } - const result = await callRuntimeRpc<{ automation: Automation }>( - target, - 'automation.update', - { id: automation.id, updates: toRuntimeAutomationUpdateInput(updates) }, - { timeoutMs: 15_000 } - ) - return result.automation -} - -export async function deleteAutomationForTarget( - automation: Automation, - sourceTarget?: AutomationHostTarget | null -): Promise { - const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - await window.api.automations.delete({ id: automation.id }) - return - } - await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) -} - -export async function runAutomationNowForTarget( - automation: Automation, - sourceTarget?: AutomationHostTarget | null -): Promise { - const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - return await window.api.automations.runNow({ id: automation.id }) - } - const result = await callRuntimeRpc<{ run: AutomationRun }>( - target, - 'automation.runNow', - { id: automation.id }, - { timeoutMs: 15_000 } - ) - return result.run -} diff --git a/src/renderer/src/components/automations/automation-host-list-rows.ts b/src/renderer/src/components/automations/automation-host-list-rows.ts index 68b37e34431..bcc1915ec6b 100644 --- a/src/renderer/src/components/automations/automation-host-list-rows.ts +++ b/src/renderer/src/components/automations/automation-host-list-rows.ts @@ -121,6 +121,15 @@ export function resolveAutomationHostListRows( const capturedRows: AutomationCapturedRow[] = [] let answered = false + const authorityByKey = new Map( + input.catalog.entries.flatMap((entry) => { + const authority = entry.owner?.authority + return authority + ? [[automationAuthorityCatalogKey(entry.stableRef.authority), authority] as const] + : [] + }) + ) + for (const entry of entries) { const cached = input.entry(entry.stableKey) if (cached && cached.fetchedAt !== null) { @@ -143,7 +152,11 @@ export function resolveAutomationHostListRows( hostLabel: entry.label, usageSummary: row.usageSummary ?? null } - capturedRows.push({ rowKey: listRow.key, row }) + const authority = authorityByKey.get(authorityKey) + if (!authority) { + continue + } + capturedRows.push({ rowKey: listRow.key, authority, row }) rows.push(listRow) hostRows.push(listRow) } diff --git a/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts b/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts new file mode 100644 index 00000000000..9c59a6ac6c8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery-connect.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight' + +const mocks = vi.hoisted(() => ({ + connectRuntimeEnvironmentAndRecordStatus: vi.fn(), + hydrateRuntimeEnvironmentSshState: vi.fn(), + connectRuntimeEnvironmentSshTarget: vi.fn(), + resyncRuntimeEnvironmentSshTargets: vi.fn(), + toastError: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn() +})) + +vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({ + connectRuntimeEnvironmentAndRecordStatus: (...args: unknown[]) => + mocks.connectRuntimeEnvironmentAndRecordStatus(...args) +})) + +vi.mock('@/runtime/runtime-environment-ssh-state', () => ({ + connectRuntimeEnvironmentSshTarget: (...args: unknown[]) => + mocks.connectRuntimeEnvironmentSshTarget(...args), + hydrateRuntimeEnvironmentSshState: (...args: unknown[]) => + mocks.hydrateRuntimeEnvironmentSshState(...args), + resyncRuntimeEnvironmentSshTargets: (...args: unknown[]) => + mocks.resyncRuntimeEnvironmentSshTargets(...args) +})) + +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => mocks.toastError(...args) } +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + setSshConnectionState: mocks.setSshConnectionState, + setSshTargetsMetadata: mocks.setSshTargetsMetadata, + setRemovedSshTargetLabels: mocks.setRemovedSshTargetLabels + }) + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { + connectAutomationHostRuntime, + connectAutomationHostSshTarget +} from './automation-host-recovery-connect' + +function installSshApi(connect: ReturnType): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ssh: { + connect, + listTargets: vi.fn().mockResolvedValue([]), + listRemovedTargetLabels: vi.fn().mockResolvedValue({}) + } + } + }) +} + +describe('automation host recovery connect', () => { + beforeEach(() => { + resetSshConnectInFlightForTests() + for (const fn of Object.values(mocks)) { + fn.mockReset() + } + mocks.hydrateRuntimeEnvironmentSshState.mockResolvedValue(undefined) + mocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue({ status: 'connected' }) + mocks.resyncRuntimeEnvironmentSshTargets.mockResolvedValue(undefined) + installSshApi(vi.fn().mockResolvedValue({ status: 'connected' })) + }) + + afterEach(() => { + resetSshConnectInFlightForTests() + }) + + it('records runtime status so the catalog can leave Unreachable', async () => { + mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(true) + + await connectAutomationHostRuntime('gpu') + + expect(mocks.connectRuntimeEnvironmentAndRecordStatus).toHaveBeenCalledWith('gpu', 15_000) + expect(mocks.hydrateRuntimeEnvironmentSshState).toHaveBeenCalledWith('gpu') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('toasts when the runtime stays unreachable', async () => { + mocks.connectRuntimeEnvironmentAndRecordStatus.mockResolvedValue(false) + + await connectAutomationHostRuntime('gpu') + + expect(mocks.toastError).toHaveBeenCalledWith('Remote host is not reachable') + expect(mocks.hydrateRuntimeEnvironmentSshState).not.toHaveBeenCalled() + }) + + it('writes the local SSH connect result into the store the catalog reads', async () => { + const connect = vi.fn().mockResolvedValue({ status: 'connected', targetId: 't1' }) + installSshApi(connect) + + await connectAutomationHostSshTarget({ targetId: 't1' }) + + expect(connect).toHaveBeenCalledWith({ targetId: 't1' }) + expect(mocks.setSshConnectionState).toHaveBeenCalledWith('t1', { + status: 'connected', + targetId: 't1' + }) + expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled() + }) + + it('dials a runtime SSH target through that server, not the local SSH API', async () => { + const connect = vi.fn() + installSshApi(connect) + + await connectAutomationHostSshTarget({ targetId: 't1', environmentId: 'gpu' }) + + expect(mocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith('gpu', 't1') + expect(connect).not.toHaveBeenCalled() + }) + + it('toasts a failed local SSH connect instead of swallowing it', async () => { + installSshApi(vi.fn().mockRejectedValue(new Error('SSH target "t1" not found'))) + + await connectAutomationHostSshTarget({ targetId: 't1' }) + + expect(mocks.toastError).toHaveBeenCalledWith('SSH target "t1" not found') + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-recovery-connect.ts b/src/renderer/src/components/automations/automation-host-recovery-connect.ts new file mode 100644 index 00000000000..6a60d246432 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery-connect.ts @@ -0,0 +1,94 @@ +/** + * Performs the transport dials `runAutomationHostRecovery` selected. + * + * The recovery module only names the host; this is the other half — write the + * returned status into the store the catalog actually reads. A fire-and-forget + * `ssh.connect` / `runtimeEnvironments.connect` leaves Unreachable on screen + * even when the backend came back. + */ + +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { connectRuntimeEnvironmentAndRecordStatus } from '@/components/status-bar/runtime-environment-explicit-connect' +import { + connectRuntimeEnvironmentSshTarget, + hydrateRuntimeEnvironmentSshState, + resyncRuntimeEnvironmentSshTargets +} from '@/runtime/runtime-environment-ssh-state' +import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight' +import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' + +const RUNTIME_RECONNECT_TIMEOUT_MS = 15_000 + +const runtimeConnectInFlight = new Set() + +export async function connectAutomationHostRuntime(environmentId: string): Promise { + if (runtimeConnectInFlight.has(environmentId)) { + return + } + runtimeConnectInFlight.add(environmentId) + try { + const reachable = await connectRuntimeEnvironmentAndRecordStatus( + environmentId, + RUNTIME_RECONNECT_TIMEOUT_MS + ) + if (!reachable) { + toast.error( + translate( + 'auto.components.automations.hostStatus.action.runtimeConnectFailed', + 'Remote host is not reachable' + ) + ) + return + } + // Why: the catalog projects this environment's SSH targets from the bucket; + // a successful ping with a stale empty bucket still looks unverified. + await hydrateRuntimeEnvironmentSshState(environmentId).catch(() => {}) + } finally { + runtimeConnectInFlight.delete(environmentId) + } +} + +export async function connectAutomationHostSshTarget(args: { + targetId: string + environmentId?: string +}): Promise { + const { targetId, environmentId } = args + if (isSshConnectInFlight(targetId)) { + return + } + try { + if (environmentId) { + await trackSshConnect(targetId, connectRuntimeEnvironmentSshTarget(environmentId, targetId)) + return + } + const connectState = await withUiConnectTimeout( + trackSshConnect(targetId, window.api.ssh.connect({ targetId })), + SSH_RECONNECT_UI_TIMEOUT_MS + ) + if (connectState) { + // Why: ssh.connect can resolve before the global state-change IPC lands. + useAppStore.getState().setSshConnectionState(targetId, connectState) + } + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.automations.hostStatus.action.sshConnectFailed', + 'SSH connection failed' + ) + ) + if (environmentId) { + void resyncRuntimeEnvironmentSshTargets(environmentId).catch(() => {}) + return + } + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) + } +} diff --git a/src/renderer/src/components/automations/automation-host-recovery.test.ts b/src/renderer/src/components/automations/automation-host-recovery.test.ts index 8d6419fb128..801e2b5d567 100644 --- a/src/renderer/src/components/automations/automation-host-recovery.test.ts +++ b/src/renderer/src/components/automations/automation-host-recovery.test.ts @@ -44,23 +44,30 @@ const RUNTIME_SSH = entry({ }) describe('automation host recovery', () => { - it('retries the host rather than the whole page', () => { + it('retries the host rather than the whole page', async () => { const target = deps() - runAutomationHostRecovery('retry', DESKTOP_SSH, target) + await runAutomationHostRecovery('retry', DESKTOP_SSH, target) expect(target.retry).toHaveBeenCalledWith(DESKTOP_SSH) }) - it('dials the SSH target when the authority is fine', () => { + it('dials the SSH target when the authority is fine', async () => { const target = deps() - runAutomationHostRecovery('reconnect', DESKTOP_SSH, target) - expect(target.connectSshTarget).toHaveBeenCalledWith('t1') + await runAutomationHostRecovery('reconnect', DESKTOP_SSH, target) + expect(target.connectSshTarget).toHaveBeenCalledWith('t1', undefined) expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled() }) - it('dials the runtime first when the server itself is unreachable', () => { + it('dials a reachable runtime SSH target on that server, not the local SSH API', async () => { + const target = deps() + await runAutomationHostRecovery('reconnect', RUNTIME_SSH, target) + expect(target.connectSshTarget).toHaveBeenCalledWith('t1', 'gpu') + expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled() + }) + + it('dials the runtime first when the server itself is unreachable', async () => { const target = deps() // Why: an unreachable server cannot be asked to dial its own SSH target. - runAutomationHostRecovery( + await runAutomationHostRecovery( 'reconnect', { ...RUNTIME_SSH, authorityHealth: 'unavailable' }, target @@ -69,30 +76,30 @@ describe('automation host recovery', () => { expect(target.connectSshTarget).not.toHaveBeenCalled() }) - it('re-asks a desktop Self host, which has no transport to dial', () => { + it('re-asks a desktop Self host, which has no transport to dial', async () => { const target = deps() const desktopSelf = entry({ stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } }) - runAutomationHostRecovery('reconnect', desktopSelf, target) + await runAutomationHostRecovery('reconnect', desktopSelf, target) expect(target.retry).toHaveBeenCalledWith(desktopSelf) }) - it('sends a runtime to the servers pane, since nothing here updates a host', () => { + it('sends a runtime to the servers pane, since nothing here updates a host', async () => { const target = deps() - runAutomationHostRecovery('update-server', RUNTIME_SSH, target) + await runAutomationHostRecovery('update-server', RUNTIME_SSH, target) expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) }) - it('sends a stale desktop SSH registration to the SSH pane instead', () => { + it('sends a stale desktop SSH registration to the SSH pane instead', async () => { const target = deps() - runAutomationHostRecovery('update-server', DESKTOP_SSH, target) + await runAutomationHostRecovery('update-server', DESKTOP_SSH, target) expect(target.openSettings).toHaveBeenCalledWith({ pane: 'ssh', repoId: null }) }) - it('does nothing without an entry to act on', () => { + it('does nothing without an entry to act on', async () => { const target = deps() - runAutomationHostRecovery('retry', null, target) + await runAutomationHostRecovery('retry', null, target) expect(target.retry).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/automations/automation-host-recovery.ts b/src/renderer/src/components/automations/automation-host-recovery.ts index bc71f08ff18..b942b23effc 100644 --- a/src/renderer/src/components/automations/automation-host-recovery.ts +++ b/src/renderer/src/components/automations/automation-host-recovery.ts @@ -14,9 +14,10 @@ import type { AutomationHostRecoveryAction } from './automation-host-status-desc export type AutomationHostRecoveryDeps = { /** Forces one fresh query for this host, bypassing TTL and retry cooldown. */ - retry: (entry: AutomationHostCatalogEntry) => void - connectSshTarget: (targetId: string) => void - connectRuntimeEnvironment: (environmentId: string) => void + retry: (entry: AutomationHostCatalogEntry) => void | Promise + /** `environmentId` is the owning runtime; omit it for a desktop SSH target. */ + connectSshTarget: (targetId: string, environmentId?: string) => void | Promise + connectRuntimeEnvironment: (environmentId: string) => void | Promise openSettings: (target: SettingsNavigationTarget) => void } @@ -30,39 +31,45 @@ function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavig return { pane: entry.stableRef.selector.kind === 'ssh' ? 'ssh' : 'automations', repoId: null } } -function reconnect(entry: AutomationHostCatalogEntry, deps: AutomationHostRecoveryDeps): void { +async function reconnect( + entry: AutomationHostCatalogEntry, + deps: AutomationHostRecoveryDeps +): Promise { const authority = entry.stableRef.authority // Authority first: an unreachable server cannot be asked to dial its own targets. if (authority.kind === 'runtime' && entry.authorityHealth === 'unavailable') { - deps.connectRuntimeEnvironment(authority.environmentId) + await deps.connectRuntimeEnvironment(authority.environmentId) return } if (entry.stableRef.selector.kind === 'ssh') { - deps.connectSshTarget(entry.stableRef.selector.targetId) + await deps.connectSshTarget( + entry.stableRef.selector.targetId, + authority.kind === 'runtime' ? authority.environmentId : undefined + ) return } if (authority.kind === 'runtime') { - deps.connectRuntimeEnvironment(authority.environmentId) + await deps.connectRuntimeEnvironment(authority.environmentId) return } // Desktop Self has no transport to dial, so the only honest fallback is to re-ask. - deps.retry(entry) + await deps.retry(entry) } -export function runAutomationHostRecovery( +export async function runAutomationHostRecovery( action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry | null, deps: AutomationHostRecoveryDeps -): void { +): Promise { if (!entry) { return } switch (action) { case 'retry': - deps.retry(entry) + await deps.retry(entry) return case 'reconnect': - reconnect(entry, deps) + await reconnect(entry, deps) return case 'update-server': deps.openSettings(versionSettingsTarget(entry)) diff --git a/src/renderer/src/components/automations/automation-host-scheduler.test.ts b/src/renderer/src/components/automations/automation-host-scheduler.test.ts index 7942eb49fe8..a473b472f79 100644 --- a/src/renderer/src/components/automations/automation-host-scheduler.test.ts +++ b/src/renderer/src/components/automations/automation-host-scheduler.test.ts @@ -360,21 +360,31 @@ describe('automation host scheduler legacy path', () => { expect(listRuns).not.toHaveBeenCalled() }) - it('leaves legacy rows unfenced and their usage unavailable', async () => { + it('fences legacy Self rows on the answering authority and leaves SSH rows unfenced', async () => { const cache = createCache() const scheduler = createAutomationHostScheduler({ cache, legacyPartitionContext: () => LOCAL_REPO, isVisible: () => true, - transport: { listLegacy: () => Promise.resolve([automation('a')]) } + transport: { + listLegacy: () => + Promise.resolve([ + automation('a'), + automation('b', { executionTargetType: 'ssh', executionTargetId: 't1' }) + ]) + } }) + const legacy = { querySupport: 'legacy-unscoped' as const } await scheduler.refresh([ - target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'legacy-unscoped' }) + target(selfRef(RUNTIME_AUTHORITY), legacy), + target(sshRef(RUNTIME_AUTHORITY, 't1'), legacy) ]) - const row = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0] - expect(row?.owner).toBeNull() - expect(row?.usageKnown).toBe(false) - expect(row?.usageSummary).toBeNull() + const selfRow = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0] + // Self mutates by id on its own authority, so the unscoped answer still fences it. + expect(selfRow?.owner).toEqual({ authority: RUNTIME_AUTHORITY, selector: { kind: 'self' } }) + expect(selfRow?.usageKnown).toBe(false) + expect(selfRow?.usageSummary).toBeNull() + expect(cache.get(sshRef(RUNTIME_AUTHORITY, 't1'))?.data[0]?.owner).toBeNull() }) it('records an incompatible host without sending anything', async () => { diff --git a/src/renderer/src/components/automations/automation-host-scheduler.ts b/src/renderer/src/components/automations/automation-host-scheduler.ts index 310e2bd0c8b..2658d562f66 100644 --- a/src/renderer/src/components/automations/automation-host-scheduler.ts +++ b/src/renderer/src/components/automations/automation-host-scheduler.ts @@ -218,6 +218,7 @@ export function createAutomationHostScheduler( // Attributed to the authority alone: one answer serves every entry in the group. instrument.response(live[0].fence, null, startedAt, automations.length, automations) const partition = partitionLegacyAutomationHostRows( + authority, automations, live.map((target) => target.ref), options.legacyPartitionContext(live[0].ref.authority), diff --git a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts index 1f5fb04e4d1..08bfa4c6d4f 100644 --- a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts +++ b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts @@ -8,10 +8,11 @@ import type { AutomationHostStatusDescriptor } from './automation-host-status-de /** * Copy for the query-contract axis. * - * Every degraded entry shares one consequence — view only — but not one cause. - * A removed target, a target we have not verified since the connection dropped, - * and a server that advertises no host scoping each get their own sentence, so - * the badge never claims a server is old on the strength of missing evidence. + * Every recorded scope gap shares one consequence — view only — but not one + * cause. A removed target, a target we have not verified since the connection + * dropped, and a server that advertises no host scoping each get their own + * sentence, so the badge never claims a server is old on the strength of + * missing evidence. A degraded transport alone earns no badge: only a gap does. */ const VIEW_ONLY_LABEL = (): string => @@ -67,13 +68,10 @@ function scopeGapDescriptor(gap: AutomationHostScopeGap): AutomationHostStatusDe } } -/** Null for `scoped`; the degraded contracts stay distinct rather than sharing one badge. */ +/** Null when nothing is view-only; the degraded contracts stay distinct rather than sharing one badge. */ export function hostScopeDescriptor( entry: Pick ): AutomationHostStatusDescriptor | null { - if (entry.querySupport === 'scoped') { - return null - } if (entry.querySupport === 'incompatible') { return { id: 'query-incompatible', @@ -89,6 +87,7 @@ export function hostScopeDescriptor( isDefault: false } } - // An unscoped contract with no recorded cause can only be the authority's own. - return scopeGapDescriptor(entry.scopeGap ?? 'authority-unscoped') + // A degraded list transport with no recorded gap has no view-only consequence: + // Runtime + Self on a legacy server still mutates by id under the pairing fence. + return entry.scopeGap ? scopeGapDescriptor(entry.scopeGap) : null } diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts index 9a9262b7130..def38143851 100644 --- a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts @@ -92,10 +92,9 @@ describe('automation host status descriptors', () => { expect(new Set(gapIds).size).toBe(SCOPE_GAPS.length) }) - it('falls back to the authority contract when no cause was recorded', () => { - expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))?.id).toBe( - 'query-legacy-unscoped' - ) + it('shows no view-only badge for a degraded transport with no recorded gap', () => { + // Runtime + Self on a legacy server: the list is unscoped but access is not. + expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))).toBeNull() }) it('blames the server only when the server itself answered without host scoping', () => { @@ -168,6 +167,17 @@ describe('automation host recovery actions', () => { ).toBeNull() }) + it('keeps normal recovery verbs for a legacy authority entry with no scope gap', () => { + expect( + automationHostRecoveryActions(entry({ querySupport: 'legacy-unscoped' })).authority + ).toBeNull() + expect( + automationHostRecoveryActions( + entry({ querySupport: 'legacy-unscoped', authorityHealth: 'stale-error' }) + ).authority + ).toBe('retry') + }) + it('reconnects rather than retries when the authority itself is unreachable', () => { expect(automationHostRecoveryActions(entry({ authorityHealth: 'unavailable' })).authority).toBe( 'reconnect' diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.ts index c73df4288ca..03ba1836991 100644 --- a/src/renderer/src/components/automations/automation-host-status-descriptors.ts +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.ts @@ -212,25 +212,23 @@ function authorityRecoveryAction( // The transport to the authority is down, so retrying the query alone cannot help. return 'reconnect' } - if (entry.querySupport !== 'scoped') { + // A degraded transport with no gap needs no repair: Runtime + Self on a + // legacy server stays fully usable, so its recovery verbs are the normal ones. + if (entry.scopeGap) { return scopeGapRecoveryAction(entry.scopeGap) } return entry.authorityHealth === 'stale-error' ? 'retry' : null } /** Updating a server only repairs a server that actually answered without host scoping. */ -function scopeGapRecoveryAction( - gap: AutomationHostScopeGap | undefined -): AutomationHostRecoveryAction | null { +function scopeGapRecoveryAction(gap: AutomationHostScopeGap): AutomationHostRecoveryAction | null { switch (gap) { case 'target-unverified': return 'reconnect' case 'target-removed': case 'target-unregistered': return null - // An unscoped contract with no recorded cause can only be the authority's own. case 'authority-unscoped': - case undefined: return 'update-server' } } diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts index 577f323319f..75891fa4f62 100644 --- a/src/renderer/src/components/automations/automation-list-row-identity.ts +++ b/src/renderer/src/components/automations/automation-list-row-identity.ts @@ -29,22 +29,18 @@ export type AutomationListRow = { /** Authority-qualified and incarnation-free; see the module note. */ key: string automation: Automation - /** Storage authority captured with the host query; absent only for the bootstrap list. */ + /** Storage authority captured with the host query. */ catalogRef?: StableAutomationCatalogRef | null - /** The host this row actually came from; empty for unscoped legacy rows. */ + /** The host this row actually came from. */ hostLabel: string /** What the owning authority reported; null means unknown, never zero. */ usageSummary: AutomationUsageSummary | null } const ROW_KEY_NAMESPACE = 'row' -/** The pre-catalog list has no host to qualify with, so it says so rather than guessing one. */ -const UNSCOPED_HOST_KEY = 'unscoped' -export function automationListRowKey(hostStableKey: string | null, automationId: string): string { - return [ROW_KEY_NAMESPACE, hostStableKey ?? UNSCOPED_HOST_KEY, automationId] - .map(encodeURIComponent) - .join('|') +export function automationListRowKey(hostStableKey: string, automationId: string): string { + return [ROW_KEY_NAMESPACE, hostStableKey, automationId].map(encodeURIComponent).join('|') } /** @@ -57,19 +53,6 @@ export function automationAuthorityRecordKey(authorityKey: string, automationId: return [authorityKey, automationId].map(encodeURIComponent).join('|') } -/** The unscoped list the page shows before any host has answered. */ -export function unscopedAutomationListRows( - automations: readonly Automation[] -): AutomationListRow[] { - return automations.map((automation) => ({ - key: automationListRowKey(null, automation.id), - automation, - catalogRef: null, - hostLabel: '', - usageSummary: null - })) -} - export function automationRepoForRow( row: AutomationListRow, repos: readonly Repo[], diff --git a/src/renderer/src/components/automations/automation-owner-action-runner.ts b/src/renderer/src/components/automations/automation-owner-action-runner.ts index b25baa16d08..8a3eb1db3ab 100644 --- a/src/renderer/src/components/automations/automation-owner-action-runner.ts +++ b/src/renderer/src/components/automations/automation-owner-action-runner.ts @@ -23,6 +23,7 @@ import { stripAutomationOwnerConflictCode, type AutomationOwnerConflictCode } from '../../../../shared/automation-owner-conflict' +import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code' import type { AutomationAuthorityRef, AutomationOwnerRef @@ -96,6 +97,15 @@ function classify(error: unknown): AutomationActionOutcome { } } } + if (hasRuntimeRpcErrorCode(error, 'selector_not_found')) { + return { + status: 'failed', + message: translate( + 'auto.components.automations.ownerAction.workspaceNotFound', + "We couldn't find that workspace on this host. Choose a workspace that exists here and try again." + ) + } + } return { status: 'failed', message: @@ -117,13 +127,10 @@ async function attempt( availability: AutomationActionAvailability, owned: (owner: AutomationOwnerRef) => Promise, orphanFenced?: () => Promise -): Promise | { status: 'uncaptured' }> { +): Promise> { if (availability.kind === 'blocked') { return { status: 'blocked', block: availability.block } } - if (availability.kind === 'uncaptured') { - return { status: 'uncaptured' } - } try { if (availability.kind === 'owned') { return { status: 'ok', value: await owned(availability.owner) } @@ -148,14 +155,10 @@ function orphanUnsupported(): AutomationActionBlock { } } -export type AutomationActionResult = - | AutomationActionOutcome - /** The caller must fall back to its unfenced path; Step 9 deletes this arm. */ - | { status: 'uncaptured' } +export type AutomationActionResult = AutomationActionOutcome export async function updateOwnedAutomation( availability: AutomationActionAvailability, - authority: AutomationAuthorityRef, id: string, updates: AutomationUpdateInput, destination?: AutomationDestination @@ -163,19 +166,24 @@ export async function updateOwnedAutomation( return await attempt( availability, (owner) => updateAutomationForOwner(owner, id, updates, destination), - () => updateOrphanAutomation(authority, id, updates) + () => + availability.kind === 'orphan-fenced' + ? updateOrphanAutomation(availability.authority, id, updates) + : Promise.reject(new Error('automation_owner_uncaptured')) ) } export async function deleteOwnedAutomation( availability: AutomationActionAvailability, - authority: AutomationAuthorityRef, id: string ): Promise> { return await attempt( availability, (owner) => deleteAutomationForOwner(owner, id), - () => deleteOrphanAutomation(authority, id) + () => + availability.kind === 'orphan-fenced' + ? deleteOrphanAutomation(availability.authority, id) + : Promise.reject(new Error('automation_owner_uncaptured')) ) } @@ -203,13 +211,15 @@ export async function showOwnedAutomation( export async function listOwnedAutomationRuns( availability: AutomationActionAvailability, - authority: AutomationAuthorityRef, automationId: string ): Promise> { return await attempt( availability, (owner) => listAutomationRunsForOwner(owner, automationId), - () => listOrphanAutomationRuns(authority, automationId) + () => + availability.kind === 'orphan-fenced' + ? listOrphanAutomationRuns(availability.authority, automationId) + : Promise.reject(new Error('automation_owner_uncaptured')) ) } diff --git a/src/renderer/src/components/automations/automation-row-action-dispatch.ts b/src/renderer/src/components/automations/automation-row-action-dispatch.ts index cf8a619abbd..c2447496115 100644 --- a/src/renderer/src/components/automations/automation-row-action-dispatch.ts +++ b/src/renderer/src/components/automations/automation-row-action-dispatch.ts @@ -1,12 +1,5 @@ /** - * One place where a row action decides between its fenced path and its legacy - * one, so the page's handlers stay three lines each. - * - * The order is deliberate. A captured owner always wins. A row the authority - * qualified but could not make executable is refused with the reason. Only a - * row with no owner metadata at all falls back to the unfenced call, and that - * arm exists solely so mixed-version clients keep working until the inference - * is removed — it is the legacy path, not a default. + * One place where every row action applies its captured owner or fails closed. */ import type { @@ -14,7 +7,6 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' -import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { automationActionAvailability, capturedAutomationOwner, @@ -46,7 +38,6 @@ export type AutomationActionNotice = { export type AutomationDispatchContext = { /** Keyed by `AutomationListRow.key`; see {@link AutomationDispatchRow}. */ capturedOwners: ReadonlyMap - authority: AutomationAuthorityRef } /** @@ -69,7 +60,7 @@ export function dispatched(value: TValue): AutomationDispatchResult( - outcome: Exclude, { status: 'uncaptured' }> + outcome: AutomationActionResult ): AutomationDispatchResult { switch (outcome.status) { case 'ok': @@ -92,100 +83,58 @@ async function dispatch( action: AutomationRowAction, fenced: ( availability: ReturnType - ) => Promise>, - legacy: () => Promise + ) => Promise> ): Promise> { const availability = automationActionAvailability( capturedAutomationOwner(context.capturedOwners, row.rowKey), action ) - const outcome = await fenced(availability) - if (outcome.status !== 'uncaptured') { - return toDispatchResult(outcome) - } - try { - return { ok: true, value: await legacy() } - } catch (error) { - return { - ok: false, - notice: { - message: error instanceof Error ? error.message : String(error), - recovery: 'retry', - severity: 'failure' - } - } - } + return toDispatchResult(await fenced(availability)) } export async function dispatchAutomationUpdate( context: AutomationDispatchContext, row: AutomationDispatchRow, updates: AutomationUpdateInput, - legacy: () => Promise, action: AutomationRowAction = 'toggle' ): Promise> { - return await dispatch( - context, - row, - action, - (availability) => - updateOwnedAutomation(availability, context.authority, row.automationId, updates), - legacy + return await dispatch(context, row, action, (availability) => + updateOwnedAutomation(availability, row.automationId, updates) ) } export async function dispatchAutomationDelete( context: AutomationDispatchContext, - row: AutomationDispatchRow, - legacy: () => Promise + row: AutomationDispatchRow ): Promise> { - return await dispatch( - context, - row, - 'delete', - (availability) => deleteOwnedAutomation(availability, context.authority, row.automationId), - legacy + return await dispatch(context, row, 'delete', (availability) => + deleteOwnedAutomation(availability, row.automationId) ) } export async function dispatchAutomationRunNow( context: AutomationDispatchContext, - row: AutomationDispatchRow, - legacy: () => Promise + row: AutomationDispatchRow ): Promise> { - return await dispatch( - context, - row, - 'run', - (availability) => runOwnedAutomationNow(availability, row.automationId), - legacy + return await dispatch(context, row, 'run', (availability) => + runOwnedAutomationNow(availability, row.automationId) ) } export async function dispatchAutomationReread( context: AutomationDispatchContext, - row: AutomationDispatchRow, - legacy: () => Promise + row: AutomationDispatchRow ): Promise> { - return await dispatch( - context, - row, - 'edit', - (availability) => showOwnedAutomation(availability, row.automationId), - legacy + return await dispatch(context, row, 'edit', (availability) => + showOwnedAutomation(availability, row.automationId) ) } export async function dispatchAutomationRunHistory( context: AutomationDispatchContext, - row: AutomationDispatchRow, - legacy: () => Promise + row: AutomationDispatchRow ): Promise> { - return await dispatch( - context, - row, - 'history', - (availability) => listOwnedAutomationRuns(availability, context.authority, row.automationId), - legacy + return await dispatch(context, row, 'history', (availability) => + listOwnedAutomationRuns(availability, row.automationId) ) } diff --git a/src/renderer/src/components/automations/automation-run-context.test.ts b/src/renderer/src/components/automations/automation-run-context.test.ts index b2f1dc2f4ae..04d2687c19e 100644 --- a/src/renderer/src/components/automations/automation-run-context.test.ts +++ b/src/renderer/src/components/automations/automation-run-context.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' -import { buildAutomationRunContextForRepo } from './automation-run-context' +import { + buildAutomationRunContextForDestination, + buildAutomationRunContextForRepo +} from './automation-run-context' +import type { AutomationCreateDestination } from './automation-create-destination' function repo(id: string, path = `/repos/${id}`): Repo { return { @@ -73,3 +77,39 @@ describe('buildAutomationRunContextForRepo', () => { ).toBeNull() }) }) + +describe('buildAutomationRunContextForDestination', () => { + const destination = { + authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 }, + destination: { selector: { kind: 'self' as const } }, + entry: {} as AutomationCreateDestination['entry'] + } + + it('does not borrow a same-repo setup from another host', () => { + const local = setup({ + id: 'setup-local', + hostId: 'local', + repoId: 'repo-builder', + path: '/local/orca' + }) + const runtime = setup({ + id: 'setup-runtime', + hostId: 'runtime:gpu', + executionHostId: 'runtime:gpu', + runtimeOwnerEnvironmentId: 'gpu', + repoId: 'repo-builder', + path: '/runtime/orca' + }) + expect( + buildAutomationRunContextForDestination({ + repoId: 'repo-builder', + destination, + projectHostSetups: [local, runtime] + }) + ).toMatchObject({ + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-runtime', + path: '/runtime/orca' + }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-context.ts b/src/renderer/src/components/automations/automation-run-context.ts index 49894f78159..7c61fb2dc35 100644 --- a/src/renderer/src/components/automations/automation-run-context.ts +++ b/src/renderer/src/components/automations/automation-run-context.ts @@ -4,6 +4,11 @@ import { } from '../../../../shared/task-source-context' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + automationCreateSetupMatchesDestination, + type AutomationCreateDestination +} from './automation-create-destination' export function buildAutomationRunContextForRepo(args: { repoId: string @@ -28,3 +33,30 @@ export function buildAutomationRunContextForRepo(args: { path: setup.path || repo.path }) } + +export function buildAutomationRunContextForDestination(args: { + repoId: string + destination: AutomationCreateDestination + projectHostSetups: readonly ProjectHostSetup[] + worktree?: Worktree | null +}): WorkspaceRunContext | null { + const candidates = args.projectHostSetups.filter( + (candidate) => + candidate.repoId === args.repoId && + candidate.setupState === 'ready' && + automationCreateSetupMatchesDestination(candidate, args.destination) + ) + const setup = args.worktree?.projectHostSetupId + ? candidates.find((candidate) => candidate.id === args.worktree?.projectHostSetupId) + : candidates[0] + if (!setup) { + return null + } + return buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }) +} diff --git a/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts b/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts new file mode 100644 index 00000000000..9a952f8685c --- /dev/null +++ b/src/renderer/src/components/automations/automation-runtime-workspace-selector.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector' + +describe('runtimeAutomationWorkspaceSelector', () => { + it('prefixes a stored worktree id the way the CLI does', () => { + expect(runtimeAutomationWorkspaceSelector('repo-1::/tmp/orca/feature')).toBe( + 'id:repo-1::/tmp/orca/feature' + ) + }) + + it('leaves an already-qualified selector alone', () => { + expect(runtimeAutomationWorkspaceSelector('id:repo-1::/tmp/orca/feature')).toBe( + 'id:repo-1::/tmp/orca/feature' + ) + expect(runtimeAutomationWorkspaceSelector('path:/tmp/orca/feature')).toBe( + 'path:/tmp/orca/feature' + ) + }) + + it('omits an empty workspace', () => { + expect(runtimeAutomationWorkspaceSelector(undefined)).toBeUndefined() + expect(runtimeAutomationWorkspaceSelector(null)).toBeUndefined() + expect(runtimeAutomationWorkspaceSelector('')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts b/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts new file mode 100644 index 00000000000..b2316d869f6 --- /dev/null +++ b/src/renderer/src/components/automations/automation-runtime-workspace-selector.ts @@ -0,0 +1,16 @@ +/** + * Runtime `automation.create` / `update` resolve `workspace` through + * `showManagedWorktree`. The CLI always sends `id:`; a bare stored + * id is a remote-only miss (`selector_not_found`) because local IPC never + * parses a selector. + */ +const WORKTREE_SELECTOR_PREFIX = /^(id|path|name|branch|issue):/ + +export function runtimeAutomationWorkspaceSelector( + workspaceId: string | null | undefined +): string | undefined { + if (!workspaceId) { + return undefined + } + return WORKTREE_SELECTOR_PREFIX.test(workspaceId) ? workspaceId : `id:${workspaceId}` +} diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index 50e0c5886d8..a249c04e832 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -28,6 +28,7 @@ const SSH_OWNER = { authority: RUNTIME, selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } as const +const SELF_OWNER = { authority: RUNTIME, selector: { kind: 'self' } } as const const ALL_CAPABILITIES = { capabilities: [ @@ -135,6 +136,53 @@ describe('listScopedAutomations', () => { }) }) +describe('listAutomationsForOwner', () => { + // Rule: never trust a scoped list from a server that ignored the selector. + // The Self read degrades to the unscoped list plus a client-side partition. + it('falls back to an unscoped Self read on a host without host scoping', async () => { + const { listAutomationsForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + callRuntimeRpc.mockResolvedValue({ + automations: [ + { id: 'a1', executionTargetType: 'local' }, + { id: 'a2', executionTargetType: 'ssh', executionTargetId: 't1' }, + { id: 'a3', executionTargetType: 'local', schedulerOwner: 'remote_host_service' } + ] + }) + const result = await listAutomationsForOwner(SELF_OWNER) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.list', + null, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + expect(result.automations.map((automation) => automation.id)).toEqual(['a1']) + expect(result.items).toEqual([{ automationId: 'a1', selector: { kind: 'self' } }]) + }) + + it('keeps failing closed for an SSH owner on a host without host scoping', async () => { + const { listAutomationsForOwner, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + await expect(listAutomationsForOwner(SSH_OWNER)).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('keeps the scoped read on a capable host', async () => { + const { listAutomationsForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listAutomationsForOwner(SELF_OWNER) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.list', + { selector: { kind: 'self' } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) +}) + describe('owner-fenced mutations', () => { it('sends the captured owner with the mutation', async () => { const { updateAutomationForOwner } = await client() @@ -160,6 +208,104 @@ describe('owner-fenced mutations', () => { expect(callRuntimeRpc).not.toHaveBeenCalled() }) + // Self records live on the answering authority and mutate by id under the + // pairing-revision fence, so a pre-fencing server stays fully usable for them. + it('mutates Self on a runtime without owner fencing instead of failing closed', async () => { + const { deleteAutomationForOwner, runAutomationNowForOwner, updateAutomationForOwner } = + await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' }, run: { id: 'r1' } }) + await updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false }) + await deleteAutomationForOwner(SELF_OWNER, 'a1') + await runAutomationNowForOwner(SELF_OWNER, 'a1') + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({ + id: 'a1', + updates: { enabled: false }, + expectedOwner: { selector: { kind: 'self' } }, + destination: undefined + }) + expect(callRuntimeRpc.mock.calls.map((call) => call[3])).toEqual( + Array.from({ length: 3 }, () => + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + ) + }) + + // An old server ignores `destination` and would silently leave the record in + // place, so a Self move still needs the fenced contract. + it('still fails a Self destination move closed on a host without owner fencing', async () => { + const { updateAutomationForOwner, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + await expect( + updateAutomationForOwner(SELF_OWNER, 'a1', { enabled: false }, { selector: { kind: 'self' } }) + ).rejects.toBeInstanceOf(AutomationHostScopeUnsupportedError) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('creates at a Self destination without probing for owner fencing', async () => { + const { createAutomationForDestination } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await createAutomationForDestination( + RUNTIME, + { name: 'n', prompt: 'p', projectId: 'repo-1' } as never, + { selector: { kind: 'self' } } + ) + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('sends an existing workspace as an id: selector on the runtime', async () => { + const { createAutomationForDestination } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await createAutomationForDestination( + RUNTIME, + { + name: 'n', + prompt: 'p', + projectId: 'repo-1', + workspaceMode: 'existing', + workspaceId: 'repo-1::/tmp/orca/feature' + } as never, + { selector: { kind: 'self' } } + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.create', + expect.objectContaining({ + repo: 'repo-1', + workspace: 'id:repo-1::/tmp/orca/feature', + destination: { selector: { kind: 'self' } } + }), + expect.anything() + ) + }) + + it('translates edited project and workspace fields for the runtime RPC', async () => { + const { updateAutomationForOwner } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + + await updateAutomationForOwner(SELF_OWNER, 'a1', { + projectId: 'repo-2', + workspaceMode: 'existing', + workspaceId: 'repo-2::/tmp/orca/other' + }) + + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.update', + expect.objectContaining({ + updates: { + repo: 'repo-2', + workspaceMode: 'existing', + workspace: 'id:repo-2::/tmp/orca/other' + } + }), + expect.anything() + ) + }) + it('fences a desktop mutation through IPC with the same precondition', async () => { const { deleteAutomationForOwner, updateAutomationForOwner } = await client() update.mockResolvedValue({ id: 'a1' }) diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts index 6eec4ce430e..1ffc63cb040 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -9,11 +9,7 @@ * that would attribute other hosts' automations to the selected one. */ -import { - callRuntimeRpc, - getRuntimeEnvironmentStatus, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { Automation, AutomationCreateInput, @@ -25,6 +21,7 @@ import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' import { validateAutomationListResponse } from '../../../../shared/automation-list-response' +import { partitionLegacyAutomationList } from '../../../../shared/automation-legacy-list-partition' import type { AutomationAuthorityRef, AutomationOwnerRef @@ -37,24 +34,18 @@ import type { } from '../../../../shared/automation-owner-precondition' import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, - type RuntimeCapability + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE } from '../../../../shared/protocol-version' -import { automationAuthorityCatalogKey } from './automation-host-catalog-types' -import { automationHostDiagnostics } from './automation-host-diagnostics' - -const REQUEST_TIMEOUT_MS = 15_000 - -export class AutomationHostScopeUnsupportedError extends Error { - readonly code = 'unsupported_host_scope' - - constructor(message: string) { - super(message) - this.name = 'AutomationHostScopeUnsupportedError' - } -} +import { + AUTOMATION_AUTHORITY_REQUEST_TIMEOUT_MS as REQUEST_TIMEOUT_MS, + assertAuthorityCapability, + assertOwnerFencingSupported, + AutomationHostScopeUnsupportedError, + requiresOwnerFencing +} from './automation-authority-capability' +import { runtimeAutomationWorkspaceSelector } from './automation-runtime-workspace-selector' +/** Re-exported so existing call sites keep importing the error from this module. */ +export { AutomationHostScopeUnsupportedError } from './automation-authority-capability' export class AutomationListResponseError extends Error { readonly code = 'invalid_response' @@ -118,41 +109,6 @@ async function callAuthority( }) } -/** - * Fails closed on a missing capability, but only on a *known* absence: an - * unreachable authority must classify as unavailable and retry, not as an old - * server the user is told to upgrade. - * - * The probe is counted here because it is counted nowhere else: it deliberately - * re-fetches on every call and rides outside the scheduler's four-slot pool, so - * an instrument that saw only pooled work would report half the relay traffic a - * 50-host refresh actually costs. - */ -async function assertAuthorityCapability( - authority: AutomationAuthorityRef, - capability: RuntimeCapability, - message: string -): Promise { - if (authority.kind !== 'runtime') { - return - } - automationHostDiagnostics.recordCapabilityProbe({ - authorityKey: automationAuthorityCatalogKey(authority) - }) - const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) - if (!status.capabilities?.includes(capability)) { - throw new AutomationHostScopeUnsupportedError(message) - } -} - -async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise { - await assertAuthorityCapability( - authority, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE - ) -} - function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList { const validation = validateAutomationListResponse(raw, selector) if (!validation.ok) { @@ -180,8 +136,9 @@ export async function listScopedAutomations( /** * The one unscoped request an old runtime gets per refresh cycle. Its result is - * partitioned client-side into every requested entry; it carries no owners and - * no usage projection, so those rows stay view-only. + * partitioned client-side into every requested entry; it carries no generations + * and no usage projection, so its SSH rows stay view-only. The partition may + * still assign owners where none are needed on the wire — Self. */ export async function listLegacyAutomations( authority: AutomationAuthorityRef @@ -198,11 +155,51 @@ export async function listLegacyAutomations( return automations as Automation[] } +/** + * Self read on a server without host-scoped lists: the unscoped list is asked + * for and partitioned client-side, never a scoped request the server would + * answer with its whole store. Project evidence lives on the answering + * authority, so none is available here and every plausible local record reads + * as Self — the same id-keyed trust the legacy server itself applies, and the + * caller looks rows up by id. + */ +async function listLegacySelfAutomations( + authority: AutomationAuthorityRef +): Promise { + const automations = await listLegacyAutomations(authority) + const partition = partitionLegacyAutomationList(automations, { + repoConnectionId: () => null, + projectsAuthoritative: false + }) + const selfRows = partition.rows.filter((row) => row.selector.kind === 'self') + return { + automations: selfRows.map((row) => row.automation), + items: selfRows.map((row) => ({ + automationId: row.automation.id, + selector: { kind: 'self' } + })), + invalidRows: 0 + } +} + /** Convenience wrapper for a row's own host; orphan scopes are requested with the selector form. */ export async function listAutomationsForOwner( owner: AutomationOwnerRef ): Promise { - return await listScopedAutomations(owner.authority, scopeSelector(owner)) + try { + return await listScopedAutomations(owner.authority, scopeSelector(owner)) + } catch (error) { + // Only Self may degrade: its records live on the authority the request is + // already pinned to. An SSH owner keeps failing closed on an old server. + if ( + owner.authority.kind === 'runtime' && + owner.selector.kind === 'self' && + error instanceof AutomationHostScopeUnsupportedError + ) { + return await listLegacySelfAutomations(owner.authority) + } + throw error + } } /** @@ -246,13 +243,22 @@ async function updateFenced( expectedOwner: AutomationOwnerPrecondition, destination?: AutomationDestination ): Promise { - await assertOwnerFencingSupported(authority) + if (requiresOwnerFencing(expectedOwner, destination)) { + await assertOwnerFencingSupported(authority) + } if (authority.kind === 'desktop') { return await window.api.automations.update({ id, updates, expectedOwner, destination }) } + const { projectId, workspaceId, ...runtimeUpdates } = updates const result = await callAuthority<{ automation: Automation }>(authority, 'automation.update', { id, - updates, + updates: { + ...runtimeUpdates, + ...('projectId' in updates ? { repo: projectId } : {}), + ...('workspaceId' in updates + ? { workspace: runtimeAutomationWorkspaceSelector(workspaceId) } + : {}) + }, expectedOwner, destination }) @@ -264,7 +270,9 @@ async function deleteFenced( id: string, expectedOwner: AutomationOwnerPrecondition ): Promise { - await assertOwnerFencingSupported(authority) + if (requiresOwnerFencing(expectedOwner)) { + await assertOwnerFencingSupported(authority) + } if (authority.kind === 'desktop') { await window.api.automations.delete({ id, expectedOwner }) return @@ -325,8 +333,10 @@ export async function runAutomationNowForOwner( owner: AutomationOwnerRef, id: string ): Promise { - await assertOwnerFencingSupported(owner.authority) const expectedOwner = ownerPrecondition(owner) + if (requiresOwnerFencing(expectedOwner)) { + await assertOwnerFencingSupported(owner.authority) + } if (owner.authority.kind === 'desktop') { return await window.api.automations.runNow({ id, expectedOwner }) } @@ -342,7 +352,11 @@ export async function createAutomationForDestination( input: AutomationCreateInput, destination: AutomationDestination ): Promise { - await assertOwnerFencingSupported(authority) + // A Self destination degrades safely on an old server: with the field ignored, + // the record still lands on that authority's local store — which is Self. + if (destination.selector.kind !== 'self') { + await assertOwnerFencingSupported(authority) + } if (authority.kind === 'desktop') { return await window.api.automations.create(input, { destination }) } @@ -350,7 +364,10 @@ export async function createAutomationForDestination( const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { ...rest, repo: projectId, - workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined, + workspace: + input.workspaceMode === 'existing' + ? runtimeAutomationWorkspaceSelector(workspaceId) + : undefined, destination }) return result.automation diff --git a/src/renderer/src/components/automations/automation-write-invalidation.test.ts b/src/renderer/src/components/automations/automation-write-invalidation.test.ts index f1e3d80255a..3a81774dfcf 100644 --- a/src/renderer/src/components/automations/automation-write-invalidation.test.ts +++ b/src/renderer/src/components/automations/automation-write-invalidation.test.ts @@ -30,6 +30,7 @@ describe('automation write invalidation', () => { it('takes the host from the row owner, not the active authority', () => { const captured: AutomationCapturedOwner = { + authority: RUNTIME, owner: { authority: RUNTIME, selector: { kind: 'ssh', targetId: 'target-1', targetGeneration: 3 } @@ -45,18 +46,21 @@ describe('automation write invalidation', () => { it('falls back to the captured selector under the acting authority', () => { const captured: AutomationCapturedOwner = { + authority: RUNTIME, owner: null, selector: { kind: 'orphan', issue: 'target-missing' } } expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({ - authority: { kind: 'desktop' }, + authority: { kind: 'runtime', environmentId: 'env-1' }, selector: { kind: 'orphan' } }) }) it('names no host for a row that carried no metadata', () => { - expect(automationRowCatalogRef({ owner: null, selector: null }, DESKTOP)).toBeNull() + expect( + automationRowCatalogRef({ authority: null, owner: null, selector: null }, DESKTOP) + ).toBeNull() }) it('widens to the whole authority rather than guessing a selector', () => { diff --git a/src/renderer/src/components/automations/automation-write-invalidation.ts b/src/renderer/src/components/automations/automation-write-invalidation.ts index 751f237dcf2..0ffb2b3cfbc 100644 --- a/src/renderer/src/components/automations/automation-write-invalidation.ts +++ b/src/renderer/src/components/automations/automation-write-invalidation.ts @@ -44,7 +44,7 @@ export function automationRowCatalogRef( if (!captured.selector) { return null } - const stable = stableAutomationAuthorityRef(authority) + const stable = stableAutomationAuthorityRef(captured.authority ?? authority) // Rebuilt per variant rather than spread: the stable ref drops the generation and the // orphan issue, and a widened `kind` would not discriminate. switch (captured.selector.kind) { diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 85769bf20b0..8310423687f 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -200,6 +200,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures { const repo = makeRepo() const worktree = makeWorktree() const noop = (): void => undefined + const fetchWorktrees = async (): Promise => true return { state: { repos: [repo], @@ -209,7 +210,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures { terminalLayoutsByTabId: {}, ptyIdsByTabId: {}, activeWorktreeId: null, - fetchWorktrees: noop, + fetchWorktrees, fetchAllWorktrees: noop, startupWorktreeRefreshCompleted: true, updateSettings: noop, diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index 8085b46e1e1..bc18c0daf29 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -25,6 +25,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import type { AutomationListRow } from './automation-list-row-identity' +import type { Worktree } from '../../../../shared/worktree/types' export type ListPanelProps = { filteredAutomations: Automation[] @@ -88,6 +89,7 @@ export type EditorDialogProps = { createDestination?: AutomationCreateDestinationControl notice?: { message: string } | null repos?: { id: string }[] + worktrees?: Worktree[] draft?: { projectId: string; workspaceId: string } onSave: () => void onDraftChange: (updater: (current: unknown) => unknown) => void diff --git a/src/renderer/src/components/automations/automations-table-layout.test.ts b/src/renderer/src/components/automations/automations-table-layout.test.ts new file mode 100644 index 00000000000..96e2f8cd9c9 --- /dev/null +++ b/src/renderer/src/components/automations/automations-table-layout.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' + +describe('AUTOMATIONS_TABLE_GRID_CLASS', () => { + it('shares leftover width across text columns instead of capping Project', () => { + expect(AUTOMATIONS_TABLE_GRID_CLASS).toContain('min-w-[58rem]') + expect(AUTOMATIONS_TABLE_GRID_CLASS).toMatch(/minmax\(8rem,1\.2fr\).*minmax\(6\.5rem,0\.9fr\)/) + }) +}) diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts index 0b0db4d5229..f4513bdbae2 100644 --- a/src/renderer/src/components/automations/automations-table-layout.ts +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -1,4 +1,5 @@ /** Column template for the automations list table; shared chrome lives in @/lib/list-table-layout. */ -// Name | Schedule | Project | Next run | Last run | Status | Agent | Actions +// Name | Schedule | Project | Host | Next run | Last run | Status | Agent | Actions +// fr maxes share leftover width; rem maxes dumped it into Name and truncated Project. export const AUTOMATIONS_TABLE_GRID_CLASS = - 'grid grid-cols-[minmax(0,1.4fr)_minmax(7.5rem,10rem)_minmax(4.5rem,8rem)_minmax(8.5rem,1fr)_minmax(8rem,11rem)_minmax(4.5rem,6rem)_2.5rem_2.5rem]' + 'grid w-full min-w-[58rem] grid-cols-[minmax(8rem,1.2fr)_minmax(7.5rem,0.95fr)_minmax(6.5rem,0.9fr)_minmax(5rem,0.7fr)_minmax(7rem,0.85fr)_minmax(6.5rem,0.8fr)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]' diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts index 35521981294..052ce37e5ca 100644 --- a/src/renderer/src/components/automations/use-automation-create-destination.ts +++ b/src/renderer/src/components/automations/use-automation-create-destination.ts @@ -10,15 +10,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { translate } from '@/i18n/i18n' +import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { AutomationHostCatalog, AutomationHostCatalogEntry } from './automation-host-catalog-types' -import type { AutomationAuthorityRepoTables } from './automation-authority-identity' import { automationCreateEligibleProjects, - automationCreateProjectMismatch, preselectAutomationCreateHost, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -49,8 +48,8 @@ export type AutomationCreateDestinationInput = { /** Non-null only when the list is filtered to one concrete host. */ filterStableKey: string | null activeWorkspaceStableKey: string | null - repoTables: AutomationAuthorityRepoTables projects: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] } export type AutomationCreateDestinationState = { @@ -90,16 +89,20 @@ export function useAutomationCreateDestination( entries, filterStableKey, activeWorkspaceStableKey, - repoTables, - projects + projects, + projectHostSetups } = input const [captured, setCaptured] = useState(null) const [reason, setReason] = useState('unselected') + const [previousOpen, setPreviousOpen] = useState(open) + if (previousOpen !== open) { + setPreviousOpen(open) + setCaptured(null) + setReason('unselected') + } useEffect(() => { if (!open) { - setCaptured(null) - setReason('unselected') return } if (captured) { @@ -141,8 +144,10 @@ export function useAutomationCreateDestination( // when the handler was created could be an incarnation old. const entriesRef = useRef(entries) entriesRef.current = entries - const repoTablesRef = useRef(repoTables) - repoTablesRef.current = repoTables + const projectsRef = useRef(projects) + projectsRef.current = projects + const projectHostSetupsRef = useRef(projectHostSetups) + projectHostSetupsRef.current = projectHostSetups const check = useCallback( (projectId: string): AutomationCreateDestinationCheck => { @@ -166,7 +171,12 @@ export function useAutomationCreateDestination( if (revalidated.status !== 'ready') { return { ok: false, notice: ownerNotice(choiceMessage(revalidated.reason)) } } - if (automationCreateProjectMismatch(repoTablesRef.current, revalidated, projectId)) { + const project = automationCreateEligibleProjects( + revalidated, + projectsRef.current, + projectHostSetupsRef.current + ).find((candidate) => candidate.id === projectId) + if (!project) { return { ok: false, notice: ownerNotice( @@ -185,9 +195,9 @@ export function useAutomationCreateDestination( const eligibleProjects = useMemo( () => resolution.status === 'ready' - ? automationCreateEligibleProjects(repoTables, resolution, projects) + ? automationCreateEligibleProjects(resolution, projects, projectHostSetups) : projects, - [projects, repoTables, resolution] + [projectHostSetups, projects, resolution] ) return { diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx index 5845d80acef..2d42cd26988 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx +++ b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx @@ -21,7 +21,9 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' const mocks = vi.hoisted(() => ({ state: {} as Record, callRuntimeRpc: vi.fn(), - getRuntimeEnvironmentStatus: vi.fn() + getRuntimeEnvironmentStatus: vi.fn(), + connectAutomationHostSshTarget: vi.fn(), + connectAutomationHostRuntime: vi.fn() })) vi.mock('@/store', () => { @@ -37,6 +39,12 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ hasRuntimeRpcErrorCode: () => false })) +vi.mock('./automation-host-recovery-connect', () => ({ + connectAutomationHostSshTarget: (...args: unknown[]) => + mocks.connectAutomationHostSshTarget(...args), + connectAutomationHostRuntime: (...args: unknown[]) => mocks.connectAutomationHostRuntime(...args) +})) + import { useAutomationHostCatalog } from './use-automation-host-catalog' const ENVIRONMENT_ID = 'env-1' @@ -140,6 +148,10 @@ beforeEach(() => { view = null mocks.callRuntimeRpc.mockReset() mocks.getRuntimeEnvironmentStatus.mockReset() + mocks.connectAutomationHostSshTarget.mockReset() + mocks.connectAutomationHostRuntime.mockReset() + mocks.connectAutomationHostSshTarget.mockResolvedValue(undefined) + mocks.connectAutomationHostRuntime.mockResolvedValue(undefined) mocks.callRuntimeRpc.mockResolvedValue({ automations: [legacyAutomation()] }) mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) Object.assign(window, { @@ -197,3 +209,61 @@ describe('useAutomationHostCatalog legacy partitioning', () => { expect(runtimeSelfAutomationIds()).toEqual(['a-legacy']) }) }) + +describe('useAutomationHostCatalog recover', () => { + it('reconnects an unreachable runtime through the status-recording helper', async () => { + await renderCatalog([]) + const runtimeSelf = view?.catalog.entries.find( + (entry) => + entry.stableRef.authority.kind === 'runtime' && entry.stableRef.selector.kind === 'self' + ) + expect(runtimeSelf).toBeDefined() + + await act(async () => { + await view?.recover('reconnect', { + ...runtimeSelf!, + authorityHealth: 'unavailable' + }) + }) + + expect(mocks.connectAutomationHostRuntime).toHaveBeenCalledWith(ENVIRONMENT_ID) + expect(mocks.connectAutomationHostSshTarget).not.toHaveBeenCalled() + expect(window.api.runtimeEnvironments.connect).not.toHaveBeenCalled() + }) + + it('reconnects a desktop SSH target through the SSH helper, not a raw ssh.connect', async () => { + mocks.state = { + ...storeState([]), + sshTargetLabels: new Map([['t1', 'Linux']]), + sshTargetGenerations: new Map([['t1', 1]]), + sshConnectionStates: new Map([['t1', { status: 'disconnected' }]]) + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + const sshEntry = view?.catalog.entries.find( + (entry) => + entry.stableRef.selector.kind === 'ssh' && entry.stableRef.selector.targetId === 't1' + ) + expect(sshEntry).toBeDefined() + + await act(async () => { + await view?.recover('reconnect', sshEntry) + }) + + expect(mocks.connectAutomationHostSshTarget).toHaveBeenCalledWith({ + targetId: 't1', + environmentId: undefined + }) + expect(window.api.ssh.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts index ae646a3db9c..9798ed43996 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -35,6 +35,10 @@ import { runAutomationHostRecovery, type AutomationHostRecoveryDeps } from './automation-host-recovery' +import { + connectAutomationHostRuntime, + connectAutomationHostSshTarget +} from './automation-host-recovery-connect' import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import { automationAuthorityPartitionContext, @@ -61,7 +65,10 @@ export type AutomationHostCatalogView = { loadCounts: AutomationHostLoadCounts selectHost: (filter: AutomationHostFilter) => void /** Runs the recovery verb the notice or empty state offered for a host. */ - recover: (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => void + recover: ( + action: AutomationHostRecoveryAction, + entry?: AutomationHostCatalogEntry | null + ) => Promise /** Manual refresh of every host in view, bypassing TTL where reachable. */ refreshHosts: () => void /** @@ -217,12 +224,9 @@ export function useAutomationHostCatalog( retry: (entry) => { void controller.scheduler.retry(automationHostFetchTarget(entry, pairingRevision)) }, - connectSshTarget: (targetId) => { - void window.api.ssh.connect({ targetId }) - }, - connectRuntimeEnvironment: (environmentId) => { - void window.api.runtimeEnvironments.connect({ selector: environmentId }) - }, + connectSshTarget: (targetId, environmentId) => + connectAutomationHostSshTarget({ targetId, environmentId }), + connectRuntimeEnvironment: (environmentId) => connectAutomationHostRuntime(environmentId), openSettings: (target) => { openSettingsPage() openSettingsTarget(target) @@ -232,9 +236,8 @@ export function useAutomationHostCatalog( ) const recover = useCallback( - (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => { - runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps) - }, + (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => + runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps), [recoveryDeps, resolution.entry] ) const refreshHosts = useCallback(() => { diff --git a/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts b/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts new file mode 100644 index 00000000000..b675c5c5e21 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-keyboard-navigation.ts @@ -0,0 +1,93 @@ +import React from 'react' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + getAutomationListArrowNavigationTarget, + type AutomationListArrowKey +} from './automation-list-keyboard-navigation' +import type { AutomationListRow } from './automation-list-row-identity' +import type { AutomationPaneTab } from './automation-page-state' + +type KeyboardNavigationInput = { + filteredRows: readonly AutomationListRow[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selectedRowKey: string | null + selectedExternalKey: string | null + selectAutomationRow: (rowKey: string | null) => void + selectExternalKey: (externalKey: string | null) => void + setActivePaneTab: (tab: AutomationPaneTab) => void + listRef: React.RefObject +} + +export function useAutomationListKeyboardNavigation({ + filteredRows, + filteredExternalAutomationEntries, + selectedRowKey, + selectedExternalKey, + selectAutomationRow, + selectExternalKey, + setActivePaneTab, + listRef +}: KeyboardNavigationInput): (key: AutomationListArrowKey) => void { + const pendingScrollRef = React.useRef(false) + const visibleItems = React.useMemo( + () => [ + ...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })), + ...filteredExternalAutomationEntries.map((entry) => ({ + kind: 'external' as const, + id: entry.key + })) + ], + [filteredExternalAutomationEntries, filteredRows] + ) + const scrollCurrentIntoView = React.useCallback(() => { + listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [listRef]) + + React.useEffect(() => { + if (!pendingScrollRef.current) { + return + } + pendingScrollRef.current = false + scrollCurrentIntoView() + }, [scrollCurrentIntoView, selectedExternalKey, selectedRowKey]) + + return React.useCallback( + (key) => { + const next = getAutomationListArrowNavigationTarget({ + items: visibleItems, + selectedId: selectedRowKey, + selectedExternalKey, + key + }) + if (!next) { + return + } + const selected = + next.kind === 'local' + ? selectedExternalKey === null && selectedRowKey === next.id + : selectedExternalKey === next.id + if (selected) { + scrollCurrentIntoView() + return + } + pendingScrollRef.current = true + if (next.kind === 'local') { + selectExternalKey(null) + selectAutomationRow(next.id) + return + } + selectAutomationRow(null) + selectExternalKey(next.id) + setActivePaneTab('overview') + }, + [ + scrollCurrentIntoView, + selectAutomationRow, + selectExternalKey, + selectedExternalKey, + selectedRowKey, + setActivePaneTab, + visibleItems + ] + ) +} diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx index ae5d768293b..1693262773e 100644 --- a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx @@ -46,8 +46,7 @@ function makeInput( ): SelectedAutomationRunHistoryInput { return { selected: DESKTOP_ROW, - context: { capturedOwners: new Map(), authority: { kind: 'desktop' } }, - legacyTarget: () => ({ kind: 'local' }), + context: { capturedOwners: new Map() }, navigation: null, reloadToken: 0, onSettled: vi.fn(), @@ -168,8 +167,9 @@ describe('useSelectedAutomationRunHistory', () => { } const input = makeInput({ context: { - capturedOwners: new Map([[DESKTOP_ROW.key, { owner, selector: { kind: 'self' } }]]), - authority: { kind: 'desktop' } + capturedOwners: new Map([ + [DESKTOP_ROW.key, { authority: owner.authority, owner, selector: { kind: 'self' } }] + ]) } }) diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.ts b/src/renderer/src/components/automations/use-selected-automation-run-history.ts index c9da1bd7ce4..17611bb0161 100644 --- a/src/renderer/src/components/automations/use-selected-automation-run-history.ts +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.ts @@ -10,6 +10,8 @@ import { useEffect, useRef } from 'react' import type { AutomationRun } from '../../../../shared/automations-types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner' import type { AutomationListRow } from './automation-list-row-identity' import { @@ -17,15 +19,11 @@ import { type AutomationDispatchContext, dispatchAutomationRunHistory } from './automation-row-action-dispatch' -import { - getAutomationTargetFromHostId, - listAutomationRunsForTarget, - type AutomationHostTarget -} from './automation-host-client' export type SelectedAutomationRunNavigation = { automationId: string hostId?: string | null + authority?: StableAutomationAuthorityRef } | null export type SelectedAutomationRunHistoryOutcome = { @@ -45,8 +43,6 @@ export type SelectedAutomationRunHistoryOutcome = { export type SelectedAutomationRunHistoryInput = { selected: AutomationListRow | null context: AutomationDispatchContext - /** The row's own host, used only when no owner was captured for it. */ - legacyTarget: (row: AutomationListRow) => AutomationHostTarget | null navigation: SelectedAutomationRunNavigation /** Changing this re-asks the host; it is how a failed read offers Retry. */ reloadToken: number @@ -60,6 +56,13 @@ function navigationHostId( return navigation?.automationId === automationId ? (navigation.hostId ?? null) : null } +function navigationAuthority( + navigation: SelectedAutomationRunNavigation, + automationId: string +): StableAutomationAuthorityRef | null { + return navigation?.automationId === automationId ? (navigation.authority ?? null) : null +} + export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void { const inputRef = useRef(input) inputRef.current = input @@ -67,17 +70,23 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist const automationId = input.selected?.automation.id ?? null const rowKey = input.selected?.key ?? null const reloadToken = input.reloadToken + const selectedNavigationAuthority = automationId + ? navigationAuthority(input.navigation, automationId) + : null const fetchKey = automationId && rowKey ? [ rowKey, capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)), + selectedNavigationAuthority + ? automationAuthorityCatalogKey(selectedNavigationAuthority) + : '', navigationHostId(input.navigation, automationId) ?? '' ].join('|') : '' useEffect(() => { - const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current + const { selected, context, onSettled } = inputRef.current if (!selected || !automationId || !rowKey) { onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null }) return @@ -86,13 +95,7 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist const owner = capturedAutomationOwnerKey( capturedAutomationOwner(context.capturedOwners, rowKey) ) - const navigationHost = navigationHostId(navigation, automationId) - const target = navigationHost - ? getAutomationTargetFromHostId(navigationHost) - : (legacyTarget(selected) ?? { kind: 'local' }) - void dispatchAutomationRunHistory(context, { rowKey, automationId }, () => - listAutomationRunsForTarget(target, automationId) - ).then((result) => { + void dispatchAutomationRunHistory(context, { rowKey, automationId }).then((result) => { if (cancelled) { return } @@ -107,6 +110,5 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist return () => { cancelled = true } - // oxlint-disable-next-line react-hooks/exhaustive-deps -- fetchKey is the refetch policy; see the module docblock for why the context object is deliberately not a dependency. }, [automationId, rowKey, fetchKey, reloadToken]) } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx index 89ca240cc98..11052a54ccd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx @@ -12,6 +12,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import type * as AutomationHostClient from '@/components/automations/automation-host-client' import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' @@ -50,7 +51,12 @@ async function render( handlers: { onOpenAutomation?: (event: React.MouseEvent) => void onOpenAutomationRun?: (event: React.MouseEvent) => void - } = {} + } = {}, + authority: StableAutomationAuthorityRef | null = record.hostId + ? record.hostId.startsWith('runtime:') + ? { kind: 'runtime', environmentId: record.hostId.slice('runtime:'.length) } + : { kind: 'desktop' } + : null ): Promise { const container = document.createElement('div') document.body.appendChild(container) @@ -59,7 +65,11 @@ async function render( await act(async () => { root.render( - + ) }) @@ -93,6 +103,25 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => { }) }) + it('asks the runtime authority for a runtime-owned SSH workspace', async () => { + mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) + mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) + + await render( + provenance({ hostId: 'ssh:builder' }), + {}, + { + kind: 'runtime', + environmentId: 'gpu' + } + ) + + expect(mocks.listAutomationsForTarget).toHaveBeenCalledWith({ + kind: 'environment', + environmentId: 'gpu' + }) + }) + it('reports a miss as uncheckable when no host was ever recorded', async () => { const container = await render(provenance()) @@ -107,20 +136,4 @@ describe('WorktreeCardAutomationDetailSection host resolution', () => { expect(container.textContent).toContain('Automation no longer available.') }) - - it('offers both affordances when a record with no recorded host is found anyway', async () => { - // The missing host degrades the *miss*, never the find: an automation the - // desktop positively returned is evidence, whatever the read was aimed by. - mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) - mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) - - const container = await render(provenance(), { - onOpenAutomation: vi.fn(), - onOpenAutomationRun: vi.fn() - }) - - expect(container.querySelector('[aria-label="Open automation"]')).not.toBeNull() - expect(container.querySelector('[aria-label="Open run"]')).not.toBeNull() - expect(container.textContent).not.toContain('could not be checked') - }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx index 9f2811a54b9..09ac459f285 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -1,6 +1,7 @@ import React from 'react' import { CalendarClock, PlayCircle } from 'lucide-react' import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { WorktreeCardDetailSection, WorktreeCardDetailSectionContent @@ -8,13 +9,14 @@ import { import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' import { translate } from '@/i18n/i18n' import { - getAutomationTargetFromHostId, + getAutomationTargetFromAuthority, listAutomationRunsForTarget, listAutomationsForTarget } from '@/components/automations/automation-host-client' type WorktreeCardAutomationDetailSectionProps = { provenance: AutomationWorkspaceProvenance + authority: StableAutomationAuthorityRef | null onOpenAutomation?: (event: React.MouseEvent) => void onOpenAutomationRun?: (event: React.MouseEvent) => void } @@ -27,29 +29,36 @@ type AutomationProvenanceAvailability = export function WorktreeCardAutomationDetailSection({ provenance, + authority, onOpenAutomation, onOpenAutomationRun }: WorktreeCardAutomationDetailSectionProps): React.JSX.Element { const [availability, setAvailability] = React.useState({ status: 'checking' }) + const authorityKind = authority?.kind ?? null + const authorityEnvironmentId = authority?.kind === 'runtime' ? authority.environmentId : null React.useEffect(() => { let cancelled = false async function resolveAvailability(): Promise { setAvailability({ status: 'checking' }) try { - // Why not the workspace's host: the automation's host is recorded on the - // provenance, and an automation can land a workspace somewhere else - // entirely — substituting one for the other asks the wrong machine. - const target = getAutomationTargetFromHostId(provenance.hostId) + // The caller resolves portable provenance against this client's runtime owner. + if (!authorityKind) { + setAvailability({ status: 'unavailable' }) + return + } + const target = getAutomationTargetFromAuthority( + authorityKind === 'runtime' + ? { kind: 'runtime', environmentId: authorityEnvironmentId! } + : { kind: 'desktop' } + ) const automations = await listAutomationsForTarget(target) const automation = automations.find((entry) => entry.id === provenance.automationId) if (!automation) { if (!cancelled) { - // With no recorded host this read went to the desktop on no evidence, - // so a miss here is "could not check", never "removed". - setAvailability({ status: provenance.hostId ? 'automation-missing' : 'unavailable' }) + setAvailability({ status: 'automation-missing' }) } return } @@ -71,7 +80,7 @@ export function WorktreeCardAutomationDetailSection({ return () => { cancelled = true } - }, [provenance.automationId, provenance.automationRunId, provenance.hostId]) + }, [authorityEnvironmentId, authorityKind, provenance.automationId, provenance.automationRunId]) const canOpenAutomation = availability.status === 'available' const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 071d857aed2..21d13f73ba1 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -56,6 +56,7 @@ export function WorktreeCardDetailsHover({ review, comment, automationProvenance, + automationAuthority, cliProvenance, children, branchName, @@ -313,6 +314,7 @@ export function WorktreeCardDetailsHover({ {automationProvenance && ( { e.stopPropagation() - const automationId = worktree.automationProvenance?.automationId - if (!automationId) { + const provenance = worktree.automationProvenance + if (!provenance) { return } - const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId + const hostId = provenance.hostId ?? worktree.hostId + const catalogRef = automationWorkspaceStorageCatalogRef( + provenance, + worktree.runtimeOwnerEnvironmentId + ) setPendingAutomationRunNavigation({ - automationId, + automationId: provenance.automationId, runId: null, + ...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}), ...(hostId ? { hostId } : {}) }) openAutomationsPage() @@ -94,9 +100,9 @@ export function useWorktreeCardFoundation({ [ openAutomationsPage, setPendingAutomationRunNavigation, - worktree.automationProvenance?.automationId, - worktree.automationProvenance?.hostId, - worktree.hostId + worktree.automationProvenance, + worktree.hostId, + worktree.runtimeOwnerEnvironmentId ] ) @@ -108,9 +114,14 @@ export function useWorktreeCardFoundation({ return } const hostId = provenance.hostId ?? worktree.hostId + const catalogRef = automationWorkspaceStorageCatalogRef( + provenance, + worktree.runtimeOwnerEnvironmentId + ) setPendingAutomationRunNavigation({ automationId: provenance.automationId, runId: provenance.automationRunId, + ...(catalogRef ? { authority: catalogRef.authority, catalogRef } : {}), ...(hostId ? { hostId } : {}) }) openAutomationsPage() @@ -119,7 +130,8 @@ export function useWorktreeCardFoundation({ openAutomationsPage, setPendingAutomationRunNavigation, worktree.automationProvenance, - worktree.hostId + worktree.hostId, + worktree.runtimeOwnerEnvironmentId ] ) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts index 64e9da831a6..dd67207bac2 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -1,6 +1,7 @@ import React, { useCallback } from 'react' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { automationWorkspaceStorageAuthority } from '../../../../shared/automation-workspace-provenance' import { hasWorktreeCardDetails } from './WorktreeCardMeta' import { usePromptCacheCountdownStartedAt } from './CacheTimer' import { useWorktreeAgentRows } from './useWorktreeAgentRows' @@ -83,6 +84,12 @@ export function useWorktreeCardSecondaryDetails({ const metaJiraIssue = showJiraIssue ? hoverJiraIssue : null const metaReview = showPR ? hoverReview : null const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null + const automationAuthority = metaAutomationProvenance + ? automationWorkspaceStorageAuthority( + metaAutomationProvenance, + worktree.runtimeOwnerEnvironmentId + ) + : null const metaCliProvenance = showCli ? worktree.cliProvenance : null const metaComment = showComment ? hoverComment : null const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards) @@ -208,6 +215,7 @@ export function useWorktreeCardSecondaryDetails({ metaJiraIssue, metaReview, metaAutomationProvenance, + automationAuthority, metaCliProvenance, metaComment, showInlineAgentList, diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index 54637f7a50a..2885fda772d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -4,6 +4,7 @@ import type { CliWorkspaceProvenance } from '../../../../shared/worktree/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' export type WorktreeCardIssueDisplay = @@ -37,6 +38,7 @@ export type WorktreeCardMetaBadgesProps = { review: WorktreeCardPrDisplay | null comment: string | null automationProvenance?: AutomationWorkspaceProvenance | null + automationAuthority?: StableAutomationAuthorityRef | null cliProvenance?: CliWorkspaceProvenance | null } diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx index de5c72f43b2..54c0f4195da 100644 --- a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -47,6 +47,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) { metaReview, metaComment, metaAutomationProvenance, + automationAuthority, metaCliProvenance, hoverIssue, hoverLinearIssue, @@ -152,6 +153,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) { review={metaReview} comment={metaComment} automationProvenance={metaAutomationProvenance} + automationAuthority={automationAuthority} cliProvenance={metaCliProvenance} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={worktree.displayName} @@ -223,6 +225,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) { review={metaReview} comment={metaComment} automationProvenance={metaAutomationProvenance} + automationAuthority={automationAuthority} cliProvenance={metaCliProvenance} detailsAfter={hasPorts ? : null} hoverControl={detailsHoverControl} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7ceed9367eb..d87306061d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15226,7 +15226,9 @@ "newAutomation": "New Automation", "rowActions": "Automation actions", "tableLastRun": "Last run", - "noListMatches": "No automations match." + "noListMatches": "No automations match.", + "tableHost": "Host", + "refreshAutomations": "Refresh automations" }, "AutomationsPageSkeleton": { "55527b7bcf": "Loading automations" @@ -15465,11 +15467,14 @@ "action": { "retry": "Retry", "reconnect": "Reconnect", - "updateServer": "Update server" + "updateServer": "Update server", + "runtimeConnectFailed": "Remote host is not reachable", + "sshConnectFailed": "SSH connection failed" } }, "ownerAction": { - "failed": "That automation action did not complete." + "failed": "That automation action did not complete.", + "workspaceNotFound": "We couldn't find that workspace on this host. Choose a workspace that exists here and try again." }, "externalScope": { "targetHidden": "This host is managed by Orca, so its external automation managers are not listed in this release.", @@ -15496,6 +15501,7 @@ "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "stale": "{host} changed while this form was open. Choose it again before saving.", "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", + "workspacesUnverified": "We couldn't verify workspaces on {host}. Check the connection and try again.", "noProjects": "No projects are set up on {host}. Add one there, or choose another host." } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 27136599f39..79ec58190d7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13822,7 +13822,11 @@ "tableStatus": "Estado", "tableActions": "Acciones", "newAutomation": "Nueva automatización", - "rowActions": "Acciones de automatización" + "rowActions": "Acciones de automatización", + "tableLastRun": "Última ejecución", + "noListMatches": "Ninguna automatización coincide.", + "tableHost": "Host", + "refreshAutomations": "Actualizar automatizaciones" }, "CreateFromPicker": { "f061f49e3f": "Buscar ramas del repositorio...", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1a2f3e9f95a..abfcb3ca16a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13822,7 +13822,11 @@ "tableStatus": "ステータス", "tableActions": "操作", "newAutomation": "新しい自動化", - "rowActions": "自動化の操作" + "rowActions": "自動化の操作", + "tableLastRun": "前回の実行", + "noListMatches": "一致する自動化はありません。", + "tableHost": "ホスト", + "refreshAutomations": "自動化を更新" }, "CreateFromPicker": { "f061f49e3f": "リポジトリブランチを検索…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a360d1331ec..176f792589a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13851,7 +13851,11 @@ "tableStatus": "상태", "tableActions": "작업", "newAutomation": "새 자동화", - "rowActions": "자동화 작업" + "rowActions": "자동화 작업", + "tableLastRun": "마지막 실행", + "noListMatches": "일치하는 자동화가 없습니다.", + "tableHost": "호스트", + "refreshAutomations": "자동화 새로고침" }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1774a4afa4b..c7d8c44f02b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13842,7 +13842,11 @@ "tableStatus": "状态", "tableActions": "操作", "newAutomation": "新建自动化", - "rowActions": "自动化操作" + "rowActions": "自动化操作", + "tableLastRun": "上次运行", + "noListMatches": "没有匹配的自动化。", + "tableHost": "主机", + "refreshAutomations": "刷新自动化" }, "CreateFromPicker": { "f061f49e3f": "搜索仓库分支...", diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index f8fc1762c42..3edb0845b79 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -5,37 +5,22 @@ import type { SshConnectionStatus, SshTargetSummary } from '../../../../shared/ssh-types' -import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' -import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' +import { + collectSshTargetGenerations, + sshConnectionStatesEqual, + sshTargetGenerationsEqual, + sshTargetLabelsEqual +} from './ssh-target-cleanup' -/** - * SSH state of one remote Orca server's own SSH targets, mirrored on this - * client. Kept strictly separate from the local `SshSlice` maps so a remote - * machine's targets can never pollute local SSH settings, pickers, or the - * execution-host registry — and vice versa (STA-1468, desktop topology). - */ export type RuntimeEnvironmentSshBucket = { connectionStates: Map targetLabels: Map - /** Durable SSH *registration* generation per target, for automation owner - * fencing. Absent for targets an older server omitted it for — never - * defaulted, because a guessed generation would fence against the wrong - * registration. Cleared with the rest of the bucket when state goes stale. */ targetGenerations: Map removedTargetLabels: Map - /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence - * from `targetLabels` only counts as target removal once a target list - * actually loaded from that environment (even an empty one). */ targetsHydrated: boolean } export type RuntimeEnvironmentSshSlice = { - /** - * Per-runtime-environment SSH state buckets, keyed by environment id. - * Do NOT read this map directly from components — use the - * `selectRuntimeAwareSsh*` selectors below, which route between the local - * SSH maps (`environmentId === null`) and the owning environment's bucket. - */ sshStateByEnvironment: Map setEnvironmentSshConnectionState: ( environmentId: string, @@ -53,13 +38,8 @@ export type RuntimeEnvironmentSshSlice = { labels: Record, generation?: number ) => void - /** Transport to the environment dropped: its mirrored SSH state can no - * longer be trusted (it may hold a pre-drop "connected"). Downgrades the - * bucket to unhydrated and clears connection states so reads resolve to - * "unknown" until a reconnect re-hydrates. */ markEnvironmentSshStateStale: (environmentId: string) => void removeEnvironmentSshState: (environmentId: string) => void - /** Drops buckets for environments no longer in the saved set. */ retainEnvironmentSshState: (environmentIds: Iterable) => void } @@ -71,24 +51,6 @@ const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { targetsHydrated: false } -function collectTargetGenerations(targets: SshTargetSummary[]): Map { - const generations = new Map() - for (const target of targets) { - const generation = sanitizeSshTargetGeneration(target.generation) - if (generation !== undefined) { - generations.set(target.id, generation) - } - } - return generations -} - -function targetGenerationsEqual(current: Map, next: Map): boolean { - return ( - current.size === next.size && - [...next].every(([targetId, generation]) => current.get(targetId) === generation) - ) -} - const stateGenerationByEnvironment = new Map() const targetConnectionGenerationByEnvironment = new Map() @@ -198,10 +160,10 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< const connectionStates = new Map( Array.from(bucket.connectionStates).filter(([targetId]) => targetIds.has(targetId)) ) - const targetGenerations = collectTargetGenerations(targets) + const targetGenerations = collectSshTargetGenerations(targets) if ( sshTargetLabelsEqual(bucket.targetLabels, targets) && - targetGenerationsEqual(bucket.targetGenerations, targetGenerations) + sshTargetGenerationsEqual(bucket.targetGenerations, targetGenerations) ) { // Why: an unchanged (even empty) list is still a successful load — the // hydration flag must flip on the first fetch of an empty target set. @@ -318,10 +280,7 @@ export function selectRuntimeAwareSshStatus( return null } const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket?.targetsHydrated) { - return null - } - return bucket.connectionStates.get(targetId)?.status ?? null + return bucket?.targetsHydrated ? (bucket.connectionStates.get(targetId)?.status ?? null) : null } /** @@ -343,10 +302,7 @@ export function selectRuntimeAwareSshError( return null } const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket?.targetsHydrated) { - return null - } - return bucket.connectionStates.get(targetId)?.error ?? null + return bucket?.targetsHydrated ? (bucket.connectionStates.get(targetId)?.error ?? null) : null } export function selectRuntimeAwareSshTargetLabel( @@ -388,11 +344,9 @@ export function selectRuntimeAwareSshTargetRemoved( return false } const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket) { - return false - } - return ( - bucket.removedTargetLabels.has(targetId) || - (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)) + return Boolean( + bucket && + (bucket.removedTargetLabels.has(targetId) || + (bucket.targetsHydrated && !bucket.targetLabels.has(targetId))) ) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 151cbb7e801..43f2458cf39 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -95,6 +95,10 @@ import { toPersistedAutomationHostFilter, type AutomationHostFilter } from '../../../../shared/automation-host-filter' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' import { normalizeExecutionHostOrder, normalizeExecutionHostScope, @@ -760,9 +764,17 @@ export type UISlice = { automationId: string runId: string | null hostId?: ExecutionHostId + authority?: StableAutomationAuthorityRef + catalogRef?: StableAutomationCatalogRef } | null setPendingAutomationRunNavigation: ( - navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null + navigation: { + automationId: string + runId: string | null + hostId?: ExecutionHostId + authority?: StableAutomationAuthorityRef + catalogRef?: StableAutomationCatalogRef + } | null ) => void openAutomationsPage: () => void closeAutomationsPage: () => void diff --git a/src/shared/automation-workspace-provenance.test.ts b/src/shared/automation-workspace-provenance.test.ts new file mode 100644 index 00000000000..75658bb913d --- /dev/null +++ b/src/shared/automation-workspace-provenance.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationWorkspaceProvenance } from './worktree/types' +import { + automationWorkspaceStorageAuthority, + automationWorkspaceStorageCatalogRef +} from './automation-workspace-provenance' + +const provenance = (overrides: Partial) => + ({ kind: 'created-by-automation', ...overrides }) as AutomationWorkspaceProvenance + +describe('automation workspace storage authority', () => { + it('keeps Runtime + SSH distinct from Desktop + SSH', () => { + expect( + automationWorkspaceStorageAuthority( + provenance({ hostId: 'ssh:builder', storageAuthority: 'runtime' }), + 'gpu' + ) + ).toEqual({ kind: 'runtime', environmentId: 'gpu' }) + expect( + automationWorkspaceStorageAuthority( + provenance({ hostId: 'ssh:builder', storageAuthority: 'desktop' }), + 'gpu' + ) + ).toEqual({ kind: 'desktop' }) + }) + + it('uses workspace ownership for provenance written by older runtimes', () => { + expect( + automationWorkspaceStorageAuthority(provenance({ hostId: 'ssh:builder' }), 'gpu') + ).toEqual({ kind: 'runtime', environmentId: 'gpu' }) + }) + + it('keeps the SSH selector with its storage authority', () => { + expect( + automationWorkspaceStorageCatalogRef( + provenance({ hostId: 'ssh:builder', storageAuthority: 'runtime' }), + 'gpu' + ) + ).toEqual({ + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 'builder' } + }) + }) +}) diff --git a/src/shared/automation-workspace-provenance.ts b/src/shared/automation-workspace-provenance.ts index 6f1c0f9894e..7ea616e1232 100644 --- a/src/shared/automation-workspace-provenance.ts +++ b/src/shared/automation-workspace-provenance.ts @@ -1,7 +1,11 @@ import type { Automation, AutomationRun } from './automations-types' -import { getRepoExecutionHostId } from './execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from './execution-host' import type { Repo } from './repo-types' import type { AutomationWorkspaceProvenance } from './worktree/types' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from './automation-owner-ref' type AutomationProvenanceRun = Pick @@ -9,7 +13,8 @@ export function buildAutomationWorkspaceProvenance( automation: Automation, run: AutomationProvenanceRun, repo: Repo, - createdAt = Date.now() + createdAt = Date.now(), + storageAuthority?: AutomationWorkspaceProvenance['storageAuthority'] ): AutomationWorkspaceProvenance { return { kind: 'created-by-automation', @@ -27,6 +32,44 @@ export function buildAutomationWorkspaceProvenance( : automation.runContext?.repoId ? { repoId: automation.runContext.repoId } : {}), - hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo) + hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo), + ...(storageAuthority ? { storageAuthority } : {}) + } +} + +/** Resolves the client-relative authority from portable persisted provenance. */ +export function automationWorkspaceStorageAuthority( + provenance: AutomationWorkspaceProvenance, + runtimeOwnerEnvironmentId?: string | null +): StableAutomationAuthorityRef | null { + if (provenance.storageAuthority === 'desktop') { + return { kind: 'desktop' } + } + const runtimeOwner = runtimeOwnerEnvironmentId?.trim() + if (provenance.storageAuthority === 'runtime') { + return runtimeOwner ? { kind: 'runtime', environmentId: runtimeOwner } : null + } + if (runtimeOwner) { + return { kind: 'runtime', environmentId: runtimeOwner } + } + const host = parseExecutionHostId(provenance.hostId) + return host?.kind === 'runtime' + ? { kind: 'runtime', environmentId: host.environmentId } + : { kind: 'desktop' } +} + +/** Resolves the exact storage bucket used by automation navigation. */ +export function automationWorkspaceStorageCatalogRef( + provenance: AutomationWorkspaceProvenance, + runtimeOwnerEnvironmentId?: string | null +): StableAutomationCatalogRef | null { + const authority = automationWorkspaceStorageAuthority(provenance, runtimeOwnerEnvironmentId) + if (!authority) { + return null + } + const host = parseExecutionHostId(provenance.hostId) + return { + authority, + selector: host?.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } } } diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index af6252f5c3a..22e094aee3c 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -165,6 +165,8 @@ export type AutomationWorkspaceProvenance = { projectId: string repoId?: string hostId?: ExecutionHostId + /** Storage lives on this process role; runtime environment IDs are client-relative. */ + storageAuthority?: 'desktop' | 'runtime' } export type AutomationWorkspaceProvenanceRequest = {