mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
Make remote hosts first class: concurrent multi-host workbench (#5071)
* Restore the outlined server card for host headers Feedback: the bordered card with the server glyph made it clearer that a host section is a separate machine, not just another group. Bring that back while keeping the recent quieting: no status dot when healthy (marks only for connecting/blocked/error/disconnected), no 'This computer' detail on the local host, and collapse/menu/count behavior unchanged. Co-authored-by: Orca <help@stably.ai> * Anchor host badge to its label, indent rows under host cards Sidebar polish from review: - The count badge sat in dead space between the label and the hover-only chevron/menu; it now hugs the label like repo headers - Rows under a host card get a left inset so projects and workspaces visibly belong to the machine above them - A host whose only visible row is a collapsed repo group counted 0 while the group badge said 9; host counts now fall back to header counts for groups contributing no visible items Co-authored-by: Orca <help@stably.ai> * Two-tier sticky headers: pinned host card above pinned group header When scrolling inside a host section, the host card now stays pinned at the top (z-30) while project/status group headers hand off beneath it (z-20, offset by the pinned card height). The host is the outer hierarchy level, so it is the most persistent context — previously the first repo header replaced it, losing 'which machine am I on' exactly when it mattered. The pinned card keeps its collapse/menu/warning affordances. Handoff rules: the next host card pushes the previous one out at the viewport top; a group pins only once it reaches the slot beneath the host card, and a previous host's group can never pin under the next host. Without host sections the logic degrades to the original single-tier behavior. Co-authored-by: Orca <help@stably.ai> * Revert host-section row indent The two-tier sticky host card now provides continuous 'inside this machine' context at any scroll depth, making the static indent redundant — and it cost 12px of sidebar width on every row while making multi-host layouts misalign with single-host ones. Host cards bracketing their sections plus the pinned header carry the ownership signal on their own. Co-authored-by: Orca <help@stably.ai> * Checkpoint multi-host sidebar and project-first notes Co-authored-by: Orca <help@stably.ai> * Add project-first compatibility persistence Co-authored-by: Orca <help@stably.ai> * Expose project host setup APIs Co-authored-by: Orca <help@stably.ai> * Group sidebar rows by project setup Co-authored-by: Orca <help@stably.ai> * Document project-first host model discussion Co-authored-by: Orca <help@stably.ai> * Resolve workspace creation through project host setups Co-authored-by: Orca <help@stably.ai> * Stamp workspace ownership with project host setup Co-authored-by: Orca <help@stably.ai> * Add project host setup existing folder API Co-authored-by: Orca <help@stably.ai> * Summarize project-first host model discussion Co-authored-by: Orca <help@stably.ai> * Add project host setup CLI commands Co-authored-by: Orca <help@stably.ai> * Allow CLI worktree creation by project host setup Co-authored-by: Orca <help@stably.ai> * Add workspace host setup picker Co-authored-by: Orca <help@stably.ai> * Add project host setup settings summary Co-authored-by: Orca <help@stably.ai> * Make project host setup settings navigable Co-authored-by: Orca <help@stably.ai> * Stabilize project host setup settings selector Co-authored-by: Orca <help@stably.ai> * Add project host existing-folder setup form Co-authored-by: Orca <help@stably.ai> * Update project host model implementation status Co-authored-by: Orca <help@stably.ai> * Keep projects outermost in default sidebar view Co-authored-by: Orca <help@stably.ai> * Update project-first sidebar status Co-authored-by: Orca <help@stably.ai> * Show host context in project sidebar groups Co-authored-by: Orca <help@stably.ai> * Show unavailable hosts in workspace run target Co-authored-by: Orca <help@stably.ai> * Import missing project host from composer Co-authored-by: Orca <help@stably.ai> * Clone project host setup from composer Co-authored-by: Orca <help@stably.ai> * Persist project host setup method Co-authored-by: Orca <help@stably.ai> * Clone project hosts over SSH Co-authored-by: Orca <help@stably.ai> * Improve SSH clone cancellation cleanup Co-authored-by: Orca <help@stably.ai> * Backfill workspace project host ownership Co-authored-by: Orca <help@stably.ai> * Gate project host setup runtime capability Co-authored-by: Orca <help@stably.ai> * Preserve independent project host setups Co-authored-by: Orca <help@stably.ai> * Add project host setup update API Co-authored-by: Orca <help@stably.ai> * Add project host setup delete API Co-authored-by: Orca <help@stably.ai> * Add project host setup create API Co-authored-by: Orca <help@stably.ai> * Expose project host setup lifecycle in renderer store Co-authored-by: Orca <help@stably.ai> * Handle independent project host setups in settings Co-authored-by: Orca <help@stably.ai> * Add pending host setup action in project settings Co-authored-by: Orca <help@stably.ai> * Show pending project host setup status in composer Co-authored-by: Orca <help@stably.ai> * Report pending setup state in workspace target resolution Co-authored-by: Orca <help@stably.ai> * Use shared host registry for project setup choices Co-authored-by: Orca <help@stably.ai> * Add settings clone flow for project host setups Co-authored-by: Orca <help@stably.ai> * Gate unavailable project host setup options Co-authored-by: Orca <help@stably.ai> * Gate unavailable project setup hosts in settings Co-authored-by: Orca <help@stably.ai> * Stream SSH clone progress to renderer Co-authored-by: Orca <help@stably.ai> * Update project host model status notes Co-authored-by: Orca <help@stably.ai> * Add CLI project host setup clone command Co-authored-by: Orca <help@stably.ai> * Make add project host aware Co-authored-by: Orca <help@stably.ai> * Complete project host setup validation Co-authored-by: Orca <help@stably.ai> * Recover floating workspace terminal WebGL atlas on reopen (#5069) Co-authored-by: Orca <help@stably.ai> * Fix stale terminal daemon spawn health (#5064) Co-authored-by: Orca <help@stably.ai> * Suspend floating workspace terminal WebGL while the panel is closed (#5073) Co-authored-by: Orca <help@stably.ai> * Fix source control branch compare base (#5074) Co-authored-by: Orca <help@stably.ai> * Fix workspace-creation tour panel clipped by the Create Worktree dialog (#5078) * Fix workspace-creation tour panel clipped by the composer dialog The tour panel portals into dialog/sheet content that clips overflow, but its position was clamped against the window viewport. With the Project field spanning nearly the dialog's full width, the panel landed past the dialog's right edge and overflow-hidden cut it down to a sliver. Clamp hosted panels within the host's bounds instead, so the panel flips below the target and stays fully visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add JSDoc docstrings to satisfy CodeRabbit docstring coverage check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Test hosted contextual tour overlay positioning --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * release: v1.4.56 * Handle buffer overflows gracefully and truncate diffs fairly (#5083) - Gracefully fall back to file-name summaries when staged diffs exceed node/ssh execution maxBuffer limits, preventing generation failures. - Split oversized diffs by file and allocate budget via water-filling, ensuring single huge files do not starve smaller human changes. - Clip truncated diff sections on line boundaries to avoid half-lines. * Wrap AI generation controls with tooltips and clean i18n dependencies (#5087) - Wrap the AI generation button in a tooltip so users can see the disabled reason or the action description on hover. - Add unit tests verifying tooltip triggers and aria-label safety. - Simplify memo dependencies in settings metadata and worktree palette by using 'useTranslation()' to handle language-change rerenders directly without needing 'i18n.language'. * fix: address review findings (#5088) * Fix localization in repository hooks and base ref suggestion toast (#5089) * Fix localization in base ref toast and custom hook description - Localize the "commit"/"commits" plural nouns in the base ref toast. - Translate missing suggestion toast strings for JA, KO, and ZH locales. - Pass `{{artifact_url}}` as a literal template variable to translate calls to prevent i18next from treating it as a dynamic placeholder. * Fix localization reactivity in RepositoryHooksSection Move static variables containing translation calls into helper functions and subscribe to translation updates using useTranslation. This ensures that localized options, descriptions, and error messages refresh dynamically when the user changes the UI language. * Fix task page labels after language changes (#5086) Co-authored-by: Orca <help@stably.ai> * release: v1.4.57 * Fix automation tabs showing a shell instead of the live agent (#5099) * Fix automation tabs showing a shell instead of the live agent Opening a background automation's terminal tab showed a bare shell while the agent (Claude) kept running headless — the sidebar updated but the pane was attached to the wrong PTY. On first mount the restored ptyId equals the tab ptyId, and isSessionOwnedByWorktree() returns true for it, so connectPanePty routed the still-live eagerly-spawned PTY into the daemon-reattach branch (transport.connect({ sessionId })), which spawns a fresh shell and orphans the live agent PTY instead of adopting it via attach()+replay. Part A: gate the deferred reattach on the absence of a live eager buffer. A live eager buffer means the PTY is a still-running local session to adopt (attach + replay), not a daemon session to re-connect. Daemon reattach and remote PTYs are unaffected (gated on the eager buffer). Part B: publish never-mounted background automation tabs into the runtime graph (gated on a live eager buffer) so the live agent PTY binds to its real tab instead of surfacing as an orphan `pty:<id>` terminal — fixing `orca terminal list`, the CLI, and automation session-reuse. Adds a characterization test (fails on the old code, passes now) and a runtime-graph publish test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden eager PTY tab adoption Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> * Fix i18n label spacing in menus and settings (#5108) * fix i18n label spacing * Fix localized account runtime labels Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> * Improve localization catalog sync workflow (#5110) Co-authored-by: Orca <help@stably.ai> * Add Warp terminal theme import (#4714) Co-authored-by: Orca <help@stably.ai> * release: v1.4.58 * Tidy README badge layout * Handle integration credential decrypt failures (#4683) Co-authored-by: Orca <help@stably.ai> * Fix git repo telemetry for repo adds (#5121) Co-authored-by: Orca <help@stably.ai> * Add feature interaction usage bucket telemetry (#5119) Co-authored-by: Orca <help@stably.ai> * Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122) Co-authored-by: Orca <help@stably.ai> * perf(windows): fix 60s startup ACL walk and OpenCode streaming freeze, with benchmark harnesses (#5124) * release: v1.4.59-rc.0 * Fix packaged shell PATH order (#5125) Co-authored-by: Orca <help@stably.ai> * Add Floating Workspace contextual tour (#5062) * Add floating workspace contextual tour Co-authored-by: Orca <help@stably.ai> * Clarify floating workspace tour intro copy Co-authored-by: Orca <help@stably.ai> * Differentiate floating workspace tour steps instead of repeating examples Co-authored-by: Orca <help@stably.ai> * Lead floating workspace tour with the user benefit Co-authored-by: Orca <help@stably.ai> * Pitch floating workspace tour around cross-repo agents Co-authored-by: Orca <help@stably.ai> * Refine floating workspace tour step 1 copy Co-authored-by: Orca <help@stably.ai> * Anchor floating workspace tour step 2 on the minimize control Co-authored-by: Orca <help@stably.ai> * Restore floating workspace tour step 2 Co-authored-by: Orca <help@stably.ai> * Anchor floating workspace tour steps on New Terminal and New Markdown Note Co-authored-by: Orca <help@stably.ai> * Retitle floating workspace tour step 2 as scratchpad Co-authored-by: Orca <help@stably.ai> * Add why-comments for tour selector fallback and placement flipping Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> * Fix source control compare base ambiguity (#5127) Co-authored-by: Orca <help@stably.ai> * release: v1.4.59-rc.1 [rc-slot:2026-06-10-15] * release: v1.4.59 * Default-driven create-project flow: name-first form with sensible defaults (#5115) Co-authored-by: Orca <help@stably.ai> * Redesign Connect integrations (#4531) Co-authored-by: Orca <help@stably.ai> * Expose E2E store via build mode * File search match counts (#5085) * Add matchCount to SearchFileResult for accurate per-file hit counts Co-authored-by: Orca <help@stably.ai> * Add file search match count design * rm design doc --------- Co-authored-by: Orca <help@stably.ai> * fix: address review findings (#5139) * perf(windows): avoid blocking daemon pid checks (#5137) * release: v1.4.60-rc.0 * release: v1.4.60 * Preserve core workflow terms in English and apply CJK spacing (#5141) * Preserve core workflow and product terms in English across locales Update translation policy to prevent localization of key terms such as "Agent", "Commit", "Markdown", and "Terminal". This ensures consistent jargon and product branding. Introduce CJK-Latin term spacing to keep these Latin terms legible when combined with CJK text, while adjusting Korean particle spacing. Also add overrides to prevent network proxy settings from being mistranslated as "Agent". * Preserve repo terminology in English and localize source control labels Treat "repo" and "repos" (and their capitalized forms) as brand terms that should remain in English/Latin across CJK and Spanish locales. Update translation files and policies to replace translated words like "repositorio" or "リポジトリ" with "repo"/"repos", and fix an issue where latin brand terms could be incorrectly matched as substrings in larger words during cleanup. Additionally, externalize and localize the "Staged Changes", "Changes", and "Untracked Files" section labels in the source control sidebar. * UX (#5143) * UX/copy tweaks (#5142) * UX/copy tweaks * UX/copy tweaks * Fix missed star UI translations (#5148) * fix: make windows ssh relay deploy survive session teardown (#5136) * Add option to remove child projects when deleting repo groups (#4702) Co-authored-by: Orca <help@stably.ai> * fix: remove checks panel response badge (#5147) * Add read-only `orca linear` CLI with trusted launch-prompt pointer (V1) (#5126) Co-authored-by: Orca <help@stably.ai> * Add AI Vault session history ## Summary - add AI Vault session scanning and resume command construction - add the Agents sidebar panel with filtering, grouping, copy/open actions, and local resume launch - support dragging saved sessions onto terminal split panes ## Validation - pnpm run lint - pnpm run typecheck - pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/register-core-handlers.test.ts src/main/ai-vault/session-scanner.test.ts src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts src/renderer/src/lib/ai-vault-session-drag.test.ts src/renderer/src/lib/launch-ai-vault-session.test.ts * Default agent launches to yolo permissions mode (#5145) * Default agent launches to yolo mode * test: update launch default validations * Fix Claude usage refresh error copy (#5155) Co-authored-by: Orca <help@stably.ai> * Move workspace board to sidebar bottom toolbar (#5146) Co-authored-by: Orca <help@stably.ai> * Rebuild contextual tour positioning on floating-ui; fix hosted dialog placement and arrow seam (#5154) Co-authored-by: Orca <help@stably.ai> * Fix missing spaces in cross-repo switch dialog (#5158) * Fix Ctrl+Tab switcher selection on release (#5116) * Fix additional i18n spacing regressions from #4995 (#5159) * Refine add project selection styling (#5160) Co-authored-by: Orca <help@stably.ai> * improve chinese localization (#5162) * Fix floating workspace needing two clicks after app switch (macOS) (#5128) * Autofocus feedback textarea when Send Feedback dialog opens (#5164) * fix: address pr-bug-scan validated finding from #4683 (#5151) Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai> * fix: enable claude agent teams by default (#5168) * Refresh Jira and Linear status after credential errors (#5169) * fix: address pr-bug-scan validated finding from #4683 Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces * Refresh Jira and Linear status to clear stale credential errors Ensure stale credential decryption errors are cleared from the store status once a successful API read completes. By updating the check in shouldRefreshStatusAfterRead to trigger when a credentialError is currently set, successful issue or list fetches will trigger a status check and remove stale error flags. --------- Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai> * Hide internal context from AI Vault titles (#5175) * Fix detached HEAD publish actions (#5173) * Keep freshly split terminal pane mounted if newborn PTY exits early (#5171) Prevent a newly split pane from collapsing immediately if its PTY exits during initial setup before any output is received or input is sent. This ensures a failed startup session remains visible to the user. * Route task PR queries by upstream source (#5176) * Route task PR queries by upstream source Implements the routing described in docs/tasks-pr-upstream-source.md so task PR and issue queries stay scoped to the selected source. * rm design doc * Prevent stale PR refreshes from restoring unlinked review state (#5180) - Pass `worktreeId` to `fetchPRForBranch` to track active worktree context - Ignore inflight or queued PR fetches if the worktree has been unlinked - Include linked PR/MR metadata in the checks panel snapshot key to trigger updates immediately on link/unlink events * Fix Claude agents management status detection (#5179) Co-authored-by: Orca <help@stably.ai> * fix: address review findings (#5177) * Allow resolving selected review comments with AI (#5184) * Allow resolving selected PR/MR review comments with AI Users can now select specific unresolved review comments or threads in the Checks panel sidebar, queue them, and trigger an AI agent to address them, marking resolved threads on the host upon agent launch. - Adds checkboxes and action/send buttons to select and queue comments. - Builds a structured, robust prompt with sanitized comment metadata. - Optimistically marks threads resolved on launch with rollback on error. - Supports both GitHub PRs and GitLab MRs. * Consolidate PR comment selection state and eliminate effects Combine independent selection states and context-tracking into a single state object. Derive active selection data and prune ineligible comments during render using useMemo instead of relying on asynchronous useEffect synchronization hooks. * Improve source control action dialog layout and recipe saving UX (#5153) * Improve source control agent action dialog layout and recipe UX - Constrain dialog and scroll area heights to prevent viewport overflow. - Add variable chips to easily insert the base prompt with tooltip previews. - Keep the recipe save controls visible when a recipe is already saved, showing informational status text instead of hiding them. - Update localized copy across multiple languages and reduce textarea rows. - Add unit tests for the variable chip preview and save target visibility. * Fix recipe-saved check in source control action dialog * Evaluate only the selected save target instead of checking all available targets, as the action only writes to the selected target. * Update daemon PTY adapter test fake PID to prevent collision with real host OS processes during runtime directory lookups. * fix: remove unsupported agent launch defaults (#5185) * Update Chinese and Japanese translations for worktrees and fixes (#5187) - Correct awkward Chinese translation of "fix" ("使固定") to "修复" and "基本的" to "主工作树" (main worktree). - Improve Japanese translation of "fix" from physical repair ("修理") to software correction ("修正"). * Embed hosted review creation composer directly in Checks panel (#5140) * Embed hosted review creation composer directly in the Checks panel - Replaces the modal pull request/merge request creation dialog with an inline composer embedded in the empty state of the Checks sidebar. - Extracts and moves pull request generation state to a dedicated store slice so AI-generated details are persisted across sidebar unmounts. * Fix hosted review composer feedback * Combine file search and file explorer right sidebar tabs (#5182) Unifies file discovery and tree navigation under a single Explorer domain, simplifying the right sidebar activity bar and reducing tab clutter. * Replaces the standalone 'search' activity bar tab with a nested 'search' subview inside the File Explorer tab * Introduces 'rightSidebarExplorerView' ('files' | 'search') state to manage the active subview inside the Explorer * Adds a search button to the File Explorer toolbar and a back button to the search subview for seamless transition * Exposes 'showRightSidebarFiles' and 'showRightSidebarSearch' store actions to route and seed search queries/include patterns * Adapts file explorer keybindings, git status polling, and external workspace watchers to respect the active subview * Maps legacy persisted search tab state to the new explorer search view for backward compatibility * release: v1.4.61-rc.1 * Add multi-repo folder workspaces (v1) (#5172) Co-authored-by: Orca <help@stably.ai> * release: v1.4.61-rc.2 * Hide unavailable project hosts in worktree composer Co-authored-by: Orca <help@stably.ai> * Remove inline project host setup from composer Co-authored-by: Orca <help@stably.ai> * Mark imported project host setup methods Co-authored-by: Orca <help@stably.ai> * Fix rebase merge fallout Co-authored-by: Orca <help@stably.ai> * Disable unavailable Add Project hosts Co-authored-by: Orca <help@stably.ai> * Compact Add Project host selector Co-authored-by: Orca <help@stably.ai> * Hide redundant SSH target chooser Co-authored-by: Orca <help@stably.ai> * Browse SSH clone destinations Co-authored-by: Orca <help@stably.ai> * Avoid local clone defaults for SSH hosts Co-authored-by: Orca <help@stably.ai> * Polish host-aware Add Project flows Co-authored-by: Orca <help@stably.ai> * Polish remote host add project flows Co-authored-by: Orca <help@stably.ai> * Remove redundant host kind chips Co-authored-by: Orca <help@stably.ai> * Fix remote project setup UX gaps Co-authored-by: Orca <help@stably.ai> * Fix multihost workspace composer project identity Co-authored-by: Orca <help@stably.ai> * Finish host context merge repair Co-authored-by: Orca <help@stably.ai> * Continue host context checklist implementation Co-authored-by: Orca <help@stably.ai> * Route Linear and Jira tasks by source context Co-authored-by: Orca <help@stably.ai> * Preserve Linear task source context in history Co-authored-by: Orca <help@stably.ai> * Scope task retry state by source context Co-authored-by: Orca <help@stably.ai> * Route GitHub drawer reads by source context Co-authored-by: Orca <help@stably.ai> * Guard GitLab selectors with repo context Co-authored-by: Orca <help@stably.ai> * Guard GitHub metadata selectors Co-authored-by: Orca <help@stably.ai> * Route GitHub task row actions by source context Co-authored-by: Orca <help@stably.ai> * Update GitHub source-context checklist status Co-authored-by: Orca <help@stably.ai> * Show host ownership for CLI provider accounts Co-authored-by: Orca <help@stably.ai> * Persist GitLab task detail source context Co-authored-by: Orca <help@stably.ai> * Show host scope for provider API budgets Co-authored-by: Orca <help@stably.ai> * Preserve Jira task source context Co-authored-by: Orca <help@stably.ai> * Scope Jira optimistic task patches Co-authored-by: Orca <help@stably.ai> * Resolve task PR bases on run host Co-authored-by: Orca <help@stably.ai> * Record Jira task workspace usage Co-authored-by: Orca <help@stably.ai> * Scope Linear optimistic task patches Co-authored-by: Orca <help@stably.ai> * Scope GitHub optimistic task patches Co-authored-by: Orca <help@stably.ai> * Clean host copy in onboarding flows Co-authored-by: Orca <help@stably.ai> * Preserve automation CLI run context Co-authored-by: Orca <help@stably.ai> * Add automation CLI source context selector Co-authored-by: Orca <help@stably.ai> * Clarify unavailable task source hosts Co-authored-by: Orca <help@stably.ai> * Surface host model runtime capability skew Co-authored-by: Orca <help@stably.ai> * Use SSH host copy in reconnect dialog Co-authored-by: Orca <help@stably.ai> * Show host context in task source picker Co-authored-by: Orca <help@stably.ai> * Mark task source display complete Co-authored-by: Orca <help@stably.ai> * Clarify provider account host selection Co-authored-by: Orca <help@stably.ai> * Guard task source switching boundary Co-authored-by: Orca <help@stably.ai> * Mark task source diagnostics persisted Co-authored-by: Orca <help@stably.ai> * Mark base resolution host boundary Co-authored-by: Orca <help@stably.ai> * Clarify external automation source states Co-authored-by: Orca <help@stably.ai> * Harden project host compatibility projection Co-authored-by: Orca <help@stably.ai> * Finish host copy audit Co-authored-by: Orca <help@stably.ai> * Add provider host scope controls Co-authored-by: Orca <help@stably.ai> * Show task source account labels Co-authored-by: Orca <help@stably.ai> * Show automation run context in CLI Co-authored-by: Orca <help@stably.ai> * Scope Jira task cache lookups by source Co-authored-by: Orca <help@stably.ai> * Seed workspace creation from task source context Co-authored-by: Orca <help@stably.ai> * Explain disabled external automation actions Co-authored-by: Orca <help@stably.ai> * Surface task source runtime capability gaps Co-authored-by: Orca <help@stably.ai> * Persist automation run context from UI saves Co-authored-by: Orca <help@stably.ai> * Require workspace run capability for setup hosts Co-authored-by: Orca <help@stably.ai> * Disable automation runs for stale host setup Co-authored-by: Orca <help@stably.ai> * Route GitHub drawer metadata by source host Co-authored-by: Orca <help@stably.ai> * Guard runtime project setup mutations by host model Co-authored-by: Orca <help@stably.ai> * Route PR page metadata by repo host Co-authored-by: Orca <help@stably.ai> * Route PR mention metadata by repo host Co-authored-by: Orca <help@stably.ai> * Route GitHub Project edits by view source Co-authored-by: Orca <help@stably.ai> * Clarify runtime automation disabled states Co-authored-by: Orca <help@stably.ai> * Guard runtime automation backend dispatch Co-authored-by: Orca <help@stably.ai> * Preserve GitLab task source identity Co-authored-by: Orca <help@stably.ai> * Remove redundant SSH target row in add project Co-authored-by: Orca <help@stably.ai> * Add task source provider availability reasons Co-authored-by: Orca <help@stably.ai> * Surface task provider preflight availability Co-authored-by: Orca <help@stably.ai> * Record local GitHub task source verification Co-authored-by: Orca <help@stably.ai> * Record Linear task source verification Co-authored-by: Orca <help@stably.ai> * Show automation source context in details Co-authored-by: Orca <help@stably.ai> * Record remote capability negotiation coverage Co-authored-by: Orca <help@stably.ai> * Record local add project create verification Co-authored-by: Orca <help@stably.ai> * Scope Linear cached task reads by source Co-authored-by: Orca <help@stably.ai> * Preserve PR generation host ownership Co-authored-by: Orca <help@stably.ai> * Route git operations by owner host Co-authored-by: Orca <help@stably.ai> * Route delete warnings by worktree owner Co-authored-by: Orca <help@stably.ai> * Route editor drops by worktree owner Co-authored-by: Orca <help@stably.ai> * Route agent draft paste by tab owner Co-authored-by: Orca <help@stably.ai> * Route file explorer requests by worktree owner Co-authored-by: Orca <help@stably.ai> * Document remaining host context gaps Co-authored-by: Orca <help@stably.ai> * Check runtime task source provider auth Co-authored-by: Orca <help@stably.ai> * Validate automation source availability Co-authored-by: Orca <help@stably.ai> * Route remaining UI requests by owner host Co-authored-by: Orca <help@stably.ai> * Route quick open file listing by worktree owner Co-authored-by: Orca <help@stably.ai> * Route typed GitHub lookups by source host Co-authored-by: Orca <help@stably.ai> * Centralize automation run identity fallback Co-authored-by: Orca <help@stably.ai> * Surface unsupported task source providers Co-authored-by: Orca <help@stably.ai> * Document automation legacy repo compatibility Co-authored-by: Orca <help@stably.ai> * Record live host model verification Co-authored-by: Orca <help@stably.ai> * Quiet disconnected SSH polling Co-authored-by: Orca <help@stably.ai> * Verify task drawer source boundaries Co-authored-by: Orca <help@stably.ai> * Verify GitLab repo source selectors Co-authored-by: Orca <help@stably.ai> * Route automations through owning host Co-authored-by: Orca <help@stably.ai> * Update host context verification checklist Co-authored-by: Orca <help@stably.ai> * Run remote automations headlessly in serve mode Co-authored-by: Orca <help@stably.ai> * Keep setup guide entry stable during refresh Co-authored-by: Orca <help@stably.ai> * Keep setup script prompt stable during host switches Co-authored-by: Orca <help@stably.ai> * Deduplicate Tasks project picker sources Co-authored-by: Orca <help@stably.ai> * Use project identity for Tasks picker dedupe Co-authored-by: Orca <help@stably.ai> * Add Tasks source host switcher Co-authored-by: Orca <help@stably.ai> * Refine Tasks source picker disclosure Co-authored-by: Orca <help@stably.ai> * Polish Tasks source picker hover Co-authored-by: Orca <help@stably.ai> * Open Tasks source menu on hover Co-authored-by: Orca <help@stably.ai> * Match Tasks source submenu hover behavior Co-authored-by: Orca <help@stably.ai> * Open Tasks source submenu from project row hover Co-authored-by: Orca <help@stably.ai> * Group automation project hosts Co-authored-by: Orca <help@stably.ai> * Tighten automation project picker density Co-authored-by: Orca <help@stably.ai> * Show selected host in Tasks project picker Co-authored-by: Orca <help@stably.ai> * Hide host labels for single-host project pickers Co-authored-by: Orca <help@stably.ai> * Use saved remote server names in host pickers Co-authored-by: Orca <help@stably.ai> * Use standard add project start for remote servers Co-authored-by: Orca <help@stably.ai> * Use saved host labels in workspace surfaces Co-authored-by: Orca <help@stably.ai> * Route remote browser tabs through runtime hosts Co-authored-by: Orca <help@stably.ai> * Keep sidebar project-first across grouping modes Co-authored-by: Orca <help@stably.ai> * Polish multi-host remote runtime UX Co-authored-by: Orca <help@stably.ai> * Fix CI lint and remove design notes Co-authored-by: Orca <help@stably.ai> * Fix CI test failures Co-authored-by: Orca <help@stably.ai> * Fix Windows CLI path expectation Co-authored-by: Orca <help@stably.ai> * Fix CI renderer test expectations Co-authored-by: Orca <help@stably.ai> * Fix remaining verify test failures Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Bryant Ung <bryant.ung@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Borja <3930245+BorjaLL@users.noreply.github.com> Co-authored-by: Parker Rex <me@parkerrex.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Trevin Chow <trevin@trevinchow.com> Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
This commit is contained in:
co-authored by
Orca
Bryant Ung
Claude Fable 5
Neil
github-actions[bot]
Jinjing
Borja
Parker Rex
Brennan Benson
Trevin Chow
buf0-bot[bot]
orca-bug-scan-bot
parent
651c026208
commit
36277801e4
@@ -93,6 +93,10 @@ docs/reference/react-performance-audit.md
|
||||
validation-screenshots/
|
||||
.stably-browser
|
||||
|
||||
# PR verification evidence screenshots are referenced from notes but should not
|
||||
# be committed.
|
||||
notes/artifacts/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -127,6 +127,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
if (
|
||||
[
|
||||
'automations',
|
||||
'project',
|
||||
'repo',
|
||||
'worktree',
|
||||
'terminal',
|
||||
@@ -156,6 +157,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
(commandPath.length === 1 &&
|
||||
[
|
||||
'automations',
|
||||
'project',
|
||||
'repo',
|
||||
'worktree',
|
||||
'terminal',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { CORE_HANDLERS } from './handlers/core'
|
||||
import { AUTOMATION_HANDLERS } from './handlers/automations'
|
||||
import { PROJECT_HANDLERS } from './handlers/project'
|
||||
import { REPO_HANDLERS } from './handlers/repo'
|
||||
import { WORKTREE_HANDLERS } from './handlers/worktree'
|
||||
import { FILE_HANDLERS } from './handlers/file'
|
||||
@@ -37,6 +38,7 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
const groups = [
|
||||
CORE_HANDLERS,
|
||||
AUTOMATION_HANDLERS,
|
||||
PROJECT_HANDLERS,
|
||||
REPO_HANDLERS,
|
||||
WORKTREE_HANDLERS,
|
||||
FILE_HANDLERS,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { quoteCliCommandArgument } from './shell-command-quote'
|
||||
import { RuntimeRpcFailureError } from './runtime-client'
|
||||
import {
|
||||
formatCliError,
|
||||
formatAutomationShow,
|
||||
formatComputerAction,
|
||||
formatGetAppState,
|
||||
formatTerminalRead,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
printResult
|
||||
} from './format'
|
||||
import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types'
|
||||
import type { Automation } from '../shared/automations-types'
|
||||
|
||||
let testScreenshotDir: string | null = null
|
||||
|
||||
@@ -140,6 +142,59 @@ describe('formatWorktreeList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAutomationShow', () => {
|
||||
function automation(overrides: Partial<Automation> = {}): Automation {
|
||||
return {
|
||||
id: 'auto-1',
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
precheck: null,
|
||||
agentId: 'codex',
|
||||
projectId: 'repo-legacy',
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
schedulerOwner: 'local_host_service',
|
||||
workspaceMode: 'new_per_run',
|
||||
workspaceId: null,
|
||||
baseBranch: null,
|
||||
reuseSession: false,
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 0,
|
||||
enabled: true,
|
||||
nextRunAt: 0,
|
||||
missedRunPolicy: 'run_once_within_grace',
|
||||
missedRunGraceMinutes: 720,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('shows explicit run context before the legacy repo id', () => {
|
||||
const output = formatAutomationShow({
|
||||
automation: automation({
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(output).toContain('runProjectId: github:stablyai/orca')
|
||||
expect(output).toContain('runHostId: runtime:gpu')
|
||||
expect(output).toContain('projectHostSetupId: setup-gpu')
|
||||
expect(output).toContain('runRepoId: repo-gpu')
|
||||
expect(output).toContain('runPath: /srv/orca')
|
||||
expect(output).toContain('legacyRepoId: repo-legacy')
|
||||
expect(output).not.toContain('projectId: repo-legacy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalRead', () => {
|
||||
it('warns limited cursor reads to continue with the next cursor', () => {
|
||||
const output = formatTerminalRead({
|
||||
|
||||
@@ -22,6 +22,14 @@ export {
|
||||
formatListWindows
|
||||
} from './computer-format'
|
||||
export type { ComputerActionFollowUpTarget } from './computer-format'
|
||||
export {
|
||||
formatProjectHostSetupCreateResult,
|
||||
formatProjectHostSetupDeleteResult,
|
||||
formatProjectHostSetupList,
|
||||
formatProjectHostSetupResult,
|
||||
formatProjectHostSetupUpdateResult,
|
||||
formatProjectList
|
||||
} from './project-format'
|
||||
export {
|
||||
formatTerminalClose,
|
||||
formatTerminalCreate,
|
||||
|
||||
@@ -7,7 +7,13 @@ import type {
|
||||
AutomationSchedulePreset,
|
||||
AutomationUpdateInput
|
||||
} from '../../shared/automations-types'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
buildWorkspaceRunContext,
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext,
|
||||
type WorkspaceRunContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type { ProjectHostSetup, TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
@@ -30,6 +36,11 @@ import {
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
|
||||
import {
|
||||
assertWorkspaceTargetFlagsCompatible,
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateTarget
|
||||
} from '../worktree-project-target'
|
||||
|
||||
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
|
||||
repo?: string
|
||||
@@ -270,6 +281,46 @@ function getPrecheckFlag(
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
@@ -293,11 +344,25 @@ async function resolveDefaultTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<{ repo?: string; workspace?: string }> {
|
||||
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const repo = getOptionalStringFlag(flags, 'repo')
|
||||
if (repo && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
|
||||
}
|
||||
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --workspace or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
const projectTarget = await resolveProjectCreateTarget(flags, client)
|
||||
if (projectTarget) {
|
||||
return {
|
||||
repo: projectTarget.repoSelector,
|
||||
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
|
||||
}
|
||||
}
|
||||
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
|
||||
if (repo || workspace) {
|
||||
return { repo, workspace }
|
||||
@@ -316,15 +381,46 @@ async function getExplicitTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<{ repo?: string; workspace?: string }> {
|
||||
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const repo = getOptionalStringFlag(flags, 'repo')
|
||||
if (repo && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
|
||||
}
|
||||
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --workspace or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
const projectTarget = await resolveProjectCreateTarget(flags, client)
|
||||
if (projectTarget) {
|
||||
return {
|
||||
repo: projectTarget.repoSelector,
|
||||
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
|
||||
}
|
||||
}
|
||||
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
|
||||
return { repo, workspace }
|
||||
}
|
||||
|
||||
function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext {
|
||||
const runContext = buildWorkspaceRunContext({
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
})
|
||||
if (!runContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Project host setup is missing automation run context fields: ${setup.id}`
|
||||
)
|
||||
}
|
||||
return runContext
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'automations list': async ({ client, json }) => {
|
||||
const result = await client.call<{ automations: Automation[] }>('automation.list')
|
||||
@@ -342,6 +438,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
const target = await resolveDefaultTarget(flags, cwd, client)
|
||||
const sourceContext = getSourceContextFlag(flags)
|
||||
const workspaceMode =
|
||||
getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run')
|
||||
const result = await client.call<{ automation: Automation }>('automation.create', {
|
||||
@@ -349,6 +446,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
prompt: getRequiredStringFlag(flags, 'prompt'),
|
||||
precheck: getPrecheckFlag(flags),
|
||||
agentId: getProviderFlag(flags),
|
||||
...(target.runContext ? { runContext: target.runContext } : {}),
|
||||
...(sourceContext !== undefined ? { sourceContext } : {}),
|
||||
repo: target.repo,
|
||||
workspace: target.workspace,
|
||||
workspaceMode,
|
||||
@@ -364,6 +463,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'automations edit': async ({ flags, client, cwd, json }) => {
|
||||
const target = await getExplicitTarget(flags, cwd, client)
|
||||
const schedule = getScheduleFlag(flags, false)
|
||||
const sourceContext = getSourceContextFlag(flags)
|
||||
const result = await client.call<{ automation: Automation }>('automation.update', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
updates: {
|
||||
@@ -371,6 +471,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
prompt: getOptionalStringFlag(flags, 'prompt'),
|
||||
precheck: getPrecheckFlag(flags),
|
||||
agentId: getOptionalProviderFlag(flags),
|
||||
...(target.runContext ? { runContext: target.runContext } : {}),
|
||||
...(sourceContext !== undefined ? { sourceContext } : {}),
|
||||
repo: target.repo,
|
||||
workspace: target.workspace,
|
||||
workspaceMode: getWorkspaceModeFlag(flags),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCloneArgs,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
RepoKind
|
||||
} from '../../shared/types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
formatProjectHostSetupCreateResult,
|
||||
formatProjectHostSetupDeleteResult,
|
||||
formatProjectHostSetupList,
|
||||
formatProjectHostSetupResult,
|
||||
formatProjectHostSetupUpdateResult,
|
||||
formatProjectList,
|
||||
printResult
|
||||
} from '../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function getOptionalRepoKind(flags: Map<string, string | boolean>): RepoKind | undefined {
|
||||
const kind = getOptionalStringFlag(flags, 'kind')
|
||||
if (kind === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (kind === 'git' || kind === 'folder') {
|
||||
return kind
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', '--kind must be git or folder')
|
||||
}
|
||||
|
||||
export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
'project list': async ({ client, json }) => {
|
||||
const result = await client.call<{ projects: Project[] }>('project.list')
|
||||
printResult(result, json, formatProjectList)
|
||||
},
|
||||
'project setups': async ({ flags, client, json }) => {
|
||||
const projectFilter = getOptionalStringFlag(flags, 'project')
|
||||
const hostFilter = getOptionalStringFlag(flags, 'host')
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setups = result.result.setups.filter(
|
||||
(setup) =>
|
||||
(projectFilter === undefined || setup.projectId === projectFilter) &&
|
||||
(hostFilter === undefined || setup.hostId === hostFilter)
|
||||
)
|
||||
printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList)
|
||||
},
|
||||
'project setup-existing-folder': async ({ flags, client, cwd, json }) => {
|
||||
const rawPath = getRequiredStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupExistingFolderArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'],
|
||||
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name')
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupResult }>(
|
||||
'projectHostSetup.setupExistingFolder',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupResult)
|
||||
},
|
||||
'project setup-clone': async ({ flags, client, cwd, json }) => {
|
||||
const rawDestination = getRequiredStringFlag(flags, 'destination')
|
||||
const args: ProjectHostSetupCloneArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'],
|
||||
url: getRequiredStringFlag(flags, 'url'),
|
||||
destination: resolveRepoPathArgument(
|
||||
rawDestination,
|
||||
cwd,
|
||||
client.isRemote,
|
||||
'Project setup clone'
|
||||
),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name')
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupResult }>(
|
||||
'projectHostSetup.clone',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupResult)
|
||||
},
|
||||
'project setup-create': async ({ flags, client, cwd, json }) => {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupCreateArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'],
|
||||
setupId: getOptionalStringFlag(flags, 'setup-id'),
|
||||
path:
|
||||
path === undefined
|
||||
? undefined
|
||||
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup create'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
|
||||
gitUsername: getOptionalStringFlag(flags, 'git-username'),
|
||||
setupState: getOptionalSetupState(flags),
|
||||
setupMethod: getOptionalIndependentSetupMethod(flags)
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupCreateResult }>(
|
||||
'projectHostSetup.create',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupCreateResult)
|
||||
},
|
||||
'project setup-update': async ({ flags, client, cwd, json }) => {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupUpdateArgs = {
|
||||
setupId: getRequiredStringFlag(flags, 'setup'),
|
||||
updates: {
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
path:
|
||||
path === undefined
|
||||
? undefined
|
||||
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'),
|
||||
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
|
||||
gitUsername: getOptionalStringFlag(flags, 'git-username'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
setupState: getOptionalSetupState(flags),
|
||||
setupMethod: getOptionalSetupMethod(flags)
|
||||
}
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupUpdateResult }>(
|
||||
'projectHostSetup.update',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupUpdateResult)
|
||||
},
|
||||
'project setup-delete': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{ result: ProjectHostSetupDeleteResult }>(
|
||||
'projectHostSetup.delete',
|
||||
{
|
||||
setupId: getRequiredStringFlag(flags, 'setup')
|
||||
}
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupDeleteResult)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalSetupState(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupUpdateArgs['updates']['setupState'] {
|
||||
const state = getOptionalStringFlag(flags, 'state')
|
||||
if (state === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
state === 'ready' ||
|
||||
state === 'not-set-up' ||
|
||||
state === 'setting-up' ||
|
||||
state === 'error' ||
|
||||
state === 'unsupported'
|
||||
) {
|
||||
return state
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--state must be ready, not-set-up, setting-up, error, or unsupported'
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalIndependentSetupMethod(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupCreateArgs['setupMethod'] {
|
||||
const method = getOptionalStringFlag(flags, 'method')
|
||||
if (method === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (method === 'imported-existing-folder' || method === 'cloned' || method === 'provisioned') {
|
||||
return method
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--method must be imported-existing-folder, cloned, or provisioned'
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalSetupMethod(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupUpdateArgs['updates']['setupMethod'] {
|
||||
const method = getOptionalStringFlag(flags, 'method')
|
||||
if (method === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
method === 'legacy-repo' ||
|
||||
method === 'imported-existing-folder' ||
|
||||
method === 'cloned' ||
|
||||
method === 'provisioned'
|
||||
) {
|
||||
return method
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--method must be legacy-repo, imported-existing-folder, cloned, or provisioned'
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,8 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
|
||||
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function isAbsoluteServerPath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\') ||
|
||||
value.startsWith('//')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
|
||||
if (!isRemote) {
|
||||
return resolvePath(cwd, inputPath)
|
||||
}
|
||||
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
|
||||
// Relative remote paths would silently target the wrong machine.
|
||||
if (!isAbsoluteServerPath(inputPath)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Remote repo add requires --path to be an absolute path on the remote server.'
|
||||
)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
|
||||
export const REPO_HANDLERS: Record<string, CommandHandler> = {
|
||||
'repo list': async ({ client, json }) => {
|
||||
@@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record<string, CommandHandler> = {
|
||||
'repo add': async ({ flags, client, cwd, json }) => {
|
||||
const repoPath = getRequiredStringFlag(flags, 'path')
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
|
||||
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
|
||||
path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add')
|
||||
})
|
||||
printResult(result, json, formatRepoShow)
|
||||
},
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
resolveCurrentWorktreeSelector
|
||||
} from '../selectors'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
assertWorkspaceTargetFlagsCompatible,
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateRepoSelector
|
||||
} from '../worktree-project-target'
|
||||
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
|
||||
|
||||
type HookWarningResult = {
|
||||
@@ -149,10 +154,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri
|
||||
return `id:${worktreeId.slice(0, separatorIndex)}`
|
||||
}
|
||||
|
||||
function getCreateRepoSelector(
|
||||
async function getCreateRepoSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwdParentWorktree: string | undefined
|
||||
): string {
|
||||
cwdParentWorktree: string | undefined,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string> {
|
||||
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
|
||||
if (projectRepoSelector) {
|
||||
return projectRepoSelector
|
||||
}
|
||||
const explicitRepo = getPresentStringFlag(flags, 'repo')
|
||||
if (explicitRepo) {
|
||||
return explicitRepo
|
||||
@@ -195,6 +205,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'worktree create': async ({ flags, client, cwd, json }) => {
|
||||
assertParentFlagsCompatible(flags)
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const callerTerminalHandle =
|
||||
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
|
||||
process.env.ORCA_TERMINAL_HANDLE.length > 0
|
||||
@@ -210,7 +221,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
const setupDecision = getOptionalSetupDecision(flags)
|
||||
const noParent = flags.get('no-parent') === true
|
||||
let cwdParentWorktree: string | undefined
|
||||
if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) {
|
||||
const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags)
|
||||
if ((!explicitParentWorktree && !noParent) || needsCwdRepoInference) {
|
||||
try {
|
||||
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
|
||||
// inside an Orca worktree. Cwd keeps CLI-created children nestable and
|
||||
@@ -222,7 +234,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
repo: getCreateRepoSelector(flags, cwdParentWorktree),
|
||||
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
|
||||
+19
-1
@@ -29,6 +29,15 @@ Automations:
|
||||
automations run Run an Orca automation now
|
||||
automations runs List automation run history
|
||||
|
||||
Projects:
|
||||
project list List durable projects known to Orca
|
||||
project setups List project host setups
|
||||
project setup-existing-folder Make a project available on a host by importing an existing folder
|
||||
project setup-clone Make a project available on a host by cloning a repository
|
||||
project setup-create Create independent project host setup metadata
|
||||
project setup-update Update project host setup metadata
|
||||
project setup-delete Remove a project host setup
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a project to Orca by filesystem path
|
||||
@@ -185,7 +194,7 @@ Common Commands:
|
||||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
@@ -204,6 +213,13 @@ Common Commands:
|
||||
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
|
||||
orca terminal switch [--terminal <handle>] [--json]
|
||||
orca terminal close [--terminal <handle>] [--json]
|
||||
orca project list [--json]
|
||||
orca project setups [--project <id>] [--host <host-id>] [--json]
|
||||
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
|
||||
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
|
||||
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-delete --setup <setup-id> [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
@@ -486,6 +502,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
|
||||
staged: '--staged Open staged source-control changes',
|
||||
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
|
||||
'source-context':
|
||||
'--source-context <json|null> Explicit TaskSourceContext for automation task/provider data',
|
||||
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
|
||||
schedule: '--schedule <schedule> Alias for --trigger',
|
||||
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
|
||||
|
||||
+780
-35
@@ -141,6 +141,18 @@ describe('orca root help', () => {
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'computer press-key Press a single key such as Return or Escape'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'project setup-existing-folder Make a project available on a host by importing an existing folder'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'project setup-create Create independent project host setup metadata'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'project setup-update Update project host setup metadata'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'project setup-delete Remove a project host setup'
|
||||
)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -154,7 +166,6 @@ describe('orca root help', () => {
|
||||
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
|
||||
expect(rootHelp).not.toContain('linear issue')
|
||||
expect(rootHelp).not.toContain('linear search')
|
||||
expect(rootHelp).not.toContain('linear status set')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', '--help'], '/tmp/repo')
|
||||
@@ -163,10 +174,6 @@ describe('orca root help', () => {
|
||||
expect(groupHelp).toContain('orca linear')
|
||||
expect(groupHelp).toContain('issue')
|
||||
expect(groupHelp).toContain('search')
|
||||
expect(groupHelp).toContain('status set')
|
||||
expect(groupHelp).toContain('comment add')
|
||||
expect(groupHelp).toContain('attach')
|
||||
expect(groupHelp).toContain('create')
|
||||
expect(groupHelp).not.toContain('--comments')
|
||||
expect(groupHelp).not.toContain('--attachments')
|
||||
|
||||
@@ -187,21 +194,6 @@ describe('orca root help', () => {
|
||||
expect(searchHelp).toContain('orca linear search <query>')
|
||||
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
||||
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'comment', 'add', '--help'], '/tmp/repo')
|
||||
|
||||
const commentHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(commentHelp).toContain('orca linear comment add [<id>]')
|
||||
expect(commentHelp).toContain('--body-file <path|-> Read Linear body from a file or stdin')
|
||||
expect(commentHelp).toContain('--write-id <uuid> Retry id from linear_write_unconfirmed')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'create', '--help'], '/tmp/repo')
|
||||
|
||||
const createHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(createHelp).toContain('orca linear create --title <title>')
|
||||
expect(createHelp).toContain('--parent-current Use the current linked issue as parent')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -325,7 +317,9 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
@@ -1005,6 +999,153 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves project and host flags to the matching repo for worktree.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-local',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-local',
|
||||
path: '/tmp/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}),
|
||||
okFixture('req_create', {
|
||||
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
|
||||
lineage: null,
|
||||
warnings: []
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'worktree',
|
||||
'create',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'runtime:gpu',
|
||||
'--name',
|
||||
'feature',
|
||||
'--no-parent',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-gpu',
|
||||
name: 'feature',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
activate: false,
|
||||
parentWorktree: undefined,
|
||||
noParent: true,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves project-host-setup directly for worktree.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}),
|
||||
okFixture('req_create', {
|
||||
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
|
||||
lineage: null,
|
||||
warnings: []
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'worktree',
|
||||
'create',
|
||||
'--project-host-setup',
|
||||
'setup-gpu',
|
||||
'--name',
|
||||
'feature',
|
||||
'--no-parent',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.create',
|
||||
expect.objectContaining({ repo: 'id:repo-gpu' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects mixing repo and project target flags on worktree.create', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'worktree',
|
||||
'create',
|
||||
'--repo',
|
||||
'id:repo-local',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--name',
|
||||
'feature',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Choose either --repo or project target flags, not both.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('passes an explicit parent through worktree.create without cwd inference', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
@@ -1368,6 +1509,174 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists projects through the project-first runtime API', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_list', {
|
||||
projects: [
|
||||
{
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
},
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['project', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('project.list')
|
||||
})
|
||||
|
||||
it('filters project host setups locally after fetching setup compatibility state', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-local',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-local',
|
||||
path: '/tmp/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-remote',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-remote',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.list')
|
||||
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local')
|
||||
})
|
||||
|
||||
it('sets up an existing project folder with a path resolved against the local cli cwd', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup', {
|
||||
result: {
|
||||
project: {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-local',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: path.resolve('/tmp/orca'),
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: path.resolve('/tmp/orca'),
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
addedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'project',
|
||||
'setup-existing-folder',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'local',
|
||||
'--path',
|
||||
'..',
|
||||
'--kind',
|
||||
'git',
|
||||
'--display-name',
|
||||
'Orca',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/orca/worktrees/feature'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', {
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
path: path.resolve('/tmp/orca/worktrees'),
|
||||
kind: 'git',
|
||||
displayName: 'Orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects remote project setup relative paths instead of resolving against client cwd', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'project',
|
||||
'setup-existing-folder',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'runtime:gpu',
|
||||
'--path',
|
||||
'./orca',
|
||||
'--pairing-code',
|
||||
'remote-runtime',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Remote project setup requires --path to be an absolute path on the remote server.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -2490,7 +2799,9 @@ describe('orca cli worktree awareness', () => {
|
||||
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
|
||||
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', {
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes emulator gesture points through to the runtime', async () => {
|
||||
@@ -2626,7 +2937,9 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
|
||||
name: 'Daily review',
|
||||
prompt: 'Review open changes',
|
||||
@@ -2644,6 +2957,243 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves project and host flags for automation create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-local',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-local',
|
||||
path: '/tmp/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}),
|
||||
okFixture('req_automation_create', {
|
||||
automation: { id: 'auto-1', name: 'GPU review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'GPU review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open changes',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'runtime:gpu',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:repo-gpu',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca'
|
||||
},
|
||||
workspace: undefined,
|
||||
workspaceMode: 'new_per_run'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves project-host-setup flags for automation edit with explicit run context', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}),
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'GPU review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['automations', 'edit', 'auto-1', '--project-host-setup', 'setup-gpu', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'automation.update',
|
||||
expect.objectContaining({
|
||||
id: 'auto-1',
|
||||
updates: expect.objectContaining({
|
||||
repo: 'id:repo-gpu',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes automation source context JSON through create', async () => {
|
||||
const sourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
|
||||
accountLabel: 'gpu-bot'
|
||||
}
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_automation_create', {
|
||||
automation: { id: 'auto-1', name: 'GPU task review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'GPU task review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open work',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--repo',
|
||||
'id:repo-gpu',
|
||||
'--source-context',
|
||||
JSON.stringify(sourceContext),
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:repo-gpu',
|
||||
sourceContext
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears automation source context on edit with null', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'GPU task review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'automation.update',
|
||||
expect.objectContaining({
|
||||
id: 'auto-1',
|
||||
updates: expect.objectContaining({
|
||||
sourceContext: null
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid automation source context JSON before calling the runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'GPU task review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open work',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--repo',
|
||||
'id:repo-gpu',
|
||||
'--source-context',
|
||||
'{nope',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--source-context must be valid JSON'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('rejects invalid automation --day values before calling the runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -2814,8 +3364,12 @@ describe('orca cli worktree awareness', () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
|
||||
okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }),
|
||||
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
|
||||
okFixture('req_create', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
}),
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -2840,7 +3394,9 @@ describe('orca cli worktree awareness', () => {
|
||||
)
|
||||
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'automation.create',
|
||||
@@ -2904,8 +3460,16 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ flag: 'enabled', value: 'false', message: '--enabled does not take a value' },
|
||||
{ flag: 'disabled', value: 'false', message: '--disabled does not take a value' }
|
||||
{
|
||||
flag: 'enabled',
|
||||
value: 'false',
|
||||
message: '--enabled does not take a value'
|
||||
},
|
||||
{
|
||||
flag: 'disabled',
|
||||
value: 'false',
|
||||
message: '--disabled does not take a value'
|
||||
}
|
||||
])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -2943,7 +3507,9 @@ describe('orca cli worktree awareness', () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
|
||||
okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } })
|
||||
okFixture('req_automation_create', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -2966,7 +3532,9 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
|
||||
name: 'Daily review',
|
||||
prompt: 'Review open changes',
|
||||
@@ -2987,7 +3555,9 @@ describe('orca cli worktree awareness', () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
|
||||
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -2996,7 +3566,9 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
|
||||
id: 'auto-1',
|
||||
updates: {
|
||||
@@ -3064,9 +3636,15 @@ describe('orca cli worktree awareness', () => {
|
||||
missedRunGraceMinutes: undefined
|
||||
}
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' })
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' })
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
|
||||
@@ -3088,4 +3666,171 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('updates project host setup metadata through the project-first runtime API', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup_update', {
|
||||
result: {
|
||||
project: {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: '',
|
||||
path: '/srv/orca',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'project',
|
||||
'setup-update',
|
||||
'--setup',
|
||||
'setup-gpu',
|
||||
'--display-name',
|
||||
'GPU VM',
|
||||
'--path',
|
||||
'/srv/orca',
|
||||
'--worktree-base-path',
|
||||
'../worktrees',
|
||||
'--state',
|
||||
'ready',
|
||||
'--method',
|
||||
'imported-existing-folder',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', {
|
||||
setupId: 'setup-gpu',
|
||||
updates: {
|
||||
displayName: 'GPU VM',
|
||||
path: path.resolve('/tmp/repo', '/srv/orca'),
|
||||
worktreeBasePath: '../worktrees',
|
||||
gitUsername: undefined,
|
||||
kind: undefined,
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('creates independent project host setup metadata through the project-first runtime API', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup_create', {
|
||||
result: {
|
||||
project: {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'project',
|
||||
'setup-create',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'runtime:gpu',
|
||||
'--setup-id',
|
||||
'setup-gpu',
|
||||
'--display-name',
|
||||
'GPU VM',
|
||||
'--state',
|
||||
'setting-up',
|
||||
'--method',
|
||||
'provisioned',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.create', {
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
setupId: 'setup-gpu',
|
||||
path: undefined,
|
||||
kind: undefined,
|
||||
displayName: 'GPU VM',
|
||||
worktreeBasePath: undefined,
|
||||
gitUsername: undefined,
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes project host setup metadata through the project-first runtime API', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup_delete', {
|
||||
result: {
|
||||
project: {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-gpu',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
repoId: '',
|
||||
path: '/srv/orca',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', {
|
||||
setupId: 'setup-gpu'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateResult
|
||||
} from '../shared/types'
|
||||
|
||||
export function formatProjectList(result: { projects: Project[] }): string {
|
||||
if (result.projects.length === 0) {
|
||||
return 'No projects found.'
|
||||
}
|
||||
return result.projects
|
||||
.map((project) => {
|
||||
const identity = project.providerIdentity
|
||||
? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}`
|
||||
: 'no-provider'
|
||||
return `${project.id} ${project.displayName} ${identity}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string {
|
||||
if (result.setups.length === 0) {
|
||||
return 'No project host setups found.'
|
||||
}
|
||||
return result.setups
|
||||
.map(
|
||||
(setup) =>
|
||||
`${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, repo.id)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupCreateResult(result: {
|
||||
result: ProjectHostSetupCreateResult
|
||||
}): string {
|
||||
const { project, setup } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, undefined)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupUpdateResult(result: {
|
||||
result: ProjectHostSetupUpdateResult
|
||||
}): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, repo?.id)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupDeleteResult(result: {
|
||||
result: ProjectHostSetupDeleteResult
|
||||
}): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return [
|
||||
`deleted: ${setup.id}`,
|
||||
formatProjectHostSetupResultFields(project, setup, repo?.id)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatProjectHostSetupResultFields(
|
||||
project: Project,
|
||||
setup: ProjectHostSetup,
|
||||
repoId: string | undefined
|
||||
): string {
|
||||
return [
|
||||
`projectId: ${project.id}`,
|
||||
`project: ${project.displayName}`,
|
||||
`setupId: ${setup.id}`,
|
||||
`hostId: ${setup.hostId}`,
|
||||
`path: ${setup.path}`,
|
||||
`state: ${setup.setupState}`,
|
||||
`method: ${setup.setupMethod}`,
|
||||
`repoId: ${repoId ?? 'none'}`
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
function isAbsoluteServerPath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\') ||
|
||||
value.startsWith('//')
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRepoPathArgument(
|
||||
inputPath: string,
|
||||
cwd: string,
|
||||
isRemote: boolean,
|
||||
remotePathSubject = 'Remote repo path'
|
||||
): string {
|
||||
if (!isRemote) {
|
||||
return resolvePath(cwd, inputPath)
|
||||
}
|
||||
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
|
||||
// Relative remote paths would silently target the wrong machine.
|
||||
if (!isAbsoluteServerPath(inputPath)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${remotePathSubject} requires --path to be an absolute path on the remote server.`
|
||||
)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch']
|
||||
const AUTOMATION_TARGET_FLAGS = [
|
||||
'repo',
|
||||
'workspace',
|
||||
'project',
|
||||
'host',
|
||||
'project-host-setup',
|
||||
'source-context',
|
||||
'workspace-mode',
|
||||
'base-branch'
|
||||
]
|
||||
const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone']
|
||||
const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout']
|
||||
const AUTOMATION_STATE_FLAGS = [
|
||||
@@ -32,7 +41,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['automations', 'create'],
|
||||
summary: 'Create a scheduled Orca automation',
|
||||
usage:
|
||||
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--json]',
|
||||
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>|--project <id> [--host <id>]|--project-host-setup <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'name',
|
||||
@@ -46,6 +55,8 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
|
||||
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
|
||||
'Use --project with --host, or --project-host-setup, to run on a specific project host setup.',
|
||||
'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.',
|
||||
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.',
|
||||
'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.',
|
||||
'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.'
|
||||
|
||||
@@ -102,10 +102,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'create'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'repo',
|
||||
'project',
|
||||
'host',
|
||||
'project-host-setup',
|
||||
'name',
|
||||
'agent',
|
||||
'prompt',
|
||||
@@ -122,6 +125,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
|
||||
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
|
||||
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
|
||||
'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.',
|
||||
'Use --no-parent when the new worktree should be independent of the current workspace.',
|
||||
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
|
||||
@@ -133,6 +137,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
examples: [
|
||||
'orca worktree create --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --json',
|
||||
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
|
||||
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
|
||||
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
|
||||
import { AUTOMATION_COMMAND_SPECS } from './automations'
|
||||
import { CORE_COMMAND_SPECS } from './core'
|
||||
import { FILE_COMMAND_SPECS } from './file'
|
||||
import { PROJECT_COMMAND_SPECS } from './project'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
|
||||
import { COMPUTER_COMMAND_SPECS } from './computer'
|
||||
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
@@ -14,6 +15,7 @@ import { LINEAR_COMMAND_SPECS } from './linear'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...CORE_COMMAND_SPECS,
|
||||
...PROJECT_COMMAND_SPECS,
|
||||
...FILE_COMMAND_SPECS,
|
||||
...AUTOMATION_COMMAND_SPECS,
|
||||
...BROWSER_BASIC_COMMAND_SPECS,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['project', 'list'],
|
||||
summary: 'List durable projects known to Orca',
|
||||
usage: 'orca project list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
examples: ['orca project list', 'orca project list --json']
|
||||
},
|
||||
{
|
||||
path: ['project', 'setups'],
|
||||
summary: 'List project host setups',
|
||||
usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'],
|
||||
notes: ['A setup means a project is available on a host at a concrete filesystem path.'],
|
||||
examples: [
|
||||
'orca project setups',
|
||||
'orca project setups --project github:stablyai/orca',
|
||||
'orca project setups --host local'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-existing-folder'],
|
||||
summary: 'Make a project available on a host by importing an existing folder',
|
||||
usage:
|
||||
'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
|
||||
notes: ['For remote runtimes, --path must be an absolute path on the remote server.'],
|
||||
examples: [
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-clone'],
|
||||
summary: 'Make a project available on a host by cloning a repository',
|
||||
usage:
|
||||
'orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'],
|
||||
notes: [
|
||||
'For remote runtimes, --destination must be an absolute parent directory on the remote server.',
|
||||
'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src',
|
||||
'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-create'],
|
||||
summary: 'Create independent project host setup metadata',
|
||||
usage:
|
||||
'orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'project',
|
||||
'host',
|
||||
'setup-id',
|
||||
'path',
|
||||
'kind',
|
||||
'display-name',
|
||||
'worktree-base-path',
|
||||
'git-username',
|
||||
'state',
|
||||
'method'
|
||||
],
|
||||
notes: [
|
||||
'Creates setup metadata without registering a repo compatibility record.',
|
||||
'Use setup-existing-folder when Orca should import and manage an actual checkout path now.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-update'],
|
||||
summary: 'Update project host setup metadata',
|
||||
usage:
|
||||
'orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'setup',
|
||||
'display-name',
|
||||
'path',
|
||||
'worktree-base-path',
|
||||
'git-username',
|
||||
'kind',
|
||||
'state',
|
||||
'method'
|
||||
],
|
||||
notes: [
|
||||
'Repo-backed setups mirror safe fields onto the repo record.',
|
||||
'Path and availability state changes are only supported for independent setup records.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"',
|
||||
'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-delete'],
|
||||
summary: 'Remove a project host setup',
|
||||
usage: 'orca project setup-delete --setup <setup-id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
|
||||
notes: [
|
||||
'Independent setups are removed directly.',
|
||||
'Repo-backed setups remove the registered repo compatibility record.'
|
||||
],
|
||||
examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json']
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Automation, AutomationRun } from '../shared/automations-types'
|
||||
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
|
||||
import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck'
|
||||
import { formatAutomationSchedule } from '../shared/automation-schedules'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
@@ -178,6 +179,17 @@ export function formatAutomationList(result: { automations: Automation[] }): str
|
||||
|
||||
export function formatAutomationShow(result: { automation: Automation }): string {
|
||||
const automation = result.automation
|
||||
const runContext = automation.runContext ?? null
|
||||
const projectLines = runContext
|
||||
? [
|
||||
`runProjectId: ${runContext.projectId}`,
|
||||
`runHostId: ${runContext.hostId}`,
|
||||
`projectHostSetupId: ${runContext.projectHostSetupId}`,
|
||||
`runRepoId: ${runContext.repoId}`,
|
||||
`runPath: ${runContext.path}`,
|
||||
`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`
|
||||
]
|
||||
: [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`]
|
||||
return [
|
||||
`id: ${automation.id}`,
|
||||
`name: ${automation.name}`,
|
||||
@@ -193,7 +205,7 @@ export function formatAutomationShow(result: { automation: Automation }): string
|
||||
: 'none'
|
||||
}`,
|
||||
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
|
||||
`projectId: ${automation.projectId}`,
|
||||
...projectLines,
|
||||
`workspaceMode: ${automation.workspaceMode}`,
|
||||
`workspaceId: ${automation.workspaceId ?? 'null'}`,
|
||||
`baseBranch: ${automation.baseBranch ?? 'null'}`,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ProjectHostSetup } from '../shared/types'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
export type ProjectCreateTarget = {
|
||||
repoSelector: string
|
||||
setup: ProjectHostSetup
|
||||
}
|
||||
|
||||
function getPresentStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!flags.has(name)) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get(name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
|
||||
}
|
||||
|
||||
export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean {
|
||||
return flags.has('project') || flags.has('host') || flags.has('project-host-setup')
|
||||
}
|
||||
|
||||
export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void {
|
||||
const hasProjectTarget = hasWorkspaceProjectTarget(flags)
|
||||
if (flags.has('repo') && hasProjectTarget) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose either --repo or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--host requires --project unless --project-host-setup is provided.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProjectCreateRepoSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<string | undefined> {
|
||||
return (await resolveProjectCreateTarget(flags, client))?.repoSelector
|
||||
}
|
||||
|
||||
export async function resolveProjectCreateTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<ProjectCreateTarget | undefined> {
|
||||
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
|
||||
const projectId = getPresentStringFlag(flags, 'project')
|
||||
const hostId = getPresentStringFlag(flags, 'host')
|
||||
if (!projectHostSetupId && !projectId && !hostId) {
|
||||
return undefined
|
||||
}
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setup = result.result.setups.find((candidate) => {
|
||||
if (candidate.setupState !== 'ready') {
|
||||
return false
|
||||
}
|
||||
if (projectHostSetupId) {
|
||||
return candidate.id === projectHostSetupId
|
||||
}
|
||||
return (
|
||||
candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId)
|
||||
)
|
||||
})
|
||||
if (!setup) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
projectHostSetupId
|
||||
? `Project host setup is not ready or was not found: ${projectHostSetupId}`
|
||||
: `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
repoSelector: `id:${setup.repoId}`,
|
||||
setup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationRun,
|
||||
AutomationRunOutputSnapshot
|
||||
} from '../../shared/automations-types'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
|
||||
const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024
|
||||
|
||||
export type HeadlessAutomationDispatchLaunch = {
|
||||
workspaceId: string
|
||||
workspaceDisplayName?: string | null
|
||||
terminalSessionId: string | null
|
||||
completion?: Promise<{
|
||||
status: 'completed' | 'dispatch_failed'
|
||||
outputSnapshot?: AutomationRunOutputSnapshot | null
|
||||
error?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type HeadlessAutomationDispatcher = (request: {
|
||||
automation: Automation
|
||||
run: AutomationRun
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
}) => Promise<HeadlessAutomationDispatchLaunch>
|
||||
|
||||
export function createHeadlessAutomationOutputSnapshotBuffer(): {
|
||||
append: (chunk: string) => void
|
||||
snapshot: () => AutomationRunOutputSnapshot | null
|
||||
} {
|
||||
const chunks: string[] = []
|
||||
let totalChars = 0
|
||||
let truncated = false
|
||||
|
||||
return {
|
||||
append(chunk): void {
|
||||
if (!chunk) {
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
totalChars += chunk.length
|
||||
let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS
|
||||
while (overflowChars > 0 && chunks.length > 0) {
|
||||
const firstChunk = chunks[0]!
|
||||
if (firstChunk.length <= overflowChars) {
|
||||
chunks.shift()
|
||||
totalChars -= firstChunk.length
|
||||
overflowChars -= firstChunk.length
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
chunks[0] = firstChunk.slice(overflowChars)
|
||||
totalChars -= overflowChars
|
||||
truncated = true
|
||||
overflowChars = 0
|
||||
}
|
||||
},
|
||||
snapshot(): AutomationRunOutputSnapshot | null {
|
||||
const content = chunks.join('').trim()
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
format: 'plain_text',
|
||||
content,
|
||||
capturedAt: Date.now(),
|
||||
truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Store } from '../persistence'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import type { ProjectHostSetup, Repo } from '../../shared/types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
|
||||
export type AutomationRunTargetResult =
|
||||
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type AutomationRunTargetOptions = {
|
||||
allowRemoteHostScheduling?: boolean
|
||||
}
|
||||
|
||||
function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null {
|
||||
if (automation.workspaceMode === 'existing') {
|
||||
const parsed = automation.workspaceId
|
||||
? splitWorktreeIdForFilesystem(automation.workspaceId)
|
||||
: null
|
||||
return parsed?.worktreePath ?? null
|
||||
}
|
||||
return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null
|
||||
}
|
||||
|
||||
export function resolveAutomationRunTarget(
|
||||
store: Store,
|
||||
automation: Automation,
|
||||
options: AutomationRunTargetOptions = {}
|
||||
): AutomationRunTargetResult {
|
||||
const context = automation.runContext ?? null
|
||||
if (!context) {
|
||||
const repo = store.getRepo(getAutomationLegacyRepoId(automation))
|
||||
const cwd = getLegacyPrecheckCwd(store, automation)
|
||||
if (!repo || !cwd) {
|
||||
return { ok: false, error: 'Automation run target is no longer available.' }
|
||||
}
|
||||
return { ok: true, cwd, repo }
|
||||
}
|
||||
const parsedHost = parseExecutionHostId(context.hostId)
|
||||
if (
|
||||
parsedHost?.kind === 'runtime' &&
|
||||
(!options.allowRemoteHostScheduling || automation.schedulerOwner !== 'remote_host_service')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Remote-server automation scheduling is not available from this Orca client yet. Run this automation on the remote server or update Orca when durable remote scheduling is available.'
|
||||
}
|
||||
}
|
||||
|
||||
const setup = store
|
||||
.getProjectHostSetups()
|
||||
.find((candidate) => candidate.id === context.projectHostSetupId)
|
||||
if (!setup) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Project is not set up on the selected automation host anymore.'
|
||||
}
|
||||
}
|
||||
if (setup.setupState !== 'ready') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Project setup on the selected automation host is ${setup.setupState}.`
|
||||
}
|
||||
}
|
||||
if (
|
||||
setup.projectId !== context.projectId ||
|
||||
setup.hostId !== context.hostId ||
|
||||
setup.repoId !== context.repoId
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Automation run target no longer matches the selected project host setup.'
|
||||
}
|
||||
}
|
||||
|
||||
const repo = store.getRepo(context.repoId)
|
||||
if (!repo) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Repository for the selected automation host is no longer available.'
|
||||
}
|
||||
}
|
||||
if (getRepoExecutionHostId(repo) !== context.hostId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Repository is no longer attached to the selected automation host.'
|
||||
}
|
||||
}
|
||||
if (repo.path !== setup.path || context.path !== setup.path) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, cwd: setup.path, repo, setup }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
|
||||
function createUnavailableAutomationUsage(
|
||||
collectedAt: number,
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
provider,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
}
|
||||
}
|
||||
|
||||
function getAutomationUsageProvider(
|
||||
automation: Automation | undefined
|
||||
): AutomationRunUsage['provider'] {
|
||||
if (automation?.agentId === 'codex') {
|
||||
return 'codex'
|
||||
}
|
||||
if (automation?.agentId === 'claude') {
|
||||
return 'claude'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function collectAutomationRunUsage({
|
||||
automation,
|
||||
run,
|
||||
claudeUsage,
|
||||
codexUsage
|
||||
}: {
|
||||
automation: Automation | undefined
|
||||
run: AutomationRun
|
||||
claudeUsage: ClaudeUsageStore | null
|
||||
codexUsage: CodexUsageStore | null
|
||||
}): Promise<AutomationRunUsage> {
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage =>
|
||||
createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage)
|
||||
|
||||
if (!automation || run.status !== 'completed') {
|
||||
return unavailable(
|
||||
getAutomationUsageProvider(automation),
|
||||
'run_not_finished',
|
||||
'Usage is only collected for completed automation runs.'
|
||||
)
|
||||
}
|
||||
if (automation.executionTargetType === 'ssh') {
|
||||
return unavailable(
|
||||
getAutomationUsageProvider(automation),
|
||||
'remote_usage_unavailable',
|
||||
'Remote automation usage is not available from local usage logs.'
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'claude') {
|
||||
if (!claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
|
||||
}
|
||||
@@ -105,6 +105,46 @@ describe('AutomationService prechecks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not run scheduled prechecks when the selected host setup is stale', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ path: '/repo/current' }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Conditional check',
|
||||
prompt: 'Check the repo',
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: '/repo/old'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
|
||||
const result = await service.runPrecheck(automation.id, run.id)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
command: 'test -f ready',
|
||||
exitCode: null,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
})
|
||||
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run prechecks for manual dispatches', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
@@ -129,4 +169,65 @@ describe('AutomationService prechecks', () => {
|
||||
await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull()
|
||||
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors scheduled prechecks before headless dispatch', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-12T08:59:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Conditional remote check',
|
||||
prompt: 'Check the repo',
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
|
||||
})
|
||||
runAutomationPrecheckMock.mockResolvedValue({
|
||||
command: 'test -f ready',
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
durationMs: 5,
|
||||
stdout: '',
|
||||
stderr: 'missing',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
})
|
||||
const headlessDispatcher = vi.fn()
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
|
||||
const requestHeadlessDispatch = (
|
||||
service as unknown as {
|
||||
requestHeadlessDispatch: (
|
||||
automationArg: typeof automation,
|
||||
runArg: typeof run,
|
||||
targetArg: { ok: true; cwd: string; repo: Repo }
|
||||
) => Promise<unknown>
|
||||
}
|
||||
).requestHeadlessDispatch.bind(service)
|
||||
|
||||
await requestHeadlessDispatch(automation, run, {
|
||||
ok: true,
|
||||
cwd: '/repo',
|
||||
repo: store.getRepo('r1')!
|
||||
})
|
||||
|
||||
expect(headlessDispatcher).not.toHaveBeenCalled()
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'skipped_precheck',
|
||||
error: 'Precheck exited with code 1.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { AutomationService } from './service'
|
||||
|
||||
const testState = { dir: '' }
|
||||
@@ -124,6 +125,227 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips dispatch when the selected project host setup is gone', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'missing-setup',
|
||||
repoId: 'r1',
|
||||
path: '/repo'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toBe('Project is not set up on the selected automation host anymore.')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips dispatch when the saved project host setup path is stale', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ path: '/repo/current' }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: '/repo/old'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toBe('Project path for the selected automation host has changed.')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips runtime-owned automations before desktop renderer dispatch', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toContain('Remote-server automation scheduling is not available')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches remote-host scheduled automations when service runs in serve mode', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true
|
||||
})
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('dispatching')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'automations:dispatchRequested',
|
||||
expect.objectContaining({
|
||||
automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }),
|
||||
run: expect.objectContaining({ id: run.id, status: 'dispatching' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches remote-host automations headlessly when no renderer is available', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher: vi.fn().mockResolvedValue({
|
||||
workspaceId: 'remote-wt-1',
|
||||
workspaceDisplayName: 'Remote automation',
|
||||
terminalSessionId: 'remote-tab-1',
|
||||
completion: Promise.resolve({
|
||||
status: 'completed',
|
||||
outputSnapshot: {
|
||||
format: 'plain_text',
|
||||
content: 'Done.',
|
||||
capturedAt: Date.now(),
|
||||
truncated: false
|
||||
},
|
||||
error: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('dispatched')
|
||||
expect(run.workspaceId).toBe('remote-wt-1')
|
||||
expect(run.workspaceDisplayName).toBe('Remote automation')
|
||||
expect(run.terminalSessionId).toBe('remote-tab-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
workspaceId: 'remote-wt-1',
|
||||
terminalSessionId: 'remote-tab-1',
|
||||
outputSnapshot: expect.objectContaining({ content: 'Done.' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
+110
-97
@@ -6,13 +6,18 @@ import type {
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult,
|
||||
AutomationRun,
|
||||
AutomationRunStatus,
|
||||
AutomationRunUsage
|
||||
AutomationRunStatus
|
||||
} from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { runAutomationPrecheck } from './precheck-runner'
|
||||
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
|
||||
import { collectAutomationRunUsage } from './run-usage-collection'
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
import {
|
||||
didAutomationPrecheckPass,
|
||||
formatAutomationPrecheckFailure
|
||||
} from '../../shared/automation-precheck'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -25,15 +30,25 @@ export class AutomationService {
|
||||
private evaluating = false
|
||||
private readonly claudeUsage: ClaudeUsageStore | null
|
||||
private readonly codexUsage: CodexUsageStore | null
|
||||
private readonly allowRemoteHostScheduling: boolean
|
||||
private readonly headlessDispatcher: HeadlessAutomationDispatcher | null
|
||||
|
||||
constructor(
|
||||
store: Store,
|
||||
opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {}
|
||||
opts: {
|
||||
tickMs?: number
|
||||
claudeUsage?: ClaudeUsageStore
|
||||
codexUsage?: CodexUsageStore
|
||||
allowRemoteHostScheduling?: boolean
|
||||
headlessDispatcher?: HeadlessAutomationDispatcher
|
||||
} = {}
|
||||
) {
|
||||
this.store = store
|
||||
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
|
||||
this.claudeUsage = opts.claudeUsage ?? null
|
||||
this.codexUsage = opts.codexUsage ?? null
|
||||
this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false
|
||||
this.headlessDispatcher = opts.headlessDispatcher ?? null
|
||||
}
|
||||
|
||||
setWebContents(webContents: WebContents | null): void {
|
||||
@@ -87,8 +102,10 @@ export class AutomationService {
|
||||
if (run.trigger !== 'scheduled' || !automation.precheck) {
|
||||
return null
|
||||
}
|
||||
const cwd = this.getPrecheckCwd(automation)
|
||||
if (!cwd) {
|
||||
const target = resolveAutomationRunTarget(this.store, automation, {
|
||||
allowRemoteHostScheduling: this.allowRemoteHostScheduling
|
||||
})
|
||||
if (!target.ok) {
|
||||
return {
|
||||
command: automation.precheck.command,
|
||||
exitCode: null,
|
||||
@@ -98,7 +115,7 @@ export class AutomationService {
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: 'Automation precheck target is no longer available.',
|
||||
error: target.error,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
}
|
||||
@@ -107,8 +124,8 @@ export class AutomationService {
|
||||
precheck: automation.precheck,
|
||||
target:
|
||||
automation.executionTargetType === 'ssh'
|
||||
? { type: 'ssh', cwd, connectionId: automation.executionTargetId }
|
||||
: { type: 'local', cwd }
|
||||
? { type: 'ssh', cwd: target.cwd, connectionId: automation.executionTargetId }
|
||||
: { type: 'local', cwd: target.cwd }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +141,12 @@ export class AutomationService {
|
||||
if (run.usage) {
|
||||
return run
|
||||
}
|
||||
const usage = await this.collectRunUsage(run)
|
||||
const usage = await collectAutomationRunUsage({
|
||||
automation: this.store.listAutomations().find((entry) => entry.id === run.automationId),
|
||||
run,
|
||||
claudeUsage: this.claudeUsage,
|
||||
codexUsage: this.codexUsage
|
||||
})
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
@@ -135,83 +157,6 @@ export class AutomationService {
|
||||
})
|
||||
}
|
||||
|
||||
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
|
||||
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage => ({
|
||||
status: 'unavailable',
|
||||
provider,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
})
|
||||
|
||||
if (!automation || run.status !== 'completed') {
|
||||
return unavailable(
|
||||
automation?.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation?.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'run_not_finished',
|
||||
'Usage is only collected for completed automation runs.'
|
||||
)
|
||||
}
|
||||
if (automation.executionTargetType === 'ssh') {
|
||||
return unavailable(
|
||||
automation.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'remote_usage_unavailable',
|
||||
'Remote automation usage is not available from local usage logs.'
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'claude') {
|
||||
if (!this.claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return this.claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!this.codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return this.codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
return unavailable(
|
||||
null,
|
||||
'provider_unsupported',
|
||||
'This agent does not report usage to Orca yet.'
|
||||
)
|
||||
}
|
||||
|
||||
private async evaluateDueRuns(): Promise<void> {
|
||||
if (this.evaluating) {
|
||||
return
|
||||
@@ -230,16 +175,6 @@ export class AutomationService {
|
||||
}
|
||||
}
|
||||
|
||||
private getPrecheckCwd(automation: Automation): string | null {
|
||||
if (automation.workspaceMode === 'existing') {
|
||||
const parsed = automation.workspaceId
|
||||
? splitWorktreeIdForFilesystem(automation.workspaceId)
|
||||
: null
|
||||
return parsed?.worktreePath ?? null
|
||||
}
|
||||
return this.store.getRepo(automation.projectId)?.path ?? null
|
||||
}
|
||||
|
||||
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
|
||||
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
|
||||
if (scheduledFor === null) {
|
||||
@@ -267,8 +202,22 @@ export class AutomationService {
|
||||
automation: Automation,
|
||||
run: AutomationRun
|
||||
): Promise<AutomationRun> {
|
||||
const target = resolveAutomationRunTarget(this.store, automation, {
|
||||
allowRemoteHostScheduling: this.allowRemoteHostScheduling
|
||||
})
|
||||
if (!target.ok) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: target.error
|
||||
})
|
||||
}
|
||||
const webContents = this.webContents
|
||||
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
|
||||
if (this.headlessDispatcher) {
|
||||
return await this.requestHeadlessDispatch(automation, run, target)
|
||||
}
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
@@ -286,6 +235,70 @@ export class AutomationService {
|
||||
webContents.send('automations:dispatchRequested', payload)
|
||||
return updated
|
||||
}
|
||||
|
||||
private async requestHeadlessDispatch(
|
||||
automation: Automation,
|
||||
run: AutomationRun,
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
): Promise<AutomationRun> {
|
||||
const precheckResult =
|
||||
run.trigger === 'scheduled' && automation.precheck
|
||||
? await this.runPrecheck(automation.id, run.id)
|
||||
: null
|
||||
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_precheck',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: formatAutomationPrecheckFailure(precheckResult)
|
||||
})
|
||||
}
|
||||
try {
|
||||
const launch = await this.headlessDispatcher!({ automation, run, target })
|
||||
const updated = this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
error: null
|
||||
})
|
||||
if (launch.completion) {
|
||||
void launch.completion
|
||||
.then((completion) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: completion.status,
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
return updated
|
||||
} catch (error) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalRunStatus(status: AutomationRunStatus): boolean {
|
||||
|
||||
@@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => {
|
||||
|
||||
const result = healthCheckDaemon(socketPath, tokenPath)
|
||||
socket.emit('connect')
|
||||
socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n'))
|
||||
socket.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
expect(socket.listenerCount('connect')).toBe(0)
|
||||
|
||||
@@ -74,15 +74,36 @@ describe('daemon health', () => {
|
||||
})
|
||||
|
||||
it('passes when a daemon answers ping', async () => {
|
||||
const ptySpawnHealthCheck = vi.fn(async () => {})
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
ptySpawnHealthCheck,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
|
||||
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await server.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
ptySpawnHealthCheck: vi.fn(async () => {
|
||||
throw new Error('stale node-pty helper')
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
|
||||
} finally {
|
||||
await server.shutdown()
|
||||
}
|
||||
|
||||
@@ -137,7 +137,10 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
|
||||
settle(false)
|
||||
return
|
||||
}
|
||||
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
|
||||
// Why: a protocol-live daemon with a stale cwd or node-pty helper
|
||||
// will answer ping but cannot create terminals, so reuse must check
|
||||
// the PTY spawn prerequisites too.
|
||||
sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' }))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
writeFileSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
@@ -66,6 +67,7 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
|
||||
const healthCheckDaemonMock = vi.fn(async () => true)
|
||||
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
|
||||
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
||||
@@ -101,6 +103,7 @@ const {
|
||||
writeFileSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
@@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('net', () => ({ connect: netConnectMock }))
|
||||
|
||||
vi.mock('./daemon-health', () => ({
|
||||
checkDaemonHealth: checkDaemonHealthMock,
|
||||
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
|
||||
healthCheckDaemon: healthCheckDaemonMock,
|
||||
@@ -268,6 +272,8 @@ async function importFresh() {
|
||||
setLocalPtyProviderMock.mockClear()
|
||||
unbindLocalProviderListenersMock.mockClear()
|
||||
rebindLocalProviderListenersMock.mockClear()
|
||||
checkDaemonHealthMock.mockClear()
|
||||
checkDaemonHealthMock.mockResolvedValue('healthy')
|
||||
healthCheckDaemonMock.mockClear()
|
||||
healthCheckDaemonMock.mockResolvedValue(true)
|
||||
getMacDaemonSystemResolverHealthMock.mockReset()
|
||||
@@ -1026,9 +1032,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
probeSocketExistsMock.mockImplementation(
|
||||
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
|
||||
)
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
getAppPathMock.mockReturnValue('/fake/app/out/main')
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
@@ -1069,8 +1075,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
})
|
||||
|
||||
it('removes detached daemon startup listeners after readiness', async () => {
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
@@ -1124,8 +1130,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
})
|
||||
|
||||
it('removes detached daemon startup listeners after startup error', async () => {
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
|
||||
@@ -202,11 +202,10 @@ describe('DaemonServer', () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
||||
// Why: older app builds still send the removed ptySpawnHealth probe.
|
||||
// The daemon must reject it gracefully so a downgraded client lands on
|
||||
// its session-preserving branch instead of crashing the daemon.
|
||||
await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow(
|
||||
'Unknown request type: ptySpawnHealth'
|
||||
// Why: downgraded clients can send request types this daemon does not
|
||||
// know. Reject gracefully instead of crashing the session server.
|
||||
await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow(
|
||||
'Unknown request type: definitelyUnknownRequest'
|
||||
)
|
||||
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
|
||||
pong: true
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host'
|
||||
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { checkPtySpawnHealth } from './pty-subprocess'
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
NOTIFY_PREFIX,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
export type DaemonServerOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
cols: number
|
||||
@@ -45,6 +47,7 @@ export class DaemonServer {
|
||||
private host: TerminalHost
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
|
||||
private clients = new Map<string, ConnectedClient>()
|
||||
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
|
||||
@@ -62,6 +65,7 @@ export class DaemonServer {
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -385,6 +389,10 @@ export class DaemonServer {
|
||||
case 'systemResolverHealth':
|
||||
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
||||
|
||||
case 'ptySpawnHealth':
|
||||
await this.ptySpawnHealthCheck()
|
||||
return { healthy: true }
|
||||
|
||||
case 'shutdown':
|
||||
if (request.payload.killSessions) {
|
||||
this.host.dispose()
|
||||
|
||||
@@ -33,6 +33,7 @@ import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
|
||||
|
||||
export type PtySubprocessOptions = {
|
||||
sessionId: string
|
||||
@@ -235,6 +236,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string):
|
||||
return formatted
|
||||
}
|
||||
|
||||
export async function checkPtySpawnHealth(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
preflightMacNodePtySpawnEnvironment()
|
||||
|
||||
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
|
||||
? process.env.ORCA_USER_DATA_PATH
|
||||
: getDefaultCwd()
|
||||
|
||||
let proc: pty.IPty
|
||||
try {
|
||||
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
throw formatPtySpawnError(err, '/bin/sh', cwd)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let exitDisposable: { dispose(): void } | undefined
|
||||
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
exitDisposable?.dispose()
|
||||
if (opts?.kill) {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// Best-effort cleanup for a short-lived health probe.
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
|
||||
kill: true
|
||||
})
|
||||
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
|
||||
|
||||
// Why: ping only proves the daemon protocol is alive. A real short-lived
|
||||
// PTY spawn catches stale node-pty helper paths captured by this process.
|
||||
exitDisposable = proc.onExit(({ exitCode }) => {
|
||||
if (exitCode === 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
|
||||
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
|
||||
if (!trimmed || trimmed === 'xterm-256color') {
|
||||
|
||||
@@ -201,6 +201,11 @@ export type SystemResolverHealthRequest = {
|
||||
type: 'systemResolverHealth'
|
||||
}
|
||||
|
||||
export type PtySpawnHealthRequest = {
|
||||
id: string
|
||||
type: 'ptySpawnHealth'
|
||||
}
|
||||
|
||||
export type GetSnapshotRequest = {
|
||||
id: string
|
||||
type: 'getSnapshot'
|
||||
@@ -260,6 +265,7 @@ export type DaemonRequest =
|
||||
| ShutdownRequest
|
||||
| PingRequest
|
||||
| SystemResolverHealthRequest
|
||||
| PtySpawnHealthRequest
|
||||
| GetSnapshotRequest
|
||||
| TakePendingOutputRequest
|
||||
|
||||
|
||||
@@ -14,6 +14,21 @@ export type ClaimedCloneTarget = {
|
||||
|
||||
type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'>
|
||||
|
||||
export function deriveCloneRepoNameFromUrl(url: string): string {
|
||||
// Why: direct callers can supply URLs whose default git clone folder would
|
||||
// be "." or ".."; rejecting them prevents parent/destination deletion.
|
||||
const source = url.replace(/\.git\/?$/, '')
|
||||
const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\')
|
||||
const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source)
|
||||
if (!repoName || repoName === '.' || repoName === '..') {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
if (repoName.includes('/') || repoName.includes('\\')) {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
return repoName
|
||||
}
|
||||
|
||||
export function deriveValidatedClonePath(args: { url: string; destination: string }): string {
|
||||
if (
|
||||
!args.destination ||
|
||||
@@ -23,17 +38,7 @@ export function deriveValidatedClonePath(args: { url: string; destination: strin
|
||||
throw new Error('Clone destination must be an absolute path')
|
||||
}
|
||||
|
||||
// Why: direct callers can supply URLs whose default git clone folder would
|
||||
// be "." or ".."; rejecting them prevents parent/destination deletion.
|
||||
const source = args.url.replace(/\.git\/?$/, '')
|
||||
const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\')
|
||||
const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source)
|
||||
if (!repoName || repoName === '.' || repoName === '..') {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
if (repoName.includes('/') || repoName.includes('\\')) {
|
||||
throw new Error('Invalid repository name derived from URL')
|
||||
}
|
||||
const repoName = deriveCloneRepoNameFromUrl(args.url)
|
||||
|
||||
const clonePath = join(args.destination, repoName)
|
||||
const resolvedDestination = resolve(args.destination)
|
||||
|
||||
+100
-1
@@ -109,6 +109,7 @@ import { browserManager } from './browser/browser-manager'
|
||||
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
||||
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
import { AutomationService } from './automations/service'
|
||||
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import {
|
||||
getCrashBreadcrumbSnapshot,
|
||||
@@ -158,6 +159,16 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
|
||||
let runtime: OrcaRuntimeService | null = null
|
||||
let rateLimits: RateLimitService | null = null
|
||||
let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
||||
|
||||
function buildHeadlessAutomationWorkspaceName(runTitle: string, scheduledFor: number): string {
|
||||
const slug = runTitle
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
|
||||
return `auto-${slug || 'run'}-${stamp}`
|
||||
}
|
||||
let starNag: StarNagService | null = null
|
||||
let agentAwakeService: AgentAwakeService | null = null
|
||||
let crashReports: CrashReportStore | null = null
|
||||
@@ -1306,7 +1317,95 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
runtime = runtimeService
|
||||
automations = new AutomationService(store, { claudeUsage, codexUsage })
|
||||
automations = new AutomationService(store, {
|
||||
claudeUsage,
|
||||
codexUsage,
|
||||
// Why: desktop clients may mirror remote-host automations, but only a
|
||||
// server process should execute schedules owned by `remote_host_service`.
|
||||
allowRemoteHostScheduling: isServeMode,
|
||||
headlessDispatcher: isServeMode
|
||||
? async ({ automation, run, target }) => {
|
||||
const terminalSnapshotLimit = 2_000
|
||||
let terminalHandle: string
|
||||
let terminalSessionId: string | null = null
|
||||
let workspaceId: string
|
||||
let workspaceDisplayName: string | null = null
|
||||
|
||||
if (automation.workspaceMode === 'new_per_run') {
|
||||
const created = await runtimeService.createManagedWorktree({
|
||||
repoSelector: target.repo.id,
|
||||
name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor),
|
||||
baseBranch: automation.baseBranch ?? undefined,
|
||||
setupDecision: 'inherit',
|
||||
activate: false,
|
||||
createdWithAgent: automation.agentId,
|
||||
startupAgent: automation.agentId,
|
||||
startupPrompt: automation.prompt,
|
||||
telemetrySource: 'unknown'
|
||||
})
|
||||
terminalHandle = created.startupTerminal?.handle ?? ''
|
||||
terminalSessionId = created.startupTerminal?.tabId ?? null
|
||||
workspaceId = created.worktree.id
|
||||
workspaceDisplayName = created.worktree.displayName ?? null
|
||||
if (!terminalHandle) {
|
||||
throw new Error(
|
||||
created.warning ||
|
||||
'Automation workspace was created, but no agent terminal started.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!automation.workspaceId) {
|
||||
throw new Error('The target workspace is no longer available.')
|
||||
}
|
||||
const terminal = await runtimeService.launchAgentTerminal(
|
||||
`id:${automation.workspaceId}`,
|
||||
{
|
||||
agent: automation.agentId,
|
||||
prompt: automation.prompt,
|
||||
title: run.title
|
||||
}
|
||||
)
|
||||
terminalHandle = terminal.handle
|
||||
terminalSessionId = terminal.tabId ?? null
|
||||
workspaceId = terminal.worktreeId
|
||||
const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`)
|
||||
workspaceDisplayName = worktree.displayName ?? null
|
||||
}
|
||||
|
||||
const completion = (async () => {
|
||||
const wait = await runtimeService.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle'
|
||||
})
|
||||
const read = await runtimeService.readTerminal(terminalHandle, {
|
||||
limit: terminalSnapshotLimit
|
||||
})
|
||||
const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer()
|
||||
snapshotBuffer.append(read.tail.join('\n'))
|
||||
if (wait.satisfied) {
|
||||
return {
|
||||
status: 'completed' as const,
|
||||
outputSnapshot: snapshotBuffer.snapshot(),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'dispatch_failed' as const,
|
||||
outputSnapshot: snapshotBuffer.snapshot(),
|
||||
error: wait.blockedReason
|
||||
? `Automation agent is blocked: ${wait.blockedReason}.`
|
||||
: 'Automation agent did not report completion.'
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
workspaceDisplayName,
|
||||
terminalSessionId,
|
||||
completion
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
runtimeService.setAutomationService(automations)
|
||||
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
||||
runtimeService.setCommitMessageAgentEnvironmentResolvers({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { TaskSourceContext } from '../../shared/task-source-context'
|
||||
|
||||
export type WorkItemArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const {
|
||||
getIssueMock,
|
||||
listIssuesMock,
|
||||
listWorkItemsMock,
|
||||
listLabelsMock,
|
||||
listAssignableUsersMock,
|
||||
getAuthenticatedViewerMock,
|
||||
mergePRMock,
|
||||
setPRAutoMergeMock,
|
||||
@@ -22,6 +24,8 @@ const {
|
||||
getIssueMock: vi.fn(),
|
||||
listIssuesMock: vi.fn(),
|
||||
listWorkItemsMock: vi.fn(),
|
||||
listLabelsMock: vi.fn(),
|
||||
listAssignableUsersMock: vi.fn(),
|
||||
getAuthenticatedViewerMock: vi.fn(),
|
||||
mergePRMock: vi.fn(),
|
||||
setPRAutoMergeMock: vi.fn(),
|
||||
@@ -46,6 +50,8 @@ vi.mock('../github/client', () => ({
|
||||
getIssue: getIssueMock,
|
||||
listIssues: listIssuesMock,
|
||||
listWorkItems: listWorkItemsMock,
|
||||
listLabels: listLabelsMock,
|
||||
listAssignableUsers: listAssignableUsersMock,
|
||||
getAuthenticatedViewer: getAuthenticatedViewerMock,
|
||||
mergePR: mergePRMock,
|
||||
setPRAutoMerge: setPRAutoMergeMock,
|
||||
@@ -74,6 +80,7 @@ describe('registerGitHubHandlers', () => {
|
||||
badgeColor: string
|
||||
addedAt: number
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
issueSourcePreference?: 'origin' | 'upstream'
|
||||
}
|
||||
let repos: FixtureRepo[] = []
|
||||
@@ -91,6 +98,8 @@ describe('registerGitHubHandlers', () => {
|
||||
getIssueMock.mockReset()
|
||||
listIssuesMock.mockReset()
|
||||
listWorkItemsMock.mockReset()
|
||||
listLabelsMock.mockReset()
|
||||
listAssignableUsersMock.mockReset()
|
||||
getAuthenticatedViewerMock.mockReset()
|
||||
mergePRMock.mockReset()
|
||||
setPRAutoMergeMock.mockReset()
|
||||
@@ -156,6 +165,58 @@ describe('registerGitHubHandlers', () => {
|
||||
expect(getIssueMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects GitHub source context from a different host', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
expect(() =>
|
||||
handlers['gh:listWorkItems'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:openclaw-2',
|
||||
repoId: 'repo-1'
|
||||
}
|
||||
})
|
||||
).toThrow('Access denied: GitHub source host does not match repository host')
|
||||
|
||||
expect(listWorkItemsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('guards label metadata lookups with source host context', async () => {
|
||||
listLabelsMock.mockResolvedValue(['bug'])
|
||||
repos = [
|
||||
...repos,
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/workspace/remote-repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'openclaw-2',
|
||||
executionHostId: 'ssh:openclaw-2'
|
||||
}
|
||||
]
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await expect(
|
||||
handlers['gh:listLabels'](null, {
|
||||
repoPath: '/workspace/remote-repo',
|
||||
repoId: 'repo-ssh',
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:openclaw-2',
|
||||
repoId: 'repo-ssh'
|
||||
}
|
||||
})
|
||||
).resolves.toEqual(['bug'])
|
||||
|
||||
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/remote-repo', undefined, 'openclaw-2')
|
||||
})
|
||||
|
||||
it('forwards listIssues for registered repositories and unwraps items', async () => {
|
||||
listIssuesMock.mockResolvedValue({ items: [] })
|
||||
|
||||
|
||||
+59
-17
@@ -14,6 +14,8 @@ import type {
|
||||
GitHubPRRefreshReason,
|
||||
PRRefreshOutcome
|
||||
} from '../../shared/types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { TaskSourceContext } from '../../shared/task-source-context'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import {
|
||||
@@ -130,7 +132,11 @@ function broadcastWorkItemMutated(
|
||||
|
||||
// Why: returns the full Repo object instead of just the path string so that
|
||||
// callers have access to repo.id for stat tracking and other context.
|
||||
type RepoScopedArgs = { repoPath: string; repoId?: string }
|
||||
type RepoScopedArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
|
||||
const repoPath = typeof args === 'string' ? args : args.repoPath
|
||||
@@ -145,6 +151,13 @@ function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo
|
||||
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
|
||||
throw new Error('Access denied: repository path does not match repo id')
|
||||
}
|
||||
if (
|
||||
typeof args !== 'string' &&
|
||||
args.sourceContext?.provider === 'github' &&
|
||||
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
|
||||
) {
|
||||
throw new Error('Access denied: GitHub source host does not match repository host')
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@@ -275,10 +288,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getIssue(repo.path, args.number, repoConnectionId(repo))
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:issue',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getIssue(repo.path, args.number, repoConnectionId(repo))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
@@ -295,7 +319,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:createIssue',
|
||||
(_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => {
|
||||
(_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const fields =
|
||||
args.labels !== undefined || args.assignees !== undefined
|
||||
@@ -416,6 +440,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
@@ -442,6 +468,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
checkName?: string
|
||||
@@ -470,6 +498,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
noCache?: boolean
|
||||
@@ -487,7 +517,16 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:resolveReviewThread',
|
||||
async (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => {
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
threadId: string
|
||||
resolve: boolean
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
// Why: thread resolve doesn't carry the PR number, so we cannot target
|
||||
// a specific cache entry. The renderer cache stores per-(repo, type, number)
|
||||
@@ -546,6 +585,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
commentId: number
|
||||
body: string
|
||||
@@ -687,6 +727,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
@@ -716,6 +758,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
@@ -743,7 +787,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
'gh:updatePRState',
|
||||
async (
|
||||
event,
|
||||
args: { repoPath: string; prNumber: number; updates: GitHubPullRequestStateUpdate }
|
||||
args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
if (
|
||||
@@ -773,7 +817,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
'gh:rerunPRChecks',
|
||||
async (
|
||||
_event,
|
||||
args: { repoPath: string; prNumber: number; headSha?: string; failedOnly?: boolean }
|
||||
args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
if (
|
||||
@@ -794,7 +838,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:requestPRReviewers',
|
||||
async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => {
|
||||
async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const result = await requestPRReviewers(
|
||||
repo.path,
|
||||
@@ -814,7 +858,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:removePRReviewers',
|
||||
async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => {
|
||||
async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const result = await removePRReviewers(
|
||||
repo.path,
|
||||
@@ -834,10 +878,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:updateIssue',
|
||||
async (
|
||||
event,
|
||||
args: { repoPath: string; repoId?: string; number: number; updates: GitHubIssueUpdate }
|
||||
) => {
|
||||
async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
|
||||
return { ok: false, error: 'Invalid issue number' }
|
||||
@@ -863,6 +904,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
body: string
|
||||
type?: 'issue' | 'pr'
|
||||
@@ -898,12 +940,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => {
|
||||
ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => {
|
||||
ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const { ipcHandlers, listWorkItemsMock, getWorkItemByProjectRefMock } = vi.hoisted(() => ({
|
||||
ipcHandlers: new Map<string, (...args: unknown[]) => unknown>(),
|
||||
listWorkItemsMock: vi.fn(),
|
||||
getWorkItemByProjectRefMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
ipcHandlers.set(channel, handler)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../gitlab/client', () => ({
|
||||
addIssueComment: vi.fn(),
|
||||
addMRInlineComment: vi.fn(),
|
||||
addMRComment: vi.fn(),
|
||||
closeMR: vi.fn(),
|
||||
createIssue: vi.fn(),
|
||||
diagnoseAuth: vi.fn(),
|
||||
getAuthenticatedViewer: vi.fn(),
|
||||
getJobTrace: vi.fn(),
|
||||
getIssue: vi.fn(),
|
||||
getMergeRequest: vi.fn(),
|
||||
getMergeRequestForBranch: vi.fn(),
|
||||
getProjectSlug: vi.fn(),
|
||||
getRateLimit: vi.fn(),
|
||||
getWorkItemByProjectRef: getWorkItemByProjectRefMock,
|
||||
listAssignableUsers: vi.fn(),
|
||||
listIssues: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
listMergeRequests: vi.fn(),
|
||||
listTodos: vi.fn(),
|
||||
listWorkItems: listWorkItemsMock,
|
||||
mergeMR: vi.fn(),
|
||||
reopenMR: vi.fn(),
|
||||
resolveMRDiscussion: vi.fn(),
|
||||
retryJob: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
updateMR: vi.fn(),
|
||||
updateMRReviewers: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitlab/work-item-details', () => ({
|
||||
getWorkItemDetails: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitlab/gitlab-project-recents', () => ({
|
||||
recordGitLabProjectRecent: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerGitLabHandlers } from './gitlab'
|
||||
|
||||
function repo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-local',
|
||||
path: '/local/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function storeWithRepos(repos: Repo[]): Pick<Store, 'getRepos' | 'getRepo'> {
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getRepo: (id: string) => repos.find((candidate) => candidate.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
describe('GitLab IPC handlers', () => {
|
||||
it('resolves repoId and source host context before listing work items', async () => {
|
||||
const remoteRepo = repo({
|
||||
id: 'repo-ssh',
|
||||
path: '/ssh/orca',
|
||||
connectionId: 'builder',
|
||||
executionHostId: toSshExecutionHostId('builder')
|
||||
})
|
||||
listWorkItemsMock.mockResolvedValueOnce({ items: [] })
|
||||
registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store)
|
||||
|
||||
const handler = ipcHandlers.get('gitlab:listWorkItems')
|
||||
await expect(
|
||||
handler?.(null, {
|
||||
repoPath: '/does/not/matter',
|
||||
repoId: 'repo-ssh',
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'gitlab',
|
||||
projectId: 'gitlab:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
repoId: 'repo-ssh'
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({ items: [] })
|
||||
|
||||
expect(listWorkItemsMock).toHaveBeenCalledWith(
|
||||
'/ssh/orca',
|
||||
'opened',
|
||||
1,
|
||||
20,
|
||||
undefined,
|
||||
undefined,
|
||||
'builder'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects source context for a different host', async () => {
|
||||
registerGitLabHandlers(
|
||||
storeWithRepos([repo({ id: 'repo-local', path: '/local/orca' })]) as Store
|
||||
)
|
||||
|
||||
const handler = ipcHandlers.get('gitlab:listWorkItems')
|
||||
await expect(
|
||||
handler?.(null, {
|
||||
repoPath: '/local/orca',
|
||||
repoId: 'repo-local',
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'gitlab',
|
||||
projectId: 'gitlab:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
repoId: 'repo-local'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('source host does not match')
|
||||
})
|
||||
|
||||
it('resolves pasted URL lookups by repoId and source host context', async () => {
|
||||
const remoteRepo = repo({
|
||||
id: 'repo-ssh',
|
||||
path: '/ssh/orca',
|
||||
connectionId: 'builder',
|
||||
executionHostId: toSshExecutionHostId('builder')
|
||||
})
|
||||
getWorkItemByProjectRefMock.mockResolvedValueOnce({
|
||||
type: 'issue',
|
||||
number: 42,
|
||||
title: 'Remote issue'
|
||||
})
|
||||
registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store)
|
||||
|
||||
const handler = ipcHandlers.get('gitlab:workItemByPath')
|
||||
await expect(
|
||||
handler?.(null, {
|
||||
repoPath: '/local/orca',
|
||||
repoId: 'repo-ssh',
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'gitlab',
|
||||
projectId: 'gitlab:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
repoId: 'repo-ssh'
|
||||
},
|
||||
host: 'gitlab.com',
|
||||
path: 'stablyai/orca',
|
||||
iid: 42,
|
||||
type: 'issue'
|
||||
})
|
||||
).resolves.toMatchObject({ number: 42 })
|
||||
|
||||
expect(getWorkItemByProjectRefMock).toHaveBeenCalledWith(
|
||||
'/ssh/orca',
|
||||
{ host: 'gitlab.com', path: 'stablyai/orca' },
|
||||
42,
|
||||
'issue',
|
||||
'builder'
|
||||
)
|
||||
})
|
||||
})
|
||||
+114
-56
@@ -10,6 +10,8 @@ import type {
|
||||
GitLabWorkItem,
|
||||
Repo
|
||||
} from '../../shared/types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { TaskSourceContext } from '../../shared/task-source-context'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
normalizeGitLabIssueAssignee,
|
||||
@@ -50,15 +52,41 @@ import {
|
||||
import { getWorkItemDetails } from '../gitlab/work-item-details'
|
||||
import type { ProjectRef } from '../gitlab/gl-utils'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined {
|
||||
const sourceRepoId =
|
||||
args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null
|
||||
const repoId = args.repoId?.trim() || sourceRepoId || null
|
||||
if (repoId) {
|
||||
const repo = store.getRepo(repoId)
|
||||
if (repo) {
|
||||
return repo
|
||||
}
|
||||
}
|
||||
const resolvedRepoPath = resolve(args.repoPath)
|
||||
return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
|
||||
}
|
||||
|
||||
// Why: mirror github.ts assertRegisteredRepo — main-process handlers
|
||||
// must never operate on a path the user hasn't explicitly registered as
|
||||
// a repo (filesystem-auth boundary).
|
||||
function assertRegisteredRepo(repoPath: string, store: Store): Repo {
|
||||
const resolvedRepoPath = resolve(repoPath)
|
||||
const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
|
||||
// a repo (filesystem-auth boundary). Source context adds a host check so a
|
||||
// task fetched from one machine cannot mutate a same-path repo on another.
|
||||
function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo {
|
||||
const repo = findRegisteredGitLabRepo(args, store)
|
||||
if (!repo) {
|
||||
throw new Error('Access denied: unknown repository path')
|
||||
}
|
||||
if (
|
||||
args.sourceContext?.provider === 'gitlab' &&
|
||||
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
|
||||
) {
|
||||
throw new Error('Access denied: GitLab source host does not match repository host')
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@@ -79,15 +107,18 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null })
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getProjectSlug(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:mrForBranch',
|
||||
async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (
|
||||
_event,
|
||||
args: GitLabRepoSelectorArgs & { branch: string; linkedMRIid?: number | null }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getMergeRequestForBranch(
|
||||
repo.path,
|
||||
args.branch,
|
||||
@@ -97,8 +128,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getMergeRequest(repo.path, args.iid, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
@@ -108,12 +139,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const state = normalizeGitLabMRListState(args.state)
|
||||
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
|
||||
const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100)
|
||||
@@ -129,10 +162,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return getIssue(repo.path, args.number, repoConnectionId(repo))
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gitlab:issue',
|
||||
async (_event, args: GitLabRepoSelectorArgs & { number: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getIssue(repo.path, args.number, repoConnectionId(repo))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:listIssues',
|
||||
@@ -140,12 +176,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
|
||||
const state = normalizeGitLabIssueListState(args.state)
|
||||
const assignee = normalizeGitLabIssueAssignee(args.assignee)
|
||||
@@ -178,8 +216,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:createIssue',
|
||||
async (_event, args: { repoPath: string; title: string; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (_event, args: GitLabRepoSelectorArgs & { title: string; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return createIssue(
|
||||
repo.path,
|
||||
args.title,
|
||||
@@ -192,8 +230,11 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:updateIssue',
|
||||
async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (
|
||||
_event,
|
||||
args: GitLabRepoSelectorArgs & { number: number; updates: GitLabIssueUpdate }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return updateIssue(
|
||||
repo.path,
|
||||
args.number,
|
||||
@@ -206,8 +247,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:addIssueComment',
|
||||
async (_event, args: { repoPath: string; number: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (_event, args: GitLabRepoSelectorArgs & { number: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return addIssueComment(
|
||||
repo.path,
|
||||
args.number,
|
||||
@@ -218,13 +259,13 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
@@ -237,12 +278,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listWorkItems(
|
||||
repo.path,
|
||||
normalizeGitLabMRListState(args.state),
|
||||
@@ -259,8 +302,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
// Powers GitLabItemDialog's tabs.
|
||||
ipcMain.handle(
|
||||
'gitlab:workItemDetails',
|
||||
async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (_event, args: GitLabRepoSelectorArgs & { iid: number; type: 'issue' | 'mr' }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getWorkItemDetails(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -271,23 +314,29 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gitlab:closeMR',
|
||||
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gitlab:reopenMR',
|
||||
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:mergeMR',
|
||||
async (
|
||||
_event,
|
||||
args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' }
|
||||
args: GitLabRepoSelectorArgs & { iid: number; method?: 'merge' | 'squash' | 'rebase' }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return mergeMR(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -300,8 +349,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:updateMR',
|
||||
async (_event, args: { repoPath: string; iid: number; updates: GitLabMRUpdate }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (_event, args: GitLabRepoSelectorArgs & { iid: number; updates: GitLabMRUpdate }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return updateMR(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -318,12 +367,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
iid: number
|
||||
reviewerIds: number[]
|
||||
projectRef?: ProjectRef | null
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return updateMRReviewers(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -337,8 +388,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:addMRComment',
|
||||
async (_event, args: { repoPath: string; iid: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (_event, args: GitLabRepoSelectorArgs & { iid: number; body: string }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return addMRComment(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -355,12 +406,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
iid: number
|
||||
input: GitLabMRInlineCommentInput
|
||||
projectRef?: ProjectRef | null
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return addMRInlineComment(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -376,9 +429,9 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:resolveMRDiscussion',
|
||||
async (
|
||||
_event,
|
||||
args: { repoPath: string; iid: number; discussionId: string; resolved: boolean }
|
||||
args: GitLabRepoSelectorArgs & { iid: number; discussionId: string; resolved: boolean }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return resolveMRDiscussion(
|
||||
repo.path,
|
||||
args.iid,
|
||||
@@ -392,8 +445,11 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:jobTrace',
|
||||
async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (
|
||||
_event,
|
||||
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getJobTrace(
|
||||
repo.path,
|
||||
args.jobId,
|
||||
@@ -406,8 +462,11 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'gitlab:retryJob',
|
||||
async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
async (
|
||||
_event,
|
||||
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return retryJob(
|
||||
repo.path,
|
||||
args.jobId,
|
||||
@@ -421,8 +480,8 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
// Why: My Todos surface — cross-project, user-scoped. The repoPath is
|
||||
// only used for the registered-repo guard; `glab api todos` doesn't
|
||||
// care about cwd because the endpoint is user-scoped.
|
||||
ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listTodos(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
@@ -434,15 +493,14 @@ export function registerGitLabHandlers(store: Store): void {
|
||||
'gitlab:workItemByPath',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const projectRef: ProjectRef = { host: args.host, path: args.path }
|
||||
const result = await getWorkItemByProjectRef(
|
||||
repo.path,
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
|
||||
await handlers['hostedReview:getCreationEligibility'](null, {
|
||||
repoPath,
|
||||
repoId: repo.id,
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
base: 'main'
|
||||
@@ -128,6 +129,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
|
||||
await handlers['hostedReview:create'](null, {
|
||||
repoPath,
|
||||
repoId: repo.id,
|
||||
worktreePath,
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
@@ -158,4 +160,26 @@ describe('registerHostedReviewHandlers', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects creation when repoId and repoPath point at different registered repos', async () => {
|
||||
store.getRepo.mockImplementation((repoId: string) =>
|
||||
repoId === repo.id ? { ...repo, path: '/other/repo' } : null
|
||||
)
|
||||
|
||||
registerHostedReviewHandlers(store as never, stats as never)
|
||||
|
||||
await expect(
|
||||
handlers['hostedReview:create'](null, {
|
||||
repoPath,
|
||||
repoId: repo.id,
|
||||
worktreePath,
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/pr',
|
||||
title: 'Feature PR'
|
||||
})
|
||||
).rejects.toThrow('Access denied: unknown repository')
|
||||
|
||||
expect(createHostedReviewMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
||||
ipcMain.handle(
|
||||
'hostedReview:getCreationEligibility',
|
||||
async (_event, args: HostedReviewCreationEligibilityArgs) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
return getHostedReviewCreationEligibility({
|
||||
...args,
|
||||
@@ -109,7 +109,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
||||
)
|
||||
|
||||
ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
|
||||
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
|
||||
const result = await createHostedReview(
|
||||
worktreePath,
|
||||
|
||||
@@ -589,6 +589,31 @@ describe('preflight', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no remote agents when the SSH connection is unavailable', async () => {
|
||||
getActiveMultiplexerMock.mockReturnValue(null)
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
await expect(
|
||||
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('returns no remote agents when the SSH connection is disposed', async () => {
|
||||
const request = vi.fn()
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
isDisposed: () => true,
|
||||
request
|
||||
})
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
await expect(
|
||||
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
|
||||
).resolves.toEqual([])
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
|
||||
@@ -248,7 +248,9 @@ export async function refreshShellPathAndDetectAgents(
|
||||
export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
throw new Error(`No active SSH connection for "${args.connectionId}"`)
|
||||
// Why: remote agent detection is passive UI polling. A disconnected host has
|
||||
// no detectable agents until reconnect, but should not spam IPC errors.
|
||||
return []
|
||||
}
|
||||
const result = (await mux.request('preflight.detectAgents', {
|
||||
commands: KNOWN_AGENT_COMMANDS
|
||||
|
||||
@@ -2831,6 +2831,43 @@ describe('registerPtyHandlers', () => {
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated')
|
||||
})
|
||||
|
||||
it('returns idle process inspection results for detached SSH PTYs without a provider', async () => {
|
||||
const provider = {
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', provider as never)
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
setPtyOwnership('remote-pty', 'ssh-1')
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
|
||||
await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' })
|
||||
).resolves.toBeNull()
|
||||
expect(provider.hasChildProcesses).not.toHaveBeenCalled()
|
||||
expect(provider.getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
|
||||
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
|
||||
@@ -226,6 +226,13 @@ function getProviderForPty(ptyId: string): IPtyProvider {
|
||||
return getProvider(connectionId)
|
||||
}
|
||||
|
||||
function hasPtyProviderForInspection(ptyId: string): boolean {
|
||||
// Why: process inspection is background polling; disconnected SSH hosts should
|
||||
// read as idle instead of surfacing repeated IPC errors.
|
||||
const connectionId = ptyOwnership.get(ptyId)
|
||||
return connectionId == null || sshProviders.has(connectionId)
|
||||
}
|
||||
|
||||
function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string {
|
||||
return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId
|
||||
}
|
||||
@@ -2801,6 +2808,9 @@ export function registerPtyHandlers(
|
||||
ipcMain.handle(
|
||||
'pty:hasChildProcesses',
|
||||
async (_event, args: { id: string }): Promise<boolean> => {
|
||||
if (!hasPtyProviderForInspection(args.id)) {
|
||||
return false
|
||||
}
|
||||
return getProviderForPty(args.id).hasChildProcesses(args.id)
|
||||
}
|
||||
)
|
||||
@@ -2808,6 +2818,9 @@ export function registerPtyHandlers(
|
||||
ipcMain.handle(
|
||||
'pty:getForegroundProcess',
|
||||
async (_event, args: { id: string }): Promise<string | null> => {
|
||||
if (!hasPtyProviderForInspection(args.id)) {
|
||||
return null
|
||||
}
|
||||
return getProviderForPty(args.id).getForegroundProcess(args.id)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -38,12 +38,25 @@ const {
|
||||
mockGitProvider: {
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
getHostPlatform: vi.fn().mockReturnValue({
|
||||
relayPlatform: 'linux-x64',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
pathFlavor: 'posix',
|
||||
commandDialect: 'posix',
|
||||
pathSeparator: '/',
|
||||
pathDelimiter: ':'
|
||||
})
|
||||
},
|
||||
mockFilesystemProvider: {
|
||||
readDir: vi.fn().mockResolvedValue([]),
|
||||
readFile: vi.fn().mockRejectedValue(new Error('not found')),
|
||||
stat: vi.fn().mockRejectedValue(new Error('not found'))
|
||||
stat: vi.fn().mockRejectedValue(new Error('not found')),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
mockMultiplexer: {
|
||||
request: vi.fn(),
|
||||
@@ -89,7 +102,7 @@ vi.mock('./filesystem-auth', () => ({
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
if (id === 'conn-1') {
|
||||
return mockGitProvider
|
||||
}
|
||||
return undefined
|
||||
@@ -98,7 +111,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
if (id === 'conn-1') {
|
||||
return mockFilesystemProvider
|
||||
}
|
||||
return undefined
|
||||
@@ -107,7 +120,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
if (id === 'conn-1') {
|
||||
return mockMultiplexer
|
||||
}
|
||||
return undefined
|
||||
@@ -809,6 +822,26 @@ describe('repos:addRemote', () => {
|
||||
mockStore.updateRepo.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
|
||||
mockGitProvider.exec.mockReset()
|
||||
mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
mockGitProvider.clone.mockReset()
|
||||
mockGitProvider.clone.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
mockGitProvider.getHostPlatform.mockReset()
|
||||
mockGitProvider.getHostPlatform.mockReturnValue({
|
||||
relayPlatform: 'linux-x64',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
pathFlavor: 'posix',
|
||||
commandDialect: 'posix',
|
||||
pathSeparator: '/',
|
||||
pathDelimiter: ':'
|
||||
})
|
||||
mockFilesystemProvider.stat.mockReset()
|
||||
mockFilesystemProvider.stat.mockRejectedValue(new Error('not found'))
|
||||
mockFilesystemProvider.createDirNoClobber.mockReset()
|
||||
mockFilesystemProvider.createDirNoClobber.mockResolvedValue(undefined)
|
||||
mockFilesystemProvider.deletePath.mockReset()
|
||||
mockFilesystemProvider.deletePath.mockResolvedValue(undefined)
|
||||
mockMultiplexer.request.mockReset()
|
||||
mockMultiplexer.notify.mockReset()
|
||||
gitSpawnMock.mockReset()
|
||||
@@ -827,6 +860,14 @@ describe('repos:addRemote', () => {
|
||||
expect(handlers.has('repos:addRemote')).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the repos:cloneRemote handler', () => {
|
||||
expect(handlers.has('repos:cloneRemote')).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the repos:createRemote handler', () => {
|
||||
expect(handlers.has('repos:createRemote')).toBe(true)
|
||||
})
|
||||
|
||||
it('creates a remote repo with connectionId', async () => {
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
@@ -841,7 +882,8 @@ describe('repos:addRemote', () => {
|
||||
displayName: 'project',
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.id')
|
||||
@@ -865,6 +907,376 @@ describe('repos:addRemote', () => {
|
||||
expect(result).toHaveProperty('repo.displayName', 'My Server Repo')
|
||||
})
|
||||
|
||||
it('clones a repo on an SSH target and registers the cloned path', async () => {
|
||||
const result = await handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user')
|
||||
expect(mockGitProvider.clone).toHaveBeenCalledWith(
|
||||
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
|
||||
'/home/user',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
timeoutMs: 10 * 60_000,
|
||||
onProgress: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/home/user/orca',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'git',
|
||||
displayName: 'orca',
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
})
|
||||
)
|
||||
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
|
||||
rootPath: '/home/user/orca'
|
||||
})
|
||||
expect(result).toHaveProperty('path', '/home/user/orca')
|
||||
expect(result).toHaveProperty('connectionId', 'conn-1')
|
||||
})
|
||||
|
||||
it('forwards SSH clone progress through the existing clone progress event', async () => {
|
||||
mockGitProvider.clone.mockImplementationOnce(
|
||||
async (
|
||||
_args: string[],
|
||||
_cwd: string,
|
||||
options?: { onProgress?: (progress: { phase: string; percent: number }) => void }
|
||||
) => {
|
||||
options?.onProgress?.({ phase: 'Receiving objects', percent: 42 })
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
)
|
||||
|
||||
await handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:clone-progress', {
|
||||
phase: 'Receiving objects',
|
||||
percent: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an existing SSH repo instead of cloning the same target again', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
path: '/home/user/orca',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
|
||||
const result = await handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(result).toBe(existing)
|
||||
expect(mockGitProvider.clone).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('upgrades an existing SSH folder repo after cloning into that path', async () => {
|
||||
const existing = {
|
||||
id: 'existing-folder',
|
||||
path: '/home/user/orca',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'folder'
|
||||
}
|
||||
const updated = { ...existing, kind: 'git' }
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
|
||||
const result = await handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(mockGitProvider.clone).toHaveBeenCalledWith(
|
||||
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
|
||||
'/home/user',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
timeoutMs: 10 * 60_000,
|
||||
onProgress: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', {
|
||||
kind: 'git',
|
||||
projectHostSetupMethod: 'cloned'
|
||||
})
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toBe(updated)
|
||||
})
|
||||
|
||||
it('does not delete a fresh SSH clone target after git clone fails', async () => {
|
||||
mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found'))
|
||||
mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found'))
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
).rejects.toThrow('repository not found')
|
||||
|
||||
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects concurrent SSH clones to the same destination', async () => {
|
||||
let releaseClone!: () => void
|
||||
mockGitProvider.clone.mockImplementationOnce(
|
||||
async () =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
releaseClone = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
)
|
||||
|
||||
const firstClone = handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1))
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
).rejects.toThrow('A clone is already in progress for this SSH destination')
|
||||
|
||||
releaseClone()
|
||||
await firstClone
|
||||
})
|
||||
|
||||
it('resolves SSH clone destinations under home before validating the path', async () => {
|
||||
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' })
|
||||
|
||||
await handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '~/projects'
|
||||
})
|
||||
|
||||
expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', {
|
||||
path: '~/projects'
|
||||
})
|
||||
expect(mockGitProvider.clone).toHaveBeenCalledWith(
|
||||
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
|
||||
'/home/ubuntu/projects',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not clean up a pre-existing SSH clone target after git clone fails', async () => {
|
||||
mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists'))
|
||||
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
|
||||
|
||||
await expect(
|
||||
handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
).rejects.toThrow('destination already exists')
|
||||
|
||||
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts an active SSH clone and reports the abort without deleting pre-existing targets', async () => {
|
||||
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
|
||||
mockGitProvider.clone.mockImplementationOnce(
|
||||
async (_args: string[], _cwd: string, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(new Error('aborted by test')))
|
||||
})
|
||||
)
|
||||
|
||||
const clonePromise = handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal }
|
||||
expect(options.signal.aborted).toBe(true)
|
||||
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects SSH clone destinations that are not absolute host paths', async () => {
|
||||
await expect(
|
||||
handlers.get('repos:cloneRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: 'relative/path'
|
||||
})
|
||||
).rejects.toThrow('Clone destination must be an absolute path on the SSH host')
|
||||
|
||||
expect(mockGitProvider.clone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a new git project on an SSH target', async () => {
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '/home/user',
|
||||
name: 'created',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/created')
|
||||
expect(mockGitProvider.exec).toHaveBeenCalledWith(['init'], '/home/user/created')
|
||||
expect(mockGitProvider.exec).toHaveBeenCalledWith(
|
||||
['commit', '--allow-empty', '-m', 'Initial commit'],
|
||||
'/home/user/created'
|
||||
)
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/home/user/created',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'git',
|
||||
displayName: 'created',
|
||||
externalWorktreeVisibility: 'hide'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.path', '/home/user/created')
|
||||
expect(result).toHaveProperty('repo.connectionId', 'conn-1')
|
||||
})
|
||||
|
||||
it('resolves SSH create parents under home before validating the path', async () => {
|
||||
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' })
|
||||
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '~/projects',
|
||||
name: 'created',
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', {
|
||||
path: '~/projects'
|
||||
})
|
||||
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith(
|
||||
'/home/ubuntu/projects/created'
|
||||
)
|
||||
expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created')
|
||||
})
|
||||
|
||||
it('creates a new folder project on an SSH target without git init', async () => {
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '/home/user',
|
||||
name: 'notes',
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/notes')
|
||||
expect(mockGitProvider.exec).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/home/user/notes',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'folder',
|
||||
displayName: 'notes'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.kind', 'folder')
|
||||
})
|
||||
|
||||
it('rejects SSH create parent paths that are not absolute host paths', async () => {
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: 'relative/path',
|
||||
name: 'created',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'Parent directory must be an absolute path on the SSH host' })
|
||||
expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(mockGitProvider.exec).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-empty existing SSH create targets', async () => {
|
||||
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
|
||||
mockFilesystemProvider.readDir.mockResolvedValueOnce([
|
||||
{ name: 'package.json', isDirectory: false, isSymlink: false }
|
||||
])
|
||||
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '/home/user',
|
||||
name: 'created',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
error: '"created" already exists at this location and is not empty.'
|
||||
})
|
||||
expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(mockGitProvider.exec).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a newly created SSH directory when git init fails', async () => {
|
||||
mockGitProvider.exec.mockRejectedValueOnce(new Error('git init failed'))
|
||||
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '/home/user',
|
||||
name: 'created',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'Failed to initialize git repository: git init failed' })
|
||||
expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created', true)
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an existing empty SSH directory and removes only .git when commit fails', async () => {
|
||||
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
|
||||
mockFilesystemProvider.readDir.mockResolvedValueOnce([])
|
||||
mockGitProvider.exec
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
.mockRejectedValueOnce(new Error('Please tell me who you are'))
|
||||
|
||||
const result = await handlers.get('repos:createRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
parentPath: '/home/user',
|
||||
name: 'created',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
error:
|
||||
'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.'
|
||||
})
|
||||
expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created/.git', true)
|
||||
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalledWith('/home/user/created', true)
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing repo if same connectionId and path already added', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
@@ -886,59 +1298,6 @@ describe('repos:addRemote', () => {
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows the same resolved remote path on a different SSH connection', async () => {
|
||||
const existing = {
|
||||
id: 'machine-1-project',
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'project',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-2',
|
||||
remotePath: '/home/user/project'
|
||||
})
|
||||
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-2'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.connectionId', 'conn-2')
|
||||
expect(result).toHaveProperty('repo.id')
|
||||
expect(result).not.toEqual({ repo: existing })
|
||||
})
|
||||
|
||||
it('dedupes remote projects after git root resolution on the same SSH connection', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'project',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
|
||||
isRepo: true,
|
||||
rootPath: '/home/user/project'
|
||||
})
|
||||
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
remotePath: '/home/user/project/src'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ repo: existing })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when SSH connection is not found', async () => {
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'unknown-conn',
|
||||
@@ -1083,6 +1442,31 @@ describe('repos:addRemote', () => {
|
||||
expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir')
|
||||
})
|
||||
|
||||
it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
path: '/home/user/orca',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
|
||||
isRepo: true,
|
||||
rootPath: '/home/user/orca'
|
||||
})
|
||||
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
remotePath: '/home/user/orca/src'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ repo: existing })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores SSH target label when custom displayName is provided', async () => {
|
||||
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' })
|
||||
mockStore.getSshTarget.mockReturnValueOnce({
|
||||
@@ -1191,7 +1575,8 @@ describe('repos:add + repos:clone', () => {
|
||||
path: '/tmp/from-add',
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide')
|
||||
@@ -1260,7 +1645,10 @@ describe('repos:add + repos:clone', () => {
|
||||
destination
|
||||
})
|
||||
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' })
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, {
|
||||
kind: 'git',
|
||||
projectHostSetupMethod: 'cloned'
|
||||
})
|
||||
expect(result).toEqual(upgraded)
|
||||
expect(result).toHaveProperty('badgeColor', '#8b5cf6')
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
@@ -1425,6 +1813,33 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(existsSync(clonePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the full fatal clone error when stderr includes progress fragments', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const proc = createMockCloneProcess()
|
||||
gitSpawnMock.mockReturnValueOnce(proc)
|
||||
|
||||
const clonePromise = handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
proc.stderr.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
"Cloning into 'orca'...\rfatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n"
|
||||
)
|
||||
)
|
||||
proc.emit('close', 128, null)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow(
|
||||
`Clone failed: Destination already exists and is not empty: ${join(
|
||||
destination,
|
||||
'orca'
|
||||
)}. Choose a different parent folder, delete the existing folder, or add the existing repository instead.`
|
||||
)
|
||||
})
|
||||
|
||||
it('removes an owned fresh clone target when git spawn emits an error', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
|
||||
+714
-174
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
@@ -115,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
@@ -159,6 +161,30 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(await list(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('disconnects a saved runtime without removing it', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const disconnect = handler<
|
||||
{ selector: string },
|
||||
{ disconnected: { id: string; name: string } }
|
||||
>('runtimeEnvironments:disconnect')
|
||||
expect(await disconnect(null, { selector: 'desk' })).toMatchObject({
|
||||
disconnected: { id: added.environment.id, name: 'desk' }
|
||||
})
|
||||
|
||||
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
|
||||
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith('desk')
|
||||
|
||||
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
|
||||
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
|
||||
})
|
||||
|
||||
it('checks a saved remote runtime and records the runtime id on success', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
|
||||
@@ -33,6 +33,7 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
@@ -103,6 +104,20 @@ export function registerRuntimeEnvironmentHandlers(): void {
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:disconnect',
|
||||
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
// Why: disconnect is intentionally non-destructive; it drops live
|
||||
// transport state while keeping the paired server available for later.
|
||||
closeRemoteRuntimeRequestConnection(environment.id)
|
||||
if (args.selector !== environment.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
}
|
||||
closeSubscriptionsForEnvironment(environment.id)
|
||||
return { disconnected: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:getStatus',
|
||||
async (
|
||||
|
||||
+11
-8
@@ -3,24 +3,27 @@ import type { Store } from '../persistence'
|
||||
import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/types'
|
||||
|
||||
export function registerSessionHandlers(store: Store): void {
|
||||
ipcMain.handle('session:get', () => {
|
||||
return store.getWorkspaceSession()
|
||||
// Why: hostId is an optional second arg so an older renderer that invokes
|
||||
// these channels without it keeps reading/writing the 'local' partition
|
||||
// exactly as before. Channel names stay stable.
|
||||
ipcMain.handle('session:get', (_event, hostId?: string | null) => {
|
||||
return store.getWorkspaceSession(hostId)
|
||||
})
|
||||
|
||||
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState) => {
|
||||
store.setWorkspaceSession(args)
|
||||
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
})
|
||||
|
||||
ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch) => {
|
||||
store.patchWorkspaceSession(args)
|
||||
ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => {
|
||||
store.patchWorkspaceSession(args, hostId)
|
||||
})
|
||||
|
||||
// Synchronous variant for the renderer's beforeunload handler.
|
||||
// sendSync blocks the renderer until this returns, guaranteeing the
|
||||
// data (including terminal scrollback buffers) is persisted to disk
|
||||
// before the window closes — regardless of before-quit ordering.
|
||||
ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState) => {
|
||||
store.setWorkspaceSession(args)
|
||||
ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
store.flush()
|
||||
event.returnValue = true
|
||||
})
|
||||
|
||||
@@ -879,7 +879,7 @@ describe('SSH IPC handlers', () => {
|
||||
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).rejects.toThrow('Failed to terminate remote SSH sessions')
|
||||
).rejects.toThrow('Failed to terminate SSH host sessions')
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', 'pty-1', 'terminated')
|
||||
expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
+1
-1
@@ -850,7 +850,7 @@ export function registerSshHandlers(
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the
|
||||
// grace window. Keep the lease/session intact so the user can retry.
|
||||
throw new Error(`Failed to terminate remote SSH sessions: ${shutdownFailures.join('; ')}`)
|
||||
throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`)
|
||||
}
|
||||
if (session) {
|
||||
await portForwardManager!.removeAllForwards(args.targetId)
|
||||
|
||||
@@ -308,6 +308,9 @@ describe('mergeWorktree', () => {
|
||||
linkedIssue: 42,
|
||||
linkedPR: 10,
|
||||
linkedLinearIssue: null,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:openclaw-2' as const,
|
||||
projectHostSetupId: 'remote-repo',
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: true,
|
||||
@@ -336,6 +339,10 @@ describe('mergeWorktree', () => {
|
||||
linkedLinearIssueOrganizationUrlKey: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
mobileDiffReview: undefined,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:openclaw-2',
|
||||
projectHostSetupId: 'remote-repo',
|
||||
isArchived: true,
|
||||
isUnread: true,
|
||||
isPinned: true,
|
||||
|
||||
@@ -279,6 +279,11 @@ export function mergeWorktree(
|
||||
id: `${repoId}::${git.path}`,
|
||||
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId,
|
||||
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}),
|
||||
...(meta?.projectHostSetupId !== undefined
|
||||
? { projectHostSetupId: meta.projectHostSetupId }
|
||||
: {}),
|
||||
path: git.path,
|
||||
head: git.head,
|
||||
branch: git.branch,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { gitExecFileAsync } from '../git/runner'
|
||||
import { parseGitHubOwnerRepo } from '../github/gh-utils'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
buildPosixRunnerScript,
|
||||
buildWindowsRunnerScript,
|
||||
@@ -1419,6 +1420,9 @@ export async function createRemoteWorktree(
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
// attach to the new occupant of the same path.
|
||||
instanceId: randomUUID(),
|
||||
...(store.getProjectHostSetups
|
||||
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
: {}),
|
||||
lastActivityAt: now,
|
||||
// Why: grants the new worktree a short grace window at the top of the
|
||||
// Recent sort. During worktree creation (git fetch + add can take several
|
||||
@@ -1922,6 +1926,9 @@ export async function createLocalWorktree(
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
// attach to the new occupant of the same path.
|
||||
instanceId: randomUUID(),
|
||||
...(store.getProjectHostSetups
|
||||
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
: {}),
|
||||
// Stamp activity so the worktree sorts into its final position
|
||||
// immediately — prevents scroll-to-reveal racing with a later
|
||||
// bumpWorktreeActivity that would re-sort the list.
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
const store = {
|
||||
getRepos: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
getProjectHostSetups: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
@@ -141,6 +142,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
mainWindow.webContents.send.mockReset()
|
||||
store.getRepos.mockReset()
|
||||
store.getRepo.mockReset()
|
||||
store.getProjectHostSetups.mockReset()
|
||||
store.getSettings.mockReset()
|
||||
store.getWorktreeMeta.mockReset()
|
||||
store.setWorktreeMeta.mockReset()
|
||||
@@ -171,6 +173,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: null
|
||||
})
|
||||
store.getProjectHostSetups.mockReturnValue([])
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
|
||||
+295
-10
@@ -228,6 +228,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
getWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getProjectHostSetups: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: vi.fn(),
|
||||
removeWorktreeLineage: vi.fn()
|
||||
@@ -292,6 +293,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
store.getWorktreeMeta,
|
||||
store.getAllWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
store.getProjectHostSetups,
|
||||
store.removeWorktreeMeta,
|
||||
store.getAllWorktreeLineage,
|
||||
store.removeWorktreeLineage,
|
||||
@@ -338,6 +340,20 @@ describe('registerWorktreeHandlers', () => {
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.getAllWorktreeMeta.mockReturnValue({})
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
store.getProjectHostSetups.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
])
|
||||
store.getAllWorktreeLineage.mockReturnValue({})
|
||||
getGitUsernameMock.mockReturnValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
@@ -3213,7 +3229,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
])
|
||||
expect(store.getWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', {
|
||||
projectId: 'repo:repo-ssh',
|
||||
hostId: 'ssh:conn-1',
|
||||
projectHostSetupId: 'repo-ssh'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
|
||||
@@ -3432,7 +3452,12 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
const stampedMeta = { lastActivityAt: 1_700_000_000_000 }
|
||||
const stampedMeta = {
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
}
|
||||
store.setWorktreeMeta.mockReturnValue(stampedMeta)
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
@@ -3442,7 +3467,12 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
expect.objectContaining({
|
||||
lastActivityAt: expect.any(Number),
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
@@ -3450,9 +3480,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => {
|
||||
it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => {
|
||||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||||
// would overwrite real activity and reshuffle the sidebar on refresh.
|
||||
// would overwrite real activity and reshuffle the sidebar on refresh. Host
|
||||
// ownership can still be filled because it is derived from the repo setup.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
@@ -3474,14 +3505,245 @@ describe('registerWorktreeHandlers', () => {
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
projectId?: string
|
||||
hostId?: string
|
||||
projectHostSetupId?: string
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
expect(listed[0].lastActivityAt).toBe(42)
|
||||
expect(listed[0]).toMatchObject({
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('repairs legacy project ids when discovery now resolves the same host setup to a logical project', async () => {
|
||||
// Why: provider identity can become available after metadata was written.
|
||||
// Existing workspaces should move from repo-scoped IDs to the logical
|
||||
// project ID without losing activity ordering.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getProjectHostSetups.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
projectId?: string
|
||||
hostId?: string
|
||||
projectHostSetupId?: string
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
|
||||
projectId: 'github:stablyai/orca'
|
||||
})
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/existing-wt',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('does not repair ownership when discovery points at a different project-host setup', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getProjectHostSetups.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'github:other/project',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
projectHostSetupId: 'repo-other-host',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs legacy project ids when SSH worktree listing falls back to persisted metadata', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-target-1'
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::/remote/orca': makeWorktreeMeta({
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'repo:repo-ssh',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
projectHostSetupId: 'repo-ssh',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
})
|
||||
store.getProjectHostSetups.mockReturnValue([
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/remote/orca',
|
||||
displayName: 'orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockReturnValue(
|
||||
makeWorktreeMeta({
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
projectHostSetupId: 'repo-ssh',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
)
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||||
id: string
|
||||
projectId?: string
|
||||
hostId?: string
|
||||
projectHostSetupId?: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1')
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', {
|
||||
projectId: 'github:stablyai/orca'
|
||||
})
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/orca',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:ssh-target-1',
|
||||
projectHostSetupId: 'repo-ssh',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not rewrite discovery metadata when instance and project-host ownership already exist', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
instanceId: 'existing-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('backfills instanceId on discovery for persisted metadata from older profiles', async () => {
|
||||
@@ -3507,6 +3769,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
instanceId: 'new-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
@@ -3517,9 +3782,19 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/existing-wt',
|
||||
expect.objectContaining({ instanceId: expect.any(String) })
|
||||
expect.objectContaining({
|
||||
instanceId: expect.any(String),
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
)
|
||||
expect(listed[0].instanceId).toBe('new-instance')
|
||||
expect(listed[0]).toMatchObject({
|
||||
instanceId: 'new-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
|
||||
@@ -3545,13 +3820,23 @@ describe('registerWorktreeHandlers', () => {
|
||||
kind: 'folder'
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/folder',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
expect.objectContaining({
|
||||
lastActivityAt: expect.any(Number),
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+87
-26
@@ -6,6 +6,7 @@ import { randomUUID } from 'crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
@@ -130,24 +131,57 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
}
|
||||
|
||||
function getProjectHostSetupMetaUpdates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
existing?: WorktreeMeta
|
||||
): Partial<Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'>> {
|
||||
const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
const sameSetup =
|
||||
existing?.projectHostSetupId === undefined ||
|
||||
existing.projectHostSetupId === ownership.projectHostSetupId
|
||||
return {
|
||||
// Why: project IDs can be upgraded from legacy repo IDs to provider-backed
|
||||
// logical IDs. If the host setup is the same, repair ownership on discovery.
|
||||
...(sameSetup && existing?.projectId !== ownership.projectId
|
||||
? { projectId: ownership.projectId }
|
||||
: {}),
|
||||
...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}),
|
||||
...(existing?.projectHostSetupId === undefined
|
||||
? { projectHostSetupId: ownership.projectHostSetupId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: worktrees discovered on disk (not created via Orca's UI) have no
|
||||
// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`.
|
||||
// That makes them sort to the bottom of "Recent" even though the user just
|
||||
// added the repo / folder. Stamp discovery time the first time we see a
|
||||
// worktree so its very existence counts as a recency signal. Subsequent
|
||||
// list calls find the persisted meta and skip the stamp.
|
||||
function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta {
|
||||
// added the repo / folder. The same authoritative discovery pass is also the
|
||||
// safest time to backfill project-host setup ownership for upgraded profiles.
|
||||
function resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
worktreeId: string
|
||||
): WorktreeMeta {
|
||||
const existing = store.getWorktreeMeta(worktreeId)
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
|
||||
if (existing) {
|
||||
if (!existing.instanceId) {
|
||||
const updates = {
|
||||
...(!existing.instanceId ? { instanceId: randomUUID() } : {}),
|
||||
...ownershipUpdates
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
// Why: profiles created before lineage shipped already have WorktreeMeta
|
||||
// rows. Backfill on authoritative discovery so upgraded workspaces can
|
||||
// immediately participate in instance-validated lineage.
|
||||
return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() })
|
||||
// immediately participate in instance-validated lineage and host routing.
|
||||
return store.setWorktreeMeta(worktreeId, updates)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
|
||||
return store.setWorktreeMeta(worktreeId, {
|
||||
lastActivityAt: Date.now(),
|
||||
...ownershipUpdates
|
||||
})
|
||||
}
|
||||
|
||||
async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise<boolean> {
|
||||
@@ -366,6 +400,7 @@ function pruneLineageForMissingRepoWorktrees(
|
||||
}
|
||||
|
||||
type SshWorktreeMetaCandidate = {
|
||||
id: string
|
||||
path: string
|
||||
meta: WorktreeMeta
|
||||
}
|
||||
@@ -389,7 +424,7 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt
|
||||
}
|
||||
|
||||
const candidates = index.get(parsed.repoId) ?? []
|
||||
candidates.push({ path: parsed.worktreePath, meta })
|
||||
candidates.push({ id: worktreeId, path: parsed.worktreePath, meta })
|
||||
index.set(parsed.repoId, candidates)
|
||||
}
|
||||
return index
|
||||
@@ -411,15 +446,24 @@ function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta):
|
||||
}
|
||||
|
||||
function listDisconnectedSshWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
metaIndex: SshWorktreeMetaIndex
|
||||
): ReturnType<typeof mergeWorktree>[] {
|
||||
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
|
||||
for (const candidate of metaIndex.get(repo.id) ?? []) {
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta)
|
||||
const meta =
|
||||
Object.keys(ownershipUpdates).length > 0
|
||||
? { ...candidate.meta, ...ownershipUpdates }
|
||||
: candidate.meta
|
||||
if (Object.keys(ownershipUpdates).length > 0) {
|
||||
store.setWorktreeMeta(candidate.id, ownershipUpdates)
|
||||
}
|
||||
const worktree = mergeWorktree(
|
||||
repo.id,
|
||||
synthesizeSshGitWorktree(repo, candidate.path, candidate.meta),
|
||||
candidate.meta
|
||||
synthesizeSshGitWorktree(repo, candidate.path, meta),
|
||||
meta
|
||||
)
|
||||
byWorktreeId.delete(worktree.id)
|
||||
byWorktreeId.set(worktree.id, worktree)
|
||||
@@ -451,7 +495,7 @@ function buildDetectedGitWorktrees(
|
||||
return detected
|
||||
}
|
||||
|
||||
meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
||||
meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId)
|
||||
return toDetectedWorktree({
|
||||
repo,
|
||||
worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
@@ -468,7 +512,7 @@ function stampAndMergeVisibleDetectedWorktree(
|
||||
repo: Repo,
|
||||
detected: DetectedWorktree
|
||||
) {
|
||||
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, detected.id)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id)
|
||||
return mergeWorktree(repo.id, detected, meta, repo.displayName)
|
||||
}
|
||||
|
||||
@@ -498,6 +542,11 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta
|
||||
id: worktreeId,
|
||||
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}),
|
||||
...(meta.projectHostSetupId !== undefined
|
||||
? { projectHostSetupId: meta.projectHostSetupId }
|
||||
: {}),
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
@@ -539,12 +588,16 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
return ids
|
||||
.map((worktreeId) => {
|
||||
const existing = allMeta[worktreeId]
|
||||
const meta = existing?.instanceId
|
||||
? existing
|
||||
: store.setWorktreeMeta(worktreeId, {
|
||||
instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
|
||||
const meta =
|
||||
existing?.instanceId && Object.keys(ownershipUpdates).length === 0
|
||||
? existing
|
||||
: store.setWorktreeMeta(worktreeId, {
|
||||
instanceId:
|
||||
existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...ownershipUpdates,
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
return mergeFolderWorkspace(repo, worktreeId, meta)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -577,7 +630,12 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => {
|
||||
const meta = store.getWorktreeMeta(worktree.id)
|
||||
return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {}))
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta)
|
||||
const repairedMeta =
|
||||
meta && Object.keys(ownershipUpdates).length === 0
|
||||
? meta
|
||||
: store.setWorktreeMeta(worktree.id, ownershipUpdates)
|
||||
return mergeFolderWorkspace(repo, worktree.id, repairedMeta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -591,6 +649,9 @@ function createFolderWorkspace(
|
||||
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const meta = store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
...(store.getProjectHostSetups
|
||||
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
: {}),
|
||||
displayName: args.displayName || args.name,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
@@ -685,7 +746,7 @@ export function registerWorktreeHandlers(
|
||||
`${repo.connectionId}:${repo.id}`,
|
||||
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
|
||||
try {
|
||||
@@ -697,7 +758,7 @@ export function registerWorktreeHandlers(
|
||||
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
@@ -750,7 +811,7 @@ export function registerWorktreeHandlers(
|
||||
`${repo.connectionId}:${repo.id}`,
|
||||
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
|
||||
try {
|
||||
@@ -762,7 +823,7 @@ export function registerWorktreeHandlers(
|
||||
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
@@ -814,7 +875,7 @@ export function registerWorktreeHandlers(
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
@@ -843,7 +904,7 @@ export function registerWorktreeHandlers(
|
||||
err
|
||||
)
|
||||
if (repo.connectionId) {
|
||||
const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
|
||||
+691
-20
@@ -16,7 +16,9 @@ import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type {
|
||||
PersistedState,
|
||||
Project,
|
||||
ProjectGroup,
|
||||
ProjectHostSetup,
|
||||
Repo,
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalTab,
|
||||
@@ -27,11 +29,13 @@ import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id'
|
||||
import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
|
||||
import {
|
||||
getDefaultPersistedState,
|
||||
getDefaultWorkspaceSession,
|
||||
ONBOARDING_FINAL_STEP,
|
||||
ONBOARDING_FLOW_VERSION
|
||||
} from '../shared/constants'
|
||||
import { folderWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host'
|
||||
import { SshConnectionStore } from './ssh/ssh-connection-store'
|
||||
|
||||
// Shared mutable state so the electron mock can reference a per-test directory
|
||||
@@ -175,6 +179,30 @@ const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeProject = (overrides: Partial<Project> = {}): Project => ({
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeProjectHostSetup = (overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup => ({
|
||||
id: 'setup-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: '',
|
||||
path: '/repo',
|
||||
displayName: 'Project',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeTerminalTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({
|
||||
id: 'tab1',
|
||||
ptyId: 'pty1',
|
||||
@@ -287,6 +315,87 @@ describe('Store', () => {
|
||||
expect(store.getRepos()).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it('backfills project host setup compatibility records from legacy repos on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'local-repo',
|
||||
path: '/Users/alice/orca',
|
||||
displayName: 'Orca',
|
||||
upstream: { owner: 'StablyAI', repo: 'Orca' }
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'remote-repo',
|
||||
path: '/home/alice/orca',
|
||||
displayName: 'orca',
|
||||
connectionId: 'gpu-vm',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'github:stablyai/orca',
|
||||
sourceRepoIds: ['local-repo', 'remote-repo']
|
||||
})
|
||||
])
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'local-repo',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
path: '/Users/alice/orca'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'remote-repo',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:gpu-vm',
|
||||
path: '/home/alice/orca'
|
||||
})
|
||||
])
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.projects).toEqual(store.getProjects())
|
||||
expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups())
|
||||
})
|
||||
|
||||
it('preserves independent project host setup records on load', async () => {
|
||||
const independentProject = makeProject({
|
||||
id: 'cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: independentProject.id,
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '/srv/cloud-project',
|
||||
displayName: 'GPU VM'
|
||||
})
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })],
|
||||
projects: [independentProject],
|
||||
projectHostSetups: [independentSetup]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project'])
|
||||
expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([
|
||||
'r1',
|
||||
'cloud-project::gpu-vm'
|
||||
])
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.projectHostSetups).toContainEqual(independentSetup)
|
||||
})
|
||||
|
||||
it('returns default settings when no data file exists', async () => {
|
||||
const store = await createStore()
|
||||
const settings = store.getSettings()
|
||||
@@ -671,26 +780,6 @@ describe('Store', () => {
|
||||
expect(store.getUI().projectOrderBy).toBe('manual')
|
||||
})
|
||||
|
||||
it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
|
||||
ui: {}
|
||||
})
|
||||
const store = await createStore()
|
||||
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the manual-default notice for profiles that already chose recent', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
|
||||
ui: { projectOrderBy: 'recent' }
|
||||
})
|
||||
const store = await createStore()
|
||||
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true)
|
||||
})
|
||||
|
||||
// ── 2. Load from existing valid file ─────────────────────────────────
|
||||
|
||||
it('reads repos from an existing data file', async () => {
|
||||
@@ -1070,6 +1159,155 @@ describe('Store', () => {
|
||||
expect(reloaded.listAutomations()[0].reuseSession).toBe(false)
|
||||
})
|
||||
|
||||
it('derives automation source and run contexts from the project host setup', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
connectionId: 'builder'
|
||||
})
|
||||
)
|
||||
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
|
||||
expect(automation.runContext).toMatchObject({
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
projectHostSetupId: 'r1',
|
||||
repoId: 'r1',
|
||||
path: '/repo'
|
||||
})
|
||||
expect(automation.sourceContext).toMatchObject({
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
projectHostSetupId: 'r1',
|
||||
repoId: 'r1',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
})
|
||||
|
||||
it('marks runtime-owned automations as remote-host scheduled', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
executionHostId: toRuntimeExecutionHostId('gpu-server'),
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
)
|
||||
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
|
||||
expect(automation.schedulerOwner).toBe('remote_host_service')
|
||||
expect(automation.runContext).toMatchObject({
|
||||
hostId: toRuntimeExecutionHostId('gpu-server')
|
||||
})
|
||||
})
|
||||
|
||||
it('snapshots automation contexts onto runs', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
|
||||
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
|
||||
store.updateAutomation(automation.id, { sourceContext: null, runContext: null })
|
||||
|
||||
expect(run.runContext).toEqual(automation.runContext)
|
||||
expect(run.sourceContext).toEqual(automation.sourceContext)
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
runContext: automation.runContext,
|
||||
sourceContext: automation.sourceContext
|
||||
})
|
||||
})
|
||||
|
||||
it('backfills legacy automation contexts on load', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
connectionId: 'builder'
|
||||
})
|
||||
)
|
||||
const automation = store.createAutomation({
|
||||
name: 'Legacy nightly',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
|
||||
const persisted = readDataFile() as {
|
||||
automations: Record<string, unknown>[]
|
||||
automationRuns: Record<string, unknown>[]
|
||||
}
|
||||
delete persisted.automations[0].runContext
|
||||
delete persisted.automations[0].sourceContext
|
||||
delete persisted.automationRuns[0].runContext
|
||||
delete persisted.automationRuns[0].sourceContext
|
||||
writeDataFile(persisted)
|
||||
|
||||
const reloaded = await createStore()
|
||||
const migratedAutomation = reloaded
|
||||
.listAutomations()
|
||||
.find((entry) => entry.id === automation.id)
|
||||
const migratedRun = reloaded
|
||||
.listAutomationRuns(automation.id)
|
||||
.find((entry) => entry.id === run.id)
|
||||
|
||||
expect(migratedAutomation?.runContext).toMatchObject({
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
projectHostSetupId: 'r1',
|
||||
repoId: 'r1',
|
||||
path: '/repo'
|
||||
})
|
||||
expect(migratedAutomation?.sourceContext).toMatchObject({
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: toSshExecutionHostId('builder'),
|
||||
projectHostSetupId: 'r1',
|
||||
repoId: 'r1',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext)
|
||||
expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext)
|
||||
})
|
||||
|
||||
it('persists automation precheck config and run results', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
@@ -2284,6 +2522,17 @@ describe('Store', () => {
|
||||
expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other')
|
||||
})
|
||||
|
||||
it('removeProject removes the derived project host setup compatibility record', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1' }))
|
||||
store.addRepo(makeRepo({ id: 'r2', path: '/repo2' }))
|
||||
|
||||
store.removeProject('r1')
|
||||
|
||||
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2'])
|
||||
expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2'])
|
||||
})
|
||||
|
||||
it('removeProject deletes child and parent lineage for the repo', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1' }))
|
||||
@@ -2330,6 +2579,278 @@ describe('Store', () => {
|
||||
expect(store.getRepo('r1')!.displayName).toBe('renamed')
|
||||
})
|
||||
|
||||
it('updateRepo keeps project host setup compatibility records in sync', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ worktreeBasePath: '../worktrees' }))
|
||||
|
||||
store.updateRepo('r1', {
|
||||
displayName: 'renamed',
|
||||
worktreeBasePath: '../new-worktrees',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
|
||||
expect(store.getProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'renamed',
|
||||
sourceRepoIds: ['r1']
|
||||
})
|
||||
])
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'r1',
|
||||
projectId: 'github:stablyai/orca',
|
||||
displayName: 'renamed',
|
||||
worktreeBasePath: '../new-worktrees'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('repo mutations preserve independent project host setup records', async () => {
|
||||
const independentProject = makeProject({
|
||||
id: 'cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: independentProject.id,
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '/srv/cloud-project',
|
||||
displayName: 'GPU VM'
|
||||
})
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
repos: [makeRepo({ id: 'r1' })],
|
||||
projects: [independentProject],
|
||||
projectHostSetups: [independentSetup]
|
||||
})
|
||||
const store = await createStore()
|
||||
|
||||
store.updateRepo('r1', { displayName: 'renamed' })
|
||||
store.reorderRepos(['r1'])
|
||||
|
||||
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project'])
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({ id: 'r1', displayName: 'renamed' }),
|
||||
independentSetup
|
||||
])
|
||||
})
|
||||
|
||||
it('updates independent project host setup records directly', async () => {
|
||||
const independentProject = makeProject({
|
||||
id: 'cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: independentProject.id,
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '/srv/cloud-project',
|
||||
displayName: 'GPU VM'
|
||||
})
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
projects: [independentProject],
|
||||
projectHostSetups: [independentSetup]
|
||||
})
|
||||
const store = await createStore()
|
||||
|
||||
const result = store.updateProjectHostSetup({
|
||||
setupId: independentSetup.id,
|
||||
updates: {
|
||||
displayName: 'GPU VM renamed',
|
||||
path: '/srv/renamed',
|
||||
worktreeBasePath: '../worktrees',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'cloned',
|
||||
gitUsername: 'alice'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
project: independentProject,
|
||||
setup: expect.objectContaining({
|
||||
id: independentSetup.id,
|
||||
displayName: 'GPU VM renamed',
|
||||
path: '/srv/renamed',
|
||||
worktreeBasePath: '../worktrees',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'cloned',
|
||||
gitUsername: 'alice'
|
||||
})
|
||||
})
|
||||
expect(store.getProjectHostSetups()[0]).toMatchObject({
|
||||
displayName: 'GPU VM renamed',
|
||||
path: '/srv/renamed'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates independent project host setup records for provisioning flows', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo({
|
||||
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
|
||||
upstream: { owner: 'stablyai', repo: 'cloud-project' }
|
||||
})
|
||||
|
||||
const result = store.createProjectHostSetup({
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'runtime:gpu-vm',
|
||||
setupId: 'cloud-project::gpu-vm',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
|
||||
expect(result?.project).toMatchObject({
|
||||
id: 'github:stablyai/cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
expect(result?.setup).toMatchObject({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
expect(store.getRepos()).toHaveLength(1)
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({ id: 'r1', repoId: 'r1' }),
|
||||
result?.setup
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate project host setup creation for the same host', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo({
|
||||
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
|
||||
upstream: { owner: 'stablyai', repo: 'cloud-project' }
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'runtime:gpu-vm'
|
||||
})
|
||||
store.createProjectHostSetup({
|
||||
projectId: independentSetup.projectId,
|
||||
hostId: independentSetup.hostId,
|
||||
setupId: independentSetup.id
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
store.createProjectHostSetup({
|
||||
projectId: 'github:stablyai/cloud-project',
|
||||
hostId: 'runtime:gpu-vm',
|
||||
setupId: 'duplicate'
|
||||
})
|
||||
).toThrow('Project host setup already exists: cloud-project::gpu-vm')
|
||||
})
|
||||
|
||||
it('updates repo-backed project host setup metadata through the repo record', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1', displayName: 'Repo', worktreeBasePath: '../old' }))
|
||||
|
||||
const result = store.updateProjectHostSetup({
|
||||
setupId: 'r1',
|
||||
updates: {
|
||||
displayName: 'Repo renamed',
|
||||
worktreeBasePath: '../new',
|
||||
setupMethod: 'cloned'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result?.repo).toMatchObject({
|
||||
id: 'r1',
|
||||
displayName: 'Repo renamed',
|
||||
worktreeBasePath: '../new',
|
||||
projectHostSetupMethod: 'cloned'
|
||||
})
|
||||
expect(result?.project).toMatchObject({
|
||||
id: 'repo:r1',
|
||||
displayName: 'Repo renamed'
|
||||
})
|
||||
expect(result?.setup).toMatchObject({
|
||||
id: 'r1',
|
||||
displayName: 'Repo renamed',
|
||||
worktreeBasePath: '../new',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects repo-backed project host setup path changes', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1', path: '/repo' }))
|
||||
|
||||
expect(() =>
|
||||
store.updateProjectHostSetup({
|
||||
setupId: 'r1',
|
||||
updates: { path: '/other' }
|
||||
})
|
||||
).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.')
|
||||
})
|
||||
|
||||
it('deletes independent project host setup records without deleting the project', async () => {
|
||||
const independentProject = makeProject({
|
||||
id: 'cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: independentProject.id,
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '/srv/cloud-project',
|
||||
displayName: 'GPU VM'
|
||||
})
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
projects: [independentProject],
|
||||
projectHostSetups: [independentSetup]
|
||||
})
|
||||
const store = await createStore()
|
||||
|
||||
const result = store.deleteProjectHostSetup({ setupId: independentSetup.id })
|
||||
|
||||
expect(result).toEqual({ project: independentProject, setup: independentSetup })
|
||||
expect(store.getProjects()).toEqual([independentProject])
|
||||
expect(store.getProjectHostSetups()).toEqual([])
|
||||
})
|
||||
|
||||
it('deletes repo-backed project host setups by removing the compatibility repo', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1', path: '/repo' }))
|
||||
store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' })
|
||||
|
||||
const result = store.deleteProjectHostSetup({ setupId: 'r1' })
|
||||
|
||||
expect(result?.project).toMatchObject({ id: 'repo:r1' })
|
||||
expect(result?.setup).toMatchObject({ id: 'r1', repoId: 'r1' })
|
||||
expect(result?.repo).toMatchObject({ id: 'r1' })
|
||||
expect(store.getRepo('r1')).toBeUndefined()
|
||||
expect(store.getProjects()).toEqual([])
|
||||
expect(store.getProjectHostSetups()).toEqual([])
|
||||
expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateRepo preserves repo-backed project host setup method', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
|
||||
store.updateRepo('r1', { projectHostSetupMethod: 'cloned' })
|
||||
|
||||
expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned')
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'r1',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('updateRepo drops repo icons that fail shared sanitization', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
@@ -7279,3 +7800,153 @@ describe('Store', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store host-partitioned workspace sessions', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId
|
||||
})
|
||||
|
||||
it('migrates a legacy workspaceSession blob into the local partition', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: makeHostSession('legacy-repo')
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
// The legacy blob is the 'local' partition; an explicit/default hostId reads it.
|
||||
expect(store.getWorkspaceSession().activeRepoId).toBe('legacy-repo')
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('legacy-repo')
|
||||
// No data was moved, so a downgrade still finds the legacy field intact.
|
||||
store.flush()
|
||||
const persisted = readDataFile() as { workspaceSession?: { activeRepoId?: string } }
|
||||
expect(persisted.workspaceSession?.activeRepoId).toBe('legacy-repo')
|
||||
})
|
||||
|
||||
it('is idempotent: re-loading already-partitioned state preserves all hosts', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: makeHostSession('local-repo'),
|
||||
workspaceSessionsByHostId: {
|
||||
'runtime:env-a': makeHostSession('runtime-repo'),
|
||||
'ssh:host-b': makeHostSession('ssh-repo')
|
||||
}
|
||||
})
|
||||
|
||||
const readSessionPartitions = (): unknown => {
|
||||
const data = readDataFile() as {
|
||||
workspaceSession?: unknown
|
||||
workspaceSessionsByHostId?: unknown
|
||||
}
|
||||
return {
|
||||
workspaceSession: data.workspaceSession,
|
||||
workspaceSessionsByHostId: data.workspaceSessionsByHostId
|
||||
}
|
||||
}
|
||||
|
||||
const first = await createStore()
|
||||
first.flush()
|
||||
const afterFirst = readSessionPartitions()
|
||||
|
||||
const second = await createStore()
|
||||
second.flush()
|
||||
const afterSecond = readSessionPartitions()
|
||||
|
||||
// Re-running the partition migration must not move or reshape any host.
|
||||
expect(afterSecond).toEqual(afterFirst)
|
||||
expect(second.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo')
|
||||
expect(second.getWorkspaceSession('ssh:host-b').activeRepoId).toBe('ssh-repo')
|
||||
expect(second.getWorkspaceSession('local').activeRepoId).toBe('local-repo')
|
||||
})
|
||||
|
||||
it('drops a stray "local" key in workspaceSessionsByHostId in favor of the legacy blob', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: makeHostSession('canonical-local'),
|
||||
workspaceSessionsByHostId: {
|
||||
local: makeHostSession('shadow-local')
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local')
|
||||
})
|
||||
|
||||
it('isolates writes: setting host A does not mutate host B or local', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
|
||||
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
||||
store.setWorkspaceSession(makeHostSession('repo-b'), 'runtime:env-b')
|
||||
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
||||
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
||||
expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b')
|
||||
|
||||
// Overwriting host A leaves host B and local untouched.
|
||||
store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a')
|
||||
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a2')
|
||||
expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b')
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
||||
})
|
||||
|
||||
it('patches a single host partition without touching the others', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
|
||||
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
||||
|
||||
store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a')
|
||||
|
||||
expect(store.getWorkspaceSession('runtime:env-a').activeTabId).toBe('tab-a')
|
||||
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
||||
// Local was never given that tab id.
|
||||
expect(store.getWorkspaceSession('local').activeTabId).toBeNull()
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
||||
})
|
||||
|
||||
it('defaults an omitted hostId to the local partition', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
||||
|
||||
// No hostId → local, which is still empty/default and unaffected by host A.
|
||||
store.setWorkspaceSession(makeHostSession('repo-local'))
|
||||
expect(store.getWorkspaceSession().activeRepoId).toBe('repo-local')
|
||||
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
||||
})
|
||||
|
||||
it('round-trips host partitions through disk', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
||||
})
|
||||
|
||||
it('drops a corrupt host partition to defaults without failing the others', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSessionsByHostId: {
|
||||
'runtime:good': makeHostSession('good-repo'),
|
||||
// activeRepoId must be string|null; a number fails the zod parse.
|
||||
'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 }
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo')
|
||||
// Bad partition collapses to defaults rather than poisoning the map.
|
||||
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+556
-77
@@ -24,6 +24,7 @@ import type {
|
||||
AutomationPrecheckResult,
|
||||
AutomationRunOutputSnapshot,
|
||||
AutomationRun,
|
||||
AutomationSchedulerOwner,
|
||||
AutomationRunTrigger,
|
||||
AutomationUpdateInput
|
||||
} from '../shared/automations-types'
|
||||
@@ -31,9 +32,19 @@ import {
|
||||
latestAutomationOccurrenceAtOrBefore,
|
||||
nextAutomationOccurrenceAfter
|
||||
} from '../shared/automation-schedules'
|
||||
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
|
||||
import { normalizeAutomationPrecheck } from '../shared/automation-precheck'
|
||||
import type {
|
||||
PersistedState,
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteArgs,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
RepoProjectHostSetupMethod,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
@@ -53,10 +64,16 @@ import type {
|
||||
WorkspaceSessionPatch,
|
||||
WorkspaceSessionState
|
||||
} from '../shared/types'
|
||||
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
|
||||
import {
|
||||
buildTaskSourceContextFromRepo,
|
||||
buildWorkspaceRunContext
|
||||
} from '../shared/task-source-context'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
|
||||
import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getGitUsername } from './git/repo'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host'
|
||||
import {
|
||||
getDefaultPersistedState,
|
||||
getDefaultNotificationSettings,
|
||||
@@ -71,6 +88,13 @@ import {
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostOrder,
|
||||
normalizeExecutionHostId,
|
||||
normalizeVisibleExecutionHostIds,
|
||||
type ExecutionHostId
|
||||
} from '../shared/execution-host'
|
||||
import { toRelaySshPtyId } from './providers/ssh-pty-id'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
@@ -97,11 +121,6 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import { normalizeAppIconId } from '../shared/app-icon'
|
||||
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
|
||||
import {
|
||||
normalizeLeftSidebarAppearanceMode,
|
||||
normalizeLeftSidebarTintColor,
|
||||
normalizeLeftSidebarTintOpacity
|
||||
} from '../shared/left-sidebar-appearance'
|
||||
import {
|
||||
compareFeatureInteractionUsageBuckets,
|
||||
getFeatureInteractionCategory,
|
||||
@@ -243,6 +262,39 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc
|
||||
)
|
||||
}
|
||||
|
||||
/** Normalize the persisted non-'local' host partitions. 'local' is intentionally
|
||||
* dropped here — it is the legacy workspaceSession blob — so the two surfaces
|
||||
* never diverge. Each partition is zod-validated independently: a corrupt host
|
||||
* drops to defaults without taking out the others. Idempotent: re-running on an
|
||||
* already-normalized map yields the same shape. */
|
||||
function parseWorkspaceSessionsByHostId(
|
||||
raw: unknown,
|
||||
defaults: WorkspaceSessionState
|
||||
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {}
|
||||
}
|
||||
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const hostId = normalizeExecutionHostId(key)
|
||||
// Why: 'local' belongs in workspaceSession; an invalid/local key here is
|
||||
// legacy noise and must not shadow the canonical local partition.
|
||||
if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
continue
|
||||
}
|
||||
const result = parseWorkspaceSession(value)
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
`[persistence] Corrupt workspace session for host ${hostId}, using defaults:`,
|
||||
result.error
|
||||
)
|
||||
continue
|
||||
}
|
||||
partitions[hostId] = { ...defaults, ...result.value }
|
||||
}
|
||||
return partitions
|
||||
}
|
||||
|
||||
function backupPath(dataFile: string, index: number): string {
|
||||
return `${dataFile}.bak.${index}`
|
||||
}
|
||||
@@ -434,11 +486,6 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][
|
||||
return getDefaultUIState().projectOrderBy
|
||||
}
|
||||
|
||||
import {
|
||||
isExistingPersistedProfile,
|
||||
resolveProjectOrderManualDefaultNoticeDismissed
|
||||
} from '../shared/project-order-manual-default-notice'
|
||||
|
||||
function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
|
||||
if (
|
||||
tab === 'explorer' ||
|
||||
@@ -572,6 +619,116 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation {
|
||||
}
|
||||
}
|
||||
|
||||
function getAutomationContextsForRepo(
|
||||
repo: Repo | undefined,
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
): Pick<Automation, 'runContext' | 'sourceContext'> {
|
||||
if (!repo) {
|
||||
return {
|
||||
runContext: null,
|
||||
sourceContext: null
|
||||
}
|
||||
}
|
||||
const projection = projectHostSetupProjectionFromRepos([repo])
|
||||
const projectedProject = projection.projects[0]
|
||||
const projectedSetup = projection.setups[0]
|
||||
const setup =
|
||||
projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup
|
||||
const runContext = setup
|
||||
? buildWorkspaceRunContext({
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: repo.id,
|
||||
path: setup.path
|
||||
})
|
||||
: null
|
||||
const providerIdentity = projectedProject?.providerIdentity
|
||||
const sourceContext = providerIdentity
|
||||
? buildTaskSourceContextFromRepo({
|
||||
provider: providerIdentity.provider,
|
||||
projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id,
|
||||
repo,
|
||||
projectHostSetupId: setup?.id,
|
||||
providerIdentity
|
||||
})
|
||||
: null
|
||||
return {
|
||||
runContext,
|
||||
sourceContext
|
||||
}
|
||||
}
|
||||
|
||||
function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner {
|
||||
if (!repo) {
|
||||
return 'local_host_service'
|
||||
}
|
||||
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
if (host?.kind === 'ssh') {
|
||||
return 'ssh_bridge'
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
return 'remote_host_service'
|
||||
}
|
||||
return 'local_host_service'
|
||||
}
|
||||
|
||||
function backfillLegacyAutomationContexts(
|
||||
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
|
||||
): {
|
||||
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
|
||||
changed: boolean
|
||||
} {
|
||||
let changed = false
|
||||
const contextsByAutomationId = new Map<string, Pick<Automation, 'runContext' | 'sourceContext'>>()
|
||||
const automations = (state.automations ?? []).map((automation) => {
|
||||
const contexts = getAutomationContextsForRepo(
|
||||
state.repos.find((repo) => repo.id === getAutomationLegacyRepoId(automation)),
|
||||
state.projectHostSetups ?? []
|
||||
)
|
||||
const next: Automation = { ...automation }
|
||||
if (!Object.hasOwn(next, 'runContext')) {
|
||||
// Why: pre-host-context automations only stored a repo id. Backfill the
|
||||
// explicit run target once so dispatch/precheck no longer infer it later.
|
||||
next.runContext = contexts.runContext
|
||||
changed = true
|
||||
}
|
||||
if (!Object.hasOwn(next, 'sourceContext')) {
|
||||
next.sourceContext = contexts.sourceContext
|
||||
changed = true
|
||||
}
|
||||
contextsByAutomationId.set(next.id, {
|
||||
runContext: next.runContext ?? null,
|
||||
sourceContext: next.sourceContext ?? null
|
||||
})
|
||||
return next
|
||||
})
|
||||
const automationRuns = (state.automationRuns ?? []).map((run) => {
|
||||
const automationContexts = contextsByAutomationId.get(run.automationId)
|
||||
const next: AutomationRun = { ...run }
|
||||
if (!Object.hasOwn(next, 'runContext')) {
|
||||
next.runContext = automationContexts?.runContext ?? null
|
||||
changed = true
|
||||
}
|
||||
if (!Object.hasOwn(next, 'sourceContext')) {
|
||||
next.sourceContext = automationContexts?.sourceContext ?? null
|
||||
changed = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (!changed) {
|
||||
return { state, changed: false }
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
automations,
|
||||
automationRuns
|
||||
},
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
type LegacySshTarget = SshTarget & {
|
||||
remoteWorkspaceSyncEnabled?: unknown
|
||||
remoteWorkspaceSyncGracePeriodSeconds?: unknown
|
||||
@@ -809,8 +966,19 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
|
||||
return owner && repo ? { owner, repo } : undefined
|
||||
}
|
||||
|
||||
function sanitizeRepoProjectHostSetupMethod(
|
||||
value: unknown
|
||||
): RepoProjectHostSetupMethod | undefined {
|
||||
return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined
|
||||
}
|
||||
|
||||
function sanitizeRepoUpdatesForPersistence<
|
||||
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>>
|
||||
T extends Partial<
|
||||
Pick<
|
||||
Repo,
|
||||
'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath' | 'projectHostSetupMethod'
|
||||
>
|
||||
>
|
||||
>(updates: T): T {
|
||||
const sanitized = { ...updates }
|
||||
if ('badgeColor' in sanitized) {
|
||||
@@ -845,6 +1013,14 @@ function sanitizeRepoUpdatesForPersistence<
|
||||
delete sanitized.worktreeBasePath
|
||||
}
|
||||
}
|
||||
if ('projectHostSetupMethod' in sanitized) {
|
||||
const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod)
|
||||
if (setupMethod === undefined) {
|
||||
delete sanitized.projectHostSetupMethod
|
||||
} else {
|
||||
sanitized.projectHostSetupMethod = setupMethod
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -1636,6 +1812,74 @@ function migrationUnsupportedEntriesEqual(
|
||||
})
|
||||
}
|
||||
|
||||
function projectHostSetupCompatibilityStateEqual(
|
||||
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
|
||||
nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'>
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) &&
|
||||
JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups)
|
||||
)
|
||||
}
|
||||
|
||||
function isRepoBackedProjectHostSetup(
|
||||
setup: ProjectHostSetup,
|
||||
currentRepoIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
const repoId = typeof setup.repoId === 'string' ? setup.repoId : ''
|
||||
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
|
||||
}
|
||||
|
||||
function mergeProjectHostSetupCompatibilityState(
|
||||
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
|
||||
repos: readonly Repo[]
|
||||
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
|
||||
const projection = projectHostSetupProjectionFromRepos(repos)
|
||||
const currentRepoIds = new Set(repos.map((repo) => repo.id))
|
||||
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
|
||||
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
|
||||
// Why: legacy/repo-backed setup rows use the repo id as the setup id. Keep
|
||||
// only independent setup rows here so repo deletion does not leave ghosts.
|
||||
const independentSetups = (state.projectHostSetups ?? []).filter((setup) => {
|
||||
if (projectedSetupIds.has(setup.id)) {
|
||||
return false
|
||||
}
|
||||
return !isRepoBackedProjectHostSetup(setup, currentRepoIds)
|
||||
})
|
||||
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
|
||||
const independentProjects = (state.projects ?? [])
|
||||
.filter(
|
||||
(project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id)
|
||||
)
|
||||
.map((project) => ({
|
||||
...project,
|
||||
sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId))
|
||||
}))
|
||||
return {
|
||||
projects: [...projection.projects, ...independentProjects],
|
||||
projectHostSetups: [...projection.setups, ...independentSetups]
|
||||
}
|
||||
}
|
||||
|
||||
function makeProjectHostSetupId(
|
||||
projectId: string,
|
||||
hostId: ExecutionHostId,
|
||||
existingIds: ReadonlySet<string>,
|
||||
requestedId?: string
|
||||
): string {
|
||||
const baseId = requestedId?.trim() || `${projectId}::${hostId}`
|
||||
if (!existingIds.has(baseId)) {
|
||||
return baseId
|
||||
}
|
||||
let suffix = 2
|
||||
let candidate = `${baseId}::${suffix}`
|
||||
while (existingIds.has(candidate)) {
|
||||
suffix++
|
||||
candidate = `${baseId}::${suffix}`
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function createMinimalPersistedTerminalTab(args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
@@ -2234,14 +2478,6 @@ export class Store {
|
||||
parsed.settings?.disabledTuiAgents
|
||||
)
|
||||
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
|
||||
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
|
||||
parsed.settings ?? {},
|
||||
'openLinksInApp'
|
||||
)
|
||||
const migratedOpenLinksInAppPreferencePrompted =
|
||||
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
|
||||
? parsed.settings.openLinksInAppPreferencePrompted
|
||||
: openLinksInAppWasPersisted
|
||||
if (
|
||||
parsed.settings?.agentYoloDefaultsMigrated !== true ||
|
||||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
|
||||
@@ -2258,12 +2494,6 @@ export class Store {
|
||||
if (!autoRenameBranchFromWorkDefaultedOn) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.settings?.openLinksInAppPreferencePrompted !==
|
||||
migratedOpenLinksInAppPreferencePrompted
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const normalizedOnboarding = normalizeLoadedOnboardingState(
|
||||
parsed.onboarding,
|
||||
defaults.onboarding
|
||||
@@ -2327,15 +2557,6 @@ export class Store {
|
||||
terminalCustomThemes: normalizeTerminalCustomThemes(
|
||||
parsed.settings?.terminalCustomThemes
|
||||
),
|
||||
leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode(
|
||||
parsed.settings?.leftSidebarAppearanceMode
|
||||
),
|
||||
leftSidebarTintColor: normalizeLeftSidebarTintColor(
|
||||
parsed.settings?.leftSidebarTintColor
|
||||
),
|
||||
leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity(
|
||||
parsed.settings?.leftSidebarTintOpacity
|
||||
),
|
||||
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
|
||||
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
|
||||
defaultTaskSource: taskProviderSettings.defaultTaskSource,
|
||||
@@ -2350,7 +2571,6 @@ export class Store {
|
||||
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
|
||||
seedDefaults: true
|
||||
}),
|
||||
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
sourceControlAi: migratedSourceControlAi,
|
||||
// Why: new builds read sourceControlAi, but rollback builds still
|
||||
@@ -2491,28 +2711,12 @@ export class Store {
|
||||
parsed.ui?.setupGuideSidebarDismissed,
|
||||
normalizedOnboarding
|
||||
)
|
||||
const projectOrderManualDefaultNoticeDismissed =
|
||||
resolveProjectOrderManualDefaultNoticeDismissed({
|
||||
rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed,
|
||||
rawProjectOrderBy: parsed.ui?.projectOrderBy,
|
||||
isExistingProfile: isExistingPersistedProfile({
|
||||
repoCount: parsed.repos?.length ?? 0,
|
||||
onboardingClosedAt: normalizedOnboarding.closedAt,
|
||||
ui: parsed.ui
|
||||
})
|
||||
})
|
||||
if (
|
||||
parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed &&
|
||||
(setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined)
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.ui?.projectOrderManualDefaultNoticeDismissed !==
|
||||
projectOrderManualDefaultNoticeDismissed
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return {
|
||||
...defaults.ui,
|
||||
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
|
||||
@@ -2521,7 +2725,6 @@ export class Store {
|
||||
rightSidebarOpen,
|
||||
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
|
||||
setupGuideSidebarDismissed,
|
||||
projectOrderManualDefaultNoticeDismissed,
|
||||
setupGuideBrowserMilestoneMigrated:
|
||||
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
|
||||
? parsed.ui.setupGuideBrowserMilestoneMigrated
|
||||
@@ -2569,6 +2772,15 @@ export class Store {
|
||||
}
|
||||
return { ...defaults.workspaceSession, ...result.value }
|
||||
})(),
|
||||
// Why: per-host session partitions for non-'local' hosts. 'local'
|
||||
// stays in workspaceSession (legacy field) so a downgrade still
|
||||
// reads the user's workspace. Each entry is zod-validated the same
|
||||
// way as the legacy blob — a corrupt partition drops to that host's
|
||||
// defaults without poisoning the others.
|
||||
workspaceSessionsByHostId: parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
|
||||
.map(normalizeSshRemotePtyLease)
|
||||
@@ -2621,9 +2833,30 @@ export class Store {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
|
||||
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
|
||||
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
|
||||
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
|
||||
const automationContextMigration = backfillLegacyAutomationContexts({
|
||||
...result,
|
||||
repos,
|
||||
...projectHostSetupCompatibility
|
||||
})
|
||||
if (automationContextMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
automations: automationContextMigration.state.automations,
|
||||
automationRuns: automationContextMigration.state.automationRuns
|
||||
}
|
||||
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
|
||||
repos,
|
||||
...projectHostSetupCompatibility,
|
||||
workspaceSession: migratedScrollback.session
|
||||
})
|
||||
if (folderScopeConnectionMigration.changed) {
|
||||
@@ -2843,6 +3076,101 @@ export class Store {
|
||||
return this.state.repos.map((repo) => this.hydrateRepo(repo))
|
||||
}
|
||||
|
||||
getProjects(): Project[] {
|
||||
return [...this.state.projects]
|
||||
}
|
||||
|
||||
getProjectHostSetups(): ProjectHostSetup[] {
|
||||
return [...this.state.projectHostSetups]
|
||||
}
|
||||
|
||||
createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null {
|
||||
const project = this.state.projects.find((entry) => entry.id === args.projectId)
|
||||
if (!project) {
|
||||
return null
|
||||
}
|
||||
const hostId = normalizeExecutionHostId(args.hostId)
|
||||
if (!hostId) {
|
||||
throw new Error(`Invalid host ID: ${args.hostId}`)
|
||||
}
|
||||
const duplicateSetup = this.state.projectHostSetups.find(
|
||||
(entry) => entry.projectId === project.id && entry.hostId === hostId
|
||||
)
|
||||
if (duplicateSetup) {
|
||||
throw new Error(`Project host setup already exists: ${duplicateSetup.id}`)
|
||||
}
|
||||
const now = Date.now()
|
||||
const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id))
|
||||
const setup: ProjectHostSetup = {
|
||||
id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId),
|
||||
projectId: project.id,
|
||||
hostId,
|
||||
repoId: '',
|
||||
path: args.path?.trim() ?? '',
|
||||
displayName: args.displayName?.trim() || project.displayName,
|
||||
...(args.kind ? { kind: args.kind } : {}),
|
||||
...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}),
|
||||
...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}),
|
||||
setupState: args.setupState ?? 'not-set-up',
|
||||
setupMethod: args.setupMethod ?? 'provisioned',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
// Why: this is the first non-repo-backed setup creation path; it must
|
||||
// persist independently so future repo projection sync does not erase it.
|
||||
this.state.projectHostSetups.push(setup)
|
||||
this.scheduleSave()
|
||||
return { project, setup }
|
||||
}
|
||||
|
||||
updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null {
|
||||
const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId)
|
||||
if (!setup) {
|
||||
return null
|
||||
}
|
||||
const project = this.state.projects.find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
return null
|
||||
}
|
||||
const repo = setup.repoId
|
||||
? this.state.repos.find((entry) => entry.id === setup.repoId)
|
||||
: undefined
|
||||
if (repo) {
|
||||
const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates)
|
||||
const updatedProject = updated
|
||||
? this.state.projects.find((entry) => entry.id === updated.setup.projectId)
|
||||
: undefined
|
||||
return updated && updatedProject
|
||||
? { project: updatedProject, setup: updated.setup, repo: updated.repo }
|
||||
: null
|
||||
}
|
||||
const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates)
|
||||
return { project, setup: updatedSetup }
|
||||
}
|
||||
|
||||
deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null {
|
||||
const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId)
|
||||
if (!setup) {
|
||||
return null
|
||||
}
|
||||
const project = this.state.projects.find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
return null
|
||||
}
|
||||
const repo = setup.repoId
|
||||
? this.state.repos.find((entry) => entry.id === setup.repoId)
|
||||
: undefined
|
||||
if (repo) {
|
||||
this.removeProject(repo.id)
|
||||
return { project, setup, repo: this.hydrateRepo(repo) }
|
||||
}
|
||||
this.state.projectHostSetups = this.state.projectHostSetups.filter(
|
||||
(entry) => entry.id !== setup.id
|
||||
)
|
||||
this.scheduleSave()
|
||||
return { project, setup }
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) read of the persisted repo count. Use this when you only need the
|
||||
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and
|
||||
@@ -3110,6 +3438,7 @@ export class Store {
|
||||
|
||||
addRepo(repo: Repo): void {
|
||||
this.state.repos.push(repo)
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
@@ -3141,12 +3470,14 @@ export class Store {
|
||||
next.push(repo)
|
||||
}
|
||||
this.state.repos = next
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
removeProject(id: string): void {
|
||||
this.state.repos = this.state.repos.filter((r) => r.id !== id)
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
// Why: presets are repo-scoped, so removing the repo means the presets
|
||||
// can never be referenced again — drop them with the parent.
|
||||
delete this.state.sparsePresetsByRepo[id]
|
||||
@@ -3184,6 +3515,7 @@ export class Store {
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'projectHostSetupMethod'
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
): Repo | null {
|
||||
@@ -3252,20 +3584,109 @@ export class Store {
|
||||
}
|
||||
}
|
||||
Object.assign(repo, sanitizedUpdates)
|
||||
this.syncProjectHostSetupCompatibilityState()
|
||||
this.scheduleSave()
|
||||
return this.hydrateRepo(repo)
|
||||
}
|
||||
|
||||
private syncProjectHostSetupCompatibilityState(): void {
|
||||
const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos)
|
||||
this.state.projects = compatibilityState.projects
|
||||
this.state.projectHostSetups = compatibilityState.projectHostSetups
|
||||
}
|
||||
|
||||
private updateRepoBackedProjectHostSetup(
|
||||
setup: ProjectHostSetup,
|
||||
repo: Repo,
|
||||
updates: ProjectHostSetupUpdateArgs['updates']
|
||||
): { setup: ProjectHostSetup; repo: Repo } | null {
|
||||
if (updates.path !== undefined && updates.path !== repo.path) {
|
||||
throw new Error(
|
||||
'Repo-backed project host setup paths must be changed by re-importing the project.'
|
||||
)
|
||||
}
|
||||
if (updates.setupState !== undefined && updates.setupState !== 'ready') {
|
||||
throw new Error('Repo-backed project host setups cannot be marked unavailable.')
|
||||
}
|
||||
const repoUpdates: Parameters<Store['updateRepo']>[1] = {}
|
||||
if (updates.displayName !== undefined) {
|
||||
repoUpdates.displayName = updates.displayName
|
||||
}
|
||||
if (updates.worktreeBasePath !== undefined) {
|
||||
repoUpdates.worktreeBasePath = updates.worktreeBasePath
|
||||
}
|
||||
if (updates.kind !== undefined) {
|
||||
repoUpdates.kind = updates.kind
|
||||
}
|
||||
if (updates.setupMethod === 'provisioned') {
|
||||
throw new Error('Repo-backed project host setups cannot be marked provisioned.')
|
||||
}
|
||||
if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') {
|
||||
repoUpdates.projectHostSetupMethod = updates.setupMethod
|
||||
}
|
||||
const updatedRepo =
|
||||
Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo
|
||||
if (!updatedRepo) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup,
|
||||
repo: updatedRepo
|
||||
}
|
||||
}
|
||||
|
||||
private updateIndependentProjectHostSetup(
|
||||
setup: ProjectHostSetup,
|
||||
updates: ProjectHostSetupUpdateArgs['updates']
|
||||
): ProjectHostSetup {
|
||||
if (updates.displayName !== undefined) {
|
||||
setup.displayName = updates.displayName.trim() || setup.displayName
|
||||
}
|
||||
if (updates.path !== undefined) {
|
||||
setup.path = updates.path.trim() || setup.path
|
||||
}
|
||||
if (updates.worktreeBasePath !== undefined) {
|
||||
const worktreeBasePath = updates.worktreeBasePath.trim()
|
||||
if (worktreeBasePath) {
|
||||
setup.worktreeBasePath = worktreeBasePath
|
||||
} else {
|
||||
delete setup.worktreeBasePath
|
||||
}
|
||||
}
|
||||
if (updates.kind !== undefined) {
|
||||
setup.kind = updates.kind
|
||||
}
|
||||
if (updates.gitUsername !== undefined) {
|
||||
const gitUsername = updates.gitUsername.trim()
|
||||
if (gitUsername) {
|
||||
setup.gitUsername = gitUsername
|
||||
} else {
|
||||
delete setup.gitUsername
|
||||
}
|
||||
}
|
||||
if (updates.setupState !== undefined) {
|
||||
setup.setupState = updates.setupState
|
||||
}
|
||||
if (updates.setupMethod !== undefined) {
|
||||
setup.setupMethod = updates.setupMethod
|
||||
}
|
||||
setup.updatedAt = Date.now()
|
||||
this.scheduleSave()
|
||||
return setup
|
||||
}
|
||||
|
||||
private hydrateRepo(repo: Repo): Repo {
|
||||
const {
|
||||
repoIcon: rawRepoIcon,
|
||||
upstream: rawUpstream,
|
||||
sourceControlAi: rawSourceControlAi,
|
||||
projectHostSetupMethod: rawProjectHostSetupMethod,
|
||||
...repoWithoutIcon
|
||||
} = repo
|
||||
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
|
||||
const upstream = sanitizeRepoUpstream(rawUpstream)
|
||||
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
|
||||
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
|
||||
const gitUsername = isFolderRepo(repo)
|
||||
? ''
|
||||
: (this.gitUsernameCache.get(repo.path) ??
|
||||
@@ -3280,6 +3701,7 @@ export class Store {
|
||||
...(repoIcon !== undefined ? { repoIcon } : {}),
|
||||
...(upstream !== undefined ? { upstream } : {}),
|
||||
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
|
||||
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
|
||||
kind: isFolderRepo(repo) ? 'folder' : 'git',
|
||||
gitUsername,
|
||||
hookSettings: {
|
||||
@@ -3340,16 +3762,20 @@ export class Store {
|
||||
const repo = this.state.repos.find((entry) => entry.id === input.projectId)
|
||||
const now = Date.now()
|
||||
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
|
||||
const schedulerOwner = getAutomationSchedulerOwner(repo)
|
||||
const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? [])
|
||||
const automation: Automation = {
|
||||
id: randomUUID(),
|
||||
name: input.name.trim() || 'Untitled automation',
|
||||
prompt: input.prompt,
|
||||
precheck: normalizeAutomationPrecheck(input.precheck),
|
||||
agentId: input.agentId,
|
||||
runContext: input.runContext ?? contexts.runContext,
|
||||
sourceContext: input.sourceContext ?? contexts.sourceContext,
|
||||
projectId: input.projectId,
|
||||
executionTargetType,
|
||||
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
|
||||
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
|
||||
schedulerOwner,
|
||||
workspaceMode: input.workspaceMode,
|
||||
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
|
||||
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
|
||||
@@ -3379,6 +3805,8 @@ export class Store {
|
||||
const repoId = updates.projectId ?? current.projectId
|
||||
const repo = this.state.repos.find((entry) => entry.id === repoId)
|
||||
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
|
||||
const schedulerOwner = getAutomationSchedulerOwner(repo)
|
||||
const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? [])
|
||||
const rrule = updates.rrule ?? current.rrule
|
||||
const dtstart = updates.dtstart ?? current.dtstart
|
||||
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
|
||||
@@ -3392,9 +3820,19 @@ export class Store {
|
||||
? normalizeAutomationPrecheck(updates.precheck)
|
||||
: normalizeAutomationPrecheck(current.precheck),
|
||||
projectId: repoId,
|
||||
runContext: Object.hasOwn(updates, 'runContext')
|
||||
? (updates.runContext ?? null)
|
||||
: updates.projectId !== undefined
|
||||
? contexts.runContext
|
||||
: (current.runContext ?? contexts.runContext),
|
||||
sourceContext: Object.hasOwn(updates, 'sourceContext')
|
||||
? (updates.sourceContext ?? null)
|
||||
: updates.projectId !== undefined
|
||||
? contexts.sourceContext
|
||||
: (current.sourceContext ?? contexts.sourceContext),
|
||||
executionTargetType,
|
||||
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
|
||||
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
|
||||
schedulerOwner,
|
||||
workspaceMode,
|
||||
workspaceId:
|
||||
workspaceMode === 'existing'
|
||||
@@ -3450,6 +3888,8 @@ export class Store {
|
||||
const run: AutomationRun = {
|
||||
id: randomUUID(),
|
||||
automationId: automation.id,
|
||||
runContext: automation.runContext ?? null,
|
||||
sourceContext: automation.sourceContext ?? null,
|
||||
title: `${automation.name} run ${runNumber}`,
|
||||
scheduledFor,
|
||||
status: 'pending',
|
||||
@@ -3664,21 +4104,6 @@ export class Store {
|
||||
updates.terminalCustomThemes
|
||||
)
|
||||
}
|
||||
if ('leftSidebarAppearanceMode' in updates) {
|
||||
sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode(
|
||||
updates.leftSidebarAppearanceMode
|
||||
)
|
||||
}
|
||||
if ('leftSidebarTintColor' in updates) {
|
||||
sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor(
|
||||
updates.leftSidebarTintColor
|
||||
)
|
||||
}
|
||||
if ('leftSidebarTintOpacity' in updates) {
|
||||
sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity(
|
||||
updates.leftSidebarTintOpacity
|
||||
)
|
||||
}
|
||||
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
|
||||
const taskProviderSettings = normalizeTaskProviderSettings({
|
||||
visibleTaskProviders:
|
||||
@@ -3795,6 +4220,10 @@ export class Store {
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
|
||||
this.state.ui?.visibleWorkspaceHostIds
|
||||
),
|
||||
workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
|
||||
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
|
||||
this.state.ui?.browserDefaultZoomLevel
|
||||
),
|
||||
@@ -3861,6 +4290,14 @@ export class Store {
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
visibleWorkspaceHostIds:
|
||||
updates.visibleWorkspaceHostIds !== undefined
|
||||
? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds)
|
||||
: normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds),
|
||||
workspaceHostOrder:
|
||||
updates.workspaceHostOrder !== undefined
|
||||
? normalizeExecutionHostOrder(updates.workspaceHostOrder)
|
||||
: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
|
||||
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
|
||||
updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel
|
||||
),
|
||||
@@ -3985,8 +4422,19 @@ export class Store {
|
||||
|
||||
// ── Workspace Session ─────────────────────────────────────────────
|
||||
|
||||
getWorkspaceSession(): PersistedState['workspaceSession'] {
|
||||
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
|
||||
/** Resolve an execution host argument to a canonical id. Unknown/empty
|
||||
* values fall back to 'local' so legacy callers without a hostId keep
|
||||
* reading and writing the local partition exactly as before. */
|
||||
private resolveHostId(hostId?: string | null): ExecutionHostId {
|
||||
return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] {
|
||||
const resolved = this.resolveHostId(hostId)
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
|
||||
}
|
||||
return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession()
|
||||
}
|
||||
|
||||
readTerminalScrollbackSnapshot(ref: string): string | null {
|
||||
@@ -3999,7 +4447,30 @@ export class Store {
|
||||
return findWorktreeIdForTab(this.getWorkspaceSession(), tabId)
|
||||
}
|
||||
|
||||
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
|
||||
setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void {
|
||||
const resolved = this.resolveHostId(hostId)
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
this.setLocalWorkspaceSession(session)
|
||||
return
|
||||
}
|
||||
this.setHostWorkspaceSession(resolved, session)
|
||||
}
|
||||
|
||||
/** Persist a non-'local' host partition. The PTY-binding race protections in
|
||||
* setLocalWorkspaceSession only apply to the local daemon, so remote hosts
|
||||
* take the lighter prune-and-store path. */
|
||||
private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void {
|
||||
const pruned = pruneWorkspaceSessionBrowserHistory(
|
||||
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
|
||||
)
|
||||
this.state.workspaceSessionsByHostId = {
|
||||
...this.state.workspaceSessionsByHostId,
|
||||
[hostId]: pruned
|
||||
}
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void {
|
||||
session = pruneWorkspaceSessionBrowserHistory(
|
||||
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
|
||||
)
|
||||
@@ -4148,22 +4619,30 @@ export class Store {
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
patchWorkspaceSession(patch: WorkspaceSessionPatch): void {
|
||||
patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void {
|
||||
const resolved = this.resolveHostId(hostId)
|
||||
// Why: the renderer's debounced hot path sends only changed top-level
|
||||
// session slices. Scalar/UI patches avoid the terminal normalization path;
|
||||
// terminal topology/layout patches still reuse the stale-PTY protections.
|
||||
let next: WorkspaceSessionState = {
|
||||
...this.getWorkspaceSession(),
|
||||
...this.getWorkspaceSession(resolved),
|
||||
...patch
|
||||
}
|
||||
if (workspaceSessionPatchNeedsFullNormalization(patch)) {
|
||||
this.setWorkspaceSession(next)
|
||||
this.setWorkspaceSession(next, resolved)
|
||||
return
|
||||
}
|
||||
if (Object.hasOwn(patch, 'browserUrlHistory')) {
|
||||
next = pruneWorkspaceSessionBrowserHistory(next)
|
||||
}
|
||||
this.state.workspaceSession = next
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
this.state.workspaceSession = next
|
||||
} else {
|
||||
this.state.workspaceSessionsByHostId = {
|
||||
...this.state.workspaceSessionsByHostId,
|
||||
[resolved]: next
|
||||
}
|
||||
}
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ type MockMultiplexer = {
|
||||
request: ReturnType<typeof vi.fn>
|
||||
notify: ReturnType<typeof vi.fn>
|
||||
onNotification: ReturnType<typeof vi.fn>
|
||||
onNotificationByMethod: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
isDisposed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -15,6 +16,7 @@ function createMockMux(): MockMultiplexer {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()),
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn().mockReturnValue(false)
|
||||
}
|
||||
@@ -79,6 +81,60 @@ describe('SshGitProvider', () => {
|
||||
expect(result).toEqual(['dist/bundle.js'])
|
||||
})
|
||||
|
||||
it('clone sends git.clone request and forwards matching progress notifications', async () => {
|
||||
const unsubscribe = vi.fn()
|
||||
const onProgress = vi.fn()
|
||||
mux.onNotificationByMethod.mockReturnValue(unsubscribe)
|
||||
mux.request.mockImplementationOnce(async (_method, params) => {
|
||||
const progressHandler = mux.onNotificationByMethod.mock.calls[0][1]
|
||||
progressHandler({
|
||||
progressId: params.progressId,
|
||||
phase: 'Receiving objects',
|
||||
percent: 42
|
||||
})
|
||||
progressHandler({
|
||||
progressId: 'other-clone',
|
||||
phase: 'Receiving objects',
|
||||
percent: 99
|
||||
})
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', {
|
||||
timeoutMs: 1000,
|
||||
onProgress
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'git.clone',
|
||||
expect.objectContaining({
|
||||
args: ['clone', '--progress', '--', 'url', 'repo'],
|
||||
cwd: '/home/user',
|
||||
progressId: expect.stringMatching(/^clone-/)
|
||||
}),
|
||||
{ signal: undefined, timeoutMs: 1000 }
|
||||
)
|
||||
expect(mux.onNotificationByMethod).toHaveBeenCalledWith(
|
||||
'git.cloneProgress',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 })
|
||||
expect(onProgress).toHaveBeenCalledTimes(1)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports an actionable reconnect message when the relay does not support cloning', async () => {
|
||||
const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number }
|
||||
methodNotFound.code = -32601
|
||||
mux.request.mockRejectedValueOnce(methodNotFound)
|
||||
|
||||
await expect(
|
||||
provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user')
|
||||
).rejects.toThrow(
|
||||
'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('getHistory sends git.history request', async () => {
|
||||
const historyResult = {
|
||||
items: [],
|
||||
@@ -176,6 +232,32 @@ describe('SshGitProvider', () => {
|
||||
expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' })
|
||||
})
|
||||
|
||||
it('exec forwards abort and timeout options to the relay request', async () => {
|
||||
const controller = new AbortController()
|
||||
mux.request.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await provider.exec(
|
||||
['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'],
|
||||
'/home/user',
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000
|
||||
}
|
||||
)
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'git.exec',
|
||||
{
|
||||
args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'],
|
||||
cwd: '/home/user'
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => {
|
||||
mux.request.mockImplementation(async (method, payload) => {
|
||||
expect(method).toBe('git.exec')
|
||||
|
||||
@@ -21,6 +21,7 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
describeMaxBufferOverflowError,
|
||||
isMaxBufferOverflowError
|
||||
@@ -60,7 +61,11 @@ export class SshGitProvider implements IGitProvider {
|
||||
private nonInteractiveExecQueues = new Map<string, NonInteractiveExecQueueEntry[]>()
|
||||
private loggedWorktreeIsCleanFallback = false
|
||||
|
||||
constructor(connectionId: string, mux: SshChannelMultiplexer) {
|
||||
constructor(
|
||||
connectionId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
private readonly hostPlatform: RemoteHostPlatform | null = null
|
||||
) {
|
||||
this.connectionId = connectionId
|
||||
this.mux = mux
|
||||
}
|
||||
@@ -69,6 +74,10 @@ export class SshGitProvider implements IGitProvider {
|
||||
return this.connectionId
|
||||
}
|
||||
|
||||
getHostPlatform(): RemoteHostPlatform | null {
|
||||
return this.hostPlatform
|
||||
}
|
||||
|
||||
async getStatus(
|
||||
worktreePath: string,
|
||||
options?: { includeIgnored?: boolean }
|
||||
@@ -540,13 +549,64 @@ export class SshGitProvider implements IGitProvider {
|
||||
await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch })
|
||||
}
|
||||
|
||||
async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return (await this.mux.request('git.exec', { args, cwd })) as {
|
||||
async exec(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const result = options
|
||||
? await this.mux.request('git.exec', { args, cwd }, options)
|
||||
: await this.mux.request('git.exec', { args, cwd })
|
||||
return result as {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
}
|
||||
|
||||
async clone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
onProgress?: (progress: { phase: string; percent: number }) => void
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const unsubscribe = options?.onProgress
|
||||
? this.mux.onNotificationByMethod('git.cloneProgress', (params) => {
|
||||
if (params.progressId !== progressId) {
|
||||
return
|
||||
}
|
||||
const phase = params.phase
|
||||
const percent = params.percent
|
||||
if (typeof phase === 'string' && typeof percent === 'number') {
|
||||
options.onProgress?.({ phase, percent })
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
try {
|
||||
const result = await this.mux.request(
|
||||
'git.clone',
|
||||
{ args, cwd, progressId },
|
||||
{ signal: options?.signal, timeoutMs: options?.timeoutMs }
|
||||
)
|
||||
return result as {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
} catch (error) {
|
||||
if (isJsonRpcMethodNotFoundError(error)) {
|
||||
throw new Error(
|
||||
'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}
|
||||
|
||||
async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> {
|
||||
return (await this.mux.request('git.isGitRepo', { dirPath })) as {
|
||||
isRepo: boolean
|
||||
|
||||
@@ -217,7 +217,11 @@ export type IGitProvider = {
|
||||
renameCurrentBranch?(worktreePath: string, newBranch: string): Promise<void>
|
||||
isGitRepo(path: string): boolean
|
||||
isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }>
|
||||
exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
|
||||
exec(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<{ stdout: string; stderr: string }>
|
||||
getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise<string | null>
|
||||
worktreeIsClean(
|
||||
worktreePath: string,
|
||||
|
||||
@@ -950,12 +950,23 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(status.capabilities).toContain('terminal.binary-stream.v1')
|
||||
expect(status.capabilities).toContain('workspace-ports.v1')
|
||||
expect(status.capabilities).toContain('mobile.tasks.v1')
|
||||
expect(status.capabilities).toContain('project-host-setup.v1')
|
||||
expect(status.capabilities).not.toContain('browser.screencast.v1')
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
|
||||
expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('advertises browser screencast only when a renderer window is available', () => {
|
||||
const runtime = createRuntime()
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
|
||||
expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1')
|
||||
})
|
||||
|
||||
it('claims the first window as authoritative and ignores later windows', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
@@ -9848,6 +9859,47 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes headless mobile session agent identity with synthesized PTY status', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {}
|
||||
})
|
||||
} as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
agent: 'claude'
|
||||
})
|
||||
runtime.onPtyData('pty-agent', '\x1b]0;✳ Claude Code\x07', Date.now())
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(created.tab).toMatchObject({
|
||||
type: 'terminal',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
launchAgent: 'claude',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'claude'
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects disabled mobile session agent launches before spawning', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
|
||||
+372
-136
@@ -25,6 +25,7 @@ import {
|
||||
deriveValidatedClonePath,
|
||||
getClonePathComparisonKey
|
||||
} from '../git/repo-clone-path'
|
||||
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { isAbsolute, join, resolve } from 'path'
|
||||
@@ -51,6 +52,17 @@ import type {
|
||||
GitHubOwnerRepo,
|
||||
GlobalSettings,
|
||||
PersistedUIState,
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCloneArgs,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteArgs,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
Repo,
|
||||
RemoveWorktreeResult,
|
||||
StatsSummary,
|
||||
@@ -106,6 +118,10 @@ import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear-agent-access'
|
||||
import type { FeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
import { parsePtySessionId } from '../../shared/pty-session-id-format'
|
||||
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
@@ -586,6 +602,11 @@ type RuntimeStore = {
|
||||
getRepo: Store['getRepo']
|
||||
addRepo: Store['addRepo']
|
||||
updateRepo: Store['updateRepo']
|
||||
getProjects?: Store['getProjects']
|
||||
getProjectHostSetups?: Store['getProjectHostSetups']
|
||||
createProjectHostSetup?: Store['createProjectHostSetup']
|
||||
updateProjectHostSetup?: Store['updateProjectHostSetup']
|
||||
deleteProjectHostSetup?: Store['deleteProjectHostSetup']
|
||||
getProjectGroups?: Store['getProjectGroups']
|
||||
createProjectGroup?: Store['createProjectGroup']
|
||||
updateProjectGroup?: Store['updateProjectGroup']
|
||||
@@ -1041,6 +1062,11 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt
|
||||
id: worktreeId,
|
||||
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}),
|
||||
...(meta.projectHostSetupId !== undefined
|
||||
? { projectHostSetupId: meta.projectHostSetupId }
|
||||
: {}),
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
@@ -1898,6 +1924,8 @@ export class OrcaRuntimeService {
|
||||
prompt: input.prompt,
|
||||
precheck: input.precheck,
|
||||
agentId: input.agentId,
|
||||
runContext: input.runContext,
|
||||
sourceContext: input.sourceContext,
|
||||
projectId: target.projectId,
|
||||
workspaceMode: target.workspaceMode,
|
||||
workspaceId: target.workspaceId,
|
||||
@@ -1929,6 +1957,12 @@ export class OrcaRuntimeService {
|
||||
if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) {
|
||||
patch.agentId = updates.agentId
|
||||
}
|
||||
if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) {
|
||||
patch.runContext = updates.runContext
|
||||
}
|
||||
if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) {
|
||||
patch.sourceContext = updates.sourceContext
|
||||
}
|
||||
if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) {
|
||||
patch.baseBranch = updates.baseBranch
|
||||
}
|
||||
@@ -2069,6 +2103,9 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
getStatus(): RuntimeStatus {
|
||||
const capabilities = this.getAvailableAuthoritativeWindow()
|
||||
? [...RUNTIME_CAPABILITIES]
|
||||
: RUNTIME_CAPABILITIES.filter((capability) => capability !== 'browser.screencast.v1')
|
||||
return {
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
@@ -2078,7 +2115,9 @@ export class OrcaRuntimeService {
|
||||
liveLeafCount: this.leaves.size,
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
capabilities: [...RUNTIME_CAPABILITIES],
|
||||
// Why: headless orca serve cannot create/stream BrowserViews, so clients
|
||||
// must not treat browser panes as supported just because runtime RPC is up.
|
||||
capabilities,
|
||||
hostPlatform: process.platform,
|
||||
protocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
@@ -2846,8 +2885,6 @@ export class OrcaRuntimeService {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
|
||||
let activatedTab: RuntimeMobileSessionSnapshotTab = tab
|
||||
|
||||
if (tab.type === 'terminal') {
|
||||
const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
|
||||
@@ -2862,11 +2899,18 @@ export class OrcaRuntimeService {
|
||||
if (shouldMaterializePendingTerminal) {
|
||||
const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined
|
||||
try {
|
||||
await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, {
|
||||
tabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
sessionId
|
||||
})
|
||||
await this.createHeadlessMobileSessionTerminal(
|
||||
worktreeId,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
tabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
sessionId
|
||||
},
|
||||
tab.launchAgent
|
||||
)
|
||||
} catch (err) {
|
||||
if (sessionId && parseAppSshPtyId(sessionId)) {
|
||||
// Why: an expired SSH reattach clears durable bindings in the store,
|
||||
@@ -2887,8 +2931,16 @@ export class OrcaRuntimeService {
|
||||
candidate.isActive
|
||||
)
|
||||
const targetTab = activeSibling ?? tab
|
||||
if (!this.notifier?.focusTerminal) {
|
||||
if (
|
||||
!targetTab.isActive &&
|
||||
this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab)
|
||||
) {
|
||||
this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab)
|
||||
}
|
||||
return this.getMobileSessionTabsForWorktree(worktreeId)
|
||||
}
|
||||
this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId)
|
||||
activatedTab = targetTab
|
||||
} else if (tab.type === 'browser') {
|
||||
// Why: browser mobile tabs are renderer-owned unified tabs; focusing the
|
||||
// session tab keeps desktop tab order/group state authoritative.
|
||||
@@ -2896,56 +2948,9 @@ export class OrcaRuntimeService {
|
||||
} else {
|
||||
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
|
||||
}
|
||||
|
||||
// Why: serve/headless snapshots have no renderer to re-publish focus, but
|
||||
// merged epochs can still contain renderer-owned group state.
|
||||
if (
|
||||
!this.getAvailableAuthoritativeWindow() &&
|
||||
this.isPureHeadlessMobileSessionPublication(snapshot!.publicationEpoch)
|
||||
) {
|
||||
this.persistHeadlessMobileSessionActiveTab(worktreeId, snapshot!, activatedTab)
|
||||
}
|
||||
return this.getMobileSessionTabsForWorktree(worktreeId)
|
||||
}
|
||||
|
||||
private persistHeadlessMobileSessionActiveTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
activeTab: RuntimeMobileSessionSnapshotTab
|
||||
): void {
|
||||
const alreadyActive =
|
||||
snapshot.activeTabId === activeTab.id &&
|
||||
snapshot.activeTabType === activeTab.type &&
|
||||
snapshot.tabs.every((candidate) => candidate.isActive === (candidate.id === activeTab.id))
|
||||
if (alreadyActive) {
|
||||
// Why: re-activating the already-active tab must not bump snapshotVersion,
|
||||
// or every redundant activation would force a remote re-render.
|
||||
return
|
||||
}
|
||||
const tabs = snapshot.tabs.map((candidate) => ({
|
||||
...candidate,
|
||||
isActive: candidate.id === activeTab.id
|
||||
}))
|
||||
const terminalTabs = tabs.filter(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
|
||||
)
|
||||
const next: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: activeTab.id,
|
||||
activeTabType: activeTab.type,
|
||||
tabGroups: this.buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
activeTab.type === 'terminal' ? activeTab : null,
|
||||
snapshot.tabGroups
|
||||
),
|
||||
tabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, next)
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
}
|
||||
|
||||
private shouldMaterializeHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
@@ -2956,6 +2961,79 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
private shouldPersistHeadlessMobileSessionActivation(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
if (snapshot.publicationEpoch.includes(':headless-merge:')) {
|
||||
return false
|
||||
}
|
||||
if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') {
|
||||
return false
|
||||
}
|
||||
return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab)
|
||||
}
|
||||
|
||||
private activateHeadlessMobileSessionTerminalTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
activeTab: RuntimeMobileSessionTerminalTab
|
||||
): void {
|
||||
const tabs = snapshot.tabs.map((candidate) => ({
|
||||
...candidate,
|
||||
isActive: candidate.id === activeTab.id
|
||||
}))
|
||||
const terminalTabs = tabs.filter(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
|
||||
)
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: activeTab.id,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: this.buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
activeTab,
|
||||
snapshot.tabGroups
|
||||
),
|
||||
tabs
|
||||
}
|
||||
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
private persistHeadlessTerminalActiveLeaf(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): void {
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId]
|
||||
const nextLayouts = existingLayout
|
||||
? {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[tab.parentTabId]: {
|
||||
...this.cloneTerminalLayoutSnapshot(existingLayout),
|
||||
activeLeafId: tab.leafId
|
||||
}
|
||||
}
|
||||
: session.terminalLayoutsByTabId
|
||||
this.store.setWorkspaceSession({
|
||||
...session,
|
||||
activeTabId: tab.parentTabId,
|
||||
activeTabIdByWorktree: {
|
||||
...session.activeTabIdByWorktree,
|
||||
[worktreeId]: tab.parentTabId
|
||||
},
|
||||
terminalLayoutsByTabId: nextLayouts
|
||||
})
|
||||
}
|
||||
|
||||
async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> {
|
||||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
@@ -6632,6 +6710,106 @@ export class OrcaRuntimeService {
|
||||
return this.store?.getRepos() ?? []
|
||||
}
|
||||
|
||||
listProjects(): Project[] {
|
||||
return this.store?.getProjects?.() ?? []
|
||||
}
|
||||
|
||||
listProjectHostSetups(): ProjectHostSetup[] {
|
||||
return this.store?.getProjectHostSetups?.() ?? []
|
||||
}
|
||||
|
||||
createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult {
|
||||
if (!this.store?.createProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const result = this.store.createProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async setupProjectExistingFolder(
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
): Promise<ProjectHostSetupResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git')
|
||||
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
if (setup.projectId !== args.projectId) {
|
||||
if (
|
||||
!existingProject.providerIdentity ||
|
||||
existingProject.providerIdentity.provider !== 'github'
|
||||
) {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
const updated = this.store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: existingProject.providerIdentity.owner,
|
||||
repo: existingProject.providerIdentity.repo
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
|
||||
}
|
||||
repo = updated
|
||||
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
}
|
||||
const setupMethod = args.setupMethod ?? 'imported-existing-folder'
|
||||
const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
|
||||
)
|
||||
}
|
||||
repo = updated
|
||||
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
|
||||
}
|
||||
return { project, setup, repo }
|
||||
}
|
||||
|
||||
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
|
||||
const repo = await this.cloneRepo(args.url, args.destination)
|
||||
return await this.setupProjectExistingFolder({
|
||||
projectId: args.projectId,
|
||||
hostId: args.hostId,
|
||||
path: repo.path,
|
||||
kind: 'git',
|
||||
displayName: args.displayName,
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
}
|
||||
|
||||
updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult {
|
||||
if (!this.store?.updateProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const result = this.store.updateProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult {
|
||||
if (!this.store?.deleteProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const result = this.store.deleteProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
listProjectGroups(): ProjectGroup[] {
|
||||
return this.store?.getProjectGroups?.() ?? []
|
||||
}
|
||||
@@ -7237,8 +7415,7 @@ export class OrcaRuntimeService {
|
||||
} else if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
|
||||
reject(new Error(`Clone failed: ${lastLine}`))
|
||||
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`))
|
||||
}
|
||||
}
|
||||
proc.on('error', (error) => {
|
||||
@@ -9314,6 +9491,7 @@ export class OrcaRuntimeService {
|
||||
const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo),
|
||||
displayName: args.displayName?.trim() || args.name,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
@@ -9696,6 +9874,7 @@ export class OrcaRuntimeService {
|
||||
// and later recreated, creation must mint a fresh instance identity so
|
||||
// stale lineage records tied to the old occupant fail validation.
|
||||
instanceId: randomUUID(),
|
||||
...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo),
|
||||
lastActivityAt: now,
|
||||
// See createRemoteWorktree: createdAt grants the new worktree a grace
|
||||
// window in Recent sort so ambient PTY bumps in OTHER worktrees can't
|
||||
@@ -9815,6 +9994,7 @@ export class OrcaRuntimeService {
|
||||
let didSpawnStartup = false
|
||||
let didSpawnSetup = false
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminalTabId: string | null = null
|
||||
if (effectiveStartup && this.ptyController?.spawn) {
|
||||
try {
|
||||
// Why: automation startup must not depend on a renderer TerminalPane
|
||||
@@ -9837,6 +10017,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
didSpawnStartup = true
|
||||
startupTerminalHandle = terminal.handle
|
||||
startupTerminalTabId = terminal.tabId ?? null
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
@@ -9956,6 +10137,16 @@ export class OrcaRuntimeService {
|
||||
: {}),
|
||||
...(addResult.localBaseRefUpdateSuggestion
|
||||
? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion }
|
||||
: {}),
|
||||
...(didSpawnStartup && startupTerminalHandle
|
||||
? {
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: startupTerminalHandle,
|
||||
...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}),
|
||||
surface: 'background' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -10047,6 +10238,7 @@ export class OrcaRuntimeService {
|
||||
let didSpawnStartup = false
|
||||
let didSpawnSetup = false
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminalTabId: string | null = null
|
||||
if (args.startup && this.ptyController?.spawn) {
|
||||
try {
|
||||
const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent
|
||||
@@ -10070,6 +10262,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
didSpawnStartup = true
|
||||
startupTerminalHandle = terminal.handle
|
||||
startupTerminalTabId = terminal.tabId ?? null
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
@@ -10171,7 +10364,20 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
return warning ? { ...result, warning } : result
|
||||
const resultWithStartupTerminal =
|
||||
didSpawnStartup && startupTerminalHandle
|
||||
? {
|
||||
...result,
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: startupTerminalHandle,
|
||||
...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}),
|
||||
surface: 'background' as const
|
||||
}
|
||||
}
|
||||
: result
|
||||
|
||||
return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11525,7 +11731,7 @@ export class OrcaRuntimeService {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
}
|
||||
}
|
||||
return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface }
|
||||
return { handle, tabId, worktreeId: workspace.id, title: opts.title ?? null, surface }
|
||||
}
|
||||
|
||||
this.assertGraphReady()
|
||||
@@ -11575,7 +11781,36 @@ export class OrcaRuntimeService {
|
||||
// populates this.leaves may not have arrived yet. Wait for the leaf to
|
||||
// appear so we can return a valid handle the caller can use right away.
|
||||
const handle = await this.waitForTerminalHandle(reply.tabId)
|
||||
return { handle, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' }
|
||||
return {
|
||||
handle,
|
||||
tabId: reply.tabId,
|
||||
worktreeId: worktreeId ?? '',
|
||||
title: reply.title,
|
||||
surface: 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
async launchAgentTerminal(
|
||||
worktreeSelector: string,
|
||||
opts: { agent: TuiAgent; prompt: string; title?: string }
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = this.store?.getRepo(worktree.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('Repository for the selected workspace is no longer available.')
|
||||
}
|
||||
const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt)
|
||||
if (repo.connectionId) {
|
||||
await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path)
|
||||
} else {
|
||||
this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path)
|
||||
}
|
||||
return await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: startup.startup.command,
|
||||
env: startup.startup.env,
|
||||
telemetry: startup.startup.telemetry,
|
||||
title: opts.title
|
||||
})
|
||||
}
|
||||
|
||||
async createMobileSessionTerminal(
|
||||
@@ -11609,7 +11844,9 @@ export class OrcaRuntimeService {
|
||||
worktreeId,
|
||||
opts.activate !== false,
|
||||
opts.afterTabId,
|
||||
command
|
||||
command,
|
||||
undefined,
|
||||
opts.agent
|
||||
)
|
||||
}
|
||||
const requestId = randomUUID()
|
||||
@@ -11694,7 +11931,8 @@ export class OrcaRuntimeService {
|
||||
activate: boolean,
|
||||
afterTabId?: string,
|
||||
command?: string,
|
||||
identity?: { tabId: string; leafId: string; sessionId?: string }
|
||||
identity?: { tabId: string; leafId: string; sessionId?: string },
|
||||
launchAgent?: TuiAgent
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`)
|
||||
const repo = this.store?.getRepo(worktree.repoId)
|
||||
@@ -11742,6 +11980,7 @@ export class OrcaRuntimeService {
|
||||
leafId,
|
||||
ptyId: livePty.pty.ptyId,
|
||||
title: terminal.title ?? livePty.pty.title ?? 'Terminal',
|
||||
...(launchAgent ? { launchAgent } : {}),
|
||||
parentLayout,
|
||||
isActive: activate
|
||||
}
|
||||
@@ -13445,15 +13684,10 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
private isPureHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
|
||||
return (
|
||||
publicationEpoch.startsWith('headless:') || publicationEpoch.startsWith('headless-hydrated:')
|
||||
)
|
||||
}
|
||||
|
||||
private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
|
||||
return (
|
||||
this.isPureHeadlessMobileSessionPublication(publicationEpoch) ||
|
||||
publicationEpoch.startsWith('headless:') ||
|
||||
publicationEpoch.startsWith('headless-hydrated:') ||
|
||||
publicationEpoch.includes(':headless-merge:')
|
||||
)
|
||||
}
|
||||
@@ -13718,6 +13952,7 @@ export class OrcaRuntimeService {
|
||||
title,
|
||||
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
|
||||
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
|
||||
...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}),
|
||||
...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
|
||||
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
|
||||
isActive: tab.isActive,
|
||||
@@ -13795,6 +14030,7 @@ export class OrcaRuntimeService {
|
||||
stateStartedAt: now,
|
||||
paneKey: this.getMobileTerminalPaneKey(tab),
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(tab.launchAgent ? { agentType: tab.launchAgent } : {}),
|
||||
worktreeId: pty.worktreeId,
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle: getLatestPtyTitle(pty) ?? tab.title,
|
||||
@@ -15103,6 +15339,72 @@ export class OrcaRuntimeService {
|
||||
return link
|
||||
}
|
||||
|
||||
private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> {
|
||||
const currentPath = resolve(cwd)
|
||||
let best: ResolvedWorktree | null = null
|
||||
for (const candidate of await this.listResolvedWorktrees()) {
|
||||
if (!isPathInsideOrEqual(candidate.path, currentPath)) {
|
||||
continue
|
||||
}
|
||||
if (!best || candidate.path.length > best.path.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
linearListIssues(
|
||||
filter?: LinearListFilter,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection,
|
||||
teamId?: string
|
||||
): ReturnType<typeof listLinearIssues> {
|
||||
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId)
|
||||
}
|
||||
|
||||
linearCreateIssue(
|
||||
teamId: string,
|
||||
title: string,
|
||||
description?: string,
|
||||
workspaceId?: string,
|
||||
parentIssueId?: string,
|
||||
projectId?: string | null,
|
||||
options?: {
|
||||
stateId?: string
|
||||
priority?: number
|
||||
estimate?: number | null
|
||||
dueDate?: string | null
|
||||
assigneeId?: string | null
|
||||
labelIds?: string[]
|
||||
}
|
||||
): ReturnType<typeof createLinearIssue> {
|
||||
return createLinearIssue(teamId, title, description, workspaceId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
|
||||
return getLinearIssue(id, workspaceId)
|
||||
}
|
||||
|
||||
linearUpdateIssue(
|
||||
id: string,
|
||||
updates: LinearIssueUpdate,
|
||||
workspaceId?: string
|
||||
): ReturnType<typeof updateLinearIssue> {
|
||||
return updateLinearIssue(id, updates, workspaceId)
|
||||
}
|
||||
|
||||
linearAddIssueComment(
|
||||
issueId: string,
|
||||
body: string,
|
||||
workspaceId?: string
|
||||
): ReturnType<typeof addLinearIssueComment> {
|
||||
return addLinearIssueComment(issueId, body, workspaceId)
|
||||
}
|
||||
|
||||
async linearIssueSetState(params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
@@ -16473,72 +16775,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> {
|
||||
const currentPath = resolve(cwd)
|
||||
let best: ResolvedWorktree | null = null
|
||||
for (const candidate of await this.listResolvedWorktrees()) {
|
||||
if (!isPathInsideOrEqual(candidate.path, currentPath)) {
|
||||
continue
|
||||
}
|
||||
if (!best || candidate.path.length > best.path.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
linearListIssues(
|
||||
filter?: LinearListFilter,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection,
|
||||
teamId?: string
|
||||
): ReturnType<typeof listLinearIssues> {
|
||||
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId)
|
||||
}
|
||||
|
||||
linearCreateIssue(
|
||||
teamId: string,
|
||||
title: string,
|
||||
description?: string,
|
||||
workspaceId?: string,
|
||||
parentIssueId?: string,
|
||||
projectId?: string | null,
|
||||
options?: {
|
||||
stateId?: string
|
||||
priority?: number
|
||||
estimate?: number | null
|
||||
dueDate?: string | null
|
||||
assigneeId?: string | null
|
||||
labelIds?: string[]
|
||||
}
|
||||
): ReturnType<typeof createLinearIssue> {
|
||||
return createLinearIssue(teamId, title, description, workspaceId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
|
||||
return getLinearIssue(id, workspaceId)
|
||||
}
|
||||
|
||||
linearUpdateIssue(
|
||||
id: string,
|
||||
updates: LinearIssueUpdate,
|
||||
workspaceId?: string
|
||||
): ReturnType<typeof updateLinearIssue> {
|
||||
return updateLinearIssue(id, updates, workspaceId)
|
||||
}
|
||||
|
||||
linearAddIssueComment(
|
||||
issueId: string,
|
||||
body: string,
|
||||
workspaceId?: string
|
||||
): ReturnType<typeof addLinearIssueComment> {
|
||||
return addLinearIssueComment(issueId, body, workspaceId)
|
||||
}
|
||||
|
||||
linearIssueComments(
|
||||
issueId: string,
|
||||
workspaceId?: string
|
||||
|
||||
@@ -30,6 +30,23 @@ describe('automation RPC methods', () => {
|
||||
prompt: 'Review changes',
|
||||
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
|
||||
agentId: 'codex',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca'
|
||||
},
|
||||
sourceContext: {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'setup-local',
|
||||
repoId: 'repo-local',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
|
||||
},
|
||||
repo: 'repo-1',
|
||||
reuseSession: true,
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
@@ -59,6 +76,8 @@ describe('automation RPC methods', () => {
|
||||
prompt: 'Review changes',
|
||||
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
|
||||
agentId: 'codex',
|
||||
runContext: expect.objectContaining({ hostId: 'runtime:gpu' }),
|
||||
sourceContext: expect.objectContaining({ hostId: 'local' }),
|
||||
repo: 'repo-1',
|
||||
reuseSession: true
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
normalizeAutomationPrecheckTimeoutSeconds
|
||||
} from '../../../../shared/automation-precheck'
|
||||
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
@@ -20,6 +22,14 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
|
||||
})
|
||||
|
||||
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
|
||||
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
})
|
||||
|
||||
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
|
||||
message: 'Invalid automation trigger'
|
||||
@@ -43,6 +53,43 @@ const OptionalNullablePlainString = z
|
||||
.pipe(z.union([z.string(), z.null(), z.undefined()]))
|
||||
.optional()
|
||||
|
||||
const TaskProviderIdentity = z
|
||||
.custom<SharedTaskProviderIdentity>(
|
||||
(value) =>
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'provider' in value &&
|
||||
['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
|
||||
)
|
||||
.optional()
|
||||
.nullable()
|
||||
|
||||
const TaskSourceContext = z
|
||||
.object({
|
||||
kind: z.literal('task-source'),
|
||||
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
|
||||
projectId: requiredString('Missing source project id'),
|
||||
hostId: ExecutionHostId,
|
||||
projectHostSetupId: OptionalNullablePlainString,
|
||||
repoId: OptionalNullablePlainString,
|
||||
providerIdentity: TaskProviderIdentity,
|
||||
accountLabel: OptionalNullablePlainString
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
|
||||
const WorkspaceRunContext = z
|
||||
.object({
|
||||
kind: z.literal('workspace-run'),
|
||||
projectId: requiredString('Missing run project id'),
|
||||
hostId: ExecutionHostId,
|
||||
projectHostSetupId: requiredString('Missing project host setup id'),
|
||||
repoId: requiredString('Missing repo id'),
|
||||
path: requiredString('Missing run path')
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
|
||||
const AutomationId = z.object({
|
||||
id: requiredString('Missing automation id')
|
||||
})
|
||||
@@ -56,6 +103,8 @@ const AutomationCreate = z.object({
|
||||
prompt: requiredString('Missing automation prompt'),
|
||||
precheck: AutomationPrecheck,
|
||||
agentId: TuiAgent,
|
||||
runContext: WorkspaceRunContext,
|
||||
sourceContext: TaskSourceContext,
|
||||
repo: OptionalString,
|
||||
workspace: OptionalString,
|
||||
workspaceMode: AutomationWorkspaceMode,
|
||||
@@ -73,6 +122,8 @@ const AutomationUpdateFields = z.object({
|
||||
prompt: OptionalString,
|
||||
precheck: AutomationPrecheck,
|
||||
agentId: TuiAgent.optional(),
|
||||
runContext: WorkspaceRunContext,
|
||||
sourceContext: TaskSourceContext,
|
||||
repo: OptionalString,
|
||||
workspace: OptionalString,
|
||||
workspaceMode: AutomationWorkspaceMode,
|
||||
|
||||
@@ -158,6 +158,9 @@ const UiUpdate = z
|
||||
hideSleepingWorkspaces: z.boolean().optional(),
|
||||
showSleepingWorkspaces: z.boolean().optional(),
|
||||
showInactiveWorkspaces: z.boolean().optional(),
|
||||
workspaceHostScope: z.string().optional(),
|
||||
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
|
||||
workspaceHostOrder: z.array(z.string()).optional(),
|
||||
hideDefaultBranchWorkspace: z.boolean().optional(),
|
||||
filterRepoIds: StringArray.optional(),
|
||||
collapsedGroups: StringArray.optional(),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from 'zod'
|
||||
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const ProjectHostSetupExistingFolder = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
}),
|
||||
path: requiredString('Missing project path'),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: OptionalString,
|
||||
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupClone = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
}),
|
||||
url: requiredString('Missing clone URL'),
|
||||
destination: requiredString('Missing clone destination'),
|
||||
displayName: OptionalString
|
||||
})
|
||||
|
||||
const ProjectHostSetupCreate = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
}),
|
||||
setupId: OptionalString,
|
||||
path: OptionalString,
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: OptionalString,
|
||||
worktreeBasePath: OptionalString,
|
||||
gitUsername: OptionalString,
|
||||
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
|
||||
setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupUpdate = z.object({
|
||||
setupId: requiredString('Missing setup ID'),
|
||||
updates: z.object({
|
||||
displayName: OptionalString,
|
||||
path: OptionalString,
|
||||
worktreeBasePath: OptionalString,
|
||||
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
|
||||
setupMethod: z
|
||||
.enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
|
||||
.optional(),
|
||||
gitUsername: OptionalString,
|
||||
kind: z.enum(['git', 'folder']).optional()
|
||||
})
|
||||
})
|
||||
|
||||
const ProjectHostSetupDelete = z.object({
|
||||
setupId: requiredString('Missing setup ID')
|
||||
})
|
||||
|
||||
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'project.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.create',
|
||||
params: ProjectHostSetupCreate,
|
||||
handler: (params, { runtime }) => ({
|
||||
result: runtime.createProjectHostSetup(params)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.setupExistingFolder',
|
||||
params: ProjectHostSetupExistingFolder,
|
||||
handler: async (params, { runtime }) => ({
|
||||
result: await runtime.setupProjectExistingFolder(params)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.clone',
|
||||
params: ProjectHostSetupClone,
|
||||
handler: async (params, { runtime }) => ({
|
||||
result: await runtime.setupProjectClone(params)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.update',
|
||||
params: ProjectHostSetupUpdate,
|
||||
handler: (params, { runtime }) => ({
|
||||
result: runtime.updateProjectHostSetup(params)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.delete',
|
||||
params: ProjectHostSetupDelete,
|
||||
handler: (params, { runtime }) => ({
|
||||
result: runtime.deleteProjectHostSetup(params)
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
|
||||
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
@@ -158,6 +159,7 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
|
||||
}),
|
||||
...PROJECT_RUNTIME_METHODS,
|
||||
defineMethod({
|
||||
name: 'projectGroup.list',
|
||||
params: null,
|
||||
|
||||
@@ -529,7 +529,11 @@ export class SshRelaySession {
|
||||
)
|
||||
registerSshFilesystemProvider(this.targetId, fsProvider)
|
||||
|
||||
const gitProvider = new SshGitProvider(this.targetId, mux)
|
||||
const gitProvider = new SshGitProvider(
|
||||
this.targetId,
|
||||
mux,
|
||||
this.remoteCliBridgeEnv?.hostPlatform ?? null
|
||||
)
|
||||
registerSshGitProvider(this.targetId, gitProvider)
|
||||
|
||||
this.wireUpPtyEvents(ptyProvider)
|
||||
|
||||
+294
-217
@@ -10,6 +10,7 @@ import type {
|
||||
import type { NativeFileDropPayload } from '../shared/native-file-drop'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||
import type { TaskSourceContext } from '../shared/task-source-context'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
FolderWorkspacePathStatusRequest
|
||||
@@ -124,8 +125,18 @@ import type {
|
||||
PRComment,
|
||||
PRInfo,
|
||||
PRRefreshOutcome,
|
||||
Project,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteArgs,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportResult,
|
||||
ProjectGroupImportMode,
|
||||
@@ -150,6 +161,18 @@ import type {
|
||||
WorkspaceSessionPatch,
|
||||
WorkspaceSessionState
|
||||
} from '../shared/types'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type GitHubRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
import type {
|
||||
WarpThemeImportPreview,
|
||||
WarpThemeImportSource
|
||||
@@ -159,6 +182,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { RuntimeAccessGrant } from '../shared/runtime-access-grants'
|
||||
import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
import type { FeatureInteractionId } from '../shared/feature-interactions'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
@@ -760,6 +784,13 @@ export type PreloadApi = {
|
||||
pickFolder: () => Promise<string | null>
|
||||
pickDirectory: () => Promise<string | null>
|
||||
clone: (args: { url: string; destination: string }) => Promise<Repo>
|
||||
cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo>
|
||||
createRemote: (args: {
|
||||
connectionId: string
|
||||
parentPath: string
|
||||
name: string
|
||||
kind: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
cloneAbort: () => Promise<void>
|
||||
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
|
||||
addRemote: (args: {
|
||||
@@ -787,6 +818,16 @@ export type PreloadApi = {
|
||||
}) => Promise<BaseRefSearchResult[]>
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
projects: {
|
||||
list: () => Promise<Project[]>
|
||||
listHostSetups: () => Promise<ProjectHostSetup[]>
|
||||
createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise<ProjectHostSetupCreateResult>
|
||||
setupExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
) => Promise<ProjectHostSetupResult>
|
||||
updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise<ProjectHostSetupUpdateResult>
|
||||
deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise<ProjectHostSetupDeleteResult>
|
||||
}
|
||||
projectGroups: {
|
||||
list: () => Promise<ProjectGroup[]>
|
||||
create: (args: {
|
||||
@@ -1095,11 +1136,13 @@ export type PreloadApi = {
|
||||
issue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
}) => Promise<IssueInfo | null>
|
||||
workItem: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
|
||||
@@ -1111,22 +1154,22 @@ export type PreloadApi = {
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}) => Promise<GitHubWorkItemDetails | null>
|
||||
prFileContents: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}) => Promise<GitHubPRFileContents>
|
||||
workItemDetails: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}
|
||||
) => Promise<GitHubWorkItemDetails | null>
|
||||
prFileContents: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: GitHubPRFile['status']
|
||||
headSha: string
|
||||
baseSha: string
|
||||
}
|
||||
) => Promise<GitHubPRFileContents>
|
||||
listIssues: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
@@ -1135,6 +1178,7 @@ export type PreloadApi = {
|
||||
createIssue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
title: string
|
||||
body: string
|
||||
labels?: string[]
|
||||
@@ -1149,33 +1193,35 @@ export type PreloadApi = {
|
||||
before?: string
|
||||
noCache?: boolean
|
||||
}) => Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>
|
||||
prChecks: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
noCache?: boolean
|
||||
}) => Promise<PRCheckDetail[]>
|
||||
prChecks: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
noCache?: boolean
|
||||
}
|
||||
) => Promise<PRCheckDetail[]>
|
||||
prCheckDetails: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
checkName?: string
|
||||
url?: string | null
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<PRCheckRunDetails | null>
|
||||
rerunPRChecks: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
failedOnly?: boolean
|
||||
}) => Promise<{ ok: true; count: number } | { ok: false; error: string }>
|
||||
rerunPRChecks: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
failedOnly?: boolean
|
||||
}
|
||||
) => Promise<{ ok: true; count: number } | { ok: false; error: string }>
|
||||
prComments: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
noCache?: boolean
|
||||
@@ -1183,17 +1229,18 @@ export type PreloadApi = {
|
||||
resolveReviewThread: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
threadId: string
|
||||
resolve: boolean
|
||||
}) => Promise<boolean>
|
||||
setPRFileViewed: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
pullRequestId: string
|
||||
path: string
|
||||
viewed: boolean
|
||||
}) => Promise<boolean>
|
||||
setPRFileViewed: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
pullRequestId: string
|
||||
path: string
|
||||
viewed: boolean
|
||||
}
|
||||
) => Promise<boolean>
|
||||
updatePRTitle: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
@@ -1201,75 +1248,83 @@ export type PreloadApi = {
|
||||
title: string
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<boolean>
|
||||
mergePR: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
setPRAutoMerge: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updatePRState: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
updates: { state: 'open' | 'closed' }
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
requestPRReviewers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
removePRReviewers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateIssue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
addIssueComment: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
number: number
|
||||
body: string
|
||||
/** Why: GitHub stores PR conversation comments under `/issues/N/comments`
|
||||
* too, so the IPC and `gh` call paths are identical. The renderer cache
|
||||
* key is keyed by the drawer's `type`, so callers pass it through to
|
||||
* scope the cross-window invalidation broadcast correctly and avoid
|
||||
* evicting an unrelated PR/issue that happens to share the number. */
|
||||
type?: 'issue' | 'pr'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<GitHubCommentResult>
|
||||
addPRReviewCommentReply: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
prNumber: number
|
||||
commentId: number
|
||||
body: string
|
||||
threadId?: string
|
||||
path?: string
|
||||
line?: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<GitHubCommentResult>
|
||||
addPRReviewComment: (
|
||||
args: GitHubPRReviewCommentInput & { repoId?: string }
|
||||
mergePR: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
setPRAutoMerge: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updatePRState: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
updates: { state: 'open' | 'closed' }
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
requestPRReviewers: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
removePRReviewers: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateIssue: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
addIssueComment: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
number: number
|
||||
body: string
|
||||
/** Why: GitHub stores PR conversation comments under `/issues/N/comments`
|
||||
* too, so the IPC and `gh` call paths are identical. The renderer cache
|
||||
* key is keyed by the drawer's `type`, so callers pass it through to
|
||||
* scope the cross-window invalidation broadcast correctly and avoid
|
||||
* evicting an unrelated PR/issue that happens to share the number. */
|
||||
type?: 'issue' | 'pr'
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => Promise<GitHubCommentResult>
|
||||
listLabels: (args: { repoPath: string; repoId?: string }) => Promise<string[]>
|
||||
addPRReviewCommentReply: (
|
||||
args: GitHubRepoSelectorArgs & {
|
||||
prNumber: number
|
||||
commentId: number
|
||||
body: string
|
||||
threadId?: string
|
||||
path?: string
|
||||
line?: number
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => Promise<GitHubCommentResult>
|
||||
addPRReviewComment: (
|
||||
args: GitHubPRReviewCommentInput & {
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
) => Promise<GitHubCommentResult>
|
||||
listLabels: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}) => Promise<string[]>
|
||||
listAssignableUsers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}) => Promise<GitHubAssignableUser[]>
|
||||
/**
|
||||
* Subscribe to local-mutation broadcasts. Used by the work-item-drawer
|
||||
@@ -1350,117 +1405,136 @@ export type PreloadApi = {
|
||||
force?: boolean
|
||||
host?: string | null
|
||||
}) => Promise<GetGitLabRateLimitResult>
|
||||
projectSlug: (args: { repoPath: string }) => Promise<GitLabProjectRef | null>
|
||||
mrForBranch: (args: {
|
||||
repoPath: string
|
||||
branch: string
|
||||
linkedMRIid?: number | null
|
||||
}) => Promise<MRInfo | null>
|
||||
mr: (args: { repoPath: string; iid: number }) => Promise<MRInfo | null>
|
||||
listMRs: (args: {
|
||||
repoPath: string
|
||||
state?: MRListState
|
||||
page?: number
|
||||
perPage?: number
|
||||
}) => Promise<ListMergeRequestsResult>
|
||||
projectSlug: (args: GitLabRepoSelectorArgs) => Promise<GitLabProjectRef | null>
|
||||
mrForBranch: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
branch: string
|
||||
linkedMRIid?: number | null
|
||||
}
|
||||
) => Promise<MRInfo | null>
|
||||
mr: (args: GitLabRepoSelectorArgs & { iid: number }) => Promise<MRInfo | null>
|
||||
listMRs: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: MRListState
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
) => Promise<ListMergeRequestsResult>
|
||||
/** Combined MR + issue list filtered by state. Issues are skipped
|
||||
* when state is 'merged' (issues don't merge). */
|
||||
listWorkItems: (args: {
|
||||
repoPath: string
|
||||
state?: MRListState
|
||||
page?: number
|
||||
perPage?: number
|
||||
}) => Promise<ListMergeRequestsResult>
|
||||
issue: (args: { repoPath: string; number: number }) => Promise<GitLabIssueInfo | null>
|
||||
listIssues: (args: {
|
||||
repoPath: string
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
}) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
|
||||
createIssue: (args: {
|
||||
repoPath: string
|
||||
title: string
|
||||
body: string
|
||||
}) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
|
||||
updateIssue: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
updates: GitLabIssueUpdate
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
addIssueComment: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
body: string
|
||||
}) => Promise<GitLabCommentResult>
|
||||
listLabels: (args: { repoPath: string }) => Promise<string[]>
|
||||
listAssignableUsers: (args: { repoPath: string }) => Promise<GitLabAssignableUser[]>
|
||||
listWorkItems: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: MRListState
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
) => Promise<ListMergeRequestsResult>
|
||||
issue: (args: GitLabRepoSelectorArgs & { number: number }) => Promise<GitLabIssueInfo | null>
|
||||
listIssues: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
}
|
||||
) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
|
||||
createIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
|
||||
updateIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
number: number
|
||||
updates: GitLabIssueUpdate
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
addIssueComment: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
number: number
|
||||
body: string
|
||||
}
|
||||
) => Promise<GitLabCommentResult>
|
||||
listLabels: (args: GitLabRepoSelectorArgs) => Promise<string[]>
|
||||
listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise<GitLabAssignableUser[]>
|
||||
/** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */
|
||||
todos: (args: { repoPath: string }) => Promise<GitLabTodo[]>
|
||||
todos: (args: GitLabRepoSelectorArgs) => Promise<GitLabTodo[]>
|
||||
/** Aggregated dialog payload — body + discussions + pipeline jobs. */
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}) => Promise<GitLabWorkItemDetails | null>
|
||||
closeMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
reopenMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
mergeMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
updates: GitLabMRUpdate
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateMRReviewers: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
reviewerIds: number[]
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}) => Promise<GitLabMRReviewersUpdateResult>
|
||||
addMRComment: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
body: string
|
||||
}) => Promise<GitLabCommentResult>
|
||||
addMRInlineComment: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
input: GitLabMRInlineCommentInput
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}) => Promise<GitLabCommentResult>
|
||||
resolveMRDiscussion: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
discussionId: string
|
||||
resolved: boolean
|
||||
}) => Promise<GitLabDiscussionResolveResult>
|
||||
jobTrace: (args: {
|
||||
repoPath: string
|
||||
jobId: number
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}) => Promise<GitLabJobTraceResult>
|
||||
retryJob: (args: {
|
||||
repoPath: string
|
||||
jobId: number
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}) => Promise<GitLabRetryJobResult>
|
||||
workItemByPath: (args: {
|
||||
repoPath: string
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}) => Promise<Omit<GitLabWorkItem, 'repoId'> | null>
|
||||
workItemDetails: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
) => Promise<GitLabWorkItemDetails | null>
|
||||
closeMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
reopenMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
mergeMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
updates: GitLabMRUpdate
|
||||
}
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
updateMRReviewers: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
reviewerIds: number[]
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}
|
||||
) => Promise<GitLabMRReviewersUpdateResult>
|
||||
addMRComment: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
body: string
|
||||
}
|
||||
) => Promise<GitLabCommentResult>
|
||||
addMRInlineComment: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
input: GitLabMRInlineCommentInput
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}
|
||||
) => Promise<GitLabCommentResult>
|
||||
resolveMRDiscussion: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
discussionId: string
|
||||
resolved: boolean
|
||||
}
|
||||
) => Promise<GitLabDiscussionResolveResult>
|
||||
jobTrace: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
jobId: number
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}
|
||||
) => Promise<GitLabJobTraceResult>
|
||||
retryJob: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
jobId: number
|
||||
projectRef?: GitLabProjectRef | null
|
||||
}
|
||||
) => Promise<GitLabRetryJobResult>
|
||||
workItemByPath: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
) => Promise<Omit<GitLabWorkItem, 'repoId'> | null>
|
||||
}
|
||||
linear: {
|
||||
connect: (args: {
|
||||
@@ -1627,8 +1701,6 @@ export type PreloadApi = {
|
||||
dismiss: () => Promise<void>
|
||||
complete: () => Promise<void>
|
||||
disable: () => Promise<void>
|
||||
openWeb: () => Promise<void>
|
||||
starOrca: () => Promise<boolean>
|
||||
forceShow: () => Promise<void>
|
||||
}
|
||||
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
|
||||
@@ -1841,11 +1913,13 @@ export type PreloadApi = {
|
||||
}) => Promise<void>
|
||||
}
|
||||
session: {
|
||||
get: () => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState) => Promise<void>
|
||||
patch: (args: WorkspaceSessionPatch) => Promise<void>
|
||||
// hostId is optional and defaults to the 'local' partition on the main
|
||||
// side, so existing callers that omit it behave exactly as before.
|
||||
get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise<void>
|
||||
patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise<void>
|
||||
readTerminalScrollback: (args: { ref: string }) => string | null
|
||||
setSync: (args: WorkspaceSessionState) => void
|
||||
setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void
|
||||
}
|
||||
remoteWorkspace: {
|
||||
get: (args: { targetId: string }) => Promise<RemoteWorkspaceSnapshot | null>
|
||||
@@ -2402,6 +2476,9 @@ export type PreloadApi = {
|
||||
}) => Promise<{ environment: PublicKnownRuntimeEnvironment }>
|
||||
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
|
||||
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
|
||||
disconnect: (args: {
|
||||
selector: string
|
||||
}) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }>
|
||||
getStatus: (args: {
|
||||
selector: string
|
||||
timeoutMs?: number
|
||||
|
||||
+119
-94
@@ -3,6 +3,13 @@
|
||||
conflict on every upstream sync of the much larger central preload
|
||||
file. Composed back into `api.gl` from `index.ts`. */
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { TaskSourceContext } from '../shared/task-source-context'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export const glApi = {
|
||||
viewer: (): Promise<unknown> => ipcRenderer.invoke('gitlab:viewer'),
|
||||
@@ -10,136 +17,154 @@ export const glApi = {
|
||||
rateLimit: (args?: { force?: boolean; host?: string | null }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:rateLimit', args),
|
||||
|
||||
projectSlug: (args: { repoPath: string }): Promise<unknown> =>
|
||||
projectSlug: (args: GitLabRepoSelectorArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:projectSlug', args),
|
||||
|
||||
mrForBranch: (args: {
|
||||
repoPath: string
|
||||
branch: string
|
||||
linkedMRIid?: number | null
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args),
|
||||
mrForBranch: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
branch: string
|
||||
linkedMRIid?: number | null
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args),
|
||||
|
||||
mr: (args: { repoPath: string; iid: number }): Promise<unknown> =>
|
||||
mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:mr', args),
|
||||
|
||||
listMRs: (args: {
|
||||
repoPath: string
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args),
|
||||
listMRs: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args),
|
||||
|
||||
listWorkItems: (args: {
|
||||
repoPath: string
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args),
|
||||
listWorkItems: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: 'opened' | 'merged' | 'closed' | 'all'
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args),
|
||||
|
||||
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
|
||||
issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:issue', args),
|
||||
|
||||
listIssues: (args: {
|
||||
repoPath: string
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
}): Promise<{ items: unknown[]; error?: unknown }> =>
|
||||
listIssues: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
}
|
||||
): Promise<{ items: unknown[]; error?: unknown }> =>
|
||||
ipcRenderer.invoke('gitlab:listIssues', args),
|
||||
|
||||
createIssue: (args: {
|
||||
repoPath: string
|
||||
title: string
|
||||
body: string
|
||||
}): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> =>
|
||||
createIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:createIssue', args),
|
||||
|
||||
updateIssue: (args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
updates: unknown
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
updateIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
number: number
|
||||
updates: unknown
|
||||
}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:updateIssue', args),
|
||||
|
||||
addIssueComment: (args: { repoPath: string; number: number; body: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:addIssueComment', args),
|
||||
addIssueComment: (
|
||||
args: GitLabRepoSelectorArgs & { number: number; body: string }
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:addIssueComment', args),
|
||||
|
||||
listLabels: (args: { repoPath: string }): Promise<string[]> =>
|
||||
listLabels: (args: GitLabRepoSelectorArgs): Promise<string[]> =>
|
||||
ipcRenderer.invoke('gitlab:listLabels', args),
|
||||
|
||||
listAssignableUsers: (args: { repoPath: string }): Promise<unknown[]> =>
|
||||
listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('gitlab:listAssignableUsers', args),
|
||||
|
||||
todos: (args: { repoPath: string }): Promise<unknown[]> =>
|
||||
todos: (args: GitLabRepoSelectorArgs): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('gitlab:todos', args),
|
||||
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args),
|
||||
workItemDetails: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args),
|
||||
|
||||
closeMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
closeMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:closeMR', args),
|
||||
|
||||
reopenMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
reopenMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:reopenMR', args),
|
||||
|
||||
mergeMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
mergeMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:mergeMR', args),
|
||||
|
||||
updateMR: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
updates: unknown
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
updateMR: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
updates: unknown
|
||||
}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('gitlab:updateMR', args),
|
||||
|
||||
updateMRReviewers: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
reviewerIds: number[]
|
||||
projectRef?: unknown
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args),
|
||||
updateMRReviewers: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
reviewerIds: number[]
|
||||
projectRef?: unknown
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args),
|
||||
|
||||
addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise<unknown> =>
|
||||
addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:addMRComment', args),
|
||||
|
||||
addMRInlineComment: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
input: unknown
|
||||
projectRef?: unknown
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args),
|
||||
addMRInlineComment: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
input: unknown
|
||||
projectRef?: unknown
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args),
|
||||
|
||||
resolveMRDiscussion: (args: {
|
||||
repoPath: string
|
||||
iid: number
|
||||
discussionId: string
|
||||
resolved: boolean
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args),
|
||||
resolveMRDiscussion: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
iid: number
|
||||
discussionId: string
|
||||
resolved: boolean
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args),
|
||||
|
||||
jobTrace: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:jobTrace', args),
|
||||
jobTrace: (
|
||||
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:jobTrace', args),
|
||||
|
||||
retryJob: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gitlab:retryJob', args),
|
||||
retryJob: (
|
||||
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:retryJob', args),
|
||||
|
||||
workItemByPath: (args: {
|
||||
repoPath: string
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args)
|
||||
workItemByPath: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
host: string
|
||||
path: string
|
||||
iid: number
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args)
|
||||
}
|
||||
|
||||
+57
-11
@@ -70,6 +70,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limi
|
||||
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
|
||||
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type { TaskSourceContext } from '../shared/task-source-context'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
ClearProjectItemFieldArgs,
|
||||
@@ -478,6 +479,10 @@ const api = {
|
||||
|
||||
clone: (args) => ipcRenderer.invoke('repos:clone', args),
|
||||
|
||||
cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args),
|
||||
|
||||
createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args),
|
||||
|
||||
cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'),
|
||||
|
||||
onCloneProgress: (
|
||||
@@ -513,6 +518,16 @@ const api = {
|
||||
}
|
||||
} satisfies PreloadApi['repos'],
|
||||
|
||||
projects: {
|
||||
list: () => ipcRenderer.invoke('projects:list'),
|
||||
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'),
|
||||
createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args),
|
||||
setupExistingFolder: (args) =>
|
||||
ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args),
|
||||
updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args),
|
||||
deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args)
|
||||
} satisfies PreloadApi['projects'],
|
||||
|
||||
projectGroups: {
|
||||
list: () => ipcRenderer.invoke('projectGroups:list'),
|
||||
create: (args) => ipcRenderer.invoke('projectGroups:create', args),
|
||||
@@ -925,12 +940,17 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener)
|
||||
},
|
||||
|
||||
issue: (args: { repoPath: string; repoId?: string; number: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:issue', args),
|
||||
issue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:issue', args),
|
||||
|
||||
workItem: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args),
|
||||
@@ -947,6 +967,7 @@ const api = {
|
||||
workItemDetails: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
type?: 'issue' | 'pr'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args),
|
||||
@@ -954,6 +975,7 @@ const api = {
|
||||
prFileContents: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
path: string
|
||||
oldPath?: string
|
||||
@@ -968,6 +990,7 @@ const api = {
|
||||
createIssue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
title: string
|
||||
body: string
|
||||
labels?: string[]
|
||||
@@ -994,6 +1017,7 @@ const api = {
|
||||
prChecks: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
prRepo?: { owner: string; repo: string } | null
|
||||
@@ -1003,6 +1027,7 @@ const api = {
|
||||
prCheckDetails: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
checkName?: string
|
||||
@@ -1013,6 +1038,7 @@ const api = {
|
||||
rerunPRChecks: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
failedOnly?: boolean
|
||||
@@ -1022,6 +1048,7 @@ const api = {
|
||||
prComments: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
prRepo?: { owner: string; repo: string } | null
|
||||
noCache?: boolean
|
||||
@@ -1030,6 +1057,7 @@ const api = {
|
||||
resolveReviewThread: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
threadId: string
|
||||
resolve: boolean
|
||||
}): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args),
|
||||
@@ -1037,6 +1065,7 @@ const api = {
|
||||
setPRFileViewed: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
pullRequestId: string
|
||||
path: string
|
||||
@@ -1054,6 +1083,7 @@ const api = {
|
||||
mergePR: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
prRepo?: { owner: string; repo: string } | null
|
||||
@@ -1063,6 +1093,7 @@ const api = {
|
||||
setPRAutoMerge: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
prRepo?: { owner: string; repo: string } | null
|
||||
@@ -1072,6 +1103,7 @@ const api = {
|
||||
updatePRState: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
updates: { state: 'open' | 'closed' }
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
@@ -1080,6 +1112,7 @@ const api = {
|
||||
requestPRReviewers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
@@ -1088,6 +1121,7 @@ const api = {
|
||||
removePRReviewers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
@@ -1096,6 +1130,7 @@ const api = {
|
||||
updateIssue: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
updates: unknown
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> =>
|
||||
@@ -1104,6 +1139,7 @@ const api = {
|
||||
addIssueComment: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
number: number
|
||||
body: string
|
||||
type?: 'issue' | 'pr'
|
||||
@@ -1113,6 +1149,7 @@ const api = {
|
||||
addPRReviewCommentReply: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
commentId: number
|
||||
body: string
|
||||
@@ -1125,6 +1162,7 @@ const api = {
|
||||
addPRReviewComment: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
prNumber: number
|
||||
commitId: string
|
||||
path: string
|
||||
@@ -1133,12 +1171,16 @@ const api = {
|
||||
body: string
|
||||
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewComment', args),
|
||||
|
||||
listLabels: (args: { repoPath: string; repoId?: string }): Promise<string[]> =>
|
||||
ipcRenderer.invoke('gh:listLabels', args),
|
||||
listLabels: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): Promise<string[]> => ipcRenderer.invoke('gh:listLabels', args),
|
||||
|
||||
listAssignableUsers: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): Promise<GitHubAssignableUser[]> => ipcRenderer.invoke('gh:listAssignableUsers', args),
|
||||
|
||||
// Why: every renderer subscribes to local mutation broadcasts so each
|
||||
@@ -1475,8 +1517,6 @@ const api = {
|
||||
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
|
||||
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
|
||||
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
|
||||
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
|
||||
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
|
||||
},
|
||||
|
||||
@@ -2222,14 +2262,16 @@ const api = {
|
||||
} satisfies PreloadApi['cache'],
|
||||
|
||||
session: {
|
||||
get: () => ipcRenderer.invoke('session:get'),
|
||||
set: (args) => ipcRenderer.invoke('session:set', args),
|
||||
patch: (args) => ipcRenderer.invoke('session:patch', args),
|
||||
// hostId is optional and defaults to 'local' on the main side, so existing
|
||||
// call sites that omit it keep targeting the local session partition.
|
||||
get: (hostId) => ipcRenderer.invoke('session:get', hostId),
|
||||
set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId),
|
||||
patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId),
|
||||
readTerminalScrollback: (args) =>
|
||||
ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args),
|
||||
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
|
||||
setSync: (args) => {
|
||||
ipcRenderer.sendSync('session:set-sync', args)
|
||||
setSync: (args, hostId) => {
|
||||
ipcRenderer.sendSync('session:set-sync', args, hostId)
|
||||
}
|
||||
} satisfies PreloadApi['session'],
|
||||
|
||||
@@ -3333,6 +3375,10 @@ const api = {
|
||||
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
|
||||
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:remove', args),
|
||||
disconnect: (args: {
|
||||
selector: string
|
||||
}): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:disconnect', args),
|
||||
getStatus: (args: {
|
||||
selector: string
|
||||
timeoutMs?: number
|
||||
|
||||
@@ -54,7 +54,6 @@ describe('validateGitExecArgs', () => {
|
||||
it.each([
|
||||
'push',
|
||||
'pull',
|
||||
'commit',
|
||||
'checkout',
|
||||
'reset',
|
||||
'rebase',
|
||||
@@ -207,4 +206,41 @@ describe('validateGitExecArgs', () => {
|
||||
expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('git clone', () => {
|
||||
it('allows only the project setup clone shape', () => {
|
||||
expectAllowed(['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'])
|
||||
expectAllowed(['clone', '--progress', '--', 'git@github.com:stablyai/orca.git', 'orca'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['clone', 'https://github.com/stablyai/orca.git']],
|
||||
[['clone', 'https://github.com/stablyai/orca.git', 'orca']],
|
||||
[['clone', '--depth=1', '--', 'https://github.com/stablyai/orca.git', 'orca']],
|
||||
[['clone', '--', 'https://github.com/stablyai/orca.git', '.']],
|
||||
[['clone', '--', 'https://github.com/stablyai/orca.git', '..']],
|
||||
[['clone', '--', 'https://github.com/stablyai/orca.git', 'nested/orca']],
|
||||
[['clone', '--', 'https://github.com/stablyai/orca.git', 'nested\\orca']]
|
||||
])('rejects unsafe clone args %j', (args) => {
|
||||
expectBlocked(args, 'git clone')
|
||||
})
|
||||
})
|
||||
|
||||
describe('git init and empty commit', () => {
|
||||
it('allows only the SSH create-project init and empty commit shapes', () => {
|
||||
expectAllowed(['init'])
|
||||
expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['init', '--bare']],
|
||||
[['init', '/tmp/other']],
|
||||
[['commit']],
|
||||
[['commit', '-am', 'message']],
|
||||
[['commit', '--allow-empty']],
|
||||
[['commit', '--allow-empty', '-m', '']]
|
||||
])('rejects unsafe create-project write args %j', (args) => {
|
||||
expectBlocked(args, 'via exec is restricted')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* Extracted from git-handler-ops.ts to keep both files under the limit.
|
||||
*/
|
||||
|
||||
// Why: only read-only git subcommands are allowed via exec. config is restricted
|
||||
// to read-only flags; branch rejects destructive flags; fetch/worktree removed.
|
||||
// Why: only read-only git subcommands are allowed via exec, except for the
|
||||
// exact init/empty-commit shapes used by SSH Create Project after the parent
|
||||
// directory has already been validated by main.
|
||||
const ALLOWED_GIT_SUBCOMMANDS = new Set([
|
||||
'rev-parse',
|
||||
'branch',
|
||||
@@ -18,6 +19,9 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([
|
||||
'merge-base',
|
||||
'diff',
|
||||
'ls-files',
|
||||
'clone',
|
||||
'init',
|
||||
'commit',
|
||||
'for-each-ref',
|
||||
'check-ref-format',
|
||||
'config'
|
||||
@@ -81,6 +85,38 @@ const DIFF_ALLOWED_FLAGS = new Set([
|
||||
'--no-ext-diff'
|
||||
])
|
||||
|
||||
function validateCloneArgs(args: string[]): void {
|
||||
// Why: project-host setup needs remote clone, but git.exec must not become a
|
||||
// general write surface. Permit only `git clone [--progress] -- <url> <dir>`.
|
||||
const allowed = args[1] === '--progress' ? args.slice(2) : args.slice(1)
|
||||
if (allowed.length !== 3 || allowed[0] !== '--') {
|
||||
throw new Error('git clone via exec is restricted to clone [--progress] -- <url> <dir>')
|
||||
}
|
||||
const targetDir = allowed[2]
|
||||
if (
|
||||
!targetDir ||
|
||||
targetDir === '.' ||
|
||||
targetDir === '..' ||
|
||||
targetDir.includes('/') ||
|
||||
targetDir.includes('\\') ||
|
||||
targetDir.includes('\0')
|
||||
) {
|
||||
throw new Error('git clone target directory must be a single safe path segment')
|
||||
}
|
||||
}
|
||||
|
||||
function validateInitArgs(args: string[]): void {
|
||||
if (args.length !== 1) {
|
||||
throw new Error('git init via exec is restricted to init with no arguments')
|
||||
}
|
||||
}
|
||||
|
||||
function validateCommitArgs(args: string[]): void {
|
||||
if (args.length !== 4 || args[1] !== '--allow-empty' || args[2] !== '-m' || !args[3]) {
|
||||
throw new Error('git commit via exec is restricted to commit --allow-empty -m <message>')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd),
|
||||
// which bypasses exact-match Set.has() checks. This helper catches both forms.
|
||||
function matchesDeniedFlag(arg: string, denySet: Set<string>): boolean {
|
||||
@@ -124,6 +160,12 @@ export function validateGitExecArgs(args: string[]): void {
|
||||
throw new Error('git config write operations are not allowed via exec')
|
||||
}
|
||||
}
|
||||
if (subcommand === 'init') {
|
||||
validateInitArgs(args)
|
||||
}
|
||||
if (subcommand === 'commit') {
|
||||
validateCommitArgs(args)
|
||||
}
|
||||
if (subcommand === 'branch') {
|
||||
if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) {
|
||||
throw new Error('Destructive git branch flags are not allowed via exec')
|
||||
@@ -156,4 +198,7 @@ export function validateGitExecArgs(args: string[]): void {
|
||||
throw new Error(`git diff flag not allowed via exec: ${unsupportedArg}`)
|
||||
}
|
||||
}
|
||||
if (subcommand === 'clone') {
|
||||
validateCloneArgs(args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('GitHandler', () => {
|
||||
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
|
||||
expect(methods).toContain('git.renameCurrentBranch')
|
||||
expect(methods).toContain('git.exec')
|
||||
expect(methods).toContain('git.clone')
|
||||
expect(methods).toContain('git.isGitRepo')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC
|
||||
protocol surface so local and SSH git behavior stay in one dispatch table. */
|
||||
import { execFile } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as path from 'path'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import type { RelayContext } from './context'
|
||||
import { expandTilde } from './context'
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
removeSafeUntrackedDiscardTarget,
|
||||
removeSafeUntrackedDiscardTargets
|
||||
} from '../shared/git-discard-path-safety'
|
||||
import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MAX_GIT_BUFFER = 10 * 1024 * 1024
|
||||
@@ -93,14 +94,15 @@ export class GitHandler {
|
||||
this.refreshLocalBaseRefForWorktreeCreate(p)
|
||||
)
|
||||
this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p))
|
||||
this.dispatcher.onRequest('git.exec', (p) => this.exec(p))
|
||||
this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context))
|
||||
this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context))
|
||||
this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p))
|
||||
}
|
||||
|
||||
private async git(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean }
|
||||
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; signal?: AbortSignal }
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const env = buildRelayCommandEnv()
|
||||
if (opts?.disableOptionalLocks) {
|
||||
@@ -110,7 +112,8 @@ export class GitHandler {
|
||||
cwd: expandTilde(cwd),
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER
|
||||
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER,
|
||||
signal: opts?.signal
|
||||
})
|
||||
}
|
||||
|
||||
@@ -585,15 +588,95 @@ export class GitHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private async exec(params: Record<string, unknown>) {
|
||||
private async exec(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const args = params.args as string[]
|
||||
const cwd = params.cwd as string
|
||||
|
||||
validateGitExecArgs(args)
|
||||
const { stdout, stderr } = await this.git(args, cwd)
|
||||
const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal })
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
private async clone(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const args = params.args as string[]
|
||||
const cwd = params.cwd as string
|
||||
const progressId = params.progressId
|
||||
validateGitExecArgs(args)
|
||||
if (typeof progressId !== 'string' || progressId.length === 0) {
|
||||
throw new Error('Missing clone progress id.')
|
||||
}
|
||||
if (args[0] !== 'clone') {
|
||||
throw new Error('git.clone only supports clone commands.')
|
||||
}
|
||||
return await this.spawnClone(args, cwd, progressId, context)
|
||||
}
|
||||
|
||||
private async spawnClone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
progressId: string,
|
||||
context?: RequestContext
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd: expandTilde(cwd),
|
||||
env: buildRelayCommandEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
context?.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
child.kill()
|
||||
}
|
||||
context?.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout = (stdout + chunk.toString('utf-8')).slice(-4096)
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf-8')
|
||||
stderr = (stderr + text).slice(-4096)
|
||||
for (const line of text.split(/[\r\n]+/)) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match) {
|
||||
this.dispatcher.notify('git.cloneProgress', {
|
||||
progressId,
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (context?.signal?.aborted) {
|
||||
reject(new Error('Clone aborted'))
|
||||
return
|
||||
}
|
||||
if (code === 0 && !signal) {
|
||||
resolve({ stdout, stderr })
|
||||
return
|
||||
}
|
||||
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async renameCurrentBranch(params: Record<string, unknown>) {
|
||||
const worktreePath = params.worktreePath
|
||||
const newBranch = params.newBranch
|
||||
|
||||
@@ -92,6 +92,11 @@ import {
|
||||
shouldPersistWorkspaceSession
|
||||
} from './lib/workspace-session'
|
||||
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
|
||||
import {
|
||||
fetchWorkspaceSessionFromHosts,
|
||||
patchWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHostSync
|
||||
} from './lib/workspace-session-host-persistence'
|
||||
import {
|
||||
getStartupErrorFallbackUI,
|
||||
hydratePersistedUIAfterStartupRead
|
||||
@@ -759,7 +764,14 @@ function App(): React.JSX.Element {
|
||||
cancelled,
|
||||
hydratePersistedUI: actions.hydratePersistedUI
|
||||
})
|
||||
const session = await window.api.session.get()
|
||||
// Why: runtime-owned worktree slices live in per-host partitions.
|
||||
// Repos were fetched above, so the known runtime hosts are derivable
|
||||
// here; merge their slices into the unified session the hydrators
|
||||
// expect. An unreadable host partition is skipped (fail-soft).
|
||||
const session = await fetchWorkspaceSessionFromHosts(
|
||||
window.api.session,
|
||||
useAppStore.getState().repos
|
||||
)
|
||||
await actions.fetchKeybindings()
|
||||
if (!cancelled) {
|
||||
actions.hydrateWorkspaceSession(session)
|
||||
@@ -1051,9 +1063,12 @@ function App(): React.JSX.Element {
|
||||
store: useAppStore,
|
||||
shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(),
|
||||
persist: ({ patch }) => {
|
||||
const localWrite = window.api.session.patch(patch)
|
||||
void localWrite
|
||||
const state = useAppStore.getState()
|
||||
// Why: route each runtime host's worktree-scoped slice to its own
|
||||
// partition; the returned promise is the local write so the
|
||||
// remote-workspace upload chain below keeps its ordering.
|
||||
const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state)
|
||||
void localWrite
|
||||
const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter(
|
||||
(targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict'
|
||||
)
|
||||
@@ -1113,7 +1128,11 @@ function App(): React.JSX.Element {
|
||||
// into the store via Zustand setters. The earlier read is only for the
|
||||
// gating flags and would miss those updates.
|
||||
const freshState = useAppStore.getState()
|
||||
window.api.session.setSync(buildWorkspaceSessionPayload(freshState))
|
||||
persistWorkspaceSessionByHostSync(
|
||||
window.api.session,
|
||||
buildWorkspaceSessionPayload(freshState),
|
||||
freshState
|
||||
)
|
||||
shutdownBuffersCaptured = true
|
||||
}
|
||||
window.addEventListener('beforeunload', captureAndFlush)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@
|
||||
close/reopen, merge, and a top-level comment composer. Files /
|
||||
inline review-comment positioning / approvals are deferred to v1.5
|
||||
since they mirror substantial GitHub-side surface area. */
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
CircleDot,
|
||||
@@ -40,15 +40,24 @@ import type {
|
||||
GitLabWorkItemDetails,
|
||||
MRComment
|
||||
} from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
item: GitLabWorkItem | null
|
||||
repoPath: string | null
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
onClose: () => void
|
||||
onCreateWorkspace?: (item: GitLabWorkItem) => void
|
||||
}
|
||||
|
||||
type GitLabDialogRepoSelector = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type JobTraceState = {
|
||||
loading: boolean
|
||||
trace?: string
|
||||
@@ -311,6 +320,8 @@ function PipelineJobRow({
|
||||
export default function GitLabItemDialog({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
sourceContext,
|
||||
onClose,
|
||||
onCreateWorkspace
|
||||
}: Props): React.JSX.Element {
|
||||
@@ -351,6 +362,16 @@ export default function GitLabItemDialog({
|
||||
const [retryingJobId, setRetryingJobId] = useState<number | null>(null)
|
||||
const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
const repoSelector = useMemo<GitLabDialogRepoSelector | null>(() => {
|
||||
if (!repoPath) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
repoPath,
|
||||
...(repoId ? { repoId } : {}),
|
||||
...(sourceContext ? { sourceContext } : {})
|
||||
}
|
||||
}, [repoId, repoPath, sourceContext])
|
||||
const updateCommentDraft = useCallback(
|
||||
(value: string): void => {
|
||||
setCommentDraftState({ itemId, value })
|
||||
@@ -359,7 +380,7 @@ export default function GitLabItemDialog({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || !repoPath) {
|
||||
if (!item || !repoSelector) {
|
||||
setDetails(null)
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
@@ -370,7 +391,7 @@ export default function GitLabItemDialog({
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
void window.api.gl
|
||||
.workItemDetails({ repoPath, iid: item.number, type: item.type })
|
||||
.workItemDetails({ ...repoSelector, iid: item.number, type: item.type })
|
||||
.then((data) => {
|
||||
if (stale) {
|
||||
return
|
||||
@@ -394,7 +415,7 @@ export default function GitLabItemDialog({
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [item, repoPath, refreshNonce])
|
||||
}, [item, repoSelector, refreshNonce])
|
||||
|
||||
// Why: clear item-scoped dialog state when the sheet target changes. The
|
||||
// top-level comment draft is reconciled during render so it cannot flash stale.
|
||||
@@ -423,12 +444,12 @@ export default function GitLabItemDialog({
|
||||
}, [])
|
||||
|
||||
const loadGitLabLabelOptions = useCallback(async (): Promise<void> => {
|
||||
if (!repoPath || labelOptions !== null || labelOptionsLoading) {
|
||||
if (!repoSelector || labelOptions !== null || labelOptionsLoading) {
|
||||
return
|
||||
}
|
||||
setLabelOptionsLoading(true)
|
||||
try {
|
||||
const labels = await window.api.gl.listLabels({ repoPath })
|
||||
const labels = await window.api.gl.listLabels(repoSelector)
|
||||
if (mountedRef.current) {
|
||||
setLabelOptions(normalizeGitLabLabels(labels))
|
||||
}
|
||||
@@ -441,15 +462,15 @@ export default function GitLabItemDialog({
|
||||
setLabelOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [labelOptions, labelOptionsLoading, mountedRef, repoPath])
|
||||
}, [labelOptions, labelOptionsLoading, mountedRef, repoSelector])
|
||||
|
||||
const loadGitLabReviewerOptions = useCallback(async (): Promise<void> => {
|
||||
if (!repoPath || reviewerOptions !== null || reviewerOptionsLoading) {
|
||||
if (!repoSelector || reviewerOptions !== null || reviewerOptionsLoading) {
|
||||
return
|
||||
}
|
||||
setReviewerOptionsLoading(true)
|
||||
try {
|
||||
const users = await window.api.gl.listAssignableUsers({ repoPath })
|
||||
const users = await window.api.gl.listAssignableUsers(repoSelector)
|
||||
if (mountedRef.current) {
|
||||
setReviewerOptions(dedupeGitLabUsers(users))
|
||||
}
|
||||
@@ -462,7 +483,7 @@ export default function GitLabItemDialog({
|
||||
setReviewerOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef, repoPath, reviewerOptions, reviewerOptionsLoading])
|
||||
}, [mountedRef, repoSelector, reviewerOptions, reviewerOptionsLoading])
|
||||
|
||||
const handleStartDetailsEdit = useCallback((): void => {
|
||||
if (!item || !details || item.type !== 'mr') {
|
||||
@@ -483,7 +504,7 @@ export default function GitLabItemDialog({
|
||||
}, [])
|
||||
|
||||
const handleSaveDetails = useCallback(async (): Promise<void> => {
|
||||
if (!item || !details || !repoPath || item.type !== 'mr') {
|
||||
if (!item || !details || !repoSelector || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
const currentTitle = details.item.title || item.title
|
||||
@@ -521,7 +542,7 @@ export default function GitLabItemDialog({
|
||||
|
||||
setDetailsSaving(true)
|
||||
try {
|
||||
const res = await window.api.gl.updateMR({ repoPath, iid: item.number, updates })
|
||||
const res = await window.api.gl.updateMR({ ...repoSelector, iid: item.number, updates })
|
||||
if (res.ok) {
|
||||
if (mountedRef.current) {
|
||||
setDetails((current) =>
|
||||
@@ -557,7 +578,7 @@ export default function GitLabItemDialog({
|
||||
item,
|
||||
labelDraft,
|
||||
mountedRef,
|
||||
repoPath,
|
||||
repoSelector,
|
||||
titleDraft
|
||||
])
|
||||
|
||||
@@ -568,7 +589,7 @@ export default function GitLabItemDialog({
|
||||
return
|
||||
}
|
||||
setExpandedJobId(job.id)
|
||||
if (!repoPath || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) {
|
||||
if (!repoSelector || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) {
|
||||
return
|
||||
}
|
||||
setJobTraceById((current) => ({
|
||||
@@ -577,7 +598,7 @@ export default function GitLabItemDialog({
|
||||
}))
|
||||
try {
|
||||
const result = await window.api.gl.jobTrace({
|
||||
repoPath,
|
||||
...repoSelector,
|
||||
jobId: job.id,
|
||||
projectRef: details?.item.projectRef ?? item.projectRef ?? null
|
||||
})
|
||||
@@ -602,18 +623,18 @@ export default function GitLabItemDialog({
|
||||
}
|
||||
}
|
||||
},
|
||||
[details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoPath]
|
||||
[details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoSelector]
|
||||
)
|
||||
|
||||
const handleRetryJob = useCallback(
|
||||
async (job: GitLabPipelineJob): Promise<void> => {
|
||||
if (!repoPath || !item) {
|
||||
if (!repoSelector || !item) {
|
||||
return
|
||||
}
|
||||
setRetryingJobId(job.id)
|
||||
try {
|
||||
const result = await window.api.gl.retryJob({
|
||||
repoPath,
|
||||
...repoSelector,
|
||||
jobId: job.id,
|
||||
projectRef: details?.item.projectRef ?? item.projectRef ?? null
|
||||
})
|
||||
@@ -648,12 +669,12 @@ export default function GitLabItemDialog({
|
||||
}
|
||||
}
|
||||
},
|
||||
[details?.item.projectRef, handleRefresh, item, mountedRef, repoPath]
|
||||
[details?.item.projectRef, handleRefresh, item, mountedRef, repoSelector]
|
||||
)
|
||||
|
||||
const handleSetReviewers = useCallback(
|
||||
async (nextReviewers: GitLabAssignableUser[]): Promise<void> => {
|
||||
if (!repoPath || !item || !details || item.type !== 'mr') {
|
||||
if (!repoSelector || !item || !details || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
const reviewerIds = nextReviewers
|
||||
@@ -671,7 +692,7 @@ export default function GitLabItemDialog({
|
||||
setReviewerUpdating(true)
|
||||
try {
|
||||
const result = await window.api.gl.updateMRReviewers({
|
||||
repoPath,
|
||||
...repoSelector,
|
||||
iid: item.number,
|
||||
reviewerIds,
|
||||
projectRef: details.item.projectRef ?? item.projectRef ?? null
|
||||
@@ -697,11 +718,11 @@ export default function GitLabItemDialog({
|
||||
}
|
||||
}
|
||||
},
|
||||
[details, item, mountedRef, repoPath]
|
||||
[details, item, mountedRef, repoSelector]
|
||||
)
|
||||
|
||||
const handleSubmitInlineComment = useCallback(async (): Promise<void> => {
|
||||
if (!repoPath || !item || !details || item.type !== 'mr') {
|
||||
if (!repoSelector || !item || !details || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
const file = (details.files ?? []).find((row) => row.path === inlineCommentFilePath)
|
||||
@@ -728,7 +749,7 @@ export default function GitLabItemDialog({
|
||||
setInlineCommentSubmitting(true)
|
||||
try {
|
||||
const result = await window.api.gl.addMRInlineComment({
|
||||
repoPath,
|
||||
...repoSelector,
|
||||
iid: item.number,
|
||||
projectRef: details.item.projectRef ?? item.projectRef ?? null,
|
||||
input: {
|
||||
@@ -768,16 +789,16 @@ export default function GitLabItemDialog({
|
||||
inlineCommentLine,
|
||||
item,
|
||||
mountedRef,
|
||||
repoPath
|
||||
repoSelector
|
||||
])
|
||||
|
||||
const handleClose = useCallback(async (): Promise<void> => {
|
||||
if (!item || !repoPath || item.type !== 'mr') {
|
||||
if (!item || !repoSelector || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
setActionInFlight('close')
|
||||
try {
|
||||
const res = await window.api.gl.closeMR({ repoPath, iid: item.number })
|
||||
const res = await window.api.gl.closeMR({ ...repoSelector, iid: item.number })
|
||||
if (res.ok) {
|
||||
if (mountedRef.current) {
|
||||
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
|
||||
@@ -798,15 +819,15 @@ export default function GitLabItemDialog({
|
||||
setActionInFlight(null)
|
||||
}
|
||||
}
|
||||
}, [item, repoPath, mountedRef, handleRefresh])
|
||||
}, [item, repoSelector, mountedRef, handleRefresh])
|
||||
|
||||
const handleReopen = useCallback(async (): Promise<void> => {
|
||||
if (!item || !repoPath || item.type !== 'mr') {
|
||||
if (!item || !repoSelector || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
setActionInFlight('reopen')
|
||||
try {
|
||||
const res = await window.api.gl.reopenMR({ repoPath, iid: item.number })
|
||||
const res = await window.api.gl.reopenMR({ ...repoSelector, iid: item.number })
|
||||
if (res.ok) {
|
||||
if (mountedRef.current) {
|
||||
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
|
||||
@@ -827,15 +848,15 @@ export default function GitLabItemDialog({
|
||||
setActionInFlight(null)
|
||||
}
|
||||
}
|
||||
}, [item, repoPath, mountedRef, handleRefresh])
|
||||
}, [item, repoSelector, mountedRef, handleRefresh])
|
||||
|
||||
const handleMerge = useCallback(async (): Promise<void> => {
|
||||
if (!item || !repoPath || item.type !== 'mr') {
|
||||
if (!item || !repoSelector || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
setActionInFlight('merge')
|
||||
try {
|
||||
const res = await window.api.gl.mergeMR({ repoPath, iid: item.number })
|
||||
const res = await window.api.gl.mergeMR({ ...repoSelector, iid: item.number })
|
||||
if (res.ok) {
|
||||
if (mountedRef.current) {
|
||||
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
|
||||
@@ -856,11 +877,11 @@ export default function GitLabItemDialog({
|
||||
setActionInFlight(null)
|
||||
}
|
||||
}
|
||||
}, [item, repoPath, mountedRef, handleRefresh])
|
||||
}, [item, repoSelector, mountedRef, handleRefresh])
|
||||
|
||||
const handleSubmitComment = useCallback(async (): Promise<void> => {
|
||||
const body = commentDraft.trim()
|
||||
if (!body || !item || !repoPath) {
|
||||
if (!body || !item || !repoSelector) {
|
||||
return
|
||||
}
|
||||
setCommentSubmitting(true)
|
||||
@@ -869,8 +890,8 @@ export default function GitLabItemDialog({
|
||||
// Branch on the item type to hit the right channel.
|
||||
const res =
|
||||
item.type === 'mr'
|
||||
? await window.api.gl.addMRComment({ repoPath, iid: item.number, body })
|
||||
: await window.api.gl.addIssueComment({ repoPath, number: item.number, body })
|
||||
? await window.api.gl.addMRComment({ ...repoSelector, iid: item.number, body })
|
||||
: await window.api.gl.addIssueComment({ ...repoSelector, number: item.number, body })
|
||||
if (res.ok) {
|
||||
if (mountedRef.current) {
|
||||
setCommentDraftState((current) =>
|
||||
@@ -889,17 +910,17 @@ export default function GitLabItemDialog({
|
||||
setCommentSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [commentDraft, item, itemId, repoPath, mountedRef, handleRefresh])
|
||||
}, [commentDraft, item, itemId, repoSelector, mountedRef, handleRefresh])
|
||||
|
||||
const handleResolveDiscussion = useCallback(
|
||||
async (threadId: string, resolved: boolean): Promise<void> => {
|
||||
if (!item || !repoPath || item.type !== 'mr') {
|
||||
if (!item || !repoSelector || item.type !== 'mr') {
|
||||
return
|
||||
}
|
||||
setResolvingThreadId(threadId)
|
||||
try {
|
||||
const res = await window.api.gl.resolveMRDiscussion({
|
||||
repoPath,
|
||||
...repoSelector,
|
||||
iid: item.number,
|
||||
discussionId: threadId,
|
||||
resolved
|
||||
@@ -927,7 +948,7 @@ export default function GitLabItemDialog({
|
||||
}
|
||||
}
|
||||
},
|
||||
[item, repoPath, mountedRef]
|
||||
[item, repoSelector, mountedRef]
|
||||
)
|
||||
|
||||
// Why: GitMerge for MRs visually disambiguates from GitBranch (and
|
||||
|
||||
@@ -42,12 +42,14 @@ import type {
|
||||
JiraTransition,
|
||||
JiraUser
|
||||
} from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type JiraIssueWorkspaceProps = {
|
||||
issue: JiraIssue | null
|
||||
onUse: (issue: JiraIssue) => void
|
||||
onClose: () => void
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
@@ -111,9 +113,11 @@ async function copyTextToClipboard(text: string, label: string): Promise<void> {
|
||||
export default function JiraIssueWorkspace({
|
||||
issue,
|
||||
onUse,
|
||||
onClose
|
||||
onClose,
|
||||
sourceContext
|
||||
}: JiraIssueWorkspaceProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const patchJiraIssue = useAppStore((s) => s.patchJiraIssue)
|
||||
const [fullIssue, setFullIssue] = useState<JiraIssue | null>(null)
|
||||
const [issueLoading, setIssueLoading] = useState(false)
|
||||
@@ -139,7 +143,7 @@ export default function JiraIssueWorkspace({
|
||||
setCommentsLoading(true)
|
||||
setCommentsError(null)
|
||||
try {
|
||||
let fetched = await jiraIssueComments(settings, targetIssue.key, targetIssue.siteId)
|
||||
let fetched = await jiraIssueComments(providerSettings, targetIssue.key, targetIssue.siteId)
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +163,7 @@ export default function JiraIssueWorkspace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
[providerSettings]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,7 +190,7 @@ export default function JiraIssueWorkspace({
|
||||
setCommentsError(null)
|
||||
setIssueLoading(true)
|
||||
|
||||
void jiraGetIssue(settings, issue.key, issue.siteId)
|
||||
void jiraGetIssue(providerSettings, issue.key, issue.siteId)
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
@@ -205,9 +209,9 @@ export default function JiraIssueWorkspace({
|
||||
})
|
||||
|
||||
void Promise.all([
|
||||
jiraListTransitions(settings, issue.key, issue.siteId),
|
||||
jiraListPriorities(settings, issue.siteId),
|
||||
jiraListAssignableUsers(settings, issue.key, undefined, issue.siteId)
|
||||
jiraListTransitions(providerSettings, issue.key, issue.siteId),
|
||||
jiraListPriorities(providerSettings, issue.siteId),
|
||||
jiraListAssignableUsers(providerSettings, issue.key, undefined, issue.siteId)
|
||||
])
|
||||
.then(([nextTransitions, nextPriorities, nextUsers]) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
@@ -220,22 +224,22 @@ export default function JiraIssueWorkspace({
|
||||
.catch(() => {})
|
||||
|
||||
void loadComments(issue, requestId)
|
||||
}, [issue, loadComments, settings])
|
||||
}, [issue, loadComments, providerSettings])
|
||||
|
||||
const refreshIssue = useCallback(async (): Promise<void> => {
|
||||
if (!displayed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const latest = await jiraGetIssue(settings, displayed.key, displayed.siteId)
|
||||
const latest = await jiraGetIssue(providerSettings, displayed.key, displayed.siteId)
|
||||
if (latest) {
|
||||
setFullIssue(latest)
|
||||
patchJiraIssue(latest.key, latest)
|
||||
patchJiraIssue(latest.key, latest, { sourceContext })
|
||||
}
|
||||
} catch {
|
||||
// Keep the visible issue snapshot if refresh fails.
|
||||
}
|
||||
}, [displayed, patchJiraIssue, settings])
|
||||
}, [displayed, patchJiraIssue, providerSettings, sourceContext])
|
||||
|
||||
const mutateIssue = useCallback(
|
||||
async (
|
||||
@@ -251,16 +255,16 @@ export default function JiraIssueWorkspace({
|
||||
try {
|
||||
if (optimistic) {
|
||||
setFullIssue({ ...displayed, ...optimistic })
|
||||
patchJiraIssue(displayed.key, optimistic)
|
||||
patchJiraIssue(displayed.key, optimistic, { sourceContext })
|
||||
}
|
||||
const result = await jiraUpdateIssue(settings, displayed.key, updates, siteId)
|
||||
const result = await jiraUpdateIssue(providerSettings, displayed.key, updates, siteId)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
await refreshIssue()
|
||||
} catch (error) {
|
||||
setFullIssue(previous)
|
||||
patchJiraIssue(previous.key, previous)
|
||||
patchJiraIssue(previous.key, previous, { sourceContext })
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -273,7 +277,7 @@ export default function JiraIssueWorkspace({
|
||||
setPendingField(null)
|
||||
}
|
||||
},
|
||||
[displayed, patchJiraIssue, pendingField, refreshIssue, settings, siteId]
|
||||
[displayed, patchJiraIssue, pendingField, refreshIssue, providerSettings, siteId, sourceContext]
|
||||
)
|
||||
|
||||
const handleSaveTitle = useCallback(() => {
|
||||
@@ -309,7 +313,12 @@ export default function JiraIssueWorkspace({
|
||||
}
|
||||
setCommentSubmitting(true)
|
||||
try {
|
||||
const result = await jiraAddIssueComment(settings, displayed.key, body, displayed.siteId)
|
||||
const result = await jiraAddIssueComment(
|
||||
providerSettings,
|
||||
displayed.key,
|
||||
body,
|
||||
displayed.siteId
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
@@ -331,7 +340,7 @@ export default function JiraIssueWorkspace({
|
||||
} finally {
|
||||
setCommentSubmitting(false)
|
||||
}
|
||||
}, [commentDraft, commentSubmitting, displayed, settings])
|
||||
}, [commentDraft, commentSubmitting, displayed, providerSettings])
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
if (!displayed) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAppStore } from '@/store'
|
||||
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
|
||||
import type { LinearIssue } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import {
|
||||
getLinearIssueTextSavePlan,
|
||||
type LinearIssueTextField
|
||||
@@ -24,6 +25,7 @@ type LinearIssueTextEditorProps = {
|
||||
onIssueChange: (patch: Pick<LinearIssue, 'title'> | Pick<LinearIssue, 'description'>) => void
|
||||
density?: 'page' | 'drawer'
|
||||
fields?: 'all' | 'title' | 'description'
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
function useAutosizeTextArea(value: string): React.RefObject<HTMLTextAreaElement | null> {
|
||||
@@ -45,9 +47,11 @@ export function LinearIssueTextEditor({
|
||||
issue,
|
||||
onIssueChange,
|
||||
density = 'page',
|
||||
fields = 'all'
|
||||
fields = 'all',
|
||||
sourceContext
|
||||
}: LinearIssueTextEditorProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
|
||||
const [draftState, setDraftState] = useState(() => createLinearIssueTextDraftState(issue))
|
||||
const [savingField, setSavingField] = useState<LinearIssueTextField | null>(null)
|
||||
@@ -111,7 +115,7 @@ export function LinearIssueTextEditor({
|
||||
onIssueChange(patch)
|
||||
patchLinearIssue(issue.id, patch)
|
||||
try {
|
||||
const result = await linearUpdateIssue(settings, issue.id, patch, issue.workspaceId)
|
||||
const result = await linearUpdateIssue(providerSettings, issue.id, patch, issue.workspaceId)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
@@ -156,7 +160,7 @@ export function LinearIssueTextEditor({
|
||||
mountedRef,
|
||||
onIssueChange,
|
||||
patchLinearIssue,
|
||||
settings,
|
||||
providerSettings,
|
||||
titleDraft,
|
||||
updateDescriptionDraft,
|
||||
updateTitleDraft
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
LinearIssueChildSummary,
|
||||
LinearProjectSummary
|
||||
} from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type LinearIssueWorkspaceProps = {
|
||||
@@ -66,6 +67,7 @@ type LinearIssueWorkspaceProps = {
|
||||
onClose: () => void
|
||||
variant?: 'sheet' | 'page'
|
||||
backLabel?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, label: string): Promise<void> {
|
||||
@@ -111,12 +113,15 @@ function LinearIssueAvatar({
|
||||
|
||||
function LinearIssueSubIssueButton({
|
||||
issue,
|
||||
onOpenIssue
|
||||
onOpenIssue,
|
||||
sourceContext
|
||||
}: {
|
||||
issue: LinearIssue
|
||||
onOpenIssue: (issue: LinearIssue) => void
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -145,7 +150,9 @@ function LinearIssueSubIssueButton({
|
||||
async (subIssue: LinearIssueChildSummary) => {
|
||||
setOpeningSubIssueId(subIssue.id)
|
||||
try {
|
||||
const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId)
|
||||
const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId, {
|
||||
sourceContext
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -173,7 +180,7 @@ function LinearIssueSubIssueButton({
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue]
|
||||
[fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue, sourceContext]
|
||||
)
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
@@ -183,7 +190,7 @@ function LinearIssueSubIssueButton({
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await linearCreateSubIssue(settings, {
|
||||
const result = await linearCreateSubIssue(providerSettings, {
|
||||
parentIssueId: issue.id,
|
||||
teamId: issue.team.id,
|
||||
title: trimmed,
|
||||
@@ -235,7 +242,7 @@ function LinearIssueSubIssueButton({
|
||||
issue.subIssues,
|
||||
issue.team.id,
|
||||
issue.workspaceId,
|
||||
settings,
|
||||
providerSettings,
|
||||
title
|
||||
])
|
||||
|
||||
@@ -311,12 +318,15 @@ function LinearIssueSubIssueButton({
|
||||
|
||||
function LinearIssueSidebarProjectCard({
|
||||
issue,
|
||||
onProjectChanged
|
||||
onProjectChanged,
|
||||
sourceContext
|
||||
}: {
|
||||
issue: LinearIssue
|
||||
onProjectChanged: (project: LinearProjectSummary) => void
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -331,7 +341,7 @@ function LinearIssueSidebarProjectCard({
|
||||
let cancelled = false
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
void linearListProjects(settings, query, 20, issue.workspaceId)
|
||||
void linearListProjects(providerSettings, query, 20, issue.workspaceId)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setProjects(result.items)
|
||||
@@ -359,21 +369,21 @@ function LinearIssueSidebarProjectCard({
|
||||
cancelled = true
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [issue.workspaceId, open, query, settings])
|
||||
}, [issue.workspaceId, open, providerSettings, query])
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
async (project: LinearProjectSummary) => {
|
||||
setSavingProjectId(project.id)
|
||||
try {
|
||||
const result = await linearUpdateIssue(
|
||||
settings,
|
||||
providerSettings,
|
||||
issue.id,
|
||||
{ projectId: project.id },
|
||||
issue.workspaceId
|
||||
)
|
||||
if (result.ok) {
|
||||
onProjectChanged(project)
|
||||
patchLinearIssue(issue.id, { project })
|
||||
patchLinearIssue(issue.id, { project }, { sourceContext })
|
||||
toast.success(
|
||||
translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated')
|
||||
)
|
||||
@@ -394,7 +404,14 @@ function LinearIssueSidebarProjectCard({
|
||||
setSavingProjectId(null)
|
||||
}
|
||||
},
|
||||
[issue.id, issue.workspaceId, onProjectChanged, patchLinearIssue, settings]
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
onProjectChanged,
|
||||
patchLinearIssue,
|
||||
providerSettings,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -482,9 +499,11 @@ export default function LinearIssueWorkspace({
|
||||
onOpenIssue,
|
||||
onClose,
|
||||
variant = 'sheet',
|
||||
backLabel = 'Back'
|
||||
backLabel = 'Back',
|
||||
sourceContext
|
||||
}: LinearIssueWorkspaceProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null)
|
||||
const [issueLoading, setIssueLoading] = useState(false)
|
||||
const [comments, setComments] = useState<LinearComment[]>([])
|
||||
@@ -519,7 +538,7 @@ export default function LinearIssueWorkspace({
|
||||
}
|
||||
try {
|
||||
let fetched = (await linearIssueComments(
|
||||
settings,
|
||||
providerSettings,
|
||||
targetIssue.id,
|
||||
targetIssue.workspaceId
|
||||
)) as LinearComment[]
|
||||
@@ -542,7 +561,7 @@ export default function LinearIssueWorkspace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[mountedRef, settings]
|
||||
[mountedRef, providerSettings]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -558,7 +577,7 @@ export default function LinearIssueWorkspace({
|
||||
return
|
||||
}
|
||||
|
||||
const issueKey = `${settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}`
|
||||
const issueKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}`
|
||||
if (hydratedIssueKeyRef.current === issueKey) {
|
||||
return
|
||||
}
|
||||
@@ -576,7 +595,7 @@ export default function LinearIssueWorkspace({
|
||||
|
||||
// Why: issue hydration and comments are separate surfaces; a comments
|
||||
// failure should not blank the issue detail the user selected.
|
||||
void linearGetIssue(settings, issue.id, issue.workspaceId)
|
||||
void linearGetIssue(providerSettings, issue.id, issue.workspaceId)
|
||||
.then((issueResult) => {
|
||||
if (!mountedRef.current || requestId !== requestIdRef.current) {
|
||||
return
|
||||
@@ -616,7 +635,7 @@ export default function LinearIssueWorkspace({
|
||||
})
|
||||
|
||||
void loadComments(issue, requestId)
|
||||
}, [issue, loadComments, mountedRef, settings])
|
||||
}, [issue, loadComments, mountedRef, providerSettings, settings, sourceContext?.hostId])
|
||||
|
||||
const displayed = fullIssue ?? issue
|
||||
|
||||
@@ -796,9 +815,17 @@ export default function LinearIssueWorkspace({
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="mx-auto grid w-full grid-cols-1 gap-10 px-7 py-10 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-10 xl:px-12">
|
||||
<main className="min-w-0">
|
||||
<LinearIssueTextEditor issue={displayed} onIssueChange={handleIssueTextChange} />
|
||||
<LinearIssueTextEditor
|
||||
issue={displayed}
|
||||
onIssueChange={handleIssueTextChange}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
|
||||
<LinearIssueSubIssueButton issue={displayed} onOpenIssue={onOpenIssue} />
|
||||
<LinearIssueSubIssueButton
|
||||
issue={displayed}
|
||||
onOpenIssue={onOpenIssue}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-border/60 pt-9">
|
||||
<div className="mb-8 flex items-center justify-between gap-3">
|
||||
@@ -894,6 +921,7 @@ export default function LinearIssueWorkspace({
|
||||
workspaceId={displayed.workspaceId}
|
||||
onCommentAdded={handleCommentAdded}
|
||||
variant="linear-page"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
@@ -905,11 +933,13 @@ export default function LinearIssueWorkspace({
|
||||
editState={editState}
|
||||
onEditStateChange={handleEditStateChange}
|
||||
layout="properties"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
) : null}
|
||||
<LinearIssueSidebarProjectCard
|
||||
issue={displayed}
|
||||
onProjectChanged={handleProjectChanged}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
<section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs">
|
||||
<div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@/components/linear-state-pill-style'
|
||||
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
|
||||
import type { LinearIssue, LinearComment } from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import {
|
||||
linearAddIssueComment,
|
||||
linearGetIssue,
|
||||
@@ -118,6 +119,7 @@ type LinearItemDrawerProps = {
|
||||
issue: LinearIssue | null
|
||||
onUse: (issue: LinearIssue) => void
|
||||
onClose: () => void
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export type LinearEditState = {
|
||||
@@ -134,18 +136,21 @@ type EditSectionProps = {
|
||||
editState: LinearEditState
|
||||
onEditStateChange: (patch: Partial<LinearEditState>) => void
|
||||
layout?: 'chips' | 'properties'
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export function LinearIssueEditSection({
|
||||
issue,
|
||||
editState,
|
||||
onEditStateChange,
|
||||
layout = 'chips'
|
||||
layout = 'chips',
|
||||
sourceContext
|
||||
}: EditSectionProps): React.JSX.Element {
|
||||
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
|
||||
const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false)
|
||||
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
|
||||
const {
|
||||
@@ -159,9 +164,9 @@ export function LinearIssueEditSection({
|
||||
const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate))
|
||||
|
||||
const teamId = issue.team?.id || null
|
||||
const states = useTeamStates(teamId, settings, issue.workspaceId)
|
||||
const labels = useTeamLabels(teamId, settings, issue.workspaceId)
|
||||
const members = useTeamMembers(teamId, settings, issue.workspaceId)
|
||||
const states = useTeamStates(teamId, providerSettings, issue.workspaceId)
|
||||
const labels = useTeamLabels(teamId, providerSettings, issue.workspaceId)
|
||||
const members = useTeamMembers(teamId, providerSettings, issue.workspaceId)
|
||||
|
||||
const handleEstimatePopoverOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -184,14 +189,14 @@ export function LinearIssueEditSection({
|
||||
const stateValue = { name: newState.name, type: newState.type, color: newState.color }
|
||||
|
||||
run('state', {
|
||||
mutate: () => linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId),
|
||||
mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ state: stateValue })
|
||||
patchLinearIssue(issue.id, { state: stateValue })
|
||||
patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ state: prevState })
|
||||
patchLinearIssue(issue.id, { state: prevState })
|
||||
patchLinearIssue(issue.id, { state: prevState }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -203,11 +208,12 @@ export function LinearIssueEditSection({
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localState,
|
||||
settings,
|
||||
providerSettings,
|
||||
states.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -216,14 +222,15 @@ export function LinearIssueEditSection({
|
||||
const priority = parseInt(value, 10)
|
||||
const prevPriority = localPriority
|
||||
run('priority', {
|
||||
mutate: () => linearUpdateIssue(settings, issue.id, { priority }, issue.workspaceId),
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ priority })
|
||||
patchLinearIssue(issue.id, { priority })
|
||||
patchLinearIssue(issue.id, { priority }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ priority: prevPriority })
|
||||
patchLinearIssue(issue.id, { priority: prevPriority })
|
||||
patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -231,22 +238,32 @@ export function LinearIssueEditSection({
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[issue.id, issue.workspaceId, localPriority, settings, patchLinearIssue, run, onEditStateChange]
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localPriority,
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleEstimateChange = useCallback(
|
||||
(estimate: number | null) => {
|
||||
const prevEstimate = localEstimate
|
||||
run('estimate', {
|
||||
mutate: () => linearUpdateIssue(settings, issue.id, { estimate }, issue.workspaceId),
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ estimate })
|
||||
patchLinearIssue(issue.id, { estimate })
|
||||
patchLinearIssue(issue.id, { estimate }, { sourceContext })
|
||||
setEstimatePopoverOpen(false)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ estimate: prevEstimate })
|
||||
patchLinearIssue(issue.id, { estimate: prevEstimate })
|
||||
patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -254,7 +271,16 @@ export function LinearIssueEditSection({
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[issue.id, issue.workspaceId, localEstimate, settings, patchLinearIssue, run, onEditStateChange]
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localEstimate,
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleEstimateSubmit = useCallback(() => {
|
||||
@@ -287,14 +313,15 @@ export function LinearIssueEditSection({
|
||||
? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl }
|
||||
: undefined
|
||||
run('assignee', {
|
||||
mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }, issue.workspaceId),
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ assignee: newAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: newAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ assignee: prevAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: prevAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -306,11 +333,12 @@ export function LinearIssueEditSection({
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localAssignee,
|
||||
settings,
|
||||
providerSettings,
|
||||
members.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -328,14 +356,27 @@ export function LinearIssueEditSection({
|
||||
|
||||
run('labels', {
|
||||
mutate: () =>
|
||||
linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }, issue.workspaceId),
|
||||
linearUpdateIssue(
|
||||
providerSettings,
|
||||
issue.id,
|
||||
{ labelIds: newLabelIds },
|
||||
issue.workspaceId
|
||||
),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ labelIds: newLabelIds, labels: newLabels })
|
||||
patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: newLabelIds, labels: newLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels })
|
||||
patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: prevLabelIds, labels: prevLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
@@ -348,11 +389,12 @@ export function LinearIssueEditSection({
|
||||
issue.workspaceId,
|
||||
localLabelIds,
|
||||
localLabels,
|
||||
settings,
|
||||
providerSettings,
|
||||
labels.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
@@ -994,14 +1036,17 @@ export function LinearIssueCommentFooter({
|
||||
issueId,
|
||||
workspaceId,
|
||||
onCommentAdded,
|
||||
variant = 'compact'
|
||||
variant = 'compact',
|
||||
sourceContext
|
||||
}: {
|
||||
issueId: string
|
||||
workspaceId?: string | null
|
||||
onCommentAdded: (comment: LinearLocalComment) => void
|
||||
variant?: 'compact' | 'linear-page'
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const submitShortcutLabel = getScreenSubmitShortcutLabel()
|
||||
const [body, setBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -1030,7 +1075,7 @@ export function LinearIssueCommentFooter({
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await linearAddIssueComment(settings, issueId, trimmed, workspaceId)
|
||||
const result = await linearAddIssueComment(providerSettings, issueId, trimmed, workspaceId)
|
||||
const typed = result as { ok: boolean; id?: string; error?: string }
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
@@ -1062,7 +1107,7 @@ export function LinearIssueCommentFooter({
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [body, issueId, onCommentAdded, settings, workspaceId])
|
||||
}, [body, issueId, onCommentAdded, providerSettings, workspaceId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -1168,7 +1213,8 @@ export function initLinearIssueEditState(issue: LinearIssue): LinearEditState {
|
||||
export default function LinearItemDrawer({
|
||||
issue,
|
||||
onUse,
|
||||
onClose
|
||||
onClose,
|
||||
sourceContext
|
||||
}: LinearItemDrawerProps): React.JSX.Element {
|
||||
const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null)
|
||||
const [comments, setComments] = useState<LinearComment[]>([])
|
||||
@@ -1178,6 +1224,7 @@ export default function LinearItemDrawer({
|
||||
const hasEditedRef = useRef(false)
|
||||
const optimisticCommentsRef = useRef<LinearComment[]>([])
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
|
||||
const handleEditStateChange = useCallback((patch: Partial<LinearEditState>) => {
|
||||
hasEditedRef.current = true
|
||||
@@ -1214,7 +1261,7 @@ export default function LinearItemDrawer({
|
||||
|
||||
// Why: fetch issue and comments independently so a transient comments
|
||||
// failure doesn't discard the successfully-fetched issue data.
|
||||
linearGetIssue(settings, issue.id, issue.workspaceId)
|
||||
linearGetIssue(providerSettings, issue.id, issue.workspaceId)
|
||||
.then((issueResult) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
@@ -1231,7 +1278,7 @@ export default function LinearItemDrawer({
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
linearIssueComments(settings, issue.id, issue.workspaceId)
|
||||
linearIssueComments(providerSettings, issue.id, issue.workspaceId)
|
||||
.then((commentsResult) => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
@@ -1256,7 +1303,7 @@ export default function LinearItemDrawer({
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [issue?.id, issue?.workspaceId, settings])
|
||||
}, [issue?.id, issue?.workspaceId, providerSettings])
|
||||
|
||||
// Why: same pointer-events fix as GitHubItemDialog — Radix may leave
|
||||
// pointer-events: none on body when overlays transition.
|
||||
@@ -1343,6 +1390,7 @@ export default function LinearItemDrawer({
|
||||
onIssueChange={handleIssueTextChange}
|
||||
density="drawer"
|
||||
fields="title"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
@@ -1400,6 +1448,7 @@ export default function LinearItemDrawer({
|
||||
issue={displayed}
|
||||
editState={editState}
|
||||
onEditStateChange={handleEditStateChange}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1411,6 +1460,7 @@ export default function LinearItemDrawer({
|
||||
onIssueChange={handleIssueTextChange}
|
||||
density="drawer"
|
||||
fields="description"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1472,6 +1522,7 @@ export default function LinearItemDrawer({
|
||||
issueId={displayed.id}
|
||||
workspaceId={displayed.workspaceId}
|
||||
onCommentAdded={handleCommentAdded}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
|
||||
<Button
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import type RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import AgentCombobox from '@/components/agent/AgentCombobox'
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -35,12 +35,18 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset
|
||||
import SmartWorkspaceNameField, {
|
||||
type SmartWorkspaceNameSelection
|
||||
} from '@/components/new-workspace/SmartWorkspaceNameField'
|
||||
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
|
||||
import ProjectHostSetupCombobox from '@/components/new-workspace/ProjectHostSetupCombobox'
|
||||
import type { SetupConfig } from '@/lib/new-workspace'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
|
||||
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
|
||||
const EMPTY_PROJECT_HOST_SETUP_OPTIONS: ProjectHostSetupOption[] = []
|
||||
const EMPTY_PROJECT_OPTIONS: NewWorkspaceProjectOption[] = []
|
||||
|
||||
type NewWorkspaceComposerCardProps = {
|
||||
contextualTourSource?: string
|
||||
@@ -52,8 +58,14 @@ type NewWorkspaceComposerCardProps = {
|
||||
onQuickAgentChange: (agent: TuiAgent | null) => void
|
||||
eligibleRepos: RepoOption[]
|
||||
repoId: string
|
||||
projectOptions?: NewWorkspaceProjectOption[]
|
||||
selectedProjectId?: string | null
|
||||
selectedRepoIsGit: boolean
|
||||
onRepoChange: (value: string) => void
|
||||
onProjectChange: (value: string) => void
|
||||
projectHostSetupOptions?: ProjectHostSetupOption[]
|
||||
selectedProjectHostSetupId?: string | null
|
||||
onProjectHostSetupChange?: (setupId: string) => void
|
||||
primaryActionLabel: string
|
||||
projectLabel?: string
|
||||
projectPlaceholder?: string
|
||||
@@ -274,8 +286,14 @@ export default function NewWorkspaceComposerCard({
|
||||
onQuickAgentChange,
|
||||
eligibleRepos,
|
||||
repoId,
|
||||
projectOptions = EMPTY_PROJECT_OPTIONS,
|
||||
selectedProjectId = null,
|
||||
selectedRepoIsGit,
|
||||
onRepoChange,
|
||||
onProjectChange,
|
||||
projectHostSetupOptions = EMPTY_PROJECT_HOST_SETUP_OPTIONS,
|
||||
selectedProjectHostSetupId = null,
|
||||
onProjectHostSetupChange,
|
||||
primaryActionLabel,
|
||||
projectLabel,
|
||||
projectPlaceholder,
|
||||
@@ -425,6 +443,16 @@ export default function NewWorkspaceComposerCard({
|
||||
openModal('add-repo')
|
||||
}, [openModal])
|
||||
const projectDescriptionId = React.useId()
|
||||
const readyProjectHostSetupOptions = React.useMemo(
|
||||
() => projectHostSetupOptions.filter((option) => option.kind === 'ready'),
|
||||
[projectHostSetupOptions]
|
||||
)
|
||||
const handleProjectHostSetupChange = React.useCallback(
|
||||
(setupId: string): void => {
|
||||
onProjectHostSetupChange?.(setupId)
|
||||
},
|
||||
[onProjectHostSetupChange]
|
||||
)
|
||||
useContextualTour(
|
||||
'workspace-creation',
|
||||
eligibleRepos.length > 0 && Boolean(repoId),
|
||||
@@ -481,10 +509,10 @@ export default function NewWorkspaceComposerCard({
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<RepoCombobox
|
||||
repos={eligibleRepos}
|
||||
value={repoId}
|
||||
onValueChange={onRepoChange}
|
||||
<ProjectCombobox
|
||||
options={projectOptions}
|
||||
value={selectedProjectId}
|
||||
onValueChange={onProjectChange}
|
||||
onValueSelected={focusNameInput}
|
||||
placeholder={
|
||||
projectPlaceholder ??
|
||||
@@ -497,7 +525,6 @@ export default function NewWorkspaceComposerCard({
|
||||
// paints the familiar field ring instead of leaving no visible
|
||||
// focus state.
|
||||
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
|
||||
showStandaloneAddButton={false}
|
||||
invalid={Boolean(projectError)}
|
||||
describedBy={projectDescriptionId}
|
||||
/>
|
||||
@@ -514,6 +541,18 @@ export default function NewWorkspaceComposerCard({
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{readyProjectHostSetupOptions.length > 1 ? (
|
||||
<div className="space-y-1">
|
||||
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
|
||||
</label>
|
||||
<ProjectHostSetupCombobox
|
||||
options={readyProjectHostSetupOptions}
|
||||
value={selectedProjectHostSetupId ?? null}
|
||||
onValueChange={handleProjectHostSetupChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
|
||||
<div
|
||||
role="status"
|
||||
|
||||
@@ -22,12 +22,14 @@ import type {
|
||||
WorkspaceCreateTelemetrySource,
|
||||
WorkspaceStatus
|
||||
} from '../../../shared/types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type ComposerModalData = {
|
||||
prefilledName?: string
|
||||
initialRepoId?: string
|
||||
linkedWorkItem?: LinkedWorkItemSummary | null
|
||||
taskSourceContext?: TaskSourceContext | null
|
||||
initialBaseBranch?: string
|
||||
initialWorkspaceStatus?: WorkspaceStatus
|
||||
/** Telemetry surface that opened the composer. Set by each
|
||||
@@ -124,6 +126,7 @@ function QuickTabBody({
|
||||
// intentionally ignored even if older callers still send it.
|
||||
initialPrompt: '',
|
||||
initialLinkedWorkItem: modalData.linkedWorkItem ?? null,
|
||||
initialTaskSourceContext: modalData.taskSourceContext ?? null,
|
||||
initialRepoId: modalData.initialRepoId,
|
||||
initialWorkspaceStatus: modalData.initialWorkspaceStatus,
|
||||
...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}),
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -84,11 +85,6 @@ import {
|
||||
import type { DiffSection } from '@/components/editor/diff-section-types'
|
||||
import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model'
|
||||
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
|
||||
import {
|
||||
REVIEW_ACTION_MERGE_BUTTON_CLASS,
|
||||
REVIEW_ACTION_STATE_BUTTON_CLASS,
|
||||
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS
|
||||
} from '@/components/right-sidebar/right-sidebar-primary-action-layout'
|
||||
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
|
||||
import {
|
||||
createGitHubChecksTabState,
|
||||
@@ -138,8 +134,6 @@ import { useAllWorktrees } from '@/store/selectors'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent'
|
||||
import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent'
|
||||
import {
|
||||
getGitHubPRReviewerRows,
|
||||
normalizeGitHubReviewerLogins
|
||||
@@ -184,6 +178,7 @@ import type {
|
||||
PRComment
|
||||
} from '../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
|
||||
// Why: the GH item dialog can be opened from any work-item list surface and
|
||||
// doesn't have the full owner/repo context the list's cache entry carries.
|
||||
@@ -207,25 +202,6 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(itemUrl)
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
if (segments.length < 2) {
|
||||
return null
|
||||
}
|
||||
parsed.pathname = `/${segments[0]}/${segments[1]}/labels`
|
||||
parsed.search = ''
|
||||
parsed.hash = ''
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt'))
|
||||
|
||||
export type ItemDialogTab = 'conversation' | 'checks' | 'files'
|
||||
@@ -547,7 +523,9 @@ function PRReviewersPanel({
|
||||
reviewRequests: item.reviewRequests
|
||||
}))
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
|
||||
)
|
||||
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const reviewerInputFocusFrameRef = useRef<number | null>(null)
|
||||
const reviewerPanelMountedRef = useRef(true)
|
||||
@@ -622,11 +600,12 @@ function PRReviewersPanel({
|
||||
open && reviewSlug ? reviewSlug.owner : null,
|
||||
open && reviewSlug ? reviewSlug.repo : null,
|
||||
reviewerSeedUsers.map((user) => user.login),
|
||||
settings
|
||||
repoOwnerSettings
|
||||
)
|
||||
const reviewerMetadataByPath = useRepoAssignees(
|
||||
open && !reviewSlug ? repoPath : null,
|
||||
open && !reviewSlug ? item.repoId : null
|
||||
open && !reviewSlug ? item.repoId : null,
|
||||
repoOwnerSettings
|
||||
)
|
||||
const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath
|
||||
const displayItem = { ...item, reviewRequests: localReviewRequests }
|
||||
@@ -725,7 +704,8 @@ function PRReviewersPanel({
|
||||
localReviewRequests.length > 0 ||
|
||||
item.reviewRequests !== undefined ||
|
||||
item.latestReviews !== undefined
|
||||
const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment'
|
||||
const canRequestReview =
|
||||
!!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment'
|
||||
|
||||
const measureReviewerPickerPlacement = useCallback(() => {
|
||||
const rect = reviewerInputRef.current?.getBoundingClientRect()
|
||||
@@ -768,7 +748,7 @@ function PRReviewersPanel({
|
||||
)
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const target = getActiveRuntimeTarget(repoOwnerSettings)
|
||||
if (target.kind !== 'environment' && !repoPath) {
|
||||
toast.error(
|
||||
translate(
|
||||
@@ -842,7 +822,7 @@ function PRReviewersPanel({
|
||||
if (logins.length === 0) {
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const target = getActiveRuntimeTarget(repoOwnerSettings)
|
||||
if (target.kind !== 'environment' && !repoPath) {
|
||||
toast.error(
|
||||
translate(
|
||||
@@ -2702,6 +2682,7 @@ function CommentCodeContext({
|
||||
function ConversationTab({
|
||||
item,
|
||||
repoPath,
|
||||
repoId,
|
||||
body,
|
||||
comments,
|
||||
files,
|
||||
@@ -2749,7 +2730,10 @@ function ConversationTab({
|
||||
const [bodySaving, setBodySaving] = useState(false)
|
||||
const bodyTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const bodyTextareaFocusFrameRef = useRef<number | null>(null)
|
||||
const repoAssignees = useRepoAssignees(repoPath, item.repoId)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
|
||||
)
|
||||
const repoAssignees = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings)
|
||||
const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
|
||||
const visibleComments = useMemo(
|
||||
() => filterPRCommentsByAudience(comments, commentFilter),
|
||||
@@ -3488,7 +3472,7 @@ function PRActionsPanel({
|
||||
<WorkItemStateBadge item={actionItem} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 justify-items-start">
|
||||
<div className="grid gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -3497,8 +3481,7 @@ function PRActionsPanel({
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn(
|
||||
REVIEW_ACTION_MERGE_BUTTON_CLASS,
|
||||
'gap-2 bg-green-600 text-white hover:bg-green-700',
|
||||
'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -3507,13 +3490,11 @@ function PRActionsPanel({
|
||||
) : (
|
||||
<GitMerge className="size-3.5" />
|
||||
)}
|
||||
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
|
||||
{mergePresentation.autoMergeAction?.label ??
|
||||
(mergePresentation.directMergeAvailable
|
||||
? mergeMethods.defaultLabel
|
||||
: mergePresentation.label)}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 opacity-60" />
|
||||
{mergePresentation.autoMergeAction?.label ??
|
||||
(mergePresentation.directMergeAvailable
|
||||
? mergeMethods.defaultLabel
|
||||
: mergePresentation.label)}
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -3559,8 +3540,7 @@ function PRActionsPanel({
|
||||
variant={nextState === 'closed' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
REVIEW_ACTION_STATE_BUTTON_CLASS,
|
||||
'gap-2',
|
||||
'w-full justify-center gap-2',
|
||||
nextState === 'closed' &&
|
||||
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
|
||||
)}
|
||||
@@ -3574,11 +3554,9 @@ function PRActionsPanel({
|
||||
) : (
|
||||
<CircleDot className="size-3.5" />
|
||||
)}
|
||||
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
|
||||
{nextState === 'closed'
|
||||
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
|
||||
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
|
||||
</span>
|
||||
{nextState === 'closed'
|
||||
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
|
||||
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -4813,6 +4791,13 @@ function MentionTextarea({
|
||||
// repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt
|
||||
// to a thrown rejection so the existing `useImmediateMutation` flow
|
||||
// (which expects throws on failure) continues to work unchanged.
|
||||
function getGitHubMutationSettings(repoId: string | null | undefined) {
|
||||
const state = useAppStore.getState()
|
||||
// Why: project-origin mutations are slug-addressed, but when we know the
|
||||
// backing repo id they must still execute on that repo's owner host.
|
||||
return getSettingsForRepoRuntimeOwner(state, repoId ?? null)
|
||||
}
|
||||
|
||||
async function runIssueUpdate(args: {
|
||||
repoPath: string | null
|
||||
repoId?: string | null
|
||||
@@ -4821,7 +4806,7 @@ async function runIssueUpdate(args: {
|
||||
updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates']
|
||||
}): Promise<void> {
|
||||
if (args.projectOrigin) {
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId))
|
||||
const updateArgs = {
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
@@ -4870,7 +4855,7 @@ async function runWorkItemBodyUpdate(args: {
|
||||
if (!targetSlug) {
|
||||
throw new Error('No GitHub repository context available for this pull request.')
|
||||
}
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId))
|
||||
const updateArgs = {
|
||||
owner: targetSlug.owner,
|
||||
repo: targetSlug.repo,
|
||||
@@ -4909,7 +4894,7 @@ async function runPullRequestStateUpdate(args: {
|
||||
updates: { state: 'open' | 'closed' }
|
||||
}): Promise<void> {
|
||||
if (args.projectOrigin) {
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId))
|
||||
const updateArgs = {
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
@@ -4979,8 +4964,10 @@ function GHEditSection({
|
||||
const assigneesItemKey = `${item.repoId}\0${item.id}`
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
|
||||
)
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
|
||||
// Why: when the dialog opens from a Project view, mutations route through
|
||||
// *BySlug IPCs and we must keep `projectViewCache` in sync alongside
|
||||
// `workItemsCache` — `patchWorkItem` only walks the latter, so without this
|
||||
@@ -5003,15 +4990,22 @@ function GHEditSection({
|
||||
const slugRepo = projectOrigin?.repo ?? null
|
||||
const repoLabelsByPath = useRepoLabels(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId
|
||||
projectOrigin ? null : repoId,
|
||||
repoOwnerSettings
|
||||
)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, repoOwnerSettings)
|
||||
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
|
||||
const repoAssigneesByPath = useRepoAssignees(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId
|
||||
projectOrigin ? null : repoId,
|
||||
repoOwnerSettings
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assignees,
|
||||
repoOwnerSettings
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees)
|
||||
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
|
||||
// Why: sync local assignees when item changes or when the detail fetch
|
||||
@@ -5221,6 +5215,18 @@ function GHEditSection({
|
||||
return null
|
||||
}
|
||||
|
||||
const checkIcon = (
|
||||
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6l3 3 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
|
||||
{/* State */}
|
||||
@@ -5290,16 +5296,34 @@ function GHEditSection({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
<GitHubWorkItemLabelPopoverContent
|
||||
open={labelPopoverOpen}
|
||||
labels={repoLabels.data}
|
||||
selectedLabels={localLabels}
|
||||
error={repoLabels.error}
|
||||
loading={repoLabels.loading}
|
||||
repositoryLabelsUrl={repositoryLabelsUrl}
|
||||
onToggleLabel={handleLabelToggle}
|
||||
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
|
||||
/>
|
||||
{repoLabels.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{repoLabels.error}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{repoLabels.data.map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => handleLabelToggle(label)}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localLabels.includes(label)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localLabels.includes(label) && checkIcon}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -5330,14 +5354,41 @@ function GHEditSection({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
<GitHubWorkItemAssigneePopoverContent
|
||||
open={assigneePopoverOpen}
|
||||
assignees={repoAssignees.data}
|
||||
selectedLogins={localAssignees}
|
||||
error={repoAssignees.error}
|
||||
loading={repoAssignees.loading}
|
||||
onToggleAssignee={handleAssigneeToggle}
|
||||
/>
|
||||
{repoAssignees.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{repoAssignees.error}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{repoAssignees.data.map((user) => (
|
||||
<button
|
||||
key={user.login}
|
||||
type="button"
|
||||
onClick={() => handleAssigneeToggle(user.login)}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localAssignees.includes(user.login)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localAssignees.includes(user.login) && checkIcon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{user.login}</span>
|
||||
{user.name && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLink, Star, X } from 'lucide-react'
|
||||
import { Card } from './ui/card'
|
||||
import { Button } from './ui/button'
|
||||
@@ -39,24 +39,18 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
const handleClose = (): void => {
|
||||
setVisible(false)
|
||||
// Why: fire-and-forget. If persisting the dismissal fails the worst case
|
||||
// is we re-fire the same threshold on next launch — not worth blocking
|
||||
// the close animation on.
|
||||
void window.api.starNag.dismiss()
|
||||
}, [busy])
|
||||
}
|
||||
|
||||
const handleDisable = useCallback((): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
const handleDisable = (): void => {
|
||||
setVisible(false)
|
||||
void window.api.starNag.disable()
|
||||
}, [busy])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -69,7 +63,9 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [handleClose, visible])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
|
||||
// over stable refs; re-binding on each render is unnecessary.
|
||||
}, [visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
@@ -82,7 +78,7 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
if (mode === 'web') {
|
||||
setBusy(true)
|
||||
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
|
||||
await window.api.starNag.openWeb()
|
||||
await window.api.starNag.disable()
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
setVisible(false)
|
||||
@@ -90,7 +86,7 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const ok = await window.api.starNag.starOrca()
|
||||
const ok = await window.api.gh.starOrca('star_nag')
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -100,6 +96,7 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
}
|
||||
return
|
||||
}
|
||||
await window.api.starNag.complete()
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
@@ -129,7 +126,6 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={handleClose}
|
||||
disabled={busy}
|
||||
aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
@@ -160,22 +156,10 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleClose}
|
||||
disabled={busy}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleDisable}
|
||||
disabled={busy}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
|
||||
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,7 @@ import { useContextualTour } from './contextual-tours/use-contextual-tour'
|
||||
import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action'
|
||||
import { closeTerminalTab } from './terminal/terminal-tab-actions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
|
||||
@@ -148,6 +149,10 @@ function isPinnedVisibleTab(
|
||||
return findUnifiedTabByVisibleId(state, worktreeId, visibleId)?.isPinned === true
|
||||
}
|
||||
|
||||
function getActiveWorktreeRuntimeEnvironmentId(worktreeId: string | null): string | null {
|
||||
return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
}
|
||||
|
||||
function isPinnedActiveEditorTab(
|
||||
state: TerminalStoreSnapshot,
|
||||
worktreeId: string,
|
||||
@@ -212,9 +217,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
const setActiveTab = useAppStore((s) => s.setActiveTab)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
|
||||
)
|
||||
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((s) => s.setTabColor)
|
||||
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
|
||||
@@ -761,7 +763,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
}
|
||||
// Why: in the paired web client, host session-tabs are authoritative.
|
||||
// Creating a local fallback races the host's initial terminal and duplicates tabs.
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -778,13 +780,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
// activity and reshuffle the sidebar. Explicit "New Tab" actions
|
||||
// (handleNewTab below) still bump normally.
|
||||
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
|
||||
}, [
|
||||
workspaceSessionReady,
|
||||
activeWorktreeId,
|
||||
activeRuntimeEnvironmentId,
|
||||
createTab,
|
||||
reconcileWorktreeTabModel
|
||||
])
|
||||
}, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel])
|
||||
|
||||
const handleNewTab = useCallback(
|
||||
(shellOverride?: string) => {
|
||||
@@ -798,10 +794,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
void openNewTerminalTabInActiveWorkspace(targetGroupId)
|
||||
return
|
||||
}
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId: activeRuntimeEnvironmentId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
command: shellOverride,
|
||||
activate: true
|
||||
})
|
||||
@@ -839,7 +836,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
focusTerminalTabSurface(newTab.id)
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
createTab,
|
||||
openNewTerminalTabInActiveWorkspace,
|
||||
@@ -901,10 +897,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
return
|
||||
}
|
||||
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void createWebRuntimeSessionBrowserTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId: activeRuntimeEnvironmentId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
url: defaultUrl
|
||||
})
|
||||
return
|
||||
@@ -913,12 +910,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'),
|
||||
focusAddressBar: true
|
||||
})
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
createBrowserTab,
|
||||
openNewBrowserTabInActiveWorkspace
|
||||
])
|
||||
}, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace])
|
||||
|
||||
const handleOpenEntry = useCallback(async (args: TabCreateEntryArgs) => {
|
||||
await openTabBarEntry(args)
|
||||
@@ -935,10 +927,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (!source) {
|
||||
return
|
||||
}
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void createWebRuntimeSessionBrowserTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId: activeRuntimeEnvironmentId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
url: source.url,
|
||||
profileId: source.sessionProfileId
|
||||
})
|
||||
@@ -949,7 +942,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
sessionProfileId: source.sessionProfileId
|
||||
})
|
||||
},
|
||||
[activeRuntimeEnvironmentId, activeWorktreeId, createBrowserTab]
|
||||
[activeWorktreeId, createBrowserTab]
|
||||
)
|
||||
|
||||
const handleNewFile = useCallback(async () => {
|
||||
@@ -982,11 +975,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) {
|
||||
return
|
||||
}
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(owningWorktreeId)
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId,
|
||||
environmentId: activeRuntimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1022,7 +1016,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
closeBrowserTab(tabId)
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
closeBrowserTab,
|
||||
setActiveBrowserTab,
|
||||
setActiveFile,
|
||||
@@ -1060,14 +1053,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (unifiedTab?.isPinned) {
|
||||
continue
|
||||
}
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (
|
||||
isWebRuntimeSessionActive(activeRuntimeEnvironmentId) &&
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser')
|
||||
) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId,
|
||||
environmentId: activeRuntimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1093,14 +1087,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
closeBrowserTab,
|
||||
closeFile,
|
||||
closeTab,
|
||||
queueEditorCloseRequests
|
||||
]
|
||||
[activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests]
|
||||
)
|
||||
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
@@ -1123,14 +1110,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (unifiedTab?.isPinned) {
|
||||
continue
|
||||
}
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (
|
||||
isWebRuntimeSessionActive(activeRuntimeEnvironmentId) &&
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser')
|
||||
) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId,
|
||||
environmentId: activeRuntimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1156,14 +1144,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
closeBrowserTab,
|
||||
closeFile,
|
||||
closeTab,
|
||||
queueEditorCloseRequests
|
||||
]
|
||||
[activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests]
|
||||
)
|
||||
|
||||
const handleCloseAllFiles = useCallback(() => {
|
||||
@@ -1188,17 +1169,18 @@ function Terminal(): React.JSX.Element | null {
|
||||
|
||||
const handleActivateTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void activateWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId,
|
||||
environmentId: activeRuntimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
}
|
||||
setActiveTab(tabId)
|
||||
setActiveTabType('terminal')
|
||||
},
|
||||
[activeRuntimeEnvironmentId, activeWorktreeId, setActiveTab, setActiveTabType]
|
||||
[activeWorktreeId, setActiveTab, setActiveTabType]
|
||||
)
|
||||
|
||||
const handleTogglePaneExpand = useCallback(
|
||||
@@ -1217,17 +1199,18 @@ function Terminal(): React.JSX.Element | null {
|
||||
|
||||
const handleActivateBrowserTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void activateWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId,
|
||||
environmentId: activeRuntimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
}
|
||||
setActiveBrowserTab(tabId)
|
||||
setActiveTabType('browser')
|
||||
},
|
||||
[activeRuntimeEnvironmentId, activeWorktreeId, setActiveBrowserTab, setActiveTabType]
|
||||
[activeWorktreeId, setActiveBrowserTab, setActiveTabType]
|
||||
)
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
queueBrowserFocusRequest
|
||||
} from '@/components/browser-pane/browser-focus'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options'
|
||||
import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
|
||||
import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
|
||||
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
|
||||
import {
|
||||
@@ -79,9 +81,15 @@ import {
|
||||
getComposerEligibleRepos,
|
||||
resolveComposerGitRepoId
|
||||
} from '@/lib/new-workspace-composer-repo'
|
||||
import {
|
||||
lookupGitHubWorkItemByOwnerRepoForSource,
|
||||
lookupGitHubWorkItemForSource
|
||||
} from '@/lib/github-work-item-source-lookup'
|
||||
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
|
||||
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
|
||||
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorktreePaletteItem = {
|
||||
@@ -152,7 +160,8 @@ function getComposerPrefetchRepoId(
|
||||
return resolveComposerGitRepoId({
|
||||
eligibleRepos: getComposerEligibleRepos(state.repos),
|
||||
initialRepoId,
|
||||
activeRepoId: state.activeRepoId
|
||||
activeRepoId: state.activeRepoId,
|
||||
focusedHostScope: state.workspaceHostScope
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,6 +221,29 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme
|
||||
)
|
||||
}
|
||||
|
||||
function PaletteHostBadgeChip({
|
||||
badge
|
||||
}: {
|
||||
badge: PaletteHostBadge | null
|
||||
}): React.JSX.Element | null {
|
||||
if (!badge) {
|
||||
return null
|
||||
}
|
||||
// Host labels come from the registry and are intentionally not translated.
|
||||
return (
|
||||
<span
|
||||
aria-label={translate(
|
||||
'auto.components.WorktreeJumpPalette.paletteHostBadge',
|
||||
'Host: {{value0}}',
|
||||
{ value0: badge.label }
|
||||
)}
|
||||
className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88"
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function findBrowserSelection(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
@@ -283,8 +315,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
|
||||
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
|
||||
@@ -317,6 +352,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const preserveCreateLookupOnCloseRef = useRef(false)
|
||||
|
||||
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
|
||||
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
|
||||
// Why: host badges only appear when more than one execution host exists; reuse
|
||||
// the same registry the sidebar host-scope strip builds so labels stay in sync.
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
buildSidebarHostOptions({
|
||||
repos,
|
||||
sshTargetLabels,
|
||||
sshConnectionStates,
|
||||
settings,
|
||||
runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId,
|
||||
hostLabelOverrides
|
||||
}),
|
||||
[
|
||||
repos,
|
||||
sshTargetLabels,
|
||||
sshConnectionStates,
|
||||
settings,
|
||||
runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId,
|
||||
hostLabelOverrides
|
||||
]
|
||||
)
|
||||
const canCreateWorktree = repos.length > 0
|
||||
|
||||
const hasQuery = deferredQuery.trim().length > 0
|
||||
@@ -1150,6 +1209,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
}
|
||||
|
||||
prefetchCreateWorkspaceBaseForComposer(repoForLookup.id)
|
||||
const sourceContext = buildTaskSourceContextFromRepo({
|
||||
provider: 'github',
|
||||
projectId: repoForLookup.id,
|
||||
repo: repoForLookup
|
||||
})
|
||||
// Why: awaiting inside the user gesture would leave the palette open
|
||||
// indefinitely on slow networks. Close immediately and populate the
|
||||
// composer once the lookup returns.
|
||||
@@ -1157,15 +1221,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
preserveCreateLookupOnCloseRef.current = true
|
||||
recordFeatureInteraction('cmd-j-create-workspace')
|
||||
closeModal()
|
||||
void window.api.gh
|
||||
.workItemByOwnerRepo({
|
||||
repoPath: repoForLookup.path,
|
||||
repoId: repoForLookup.id,
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
number,
|
||||
type: ghLink.type
|
||||
})
|
||||
void lookupGitHubWorkItemByOwnerRepoForSource({
|
||||
repoPath: repoForLookup.path,
|
||||
repoId: repoForLookup.id,
|
||||
sourceContext,
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
number,
|
||||
type: ghLink.type
|
||||
})
|
||||
.then((item) => {
|
||||
if (!createLookupGuard.isCurrent(lookupToken)) {
|
||||
return
|
||||
@@ -1227,12 +1291,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
}
|
||||
|
||||
prefetchCreateWorkspaceBaseForComposer(repoForLookup.id)
|
||||
const sourceContext = buildTaskSourceContextFromRepo({
|
||||
provider: 'github',
|
||||
projectId: repoForLookup.id,
|
||||
repo: repoForLookup
|
||||
})
|
||||
const lookupToken = createLookupGuard.start()
|
||||
preserveCreateLookupOnCloseRef.current = true
|
||||
recordFeatureInteraction('cmd-j-create-workspace')
|
||||
closeModal()
|
||||
void window.api.gh
|
||||
.workItem({ repoPath: repoForLookup.path, repoId: repoForLookup.id, number: ghNumber })
|
||||
void lookupGitHubWorkItemForSource({
|
||||
repoPath: repoForLookup.path,
|
||||
repoId: repoForLookup.id,
|
||||
sourceContext,
|
||||
number: ghNumber
|
||||
})
|
||||
.then((item) => {
|
||||
if (!createLookupGuard.isCurrent(lookupToken)) {
|
||||
return
|
||||
@@ -1452,6 +1525,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected')
|
||||
: null
|
||||
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
|
||||
const hostBadge = getPaletteHostBadge(repo, hostOptions)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -1567,6 +1641,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<PaletteHostBadgeChip badge={hostBadge} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1618,6 +1693,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
? repoMap.get(simulatorWorktree.repoId)
|
||||
: undefined
|
||||
const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName
|
||||
const simulatorHostBadge = getPaletteHostBadge(simulatorRepo, hostOptions)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -1683,6 +1759,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<PaletteHostBadgeChip badge={simulatorHostBadge} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1694,6 +1771,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const browserWorktree = worktreeMap.get(result.worktreeId)
|
||||
const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined
|
||||
const browserRepoName = browserRepo?.displayName ?? result.repoName
|
||||
const browserHostBadge = getPaletteHostBadge(browserRepo, hostOptions)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -1759,6 +1837,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<PaletteHostBadgeChip badge={browserHostBadge} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
formatAutomationTokens,
|
||||
summarizeAutomationRunUsage
|
||||
} from './automation-usage-model'
|
||||
import type { AutomationTargetAvailability } from './automation-target-availability'
|
||||
import { getAutomationSourceDisplay } from './automation-source-display'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AutomationDetailProps = {
|
||||
@@ -21,6 +23,8 @@ type AutomationDetailProps = {
|
||||
projectName: string
|
||||
workspaceName: string
|
||||
projectDefaultBaseRef: string | null
|
||||
hostLabelById?: ReadonlyMap<string, string>
|
||||
runNowAvailability: AutomationTargetAvailability | null
|
||||
now: number
|
||||
onRunNow: (automation: Automation) => void
|
||||
onEdit: (automation: Automation) => void
|
||||
@@ -28,11 +32,21 @@ type AutomationDetailProps = {
|
||||
onDelete: (automation: Automation) => void
|
||||
}
|
||||
|
||||
function DetailMetric({ label, value }: { label: string; value: string }): React.JSX.Element {
|
||||
function DetailMetric({
|
||||
label,
|
||||
value,
|
||||
title
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
title?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 break-words text-sm font-medium">{value}</div>
|
||||
<div className="mt-1 break-words text-sm font-medium" title={title}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -86,6 +100,8 @@ export function AutomationDetail({
|
||||
projectName,
|
||||
workspaceName,
|
||||
projectDefaultBaseRef,
|
||||
hostLabelById,
|
||||
runNowAvailability,
|
||||
now,
|
||||
onRunNow,
|
||||
onEdit,
|
||||
@@ -115,6 +131,8 @@ export function AutomationDetail({
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
|
||||
: workspaceName
|
||||
const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById)
|
||||
const runNowDisabled = runNowAvailability?.canRunNow === false
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
@@ -133,10 +151,26 @@ export function AutomationDetail({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="secondary" size="sm" onClick={() => onRunNow(automation)}>
|
||||
<Play className="size-4" />
|
||||
{translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onRunNow(automation)}
|
||||
disabled={runNowDisabled}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
{translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{runNowDisabled ? (
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{runNowAvailability.message}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<ToolbarIconButton
|
||||
label={translate(
|
||||
'auto.components.automations.AutomationDetail.4b1ea02d2e',
|
||||
@@ -184,6 +218,12 @@ export function AutomationDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runNowAvailability?.canRunNow === false ? (
|
||||
<div className="rounded-md border border-border/50 bg-muted/40 p-3 text-sm text-muted-foreground shadow-sm">
|
||||
{runNowAvailability.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-5 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
|
||||
<DetailMetric
|
||||
label={translate('auto.components.automations.AutomationDetail.18763ded26', 'Schedule')}
|
||||
@@ -209,6 +249,13 @@ export function AutomationDetail({
|
||||
label={translate('auto.components.automations.AutomationDetail.15ea446b93', 'Session')}
|
||||
value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'}
|
||||
/>
|
||||
{sourceDisplay ? (
|
||||
<DetailMetric
|
||||
label={translate('auto.components.automations.AutomationDetail.29baf8f4c2', 'Source')}
|
||||
value={sourceDisplay.label}
|
||||
title={sourceDisplay.title}
|
||||
/>
|
||||
) : null}
|
||||
<DetailMetric
|
||||
label={translate('auto.components.automations.AutomationDetail.620b22145e', 'Grace')}
|
||||
value={formatGrace(automation.missedRunGraceMinutes)}
|
||||
|
||||
@@ -58,6 +58,7 @@ type AutomationEditorDialogProps = {
|
||||
settings: GlobalSettings | null
|
||||
draft: AutomationDraft
|
||||
onProjectChange: (projectId: string) => void
|
||||
getRepoHostLabel?: (repo: Repo) => string | null | undefined
|
||||
onCreateTargetChange: (target: AutomationCreateTarget) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
|
||||
@@ -78,6 +79,7 @@ export function AutomationEditorDialog({
|
||||
settings,
|
||||
draft,
|
||||
onProjectChange,
|
||||
getRepoHostLabel,
|
||||
onCreateTargetChange,
|
||||
onOpenChange,
|
||||
onDraftChange,
|
||||
@@ -166,6 +168,7 @@ export function AutomationEditorDialog({
|
||||
pickerTriggerClassName={PICKER_TRIGGER_CLASS}
|
||||
modeToggleItemClassName={MODE_TOGGLE_ITEM_CLASS}
|
||||
onProjectChange={onProjectChange}
|
||||
getRepoHostLabel={getRepoHostLabel}
|
||||
onDraftChange={onDraftChange}
|
||||
onOpenChange={onOpenChange}
|
||||
onSave={onSave}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import AgentCombobox from '@/components/agent/AgentCombobox'
|
||||
import RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AutomationWorkspaceMode } from '../../../../shared/automations-types'
|
||||
@@ -15,6 +14,7 @@ import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField'
|
||||
import { AutomationSessionField } from './AutomationSessionField'
|
||||
import { CreateFromPicker } from './CreateFromPicker'
|
||||
import { WorkspaceCombobox } from './WorkspaceCombobox'
|
||||
import AutomationProjectCombobox from './AutomationProjectCombobox'
|
||||
import type { AutomationDraft } from './AutomationEditorDialog'
|
||||
|
||||
type AutomationEditorDialogFooterProps = {
|
||||
@@ -34,6 +34,7 @@ type AutomationEditorDialogFooterProps = {
|
||||
pickerTriggerClassName: string
|
||||
modeToggleItemClassName: string
|
||||
onProjectChange: (projectId: string) => void
|
||||
getRepoHostLabel?: (repo: Repo) => string | null | undefined
|
||||
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: () => void
|
||||
@@ -56,6 +57,7 @@ export function AutomationEditorDialogFooter({
|
||||
pickerTriggerClassName,
|
||||
modeToggleItemClassName,
|
||||
onProjectChange,
|
||||
getRepoHostLabel,
|
||||
onDraftChange,
|
||||
onOpenChange,
|
||||
onSave
|
||||
@@ -69,7 +71,7 @@ export function AutomationEditorDialogFooter({
|
||||
'Project'
|
||||
)}
|
||||
>
|
||||
<RepoCombobox
|
||||
<AutomationProjectCombobox
|
||||
repos={repos}
|
||||
value={draft.projectId}
|
||||
onValueChange={onProjectChange}
|
||||
@@ -78,7 +80,7 @@ export function AutomationEditorDialogFooter({
|
||||
'Select project'
|
||||
)}
|
||||
triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`}
|
||||
showStandaloneAddButton={false}
|
||||
getRepoHostLabel={getRepoHostLabel}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronRight, ChevronsUpDown, FolderPlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandInput, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { searchRepos } from '@/lib/repo-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import {
|
||||
getAutomationProjectGroupForRepo,
|
||||
getAutomationProjectGroups,
|
||||
getAutomationProjectSelectedSource
|
||||
} from './automation-project-groups'
|
||||
|
||||
type AutomationProjectComboboxProps = {
|
||||
repos: Repo[]
|
||||
value: string
|
||||
onValueChange: (repoId: string) => void
|
||||
placeholder?: string
|
||||
triggerClassName?: string
|
||||
getRepoHostLabel?: (repo: Repo) => string | null | undefined
|
||||
}
|
||||
|
||||
function getRepoDetail(repo: Repo, hostLabel?: string | null): string {
|
||||
const label = hostLabel?.trim()
|
||||
return label ? `${label} · ${repo.path}` : repo.path
|
||||
}
|
||||
|
||||
function hasMultipleHosts(repos: readonly Repo[]): boolean {
|
||||
const hostIds = new Set<string>()
|
||||
for (const repo of repos) {
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
if (hostIds.size > 1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function hasMultipleHostsInGroup(sources: readonly Repo[]): boolean {
|
||||
return hasMultipleHosts(sources)
|
||||
}
|
||||
|
||||
export default function AutomationProjectCombobox({
|
||||
repos,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = 'Select project',
|
||||
triggerClassName,
|
||||
getRepoHostLabel
|
||||
}: AutomationProjectComboboxProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const [hostMenuProjectKey, setHostMenuProjectKey] = useState<string | null>(null)
|
||||
const hostMenuCloseTimerRef = useRef<number | null>(null)
|
||||
const hostMenuHoverRef = useRef<{
|
||||
projectKey: string | null
|
||||
row: boolean
|
||||
content: boolean
|
||||
}>({ projectKey: null, row: false, content: false })
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const focusFrameRef = useRef<number | null>(null)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const groups = useMemo(() => getAutomationProjectGroups(repos, value), [repos, value])
|
||||
const selectedGroup = useMemo(
|
||||
() => getAutomationProjectGroupForRepo(groups, value),
|
||||
[groups, value]
|
||||
)
|
||||
const selectedRepo = selectedGroup
|
||||
? getAutomationProjectSelectedSource(selectedGroup, value)
|
||||
: null
|
||||
const showHostLabels = useMemo(() => hasMultipleHosts(repos), [repos])
|
||||
const filteredGroups = useMemo(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return groups
|
||||
}
|
||||
return groups.filter((group) => searchRepos(group.sources, trimmed).length > 0)
|
||||
}, [groups, query])
|
||||
|
||||
const cancelFocusFrame = useCallback((): void => {
|
||||
if (focusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setInputNode = useCallback(
|
||||
(node: HTMLInputElement | null): void => {
|
||||
if (node === null) {
|
||||
cancelFocusFrame()
|
||||
}
|
||||
inputRef.current = node
|
||||
},
|
||||
[cancelFocusFrame]
|
||||
)
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
cancelFocusFrame()
|
||||
focusFrameRef.current = requestAnimationFrame(() => {
|
||||
focusFrameRef.current = null
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}, [cancelFocusFrame])
|
||||
|
||||
const clearHostMenuCloseTimer = useCallback(() => {
|
||||
if (hostMenuCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hostMenuCloseTimerRef.current)
|
||||
hostMenuCloseTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetHostMenuHover = useCallback(() => {
|
||||
hostMenuHoverRef.current = { projectKey: null, row: false, content: false }
|
||||
}, [])
|
||||
|
||||
const setHostMenuHover = useCallback(
|
||||
(projectKey: string, region: 'row' | 'content', hovered: boolean) => {
|
||||
clearHostMenuCloseTimer()
|
||||
if (hostMenuHoverRef.current.projectKey !== projectKey) {
|
||||
hostMenuHoverRef.current = { projectKey, row: false, content: false }
|
||||
}
|
||||
hostMenuHoverRef.current[region] = hovered
|
||||
if (hovered) {
|
||||
setHostMenuProjectKey(projectKey)
|
||||
return
|
||||
}
|
||||
hostMenuCloseTimerRef.current = window.setTimeout(() => {
|
||||
const hover = hostMenuHoverRef.current
|
||||
if (hover.projectKey === projectKey && !hover.row && !hover.content) {
|
||||
setHostMenuProjectKey((current) => (current === projectKey ? null : current))
|
||||
resetHostMenuHover()
|
||||
}
|
||||
hostMenuCloseTimerRef.current = null
|
||||
}, 100)
|
||||
},
|
||||
[clearHostMenuCloseTimer, resetHostMenuHover]
|
||||
)
|
||||
|
||||
useEffect(() => clearHostMenuCloseTimer, [clearHostMenuCloseTimer])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (nextOpen) {
|
||||
setCommandValue(value)
|
||||
return
|
||||
}
|
||||
cancelFocusFrame()
|
||||
setQuery('')
|
||||
setHostMenuProjectKey(null)
|
||||
resetHostMenuHover()
|
||||
},
|
||||
[cancelFocusFrame, resetHostMenuHover, value]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(repoId: string) => {
|
||||
onValueChange(repoId)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setHostMenuProjectKey(null)
|
||||
resetHostMenuHover()
|
||||
},
|
||||
[onValueChange, resetHostMenuHover]
|
||||
)
|
||||
|
||||
const handleAddFolder = useCallback(async () => {
|
||||
if (isAdding) {
|
||||
return
|
||||
}
|
||||
setIsAdding(true)
|
||||
try {
|
||||
const repo = await addRepo()
|
||||
if (repo) {
|
||||
if (isGitRepoKind(repo)) {
|
||||
await fetchWorktrees(repo.id)
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
handleSelect(repo.id)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}
|
||||
}, [addRepo, fetchWorktrees, handleSelect, isAdding, mountedRef])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<RepoBadgeLabel
|
||||
name={selectedRepo.displayName}
|
||||
color={selectedRepo.badgeColor}
|
||||
badgeClassName="size-1.5"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[16rem] p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
|
||||
<CommandInput
|
||||
ref={setInputNode}
|
||||
placeholder={translate(
|
||||
'auto.components.automations.AutomationProjectCombobox.search',
|
||||
'Search projects/folders...'
|
||||
)}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.automations.AutomationProjectCombobox.empty',
|
||||
'No projects/folders match your search.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredGroups.map((group) => {
|
||||
const selectedSource = getAutomationProjectSelectedSource(group, value)
|
||||
const selectedProject = group.sources.some((source) => source.id === value)
|
||||
const hasHostMenu = hasMultipleHostsInGroup(group.sources)
|
||||
const hostLabel = showHostLabels ? getRepoHostLabel?.(selectedSource) : null
|
||||
const detail = hasHostMenu
|
||||
? `${hostLabel?.trim() || getRepoExecutionHostId(selectedSource)} · ${group.sources.length} hosts`
|
||||
: getRepoDetail(selectedSource, hostLabel)
|
||||
return (
|
||||
<div
|
||||
key={group.projectKey}
|
||||
onMouseEnter={() => {
|
||||
setCommandValue(group.repo.id)
|
||||
if (hasHostMenu) {
|
||||
setHostMenuHover(group.projectKey, 'row', true)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hasHostMenu) {
|
||||
setHostMenuHover(group.projectKey, 'row', false)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group/automation-project-row flex items-stretch transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
commandValue === group.repo.id && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(selectedSource.id)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 text-foreground',
|
||||
selectedProject ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<RepoBadgeLabel
|
||||
name={group.repo.displayName}
|
||||
color={group.repo.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
</button>
|
||||
{hasHostMenu ? (
|
||||
<Popover
|
||||
open={hostMenuProjectKey === group.projectKey}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setHostMenuProjectKey(nextOpen ? group.projectKey : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={translate(
|
||||
'auto.components.automations.AutomationProjectCombobox.chooseHost',
|
||||
'Choose automation host'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="flex w-7 shrink-0 items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[min(260px,calc(100vw-1rem))] p-1"
|
||||
onMouseEnter={() => setHostMenuHover(group.projectKey, 'content', true)}
|
||||
onMouseLeave={() => setHostMenuHover(group.projectKey, 'content', false)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{group.sources.map((source) => {
|
||||
const sourceHostLabel = showHostLabels
|
||||
? getRepoHostLabel?.(source)
|
||||
: null
|
||||
const sourceSelected = source.id === selectedSource.id
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleSelect(source.id)}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground',
|
||||
sourceSelected ? 'opacity-70' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs">
|
||||
{sourceHostLabel ?? getRepoExecutionHostId(source)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{source.path}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
<div className="border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isAdding}
|
||||
onClick={() => void handleAddFolder()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setCommandValue('')}
|
||||
className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal"
|
||||
>
|
||||
<FolderPlus className="size-3.5 text-muted-foreground" />
|
||||
<span>
|
||||
{isAdding
|
||||
? translate(
|
||||
'auto.components.automations.AutomationProjectCombobox.adding',
|
||||
'Adding project…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationProjectCombobox.addProject',
|
||||
'Add project'
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
@@ -51,8 +53,14 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
buildAutomationCronSchedule,
|
||||
@@ -89,6 +97,27 @@ import {
|
||||
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
|
||||
import { AutomationRunHistory } from './AutomationRunHistory'
|
||||
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
|
||||
import { getAutomationTargetAvailability } from './automation-target-availability'
|
||||
import { buildAutomationRunContextForRepo } from './automation-run-context'
|
||||
import {
|
||||
getRepoBackedProviderAvailability,
|
||||
type RuntimeProviderPreflightStatus
|
||||
} from '../task-source-provider-availability'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import {
|
||||
getExternalAutomationActionDisabledMessage,
|
||||
getExternalAutomationSourceAvailability,
|
||||
isSshConnectionBusy
|
||||
} from './external-automation-source-availability'
|
||||
import {
|
||||
createAutomationForTarget,
|
||||
deleteAutomationForTarget,
|
||||
getAutomationListTarget,
|
||||
listAutomationRunsForTarget,
|
||||
listAutomationsForTarget,
|
||||
runAutomationNowForTarget,
|
||||
updateAutomationForTarget
|
||||
} from './automation-host-client'
|
||||
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
|
||||
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
|
||||
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
|
||||
@@ -99,6 +128,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id)
|
||||
const DEFAULT_TIME = '09:00'
|
||||
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
|
||||
type AutomationPaneTab = 'overview' | 'runs'
|
||||
type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' }
|
||||
|
||||
type ExternalAutomationListEntry =
|
||||
| {
|
||||
@@ -123,6 +153,46 @@ function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
|
||||
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
|
||||
}
|
||||
|
||||
function getRepoBackedAutomationSourceContext(
|
||||
automation: Automation
|
||||
): RepoBackedAutomationSourceContext | null {
|
||||
const context = automation.sourceContext
|
||||
return context?.provider === 'github' || context?.provider === 'gitlab'
|
||||
? (context as RepoBackedAutomationSourceContext)
|
||||
: null
|
||||
}
|
||||
|
||||
function getRuntimeSourceHostAvailability(
|
||||
context: TaskSourceContext,
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
string,
|
||||
{ status: RuntimeStatus | null; checkedAt: number }
|
||||
>
|
||||
): TaskSourceHostAvailability | null {
|
||||
const parsed = parseExecutionHostId(context.hostId)
|
||||
if (parsed?.kind !== 'runtime') {
|
||||
return null
|
||||
}
|
||||
const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId)
|
||||
if (!entry) {
|
||||
return { hostId: context.hostId, reason: 'checking-task-source-capability' }
|
||||
}
|
||||
if (!entry.status) {
|
||||
return { hostId: context.hostId, health: 'disconnected' }
|
||||
}
|
||||
if (entry.status.graphStatus !== 'ready') {
|
||||
return { hostId: context.hostId, health: 'connecting' }
|
||||
}
|
||||
const capabilities = entry.status.capabilities
|
||||
if (!capabilities) {
|
||||
return { hostId: context.hostId, reason: 'checking-task-source-capability' }
|
||||
}
|
||||
if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) {
|
||||
return { hostId: context.hostId, reason: 'missing-task-source-capability' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatTimeInput(hour: number, minute: number): string {
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
@@ -191,11 +261,7 @@ function getExternalProviderLabel(manager: ExternalAutomationManager): string {
|
||||
}
|
||||
|
||||
function getExternalTargetKindLabel(manager: ExternalAutomationManager): string {
|
||||
return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local'
|
||||
}
|
||||
|
||||
function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean {
|
||||
return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
|
||||
return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
|
||||
}
|
||||
|
||||
function getExternalRunStatusLabel(run: ExternalAutomationRun): string {
|
||||
@@ -257,6 +323,7 @@ async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number)
|
||||
|
||||
export default function AutomationsPage(): React.JSX.Element {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
@@ -269,7 +336,17 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
|
||||
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
|
||||
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const expectedPreflightContextKey = useAppStore((s) =>
|
||||
localPreflightContextKey(getLocalPreflightContext(s))
|
||||
)
|
||||
const selectedId = useAppStore((s) => s.selectedAutomationId)
|
||||
const setSelectedId = useAppStore((s) => s.setSelectedAutomationId)
|
||||
const repoMap = useRepoMap()
|
||||
@@ -306,6 +383,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null)
|
||||
const [selectedExternalRunPage, setSelectedExternalRunPage] =
|
||||
useState<SelectedExternalRunPage | null>(null)
|
||||
const runtimePreflightMountedRef = useRef(true)
|
||||
const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set())
|
||||
const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState<
|
||||
ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus>
|
||||
>(() => new Map())
|
||||
const selectAutomationId = useCallback(
|
||||
(automationId: string | null): void => {
|
||||
setSelectedAutomationRunPageId(null)
|
||||
@@ -482,9 +564,142 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage })
|
||||
const isSelectedAutomationRunPageRerunPending =
|
||||
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
|
||||
const selectedRepo = selected ? (repoMap.get(selected.projectId) ?? null) : null
|
||||
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
|
||||
const repoBackedAutomationSourceContexts = useMemo(
|
||||
() =>
|
||||
automations
|
||||
.map((automation) => getRepoBackedAutomationSourceContext(automation))
|
||||
.filter((context): context is RepoBackedAutomationSourceContext => context !== null),
|
||||
[automations]
|
||||
)
|
||||
const runtimeAutomationSourceHostIds = useMemo(() => {
|
||||
const hostIds = new Set<TaskSourceContext['hostId']>()
|
||||
for (const context of repoBackedAutomationSourceContexts) {
|
||||
const parsed = parseExecutionHostId(context.hostId)
|
||||
if (parsed?.kind !== 'runtime') {
|
||||
continue
|
||||
}
|
||||
const hostAvailability = getRuntimeSourceHostAvailability(
|
||||
context,
|
||||
runtimeStatusByEnvironmentId
|
||||
)
|
||||
if (hostAvailability) {
|
||||
continue
|
||||
}
|
||||
hostIds.add(parsed.id)
|
||||
}
|
||||
return [...hostIds].sort()
|
||||
}, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId])
|
||||
useEffect(
|
||||
() => () => {
|
||||
runtimePreflightMountedRef.current = false
|
||||
},
|
||||
[]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!preflightStatusCurrent || !preflightStatusChecked) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
}, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus])
|
||||
useEffect(() => {
|
||||
const unrequestedHostIds = runtimeAutomationSourceHostIds.filter(
|
||||
(hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId)
|
||||
)
|
||||
if (unrequestedHostIds.length === 0) {
|
||||
return
|
||||
}
|
||||
setRuntimePreflightStatusByHostId((current) => {
|
||||
const next = new Map(current)
|
||||
for (const hostId of unrequestedHostIds) {
|
||||
next.set(hostId, { checked: false, status: null })
|
||||
}
|
||||
return next
|
||||
})
|
||||
for (const hostId of unrequestedHostIds) {
|
||||
runtimePreflightRequestedHostIdsRef.current.add(hostId)
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind !== 'runtime') {
|
||||
continue
|
||||
}
|
||||
// Why: automation sources can be owned by a different remote server than
|
||||
// the run target; provider auth/tooling must be checked on the source host.
|
||||
void callRuntimeRpc<PreflightStatus>(
|
||||
{ kind: 'environment', environmentId: parsed.environmentId },
|
||||
'preflight.check',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
.then((status) => {
|
||||
if (!runtimePreflightMountedRef.current) {
|
||||
return
|
||||
}
|
||||
setRuntimePreflightStatusByHostId((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(hostId, { checked: true, status })
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!runtimePreflightMountedRef.current) {
|
||||
return
|
||||
}
|
||||
setRuntimePreflightStatusByHostId((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(hostId, { checked: true, status: null })
|
||||
return next
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [runtimeAutomationSourceHostIds])
|
||||
const automationSourceHostAvailabilityById = useMemo(() => {
|
||||
const availabilityById = new Map<string, TaskSourceHostAvailability[]>()
|
||||
for (const automation of automations) {
|
||||
const context = getRepoBackedAutomationSourceContext(automation)
|
||||
if (!context) {
|
||||
continue
|
||||
}
|
||||
const hostAvailability = getRuntimeSourceHostAvailability(
|
||||
context,
|
||||
runtimeStatusByEnvironmentId
|
||||
)
|
||||
const providerAvailability = getRepoBackedProviderAvailability({
|
||||
provider: context.provider,
|
||||
contexts: [context],
|
||||
preflightStatus,
|
||||
preflightReady: preflightStatusCurrent && preflightStatusChecked,
|
||||
runtimePreflightStatusByHostId
|
||||
})
|
||||
const availability = [
|
||||
...(hostAvailability ? [hostAvailability] : []),
|
||||
...providerAvailability
|
||||
]
|
||||
if (availability.length > 0) {
|
||||
availabilityById.set(automation.id, availability)
|
||||
}
|
||||
}
|
||||
return availabilityById
|
||||
}, [
|
||||
automations,
|
||||
preflightStatus,
|
||||
preflightStatusChecked,
|
||||
preflightStatusCurrent,
|
||||
runtimePreflightStatusByHostId,
|
||||
runtimeStatusByEnvironmentId
|
||||
])
|
||||
const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null
|
||||
const selectedWorktree =
|
||||
selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null
|
||||
const selectedRunNowAvailability = selected
|
||||
? getAutomationTargetAvailability({
|
||||
automation: selected,
|
||||
repo: selectedRepo,
|
||||
workspace: selectedWorktree,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id)
|
||||
})
|
||||
: null
|
||||
const canSaveDraft =
|
||||
editingAutomationId === null ||
|
||||
!draftAtOpen ||
|
||||
@@ -497,10 +712,56 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
sourceKey: getExternalAutomationSourceKey(selectedExternal.manager)
|
||||
}
|
||||
: null
|
||||
const selectedExternalSshStatus = selectedExternalSshSource
|
||||
? sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status
|
||||
: undefined
|
||||
const selectedExternalSshConnected = selectedExternalSshStatus === 'connected'
|
||||
const isSelectedExternalSshConnecting =
|
||||
selectedExternalSshSource !== null &&
|
||||
(connectingExternalSourceKey === selectedExternalSshSource.sourceKey ||
|
||||
isSshConnectionBusy(sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status))
|
||||
isSshConnectionBusy(selectedExternalSshStatus))
|
||||
const selectedExternalSourceAvailability =
|
||||
selectedExternal?.kind === 'source'
|
||||
? getExternalAutomationSourceAvailability({
|
||||
manager: selectedExternal.manager,
|
||||
providerLabel: getExternalProviderLabel(selectedExternal.manager),
|
||||
targetKindLabel: getExternalTargetKindLabel(selectedExternal.manager),
|
||||
sshStatus: selectedExternalSshStatus,
|
||||
isConnectingOverride: isSelectedExternalSshConnecting
|
||||
})
|
||||
: null
|
||||
|
||||
const getAutomationRepoHostLabel = useCallback(
|
||||
(repo: Repo): string => {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return sshTargetLabels.get(parsed.targetId) ?? parsed.targetId
|
||||
}
|
||||
if (parsed?.kind === 'runtime') {
|
||||
return (
|
||||
runtimeEnvironments.find((environment) => environment.id === parsed.environmentId)
|
||||
?.name ?? parsed.environmentId
|
||||
)
|
||||
}
|
||||
return 'Local Mac'
|
||||
},
|
||||
[runtimeEnvironments, sshTargetLabels]
|
||||
)
|
||||
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
|
||||
const hostLabelById = useMemo(() => {
|
||||
const labels = new Map<string, string>([['local', 'Local Mac']])
|
||||
for (const [targetId, label] of sshTargetLabels) {
|
||||
labels.set(`ssh:${encodeURIComponent(targetId)}`, label)
|
||||
}
|
||||
for (const environment of runtimeEnvironments) {
|
||||
labels.set(`runtime:${encodeURIComponent(environment.id)}`, environment.name)
|
||||
}
|
||||
for (const [hostId, label] of hostLabelOverrides) {
|
||||
labels.set(hostId, label)
|
||||
}
|
||||
return labels
|
||||
}, [hostLabelOverrides, runtimeEnvironments, sshTargetLabels])
|
||||
|
||||
useEffect(() => {
|
||||
if ((!selected || selectedExternal) && activePaneTab === 'runs') {
|
||||
@@ -525,10 +786,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
const automationHostTarget = getAutomationListTarget(settings)
|
||||
try {
|
||||
const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([
|
||||
window.api.automations.list(),
|
||||
window.api.automations.listRuns(),
|
||||
listAutomationsForTarget(automationHostTarget),
|
||||
listAutomationRunsForTarget(automationHostTarget),
|
||||
window.api.automations.listExternalManagers()
|
||||
])
|
||||
const currentSelectedId = useAppStore.getState().selectedAutomationId
|
||||
@@ -539,7 +801,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
? currentSelectedId
|
||||
: (nextAutomations[0]?.id ?? null)
|
||||
const nextSelectedRuns = nextSelectedId
|
||||
? await window.api.automations.listRuns({ automationId: nextSelectedId })
|
||||
? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId)
|
||||
: []
|
||||
setAutomations(nextAutomations)
|
||||
setRuns(nextRuns)
|
||||
@@ -554,7 +816,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectAutomationId])
|
||||
}, [selectAutomationId, settings])
|
||||
|
||||
const hydratePersistedUIState = useCallback(async (): Promise<void> => {
|
||||
useAppStore.getState().hydratePersistedUI(await window.api.ui.get())
|
||||
@@ -577,15 +839,17 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void window.api.automations.listRuns({ automationId }).then((nextRuns) => {
|
||||
if (!cancelled) {
|
||||
setSelectedAutomationRuns({ automationId, runs: nextRuns })
|
||||
void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then(
|
||||
(nextRuns) => {
|
||||
if (!cancelled) {
|
||||
setSelectedAutomationRuns({ automationId, runs: nextRuns })
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selected?.id, runs])
|
||||
}, [selected?.id, runs, settings])
|
||||
|
||||
useEffect(() => {
|
||||
const onAutomationsChanged = (): void => {
|
||||
@@ -783,7 +1047,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
name: latest.name,
|
||||
prompt: latest.prompt,
|
||||
agentId: latest.agentId,
|
||||
projectId: latest.projectId,
|
||||
projectId: getAutomationRunRepoId(latest),
|
||||
workspaceMode: latest.workspaceMode,
|
||||
workspaceId: latest.workspaceId ?? '',
|
||||
baseBranch: latest.baseBranch ?? '',
|
||||
@@ -1035,13 +1299,27 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
? Math.max(0, rawMissedRunGraceMinutes)
|
||||
: 720
|
||||
const precheck = buildDraftPrecheck(draft)
|
||||
const runContext = buildAutomationRunContextForRepo({
|
||||
repoId: draft.projectId,
|
||||
repos,
|
||||
projectHostSetups
|
||||
})
|
||||
if (!runContext) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.32534e7c9c',
|
||||
'Choose an available workspace before saving.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
let currentAutomation = editingAutomationId
|
||||
? (automations.find((automation) => automation.id === editingAutomationId) ?? null)
|
||||
: null
|
||||
if (editingAutomationId) {
|
||||
try {
|
||||
currentAutomation =
|
||||
(await window.api.automations.list()).find(
|
||||
(await listAutomationsForTarget(getAutomationListTarget(settings))).find(
|
||||
(automation) => automation.id === editingAutomationId
|
||||
) ?? currentAutomation
|
||||
} catch {
|
||||
@@ -1053,6 +1331,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
prompt: draft.prompt,
|
||||
precheck,
|
||||
agentId: draft.agentId,
|
||||
runContext,
|
||||
projectId: draft.projectId,
|
||||
workspaceMode: draft.workspaceMode,
|
||||
workspaceId: draft.workspaceId,
|
||||
@@ -1067,15 +1346,18 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
updates.dtstart = now
|
||||
}
|
||||
const automation = editingAutomationId
|
||||
? await window.api.automations.update({
|
||||
id: editingAutomationId,
|
||||
updates
|
||||
})
|
||||
: await window.api.automations.create({
|
||||
? currentAutomation
|
||||
? await updateAutomationForTarget(currentAutomation, updates)
|
||||
: await window.api.automations.update({
|
||||
id: editingAutomationId,
|
||||
updates
|
||||
})
|
||||
: await createAutomationForTarget({
|
||||
name: draft.name,
|
||||
prompt: draft.prompt,
|
||||
precheck,
|
||||
agentId: draft.agentId,
|
||||
runContext,
|
||||
projectId: draft.projectId,
|
||||
workspaceMode: draft.workspaceMode,
|
||||
workspaceId: draft.workspaceId,
|
||||
@@ -1126,15 +1408,12 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const toggleAutomation = async (automation: Automation): Promise<void> => {
|
||||
await window.api.automations.update({
|
||||
id: automation.id,
|
||||
updates: { enabled: !automation.enabled }
|
||||
})
|
||||
await updateAutomationForTarget(automation, { enabled: !automation.enabled })
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const deleteAutomation = async (automation: Automation): Promise<void> => {
|
||||
await window.api.automations.delete({ id: automation.id })
|
||||
await deleteAutomationForTarget(automation)
|
||||
if (useAppStore.getState().selectedAutomationId === automation.id) {
|
||||
selectAutomationId(null)
|
||||
}
|
||||
@@ -1195,7 +1474,24 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const runNow = async (automation: Automation): Promise<void> => {
|
||||
await window.api.automations.runNow({ id: automation.id })
|
||||
const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null
|
||||
const workspace = automation.workspaceId
|
||||
? (worktreeMap.get(automation.workspaceId) ?? null)
|
||||
: null
|
||||
const availability = getAutomationTargetAvailability({
|
||||
automation,
|
||||
repo,
|
||||
workspace,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
|
||||
})
|
||||
if (!availability.canRunNow) {
|
||||
toast.error(availability.message)
|
||||
return
|
||||
}
|
||||
await runAutomationNowForTarget(automation)
|
||||
useAppStore.getState().recordFeatureInteraction('automation-run')
|
||||
await hydratePersistedUIState()
|
||||
await refresh()
|
||||
@@ -1205,7 +1501,6 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const rerunAutomationRun = async (automation: Automation, run: AutomationRun): Promise<void> => {
|
||||
const automationId = automation.id
|
||||
const runId = run.id
|
||||
if (rerunRunIdsInFlightRef.current.has(runId)) {
|
||||
return
|
||||
@@ -1214,7 +1509,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
rerunRunIdsInFlightRef.current.add(runId)
|
||||
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
|
||||
try {
|
||||
await window.api.automations.runNow({ id: automationId })
|
||||
await runAutomationNowForTarget(automation)
|
||||
await hydratePersistedUIState()
|
||||
await refresh()
|
||||
toast.message(
|
||||
@@ -1373,6 +1668,16 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const sourceKey = getExternalAutomationSourceKey(manager)
|
||||
setConnectingExternalSourceKey(sourceKey)
|
||||
try {
|
||||
if (sshConnectionStates.get(manager.target.connectionId)?.status === 'connected') {
|
||||
await refresh()
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.a21f6c33ad',
|
||||
'Automation source refreshed.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const state = await window.api.ssh.connect({ targetId: manager.target.connectionId })
|
||||
if (!state || state.status !== 'connected') {
|
||||
toast.error(
|
||||
@@ -1568,6 +1873,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
settings={settings}
|
||||
draft={draft}
|
||||
onProjectChange={handleProjectChange}
|
||||
getRepoHostLabel={getAutomationRepoHostLabel}
|
||||
onCreateTargetChange={handleCreateTargetChange}
|
||||
onOpenChange={setCreateOpen}
|
||||
onDraftChange={setDraft}
|
||||
@@ -1753,10 +2059,19 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
{automations.map((automation) => {
|
||||
const automationRepo = repoMap.get(automation.projectId)
|
||||
const automationRepo = repoMap.get(getAutomationRunRepoId(automation))
|
||||
const automationWorktree = automation.workspaceId
|
||||
? worktreeMap.get(automation.workspaceId)
|
||||
: null
|
||||
const automationRunAvailability = getAutomationTargetAvailability({
|
||||
automation,
|
||||
repo: automationRepo,
|
||||
workspace: automationWorktree,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
|
||||
})
|
||||
const workspaceLabel =
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}`
|
||||
@@ -1836,12 +2151,25 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onSelect={() => void runNow(automation)}>
|
||||
<ContextMenuItem
|
||||
disabled={!automationRunAvailability.canRunNow}
|
||||
onSelect={(event) => {
|
||||
if (!automationRunAvailability.canRunNow) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
void runNow(automation)
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{automationRunAvailability.canRunNow
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)
|
||||
: automationRunAvailability.message}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => void openEditDialog(automation)}>
|
||||
<Pencil className="size-3.5" />
|
||||
@@ -1882,11 +2210,16 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const providerLabel = getExternalProviderLabel(entry.manager)
|
||||
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
|
||||
if (entry.kind === 'source') {
|
||||
const sourceStatus =
|
||||
entry.manager.target.type === 'ssh' ? 'Connect to load jobs' : 'Unavailable'
|
||||
const sourceSummary =
|
||||
entry.manager.error ??
|
||||
`${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.`
|
||||
const sshStatus =
|
||||
entry.manager.target.type === 'ssh'
|
||||
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
|
||||
: undefined
|
||||
const sourceAvailability = getExternalAutomationSourceAvailability({
|
||||
manager: entry.manager,
|
||||
providerLabel,
|
||||
targetKindLabel,
|
||||
sshStatus
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
@@ -1919,12 +2252,12 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
<span className="truncate">{targetKindLabel}</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-muted-foreground">
|
||||
{sourceSummary}
|
||||
{sourceAvailability.summary}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
|
||||
<Clock className="size-3.5" />
|
||||
<span className="line-clamp-2">{sourceStatus}</span>
|
||||
<span className="line-clamp-2">{sourceAvailability.statusLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
@@ -1932,7 +2265,18 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const nextRunLabel = entry.job.enabled
|
||||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: 'Paused'
|
||||
const actionDisabled = !entry.manager.canManage || externalActionKey !== null
|
||||
const entrySshStatus =
|
||||
entry.manager.target.type === 'ssh'
|
||||
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
|
||||
: undefined
|
||||
const disabledMessage = getExternalAutomationActionDisabledMessage({
|
||||
manager: entry.manager,
|
||||
providerLabel,
|
||||
targetKindLabel,
|
||||
sshStatus: entrySshStatus,
|
||||
actionInProgress: externalActionKey !== null
|
||||
})
|
||||
const actionDisabled = disabledMessage !== null
|
||||
const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job)
|
||||
return (
|
||||
<ContextMenu key={entry.key}>
|
||||
@@ -1995,10 +2339,13 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
onSelect={() => requestExternalAction(entry.manager, entry.job, 'run')}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{disabledMessage ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
{entry.manager.provider === 'hermes' ? (
|
||||
<ContextMenuItem
|
||||
@@ -2134,14 +2481,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
{selectedExternal.manager.targetLabel}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getExternalProviderLabel(selectedExternal.manager)}{' '}
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.aaa007846f',
|
||||
'source unavailable'
|
||||
)}
|
||||
{selectedExternal.manager.error
|
||||
? ` - ${selectedExternal.manager.error}`
|
||||
: null}
|
||||
{selectedExternalSourceAvailability?.summary}
|
||||
</div>
|
||||
</div>
|
||||
{selectedExternalSshSource ? (
|
||||
@@ -2149,31 +2489,33 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSelectedExternalSshConnecting}
|
||||
disabled={selectedExternalSourceAvailability?.isConnecting ?? false}
|
||||
onClick={() =>
|
||||
void connectExternalAutomationSource(selectedExternalSshSource.manager)
|
||||
}
|
||||
>
|
||||
{isSelectedExternalSshConnecting ? (
|
||||
{selectedExternalSourceAvailability?.isConnecting ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
{isSelectedExternalSshConnecting
|
||||
{selectedExternalSourceAvailability?.isConnecting
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.f93ed7a6f8',
|
||||
'Connecting...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.7934ee0d81',
|
||||
'Connect SSH'
|
||||
)}
|
||||
: selectedExternalSshConnected
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.53f06f0ad5',
|
||||
'Retry source'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.7934ee0d81',
|
||||
'Connect SSH'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.97ff587ee3',
|
||||
'Connect this source to check for Hermes automations in the remote profile.'
|
||||
)}
|
||||
{selectedExternalSourceAvailability?.detail}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2213,6 +2555,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
? 'New workspace each run'
|
||||
: (selectedWorktree?.displayName ?? 'Missing workspace')
|
||||
}
|
||||
hostLabelById={hostLabelById}
|
||||
runNowAvailability={selectedRunNowAvailability}
|
||||
now={relativeNow}
|
||||
onRunNow={(automation) => void runNow(automation)}
|
||||
onEdit={(automation) => void openEditDialog(automation)}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { CreateFromPicker } from './CreateFromPicker'
|
||||
import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefs
|
||||
} from '@/runtime/runtime-repo-client'
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/command', () => ({
|
||||
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandInput: () => <input />,
|
||||
CommandItem: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
|
||||
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
const storeState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
|
||||
repos: [] as Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-repo-client', () => ({
|
||||
getRuntimeRepoBaseRefDefault: vi.fn().mockResolvedValue({
|
||||
defaultBaseRef: 'main',
|
||||
remoteCount: 1
|
||||
}),
|
||||
searchRuntimeRepoBaseRefs: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function repoMapFor(repo: Repo): Map<string, Repo> {
|
||||
return new Map([[repo.id, repo]])
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo>): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPicker(repo: Repo): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CreateFromPicker
|
||||
repoId={repo.id}
|
||||
repoMap={repoMapFor(repo)}
|
||||
worktrees={[]}
|
||||
value=""
|
||||
onValueChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('CreateFromPicker host routing', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
storeState.repos = []
|
||||
})
|
||||
|
||||
it('uses the selected runtime-owned repo host instead of the focused runtime', async () => {
|
||||
const repo = makeRepo({ executionHostId: 'runtime:owner-runtime' })
|
||||
storeState.repos = [repo]
|
||||
|
||||
await renderPicker(repo)
|
||||
|
||||
expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'owner-runtime' },
|
||||
repo.id
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an explicit local repo on the local client even when a runtime is focused', async () => {
|
||||
const repo = makeRepo({ executionHostId: 'local' })
|
||||
storeState.repos = [repo]
|
||||
|
||||
await renderPicker(repo)
|
||||
|
||||
expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
repo.id
|
||||
)
|
||||
expect(searchRuntimeRepoBaseRefs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner'
|
||||
import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefs
|
||||
@@ -40,8 +41,8 @@ export function CreateFromPicker({
|
||||
triggerClassName?: string
|
||||
onValueChange: (baseBranch: string) => void
|
||||
}): React.JSX.Element {
|
||||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(state) => state.settings?.activeRuntimeEnvironmentId ?? null
|
||||
const activeRuntimeEnvironmentId = useAppStore((state) =>
|
||||
getRuntimeEnvironmentIdForRepo(state, repoId)
|
||||
)
|
||||
const repo = repoMap.get(repoId)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user