diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 325ab73a847..03d2c76ccc0 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build # Contents: Read and write. The secret names below are historical — one App, one # private key, both dev-channel repos — and rotating it stays a single operation. # Provision with `bash config/scripts/setup-hourly-release-token.sh`. +# +# The requested ref is vetted before checkout: it must be a branch or tag of this +# repo, or a commit reachable from one. PR refs are refused outright — this +# workflow runs the checked-out code next to MAC_CERTS and the notary password, +# so "just build that community PR" must not become a way to hand fork code the +# release identity. A branch here always belongs to someone with write access, +# which is the same trust the dispatch button itself already requires. on: workflow_dispatch: @@ -45,7 +52,7 @@ on: # than the one the workflow file itself is read from — normally leave the # picker on main and name your branch here, so a stale copy of this # workflow on an old branch is not what runs. - description: 'Branch, tag, or SHA to build (default: the branch selected above)' + description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)' required: false default: '' type: string @@ -77,6 +84,14 @@ env: jobs: build-adhoc-mac: if: github.repository == 'stablyai/orca' + # Why an environment: it gives the signing/notary/App secrets somewhere to + # live that a stale copy of this workflow on an old branch cannot reach. + # Referencing it is a no-op until repo settings give it teeth; the intended + # follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID, + # APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and + # HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch + # policy to main so only main's copy of this file can read them. + environment: adhoc-mac-build runs-on: blacksmith-6vcpu-macos-15 # Why 150: it must exceed the worst case the retry budgets below can produce # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or @@ -85,6 +100,57 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=4096 steps: + # Why vet before checkout: everything after this step runs the checked-out + # code with release signing credentials in reach. Branches and tags of this + # repo are the intended audience; refs/pull/* would smuggle in fork code, + # and a raw SHA is only accepted when some branch or tag of this repo can + # actually reach it. Resolving to a pinned SHA here also means the commit + # that was vetted is the commit that gets checked out — a push to the + # branch between the two steps cannot swap it. + - name: Vet the requested ref + id: vetted + shell: bash + env: + REQUESTED_REF: ${{ inputs.ref || github.ref_name }} + REPO_URL: https://github.com/${{ github.repository }} + run: | + set -euo pipefail + case "$REQUESTED_REF" in + refs/pull/*|pull/*) + echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead." + exit 1 + ;; + esac + # Bare: a work-tree repo refuses to fetch over its own checked-out + # branch. tree:0 keeps the fetch to the commit graph — no trees, no + # blobs — so this stays cheap next to the build it fronts. + scratch="$RUNNER_TEMP/vet-requested-ref" + git init -q --bare "$scratch" + git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' + # Branch first to keep actions/checkout's old tie-break: bare + # rev-parse would prefer the tag when a branch shares its name. + sha="" + for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do + if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then + break + fi + sha="" + done + if [[ -z "$sha" ]]; then + echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca." + exit 1 + fi + # The object resolving locally is not proof a branch or tag reaches + # it: a partial clone can lazily fetch a bare SHA on demand, and + # GitHub serves PR-only commits by SHA. Reachability is the actual + # trust test. + if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then + echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials." + exit 1 + fi + echo "Vetted $REQUESTED_REF -> $sha" + echo "sha=$sha" >>"$GITHUB_OUTPUT" + - name: Checkout the requested ref uses: actions/checkout@v6 with: @@ -92,7 +158,7 @@ jobs: # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. - ref: ${{ inputs.ref || github.ref_name }} + ref: ${{ steps.vetted.outputs.sha }} fetch-depth: 0 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not @@ -146,17 +212,26 @@ jobs: env: REF: ${{ inputs.ref || github.ref_name }} LABEL: ${{ inputs.label }} + MAIN_REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Why this check: the version script is read from the branch being built, + # Why this check: the version scripts are read from the branch being built, # not from main, so a branch cut before the adhoc channel landed has no - # copy of it. Say that plainly instead of failing with a module-not-found. - if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then - echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel." - exit 1 - fi + # copy of them. Say that plainly instead of failing with a module-not-found. + for script in adhoc-build-version dev-channel-base-version; do + if [[ ! -f "config/scripts/$script.mjs" ]]; then + echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel." + exit 1 + fi + done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \ + # Why the main repo's tags: package.json on a branch is as stale as the + # main it forked from, and stable patches never merge back into it. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ + node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow." diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index ebe544436a6..bc3c5a8afff 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -179,6 +179,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail # Why read the highest number off existing titles rather than counting @@ -195,7 +196,19 @@ jobs: --jq '[.[] | (.name // "") | capture(" • (?[0-9]+) • ")? | .n | tonumber] | max // 0')" build_number=$(( last_number + 1 )) echo "Hourly build number $build_number (previous high: $last_number)" - ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # hourly repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" \ + ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" # Why check rather than trust: the checkout above pins `ref: main`, but a # workflow_dispatch runs this file from whatever branch was dispatched. A diff --git a/.gitignore b/.gitignore index 2918ae32a69..b1de9292f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index bfb1617f8ea..6bc54a6f5c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch index eab0cd58452..caa96484d89 100644 --- a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch @@ -1,28 +1,64 @@ diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js -index d669293d06d801ef472d5c2454ce86876e5c321f..96bb66cdc05289b6a7ce5ed64fc833624c885c57 100644 +index d669293d06d801ef472d5c2454ce86876e5c321f..18f72336a60d498f685e2cc32b7a09dbe16958e5 100644 --- a/lib/addon-serialize.js +++ b/lib/addon-serialize.js @@ -1,2 +1,2 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); -+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&(a?this._nullCellCount<=1:this._nullCellCount<=0))&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",e.length-this._nullCellCount>0&&(r+=`[${e.length-this._nullCellCount}C`),r+=`[${this._nullCellCount}X`,e.length-this._nullCellCount>0&&(r+=`[${e.length-this._nullCellCount}D`),r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),c=i&&t.isInverse()?this._buffer.getLine(r+1):void 0,p=c?.getCell(0,this._nextRowFirstChar),f=s===this._terminal.cols-1&&c?.isWrapped&&(p?.getWidth()??0)>1&&!!p&&h(t,p),a=i&&!!t.isInverse()&&!f,d=a&&(!!t.isUnderline()||!!t.isStrikethrough()||!!t.isOverline()),n=this._diffStyle(t,this._cursorStyle);if(i?a?n.length>0:!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i&&!a?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),(a?(d&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(t.getWidth()),d&&(this._currentRow+=`\x1B[0m\x1B[${this._diffStyle(t,this._defaultCell).join(";")}m`)):this._currentRow+=t.getChars()),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); //# sourceMappingURL=addon-serialize.js.map \ No newline at end of file diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs -index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..475e45b3ed228027531deea5530bd03859f4a4e0 100644 +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..bb46224439629de3845c0b56ab9e4cd397dfe098 100644 --- a/lib/addon-serialize.mjs +++ b/lib/addon-serialize.mjs -@@ -15,5 +15,5 @@ +@@ -14,6 +14,6 @@ + * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r +-var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r -`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; -+`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; ++var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&(f?this._nullCellCount<=1:this._nullCellCount<=0))&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",l.length-this._nullCellCount>0&&(r+=`\x1B[${l.length-this._nullCellCount}C`),r+=`\x1B[${this._nullCellCount}X`,l.length-this._nullCellCount>0&&(r+=`\x1B[${l.length-this._nullCellCount}D`),r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",f=i&&e.isInverse()?this._buffer.getLine(r+1):void 0,g=f?.getCell(0,this._nextRowFirstChar),C=l===this._terminal.cols-1&&f?.isWrapped&&(g?.getWidth()??0)>1&&!!g&&U(e,g),q=i&&!!e.isInverse()&&!C,d=q&&(!!e.isUnderline()||!!e.isStrikethrough()||!!e.isOverline()),o=this._diffStyle(e,this._cursorStyle);if(i?q?o.length>0:!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i&&!q?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),(q?(d&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(e.getWidth()),d&&(this._currentRow+=`\x1B[0m\x1B[${this._diffStyle(e,this._defaultCell).join(";")}m`)):this._currentRow+=e.getChars()),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; //# sourceMappingURL=addon-serialize.mjs.map diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts -index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbeccd43cfaae 100644 +index e1728feb219c362dfa2ecb602ff99f830d520757..beaff8dd2c985a1e7378d42683c9940865057d6a 100644 --- a/src/SerializeAddon.ts +++ b/src/SerializeAddon.ts -@@ -310,7 +310,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -148,6 +148,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + + // this is a null cell for reference for checking whether background is empty or not + private _backgroundCell: IBufferCell = this._buffer.getNullCell(); ++ private _defaultCell: IBufferCell = this._buffer.getNullCell(); + + private _firstRow: number = 0; + private _lastCursorRow: number = 0; +@@ -214,7 +215,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + if ( + // you must output character to cause overflow, control sequence can't do this + nextRowFirstChar.getChars() && +- isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0 ++ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) + ) { + if ( + // the last character can't be null, +@@ -251,9 +252,14 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (this._nullCellCount > 0) { + // do these because we filled the last several null slot, which we shouldn't + rowSeparator += '\u001b[A'; +- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`; ++ const contentCellCount = currentLine.length - this._nullCellCount; ++ if (contentCellCount > 0) { ++ rowSeparator += `\u001b[${contentCellCount}C`; ++ } + rowSeparator += `\u001b[${this._nullCellCount}X`; +- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`; ++ if (contentCellCount > 0) { ++ rowSeparator += `\u001b[${contentCellCount}D`; ++ } + rowSeparator += '\u001b[B'; + } + +@@ -310,7 +316,20 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (flagsChanged) { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } @@ -44,7 +80,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbecc if (!equalUnderline(cell, oldCell)) { const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { -@@ -337,7 +350,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -337,7 +356,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } @@ -53,3 +89,58 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbecc if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } } } +@@ -356,12 +375,21 @@ class StringSerializeHandler extends BaseSerializeHandler { + + // this cell don't have content + const isEmptyCell = cell.getChars() === ''; ++ const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined; ++ const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar); ++ // A pending wide glyph recreates its own final-column padding during replay. ++ const isWideWrapPadding = col === this._terminal.cols - 1 && ++ nextLine?.isWrapped && ++ (nextRowFirstCell?.getWidth() ?? 0) > 1 && ++ !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell); ++ // Cursor movement cannot reproduce an inverse cell's visible background. ++ const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding; + + const sgrSeq = this._diffStyle(cell, this._cursorStyle); + +- // the empty cell style is only assumed to be changed when background changed, because +- // foreground is always 0. +- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0; ++ const styleChanged = isEmptyCell ++ ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell) ++ : sgrSeq.length > 0; + + /** + * handles style change +@@ -395,7 +423,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + /** + * handles actual content + */ +- if (isEmptyCell) { ++ if (isEmptyCell && !materializeEmptyCell) { + this._nullCellCount += cell.getWidth(); + } else { + if (this._nullCellCount > 0) { +@@ -411,7 +439,19 @@ class StringSerializeHandler extends BaseSerializeHandler { + this._nullCellCount = 0; + } + +- this._currentRow += cell.getChars(); ++ if (materializeEmptyCell) { ++ const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline(); ++ if (hasDecoration) { ++ this._currentRow += '\u001b[24;29;55m'; ++ } ++ this._currentRow += ' '.repeat(cell.getWidth()); ++ if (hasDecoration) { ++ const restoreSgrSeq = this._diffStyle(cell, this._defaultCell); ++ this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`; ++ } ++ } else { ++ this._currentRow += cell.getChars(); ++ } + + // update cursor + this._lastContentCursorRow = this._lastCursorRow = row; diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 585640c7b8d..ad6fdd1550c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-01", + "updatedAt": "2026-08-03", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,101 @@ } }, "gates": [ + { + "id": "editor.restored-sibling-owner-reparent", + "title": "Restored sibling tabs migrate filesystem authority before becoming editable", + "maturity": "experimental", + "protection": "partial", + "owner": "editor-runtime", + "layer": "renderer-store-controller-contract", + "surfaces": [ + "restored editor tabs", + "editor save and autosave", + "filesystem watches and hot-exit persistence" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "paired-runtime"], + "coverageNotes": "Deterministic store/controller tests cover local, direct-SSH, paired-runtime identity, folder workspaces, queue quiescence, stale route and connection-generation rejection, authoritative active-workspace projection, owner-derived IDs, drafts and editor maps, tab/group placement, exact watch replacement, change/delete/rename correlation, explicit save, autosave, collision refusal without activation reconciliation, provenance, and restart hot-exit persistence. Live headed/headless paired-runtime and post-establish Electron IPC remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11304", + "https://github.com/stablyai/orca/pull/11369" + ], + "invariant": "A restored absolute path owned by a same-host sibling workspace cannot read, save, watch, or persist until old saves drain, the exact route and host generations are revalidated, and one authoritative activation transaction installs the sibling owner, target projection, editor state, reconciliation, and first-activation terminal preparation; collisions and stale routes fail before activation preparation or reconciliation.", + "oracle": "Hold an old-owner save in flight, start migration, then remove the sibling, change a folder root, or reconnect SSH and require owner migration to fail closed after quiescence. On a stable route require one coherent target-workspace projection, first-activation terminal generation preparation, destination authority for explicit save and autosave, exact watch replacement, destination-only change/delete/rename handling, owner-derived state rekeys, and hot-exit restart ownership. Seed an invalid destination tab/group model before a collision and require the target projection to remain byte-identical.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "testFiles": [ + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "assertions": [ + "one atomic commit rekeys owner-derived editor, preview, draft, cursor, view, tab, group, active, reveal, focus, provenance, and persistence state", + "active reparenting uses the centralized workspace activation transaction for terminal, browser, pending-creation, explorer, first-activation, and post-commit state", + "source watch unsubscribes once, destination watch subscribes once, and only destination change/delete/rename events reach the file", + "dirty and clean destination collisions fail closed without changing either open session or reconciling destination tabs and groups", + "folder and direct-SSH owners retain exact root, host, target, and connection-generation authority" + ] + }, + { + "file": "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "assertions": [ + "migration waits for the old save queue and routes later explicit and automatic saves through the destination worktree", + "a removed sibling, changed folder root, or changed direct-SSH generation after quiescence rejects migration without changing ownership" + ] + }, + { + "file": "src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "assertions": [ + "same-host runtime, folder, local, and direct-SSH roots resolve without cross-host fallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "result": "passed", + "durationSeconds": 3, + "summary": "Three deterministic files passed 20 lifecycle, stale-route, activation, ownership, host, collision, persistence, watch, and save tests." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused renderer store/controller contract on a local development runner" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One local deterministic run is recorded; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The final2 verifier's byte-identical post-quiescence lifecycle scope passed 21/21 on 7890271160 and failed 7 tests on f92db196a1; base e08eba674c, published 1bcfe3bcb0, and revert b3627461ab were structurally red. The permanent collision oracle is in test blob ac62a89c671a0c8f081ebf06736715bd2150ce9f (SHA-256 a61970ace80be1cb56f781aee6f878a8446c6c4527f9eb0cd0c151d8df96bf50). The same byte-identical blob is structurally red on base, published, and revert, behaviorally red on rejected final2 candidate 7890271160 because pre-commit reconciliation removes the stale destination tab, and green on implementation b72a838cdc; the full permanent gate passes 20/20." + }, + "performanceBudget": { + "required": true, + "evidence": "Route lookup scans only the already-indexed renderer workspace catalog, while migration is O(open editor files + source/destination tabs/groups) and performs no provider call, Git scan, polling, retry, subprocess, or global fanout beyond the required old/new watch delta." + }, + "promotionCriteria": [ + "Collect two fresh child-verifier decisions against the correction candidate.", + "Collect headed and headless paired-runtime coverage after the post-establish Electron IPC gap is fixed.", + "Collect physical Windows or WSL path/host evidence and CI soak history." + ], + "knownGaps": [ + "Headed Electron post-establish IPC, paired headless, reconnect, and physical Windows/WSL journeys are uncollected.", + "Cold many-repo main-process sibling discovery remains a separately measured follow-up.", + "Registered-workspace symlink policy remains inherited and unchanged." + ], + "demotionRule": "Keep experimental or demote if migration can race a save, retain source authority, fan out watches, lose dirty/editor state, accept an owner-generation change, merge a collision, or the focused contract flakes without a product or harness bug." + }, { "id": "terminal-provider.login-session-retirement", "title": "macOS login-session retirement preserves live daemons through transient rejection bursts", @@ -1031,6 +1126,126 @@ ], "demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers." }, + { + "id": "runtime.streaming-subscription-close-delivery", + "title": "Retiring a runtime transport always tells the renderer its streams closed", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "runtime-subscription-ipc-contract", + "surfaces": [ + "paired remote server", + "runtime environment disconnect and re-pair", + "terminal.multiplex streaming", + "browser screencast streaming", + "parked remote terminal reveal" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.", + "motivatingLinks": [ + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts", + "docs/reference/headless-linux-server.md" + ], + "invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.", + "oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/ipc/runtime-environments.test.ts", + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environments.test.ts", + "assertions": [ + "tells the renderer when retiring the transport closes its streaming subscription (the load-bearing repro; red with the fix reverted)", + "retires an environment's remaining subscriptions when one teardown throws (load-bearing; red without per-subscription isolation)", + "contains a throwing renderer send on a host-initiated close (load-bearing; red without the guarded send, and the only coverage of the unguarded notifyClosed call site)", + "retires remaining subscriptions when a liveness probe inside notifyClosed throws (load-bearing; keeps the isolation structural rather than comment-asserted)", + "suppresses stale payloads from a retired transport but never re-sends its close (forward guard on the retained generation gate, not a repro)" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts", + "assertions": [ + "a revealed hidden-but-mounted remote terminal restores, accepts input, and paints live", + "a revealed cold-parked remote terminal restores, accepts input, and paints live", + "a cold-parked remote terminal revealed after a runtime disconnect and reconnect restores, accepts input, and paints live", + "the host PTY grid converges on the revealed pane grid in every scenario" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts", + "result": "passed", + "durationSeconds": 0.81, + "summary": "53 runtime environment IPC tests passed, including the new close-delivery, both teardown-isolation, transport-path containment, and stale-payload-suppression contracts." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 15.5, + "summary": "All three reveal scenarios restored their buffer, delivered typed input to the host PTY, painted live without a tab flip, and converged the PTY on the 135x60 pane grid. Before the fix the reconnect-parked scenario stayed blank at recoveryState connecting with the PTY stranded at the host 128x60 grid." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 15.5, + "summary": "Repeat run on a clean uninstrumented build; all three scenarios green with identical grids." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 14.7, + "summary": "Third consecutive pass after the review follow-ups (env save/restore and the hidden-mounted stayed-mounted assertion)." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "focused runtime environment IPC tests plus one headed paired-server reveal journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic IPC coverage plus the three recorded paired-server journey runs, which passed consecutively on macOS at 14.7-15.5s; the spec also passed once on a shared CI runner. Longer soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On main the paired-server journey reproduced a blank, non-interactive pane after revealing a cold-parked remote terminal across a reconnect: recoveryState stayed connecting, the xterm buffer stayed empty, typed input never reached the host sink, a tab flip did not recover it, and the PTY stayed at the host 128x60 grid. Renderer instrumentation showed the multiplexer reusing a dead subscription (ensureConnected reuse-ready) and never receiving a subscribed event. Reverting the main-side fix also turns the close-delivery IPC test red; with the fix the multiplexer observes handleClose, reconnects fresh, and every scenario passes." + }, + "performanceBudget": { + "required": true, + "evidence": "The fix adds one boolean latch and at most one extra IPC send per retired subscription, on a path that already tears the subscription down. It introduces no timer, poll, retry, subprocess, or per-frame work, and the close is deduplicated so a transport-driven close after an environment-wide retirement sends nothing." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Collect live Linux and Windows paired-server reveal evidence.", + "Add real sleep/wake and network-loss transport drops alongside the explicit disconnect trigger." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS host and client only.", + "The transport drop is an explicit runtime disconnect; real sleep/wake and network partitions are not yet exercised by this gate.", + "Browser screencast subscribers share the fixed contract but have no dedicated reveal journey; the web and mobile clients run parallel transports and are unaffected." + ], + "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", @@ -1695,13 +1910,15 @@ "invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.", "oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ + "src/main/daemon/daemon-server-kill-attribution.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/remote-runtime-request-connection.integration.test.ts", + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts", "src/main/runtime/rpc/methods/session-tabs.test.ts", "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts", "src/main/runtime/rpc/e2ee-channel.test.ts", @@ -1723,6 +1940,19 @@ "tests/e2e/daemon-generation-legacy-close-safety.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/daemon/daemon-server-kill-attribution.test.ts", + "assertions": [ + "successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded" + ] + }, + { + "file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts", + "assertions": [ + "remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential", + "close attribution adds no terminal inventory scan or provider fanout" + ] + }, { "file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts", "assertions": [ @@ -6511,15 +6741,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", + "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"], - "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", - "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1" @@ -6528,6 +6759,7 @@ "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts", "src/main/runtime/orchestration/formatter.test.ts", "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/cli/handlers/orchestration.test.ts", "src/cli/handlers/orchestration-check-identity.test.ts", @@ -6549,6 +6781,13 @@ "ordinary mail and remote attachments use the same attested pane and process authority" ] }, + { + "file": "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", + "assertions": [ + "creator lookup uses the assignee-handle and pane-leaf indexes without a retained-Run scan", + "300 Task reads remain bounded with 50,000 unrelated retained Runs" + ] + }, { "file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "assertions": [ @@ -6733,6 +6972,104 @@ ], "demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox." }, + { + "id": "orchestration.settled-worker-terminal-release", + "title": "Settled worker cleanup preserves one exact terminal lease and immutable output", + "maturity": "experimental", + "protection": "partial", + "owner": "orchestration", + "layer": "runtime-sqlite-terminal-lifecycle", + "surfaces": [ + "worker-release", + "worker-retain", + "explicit terminal reuse", + "restart reconciliation", + "worker-read archives", + "orchestration reset" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"], + "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", + "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", + "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", + "src/main/runtime/orchestration/worker-transcript-read.test.ts", + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", + "assertions": [ + "rejects exact reuse after release intent instead of closing the new worker", + "lets an explicit retain cancel a release while output capture is pending", + "retains when the terminal host scope changed instead of closing", + "reads an immutable transcript snapshot after the provider file disappears", + "backfills a legacy creator plus explicit reuser as ambiguous", + "removes terminal authority and archived output on orchestration reset" + ] + }, + { + "file": "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", + "assertions": ["resumes a pending idempotent worker release after restart"] + }, + { + "file": "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", + "assertions": [ + "finishes a requested release after restart-style interruption", + "coalesces overlapping reconciliation passes and closes each resource once", + "keeps live terminals bounded across 50 settled workers while controls survive" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 3.48, + "summary": "Five focused files passed 56 tests covering lease serialization, reminted-handle transfer, duplicate-identity fencing, retain and takeover races, immutable archives, conservative legacy migration, mutation restart, reset cleanup, bounded accounting, and renderer input reporting." + } + ], + "runtimeBudget": { + "p95Seconds": 8, + "scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "First local deterministic run only; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state." + }, + "performanceBudget": { + "required": true, + "evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Add live headed and headless paired-runtime release/reuse journeys.", + "Add Docker SSH reconnect and physical Windows WSL host-scope evidence.", + "Prove provider-close lost acknowledgements converge with an exact durable close receipt." + ], + "knownGaps": [ + "Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.", + "Federated release remains explicitly unsupported and retained.", + "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised." + ], + "demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect." + }, { "id": "terminal-render.windows-cjk-repaint", "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs index db7c95676d7..b62f28d1c4f 100644 --- a/config/scripts/adhoc-build-version.mjs +++ b/config/scripts/adhoc-build-version.mjs @@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' /** Long enough to name a feature, short enough that a picker row stays readable. */ export const ADHOC_LABEL_MAX_LENGTH = 32 @@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) { ].join(' • ') } -export function getAdhocBuildIdentity(now = new Date(), label = '') { +export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const version = createAdhocBuildVersion(packageJson.version, now) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const version = createAdhocBuildVersion(base, now) return { commit, version, @@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') { } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '') + const identity = getAdhocBuildIdentity( + new Date(), + process.env.ORCA_ADHOC_LABEL ?? '', + readPublishedVersionsFromEnv() + ) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( `version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n` diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0d14e2373b6..6e4803ed7ba 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -1,4 +1,12 @@ -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' @@ -31,6 +39,20 @@ describe('Windows CLI launcher', () => { } }) + itCrossHost('never materializes the child environment block from ProcessStartInfo', () => { + // Why: both ProcessStartInfo env properties copy the process block into a case-insensitive + // dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046). + const source = readFileSync( + join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'), + 'utf8' + ) + const code = source.replace(/^\s*\/\/.*$/gm, '') + + expect(code).not.toContain('EnvironmentVariables') + expect(code).not.toContain('startInfo.Environment') + expect(code).toContain('Environment.SetEnvironmentVariable') + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { @@ -89,4 +111,70 @@ describe('Windows CLI launcher', () => { rmSync(appRoot, { recursive: true, force: true }) } }) + + itWindows('survives an inherited environment block containing PATH and Path', () => { + const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher ')) + try { + const resourcesPath = join(appRoot, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + const outputPath = join(appRoot, 'child-result.json') + const harnessSourcePath = join( + projectRoot, + 'config', + 'scripts', + 'fixtures', + 'DuplicatePathProcessLauncher.cs' + ) + const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe') + mkdirSync(dirname(launcherPath), { recursive: true }) + mkdirSync(dirname(cliPath), { recursive: true }) + copyFileSync(process.execPath, join(appRoot, 'Orca.exe')) + writeFileSync( + cliPath, + `require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({ + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path') +}))\n`, + 'utf8' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + + const compiler = findFrameworkCompiler() + expect(compiler).not.toBeNull() + const compileHarness = spawnSync( + compiler, + ['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath], + { encoding: 'utf8' } + ) + expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0) + + const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' }) + expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0) + expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({ + electronRunAsNode: '1', + pathKeys: ['PATH', 'Path'] + }) + } finally { + rmSync(appRoot, { recursive: true, force: true }) + } + }) }) + +function findFrameworkCompiler() { + const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot + if (!windowsDirectory) { + return null + } + return ( + [ + join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), + join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe') + ].find((candidate) => existsSync(candidate)) ?? null + ) +} diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs new file mode 100644 index 00000000000..62a28c6a374 --- /dev/null +++ b/config/scripts/dev-channel-base-version.mjs @@ -0,0 +1,66 @@ +const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +function parseVersionTriple(value) { + const match = SEMVER.exec(String(value ?? '').trim()) + if (!match) { + return null + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null + } +} + +function compareTriples(a, b) { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch +} + +/** + * The base `X.Y.Z` an hourly or adhoc build should carry. + * + * Why not package.json alone: main's version only moves on `release:` commits, and + * stable patches are cut from release branches that never merge back. On + * 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and + * 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while + * carrying code newer than 1.4.167, and sorted *below* the stable their user was + * already running. Published tags are the only honest answer to "what number is + * taken"; package.json is a floor, not a source of truth. + */ +export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { + const fromPackage = parseVersionTriple(packageVersion) + if (!fromPackage) { + throw new Error(`Package version is not valid semver: ${packageVersion}`) + } + + // Unparseable tags are skipped rather than fatal: the main repo carries old tags + // that predate the current scheme, and one of them must not fail every build. + const published = publishedVersions.map(parseVersionTriple).filter(Boolean) + + let base = fromPackage + if (published.length > 0) { + const highest = published.reduce((best, entry) => + compareTriples(entry, best) > 0 ? entry : best + ) + // A shipped stable owns its number, so the next dev build belongs on the patch + // above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still + // the version being worked toward, which is exactly what main is building. + const shipped = published.some( + (entry) => !entry.prerelease && compareTriples(entry, highest) === 0 + ) + const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest + if (compareTriples(next, base) > 0) { + base = next + } + } + + return `${base.major}.${base.minor}.${base.patch}` +} + +/** Tag list the workflow reads out of the main repo, newline separated. */ +export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) { + return String(value ?? '') + .split(/\s+/) + .filter(Boolean) +} diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs new file mode 100644 index 00000000000..d2631eff0e9 --- /dev/null +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +describe('dev channel base version', () => { + it('falls back to package.json when no tags are supplied', () => { + expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168') + expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168') + }) + + // The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three + // stables shipped, so hourlies claimed a version their users had already passed. + it('climbs past stables that shipped while main stood still', () => { + expect( + resolveDevChannelBaseVersion('1.4.165-rc.0', [ + 'v1.4.165', + 'v1.4.166', + 'v1.4.167', + 'v1.4.165-rc.0' + ]) + ).toBe('1.4.168') + }) + + // Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev + // build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward, + // which is what main holds — claiming 1.4.169 would jump a release nobody cut. + it('takes the patch above a shipped stable and holds at an open prerelease', () => { + expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168') + expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168') + }) + + // Why max and not most-recent: a hotfix on an old line published today would + // otherwise drag every subsequent hourly backwards. + it('reads the highest tag, not the last one listed', () => { + expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe( + '1.4.168' + ) + }) + + it('treats package.json as a floor when it leads the tags', () => { + expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0') + }) + + // Why skipped rather than fatal: the main repo carries legacy tags, and one + // unparseable entry must not fail every hourly build. + it('ignores tags it cannot parse', () => { + expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe( + '1.4.168' + ) + }) + + it('rejects a package version that is not semver', () => { + expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/) + }) + + it('carries major and minor rollovers through the bump', () => { + expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1') + }) + + it('splits an env tag list on any whitespace', () => { + expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166']) + expect(readPublishedVersionsFromEnv('')).toEqual([]) + expect(readPublishedVersionsFromEnv(undefined)).toEqual([]) + }) +}) diff --git a/config/scripts/fixtures/DuplicatePathProcessLauncher.cs b/config/scripts/fixtures/DuplicatePathProcessLauncher.cs new file mode 100644 index 00000000000..6b46382103b --- /dev/null +++ b/config/scripts/fixtures/DuplicatePathProcessLauncher.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +internal static class DuplicatePathProcessLauncher +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + bool inheritHandles, + uint creationFlags, + IntPtr environment, + string currentDirectory, + ref StartupInfo startupInfo, + out ProcessInformation processInformation + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + + private static int Main(string[] args) + { + List entries = new List(); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase)) + { + entries.Add((string)entry.Key + "=" + (string)entry.Value); + } + } + entries.Add("PATH=C:\\live"); + entries.Add("Path=C:\\shadowed"); + entries.Add("ORCA_TEST_OUTPUT=" + args[1]); + + IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0"); + StartupInfo startupInfo = new StartupInfo(); + startupInfo.cb = Marshal.SizeOf(startupInfo); + ProcessInformation processInformation; + try + { + bool started = CreateProcess( + args[0], + new StringBuilder("\"" + args[0] + "\""), + IntPtr.Zero, + IntPtr.Zero, + false, + 0x00000400, + environment, + null, + ref startupInfo, + out processInformation + ); + if (!started) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + Marshal.FreeHGlobal(environment); + } + + try + { + WaitForSingleObject(processInformation.hProcess, 0xffffffff); + uint exitCode; + if (!GetExitCodeProcess(processInformation.hProcess, out exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return (int)exitCode; + } + finally + { + CloseHandle(processInformation.hThread); + CloseHandle(processInformation.hProcess); + } + } +} diff --git a/config/scripts/happy-dom-mutation-observer-retention.test.ts b/config/scripts/happy-dom-mutation-observer-retention.test.ts new file mode 100644 index 00000000000..475a84c9b31 --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.test.ts @@ -0,0 +1,72 @@ +/** @vitest-environment happy-dom */ +import { describe, expect, it } from 'vitest' + +import { + installHappyDomMutationObserverRetention, + retainedMutationCallbackCount +} from './happy-dom-mutation-observer-retention' + +function readListenerCallbacks(target: Node): unknown[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === 'mutationListeners' + ) + const listeners = listenersSymbol + ? (target as unknown as Record)[listenersSymbol] + : [] + if (!Array.isArray(listeners)) { + return [] + } + return listeners.map((listener: { callback?: { deref: () => unknown } }) => + listener.callback?.deref() + ) +} + +describe('happy-dom MutationObserver retention', () => { + it('pins the internal callback that happy-dom only holds weakly', () => { + expect(installHappyDomMutationObserverRetention()).toBe(true) + const target = document.createElement('div') + document.body.append(target) + const observer = new MutationObserver(() => {}) + observer.observe(target, { childList: true, subtree: true }) + + const callbacks = readListenerCallbacks(target) + expect(callbacks.length).toBe(1) + expect(callbacks[0]).toBeTypeOf('function') + expect(retainedMutationCallbackCount(observer)).toBe(1) + + observer.disconnect() + expect(retainedMutationCallbackCount(observer)).toBe(0) + target.remove() + }) + + it('keeps delivering records after the weak callback would have been collected', async () => { + installHappyDomMutationObserverRetention() + const target = document.createElement('div') + document.body.append(target) + let deliveries = 0 + const observer = new MutationObserver(() => { + deliveries += 1 + }) + observer.observe(target, { childList: true, subtree: true }) + + target.replaceChildren(document.createElement('div')) + await Promise.resolve() + expect(deliveries).toBe(1) + + const collectGarbage = (globalThis as { gc?: () => void }).gc + if (collectGarbage) { + for (let round = 0; round < 5; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + collectGarbage() + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + target.replaceChildren(document.createElement('span')) + await Promise.resolve() + expect(deliveries).toBe(2) + + observer.disconnect() + target.remove() + }) +}) diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts new file mode 100644 index 00000000000..a315b3d520c --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.ts @@ -0,0 +1,81 @@ +// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause +// under parallel test load silently and permanently stops a still-connected observer. Browsers +// keep that callback reachable for as long as the observer observes; mirror that lifetime here so +// DOM-driven tests never lose mutation records mid-run. + +type HappyDomMutationListener = { + callback?: { deref: () => unknown } +} + +type PatchableMutationObserver = { + observe: (target: Node, options?: MutationObserverInit) => void + disconnect: () => void +} + +const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners' +const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention') + +const retainedCallbacks = new WeakMap>() + +function readMutationListeners(target: Node): HappyDomMutationListener[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION + ) + if (!listenersSymbol) { + return [] + } + const listeners = (target as unknown as Record)[listenersSymbol] + return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : [] +} + +/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */ +export function retainedMutationCallbackCount(observer: MutationObserver): number { + return retainedCallbacks.get(observer)?.size ?? 0 +} + +export function installHappyDomMutationObserverRetention(): boolean { + const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver }) + .MutationObserver + if (!observerClass) { + return false + } + const prototype = observerClass.prototype as unknown as PatchableMutationObserver & + Record + if (prototype[RETENTION_INSTALLED] === true) { + return true + } + const observe = prototype.observe + const disconnect = prototype.disconnect + + prototype.observe = function patchedObserve( + this: object, + target: Node, + options?: MutationObserverInit + ): void { + const existing = new Set(readMutationListeners(target)) + observe.call(this as unknown as PatchableMutationObserver, target, options) + const pinned = retainedCallbacks.get(this) ?? new Set() + for (const listener of readMutationListeners(target)) { + if (existing.has(listener)) { + continue + } + const callback = listener.callback?.deref() + if (callback) { + pinned.add(callback) + } + } + if (pinned.size > 0) { + retainedCallbacks.set(this, pinned) + } + } + + prototype.disconnect = function patchedDisconnect(this: object): void { + disconnect.call(this as unknown as PatchableMutationObserver) + retainedCallbacks.delete(this) + } + + prototype[RETENTION_INSTALLED] = true + return true +} + +installHappyDomMutationObserverRetention() diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index f4545af086b..12257c2b4ff 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' /** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically * by semver and every build is uniquely versioned. */ @@ -45,18 +49,19 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) { ].join(' • ') } -export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) { +export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const version = createHourlyBuildVersion(packageJson.version, now) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const version = createHourlyBuildVersion(base, now) return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) } } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1') - const identity = getHourlyBuildIdentity(new Date(), buildNumber) + const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv()) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( `version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n` diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 849fe784b46..e869f652e40 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -227,12 +227,79 @@ describe('orchestration skill guidance', () => { expect(agentGuidance).toContain('After sending `worker_done`, end your turn') expect(agentGuidance).toContain('idle at the agent prompt') - expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`') + expect(agentGuidance).toContain( + 'do not start more work, poll, or attempt to close the terminal yourself' + ) expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input') expect(skill).not.toContain('post-completion polling messages') expect(skill).not.toContain('every 2 minutes') }) + it('makes settled worker terminal release an explicit coordinator step', () => { + const skill = readSkill() + const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') + const agentGuidance = getSection(skill, 'Agent Guidance') + const nextAction = getSection(skill, 'Next Action') + + expect(workerLoop).toContain( + '# Process every message. For each accepted worker_done that is not immediately reused:\n' + + 'orca orchestration worker-release --dispatch --json' + ) + expect(workerLoop).toContain( + 'Acknowledge only after every message and required release decision is handled' + ) + expect(workerLoop).toContain( + 'read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`' + ) + expect(workerLoop).toContain( + 'orca orchestration worker-start --task --terminal --json` so Orca ' + + 'transfers cleanup ownership to the new Dispatch' + ) + expect(workerLoop).toContain( + 'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' + + 'explicitly asked to keep that worker live.' + ) + expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation') + expect(workerLoop).toContain('orca orchestration worker-retain --dispatch --json') + expect(workerLoop).toContain( + 'the same Dispatch can be passed to `worker-release`, which clears the requested retention' + ) + expect(agentGuidance).toContain( + 'Coordinators must account for every settled worker terminal before waiting again or ending ' + + 'the turn' + ) + expect(agentGuidance).toContain('released workers remain readable through `worker-read`') + expect(nextAction).toContain( + 'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' + + 'follow-up Dispatch or run `worker-release` before the next wait.' + ) + }) + + it('never authorizes release from idle, timeout, or worker-side triggers', () => { + const skill = readSkill() + const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') + const agentGuidance = getSection(skill, 'Agent Guidance') + + // The prohibition sentence is the guard the negative patterns below rely on. + expect(workerLoop).toContain( + 'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' + + 'escalation, or rejected/stale `worker_done`.' + ) + expect(workerLoop).toContain( + 'do not substitute `terminal close`; follow the exact recovery action in the receipt' + ) + expect(skill).not.toMatch( + /release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu + ) + expect(skill).not.toMatch( + /\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu + ) + expect(agentGuidance).toContain( + 'do not start more work, poll, or attempt to close the terminal yourself' + ) + expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu) + }) + it('documents @grok in the Messaging group address list', () => { const skill = readSkill() const messaging = getSection(skill, 'Messaging') diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index d239d930717..3e232c5fce8 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -39,14 +39,23 @@ else # Why the features are off: this repo holds releases and nothing else. Leaving # issues open invites bug reports against a branch build in a repo nobody # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. gh repo create "$ADHOC_REPO" \ --public \ --description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \ + --add-readme \ --disable-issues \ --disable-wiki || fail "Could not create $ADHOC_REPO." fi +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + echo echo "Granting $APP_SLUG access to $ADHOC_REPO..." diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 1146cd4a5ca..735e1b2a0af 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. execArgv: ['--no-experimental-webstorage'], + // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. + setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md new file mode 100644 index 00000000000..6c93185b602 --- /dev/null +++ b/docs/reference/windows-setup-shell.md @@ -0,0 +1,86 @@ +# Windows setup-runner shell + +On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated +runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by +default**, exactly as it has been since setup hooks shipped. + +A script opts into bash by starting with a `#!` interpreter line: + +```yaml +scripts: + setup: | + #!/usr/bin/env bash + [ -f .env ] || cp .env.example .env + pnpm install +``` + +Without that line the script keeps running under `cmd.exe`: + +```yaml +scripts: + setup: | + copy .env.example .env + xcopy /E assets dist +``` + +## Why the script declares it, not the terminal preference + +`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the +language a project's setup script is written in. Deriving the runner from it had two consequences: + +- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`, + `xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working. +- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project + could write a setup script that worked for all of its Windows contributors. + +A `#!` line is per-project, explicit, and identical for everyone who checks the repo out. + +The same rule applies to the per-user setup command in **Settings → repository hooks** +(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a +POSIX one-liner stored there needs its own `#!` line to run under bash on Windows. + +## What the `#!` line does and does not select + +The generated runner is always executed by bash (`bash `; Git Bash on native Windows), on +every platform. The `#!` line therefore does two things: + +- It declares the script is written for a POSIX shell, which is what selects the bash runner. +- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does + get `pipefail`. Without that replay the flags would be silently dropped, because `bash ` + never parses the interpreter line. Only the flags `set` itself accepts + (`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are + dropped, because `set -l` exits 2 and would abort the runner before its first line. + +The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and +`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux. + +## Requirements for the bash runner + +A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the +terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner +uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is +typed into whatever shell the terminal opened with. + +When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses +the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd` +runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script +(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a +half-set-up worktree that looks finished. + +## Launching a `.cmd` runner from a Git Bash terminal + +The runner format and the shell that types the launch command are independent: a Git Bash terminal +with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c +"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens +interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell +`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch +and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the +launching pane; the runner file's `.cmd`/`.sh` extension describes the format. + +The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the +agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the +bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the +same `ProcessStartInfo` launcher, so the batch runner is never handed to bash. + +WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts +choose their runner from the remote path format, never from local Windows preferences. diff --git a/mobile/.gitignore b/mobile/.gitignore index 449fa3f2439..0428cbb65a9 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,5 +1,6 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts +src/components/pr-sidebar/mermaid-webview-engine.generated.ts .expo/ dist/ /android/ diff --git a/mobile/app.json b/mobile/app.json index 413cebc8d28..53654335486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.36", + "version": "0.0.37", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 9 + "versionCode": 10 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 24da552eafe..b71f171ceb8 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -164,7 +164,8 @@ export function HostScreen({ const [filters, setFilters] = useState({ filterRepoIds: new Set(), hideSleeping: false, - hideDefaultBranch: false + hideDefaultBranch: false, + alwaysShowDefaultBranch: true }) const [groupMode, setGroupMode] = useState('repo') const [workspaceStatuses, setWorkspaceStatuses] = useState( @@ -195,6 +196,7 @@ export function HostScreen({ sortMode: 'recent', hideSleeping: false, hideDefaultBranch: false, + alwaysShowDefaultBranch: true, filterRepoIds: [], collapsedGroups: [], workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES @@ -206,6 +208,7 @@ export function HostScreen({ sortMode, hideSleeping: filters.hideSleeping, hideDefaultBranch: filters.hideDefaultBranch, + alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false, filterRepoIds: [...filters.filterRepoIds], collapsedGroups: [...collapsedGroups], workspaceStatuses @@ -222,7 +225,8 @@ export function HostScreen({ setFilters({ filterRepoIds: new Set(next.filterRepoIds), hideSleeping: next.hideSleeping, - hideDefaultBranch: next.hideDefaultBranch + hideDefaultBranch: next.hideDefaultBranch, + alwaysShowDefaultBranch: next.alwaysShowDefaultBranch }) }, []) @@ -234,6 +238,9 @@ export function HostScreen({ if (!client) { return } + // alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it + // but has no toggle, so echoing its local default would silently revert a + // desktop opt-out on the first filter tap before ui.get lands (#8873). const payload: WorkspaceViewSettings = { groupBy: groupModeToDesktop(next.groupMode), sortBy: next.sortMode, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index f0ed85df944..21bc77fae38 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -107,6 +107,7 @@ import type { TerminalWebViewHandle } from '../../../../src/terminal/terminal-webview-contract' import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' +import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { getDefaultTerminalAccessoryBuiltInIds, @@ -235,6 +236,11 @@ import { } from '../../../../src/session/mobile-terminal-prune-decision' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' +import { + TerminalViewportResubscribeBudget, + readTerminalViewportDims, + runTerminalViewportFitPass +} from '../../../../src/session/mobile-terminal-viewport-resubscribe' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects' @@ -1030,6 +1036,8 @@ export default function SessionScreen() { const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) + // Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337). + const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget()) // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) @@ -1376,6 +1384,7 @@ export default function SessionScreen() { subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() terminalDiagnosticsRef.current.clearTerminalCache() + viewportResubscribeBudgetRef.current.clear() webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() @@ -1505,10 +1514,11 @@ export default function SessionScreen() { return } updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) - const cols = (data.cols as number) || 80 - const rows = (data.rows as number) || 24 - const scrollbackCols = cols - const scrollbackRows = rows + const { hostCols, hostRows } = readTerminalViewportDims(data) + // Why: absent host dims must not be coerced into a comparable size — 80x24 + // never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337). + const cols = hostCols ?? viewportRef.current?.cols ?? 80 + const rows = hostRows ?? viewportRef.current?.rows ?? 24 const initialData = typeof data.serialized === 'string' && data.serialized.length > 0 ? data.serialized @@ -1527,46 +1537,36 @@ export default function SessionScreen() { ref.init(cols, rows, initialData, false, oscLinks) initializedHandlesRef.current.add(handle) if (data.displayMode) { + const displayMode = data.displayMode as MobileDisplayMode + // Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route. setTerminalModes((prev) => - new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode) ) } // Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM. scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) - // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit. - const needsResubscribe = - !viewportMeasuredRef.current || - (viewportRef.current != null && - (scrollbackCols !== viewportRef.current.cols || - scrollbackRows !== viewportRef.current.rows)) - if (needsResubscribe) { - void (async () => { - // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). - await getTerminalRef(handle)?.awaitReady() - if (subscribeSeqRef.current.get(handle) !== seq) { - return - } - const dims = await getTerminalRef(handle)?.measureFitDimensions( - terminalFrameHeightRef.current || undefined - ) - // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. - if (subscribeSeqRef.current.get(handle) !== seq) { - return - } - if (!getTerminalRef(handle)) { - return - } - // Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it. - if (dims) { - diagnostics.streamResubscribing(handle, seq, dims) - viewportRef.current = dims - viewportMeasuredRef.current = true - unsubscribeTerminal(handle) - initializedHandlesRef.current.delete(handle) - subscribeToTerminal(handle) - } - })() - } + // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init + // and resubscribe so the server can phone-fit — bounded per handle so a + // non-converging host degrades visibly instead of hot-looping (STA-3337). + runTerminalViewportFitPass({ + handle, + seq, + hostCols, + hostRows, + budget: viewportResubscribeBudgetRef.current, + diagnostics, + viewportRef, + viewportMeasuredRef, + subscribeSeqRef, + initializedHandlesRef, + terminalUnsubsRef, + terminalFrameHeightRef, + getTerminalRef, + unsubscribeTerminal, + subscribeToTerminal, + scheduleDelayedAction, + showToast + }) } else if (data.type === 'metadata') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) } else if (data.type === 'data') { @@ -1591,8 +1591,12 @@ export default function SessionScreen() { } else if (data.type === 'resized') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) // Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry. - const cols = (data.cols as number) || 80 - const rows = (data.rows as number) || 24 + const viewport = viewportMeasuredRef.current ? viewportRef.current : null + const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize( + handle, + data, + viewport + ) const serialized = typeof data.serialized === 'string' ? data.serialized : null diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined @@ -1602,8 +1606,10 @@ export default function SessionScreen() { getTerminalRef(handle)?.resize(cols, rows) } if (data.displayMode) { + const displayMode = data.displayMode as MobileDisplayMode + // Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route. setTerminalModes((prev) => - new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode) ) } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) @@ -1619,7 +1625,7 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast] ) const nativeChatStream = useMobileNativeChatTerminalStream({ @@ -1720,12 +1726,16 @@ export default function SessionScreen() { unsubscribeTerminal(handle) terminalRefs.current.delete(handle) initializedHandlesRef.current.delete(handle) + viewportResubscribeBudgetRef.current.forget(handle) clearTerminalLiveInputDefault(handle) } setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) // Why: a chat-covered handle the host reports again refills its rearm budget, // so an exhausted rearm can't lock the composer until leave-chat. nativeChatStream.notifyListedHandles(liveHandles) + // Why: same absence-gated refill for the viewport-fit budget — a handle that + // left the list and returned may converge now, so it earns fresh attempts. + viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) lastKnownTerminalCountRef.current = result.terminals.length // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() @@ -3570,6 +3580,7 @@ export default function SessionScreen() { if ( current && current.cursorY === metrics.cursorY && + current.contentBottomRow === metrics.contentBottomRow && current.rows === metrics.rows && current.altScreen === metrics.altScreen ) { @@ -4251,24 +4262,11 @@ export default function SessionScreen() { ? Math.max(0, keyboardHeight - insets.bottom) : keyboardHeight : 0 - const activeTerminalKeyboardLift = (() => { - if (keyboardLift <= 0 || !activeHandle) { - return 0 - } - const metrics = terminalKeyboardMetrics.get(activeHandle) - if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) { - return keyboardLift - } - if (metrics.altScreen) { - return keyboardLift - } - const rowHeight = terminalFrameHeightRef.current / metrics.rows - const cursorBottom = (metrics.cursorY + 1) * rowHeight - const dockTop = terminalFrameHeightRef.current - keyboardLift - const margin = rowHeight - // Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put. - return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop)) - })() + const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({ + keyboardLift, + metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined, + terminalFrameHeight: terminalFrameHeightRef.current + }) const toastAnimatedStyle = { opacity: toastOpacityRef.current, transform: [{ translateY: -keyboardLift }] diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 907f50ed974..57532a4a8ae 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -18,14 +18,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { pickResumeWorktree } from '../src/worktree/resume-worktree' -import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client' -import { - markHomeWorktreeCatalogUnavailable, - type HomeWorktreeSummary, - type HostWorktreeInfo -} from '../src/worktree/home-worktree-info' +import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' +import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' +import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' import type { RpcClient } from '../src/transport/rpc-client' +import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useAllHostClients, @@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string { } } -type StatsSummary = { - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null -} - type HomeTaskSettings = { visibleTaskProviders?: unknown } @@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number { function fetchStats( client: RpcClient, hostId: string, - setStats: (s: StatsSummary) => void, + setStats: ( + updater: (prev: Record) => Record + ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'stats.summary') @@ -132,61 +124,13 @@ function fetchStats( return } if (response.ok) { - setStats(response.result as StatsSummary) + // Keyed by host: the header totals every desktop instead of showing whoever replied last. + setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) } }) .catch(() => {}) } -function fetchWorktreeInfo( - client: RpcClient, - hostId: string, - setInfo: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - const markUnavailable = () => { - setInfo((prev) => { - const current = prev[hostId] - const next = markHomeWorktreeCatalogUnavailable(current, hostId) - return next === current ? prev : { ...prev, [hostId]: next } - }) - } - - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const result = response.result as { worktrees: HomeWorktreeSummary[] } - const worktrees = result.worktrees ?? [] - setCachedWorktrees(hostId, worktrees) - const activeStatuses = new Set(['working', 'active', 'permission']) - const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) - // Mirror the desktop's focused workspace (see pickResumeWorktree). - const lastActive = pickResumeWorktree(worktrees) - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: worktrees.length, - activeCount: active.length, - lastActiveWorktree: lastActive - } - })) - } else { - markUnavailable() - } - }) - .catch(() => { - if (!disposed()) { - markUnavailable() - } - }) -} - function fetchAccountsSnapshot( client: RpcClient, hostId: string, @@ -272,7 +216,7 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) - const [stats, setStats] = useState(null) + const [statsByHost, setStatsByHost] = useState>({}) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) @@ -285,6 +229,8 @@ export default function HomeScreen() { // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) + // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. + const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) const allClients = useAllHostClients(hostIds) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), @@ -375,8 +321,8 @@ export default function HomeScreen() { }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStats, () => stale) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } @@ -457,60 +403,70 @@ export default function HomeScreen() { }) }, [allClients, hosts]) - // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap. - useEffect(() => { - const cleanups: Array<() => void> = [] - for (const entry of allClients) { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - let statsFetched = false - const wireUp = (state: ConnectionState) => { - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return + // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body + // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the + // returned disposer owns every handle allocated here. + const wireHostSubscriptions = (entry: { + hostId: string + client: RpcClient + state: ConnectionState + }) => { + let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null + const refetchGate = createHostConnectRefetchGate() + const wireUp = (state: ConnectionState) => { + const reconnected = refetchGate.observe(state) + if (state === 'connected') { + if (!unsubNotif) { + unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) + } + if (!unsubAccounts) { + unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') { + return + } + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + const snapshot = decodeAccountsSnapshot(evt.snapshot) + setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot; malformed remote data must + // not enter render state or crash the home host cards. } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - if (!statsFetched) { - statsFetched = true - fetchStats(entry.client, entry.hostId, setStats, () => false) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } + } + }) + } + // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host + // snapshot on every reconnect — a one-shot latch left the card on stale data forever. + if (reconnected) { + fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) + } + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null } } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - cleanups.push(() => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - }) } + wireUp(entry.state) + const unsubState = entry.client.onStateChange(wireUp) + return () => { + unsubState() + unsubNotif?.() + unsubAccounts?.() + } + } + + // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. + useEffect(() => { + const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) return () => { for (const c of cleanups) { c() @@ -750,7 +706,6 @@ export default function HomeScreen() { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null - const info = worktreeInfo[item.id] const verdict = classifyConnection({ state, reconnectAttempts: attempts, @@ -763,12 +718,7 @@ export default function HomeScreen() { state={state} verdict={verdict} path={hostPaths[item.id] ?? 'lan'} - worktreeCounts={ - info && !info.catalogUnavailable - ? { total: info.totalWorktrees, active: info.activeCount } - : undefined - } - worktreeCountsUnavailable={info?.catalogUnavailable === true} + worktreeInfo={worktreeInfo[item.id]} onPress={() => router.push(`/h/${item.id}`)} onLongPress={() => { triggerMediumImpact() diff --git a/mobile/package.json b/mobile/package.json index 1041b422a34..44feca5f4c5 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "oxlint", @@ -48,6 +48,7 @@ "expo-status-bar": "^55.0.6", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", + "mermaid": "11.16.0", "react": "^19.2.6", "react-dom": "19.2.6", "react-native": "^0.83.9", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 9788413f925..5530acb4da1 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: lucide-react-native: specifier: ^1.14.0 version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + mermaid: + specifier: 11.16.0 + version: 11.16.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -195,6 +198,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/cli@7.28.6': resolution: {integrity: sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==} engines: {node: '>=6.9.0'} @@ -1178,6 +1184,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -1768,6 +1780,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -1881,6 +1899,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -2680,6 +2701,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2689,6 +2803,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -2741,6 +2858,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2818,6 +2938,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -3332,6 +3455,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3353,6 +3480,12 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3392,6 +3525,162 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -3408,6 +3697,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3470,6 +3762,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3526,6 +3821,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3611,6 +3909,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -4304,6 +4605,9 @@ packages: resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} engines: {node: '>=20.0.0'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4436,6 +4740,9 @@ packages: engines: {node: '>=8'} hasBin: true + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4458,6 +4765,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -4855,9 +5169,16 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -4866,6 +5187,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4958,6 +5285,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5018,6 +5348,11 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -5041,6 +5376,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + metro-babel-transformer@0.83.7: resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==} engines: {node: '>=20.19.4'} @@ -5418,6 +5756,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5437,6 +5778,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5490,6 +5834,12 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5798,11 +6148,20 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.1.3: resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -6103,6 +6462,9 @@ packages: styleq@0.1.3: resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -6214,6 +6576,10 @@ packages: peerDependencies: typescript: '>=4.0.0' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-jest@29.0.5: resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6694,6 +7060,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.1.2 + '@babel/cli@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7935,6 +8306,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -8637,6 +9012,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@isaacs/ttlcache@1.4.1': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -8847,6 +9230,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -9603,12 +9990,131 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} '@types/emscripten@1.41.5': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 26.1.1 @@ -9677,6 +10183,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/whatwg-mimetype@3.0.2': {} @@ -9784,6 +10293,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -10407,6 +10921,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -10440,6 +10956,14 @@ snapshots: dependencies: browserslist: 4.28.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + create-jest@29.7.0(@types/node@26.1.1): dependencies: '@jest/types': 29.6.3 @@ -10496,6 +11020,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -10520,6 +11228,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.21: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10560,6 +11270,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -10602,6 +11316,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -10748,6 +11466,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -11653,6 +12373,8 @@ snapshots: - bufferutil - utf-8-validate + hachure-fill@0.5.2: {} + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -11781,6 +12503,8 @@ snapshots: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -11802,6 +12526,10 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -12446,14 +13174,24 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + leven@3.1.0: {} levn@0.4.1: @@ -12527,6 +13265,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.memoize@4.1.2: {} @@ -12584,6 +13324,8 @@ snapshots: dependencies: tmpl: 1.0.5 + marked@16.4.2: {} + marky@1.3.0: {} math-intrinsics@1.1.0: {} @@ -12600,6 +13342,30 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.12 + es-toolkit: 1.50.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 11.1.1 + metro-babel-transformer@0.83.7: dependencies: '@babel/core': 7.29.7 @@ -13214,6 +13980,8 @@ snapshots: p-try@2.2.0: {} + package-manager-detector@1.8.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13235,6 +14003,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -13272,6 +14042,13 @@ snapshots: pngjs@3.4.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -13646,6 +14423,8 @@ snapshots: dependencies: glob: 7.2.3 + robust-predicates@3.0.3: {} + rolldown@1.1.3: dependencies: '@oxc-project/types': 0.137.0 @@ -13667,6 +14446,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.3 '@rolldown/binding-win32-x64-msvc': 1.1.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -13983,6 +14771,8 @@ snapshots: styleq@0.1.3: {} + stylis@4.4.0: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -14084,6 +14874,8 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 + ts-dedent@2.3.0: {} + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.1))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 diff --git a/mobile/scripts/build-mermaid-webview-engine.mjs b/mobile/scripts/build-mermaid-webview-engine.mjs new file mode 100644 index 00000000000..bf53ae2f4b8 --- /dev/null +++ b/mobile/scripts/build-mermaid-webview-engine.mjs @@ -0,0 +1,42 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const scriptDir = import.meta.dirname +const mobileRoot = path.resolve(scriptDir, '..') +const outputPath = path.join( + mobileRoot, + 'src', + 'components', + 'pr-sidebar', + 'mermaid-webview-engine.generated.ts' +) + +// Why: unlike the terminal engine (esbuilt from source for old-WebView shims), +// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact +// the diagram WebView previously fetched from a CDN, now pinned and +// integrity-checked through the lockfile and available offline. +async function main() { + const packageJsonPath = require.resolve('mermaid/package.json') + const [packageJson, bundleJs] = await Promise.all([ + readFile(packageJsonPath, 'utf8').then(JSON.parse), + readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8') + ]) + + // A literal inside the bundle would close the inline script tag it + // is embedded in; \/ is identical to / in JS strings, regexes, and comments. + const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script') + + const source = [ + '// Generated by scripts/build-mermaid-webview-engine.mjs.', + `// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`, + '// Do not edit by hand; regenerate via pnpm postinstall.', + `export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`, + '' + ].join('\n') + + await writeFile(outputPath, source) +} + +await main() diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts index a591bb01d70..bbda033735e 100644 --- a/mobile/src/cache/home-snapshot-cache.ts +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -5,25 +5,12 @@ // WebSocket reconnects and the first responses come back. import AsyncStorage from '@react-native-async-storage/async-storage' import type { AccountsSnapshot } from '../components/AccountUsage' +// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card +// needs it to know whether a rehydrated count is minutes or days old. +import type { HostWorktreeInfo } from '../worktree/home-worktree-info' const STORAGE_KEY = 'orca:home-snapshot:v1' -type WorktreeSummary = { - worktreeId: string - repo: string - branch: string - displayName: string - liveTerminalCount: number - status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' -} - -type HostWorktreeInfo = { - hostId: string - totalWorktrees: number - activeCount: number - lastActiveWorktree: WorktreeSummary | null -} - export type HomeSnapshot = { worktreeInfo: Record accountsByHost: Record diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx new file mode 100644 index 00000000000..a1889793982 --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionVerdict } from '../transport/connection-health' +import type { HostProfile } from '../transport/types' +import { + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from '../worktree/home-worktree-info' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) + +const host: HostProfile = { + id: 'host-1', + name: 'Studio', + endpoint: 'ws://studio.local:8765', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} +const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' } +const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise { + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host, + state: 'connected', + verdict, + path: 'lan', + worktreeInfo, + onPress: () => {}, + onLongPress: () => {} + }) + ) + }) + return renderer!.root + .findAllByType('Text') + .flatMap((node) => node.children.filter((child) => typeof child === 'string')) + } + + it('renders the counts the host proved', async () => { + expect(await renderCard(loaded)).toContain('12 worktrees · 2 active') + }) + + it('keeps rendering the last proven counts after a failed refresh', async () => { + // The regression this card shipped once: the caller dropped the counts the + // failure path deliberately preserved. + expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('never asserts a count for a catalog that failed with nothing proven', async () => { + expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain( + 'Worktree list unavailable' + ) + }) + + it('shows no worktree line before the first read lands', async () => { + const lines = await renderCard(undefined) + + expect(lines).not.toContain('0 worktrees') + expect(lines).not.toContain('Worktree list unavailable') + }) +}) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index 4e466922f4a..79a58bf760e 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' +import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { @@ -13,20 +14,15 @@ export function MobileHostCard(props: { state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath - worktreeCounts?: { total: number; active: number } - // Why (STA-3123): the host is connected but its worktree catalog request failed, - // so the card must say "unavailable" instead of asserting a count of zero. - worktreeCountsUnavailable?: boolean + // Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts + // away (STA-3123 shipped that bug once already). + worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void }) { const connected = props.state === 'connected' const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) - const worktreeSummary = props.worktreeCounts - ? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}` - : props.worktreeCountsUnavailable - ? 'Worktree list unavailable' - : null + const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( [styles.card, pressed && styles.cardPressed]} diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 2d3469ef1ff..744db32815a 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -13,6 +13,7 @@ vi.mock('react-native', () => ({ Text: 'Text', View: 'View' })) +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) function flattenText(node: ReactTestInstance): string { return node.children diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts index 20a92663a49..5a59e749b20 100644 --- a/mobile/src/components/MobileMarkdown.test.ts +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -1,8 +1,40 @@ import { describe, expect, it } from 'vitest' +import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' import { parseMobileMarkdown } from './mobile-markdown-parser' +describe('isMobileMermaidLanguage', () => { + it('matches mermaid case-insensitively after trim', () => { + expect(isMobileMermaidLanguage('mermaid')).toBe(true) + expect(isMobileMermaidLanguage('Mermaid')).toBe(true) + expect(isMobileMermaidLanguage(' MERMAID ')).toBe(true) + }) + + it('rejects non-mermaid languages and missing language', () => { + expect(isMobileMermaidLanguage(undefined)).toBe(false) + expect(isMobileMermaidLanguage('')).toBe(false) + expect(isMobileMermaidLanguage('ts')).toBe(false) + expect(isMobileMermaidLanguage('mermaidx')).toBe(false) + }) +}) + describe('parseMobileMarkdown', () => { + it('parses mermaid fences as code blocks with language mermaid', () => { + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([ + { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true } + ]) + expect(isMobileMermaidLanguage('mermaid')).toBe(true) + }) + + it('marks an unterminated fence as not closed while it streams', () => { + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([ + { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false } + ]) + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({ + closed: true + }) + }) + it('parses GFM tables into table blocks', () => { expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([ { diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 975a4b4914a..ca272ac7bb0 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -12,7 +12,9 @@ import { isIntrawordUnderscoreToken, trimAutolinkTrailingPunctuation } from './markdown-inline-token-rules' +import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { parseMobileMarkdown } from './mobile-markdown-parser' +import { MermaidDiagram } from './pr-sidebar/MermaidDiagram' type Props = { content?: string @@ -29,6 +31,8 @@ type Props = { const MAX_TABLE_ROWS = 40 const MAX_TABLE_COLUMNS = 8 +/** Prose base size — passed to MermaidDiagram fallback mono text. */ +const MERMAID_BASE = 13 // Web/mail hrefs open the system handler; file-target hrefs (file: URIs and // scheme-less paths — the entire desktop file-link contract) go to onOpenFile. @@ -177,6 +181,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile if (!text) { return fallback ? {fallback} : null } + const mermaidSourceOccurrences = new Map() return ( @@ -199,6 +204,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile ) } if (block.type === 'code') { + // Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar. + // Unclosed fences are still streaming: mounting the WebView per tick would + // reload its document up to 20x/sec, so they stay raw code until terminated. + if (isMobileMermaidLanguage(block.language) && block.closed) { + const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0 + mermaidSourceOccurrences.set(block.text, occurrence + 1) + return ( + + ) + } return ( {block.language ? {block.language} : null} diff --git a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts new file mode 100644 index 00000000000..c89dfa00853 --- /dev/null +++ b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts @@ -0,0 +1,80 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +vi.mock('react-native', () => ({ + Linking: { openURL: vi.fn() }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + renderer?.unmount() + renderer = undefined + vi.restoreAllMocks() +}) + +function render(content: string): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileMarkdown, { content })) + }) + return renderer! +} + +function mermaidCount(tree: ReactTestRenderer): number { + return tree.root.findAllByType('MermaidDiagram' as never).length +} + +function firstMermaid(tree: ReactTestRenderer) { + return tree.root.findByType('MermaidDiagram' as never) +} + +describe('MobileMarkdown mermaid routing', () => { + it('routes a closed mermaid fence to MermaidDiagram', () => { + const tree = render('```mermaid\ngraph TD; A-->B\n```') + expect(mermaidCount(tree)).toBe(1) + }) + + it('keeps a streaming (unterminated) mermaid fence as raw code', () => { + const tree = render('```mermaid\ngraph TD; A-->B') + expect(mermaidCount(tree)).toBe(0) + }) + + it('keeps identical sibling diagrams uniquely keyed', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const diagram = '```mermaid\ngraph TD; A-->B\n```' + const tree = render(`${diagram}\n\n${diagram}`) + const warnings = consoleError.mock.calls.flat().join(' ') + expect(mermaidCount(tree)).toBe(2) + expect(warnings).not.toContain('same key') + }) + + it('preserves a completed diagram while later prose streams', () => { + const diagram = '```mermaid\ngraph TD; A-->B\n```' + const tree = render(diagram) + const initial = firstMermaid(tree) + act(() => tree.update(createElement(MobileMarkdown, { content: `${diagram}\n\nNext` }))) + expect(firstMermaid(tree)).toBe(initial) + }) + + it('remounts a diagram when its source changes', () => { + const tree = render('```mermaid\ngraph TD; A-->B\n```') + const initial = firstMermaid(tree) + act(() => + tree.update(createElement(MobileMarkdown, { content: '```mermaid\ngraph TD; A-->C\n```' })) + ) + expect(firstMermaid(tree)).not.toBe(initial) + }) + + it('does not route other code fences to MermaidDiagram', () => { + const tree = render('```ts\nconst a = 1\n```') + expect(mermaidCount(tree)).toBe(0) + }) +}) diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts index b6375f389c5..fa16b3b9310 100644 --- a/mobile/src/components/mobile-markdown-parser.ts +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -2,7 +2,7 @@ export type MobileMarkdownBlock = | { type: 'paragraph'; text: string } | { type: 'heading'; level: number; text: string } | { type: 'quote'; text: string } - | { type: 'code'; text: string; language?: string } + | { type: 'code'; text: string; language?: string; closed: boolean } | { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> } | { type: 'image'; alt: string; url: string } | { type: 'table'; headers: string[]; rows: string[][] } @@ -42,10 +42,12 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { code.push(lines[index] ?? '') index += 1 } - if (index < lines.length) { + // closed=false means the fence is still streaming in (no terminator yet). + const closed = index < lines.length + if (closed) { index += 1 } - blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] }) + blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed }) continue } diff --git a/mobile/src/components/mobile-mermaid-language.ts b/mobile/src/components/mobile-mermaid-language.ts new file mode 100644 index 00000000000..8e7811ba167 --- /dev/null +++ b/mobile/src/components/mobile-mermaid-language.ts @@ -0,0 +1,4 @@ +/** True when a fenced code block language should render as a Mermaid diagram. */ +export function isMobileMermaidLanguage(language?: string): boolean { + return language?.trim().toLowerCase() === 'mermaid' +} diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx index b4c4811c0d3..0df2aa210db 100644 --- a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx +++ b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { WebView } from 'react-native-webview' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated' type Props = { source: string @@ -9,11 +10,15 @@ type Props = { } // Renders a ```mermaid fence as a diagram via a sandboxed WebView (mermaid has no -// native RN renderer). Mermaid is loaded from a CDN inside the WebView HTML, the -// SVG is themed dark to match the sidebar, and the WebView posts back its rendered -// height so we can size to content. On any failure (no network, parse error, -// render error) we fall back to the raw source in a labeled mono code box. -export function MermaidDiagram({ source, base }: Props) { +// native RN renderer). Mermaid ships inside the app as a generated bundle embedded +// in the WebView HTML — no network — the SVG is themed dark to match the sidebar, +// and the WebView posts back its rendered height so we can size to content. On any +// failure (parse error, render error) we fall back to the raw source in a labeled +// mono code box. +// memo: both props are primitives; without it every mounted diagram re-renders +// per frame during pinch-to-zoom (textScale updates), marshalling the full HTML +// string across the Fabric boundary each time. +export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Props) { const [height, setHeight] = useState(0) const [failed, setFailed] = useState(false) const html = useMemo(() => buildHtml(source), [source]) @@ -58,7 +63,7 @@ export function MermaidDiagram({ source, base }: Props) { /> ) -} +}) function MermaidFallback({ source, base }: Props) { return ( @@ -73,21 +78,33 @@ function MermaidFallback({ source, base }: Props) { ) } -// Self-contained HTML: load mermaid from CDN, render the graph, post the body +// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and +// the U+2028/U+2029 line separators raw — so a source containing `` +// would close this inline +
@@ -114,7 +131,7 @@ function buildHtml(source: string): string { } }); mermaid.run({ querySelector: '.mermaid' }) - .then(reportHeight) + .then(function () { reportHeight(); }) .catch(function () { post('error'); }); } catch (e) { post('error'); diff --git a/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts b/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts new file mode 100644 index 00000000000..d66d82d9f53 --- /dev/null +++ b/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildHtml } from './MermaidDiagram' +import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated' + +vi.mock('react-native', () => ({ + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-webview', () => ({ WebView: 'WebView' })) + +// The diagram source is untrusted (agent output, PR/chat content). It is embedded +// inside an inline payload break out of the inline script', () => { + const payload = 'graph TD; A-->B' + const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length + // The payload's two must add zero raw closers over a benign render — + // they were neutralized to \u003c instead of closing our inline script. + const benign = countClosers(buildHtml('graph TD; A-->B')) + expect(countClosers(buildHtml(payload))).toBe(benign) + expect(buildHtml(payload)).toContain('\\u003c/script') + }) + + it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => { + const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c` + const html = buildHtml(payload) + expect(html).toContain('\\u2028') + expect(html).toContain('\\u2029') + expect(html.includes(String.fromCharCode(0x2028))).toBe(false) + expect(html.includes(String.fromCharCode(0x2029))).toBe(false) + }) + + it('still round-trips ordinary source to the exact original string', () => { + const payload = 'graph LR\n A["node & "] --> B' + const html = buildHtml(payload) + const match = html.match(/\.textContent = (".*?");\n {4}mermaid\.initialize/s) + expect(match).not.toBeNull() + expect(JSON.parse(match![1]!)).toBe(payload) + }) + + // Same gate as the terminal document: mermaid is embedded from the lockfile-pinned + // package, so the document itself must load nothing external (offline-safe, no CDN + // supply-chain exposure). The engine's internal URL literals (xmlns, docs links) + // are inert data, so the gate checks the document with the engine stripped out. + it('embeds the mermaid engine and loads no external resource', () => { + const html = buildHtml('graph TD; A-->B') + expect(html).toContain(MERMAID_ENGINE_JS) + expect(html.replace(MERMAID_ENGINE_JS, '')).not.toMatch(/\bhttps?:\/\//) + }) + + it('blocks external resources requested by diagram syntax', () => { + const html = buildHtml('flowchart LR\nA@{ img: "https://example.com/pixel.png" }') + const policy = html.match(/Content-Security-Policy" content="([^"]+)"/)?.[1] + expect(policy).toContain("default-src 'none'") + expect(policy).toContain('img-src data: blob:') + expect(policy).not.toMatch(/https?:/) + }) +}) diff --git a/mobile/src/session/mobile-file-tap-open.test.ts b/mobile/src/session/mobile-file-tap-open.test.ts index e9a59685e27..d2a324d2c12 100644 --- a/mobile/src/session/mobile-file-tap-open.test.ts +++ b/mobile/src/session/mobile-file-tap-open.test.ts @@ -135,6 +135,56 @@ describe('openMobileFileTap', () => { expect(switchSessionTab).toHaveBeenCalledWith(openedTab) }) + it('opens a sibling terminal path through the resolved owning worktree', async () => { + const client = createClient([ + ok({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md' + } + }) + ]) + const pushPreviewRoute = vi.fn() + + openMobileFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: '/repo-b/docs/readme.md', + line: null, + column: null, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + await Promise.resolve() + + expect(pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + hostId: 'host-1', + worktreeId: 'wt-2', + source: 'worktree', + relativePath: 'docs/readme.md' + }) + }) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + it('opens worktree-contained line references through the preview route', async () => { const client = createClient([ ok({ diff --git a/mobile/src/session/mobile-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts index f9d8ea84017..00b98ce214d 100644 --- a/mobile/src/session/mobile-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -95,13 +95,17 @@ async function openMobileFileTapAsync( if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) { return } + const resolvedWorktreeId = resolved.worktree?.trim() || options.worktreeId + const resolvedWorktree = `id:${resolvedWorktreeId}` + const resolvedWorktreeName = + resolvedWorktreeId === options.worktreeId ? options.worktreeName : undefined if (resolved.openTarget?.kind === 'absolute-file') { options.triggerOpenFeedback() options.pushPreviewRoute( createMobileFilePreviewHref({ hostId: options.hostId, - worktreeId: options.worktreeId, + worktreeId: resolvedWorktreeId, source: 'terminalArtifact', absolutePath: resolved.openTarget.absolutePath, grantId: resolved.openTarget.grantId, @@ -113,7 +117,7 @@ async function openMobileFileTapAsync( name: displayNameFromPath(resolved.openTarget.absolutePath), ...(options.line !== null ? { line: String(options.line) } : {}), ...(options.column !== null ? { column: String(options.column) } : {}), - ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}) + ...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {}) }) ) return @@ -128,17 +132,21 @@ async function openMobileFileTapAsync( return } options.triggerOpenFeedback() - if (options.line !== null || options.column !== null) { + if ( + resolvedWorktreeId !== options.worktreeId || + options.line !== null || + options.column !== null + ) { options.pushPreviewRoute( createMobileFilePreviewHref({ hostId: options.hostId, - worktreeId: options.worktreeId, + worktreeId: resolvedWorktreeId, source: 'worktree', relativePath: openedPath, name: displayNameFromPath(openedPath), ...(options.line !== null ? { line: String(options.line) } : {}), ...(options.column !== null ? { column: String(options.column) } : {}), - ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}) + ...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {}) }) ) return @@ -153,7 +161,7 @@ async function openMobileFileTapAsync( } const openResponse = await options.client.sendRequest( 'files.open', - { worktree, relativePath: openedPath }, + { worktree: resolvedWorktree, relativePath: openedPath }, { timeoutMs: 15_000 } ) if (!openResponse.ok) { diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts index 8695928ed03..6ee636fb9f4 100644 --- a/mobile/src/session/mobile-terminal-diagnostics.ts +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -141,12 +141,35 @@ export class MobileTerminalDiagnostics { }) } - streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void { + streamResubscribing( + handle: string, + seq: number, + dims: { cols: number; rows: number }, + attempt: number, + delayMs: number + ): void { logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', { handle: shortenMobileTerminalDiagnosticId(handle), seq, cols: dims.cols, - rows: dims.rows + rows: dims.rows, + attempt, + delayMs + }) + } + + streamResubscribeHeld(handle: string, seq: number): void { + logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq + }) + } + + streamResubscribeExhausted(handle: string, seq: number, attempts: number): void { + logMobileTerminalDiagnostic('stream-resubscribe-exhausted', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + attempts }) } diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts new file mode 100644 index 00000000000..35aac89f6f8 --- /dev/null +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS, + TerminalViewportResubscribeBudget, + readTerminalViewportDims, + resolveTerminalViewportResubscribe, + runTerminalViewportFitPass, + shouldResubscribeAfterViewportMeasure, + type TerminalViewportFitPassArgs +} from './mobile-terminal-viewport-resubscribe' + +const PHONE = { cols: 40, rows: 50 } + +describe('readTerminalViewportDims', () => { + it('accepts only usable numeric host dimensions', () => { + expect(readTerminalViewportDims({ cols: 40, rows: 50 })).toEqual({ + hostCols: 40, + hostRows: 50 + }) + expect(readTerminalViewportDims({ cols: Number.NaN, rows: 0 })).toEqual({ + hostCols: null, + hostRows: null + }) + }) +}) + +describe('resolveTerminalViewportResubscribe', () => { + it('resubscribes immediately on the first pass when the viewport is unmeasured', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: false, + viewport: null, + attempts: 0 + }) + ).toEqual({ kind: 'resubscribe', delayMs: 0 }) + }) + + it('caps even the unmeasured pass once the budget is spent', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: false, + viewport: null, + attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + }) + ).toEqual({ kind: 'exhausted' }) + }) + + it('holds on absent host dims instead of resubscribing (STA-3337 regression)', () => { + for (const [hostCols, hostRows] of [ + [null, null], + [80, null], + [null, 24] + ] as const) { + expect( + resolveTerminalViewportResubscribe({ + hostCols, + hostRows, + viewportMeasured: true, + viewport: PHONE, + attempts: 0 + }) + ).toEqual({ kind: 'hold' }) + } + }) + + it('keeps holding on absent dims across repeated frames without spending budget', () => { + for (let frame = 0; frame < 50; frame += 1) { + expect( + resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: true, + viewport: PHONE, + attempts: 0 + }).kind + ).toBe('hold') + } + }) + + it('converges when host dims match the measured viewport', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + viewportMeasured: true, + viewport: PHONE, + attempts: 2 + }) + ).toEqual({ kind: 'converged' }) + }) + + it('backs off across mismatch attempts and then exhausts', () => { + const delays = [0, 1, 2].map((attempts) => { + const decision = resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + attempts + }) + if (decision.kind !== 'resubscribe') { + throw new Error(`expected resubscribe at attempt ${attempts}, got ${decision.kind}`) + } + return decision.delayMs + }) + expect(delays[0]).toBe(0) + expect(delays[1]).toBeGreaterThan(0) + expect(delays[2]).toBeGreaterThan(delays[1]) + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + }) + ).toEqual({ kind: 'exhausted' }) + }) +}) + +describe('shouldResubscribeAfterViewportMeasure', () => { + it('always resubscribes when the viewport was never measured (server must learn it)', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + measured: PHONE, + viewportWasMeasured: false + }) + ).toBe(true) + }) + + it('skips the resubscribe when the fresh measure already matches the host', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + measured: PHONE, + viewportWasMeasured: true + }) + ).toBe(false) + }) + + it('resubscribes when the host still disagrees with the fresh measure', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: 80, + hostRows: 24, + measured: PHONE, + viewportWasMeasured: true + }) + ).toBe(true) + }) +}) + +describe('TerminalViewportResubscribeBudget', () => { + const exhaust = (budget: TerminalViewportResubscribeBudget, handle: string) => { + for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) { + budget.chargeAttempt(handle) + } + } + + it('starts at zero and counts charged attempts per handle', () => { + const budget = new TerminalViewportResubscribeBudget() + expect(budget.attempts('t1')).toBe(0) + budget.chargeAttempt('t1') + budget.chargeAttempt('t1') + expect(budget.attempts('t1')).toBe(2) + expect(budget.attempts('t2')).toBe(0) + }) + + it('resets attempts and re-arms the announcement on convergence', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + budget.markConverged('t1') + expect(budget.attempts('t1')).toBe(0) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + }) + + it('announces exhaustion exactly once', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(false) + }) + + it('does not refill an exhausted handle that stayed listed', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + for (let refresh = 0; refresh < 5; refresh += 1) { + budget.notifyListedHandles(new Set(['t1'])) + } + expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + }) + + it('refills only after the exhausted handle went absent and came back', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + budget.notifyListedHandles(new Set()) + // Still exhausted while absent — the refill lands on the return. + expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + budget.notifyListedHandles(new Set(['t1'])) + expect(budget.attempts('t1')).toBe(0) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + }) + + it('leaves below-cap handles untouched by list refreshes', () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt('t1') + budget.notifyListedHandles(new Set()) + budget.notifyListedHandles(new Set(['t1'])) + expect(budget.attempts('t1')).toBe(1) + }) + + it('forget clears one handle, clear clears everything', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + exhaust(budget, 't2') + budget.forget('t1') + expect(budget.attempts('t1')).toBe(0) + expect(budget.attempts('t2')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + budget.clear() + expect(budget.attempts('t2')).toBe(0) + }) +}) + +describe('runTerminalViewportFitPass', () => { + const HANDLE = 't1' + + function makeHarness(overrides: { + hostCols?: number | null + hostRows?: number | null + viewportMeasured?: boolean + viewport?: { cols: number; rows: number } | null + measured?: { cols: number; rows: number } | null + budget?: TerminalViewportResubscribeBudget + }) { + const budget = overrides.budget ?? new TerminalViewportResubscribeBudget() + const diagnostics = { + streamResubscribing: vi.fn(), + streamResubscribeHeld: vi.fn(), + streamResubscribeExhausted: vi.fn() + } + const terminalUnsubsRef = { current: new Map void>([[HANDLE, () => {}]]) } + const scheduled: { fn: () => void; ms: number }[] = [] + const webView = { + awaitReady: () => Promise.resolve(), + measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE) + } + const unsubscribeTerminal = vi.fn((handle: string) => { + terminalUnsubsRef.current.delete(handle) + }) + // Mimic the real subscribe path: arming registers an unsubscribe handle. + const subscribeToTerminal = vi.fn((handle: string) => { + terminalUnsubsRef.current.set(handle, () => {}) + }) + const showToast = vi.fn() + const args: TerminalViewportFitPassArgs = { + handle: HANDLE, + seq: 1, + hostCols: overrides.hostCols ?? null, + hostRows: overrides.hostRows ?? null, + budget, + diagnostics, + viewportRef: { current: overrides.viewport ?? null }, + viewportMeasuredRef: { current: overrides.viewportMeasured ?? false }, + subscribeSeqRef: { current: new Map([[HANDLE, 1]]) }, + initializedHandlesRef: { current: new Set([HANDLE]) }, + terminalUnsubsRef, + terminalFrameHeightRef: { current: 0 }, + getTerminalRef: () => webView, + unsubscribeTerminal, + subscribeToTerminal, + scheduleDelayedAction: (fn, ms) => scheduled.push({ fn, ms }), + showToast + } + return { + args, + budget, + diagnostics, + scheduled, + subscribeToTerminal, + unsubscribeTerminal, + showToast + } + } + + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + + it('holds without any teardown when host dims are absent (STA-3337 regression)', async () => { + const h = makeHarness({ viewportMeasured: true, viewport: PHONE }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.diagnostics.streamResubscribeHeld).toHaveBeenCalledTimes(1) + }) + + it('measures and resubscribes immediately on the first pass, charging one attempt', async () => { + const h = makeHarness({ hostCols: 80, hostRows: 24 }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1) + expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1) + expect(h.args.viewportRef.current).toEqual(PHONE) + expect(h.args.viewportMeasuredRef.current).toBe(true) + expect(h.budget.attempts(HANDLE)).toBe(1) + }) + + it('treats an equal fresh measure as convergence instead of resubscribing', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + // Stale cached viewport disagrees with the host, but the fresh measure matches it. + const h = makeHarness({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + viewportMeasured: true, + viewport: { cols: 30, rows: 20 }, + measured: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(0) + }) + + it('defers later attempts through the backoff scheduler while keeping the stream up', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + expect(h.scheduled[0].ms).toBeGreaterThan(0) + // The stream must still be up until the deferred retry fires. + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1) + expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1) + expect(h.budget.attempts(HANDLE)).toBe(2) + }) + + it('drops a deferred retry whose subscribe generation went stale', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + h.args.subscribeSeqRef.current.set(HANDLE, 2) + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(1) + }) + + it('drops a deferred retry after the live stream converges', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + expect(h.budget.observeResize(HANDLE, PHONE, PHONE)).toEqual([PHONE.cols, PHONE.rows]) + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(0) + }) + + it('announces exhaustion once and stops touching the stream', async () => { + const budget = new TerminalViewportResubscribeBudget() + for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) { + budget.chargeAttempt(HANDLE) + } + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.showToast).toHaveBeenCalledTimes(1) + expect(h.diagnostics.streamResubscribeExhausted).toHaveBeenCalledTimes(2) + }) +}) + +describe('STA-3337 stream shapes', () => { + it('empty scrollback with absent dims settles after a single register pass', () => { + const budget = new TerminalViewportResubscribeBudget() + // Pass 1: no viewport yet — measure and resubscribe so the server learns it. + const first = resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: false, + viewport: null, + attempts: budget.attempts('t1') + }) + expect(first).toEqual({ kind: 'resubscribe', delayMs: 0 }) + budget.chargeAttempt('t1') + // Pass 2+: host still reports no dims — the stream must be left alone. + for (let frame = 0; frame < 10; frame += 1) { + expect( + resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: true, + viewport: PHONE, + attempts: budget.attempts('t1') + }).kind + ).toBe('hold') + } + expect(budget.attempts('t1')).toBe(1) + }) + + it('non-converging numeric dims degrade after the bounded backoff run', () => { + const budget = new TerminalViewportResubscribeBudget() + const kinds: string[] = [] + for (let frame = 0; frame < 6; frame += 1) { + const decision = resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: frame > 0, + viewport: frame > 0 ? PHONE : null, + attempts: budget.attempts('t1') + }) + kinds.push(decision.kind) + if (decision.kind === 'resubscribe') { + budget.chargeAttempt('t1') + } + } + expect(kinds).toEqual([ + 'resubscribe', + 'resubscribe', + 'resubscribe', + 'exhausted', + 'exhausted', + 'exhausted' + ]) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts new file mode 100644 index 00000000000..8ad3c99c13e --- /dev/null +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -0,0 +1,293 @@ +/** Bounds the scrollback→measure→resubscribe fit loop (STA-3337): a host whose + * frame dims can never equal the phone viewport must not re-arm the stream + * forever — it broke gesture recognition and drained battery at ~25 cycles/s. */ + +import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' + +export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3 + +/** Attempt-indexed teardown delay. Attempt 0 is the ordinary first fit pass + * (server learns the viewport) and must stay immediate; later attempts mean + * the server answered with non-matching dims, so probe at a decaying rate. */ +const TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS = [0, 750, 3000] as const + +export type TerminalViewportDims = { readonly cols: number; readonly rows: number } + +function readPositiveDimension(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +export function readTerminalViewportDims(data: Readonly>): { + readonly hostCols: number | null + readonly hostRows: number | null +} { + return { + hostCols: readPositiveDimension(data.cols), + hostRows: readPositiveDimension(data.rows) + } +} + +export type TerminalViewportResubscribeDecision = + | { readonly kind: 'resubscribe'; readonly delayMs: number } + | { readonly kind: 'converged' } + | { readonly kind: 'hold' } + | { readonly kind: 'exhausted' } + +function resubscribeDelayMs(attempts: number): number { + return TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS[ + Math.min(attempts, TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS.length - 1) + ] +} + +export function resolveTerminalViewportResubscribe(args: { + hostCols: number | null + hostRows: number | null + viewportMeasured: boolean + viewport: TerminalViewportDims | null + attempts: number +}): TerminalViewportResubscribeDecision { + const overBudget = args.attempts >= MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + // First subscribe carries no viewport; resubscribing is how the server learns it. + if (!args.viewportMeasured || args.viewport == null) { + return overBudget ? { kind: 'exhausted' } : { kind: 'resubscribe', delayMs: 0 } + } + // Why: a host that doesn't report PTY dims can never converge — resubscribing + // replays the identical frame, so keep the stream instead of probing it. + if (args.hostCols == null || args.hostRows == null) { + return { kind: 'hold' } + } + if (args.hostCols === args.viewport.cols && args.hostRows === args.viewport.rows) { + return { kind: 'converged' } + } + return overBudget + ? { kind: 'exhausted' } + : { kind: 'resubscribe', delayMs: resubscribeDelayMs(args.attempts) } +} + +/** Post-measure re-check: the pre-measure mismatch may have been a stale cached + * viewport. Resubscribing is only productive when the server still disagrees + * with the fresh measure, or was never told the viewport at all. */ +export function shouldResubscribeAfterViewportMeasure(args: { + hostCols: number | null + hostRows: number | null + measured: TerminalViewportDims + viewportWasMeasured: boolean +}): boolean { + if (!args.viewportWasMeasured) { + return true + } + return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows +} + +/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts + * refill only when the handle actually left terminal.list and came back. A + * still-listed non-converging handle re-funded on every list refresh would undo + * the bound this class exists to enforce. */ +export class TerminalViewportResubscribeBudget { + private readonly attemptsByHandle = new Map() + private readonly absentSinceExhaustion = new Set() + private readonly announcedExhaustion = new Set() + private readonly retryGenerationByHandle = new Map() + + attempts(handle: string): number { + return this.attemptsByHandle.get(handle) ?? 0 + } + + chargeAttempt(handle: string): void { + this.attemptsByHandle.set(handle, this.attempts(handle) + 1) + } + + retryGeneration(handle: string): object { + const existing = this.retryGenerationByHandle.get(handle) + if (existing) { + return existing + } + const generation = {} + this.retryGenerationByHandle.set(handle, generation) + return generation + } + + isRetryGenerationCurrent(handle: string, generation: object): boolean { + return this.retryGenerationByHandle.get(handle) === generation + } + + observeResize( + handle: string, + data: Readonly>, + viewport: TerminalViewportDims | null + ): readonly [number, number] { + const { hostCols, hostRows } = readTerminalViewportDims(data) + const cols = hostCols ?? 80 + const rows = hostRows ?? 24 + if (viewport?.cols === cols && viewport.rows === rows) { + this.markConverged(handle) + } + return [cols, rows] + } + + markConverged(handle: string): void { + this.forget(handle) + } + + /** True exactly once per exhaustion so the degraded state is announced, not spammed. */ + shouldAnnounceExhaustion(handle: string): boolean { + if (this.announcedExhaustion.has(handle)) { + return false + } + this.announcedExhaustion.add(handle) + return true + } + + notifyListedHandles(liveHandles: ReadonlySet): void { + for (const handle of Array.from(this.attemptsByHandle.keys())) { + if (this.attempts(handle) < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) { + continue + } + if (!liveHandles.has(handle)) { + this.absentSinceExhaustion.add(handle) + continue + } + // Why: only an absence marker buys a refill — the handle's PTY may be live + // again, so a fresh budget (and a fresh degrade announcement) is warranted. + if (this.absentSinceExhaustion.delete(handle)) { + this.forget(handle) + } + } + } + + forget(handle: string): void { + this.attemptsByHandle.delete(handle) + this.absentSinceExhaustion.delete(handle) + this.announcedExhaustion.delete(handle) + this.retryGenerationByHandle.delete(handle) + } + + clear(): void { + this.attemptsByHandle.clear() + this.absentSinceExhaustion.clear() + this.announcedExhaustion.clear() + this.retryGenerationByHandle.clear() + } +} + +type MutableRef = { current: T } + +type TerminalFitWebView = { + awaitReady: () => Promise + measureFitDimensions: (frameHeight?: number) => Promise +} + +export type TerminalViewportFitPassArgs = { + handle: string + seq: number + hostCols: number | null + hostRows: number | null + budget: TerminalViewportResubscribeBudget + diagnostics: Pick< + MobileTerminalDiagnostics, + 'streamResubscribing' | 'streamResubscribeHeld' | 'streamResubscribeExhausted' + > + viewportRef: MutableRef + viewportMeasuredRef: MutableRef + subscribeSeqRef: MutableRef> + initializedHandlesRef: MutableRef> + terminalUnsubsRef: MutableRef void>> + terminalFrameHeightRef: MutableRef + getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined + unsubscribeTerminal: (handle: string) => void + subscribeToTerminal: (handle: string) => void + scheduleDelayedAction: (fn: () => void, ms: number) => void + showToast: (message: string, durationMs?: number) => void +} + +/** One bounded fit pass per scrollback frame: converge, hold, degrade visibly, + * or measure and resubscribe (backing off) so the server can phone-fit. */ +export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): void { + const { handle, seq, hostCols, hostRows, budget, diagnostics } = args + const retryGeneration = budget.retryGeneration(handle) + const decision = resolveTerminalViewportResubscribe({ + hostCols, + hostRows, + viewportMeasured: args.viewportMeasuredRef.current, + viewport: args.viewportRef.current, + attempts: budget.attempts(handle) + }) + if (decision.kind === 'converged') { + budget.markConverged(handle) + return + } + if (decision.kind === 'hold') { + diagnostics.streamResubscribeHeld(handle, seq) + return + } + if (decision.kind === 'exhausted') { + diagnostics.streamResubscribeExhausted(handle, seq, budget.attempts(handle)) + if (budget.shouldAnnounceExhaustion(handle)) { + args.showToast("Couldn't fit the terminal to this screen", 4000) + } + return + } + const viewportWasMeasured = args.viewportMeasuredRef.current + void (async () => { + // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). + await args.getTerminalRef(handle)?.awaitReady() + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + const dims = await args + .getTerminalRef(handle) + ?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined) + // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + if (!args.getTerminalRef(handle) || !dims) { + return + } + args.viewportRef.current = dims + args.viewportMeasuredRef.current = true + if ( + !shouldResubscribeAfterViewportMeasure({ + hostCols, + hostRows, + measured: dims, + viewportWasMeasured + }) + ) { + // Why: the pre-measure mismatch was a stale cached viewport; the server already agrees. + budget.markConverged(handle) + return + } + const resubscribe = (): void => { + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + if (!args.getTerminalRef(handle)) { + return + } + diagnostics.streamResubscribing(handle, seq, dims, budget.attempts(handle), decision.delayMs) + args.unsubscribeTerminal(handle) + args.initializedHandlesRef.current.delete(handle) + args.subscribeToTerminal(handle) + // Why: only a resubscribe that actually armed spends budget; one turned away by its own gates never reached the host. + if (args.terminalUnsubsRef.current.has(handle)) { + budget.chargeAttempt(handle) + } + } + if (decision.delayMs > 0) { + // Why: keep the live stream up through the backoff so input keeps flowing; teardown happens only when the retry fires. + args.scheduleDelayedAction(resubscribe, decision.delayMs) + } else { + resubscribe() + } + })() +} diff --git a/mobile/src/stats/home-stats-total.test.ts b/mobile/src/stats/home-stats-total.test.ts new file mode 100644 index 00000000000..035f6c7476f --- /dev/null +++ b/mobile/src/stats/home-stats-total.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { totalHomeStats, type HomeStatsSummary } from './home-stats-total' + +function stats(overrides: Partial = {}): HomeStatsSummary { + return { + totalAgentsSpawned: 10, + totalPRsCreated: 2, + totalAgentTimeMs: 60_000, + firstEventAt: 1_700_000_000_000, + ...overrides + } +} + +describe('totalHomeStats', () => { + it('has nothing to show before any host answers', () => { + expect(totalHomeStats({}, ['host-1'])).toBeNull() + }) + + it('passes a single host through unchanged', () => { + expect(totalHomeStats({ 'host-1': stats() }, ['host-1'])).toEqual(stats()) + }) + + it('sums every desktop instead of letting the last reply win', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + }, + ['host-1', 'host-2'] + ) + + expect(total).toMatchObject({ + totalAgentsSpawned: 15, + totalPRsCreated: 3, + totalAgentTimeMs: 90_000 + }) + }) + + it('drops a removed desktop from the total', () => { + // Replies are cached for the life of the process, so the entry outlives the pairing. + const byHost = { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + } + + expect(totalHomeStats(byHost, ['host-1'])).toEqual(stats()) + expect(totalHomeStats(byHost, [])).toBeNull() + }) + + it('keeps the earliest known first event', () => { + const total = totalHomeStats( + { + 'host-1': stats({ firstEventAt: 2_000 }), + 'host-2': stats({ firstEventAt: null }), + 'host-3': stats({ firstEventAt: 1_000 }) + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total?.firstEventAt).toBe(1_000) + }) + + it('reports no first event when no host has one', () => { + expect( + totalHomeStats({ 'host-1': stats({ firstEventAt: null }) }, ['host-1'])?.firstEventAt + ).toBeNull() + }) + + it('ignores a malformed reply instead of poisoning the header', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': null as unknown as HomeStatsSummary, + 'host-3': { totalAgentsSpawned: 'lots' } as unknown as HomeStatsSummary + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total).toEqual(stats()) + }) +}) diff --git a/mobile/src/stats/home-stats-total.ts b/mobile/src/stats/home-stats-total.ts new file mode 100644 index 00000000000..72264872561 --- /dev/null +++ b/mobile/src/stats/home-stats-total.ts @@ -0,0 +1,51 @@ +export type HomeStatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +/** + * Why: the home header shows one lifetime-usage row for every paired desktop. Each host answers + * stats.summary for itself, so a single shared slot made the row flip to whichever host replied + * last — visible churn now that every reconnect re-reads. Sum instead; one host still totals itself. + * + * Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host + * for the life of the process, so an entry outlives the host it describes. + */ +export function totalHomeStats( + byHost: Record, + hostIds: readonly string[] +): HomeStatsSummary | null { + const hosts = hostIds.filter((id) => id in byHost).map((id) => byHost[id]) + if (hosts.length === 0) { + return null + } + const total: HomeStatsSummary = { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + firstEventAt: null + } + for (const host of hosts) { + // The rows come straight off the wire unvalidated; a malformed desktop reply must not + // NaN out or crash the header for every other host. + if (!host || typeof host !== 'object') { + continue + } + total.totalAgentsSpawned += finiteOrZero(host.totalAgentsSpawned) + total.totalPRsCreated += finiteOrZero(host.totalPRsCreated) + total.totalAgentTimeMs += finiteOrZero(host.totalAgentTimeMs) + if (typeof host.firstEventAt === 'number' && Number.isFinite(host.firstEventAt)) { + total.firstEventAt = + total.firstEventAt === null + ? host.firstEventAt + : Math.min(total.firstEventAt, host.firstEventAt) + } + } + return total +} + +function finiteOrZero(value: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts new file mode 100644 index 00000000000..4c11320db2e --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' + +import { computeActiveTerminalKeyboardLift } from './terminal-keyboard-avoidance-lift' +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' +import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +const FRAME_HEIGHT = 800 +const ROWS = 40 +const KEYBOARD_LIFT = 300 + +function metrics( + overrides: Partial = {} +): TerminalKeyboardAvoidanceMetrics { + return { cursorY: 0, contentBottomRow: 0, rows: ROWS, altScreen: false, ...overrides } +} + +describe('computeActiveTerminalKeyboardLift', () => { + it('returns 0 when the keyboard is closed', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: 0, + metrics: metrics({ cursorY: 30, contentBottomRow: 34 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(0) + }) + + it('falls back to the full lift when metrics are missing', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: undefined, + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('falls back to the full lift when rows or frame height are unmeasured', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ rows: 0 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics(), + terminalFrameHeight: 0 + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('lifts fully for alt-screen TUIs', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 10, contentBottomRow: 10, altScreen: true }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('clears a main-buffer footer while an old payload retains cursor-only behavior', () => { + const candidate = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 30, contentBottomRow: 34 }), + terminalFrameHeight: FRAME_HEIGHT + }) + const oldPayload = parseTerminalKeyboardAvoidanceMetrics({ cursorY: 30, rows: ROWS }) + const cursorOnly = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: oldPayload, + terminalFrameHeight: FRAME_HEIGHT + }) + expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 }) + }) + + it('keeps short output near the top put (no lift)', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 2, contentBottomRow: 5 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(0) + }) + + it('matches cursor-clearing behavior for a scrolled shell (prompt at the bottom)', () => { + const lift = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 38, contentBottomRow: 38 }), + terminalFrameHeight: FRAME_HEIGHT + }) + expect(lift).toBe(KEYBOARD_LIFT) + }) + + it('never exceeds the keyboard lift', () => { + const lift = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 39, contentBottomRow: 39 }), + terminalFrameHeight: FRAME_HEIGHT + }) + expect(lift).toBeLessThanOrEqual(KEYBOARD_LIFT) + }) + + it('uses the platform-adjusted lift proportionally on iOS and Android', () => { + const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 }) + const android = computeActiveTerminalKeyboardLift({ + keyboardLift: 300, + metrics: tuiMetrics, + terminalFrameHeight: FRAME_HEIGHT + }) + const ios = computeActiveTerminalKeyboardLift({ + keyboardLift: 266, + metrics: tuiMetrics, + terminalFrameHeight: FRAME_HEIGHT + }) + expect({ android, ios }).toEqual({ android: 220, ios: 186 }) + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts b/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts new file mode 100644 index 00000000000..6ada640430d --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts @@ -0,0 +1,29 @@ +import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +type ActiveTerminalKeyboardLiftParams = { + keyboardLift: number + metrics: TerminalKeyboardAvoidanceMetrics | undefined + terminalFrameHeight: number +} + +export function computeActiveTerminalKeyboardLift( + params: ActiveTerminalKeyboardLiftParams +): number { + const { keyboardLift, metrics, terminalFrameHeight } = params + if (keyboardLift <= 0) { + return 0 + } + if (!metrics || metrics.rows <= 0 || terminalFrameHeight <= 0) { + return keyboardLift + } + if (metrics.altScreen) { + return keyboardLift + } + const rowHeight = terminalFrameHeight / metrics.rows + // Main-buffer TUI footer rows can sit below the caret. + const anchorRow = Math.max(metrics.cursorY, metrics.contentBottomRow) + const anchorBottom = (anchorRow + 1) * rowHeight + const dockTop = terminalFrameHeight - keyboardLift + const margin = rowHeight + return Math.min(keyboardLift, Math.max(0, anchorBottom + margin - dockTop)) +} diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts new file mode 100644 index 00000000000..a3a29ac61f9 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts @@ -0,0 +1,43 @@ +export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = ` + function lineHasVisibleContent(line, cell) { + if (line.translateToString(true).trim().length > 0) return true; + if (!cell || !line.getCell) return false; + var limit = Math.min(term.cols || 0, line.length || 0); + for (var x = 0; x < limit; x++) { + var current = line.getCell(x, cell); + if (!current) continue; + if (!current.isBgDefault() || current.isInverse()) return true; + if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; + if (typeof current.isOverline === 'function' && current.isOverline()) return true; + } + return false; + } + + function computeContentBottomRow() { + if (!term || !term.buffer || !term.buffer.active) return 0; + var buffer = term.buffer.active; + var top = buffer.viewportY || 0; + var cell = buffer.getNullCell ? buffer.getNullCell() : null; + for (var y = (term.rows || 0) - 1; y >= 0; y--) { + try { + var line = buffer.getLine(top + y); + if (line && lineHasVisibleContent(line, cell)) return y; + } catch (e) {} + } + return 0; + } + + function emitKeyboardAvoidanceMetrics() { + if (!term) return; + var alt = false; + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: term.rows || 0, + altScreen: alt + }); + } +` diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts new file mode 100644 index 00000000000..3a09ae3acd4 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -0,0 +1,215 @@ +import { readFileSync } from 'node:fs' +import { Script } from 'node:vm' +import { Terminal } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +const terminalHtmlSource = readFileSync( + new URL('./terminal-webview-html.ts', import.meta.url), + 'utf8' +) +const reflowSource = readFileSync( + new URL('./terminal-webview-reflow-injected.ts', import.meta.url), + 'utf8' +) + +type Cell = { isBgDefault: () => boolean; isInverse: () => number } +type MetricsNotification = { + type: string + cursorY: number + contentBottomRow: number + rows: number + altScreen: boolean +} + +function makeLine(text = '', styledColumns: number[] = []) { + const styled = new Set(styledColumns) + return { + isWrapped: false, + length: 10, + translateToString: vi.fn(() => text), + getCell: (column: number): Cell => ({ + isBgDefault: () => !styled.has(column), + isInverse: () => 0 + }) + } +} + +function runMetrics(lines: (ReturnType | undefined)[], altScreen = false) { + const notifications: Record[] = [] + const buffer = { + cursorY: 2, + viewportY: 3, + type: altScreen ? 'alternate' : 'normal', + getLine: (index: number) => lines[index - 3], + getNullCell: () => ({}) + } + const context = { + notifications, + notify: (message: Record) => notifications.push(message), + term: { buffer: { active: buffer }, cols: 10, rows: lines.length } + } + new Script( + `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` + ).runInNewContext(context) + return notifications[0] as MetricsNotification +} + +function runTerminalMetrics(term: Terminal) { + const notifications: Record[] = [] + new Script( + `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` + ).runInNewContext({ + notify: (message: Record) => notifications.push(message), + term + }) + return notifications[0] as MetricsNotification +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +describe('terminal keyboard-avoidance WebView metrics', () => { + it('finds text on wrapped rows using the visible viewport offset', () => { + const lines = [makeLine('header'), makeLine(''), makeLine('wrapped footer')] + lines[2]!.isWrapped = true + expect(runMetrics(lines)).toMatchObject({ contentBottomRow: 2 }) + }) + + it('supports cells without decoration APIs and keeps background-only ANSI chrome visible', () => { + expect(runMetrics([makeLine('header'), makeLine(''), makeLine('')])).toMatchObject({ + contentBottomRow: 0 + }) + expect(runMetrics([makeLine('header'), makeLine(''), makeLine('', [4])])).toMatchObject({ + contentBottomRow: 2 + }) + }) + + it('classifies real xterm text and styled whitespace by rendered visibility', async () => { + const cases = [ + { name: 'default spaces', data: ' ', expected: 0 }, + { name: 'text', data: 'footer', expected: 7 }, + { name: 'background', data: '\x1b[41m \x1b[0m', expected: 7 }, + { name: 'inverse', data: '\x1b[7m \x1b[0m', expected: 7 }, + { name: 'underline', data: '\x1b[4m \x1b[0m', expected: 7 }, + { name: 'strikethrough', data: '\x1b[9m \x1b[0m', expected: 7 }, + { name: 'overline', data: '\x1b[53m \x1b[0m', expected: 7 }, + // Hidden text still reserves TUI layout, so keyboard avoidance treats it as content. + { name: 'invisible text', data: '\x1b[8mfooter\x1b[0m', expected: 7 } + ] + + for (const { name, data, expected } of cases) { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, `\x1b[8;1H${data}`) + expect(runTerminalMetrics(term), name).toMatchObject({ contentBottomRow: expected }) + } finally { + term.dispose() + } + } + }) + + it('tracks the real xterm viewport and alternate screen', async () => { + const term = new Terminal({ cols: 10, rows: 4, scrollback: 100 }) + try { + await write(term, 'header\r\n\r\n\r\n\r\nfooter') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3, altScreen: false }) + term.scrollLines(-2) + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: false }) + await write(term, '\x1b[?1049h\x1b[4m \x1b[0m') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: true }) + } finally { + term.dispose() + } + }) + + it('follows real xterm resize and reset state', async () => { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, '\x1b[8;1Hfooter') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 7 }) + term.resize(10, 4) + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3 }) + term.reset() + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0 }) + } finally { + term.dispose() + } + }) + + it('keeps real xterm metrics compatible with old payloads', async () => { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, '\x1b[8;1Hfooter\x1b[2;1H') + const { cursorY, rows, altScreen } = runTerminalMetrics(term) + expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY, rows, altScreen })).toEqual({ + cursorY: 1, + contentBottomRow: 1, + rows: 8, + altScreen: false + }) + } finally { + term.dispose() + } + }) + + it('releases real xterm metric observers across terminal lifecycles', async () => { + for (let cycle = 0; cycle < 25; cycle += 1) { + const term = new Terminal({ cols: 10, rows: 4 }) + let emissions = 0 + const observer = term.onWriteParsed(() => { + runTerminalMetrics(term) + emissions += 1 + }) + try { + await write(term, `cycle ${cycle}`) + expect(emissions).toBeGreaterThan(0) + observer.dispose() + const disposedAt = emissions + await write(term, ' after dispose') + expect(emissions).toBe(disposedAt) + } finally { + observer.dispose() + term.dispose() + } + } + }) + + it('stops at the first bottom-up match and skips scans on alternate screen', () => { + const footer = makeLine('footer') + const header = makeLine('header') + expect(runMetrics([header, makeLine(''), footer])).toMatchObject({ contentBottomRow: 2 }) + expect(footer.translateToString).toHaveBeenCalledTimes(1) + expect(header.translateToString).not.toHaveBeenCalled() + + footer.translateToString.mockImplementation(() => { + throw new Error('alternate screen must not scan') + }) + expect(runMetrics([header, makeLine(''), footer], true)).toMatchObject({ + altScreen: true, + contentBottomRow: 0 + }) + }) + + it('refreshes metrics after every buffer geometry reset', () => { + const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)') + const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart) + const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") + const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)') + const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart) + + for (const block of [ + terminalHtmlSource.slice(resizeStart, resizeEnd), + terminalHtmlSource.slice(clearStart, clearEnd), + terminalHtmlSource.slice(textScaleStart, textScaleEnd), + reflowSource + ]) { + expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan( + block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset') + ) + } + }) +}) diff --git a/mobile/src/terminal/terminal-webview-contract.test.ts b/mobile/src/terminal/terminal-webview-contract.test.ts new file mode 100644 index 00000000000..0a3527111e3 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +describe('parseTerminalKeyboardAvoidanceMetrics', () => { + it('parses a full payload', () => { + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: 30, + contentBottomRow: 34, + rows: 40, + altScreen: true + }) + ).toEqual({ cursorY: 30, contentBottomRow: 34, rows: 40, altScreen: true }) + }) + + it('defaults contentBottomRow to cursorY when absent (older WebView bundles)', () => { + expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY: 12, rows: 40 })).toEqual({ + cursorY: 12, + contentBottomRow: 12, + rows: 40, + altScreen: false + }) + }) + + it('defaults non-numeric fields to zero', () => { + expect(parseTerminalKeyboardAvoidanceMetrics({})).toEqual({ + cursorY: 0, + contentBottomRow: 0, + rows: 0, + altScreen: false + }) + }) + + it('bounds untrusted numeric fields to the reported viewport', () => { + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: Number.POSITIVE_INFINITY, + contentBottomRow: 99.8, + rows: 40.7, + altScreen: 'true' + }) + ).toEqual({ cursorY: 0, contentBottomRow: 39, rows: 40, altScreen: false }) + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: -4, + contentBottomRow: Number.NaN, + rows: -1 + }) + ).toEqual({ cursorY: 0, contentBottomRow: 0, rows: 0, altScreen: false }) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 63c155b7a23..c28fd4a5038 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -14,10 +14,34 @@ export type TerminalModes = { export type TerminalKeyboardAvoidanceMetrics = { cursorY: number + // Main-buffer TUIs can render footer rows below the caret. + contentBottomRow: number rows: number altScreen: boolean } +export function parseTerminalKeyboardAvoidanceMetrics( + msg: Record +): TerminalKeyboardAvoidanceMetrics { + const rows = toNonNegativeInteger(msg.rows) + const maxRow = Math.max(0, rows - 1) + const cursorY = Math.min(toNonNegativeInteger(msg.cursorY), maxRow) + const contentBottomRow = + msg.contentBottomRow === undefined + ? cursorY + : Math.min(toNonNegativeInteger(msg.contentBottomRow), maxRow) + return { + cursorY, + contentBottomRow, + rows, + altScreen: msg.altScreen === true + } +} + +function toNonNegativeInteger(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 +} + export type MobileTerminalTheme = RuntimeMobileTerminalTheme export type TerminalSelectionEvents = { diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index a7715e4f564..c3ba29bb542 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -3,6 +3,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected' @@ -287,6 +288,7 @@ window.onerror = function(msg) { if (cols < MIN_FIT_COLS) return; var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); term.resize(cols, rows); + emitKeyboardAvoidanceMetrics(); } applyFitScale('text-scale'); }); @@ -802,6 +804,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} if (!term) return; initRows = rows || initRows; term.resize(cols || term.cols, rows || term.rows); + emitKeyboardAvoidanceMetrics(); applyFitScale('resize-msg'); notify({ type: 'ready', cols: cols, rows: rows }); } @@ -958,6 +961,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} initialOscLinkEvictionReady = false; if (term) { term.clear(); term.reset(); } emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); resetEvictionCounter(); if (selMode === 'select') { notify({ type: 'selection-evicted' }); @@ -1100,17 +1104,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} sgrMousePixelsMode: false }; - function emitKeyboardAvoidanceMetrics() { - if (!term) return; - var alt = false; - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - notify({ - type: 'keyboard-avoidance-metrics', - cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, - rows: term.rows || 0, - altScreen: alt - }); - } + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} function attachTermObservers() { if (!term) return; diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts new file mode 100644 index 00000000000..bcd777a2e8f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' + +import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch' + +describe('dispatchTerminalWebViewNotification', () => { + it('preserves bounded keyboard metrics through the dispatcher', () => { + const onKeyboardAvoidanceMetrics = vi.fn() + dispatchTerminalWebViewNotification( + { + type: 'keyboard-avoidance-metrics', + cursorY: 30, + contentBottomRow: 99, + rows: 40, + altScreen: true + }, + { onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() } + ) + expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({ + cursorY: 30, + contentBottomRow: 39, + rows: 40, + altScreen: true + }) + }) + + it('keeps old WebView payloads cursor-compatible', () => { + const onKeyboardAvoidanceMetrics = vi.fn() + dispatchTerminalWebViewNotification( + { type: 'keyboard-avoidance-metrics', cursorY: 12, rows: 40 }, + { onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() } + ) + expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({ + cursorY: 12, + contentBottomRow: 12, + rows: 40, + altScreen: false + }) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.ts index 372df650fe0..77019d3f996 100644 --- a/mobile/src/terminal/terminal-webview-notification-dispatch.ts +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.ts @@ -1,4 +1,7 @@ -import type { TerminalSelectionEvents } from './terminal-webview-contract' +import { + parseTerminalKeyboardAvoidanceMetrics, + type TerminalSelectionEvents +} from './terminal-webview-contract' export type TerminalWebViewNotificationHandlers = Omit< TerminalSelectionEvents, @@ -63,13 +66,7 @@ export function dispatchTerminalWebViewNotification( handlers.onOpenUrl?.(url) } } else if (msg.type === 'keyboard-avoidance-metrics') { - const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 - const rows = typeof msg.rows === 'number' ? msg.rows : 0 - handlers.onKeyboardAvoidanceMetrics?.({ - cursorY, - rows, - altScreen: !!msg.altScreen - }) + handlers.onKeyboardAvoidanceMetrics?.(parseTerminalKeyboardAvoidanceMetrics(msg)) } else if (msg.type === 'haptic') { const kind = msg.kind if (kind === 'selection' || kind === 'success' || kind === 'error' || kind === 'edge-bump') { diff --git a/mobile/src/terminal/terminal-webview-reflow-injected.ts b/mobile/src/terminal/terminal-webview-reflow-injected.ts index 4fea62f5107..ef704529e4b 100644 --- a/mobile/src/terminal/terminal-webview-reflow-injected.ts +++ b/mobile/src/terminal/terminal-webview-reflow-injected.ts @@ -28,5 +28,6 @@ export const TERMINAL_REFLOW_JS = ` } applyFitScale('reflow-msg'); updateScrollIndicator(false); + emitKeyboardAvoidanceMetrics(); } ` diff --git a/mobile/src/transport/host-connect-refetch-gate.test.ts b/mobile/src/transport/host-connect-refetch-gate.test.ts new file mode 100644 index 00000000000..17b3d68ae24 --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from './host-connect-refetch-gate' +import type { ConnectionState } from './types' + +function refetchesFor(states: ConnectionState[]): number { + const gate = createHostConnectRefetchGate() + return states.filter((state) => gate.observe(state)).length +} + +describe('createHostConnectRefetchGate', () => { + it('fetches once on the first connect', () => { + expect(refetchesFor(['connecting', 'handshaking', 'connected'])).toBe(1) + }) + + it('fetches again after a dropped socket comes back', () => { + expect( + refetchesFor(['connected', 'reconnecting', 'connecting', 'handshaking', 'connected']) + ).toBe(2) + }) + + it('does not storm while the connection holds', () => { + expect(refetchesFor(['connected', 'connected', 'connected'])).toBe(1) + }) + + it('never fetches while the host stays down', () => { + expect(refetchesFor(['connecting', 'disconnected', 'reconnecting', 'auth-failed'])).toBe(0) + }) +}) diff --git a/mobile/src/transport/host-connect-refetch-gate.ts b/mobile/src/transport/host-connect-refetch-gate.ts new file mode 100644 index 00000000000..4ce2ba885fc --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.ts @@ -0,0 +1,18 @@ +import type { ConnectionState } from './types' + +// Why: a logical client survives socket drops, so screens that fetch once per client latched +// after the first connect and never refreshed after a background/handoff reconnect. Gate the +// refetch on the transition INTO 'connected' — one read per reconnect, no polling timer. +export function createHostConnectRefetchGate(): { + observe: (state: ConnectionState) => boolean +} { + let connected = false + return { + observe(state) { + const nowConnected = state === 'connected' + const crossedIntoConnected = nowConnected && !connected + connected = nowConnected + return crossedIntoConnected + } + } +} diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts new file mode 100644 index 00000000000..dfb0572aa38 --- /dev/null +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -0,0 +1,83 @@ +import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' +import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' +import type { RpcClient } from './rpc-client' +import type { HostProfile } from './types' +import type { MobileConnectionPath } from './stable-logical-rpc-client' + +const DIRECT_PROBE_INTERVAL_MS = 15_000 + +// While the runtime channel rides the relay, periodically probe the direct +// endpoint and migrate back once hysteresis proves it stable. +export class DirectReturnProbe { + private timer: ReturnType | null = null + + constructor( + private readonly deps: { + now: () => number + setTimer: typeof setTimeout + clearTimer: typeof clearTimeout + openDirect: (endpoint: string) => RpcClient + }, + private readonly hooks: { + hysteresis: MobileEndpointHysteresis + host: () => HostProfile + canSchedule: () => boolean + canAttempt: () => boolean + beginOperation: () => void + migrate: (client: RpcClient, path: MobileConnectionPath) => Promise + onDirectMigrated: () => Promise + afterProbe: () => void + } + ) {} + + schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void { + if (!this.hooks.canSchedule() || this.timer) { + return + } + this.timer = this.deps.setTimer(() => { + this.timer = null + void this.probe() + }, delayMs) + } + + clear(): void { + if (this.timer) { + this.deps.clearTimer(this.timer) + this.timer = null + } + } + + private async probe(): Promise { + if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) { + this.schedule() + return + } + this.hooks.beginOperation() + let successful: Awaited> = null + try { + successful = await openAuthenticatedDirectEndpoint( + this.hooks.host(), + this.deps.openDirect, + 12_000 + ) + if (!successful) { + this.hooks.hysteresis.recordDirectFailure(this.deps.now()) + return + } + if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) { + successful.client.close() + return + } + await this.hooks.migrate(successful.client, successful.path) + successful = null + this.hooks.hysteresis.recordMigration(this.deps.now()) + await this.hooks.onDirectMigrated() + } finally { + successful?.client.close() + // Why: a relay drop or backoff timer can arrive while the probe owns the + // operation mutex; afterProbe releases it and replays deferred recovery. + this.hooks.afterProbe() + this.schedule() + } + } +} diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 57881022a56..d41898152c3 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -90,6 +90,7 @@ function createSupervisor( readBundle: readMobileRelayCredentialBundle, writeBundle: writeMobileRelayCredentialBundle, saveHost, + onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, setTimer: setTimeout, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index d26e0706f70..0098de6e079 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -3,7 +3,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' -import type { HostProfile } from './types' +import type { ConnectionLogSink, HostProfile } from './types' export type MobileEndpointSupervisorDependencies = { openDirect: (endpoint: string) => RpcClient @@ -20,4 +20,5 @@ export type MobileEndpointSupervisorDependencies = { randomBytes: (length: number) => Uint8Array setTimer: typeof setTimeout clearTimer: typeof clearTimeout + onLog?: ConnectionLogSink } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts new file mode 100644 index 00000000000..d483c6536e8 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -0,0 +1,169 @@ +import { vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +export class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (_method: string, _params?: unknown): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null, + private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000, + private readonly renewed = true + ) { + super(state) + } + // Why: production-realistic defaults — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => this.resumeExpiry + getResumeConfirmation = () => ({ + v: 1 as const, + reqId: 'confirm-1', + currentVersion: 2, + acceptedAs: this.renewed ? ('current' as const) : ('grace' as const), + renewed: this.renewed, + resumeExpiresAt: this.resumeExpiry + }) + getFailure = () => this.failure +} + +export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + private generation = 1 + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.generation += 1 + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path + getGeneration = () => this.generation +} + +export const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +export const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay +} +export const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: Number.MAX_SAFE_INTEGER + } +} + +export function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundle), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length) => new Uint8Array(length).fill(1), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +export function mockCredentialRotation(logical: FakeLogicalClient): void { + let installResult: Record | null = null + logical.sendRequest.mockImplementation(async (method, params) => { + const request = params as { installReqId?: string; reqId?: string } + if (method === 'pairing.provisionRelay') { + installResult = { + v: 1, + reqId: request.reqId, + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: Date.now() + 300_000, + graceExpiresAt: Date.now() + 60_000 + } + return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } + } + return { + id: 'rpc-1', + ok: true, + result: { + v: 1, + relay, + installStatus: installResult + ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } + : { v: 1, reqId: request.installReqId, state: 'not-found' } + }, + _meta: { runtimeId: 'runtime-1' } + } + }) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 3ec05f5ef1e..d1b8b6918cd 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -2,177 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' import { RelayOuterError } from './mobile-relay-e2ee-link' -import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { - MobileEndpointSupervisor, - type MobileEndpointSupervisorDependencies -} from './mobile-endpoint-supervisor' -import type { RpcClient } from './rpc-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ConnectionState, HostProfile, RpcResponse } from './types' + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host, + mockCredentialRotation, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) -class FakeSession implements RpcClient { - readonly sendRequest = vi.fn( - async (_method: string, _params?: unknown): Promise => ({ - id: 'rpc-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - ) - readonly subscribe = vi.fn(() => () => {}) - readonly updateTerminalSubscriptionViewport = vi.fn() - readonly notifyForeground = vi.fn() - readonly close = vi.fn() - private readonly listeners = new Set<(state: ConnectionState) => void>() - - constructor(private state: ConnectionState) {} - - getState = () => this.state - getReconnectAttempt = () => 0 - getLastConnectedAt = () => null - onStateChange = (listener: (state: ConnectionState) => void) => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - publishState(state: ConnectionState): void { - this.state = state - for (const listener of this.listeners) { - listener(state) - } - } -} - -class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { - constructor( - state: ConnectionState, - private readonly failure: Error | null = null, - private readonly lease = Date.now() + 120_000 - ) { - super(state) - } - getLeaseExpiresAt = () => this.lease - getResumeConfirmation = () => ({ - v: 1 as const, - reqId: 'confirm-1', - currentVersion: 2, - acceptedAs: 'current' as const, - renewed: true, - resumeExpiresAt: Date.now() + 300_000 - }) - getFailure = () => this.failure -} - -class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { - private path: MobileConnectionPath - private generation = 1 - - constructor(state: ConnectionState, path: MobileConnectionPath) { - super(state) - this.path = path - } - - migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { - if (session.getState() !== 'connected') { - session.close() - throw new Error(`replacement session ${session.getState()}`) - } - this.path = path - this.generation += 1 - this.publishState('connected') - }) - suspendActiveSession = vi.fn(() => this.publishState('disconnected')) - getActivePath = () => this.path - getGeneration = () => this.generation -} - -const relay = { - v: 1 as const, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const -} -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } - ], - relayHostId: relay.relayHostId, - relay -} -const bundle: MobileRelayCredentialBundle = { - v: 1, - hostId: host.id, - deviceToken: host.deviceToken, - current: { - token: 'A'.repeat(43), - hash: 'B'.repeat(43), - version: 2, - expiresAt: Number.MAX_SAFE_INTEGER - } -} - -function dependencies( - overrides: Partial = {} -): MobileEndpointSupervisorDependencies { - return { - openDirect: vi.fn(() => new FakeSession('connected')), - openRelay: vi.fn(() => new FakeRelaySession('connected')), - resolveRelay: vi.fn(async ({ relay }) => relay), - readBundle: vi.fn(async () => bundle), - writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), - now: Date.now, - randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, - ...overrides - } -} - -function mockCredentialRotation(logical: FakeLogicalClient): void { - let installResult: Record | null = null - logical.sendRequest.mockImplementation(async (method, params) => { - const request = params as { installReqId?: string; reqId?: string } - if (method === 'pairing.provisionRelay') { - installResult = { - v: 1, - reqId: request.reqId, - authorizationMode: 'authenticated-direct', - currentVersion: 3, - resumeExpiresAt: Date.now() + 300_000, - graceExpiresAt: Date.now() + 60_000 - } - return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } - } - return { - id: 'rpc-1', - ok: true, - result: { - v: 1, - relay, - installStatus: installResult - ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } - : { v: 1, reqId: request.installReqId, state: 'not-found' } - }, - _meta: { runtimeId: 'runtime-1' } - } - }) -} - describe('mobile endpoint supervisor', () => { beforeEach(() => { vi.useFakeTimers() @@ -716,6 +561,7 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) @@ -744,12 +590,13 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) supervisor.setForeground(false) finishResolve?.(relay) @@ -785,11 +632,14 @@ describe('mobile endpoint supervisor', () => { .fn() .mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000)) .mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4404))) - const deps = dependencies({ openRelay }) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(5000) @@ -797,6 +647,27 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('skips forced rotation for a session resumed without renewal', async () => { + // Why: renewed=false means a re-resume provably returns the same unchanged + // deadline; rotating anyway churned one session replacement per clamp floor. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn( + () => new FakeRelaySession('connected', null, Date.now() + 31_000, false) + ) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledTimes(1) + supervisor.stop() + }) + it('keeps a fatal lease-replacement gate after the active relay later drops', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi @@ -808,12 +679,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) // The old relay can outlive its rejected lease replacement, then close separately. @@ -833,12 +705,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) supervisor.setForeground(true) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index c8b3cdbcfb1..be295fdcdda 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -1,5 +1,5 @@ -import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' +import { DirectReturnProbe } from './mobile-direct-return-probe' import { RelayReconnectController } from './mobile-relay-reconnect-controller' import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' @@ -9,6 +9,8 @@ import { persistRelayHost, toError } from './mobile-endpoint-supervisor-support' +import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' +import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log' import { applyResumeConfirmation, mobileRelayCredentialNeedsRotation, @@ -20,7 +22,6 @@ import type { HostProfile } from './types' export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' -const DIRECT_PROBE_INTERVAL_MS = 15_000 const DIRECT_OBSERVATION_MS = 30_000 const MINIMUM_DWELL_MS = 60_000 const FAILURE_COOLDOWN_MS = 60_000 @@ -32,11 +33,12 @@ export class MobileEndpointSupervisor { private operationInFlight = false private credentialRotationInFlight = false private relayRotationPending = false - private probeTimer: ReturnType | null = null private unsubscribeState: (() => void) | null = null private readonly hysteresis: MobileEndpointHysteresis private readonly relayReconnect: RelayReconnectController private readonly leaseRotation: RelayLeaseRotationTimer + private readonly logRelay: RelayRecoveryLog + private readonly directProbe: DirectReturnProbe constructor( private readonly logical: StableLogicalRpcClient, @@ -49,24 +51,52 @@ export class MobileEndpointSupervisor { failureCooldownMs: FAILURE_COOLDOWN_MS, minimumDwellMs: MINIMUM_DWELL_MS }) + this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog) this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this)) this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => { this.relayRotationPending = true void this.recoverRelay(true) }) + this.directProbe = new DirectReturnProbe(dependencies, { + hysteresis: this.hysteresis, + host: () => this.host, + canSchedule: () => + !this.stopped && this.foreground && this.logical.getActivePath() === 'relay', + canAttempt: () => !this.stopped && this.foreground && !this.operationInFlight, + beginOperation: () => { + this.operationInFlight = true + }, + migrate: (client, path) => this.logical.migrateTo(client, path), + onDirectMigrated: async () => { + this.leaseRotation.clear() + this.relayRotationPending = false + await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) + }, + afterProbe: () => { + this.operationInFlight = false + if (this.relayRotationPending || this.logical.getState() !== 'connected') { + void this.recoverRelay(this.relayRotationPending) + } + } + }) } async start(): Promise { this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) - if (this.stopped || !this.bundle || !this.host.relay) { + if (this.stopped || !this.host.relay) { return } + if (!this.bundle) { + // Why: a Keychain race at open must not kill relay recovery for the whole + // process lifetime; each recovery attempt re-reads the durable bundle. + this.logRelay('credential bundle unavailable at start; recovery will re-read') + } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) } - this.scheduleDirectProbe() + this.directProbe.schedule() } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. @@ -78,7 +108,7 @@ export class MobileEndpointSupervisor { // are still loading, before the supervisor subscribes to state changes. await this.recoverRelay() } else { - this.scheduleDirectProbe() + this.directProbe.schedule() } } @@ -87,11 +117,11 @@ export class MobileEndpointSupervisor { this.foreground = foreground if (foreground) { this.relayReconnect.handleForeground(this.logical, wasForeground) - this.scheduleDirectProbe(0) + this.directProbe.schedule(0) } else { // Why: background phones must not hold billed relay data splices. this.relayReconnect.suspendActiveRelay(this.logical) - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -101,7 +131,7 @@ export class MobileEndpointSupervisor { this.stopped = true this.unsubscribeState?.() this.unsubscribeState = null - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -112,7 +142,6 @@ export class MobileEndpointSupervisor { this.stopped || !this.foreground || this.operationInFlight || - !this.bundle || !this.host.relay || (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) ) { @@ -121,23 +150,42 @@ export class MobileEndpointSupervisor { // Why: revival and lease timers can overlap resume failures; one shared cooldown // prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn. if (this.relayReconnect.shouldDefer()) { + this.logRelay('recovery deferred by cooldown or gate') return } this.operationInFlight = true let lastError: Error | null = null let retryAfterOperation = false try { - const credentials = this.relayReconnect.eligibleCredentials( - this.bundle.current, - this.bundle.grace - ) - for (const credential of credentials) { + const selection = await selectDialableRelayCredentials({ + bundle: this.bundle, + controller: this.relayReconnect, + readBundle: () => this.dependencies.readBundle(this.host.id), + onAdoptedFresherBundle: () => this.logRelay('adopted fresher durable credential bundle') + }) + this.bundle = selection.bundle + if (selection.credentials.length === 0) { + // Why: "expired" vs "missing" separates a sleep-past-expiry phone + // (needs re-pair or LAN) from a Keychain failure in field reports. + this.logRelay( + selection.bundle + ? 'relay credential expired or rejected; slow reprobe armed' + : 'no relay credential bundle; slow reprobe armed' + ) + this.relayReconnect.armCredentialReprobe() + return + } + for (const credential of selection.credentials) { const result = await this.tryRelayCredential(credential) if (result.ok) { retryAfterOperation = this.logical.getState() !== 'connected' return } lastError = result.error + this.logRelay( + 'relay dial failed', + `${result.error.name}: ${String(result.error.message).slice(0, 80)}` + ) if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) { // Why: a rejected version stays invalid; retry only the grace credential. this.relayReconnect.recordRejectedCredential(credential.version) @@ -145,12 +193,10 @@ export class MobileEndpointSupervisor { break } } - if (credentials.length > 0) { - // Why: cleanup may happen while a relay dial is awaiting the network; - // record its outcome without recreating a foreground retry timer. - const scheduleRetry = !forceReplacement && this.foreground && !this.stopped - this.relayReconnect.registerFailure(lastError, scheduleRetry) - } + // Why: cleanup may happen while a relay dial is awaiting the network; + // record its outcome without recreating a foreground retry timer. + const scheduleRetry = !forceReplacement && this.foreground && !this.stopped + this.relayReconnect.registerFailure(lastError, scheduleRetry) } finally { this.operationInFlight = false if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) { @@ -207,6 +253,7 @@ export class MobileEndpointSupervisor { } this.relayRotationPending = false this.hysteresis.recordMigration(this.dependencies.now()) + this.logRelay('runtime channel migrated to relay') const confirmation = session.getResumeConfirmation() if (confirmation) { this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation) @@ -215,71 +262,24 @@ export class MobileEndpointSupervisor { await this.dependencies.writeBundle(this.bundle).catch(() => {}) } // Why: async persistence can finish after stop/background; never recreate a stale timer. + // Why: rotate against the resume credential's expiry, never the hello's + // leaseExpiresAt — that field is the cell's ~10s attach-reservation + // deadline, and using it forced a session replacement every second. + // Why: renewed=false means a re-resume provably returns the same + // unchanged deadline — rotating then just churns one replacement per + // clamp floor until a fresh credential arrives over direct or disk. this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground ? null : session.getLeaseExpiresAt() + this.stopped || !this.foreground || confirmation?.renewed === false + ? null + : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) ) - this.scheduleDirectProbe() + this.directProbe.schedule() return { ok: true } } catch (error) { return { ok: false, error: session.getFailure() ?? toError(error) } } } - private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void { - if ( - this.stopped || - !this.foreground || - this.logical.getActivePath() !== 'relay' || - this.probeTimer - ) { - return - } - this.probeTimer = this.dependencies.setTimer(() => { - this.probeTimer = null - void this.probeDirect() - }, delayMs) - } - - private async probeDirect(): Promise { - if ( - this.stopped || - !this.foreground || - this.operationInFlight || - !this.hysteresis.canProbe(this.dependencies.now()) - ) { - this.scheduleDirectProbe() - return - } - this.operationInFlight = true - let successful: Awaited> = null - try { - const openDirect = this.dependencies.openDirect - successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000) - if (!successful) { - this.hysteresis.recordDirectFailure(this.dependencies.now()) - return - } - if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) { - successful.client.close() - return - } - await this.logical.migrateTo(successful.client, successful.path) - successful = null - this.hysteresis.recordMigration(this.dependencies.now()) - this.leaseRotation.clear() - this.relayRotationPending = false - await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) - } finally { - successful?.client.close() - this.operationInFlight = false - // Why: a relay drop or backoff timer can arrive while the direct probe owns the mutex. - if (this.relayRotationPending || this.logical.getState() !== 'connected') { - void this.recoverRelay(this.relayRotationPending) - } - this.scheduleDirectProbe() - } - } - private async rotateCredentialIfNeeded(force = false): Promise { if ( this.stopped || @@ -321,11 +321,4 @@ export class MobileEndpointSupervisor { } } } - - private clearDirectProbeTimer(): void { - if (this.probeTimer) { - this.dependencies.clearTimer(this.probeTimer) - this.probeTimer = null - } - } } diff --git a/mobile/src/transport/mobile-relay-credential-selection.ts b/mobile/src/transport/mobile-relay-credential-selection.ts new file mode 100644 index 00000000000..dcaca2080dd --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-selection.ts @@ -0,0 +1,42 @@ +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' + +// Why: pairing recovery, re-pairing, or a raced rotation can land a fresh +// durable bundle after the supervisor snapshotted its copy; a gated retry must +// see it instead of dialing (or refusing to dial) on stale state forever. +// +// Adoption is decided by OUTCOME, not by version: renewals extend expiresAt +// without bumping current.version, and a re-pair restarts the version counter, +// so version comparison cannot tell fresh from stale. The durable bundle is +// adopted exactly when it yields a dialable (unexpired, non-rejected) +// credential while the in-memory one does not — which also means a revoked +// version can never be resurrected from a stale disk copy. +export async function selectDialableRelayCredentials(args: { + bundle: MobileRelayCredentialBundle | null + controller: RelayReconnectController + readBundle: () => Promise + onAdoptedFresherBundle: () => void +}): Promise<{ + bundle: MobileRelayCredentialBundle | null + credentials: MobileRelayCredentialBundle['current'][] +}> { + const memory = args.bundle + const memoryCredentials = memory + ? args.controller.eligibleCredentials(memory.current, memory.grace) + : [] + if (memoryCredentials.length > 0) { + // Why: in-memory can be newer than disk (a resume confirmation whose + // durable write failed); never let a stale disk copy shadow it. + return { bundle: memory, credentials: memoryCredentials } + } + const disk = await args.readBundle().catch(() => null) + if (disk) { + const diskCredentials = args.controller.eligibleCredentials(disk.current, disk.grace) + if (diskCredentials.length > 0) { + args.controller.acceptFreshCredential(disk.current.version) + args.onAdoptedFresherBundle() + return { bundle: disk, credentials: diskCredentials } + } + } + return { bundle: memory ?? disk, credentials: [] } +} diff --git a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts index bf2a3dd4d1c..19aedfe8be1 100644 --- a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts +++ b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts @@ -2,6 +2,13 @@ // little before the deadline (and retry shortly if a forced rotation didn't land) // so the session never lapses. Owns the single lease/rotation timer slot. const LEASE_ROTATION_MARGIN_MS = 30_000 +// Why: both ends must be clamped. The floor bounds any bad deadline to one +// forced rotation per minute instead of a sub-second loop; the ceiling keeps +// the delay far below setTimeout's 32-bit limit (a 30-day resume TTL minus the +// margin overflows int32 and fires at 1ms), at the cost of a harmless +// re-resume every few hours on long-lived sessions. +const LEASE_ROTATION_MIN_DELAY_MS = 60_000 +const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000 export type RelayLeaseRotationDependencies = { now: () => number @@ -23,9 +30,12 @@ export class RelayLeaseRotationTimer { if (!leaseExpiresAt) { return } - const delay = Math.max( - 1000, - leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + const delay = Math.min( + LEASE_ROTATION_MAX_DELAY_MS, + Math.max( + LEASE_ROTATION_MIN_DELAY_MS, + leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + ) ) this.arm(delay) } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index d630f0db68b..4f50878c6d5 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -6,7 +6,9 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) describe('relay reconnect controller', () => { beforeEach(() => { @@ -47,16 +49,146 @@ describe('relay reconnect controller', () => { expect(onRetry).not.toHaveBeenCalled() }) - it('waits for an external signal after rejected E2EE authentication', () => { + it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => { + // Why: on a relay-only phone a permanent gate is a permanent outage — the + // desktop can commit pairing credentials moments after the first rejection. const onRetry = vi.fn() const reconnect = createController(onRetry) reconnect.registerFailure(new MobileE2EEAuthenticationError()) expect(reconnect.shouldDefer()).toBe(true) - expect(vi.getTimerCount()).toBe(0) - vi.advanceTimersByTime(60_000) expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(59_000) + expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // The reprobe tick passes the gate exactly once, then defers again. + expect(reconnect.shouldDefer()).toBe(false) + expect(reconnect.shouldDefer()).toBe(true) + }) + + it('keeps the fresh-credential gate reprobing after each failed gated attempt', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new RelayOuterError(4401)) + expect(reconnect.shouldDefer()).toBe(true) + + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt fails again with only rejected credentials on hand. + reconnect.registerFailure(new RelayOuterError(4401)) + + // The gated cadence escalates: the second reprobe waits twice as long. + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('resets the gated cadence when the app returns to the foreground', () => { + // Why: reopening the app is the strongest "conditions changed" signal a + // phone produces — it must not wait out an escalated 15-minute tick even + // when it cannot lift the credential gate itself. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new RelayOuterError(4401)) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // A failed gated attempt escalates the next tick beyond the base cadence. + reconnect.registerFailure(new RelayOuterError(4401)) + + reconnect.clear() + reconnect.handleForeground(logical, false) + expect(onRetry).toHaveBeenCalledTimes(2) + + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(3) + }) + + it('mints the gate pass when arming a no-bundle reprobe under a held gate', () => { + // Why: an external-signal gate never records rejected versions, so the + // no-dialable-bundle path must still mint the tick's pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt finds no dialable credential at all and re-arms. + reconnect.armCredentialReprobe() + + vi.advanceTimersByTime(120_000) + expect(onRetry).toHaveBeenCalledTimes(2) + expect(reconnect.shouldDefer()).toBe(false) + }) + + it('does not arm a gate reprobe timer when the supervisor declined retries', () => { + // Why: scheduleRetry=false means background or stopped — a tick firing + // there wakes nothing useful, and the next foreground resume re-arms. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError(), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4401), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4429), false) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(onRetry).not.toHaveBeenCalled() + + // Resume restores the gated cadence instead of stalling forever. + reconnect.handleForeground(logical, false) + expect(reconnect.shouldDefer()).toBe(true) + expect(vi.getTimerCount()).toBe(1) + }) + + it('does not let an orphaned reprobe timer swallow the next fast backoff', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + // A foreground revival nudge clears the external-signal gate and its timer. + reconnect.handleForeground(logical, true) + expect(onRetry).toHaveBeenCalledTimes(1) + + // A plain transport failure must schedule its own fast retry, not wait + // out a leftover 60s reprobe timer. + reconnect.registerFailure(new RelayOuterError(4429)) + vi.advanceTimersByTime(600) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('lifts the fresh-credential gate when a durable bundle carries a new version', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.recordRejectedCredential(2) + reconnect.armCredentialReprobe() + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(2) + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(3) + expect(reconnect.shouldDefer()).toBe(false) + expect( + reconnect.eligibleCredentials( + { token: 'fresh', version: 3, expiresAt: Number.MAX_SAFE_INTEGER }, + null + ) + ).toHaveLength(1) }) it('upgrades host-revival gating to fresh credentials without later downgrading it', () => { diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 6f586c17499..9ec5c1398bc 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -6,18 +6,10 @@ import { import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' +import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState } from './types' -// Why: relay resume closes and silent cellular NAT rebinds otherwise cause -// immediate re-dials that ping-pong the phone between connected and disconnected. -const RELAY_BACKOFF_MIN_MS = 250 -const RELAY_BACKOFF_BASE_MS = 500 -const RELAY_BACKOFF_CEILING_MS = 30_000 -const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS -const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 -const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 - export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -34,21 +26,29 @@ export class RelayReconnectController { private timer: ReturnType | null = null private activeSession: MobileRelayRpcSession | null = null private recoveryGate: RecoveryGate | null = null + private gateReprobePending = false + private gateReprobeStreak = 0 private readonly rejectedCredentialVersions = new Set() + private readonly delays: RelayRetryDelays constructor( private readonly dependencies: RelayReconnectDependencies, private readonly onRetry: (forceReplacement?: boolean) => void - ) {} + ) { + this.delays = new RelayRetryDelays(dependencies.randomBytes) + } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { - // Why: an app resume is a fresh signal, unlike repeated network-flap nudges. + // Why: an app resume is a fresh signal, unlike repeated network-flap + // nudges — it resets the gated cadence even when it cannot lift the + // credential gate, so reopening the app never waits out a 15min tick. + this.gateReprobeStreak = 0 if (this.recoveryGate !== 'fresh-credential') { this.reset() } } else if (this.recoveryGate === 'external-signal') { - this.recoveryGate = null + this.liftGate() } if ( wasForeground && @@ -87,8 +87,7 @@ export class RelayReconnectController { this.activeSession = session this.activeRelayConnectedAt = this.dependencies.now() this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } resetForDirectConnection(): boolean { @@ -97,10 +96,14 @@ export class RelayReconnectController { this.activeSession = null this.activeRelayConnectedAt = null if (needsCredentialRefresh) { - // Why: the rejected credential stays unusable until its replacement is durable. + // Why: the rejected credential stays unusable until its replacement is + // durable. No reprobe timer here — direct is live, rotation over it + // clears the gate, and any later state failure re-arms via shouldDefer. this.consecutiveFailures = 0 this.nextAttemptAt = 0 this.recoveryGate = 'fresh-credential' + this.gateReprobePending = false + this.gateReprobeStreak = 0 this.clearTimer() } else { this.reset() @@ -128,10 +131,43 @@ export class RelayReconnectController { if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) { this.recoveryGate = 'fresh-credential' this.clearTimer() + this.scheduleGateReprobe() } return eligible } + // For callers that found no dialable credential at all: keep a slow retry + // alive so a later durable write can recover. + armCredentialReprobe(): void { + if (this.rejectedCredentialVersions.size > 0) { + this.recoveryGate = 'fresh-credential' + this.clearTimer() + this.scheduleGateReprobe() + return + } + if (this.recoveryGate) { + // Why: under a held gate the tick must mint its pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + this.clearTimer() + this.scheduleGateReprobe() + return + } + // Why: a merely missing or expired bundle must not enter the credential + // gate — that gate forces a rotation on the next direct connect. A plain + // cooldown retries the read on the same escalating cadence. + const delay = this.delays.gateReprobeDelayMs(this.gateReprobeStreak) + this.nextAttemptAt = this.dependencies.now() + delay + this.clearTimer() + this.scheduleReprobeTick(delay, false) + } + + // A durable bundle whose current version is not rejected reopens the gate. + acceptFreshCredential(version: number): void { + if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) { + this.liftGate() + } + } + recordRejectedCredential(version: number): void { this.rejectedCredentialVersions.add(version) } @@ -154,6 +190,12 @@ export class RelayReconnectController { // re-dial. Arms the self-scheduled retry so recovery still happens on its own. shouldDefer(): boolean { if (this.recoveryGate) { + if (this.gateReprobePending) { + // Why: the slow reprobe tick gets exactly one attempt through the gate. + this.gateReprobePending = false + return false + } + this.scheduleGateReprobe() return true } if (this.dependencies.now() < this.nextAttemptAt) { @@ -173,8 +215,12 @@ export class RelayReconnectController { this.recoveryGate === 'fresh-credential' || (this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential') ) { - // Why: only the gate's external signal can make a known-fatal recovery retryable. + // Why: a failed reprobe stays gated, but the slow cadence must keep going + // — unless the supervisor is backgrounded/stopped; resume re-arms it. this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } const now = this.dependencies.now() @@ -189,18 +235,29 @@ export class RelayReconnectController { this.activeRelayConnectedAt = null this.consecutiveFailures += 1 const delay = - recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() + recovery?.kind === 'retry-after-host-offline' + ? this.delays.hostOfflineDelayMs() + : this.delays.transportDelayMs(this.consecutiveFailures) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { - // Why: pairing state cannot change on a timer; polling only wakes the radio. + // Why: an E2EE rejection is usually pairing revocation, but it also fires + // transiently right after pairing while the desktop commits credentials — + // reprobe slowly instead of waiting forever for a UI nudge. this.recoveryGate = 'external-signal' this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'disable-relay-credential') { - // Why: a rejected outer credential cannot recover until direct connectivity refreshes it. + // Why: never redial a rejected credential fast, but keep a slow reprobe + // alive — the caller re-reads durable state before each gated attempt. this.recoveryGate = 'fresh-credential' this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'retry-after-host-offline') { @@ -235,12 +292,12 @@ export class RelayReconnectController { this.consecutiveFailures = 0 this.activeRelayConnectedAt = null this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } clear(): void { this.clearTimer() + this.gateReprobePending = false this.activeSession = null this.activeRelayConnectedAt = null } @@ -263,20 +320,34 @@ export class RelayReconnectController { }, delay) } - private delayMs(): number { - const exponent = Math.max(0, this.consecutiveFailures - 1) - const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) - // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. - return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + private scheduleGateReprobe(): void { + this.scheduleReprobeTick(this.delays.gateReprobeDelayMs(this.gateReprobeStreak), true) } - private hostOfflineDelayMs(): number { - const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS - return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + private scheduleReprobeTick(delay: number, mintToken: boolean): void { + if (this.timer) { + return + } + this.timer = this.dependencies.setTimer(() => { + this.timer = null + // Why: the streak advances once per fired tick — arm attempts within one + // cycle recompute the same delay instead of triple-escalating it. + this.gateReprobeStreak = Math.min(this.gateReprobeStreak + 1, 8) + // Why: the tick token is only minted while its gate still holds; a later + // gate must not spend a stale token and bypass its own cooldown. + if (mintToken && this.recoveryGate) { + this.gateReprobePending = true + } + this.onRetry() + }, delay) } - private jitterFraction(): number { - const [high, low] = this.dependencies.randomBytes(2) - return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + // Why: clearing a gate must also drop its timer, pending tick, and cadence — + // an orphaned reprobe timer would otherwise swallow the next fast backoff. + private liftGate(): void { + this.recoveryGate = null + this.gateReprobePending = false + this.gateReprobeStreak = 0 + this.clearTimer() } } diff --git a/mobile/src/transport/mobile-relay-recovery-log.ts b/mobile/src/transport/mobile-relay-recovery-log.ts new file mode 100644 index 00000000000..9b373cd112a --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-log.ts @@ -0,0 +1,22 @@ +import type { ConnectionLogSink } from './types' + +export type RelayRecoveryLog = (message: string, detail?: string) => void + +// Why: relay recovery failed silently in production for weeks; every decision +// must reach logcat and the in-app connection log. +export function createRelayRecoveryLog( + now: () => number, + onLog?: ConnectionLogSink +): RelayRecoveryLog { + let sequence = 0 + return (message, detail) => { + console.log(`[relay] ${message}`, detail ?? '') + onLog?.({ + id: `relay-${++sequence}`, + ts: now(), + level: 'info', + message: `Relay: ${message}`, + ...(detail ? { detail } : {}) + }) + } +} diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts new file mode 100644 index 00000000000..6ff145acdb4 --- /dev/null +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -0,0 +1,45 @@ +// Why: relay resume closes and silent cellular NAT rebinds otherwise cause +// immediate re-dials that ping-pong the phone between connected and disconnected. +const RELAY_BACKOFF_MIN_MS = 250 +const RELAY_BACKOFF_BASE_MS = 500 +const RELAY_BACKOFF_CEILING_MS = 30_000 +export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS +const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 +const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: gates must slow recovery down, never end it — a relay-only phone has no +// direct path to refresh credentials, so a timerless gate is a permanent outage. +// The cadence escalates and jitters so a permanently revoked device is not a +// forever one-minute beacon and a fleet-wide gating event cannot phase-align. +const RELAY_GATE_REPROBE_BASE_MS = 60_000 +const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 + +// Every relay retry delay in one place: transport backoff, known-offline +// polling, and the escalating gated reprobe cadence, all jittered. +export class RelayRetryDelays { + constructor(private readonly randomBytes: (length: number) => Uint8Array) {} + + transportDelayMs(consecutiveFailures: number): number { + const exponent = Math.max(0, consecutiveFailures - 1) + const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) + // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. + return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + } + + hostOfflineDelayMs(): number { + const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS + return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + } + + gateReprobeDelayMs(streak: number): number { + const base = Math.min( + RELAY_GATE_REPROBE_CEILING_MS, + RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(streak, 8) + ) + return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) + } + + private jitterFraction(): number { + const [high, low] = this.randomBytes(2) + return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + } +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 54ae8387411..f5fa49a785e 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -119,7 +119,7 @@ describe('mobile relay RPC session', () => { }) expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') - expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number)) + expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number)) }) it('rejects a mismatched outer credential version and closes the physical link', () => { diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a614e833387..ac065012203 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -19,7 +19,10 @@ type PendingRequest = { } export type MobileRelayRpcSession = RpcClient & { - getLeaseExpiresAt(): number | null + // The cell's attach-reservation deadline (~10s). Diagnostics only — never + // schedule anything from it; rotation keys off getResumeExpiresAt(). + getAttachDeadlineAt(): number | null + getResumeExpiresAt(): number | null getResumeConfirmation(): DeviceResumeConfirmed | null getFailure(): Error | null } @@ -40,7 +43,8 @@ export function connectMobileRelayRpcSession(args: { let state: ConnectionState = 'connecting' let requestCounter = 0 let lastConnectedAt: number | null = null - let leaseExpiresAt: number | null = null + let attachDeadlineAt: number | null = null + let resumeExpiresAt: number | null = null let resumeConfirmation: DeviceResumeConfirmed | null = null let failure: Error | null = null let closed = false @@ -65,7 +69,8 @@ export function connectMobileRelayRpcSession(args: { fail(new Error('relay resume credential version mismatch')) return } - leaseExpiresAt = hello.leaseExpiresAt + attachDeadlineAt = hello.leaseExpiresAt + resumeExpiresAt = hello.resumeExpiresAt publishState('handshaking') }, onAuthenticated: () => void confirmResume(), @@ -109,7 +114,8 @@ export function connectMobileRelayRpcSession(args: { streams.clear() publishState('disconnected') }, - getLeaseExpiresAt: () => leaseExpiresAt, + getAttachDeadlineAt: () => attachDeadlineAt, + getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, getFailure: () => failure } @@ -131,6 +137,7 @@ export function connectMobileRelayRpcSession(args: { throw new Error('relay resume confirmation missing') } resumeConfirmation = result.resumeConfirmation + resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() publishState('connected') } catch (error) { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts new file mode 100644 index 00000000000..dcb8ba194e2 --- /dev/null +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -0,0 +1,454 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' +import { + createStableLogicalRpcClient, + type MobileConnectionPath, + type StableLogicalRpcClient +} from './stable-logical-rpc-client' +import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { + MobileEndpointSupervisor, + type MobileEndpointSupervisorDependencies +} from './mobile-endpoint-supervisor' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +// Regression suite for the 2026-08 field failure: a phone paired over the +// relay whose direct LAN endpoint is unreachable (Tailscale off) dialed the +// LAN endpoint forever and never recovered a relay runtime session. + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' +})) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null + ) { + super(state) + } + // Why: production-realistic constants — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 + getResumeConfirmation = () => null + getFailure = () => this.failure +} + +class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +const DIRECT_ENDPOINT = 'ws://100.88.90.25:6768' + +const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: DIRECT_ENDPOINT, + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: DIRECT_ENDPOINT }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/id' + } + ], + relayHostId: relay.relayHostId, + relay +} + +function bundleWith(version: number, expiresAt: number): MobileRelayCredentialBundle { + return { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: `token-v${version}`.padEnd(43, 'A'), + hash: 'B'.repeat(43), + version, + expiresAt + } + } +} + +function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length: number) => new Uint8Array(length), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +describe('relay runtime recovery without direct connectivity', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('recovers from a rejected outer credential once a fresher bundle is durable', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(2, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, the fresh-credential gate latched here with no timer and no exit. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 3 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers when the credential bundle was unreadable at supervisor start', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(null) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + // Pre-fix, a null first read killed relay recovery for the process lifetime. + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers from an expired-at-start bundle after a fresh credential lands', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi.fn(async () => expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + // Pre-fix, an expired bundle produced a silent no-op with nothing scheduled. + await vi.advanceTimersByTimeAsync(60_000) + + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('retries after an E2EE authentication rejection without a UI nudge', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, this state waited indefinitely for a foreground/navigation event. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a durable renewal that extends expiry without bumping the version', async () => { + // Why: resume confirmations and pairing recovery renew expiresAt while + // keeping current.version — version comparison cannot see this freshness. + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(expired) + .mockResolvedValueOnce(expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(deps.openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 2 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a re-paired credential whose version counter restarted', async () => { + // Why: re-pairing overwrites the same keychain slot with a NEW credential + // record whose counter restarts at 1 — lower than the rejected version. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(1, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(4, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 1 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('does not churn relay sessions off the cell attach-reservation deadline', async () => { + // Why: the relay-hello's leaseExpiresAt is a ~10s attach deadline. Keying + // rotation off it replaced the session every second, killing any RPC + // slower than the cycle (the field symptom: "Worktree list unavailable"). + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn(() => new FakeRelaySession('connected')) + // Direct stays unreachable, as in the field — return probes must not + // confuse the churn measurement by migrating back to direct. + const openDirect = vi.fn(() => new FakeSession('disconnected')) + const deps = dependencies({ openRelay, openDirect }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers immediately on a background/foreground cycle after an E2EE rejection', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + supervisor.setForeground(false) + supervisor.setForeground(true) + await vi.advanceTimersByTimeAsync(0) + + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) + +// The field failure's first symptom: a real direct rpc-client dialing an +// unroutable LAN endpoint (instant 1006) while relay credentials sit unused. +class DeadSocket { + static constructed: string[] = [] + onopen: (() => void) | null = null + onclose: ((event: { code?: number; reason?: string }) => void) | null = null + onerror: ((event: unknown) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + readyState = 0 + + constructor(readonly url: string) { + DeadSocket.constructed.push(url) + setTimeout(() => { + this.readyState = 3 + this.onclose?.({ code: 1006, reason: '' }) + }, 50) + } + + send(): void {} + close(): void { + this.readyState = 3 + } +} + +describe('failover with a real direct rpc-client', () => { + beforeEach(() => { + DeadSocket.constructed = [] + vi.stubGlobal('WebSocket', DeadSocket) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('migrates the logical client to relay after the direct dial fails', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) + + it('reaches relay even when the bundle read settles after the first direct failure', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies({ + readBundle: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + return bundleWith(2, Number.MAX_SAFE_INTEGER) + }) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + const started = supervisor.start() + await vi.advanceTimersByTimeAsync(300) + await started + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) +}) diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts new file mode 100644 index 00000000000..5799de96366 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' +import type { RpcClient } from '../transport/rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from '../transport/stable-logical-rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch' +import type { HostWorktreeInfo } from './home-worktree-info' + +type FakeSession = { + client: RpcClient + calls: number + settle: (response: RpcResponse | Error) => void +} + +function fakeSession(): FakeSession { + const pending: Array<(response: RpcResponse | Error) => void> = [] + const fake: FakeSession = { + calls: 0, + settle(response: RpcResponse | Error) { + const next = pending.shift() + next?.(response) + }, + client: { + sendRequest: () => { + fake.calls += 1 + return new Promise((resolve, reject) => { + pending.push((response) => + response instanceof Error ? reject(response) : resolve(response) + ) + }) + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: (): ConnectionState => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + } + return fake +} + +// Drains the promise chain so a retry queued in a .catch has reached the transport. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function catalogResponse(count: number, active: number): RpcResponse { + const worktrees = Array.from({ length: count }, (_, index) => ({ + worktreeId: `wt-${index}`, + repo: 'orca', + branch: `branch-${index}`, + displayName: `Workspace ${index}`, + liveTerminalCount: 0, + status: index < active ? ('working' as const) : ('done' as const) + })) + return { ok: true, result: { worktrees } } as RpcResponse +} + +function infoStore() { + let state: Record = {} + return { + get current() { + return state + }, + setInfo(updater: (prev: Record) => Record) { + state = updater(state) + } + } +} + +const notDisposed = () => false + +describe('fetchHomeHostWorktreeInfo', () => { + it('keeps the last proven counts when the in-flight read rejects', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await failed + + expect(store.current['host-1']).toEqual({ + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: expect.objectContaining({ worktreeId: 'wt-0' }), + catalogUnavailable: true, + staleCounts: true, + // Age-stamped so the card can stop calling day-old counts "last known". + countsProvenAt: expect.any(Number) + }) + }) + + it('stamps when the host proved the counts', async () => { + const store = infoStore() + const host = fakeSession() + const before = Date.now() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(3, 1)) + await loaded + + const provenAt = store.current['host-1'].countsProvenAt + expect(provenAt).toBeGreaterThanOrEqual(before) + expect(provenAt).toBeLessThanOrEqual(Date.now()) + }) + + it('marks a host whose catalog never loaded as unavailable, not empty', async () => { + const store = infoStore() + const host = fakeSession() + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle({ ok: false, error: { code: 'internal' } } as RpcResponse) + await failed + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 0, + catalogUnavailable: true + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + }) + + it('re-reads the catalog on reconnect and clears the stale flag', async () => { + const store = infoStore() + const host = fakeSession() + const gate = createHostConnectRefetchGate() + + const connect = async (response: RpcResponse | Error) => { + if (!gate.observe('connected')) { + return + } + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(response) + await done + } + + await connect(catalogResponse(12, 2)) + // Socket dies mid-poll: counts survive, flagged stale. + const dropped = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await dropped + gate.observe('reconnecting') + expect(store.current['host-1'].staleCounts).toBe(true) + + await connect(catalogResponse(13, 3)) + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 13, + activeCount: 3 + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + // Two connects + the dropped poll: the gate must not re-read while the link holds. + expect(host.calls).toBe(3) + await connect(catalogResponse(13, 3)) + expect(host.calls).toBe(3) + }) + + it('re-reads through a relay→direct cutover, which never leaves connected', async () => { + const store = infoStore() + const relay = fakeSession() + const direct = fakeSession() + const logical = createStableLogicalRpcClient(relay.client, 'relay') + const gate = createHostConnectRefetchGate() + const gateFires: ConnectionState[] = [] + logical.onStateChange((state) => { + if (gate.observe(state)) { + gateFires.push(state) + } + }) + gate.observe(logical.getState()) + + const loaded = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + relay.settle(catalogResponse(12, 2)) + await loaded + + // The supervisor's direct probe migrates while a refresh is on the wire. + const interrupted = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + await logical.migrateTo(direct.client, 'lan') + await flush() + + expect(direct.calls).toBe(1) + direct.settle(catalogResponse(13, 3)) + await interrupted + + // The gate can't cover this: migrateTo republishes 'connected' from 'connected'. + expect(gateFires).toEqual([]) + expect(store.current['host-1']).toMatchObject({ totalWorktrees: 13, activeCount: 3 }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + }) + + it('gives up on a host that keeps cutting over so the card still reports the failure', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const cutoverStorm = fetchHomeHostWorktreeInfo( + host.client, + 'host-1', + store.setInfo, + notDisposed + ) + for (let attempt = 0; attempt < 6; attempt += 1) { + host.settle(new LogicalClientCutoverError()) + await flush() + } + await cutoverStorm + + // 1 original + CUTOVER_RETRY_LIMIT retries, then the failure is surfaced. + expect(host.calls).toBe(4) + expect(store.current['host-1'].staleCounts).toBe(true) + }) + + it('ignores a response that lands after the screen is disposed', async () => { + const store = infoStore() + const host = fakeSession() + + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, () => true) + host.settle(new Error('socket closed mid-request')) + await done + + expect(store.current['host-1']).toBeUndefined() + }) +}) diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts new file mode 100644 index 00000000000..7c6dafe6b14 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -0,0 +1,79 @@ +import { setCachedWorktrees } from '../cache/worktree-cache' +import { sendSingleFlightRequest } from '../transport/request-single-flight' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { + markHomeWorktreeCatalogUnavailable, + type HomeWorktreeSummary, + type HostWorktreeInfo +} from './home-worktree-info' +import { pickResumeWorktree } from './resume-worktree' +import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client' + +const ACTIVE_STATUSES = new Set(['working', 'active', 'permission']) +// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the +// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop +// can't spin. See runtime-capability-probe.ts for the same hazard on status.get. +const CUTOVER_RETRY_LIMIT = 2 + +export type HostWorktreeInfoSetter = ( + updater: (prev: Record) => Record +) => void + +/** Reads one host's worktree catalog for the Home card, preserving proven counts on failure. */ +export function fetchHomeHostWorktreeInfo( + client: RpcClient, + hostId: string, + setInfo: HostWorktreeInfoSetter, + disposed: () => boolean +): Promise { + const markUnavailable = (): void => { + setInfo((prev) => { + const current = prev[hostId] + const next = markHomeWorktreeCatalogUnavailable(current, hostId) + return next === current ? prev : { ...prev, [hostId]: next } + }) + } + + const attempt = (cutoverRetriesLeft: number): Promise => + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) + .then((response) => { + if (disposed()) { + return + } + if (!response.ok) { + markUnavailable() + return + } + const result = response.result as { worktrees?: HomeWorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) + const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) + // Mirror the desktop's focused workspace (see pickResumeWorktree). + const lastActive = pickResumeWorktree(worktrees) + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: worktrees.length, + activeCount: active.length, + lastActiveWorktree: lastActive, + countsProvenAt: Date.now() + } + })) + }) + .catch((error: unknown) => { + if (disposed()) { + return + } + // A cutover raises only after migrateTo installed an authenticated replacement, so the + // read was interrupted, not answered — ask again instead of latching "Last known". + if (cutoverRetriesLeft > 0 && isLogicalClientCutoverError(error)) { + return attempt(cutoverRetriesLeft - 1) + } + // Any other rejection (socket died mid-request) is a failed refresh, not an empty host. + markUnavailable() + }) + + return attempt(CUTOVER_RETRY_LIMIT) +} diff --git a/mobile/src/worktree/home-worktree-info.test.ts b/mobile/src/worktree/home-worktree-info.test.ts index ebbfe379e99..5cb80d007dd 100644 --- a/mobile/src/worktree/home-worktree-info.test.ts +++ b/mobile/src/worktree/home-worktree-info.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info' +import { + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS, + homeHostWorktreeSummary, + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from './home-worktree-info' describe('markHomeWorktreeCatalogUnavailable', () => { it('marks a host unavailable when no catalog has loaded', () => { @@ -28,7 +33,8 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({ ...current, - catalogUnavailable: true + catalogUnavailable: true, + staleCounts: true }) }) @@ -44,3 +50,73 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current) }) }) + +describe('homeHostWorktreeSummary', () => { + const provenAt = 1_700_000_000_000 + const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: provenAt + } + + it('summarizes a freshly loaded catalog', () => { + expect(homeHostWorktreeSummary(loaded, provenAt)).toBe('12 worktrees · 2 active') + expect( + homeHostWorktreeSummary({ ...loaded, totalWorktrees: 1, activeCount: 0 }, provenAt) + ).toBe('1 worktree') + }) + + it('keeps showing the last proven counts after a failed refresh', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + expect(homeHostWorktreeSummary(afterFailure, provenAt + 30_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('keeps the last proven counts however long ago the refresh failed', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + // A session that stays connected past the live window then hits one failed refresh is the + // headline case: the counts are history, not nothing. + expect( + homeHostWorktreeSummary(afterFailure, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(afterFailure, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('stops calling aged-out counts live even while a refresh is only pending', () => { + // A snapshot rehydrated from a previous app session describes a host we have not reached yet. + expect(homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS)).toBe( + '12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(loaded, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('treats counts persisted before age stamping as no longer live', () => { + const preUpgrade: HostWorktreeInfo = { ...loaded, countsProvenAt: undefined } + + expect(homeHostWorktreeSummary(preUpgrade, provenAt)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(preUpgrade, 'host-1'), provenAt) + ).toBe('Last known: 12 worktrees · 2 active') + }) + + it('reports unavailable only when no catalog ever loaded', () => { + expect(homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toBe( + 'Worktree list unavailable' + ) + expect(homeHostWorktreeSummary(undefined)).toBeNull() + }) +}) diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 867e266e89e..a417636dcaf 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -15,8 +15,17 @@ export type HostWorktreeInfo = { activeCount: number lastActiveWorktree: HomeWorktreeSummary | null catalogUnavailable?: boolean + // The counts are the last proven ones, kept across a failed refresh. + staleCounts?: boolean + // When the host last answered with these counts (ms epoch); absent in snapshots an older + // build persisted, which is why an unstamped count counts as expired rather than fresh. + countsProvenAt?: number } +// Why: the home snapshot is persisted across launches, so counts could otherwise be rendered as +// live while describing a workspace set proven days ago. Past this window they are only history. +export const HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS = 10 * 60_000 + export function markHomeWorktreeCatalogUnavailable( current: HostWorktreeInfo | undefined, hostId: string @@ -25,7 +34,9 @@ export function markHomeWorktreeCatalogUnavailable( return current } if (current) { - return { ...current, catalogUnavailable: true } + // Why: `current` predates this failure, so its counts are proven host truth — a dropped + // socket must not erase them, only flag them as no longer live. + return { ...current, catalogUnavailable: true, staleCounts: true } } return { hostId, @@ -35,3 +46,32 @@ export function markHomeWorktreeCatalogUnavailable( catalogUnavailable: true } } + +/** The host card's worktree line, or null when nothing is known yet. */ +export function homeHostWorktreeSummary( + info: HostWorktreeInfo | undefined, + now: number = Date.now() +): string | null { + if (!info) { + return null + } + // Why (STA-3123): a catalog that never loaded must not assert a count the host has not proven. + if (info.catalogUnavailable && !info.staleCounts) { + return 'Worktree list unavailable' + } + const counts = `${info.totalWorktrees} worktree${info.totalWorktrees === 1 ? '' : 's'}${ + info.activeCount > 0 ? ` · ${info.activeCount} active` : '' + }` + // Age bounds liveness, not the counts themselves: a failed refresh — or a rehydrated snapshot the + // host has not re-confirmed — is still the last thing it told us, so keep it and drop the claim + // that it is current. + return info.staleCounts || !provenRecently(info, now) ? `Last known: ${counts}` : counts +} + +function provenRecently(info: HostWorktreeInfo, now: number): boolean { + // A snapshot written before countsProvenAt existed has an unknowable age; treat it as expired. + return ( + typeof info.countsProvenAt === 'number' && + now - info.countsProvenAt <= HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + ) +} diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index d6d04d43d3e..d1fc267c09f 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -106,6 +106,95 @@ describe('filterWorktrees', () => { ).toEqual([featureNamedMain]) }) + it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + const feature = worktree({ worktreeId: 'feature', isMainWorktree: false }) + + expect( + filterWorktrees( + [main, feature], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([main]) + }) + + it('keeps a sleeping folder workspace visible under hide-sleeping', () => { + const folder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:workspace-1', + branch: '', + isMainWorktree: true + }) + + expect( + filterWorktrees( + [folder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([folder]) + }) + + it('re-hides the sleeping main worktree when the desktop setting is off', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { + filterRepoIds: new Set(), + hideSleeping: true, + hideDefaultBranch: false, + alwaysShowDefaultBranch: false + }, + '' + ) + ).toEqual([]) + }) + + it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => { + const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' }) + + expect( + filterWorktrees( + [legacyMain], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyMain]) + }) + + it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => { + // A folder workspace has no branch, so the legacy branch heuristic can never + // recognise it — without its own arm, #8873 still reproduces on old desktops. + const legacyFolder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:legacy-1', + branch: '' + }) + + expect( + filterWorktrees( + [legacyFolder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyFolder]) + }) + + it('lets hide-default-branch still win over the sleeping exemption', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true }, + '' + ) + ).toEqual([]) + }) + it('keeps folder workspaces when default branch hiding is enabled', () => { const folder = worktree({ workspaceKind: 'folder-workspace', diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index bdb9e4783e3..d798a7fe5fa 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -65,6 +65,19 @@ function isDefaultBranchWorkspace(w: Worktree): boolean { return branch === 'main' || branch === 'master' } +/** + * Whether "Hide sleeping" must keep this row — the project's entry point (#8873). + * Falls back to the branch heuristic for hosts that predate isMainWorktree; a + * folder workspace is always its project's entry point, and isDefaultBranchWorkspace + * rejects it by design, so it needs its own legacy arm or #8873 still reproduces. + */ +function isSleepingSweepExempt(w: Worktree, alwaysShowDefaultBranch: boolean | undefined): boolean { + if (alwaysShowDefaultBranch === false) { + return false + } + return w.isMainWorktree ?? (w.workspaceKind === 'folder-workspace' || isDefaultBranchWorkspace(w)) +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -80,7 +93,9 @@ export function filterWorktrees( ): Worktree[] { let result = worktrees.filter((w) => !w.isArchived) if (filters.hideSleeping) { - result = result.filter(isWorktreeActive) + result = result.filter( + (w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w) + ) } if (filters.hideDefaultBranch) { result = result.filter((w) => !isDefaultBranchWorkspace(w)) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4dfbc74fa53..4ddb135affe 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -52,6 +52,8 @@ export type FilterState = { filterRepoIds: Set hideSleeping: boolean hideDefaultBranch: boolean + /** Absent means on: #8873's exemption must fail open on older host payloads. */ + alwaysShowDefaultBranch?: boolean } export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] } diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 0987915645a..32a4ac73a63 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -16,6 +16,7 @@ export type WorkspaceViewSettings = { sortBy?: 'name' | 'smart' | 'recent' | 'repo' | 'manual' hideSleepingWorkspaces?: boolean hideDefaultBranchWorkspace?: boolean + alwaysShowDefaultBranchWorkspace?: boolean filterRepoIds?: string[] collapsedGroups?: string[] workspaceStatuses?: WorkspaceStatusDefinition[] @@ -60,6 +61,7 @@ export type MobileViewState = { sortMode: MobileSortMode hideSleeping: boolean hideDefaultBranch: boolean + alwaysShowDefaultBranch: boolean filterRepoIds: string[] collapsedGroups: string[] workspaceStatuses: readonly WorkspaceStatusDefinition[] @@ -83,6 +85,8 @@ export function applyDesktopViewSettings( sortMode: sortMode ?? current.sortMode, hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping, hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch, + alwaysShowDefaultBranch: + settings.alwaysShowDefaultBranchWorkspace ?? current.alwaysShowDefaultBranch, filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds, collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups, workspaceStatuses diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index 9e7e3ecc59d..e39f90eedf1 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', - include: ['src/**/*.test.ts'] + // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were + // silently never collected, so render-level regressions shipped untested. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] } }) diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 8f89e303413..6261775859b 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Specialized; using System.Diagnostics; using System.IO; using System.Text; @@ -43,18 +42,17 @@ internal static class OrcaCliLauncher // Why: launching without cmd.exe preserves embedded newlines while matching the // packaged batch launcher's Electron-as-Node environment contract. - MoveEnvironmentVariable(startInfo.EnvironmentVariables, "NODE_OPTIONS", "ORCA_NODE_OPTIONS"); - MoveEnvironmentVariable( - startInfo.EnvironmentVariables, - "NODE_REPL_EXTERNAL_MODULE", - "ORCA_NODE_REPL_EXTERNAL_MODULE" + // Why: ProcessStartInfo's env copy rejects duplicate PATH/Path keys; mutating this + // short-lived process preserves the native block for child inheritance (#12046). + MoveEnvironmentVariable("NODE_OPTIONS", "ORCA_NODE_OPTIONS"); + MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); + Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); + Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); + string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); + Environment.SetEnvironmentVariable( + "ORCA_CLI_COMMAND", + requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" ); - startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1"; - startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1"; - startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] = - Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide" - ? "orca-ide" - : "orca"; using (Process child = Process.Start(startInfo)) { @@ -69,19 +67,12 @@ internal static class OrcaCliLauncher } } - private static void MoveEnvironmentVariable( - StringDictionary environment, - string sourceName, - string targetName - ) + private static void MoveEnvironmentVariable(string sourceName, string targetName) { string value = Environment.GetEnvironmentVariable(sourceName); - environment.Remove(sourceName); - environment.Remove(targetName); - if (value != null) - { - environment[targetName] = value; - } + Environment.SetEnvironmentVariable(sourceName, null); + // Why: a null value clears the target, matching the previous unconditional Remove. + Environment.SetEnvironmentVariable(targetName, value); } private static string BuildArguments(string cliPath, string[] args) diff --git a/package.json b/package.json index 3b7da4f9bdd..3a86ecfaf71 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.577.0", "marked": "^17.0.1", - "mermaid": "^11.15.0", + "mermaid": "^11.16.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", "oxlint": "^1.75.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd1f3e41e4..c4d53a979e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ patchedDependencies: hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch '@xterm/addon-serialize@0.15.0-beta.287': - hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 + hash: 96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258 @@ -46,7 +46,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)) + version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -283,8 +283,8 @@ importers: specifier: ^17.0.1 version: 17.0.6 mermaid: - specifier: ^11.15.0 - version: 11.15.0 + specifier: ^11.16.0 + version: 11.16.0 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -1041,8 +1041,8 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mermaid-js/parser@1.1.1': - resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} @@ -5211,8 +5211,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.15.0: - resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7511,7 +7511,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mermaid-js/parser@1.1.1': + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -9596,7 +9596,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8) - '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))': dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8) @@ -11647,11 +11647,11 @@ snapshots: merge2@1.4.1: {} - mermaid@11.15.0: + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.1 - '@mermaid-js/parser': 1.1.1 + '@mermaid-js/parser': 1.2.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.3 diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index e78652d4198..4c790282bb0 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -224,10 +224,18 @@ Wait until every expected Dispatch settles, not for a fixed number of batches: ```bash orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json -# Process every message in the returned Delivery, then atomically ack and continue: +# Process every message. For each accepted worker_done that is not immediately reused: +orca orchestration worker-release --dispatch --json +# Acknowledge only after every message and required release decision is handled: orca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json ``` +After processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`. + +Run `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal. + +Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely. + Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity: ```bash @@ -364,11 +372,12 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`: `orca orchestration send --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id --dispatch-id --outcome succeeded --files-modified "path/a" --report-path "" --json` - A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body. -- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input. +- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input. - For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs: `orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"","dispatchId":"","phase":"implementing"}' --json` - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. +- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. ## Example @@ -383,6 +392,6 @@ orca orchestration check --wait --types worker_done,escalation,question --timeou ## Next Action -Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. +Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait. Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 748d62dd1a6..0609150c458 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 1cafa87cb95..346105d8732 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -109,6 +109,9 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ 'orchestration worker-read', 'orchestration worker-stop', 'orchestration worker-abandon', + 'orchestration worker-release', + 'orchestration worker-retain', + 'orchestration worker-list', 'orchestration dispatch', 'orchestration ask', 'orchestration dispatch-show', diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 0b7ccdb7d6b..f9cbdb8a41a 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -79,6 +79,16 @@ const TASK_STATUS_VALUES = [ 'blocked' ] as const +// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC. +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + type LifecycleSendRejection = { action: 'rejected' code: string @@ -422,6 +432,33 @@ function formatWorkerTranscriptMessage(message: NativeChatMessage): string { return `[${message.role}] ${blocks.join('\n')}`.trimEnd() } +type WorkerReleaseReceipt = { + dispatchId: string + state: string + reason?: string + processAction: string + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +function formatWorkerRelease(value: WorkerReleaseReceipt): string { + const head = `Worker ${value.dispatchId} terminal [${value.state}]` + const lines = [ + `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` + ] + if (value.archive) { + lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) + } + if (value.lastError) { + lines.push(value.lastError) + } + if (value.recovery) { + lines.push(value.recovery) + } + return lines.join('\n') +} + function safeJson(value: unknown): string { try { return JSON.stringify(value) @@ -930,6 +967,79 @@ export const ORCHESTRATION_HANDLERS: Record = { ) }, + 'orchestration worker-release': async ({ flags, client, json }) => { + const result = await callMutation( + client, + flags, + 'orchestration.workerRelease', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-retain': async ({ flags, client, json }) => { + const result = await callMutation( + client, + flags, + 'orchestration.workerRetain', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-list': async ({ flags, client, json }) => { + const terminalState = getOptionalStringFlag(flags, 'terminal-state') + if ( + terminalState && + !WORKER_TERMINAL_LIST_STATES.includes( + terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] + ) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` + ) + } + const result = await client.call<{ + workers: { + dispatchId: string + taskId: string + runId: string + workerState: string + dispatchStatus: string + agentTerminalHandle: string | null + terminalState: string | null + resource: unknown + }[] + counts: Record + }>('orchestration.workerList', { + run: getOptionalStringFlag(flags, 'run'), + terminalState + }) + printResult(result, json, (r) => { + if (r.workers.length === 0) { + return 'No workers found.' + } + const rows = r.workers + .map( + (w) => + `${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}` + ) + .join('\n') + const counts = Object.entries(r.counts) + .map(([state, count]) => `${state}=${count}`) + .join(' ') + return counts ? `${rows}\nTerminals: ${counts}` : rows + }) + }, + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) const dryRun = flags.has('dry-run') ? true : undefined diff --git a/src/cli/help.ts b/src/cli/help.ts index d85a318cb39..2ffa3ecd8df 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -111,6 +111,9 @@ Orchestration: orchestration worker-read Read bounded output from one supervised worker orchestration worker-stop Stop one supervised worker orchestration worker-abandon Fence an uncertain worker without claiming it stopped + orchestration worker-release Release a settled worker's terminal after archiving its output + orchestration worker-retain Keep a worker terminal live for debugging + orchestration worker-list Report worker terminal resource accounting orchestration coordinator-start Start the legacy automatic coordinator loop orchestration coordinator-stop Stop the legacy automatic coordinator loop orchestration gate-create Create a decision gate blocking a task @@ -444,6 +447,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'orchestration worker-read' && flag === 'cursor') { return '--cursor Opaque cursor returned by a previous worker-read page' } + if (command === 'orchestration worker-list' && flag === 'terminal-state') { + return '--terminal-state Terminal accounting filter: active, reclaimable, retained, release_pending, release_unknown, or released' + } if (command === 'linear list-issues' && flag === 'workspace') { return '--workspace Connected Linear workspace id, or all' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6eb064bee28..bca1910cb10 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -362,6 +362,15 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'orchestration worker-abandon Fence an uncertain worker without claiming it stopped' ) + expect(logSpy.mock.calls[0][0]).toContain( + "orchestration worker-release Release a settled worker's terminal after archiving its output" + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-retain Keep a worker terminal live for debugging' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-list Report worker terminal resource accounting' + ) expect(callMock).not.toHaveBeenCalled() }) diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 2b05853e058..a8d8b2411f5 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -67,5 +67,39 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'orca orchestration worker-abandon --dispatch [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], notes: ['Retains all possibly-live resources and performs no process or filesystem action.'] + }, + { + path: ['orchestration', 'worker-release'], + summary: 'Release the terminal of one settled supervised worker', + usage: + 'orca orchestration worker-release --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: [ + 'Post-completion cleanup for a settled (succeeded or failed) worker; closes only the exact coordinator-owned agent terminal of that worker.', + 'An inspectable output archive is preserved before the terminal closes, so worker-read still returns output afterwards.', + 'Never closes setup terminals, configured tabs, reused or pre-existing terminals, user-taken-over terminals, or unproven identities.', + 'Idempotent: repeating the call reports already_released. Only release_unknown exits 1; retained, release_pending, and already_released exit 0.' + ] + }, + { + path: ['orchestration', 'worker-retain'], + summary: 'Keep one supervised worker terminal live for debugging', + usage: + 'orca orchestration worker-retain --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: [ + 'Records a durable user-requested exception; a later explicit worker-release clears it and releases the terminal.', + 'Performs no process or filesystem action.' + ] + }, + { + path: ['orchestration', 'worker-list'], + summary: 'List supervised worker terminal resource accounting', + usage: + 'orca orchestration worker-list [--run ] [--terminal-state ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'], + notes: [ + 'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.' + ] } ] diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index 9caf7966c83..9811773eb07 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -119,6 +119,7 @@ describe('restored subagent liveness sweep', () => { try { expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] }) const previous = server.getStatusSnapshotForPane(PANE)[0] + expect(previous?.restoredUnconfirmed).toBe(true) const previousReceivedAt = previous?.receivedAt ?? 0 const reconciledAt = previousReceivedAt + 1 const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt) @@ -130,6 +131,8 @@ describe('restored subagent liveness sweep', () => { receivedAt: reconciledAt, stateStartedAt: reconciledAt }) + // Why: the reconciled `done` is process-probe-verified, so it must shed the hydrated-unconfirmed marker. + expect(server.getStatusSnapshotForPane(PANE)[0]?.restoredUnconfirmed).toBeUndefined() now.mockRestore() } finally { server.stop() @@ -163,7 +166,9 @@ describe('restored subagent liveness sweep', () => { state: 'waiting', subagents: undefined, receivedAt: previous?.receivedAt, - stateStartedAt: previous?.stateStartedAt + stateStartedAt: previous?.stateStartedAt, + // Why: a still-nonterminal reconciled row remains unconfirmed — only hooks or a terminal fact confirm it. + restoredUnconfirmed: true }) } finally { server.stop() diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index b3ff89ab355..96bb121c7e7 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -7026,6 +7026,294 @@ describe('Last-status persistence', () => { } }) + it('restores hydrated nonterminal statuses as unconfirmed until a live event lands', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'may finish offline', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'finished before restart', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, restoredUnconfirmed: true }) + ) + const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) + expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + // Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed. + expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'confirmed live', agentType: 'codex' } + }, + 'conn-1' + ) + const confirmed = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(confirmed).toMatchObject({ + state: 'done', + prompt: 'confirmed live', + agentType: 'codex' + }) + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('lets an identical live OSC observation confirm a hydrated row', async () => { + const payload = { + state: 'working' as const, + prompt: 'still running', + agentType: 'claude' as const + } + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + if (!restored) { + throw new Error('expected hydrated status') + } + expect(restored?.restoredUnconfirmed).toBe(true) + vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + const confirmed = server.getStatusSnapshot()[0] + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1) + } finally { + server.stop() + } + }) + + it('does not let a hydrated Claude permission suppress the first live working event', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + payload: { + state: 'waiting', + prompt: 'review command', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'dangerous command' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'review command', + agentType: 'claude', + toolName: 'Read', + toolInput: 'package.json' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + toolName: 'Read' + }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('does not inherit a stale tool id from hydrated Claude progress', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + toolUseId: 'tool-old', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + toolAgentType: 'main', + payload: { + state: 'waiting', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PostToolUse', + toolUseId: 'tool-new', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + server.stop() + } + }) + + it('never persists the unconfirmed flag and re-stamps it on every hydrate', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'still running', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const secondServer = new AgentHookServer() + await secondServer.start({ env: 'production', userDataPath }) + secondServer.flushStatusPersistSync() + secondServer.stop() + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('restoredUnconfirmed') + + const thirdServer = new AgentHookServer() + await thirdServer.start({ env: 'production', userDataPath }) + try { + expect(thirdServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', restoredUnconfirmed: true }) + ]) + } finally { + thirdServer.stop() + } + }) + + it('refuses interrupt inference on an unconfirmed hydrated row', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const baseline = server.getStatusSnapshot()[0] + expect(baseline).toMatchObject({ paneKey: PANE, restoredUnconfirmed: true }) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + // Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state. + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined() + } finally { + server.stop() + } + }) + it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { const now = 1_700_000_000_000 vi.spyOn(Date, 'now').mockReturnValue(now) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f99f2cae9d..71fa1a6a0fc 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -83,6 +83,8 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true } type NormalizedLocalHook = { @@ -92,7 +94,7 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' + 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' > & { launchTokenHash?: string } @@ -378,6 +380,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...entry.payload } } @@ -434,6 +437,9 @@ function shouldKeepClaudePermissionVisible( previous: EnrichedAgentHookEventPayload | undefined, next: AgentHookEventPayload ): boolean { + if (previous?.restoredUnconfirmed) { + return false + } if ( previous?.payload.agentType !== 'claude' || previous.payload.state !== 'waiting' || @@ -507,6 +513,7 @@ function shouldInheritClaudeToolUseIdForPermission( next: AgentHookEventPayload ): boolean { if ( + previous?.restoredUnconfirmed || previous?.payload.agentType !== 'claude' || previous.payload.state !== 'working' || previous.hookEventName !== 'PreToolUse' || @@ -728,6 +735,10 @@ export class AgentHookServer { if (existing.providerSessionOnly) { return false } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } const payload = existing.payload const agentType: AgentType | undefined = payload.agentType // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. @@ -816,6 +827,10 @@ export class AgentHookServer { if (!existing) { return false } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } const payload = existing.payload // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. if ( @@ -1069,8 +1084,13 @@ export class AgentHookServer { const connectionClearWatermark = payload.connectionId ? this.connectionTimestampWatermarkById.get(payload.connectionId) : undefined - // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear. - const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) if (payload.connectionId) { this.connectionTimestampWatermarkById.set(payload.connectionId, now) } @@ -1128,7 +1148,8 @@ export class AgentHookServer { ? { agentType: previous.payload.agentType, state: previous.payload.state, - updatedAt: previous.receivedAt + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed } : undefined, incoming: rootContextPreservingPayload.payload.agentType, @@ -1740,6 +1761,7 @@ export class AgentHookServer { | EnrichedAgentHookEventPayload | undefined if ( + !previous?.restoredUnconfirmed && previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && @@ -2371,8 +2393,12 @@ export class AgentHookServer { const reconciledAt = stateChanged ? Math.max(Date.now(), enriched.receivedAt + 1) : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched const reconciled: EnrichedAgentHookEventPayload = { - ...enriched, + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), receivedAt: reconciledAt, stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, payload: { ...enriched.payload, state, subagents } @@ -2505,6 +2531,10 @@ export class AgentHookServer { (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) entry.payload = hydratedPayload } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) if (entry.connectionId) { // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. @@ -2621,6 +2651,8 @@ export class AgentHookServer { const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index b57be2544b6..d8f504beca3 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -21,7 +21,11 @@ function fakeChild(): FakeChild { resume: ReturnType } stderr: EventEmitter - stdin: EventEmitter & { write: ReturnType } + stdin: EventEmitter & { + write: ReturnType + destroyed: boolean + writable: boolean + } kill: ReturnType } child.stdout = Object.assign(new EventEmitter(), { @@ -29,7 +33,18 @@ function fakeChild(): FakeChild { resume: vi.fn() }) child.stderr = new EventEmitter() - child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) }) + const stdin = new EventEmitter() as EventEmitter & { + write: ReturnType + destroyed: boolean + writable: boolean + } + stdin.write = vi.fn((_data, onWritten?: (error?: Error | null) => void) => { + onWritten?.(null) + return true + }) + stdin.destroyed = false + stdin.writable = true + child.stdin = stdin child.kill = vi.fn() return child as unknown as FakeChild } @@ -166,7 +181,7 @@ describe('waitForWslRelaySentinel', () => { const drain = vi.fn() const writeMock = child.stdin.write as ReturnType writeMock.mockImplementation((_data, onWritten) => { - onWritten(null) + onWritten?.(null) return false }) const promise = waitForWslRelaySentinel(child) @@ -180,4 +195,16 @@ describe('waitForWslRelaySentinel', () => { expect(drain).toHaveBeenCalledOnce() expect(transport.supportsWriteSettlement).toBe(true) }) + + it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => { + const child = fakeChild() + const promise = waitForWslRelaySentinel(child) + emitStdout(child, RELAY_SENTINEL) + await promise + // Real pipes emit EPIPE on the stream after a failed write; without a + // listener Node surfaces that as an unhandled exception (CI flake). + expect(() => { + child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) + }).not.toThrow() + }) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts index 5e1b5b5a872..41e6ebe9683 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -88,6 +88,12 @@ export function waitForWslRelaySentinel( child.on('error', (err) => fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` }) ) + // Why: stdin.write() surfaces EPIPE asynchronously when the guest dies + // mid-flight (try/catch only covers the sync throw). Without a listener + // Node treats that as an uncaught exception and fails the whole process. + child.stdin.on('error', () => { + // Channel already closing — mux close handling takes over. + }) child.on('exit', (code) => { exitCode = code }) diff --git a/src/main/ai-vault/ai-vault-scan-cancellation.test.ts b/src/main/ai-vault/ai-vault-scan-cancellation.test.ts new file mode 100644 index 00000000000..74bf27bdc74 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-cancellation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { abandonRemoteSessionScanOnCancel } from './ai-vault-scan-cancellation' + +describe('abandonRemoteSessionScanOnCancel', () => { + it('stops waiting on a scan that has no transport-level abort', async () => { + const controller = new AbortController() + const pending = abandonRemoteSessionScanOnCancel( + new Promise(() => {}), + controller.signal + ) + + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('rejects immediately when the caller already cancelled', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'), controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('passes the scan result through without a signal', async () => { + await expect(abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'))).resolves.toBe( + 'scanned' + ) + }) +}) diff --git a/src/main/ai-vault/ai-vault-scan-cancellation.ts b/src/main/ai-vault/ai-vault-scan-cancellation.ts new file mode 100644 index 00000000000..65ecf772cc3 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-cancellation.ts @@ -0,0 +1,47 @@ +import { AI_VAULT_SCAN_CANCELLED_MESSAGE } from '../../shared/ai-vault-types' + +export function throwIfAiVaultScanCancelled(signal?: AbortSignal): void { + if (!signal?.aborted) { + return + } + throw createAiVaultScanCancelledError() +} + +// Why: the runtime RPC transport has no abort hook, so cancellation can only +// stop the caller waiting on it — the in-flight request settles on its own +// timeout instead of holding an 'all'-scope merge open after every waiter left. +export function abandonRemoteSessionScanOnCancel( + promise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return promise + } + return new Promise((resolve, reject) => { + if (signal.aborted) { + // The scanner promise already exists; observe it so its later failure is + // not an unhandled rejection after this caller walked away. + void promise.catch(() => undefined) + reject(createAiVaultScanCancelledError()) + return + } + const onAbort = (): void => reject(createAiVaultScanCancelledError()) + signal.addEventListener('abort', onAbort, { once: true }) + void promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +export function createAiVaultScanCancelledError(): Error { + const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE) + error.name = 'AbortError' + return error +} diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts new file mode 100644 index 00000000000..6464770d516 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' + +const EMPTY_RESULT = { sessions: [], issues: [], scannedAt: '2026-07-27T00:00:00.000Z' } + +describe('AiVaultScanCoordinator', () => { + it('keeps a shared scan alive when only one caller cancels', async () => { + const coordinator = new AiVaultScanCoordinator() + let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined + let sharedSignal: AbortSignal | undefined + const start = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + sharedSignal = signal + resolveScan = resolve + }) + ) + const controller = new AbortController() + const first = coordinator.run({ key: 'scope', signal: controller.signal, start }) + const second = coordinator.run({ key: 'scope', start }) + await Promise.resolve() + + controller.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(sharedSignal?.aborted).toBe(false) + resolveScan?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + expect(start).toHaveBeenCalledTimes(1) + }) + + it('preempts a non-forced scan and re-joins its caller onto the forced scan', async () => { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + let resolveForced: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => { + if (signals.length === 1) { + signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + } else { + resolveForced = resolve + } + }) + }) + const first = coordinator.run({ key: 'scope', start }) + await Promise.resolve() + + const forced = coordinator.run({ key: 'scope', force: true, start }) + const joined = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(signals[0]?.aborted).toBe(true) + expect(start).toHaveBeenCalledTimes(2) + resolveForced?.(EMPTY_RESULT) + // The first caller never asked to cancel, so someone else's Refresh must + // hand it the replacement's result instead of a spurious cancellation. + await expect(Promise.all([first, forced, joined])).resolves.toEqual([ + EMPTY_RESULT, + EMPTY_RESULT, + EMPTY_RESULT + ]) + }) + + it('keeps coalescing forced callers onto a forced scan that is still fresh', async () => { + vi.useFakeTimers() + try { + const coordinator = new AiVaultScanCoordinator() + let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn( + () => + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const first = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + vi.advanceTimersByTime(4_999) + const second = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(start).toHaveBeenCalledTimes(1) + resolveScan?.(EMPTY_RESULT) + // Both callers settle off the single shared scan instead of preempting it. + await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + } finally { + vi.useRealTimers() + } + }) + + it('lets a forced refresh preempt a forced scan that hung past the coalescing window', async () => { + vi.useFakeTimers() + try { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + let resolveSecond: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => { + if (signals.length > 1) { + resolveSecond = resolve + } + }) + }) + const stuck = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + vi.advanceTimersByTime(5_000) + const retry = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(signals[0]?.aborted).toBe(true) + expect(start).toHaveBeenCalledTimes(2) + resolveSecond?.(EMPTY_RESULT) + // The caller stranded on the hung scan rides the replacement out rather + // than being told its own refresh was cancelled. + await expect(Promise.all([stuck, retry])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + } finally { + vi.useRealTimers() + } + }) + + it('starts a fresh scan after every waiter cancels', async () => { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + if (signals.length > 1) { + return Promise.resolve(EMPTY_RESULT) + } + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + }) + }) + const controller = new AbortController() + const first = coordinator.run({ key: 'scope', signal: controller.signal, start }) + await Promise.resolve() + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + + const second = coordinator.run({ key: 'scope', start }) + + await expect(second).resolves.toEqual(EMPTY_RESULT) + expect(start).toHaveBeenCalledTimes(2) + expect(signals[0]?.aborted).toBe(true) + }) +}) diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.ts b/src/main/ai-vault/ai-vault-scan-coordinator.ts new file mode 100644 index 00000000000..7e704e4b820 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-coordinator.ts @@ -0,0 +1,155 @@ +import { + AI_VAULT_SCAN_CANCELLED_MESSAGE, + type AiVaultListResult +} from '../../shared/ai-vault-types' + +// Matches the renderer's forced-rescan throttle: forced callers that arrive +// inside this window share one scan, later ones may preempt a scan that hung. +const FORCED_SCAN_PREEMPT_AFTER_MS = 5_000 + +type ScanEntry = { + controller: AbortController + force: boolean + startedAt: number + promise: Promise + waiterCount: number + // The last waiter leaving must not abort a scan that already finished. + settled: boolean + // Set when a forced caller replaced this entry, so its waiters re-join the + // replacement instead of surfacing someone else's refresh as their own cancel. + preemptedBy: ScanEntry | null +} + +export class AiVaultScanCoordinator { + private readonly entries = new Map() + + run(args: { + key: string + force?: boolean + signal?: AbortSignal + start: (signal: AbortSignal) => Promise + }): Promise { + if (args.signal?.aborted) { + return Promise.reject(scanCancellationError()) + } + let entry = this.entries.get(args.key) + const preempted = entry && args.force === true && canPreemptForForcedScan(entry) ? entry : null + if (preempted) { + this.removeEntry(args.key, preempted) + entry = undefined + } + if (!entry) { + entry = this.createEntry(args.key, args.force === true, args.start) + this.entries.set(args.key, entry) + } + if (preempted) { + // The replacement must be registered before the abort lands: waiters of + // the old scan re-join it synchronously from their abort listener. + preempted.preemptedBy = entry + preempted.controller.abort() + } + return this.attach(args.key, entry, args.signal) + } + + private createEntry( + key: string, + force: boolean, + start: (signal: AbortSignal) => Promise + ): ScanEntry { + const controller = new AbortController() + const entry: ScanEntry = { + controller, + force, + startedAt: Date.now(), + promise: Promise.resolve().then(() => { + if (controller.signal.aborted) { + throw scanCancellationError() + } + return start(controller.signal) + }), + waiterCount: 0, + settled: false, + preemptedBy: null + } + const onSettled = (): void => { + entry.settled = true + this.removeEntry(key, entry) + } + void entry.promise.then(onSettled, onSettled) + return entry + } + + private attach(key: string, entry: ScanEntry, signal?: AbortSignal): Promise { + entry.waiterCount++ + return new Promise((resolve, reject) => { + let attached = true + const detach = (): void => { + if (!attached) { + return + } + attached = false + signal?.removeEventListener('abort', onAbort) + entry.controller.signal.removeEventListener('abort', onAbort) + entry.waiterCount-- + if (entry.waiterCount === 0 && !entry.settled && !entry.controller.signal.aborted) { + this.removeEntry(key, entry) + entry.controller.abort() + } + } + const onAbort = (): void => { + if (!attached) { + return + } + // Why: a forced refresh aborts the shared entry, but the other waiters + // never asked to cancel — rejecting them turns one window's Refresh into + // a cancelled multi-host merge somewhere else. Re-join the replacement. + const replacement = signal?.aborted ? null : entry.preemptedBy + detach() + if (!replacement) { + reject(scanCancellationError()) + return + } + void this.attach(key, replacement, signal).then(resolve, reject) + } + signal?.addEventListener('abort', onAbort, { once: true }) + entry.controller.signal.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted || entry.controller.signal.aborted) { + onAbort() + return + } + void entry.promise.then( + (result) => { + if (attached) { + detach() + resolve(result) + } + }, + (error) => { + if (attached) { + detach() + reject(error) + } + } + ) + }) + } + + private removeEntry(key: string, entry: ScanEntry): void { + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + } +} + +// Why: without this a forced scan that never settles (no inactivity timer on the +// legacy SSH file-stream reader, see #11364) makes every later Refresh join the +// dead promise, so the panel stays empty until the app restarts. +function canPreemptForForcedScan(entry: ScanEntry): boolean { + return !entry.force || Date.now() - entry.startedAt >= FORCED_SCAN_PREEMPT_AFTER_MS +} + +function scanCancellationError(): Error { + const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE) + error.name = 'AbortError' + return error +} diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 4b39e75eb72..facf417cdbb 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -3,6 +3,13 @@ import { scanAiVaultSessions } from './session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' +import { + aiVaultSessionDepthCovers, + requestedAiVaultSessionDepth, + truncateAiVaultListResult, + type AiVaultSessionDepth +} from '../../shared/ai-vault-session-depth' // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the @@ -19,65 +26,77 @@ export type AiVaultSessionSources = { type CachedAiVaultList = { key: string + depth: AiVaultSessionDepth result: AiVaultListResult expiresAt: number } let cachedList: CachedAiVaultList | null = null -let inflightList: Promise | null = null -let inflightKey: string | null = null +let scanCoordinator = new AiVaultScanCoordinator() let sources: AiVaultSessionSources = {} export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { sources = next } -export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { +export async function listAiVaultSessions( + args?: AiVaultListArgs, + options: { signal?: AbortSignal } = {} +): Promise { // Scope paths change the result set, so they must be part of the cache key. - const key = JSON.stringify({ - limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [] - }) + const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() }) + const depth = requestedAiVaultSessionDepth(args) + const scanKey = JSON.stringify({ key, depth }) const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL - // transcripts; explicit refreshes bypass the cache but not an active scan. - if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { - return cachedList.result + // transcripts; explicit refreshes bypass the cache and preempt stale scans. + if ( + args?.force !== true && + cachedList?.key === key && + cachedList.expiresAt > now && + aiVaultSessionDepthCovers(cachedList.depth, depth) + ) { + return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } - if (inflightList && inflightKey === key) { - return inflightList - } - - inflightKey = key - const additionalCodexSessionsDirs = - sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), - // Why: this scan is always host-local; callers addressing this host by a - // runtime id get the result restamped at the RPC edge, never rescanned. - executionHostId: LOCAL_EXECUTION_HOST_ID - }))() - .then((result) => { - cachedList = { - key, - result, - expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + return scanCoordinator.run({ + key: scanKey, + force: args?.force, + signal: options.signal, + start: async (scanSignal) => { + const additionalCodexSessionsDirs = + sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] + const result = await scanAiVaultSessions({ + limit: args?.limit, + unlimited: args?.unlimited, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + // Cancelled/superseded callers must stop the parse, not just stop + // waiting for it — the scan owns hundreds of transcript reads. + signal: scanSignal, + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + if (!scanSignal.aborted) { + const current = cachedList + if ( + args?.force === true || + current?.key !== key || + current.expiresAt <= Date.now() || + !aiVaultSessionDepthCovers(current.depth, depth) + ) { + cachedList = { + key, + depth, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + } + } } return result - }) - .finally(() => { - // Only clear tracking if it still refers to this request: a concurrent - // different-key scan may have replaced it and must stay dedupable. - if (inflightKey === key) { - inflightKey = null - inflightList = null - } - }) - return inflightList + } + }) } // Exported for the subagent-transcript IPC path, which validates @@ -95,7 +114,6 @@ export async function getAiVaultWslHomeDirs(): Promise { // Why: tests reset module-level cache/source state between cases. export function resetAiVaultSessionListCacheForTests(): void { cachedList = null - inflightList = null - inflightKey = null + scanCoordinator = new AiVaultScanCoordinator() sources = {} } diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts new file mode 100644 index 00000000000..9a107fd1ef3 --- /dev/null +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -0,0 +1,47 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 +const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 + +/** Splits transcript content into lines. Without a signal there is nothing to + * observe, so it splits in one pass; with one it yields to the event loop so a + * cancelled scan stops mid-transcript instead of parsing megabytes for a caller + * that already left. */ +export function remoteSessionContentLines( + content: string, + signal?: AbortSignal +): Iterable | AsyncIterable { + return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/) +} + +async function* cancellableContentLines( + content: string, + signal: AbortSignal +): AsyncGenerator { + // Content below one yield window would otherwise never observe cancellation. + throwIfAiVaultScanCancelled(signal) + let lineStart = 0 + let yieldStart = 0 + let linesSinceYield = 0 + + for (let index = 0; index <= content.length; index++) { + if (index < content.length && content.charCodeAt(index) !== 10) { + continue + } + const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index + yield content.slice(lineStart, lineEnd) + lineStart = index + 1 + linesSinceYield++ + if ( + linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT || + index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT + ) { + throwIfAiVaultScanCancelled(signal) + await yieldToEventLoop() + throwIfAiVaultScanCancelled(signal) + linesSinceYield = 0 + yieldStart = index + } + } +} diff --git a/src/main/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts index 238ea13aa65..aa5809a776d 100644 --- a/src/main/ai-vault/remote-session-file-stat.ts +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -1,19 +1,24 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { FileStat } from '../providers/types' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' export async function statRemoteSessionFile( - provider: IFilesystemProvider, + provider: RemoteSessionFilesystemProvider, path: string, agent: AiVaultAgent, executionHostId: ExecutionHostId, issues: AiVaultScanIssue[], - options?: { missingIsExpected?: boolean } + options?: { missingIsExpected?: boolean; signal?: AbortSignal } ): Promise { try { + throwIfAiVaultScanCancelled(options?.signal) const stat = await provider.stat(path) + throwIfAiVaultScanCancelled(options?.signal) const mtimeMs = remoteSessionMtimeMs(stat) return { path, @@ -25,8 +30,14 @@ export async function statRemoteSessionFile( ...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {}) } } catch (error) { + throwIfAiVaultScanCancelled(options?.signal) if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { - issues.push({ executionHostId, agent, path, message: errorMessage(error) }) + recordRemoteSessionScanIssue(issues, { + executionHostId, + agent, + path, + message: errorMessage(error) + }) } return null } diff --git a/src/main/ai-vault/remote-session-scan-batching.test.ts b/src/main/ai-vault/remote-session-scan-batching.test.ts new file mode 100644 index 00000000000..da9ee87601c --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-batching.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { mapRemoteScanBatches } from './remote-session-scan-batching' + +describe('mapRemoteScanBatches', () => { + it('observes an abort that lands while the final batch yields', async () => { + const controller = new AbortController() + + await expect( + mapRemoteScanBatches( + ['a', 'b'], + 2, + async (item) => { + controller.abort() + return item + }, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('observes an abort for empty inputs that never enter the loop', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + mapRemoteScanBatches([], 8, async (item) => item, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('returns every batch result when nothing cancels', async () => { + expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6]) + }) +}) diff --git a/src/main/ai-vault/remote-session-scan-batching.ts b/src/main/ai-vault/remote-session-scan-batching.ts new file mode 100644 index 00000000000..d51703e1b15 --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-batching.ts @@ -0,0 +1,22 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +// Batching bounds concurrent remote round trips; the yield between batches keeps +// the process responsive, and the batch boundary is where cancellation lands. +export async function mapRemoteScanBatches( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, + signal?: AbortSignal +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += concurrency) { + throwIfAiVaultScanCancelled(signal) + results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper)))) + await yieldToEventLoop() + } + // An abort can land while the last batch yields, and empty inputs never enter + // the loop at all — observe it here so neither path returns as a success. + throwIfAiVaultScanCancelled(signal) + return results +} diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts new file mode 100644 index 00000000000..77b8e371b51 --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -0,0 +1,47 @@ +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' + +// Why: discovery batches (8 sources) each stat in batches of 8, and parse +// batches read whole transcripts — nested fan-out put ~64 filesystem round +// trips on one SSH mux or relay event loop at once, blowing the scan budget and +// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling. +const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8 + +/** Wraps a provider so all of one scan's filesystem calls share a single + * in-flight ceiling, regardless of how the callers nest their batches. */ +export function limitRemoteScanFilesystemConcurrency( + provider: RemoteSessionFilesystemProvider, + maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY +): RemoteSessionFilesystemProvider { + const gate = createConcurrencyGate(maxInFlight) + return { + readDir: (dirPath) => gate(() => provider.readDir(dirPath)), + readFile: (filePath) => gate(() => provider.readFile(filePath)), + stat: (filePath) => gate(() => provider.stat(filePath)) + } +} + +function createConcurrencyGate(maxInFlight: number): (run: () => Promise) => Promise { + const limit = Math.max(1, Math.floor(maxInFlight)) + const waiting: (() => void)[] = [] + let inFlight = 0 + + return async (run: () => Promise): Promise => { + if (inFlight < limit) { + inFlight++ + } else { + // The releasing call hands its slot over directly, so a queued caller can + // never race a fresh one into an over-limit slot. + await new Promise((resolve) => waiting.push(resolve)) + } + try { + return await run() + } finally { + const next = waiting.shift() + if (next) { + next() + } else { + inFlight-- + } + } + } +} diff --git a/src/main/ai-vault/remote-session-scan-issues.ts b/src/main/ai-vault/remote-session-scan-issues.ts new file mode 100644 index 00000000000..aee229f443c --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-issues.ts @@ -0,0 +1,24 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const REMOTE_SCAN_ISSUE_LIMIT = 500 + +export function recordRemoteSessionScanIssue( + issues: AiVaultScanIssue[], + issue: AiVaultScanIssue +): void { + if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) { + issues.push(issue) + return + } + if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) { + // Kinded: this row is a scan notice, not a skipped transcript — the panel + // counts unkinded issues as skipped transcript files. + issues.push({ + executionHostId: issue.executionHostId, + agent: issue.agent, + kind: 'notice', + path: 'Agent Session History scan', + message: 'Additional scan issues were omitted.' + }) + } +} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts index d6a305ba4cf..8f5ec3c1bbb 100644 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -1,39 +1,50 @@ -import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' +import { remoteSessionContentLines } from './remote-session-content-lines' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' export async function remoteCodexIndexTitles(args: { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider codexHome: string hostPlatform: RemoteHostPlatform titleCaches: Map>> + signal?: AbortSignal }): Promise> { const cached = args.titleCaches.get(args.codexHome) if (cached) { return cached } - const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) + const pending = readRemoteCodexIndexTitles( + args.provider, + args.codexHome, + args.hostPlatform, + args.signal + ) args.titleCaches.set(args.codexHome, pending) return pending } async function readRemoteCodexIndexTitles( - provider: IFilesystemProvider, + provider: RemoteSessionFilesystemProvider, codexHome: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise> { const titleBySessionId = new Map() try { + throwIfAiVaultScanCancelled(signal) const { content, isBinary } = await provider.readFile( joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) ) + throwIfAiVaultScanCancelled(signal) if (isBinary) { return titleBySessionId } - for (const line of content.split(/\r?\n/)) { + for await (const line of remoteSessionContentLines(content, signal)) { const record = parseJsonObject(line) if (!record) { continue @@ -45,6 +56,7 @@ async function readRemoteCodexIndexTitles( } } } catch { + throwIfAiVaultScanCancelled(signal) // Codex indexes are opportunistic; raw transcripts remain sufficient. } return titleBySessionId diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 14a47f36559..a309f51077b 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -6,6 +6,9 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { mapRemoteScanBatches } from './remote-session-scan-batching' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -26,15 +29,22 @@ export async function discoverRemoteSourceCandidates(args: { ? partitionSubagentTranscriptPaths(walked) : null const paths = partition ? partition.sessionFilePaths : walked - const files = await mapDiscoveryConcurrently(paths, (path) => - statRemoteSessionFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues, - { missingIsExpected: Boolean(args.source.fixedChildFileSegments) } - ) + const files = await mapRemoteScanBatches( + paths, + REMOTE_DISCOVERY_CONCURRENCY, + (path) => + statRemoteSessionFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + { + missingIsExpected: Boolean(args.source.fixedChildFileSegments), + signal: args.context.signal + } + ), + args.context.signal ) return files .filter((file): file is FileWithMtime => Boolean(file)) @@ -50,10 +60,12 @@ async function listRemoteFixedChildFiles( context: RemoteScannerContext, issues: AiVaultScanIssue[] ): Promise { + throwIfAiVaultScanCancelled(context.signal) let entries try { entries = await context.provider.readDir(source.rootDir) } catch (err) { + throwIfAiVaultScanCancelled(context.signal) recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) return [] } @@ -73,10 +85,12 @@ async function walkRemoteSessionFiles( dirPath = source.rootDir, depth = 0 ): Promise { + throwIfAiVaultScanCancelled(context.signal) let entries try { entries = await context.provider.readDir(dirPath) } catch (err) { + throwIfAiVaultScanCancelled(context.signal) recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) return [] } @@ -84,6 +98,7 @@ async function walkRemoteSessionFiles( const extensions = new Set(source.extensions) const files: string[] = [] for (const entry of entries) { + throwIfAiVaultScanCancelled(context.signal) const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) if ( entry.isDirectory && @@ -112,18 +127,12 @@ function recordRemoteDirectoryIssue( err: unknown ): void { if (!isMissingRemoteSessionPathError(err)) { - issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + recordRemoteSessionScanIssue(issues, { + executionHostId, + agent: source.agent, + kind: 'host', + path, + message: errorMessage(err) + }) } } - -async function mapDiscoveryConcurrently( - items: readonly T[], - mapper: (item: T) => Promise -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { - const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) - results.push(...(await Promise.all(batch.map(mapper)))) - } - return results -} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 5f57fa47c7b..d704c2da671 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -25,7 +25,9 @@ type RemoteContentParser = ( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + // Line-based parsers iterate cancellably; whole-document parsers ignore it. + signal?: AbortSignal ) => Promise | AiVaultSession | null export function remoteSessionSources( @@ -126,7 +128,8 @@ function remoteAntigravitySource( file, content, context.hostPlatform.os, - parserOptions(context) + parserOptions(context), + context.signal ) return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null } @@ -150,7 +153,9 @@ function source( filePredicate, directoryPredicate, parse: (file, content, context) => - Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) + Promise.resolve( + parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) + ) } } @@ -193,13 +198,15 @@ function remoteCodexSources( codexHome, executionHostId: context.executionHostId, executionHostPlatform: context.hostPlatform.os, + signal: context.signal, readIndexedTitle: async (sessionId) => ( await remoteCodexIndexTitles({ provider: context.provider, codexHome, hostPlatform, - titleCaches: context.titleCaches + titleCaches: context.titleCaches, + signal: context.signal }) ).get(sessionId) ?? null }) @@ -233,27 +240,30 @@ function piParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options) + return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) } function ompParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options) + return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) } function openClawParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options) + return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) } function remotePathSegments(path: string): string[] { diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 77806f38be6..58fb255e3cc 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -6,13 +6,19 @@ import type { FileWithMtime } from './session-scanner-types' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform + signal?: AbortSignal titleCaches: Map>> antigravityWorkspaceResolver: AntigravityWorkspaceResolver } +export type RemoteSessionFilesystemProvider = Pick< + IFilesystemProvider, + 'readDir' | 'readFile' | 'stat' +> + export type RemoteParserOptions = { executionHostId: ExecutionHostId executionHostPlatform: NodeJS.Platform diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 4ba3bd4d647..a4705ee1bfb 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -153,7 +153,9 @@ describe('scanRemoteAiVaultSessions', () => { provider, executionHostId: 'ssh:dev-box', remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + unlimited: true }) expect(result.issues).toEqual([]) @@ -404,11 +406,13 @@ describe('scanRemoteAiVaultSessions', () => { expect.arrayContaining([ expect.objectContaining({ agent: 'antigravity', + kind: 'host', path: brainDir, message: expect.stringContaining('EACCES') }), expect.objectContaining({ agent: 'claude', + kind: 'host', path: claudeProjectDir, message: expect.stringContaining('ECONNRESET') }) @@ -706,6 +710,93 @@ describe('scanRemoteAiVaultSessions', () => { 'scoped-session' ]) }) + + it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => { + const provider = new MemoryRemoteProvider() + for (const [sessionId, mtimeMs, hour] of [ + ['other-newest', 50, '05'], + ['other-newer', 40, '04'] + ] as const) { + provider.addFile( + `/home/ada/.codex/sessions/${sessionId}.jsonl`, + codexTranscript({ + sessionId, + title: sessionId, + cwd: '/home/ada/other', + timestamp: `2026-07-04T${hour}:00:00.000Z` + }), + mtimeMs + ) + } + provider.addFile( + '/home/ada/.codex/sessions/scoped.jsonl', + codexTranscript({ + sessionId: 'scoped-session', + title: 'Scoped workspace', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T01:00:00.000Z' + }), + 10 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-newest', + 'scoped-session' + ]) + }) + + it('caps scoped backfill at the requested limit', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/other.jsonl', + codexTranscript({ + sessionId: 'other-session', + title: 'Other workspace', + cwd: '/home/ada/other', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 50 + ) + for (const [sessionId, mtimeMs] of [ + ['newer-scoped', 30], + ['older-scoped', 20] + ] as const) { + provider.addFile( + `/home/ada/.codex/sessions/${sessionId}.jsonl`, + codexTranscript({ + sessionId, + title: sessionId, + cwd: '/home/ada/repo', + timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z` + }), + mtimeMs + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-session', + 'newer-scoped' + ]) + }) }) function codexTranscript(args: { diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 0ad894214e1..2523cd22a43 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -5,7 +5,7 @@ import type { } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { IFilesystemProvider } from '../providers/types' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { codexRolloutHardlinkIdentity, @@ -14,44 +14,71 @@ import { } from './codex-session-root-dedup' import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionFilesystemProvider +} from './remote-session-scanner-types' import { sessionSortTime } from './session-scanner-accumulator' import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { mapRemoteScanBatches } from './remote-session-scan-batching' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' +import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' -const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 -const REMOTE_SCOPE_PARSE_LIMIT = 2000 +const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 +// Remote scope membership is only known after a transcript is read, so the scope +// backfill carries its own ceiling on remote reads instead of borrowing the +// recency cap — under the cap, newer out-of-scope files ate the scope budget and +// silently dropped older in-scope sessions the scope contract guarantees. +const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000 export async function scanRemoteAiVaultSessions(args: { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId remoteHome: string hostPlatform: RemoteHostPlatform limit?: number + unlimited?: boolean scopePaths?: readonly string[] + signal?: AbortSignal }): Promise { - const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT + throwIfAiVaultScanCancelled(args.signal) + const limit = aiVaultScanLimit(args) const issues: AiVaultScanIssue[] = [] + // One ceiling for the whole scan: discovery walks, stats and transcript reads + // all queue behind it instead of multiplying into a nested fan-out. + const provider = limitRemoteScanFilesystemConcurrency(args.provider) const context: RemoteScannerContext = { - provider: args.provider, + provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, + signal: args.signal, titleCaches: new Map(), antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { try { - const read = await args.provider.readFile(historyPath) + throwIfAiVaultScanCancelled(args.signal) + const read = await provider.readFile(historyPath) + throwIfAiVaultScanCancelled(args.signal) return read.isBinary ? null : read.content - } catch { + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } return null } }) } const candidates = dedupeCodexRolloutFileAliases( ( - await mapRemoteScanConcurrently( + await mapRemoteScanBatches( remoteSessionSources(args.remoteHome, args.hostPlatform), - (source) => discoverRemoteSourceCandidates({ source, context, issues }) + REMOTE_SCAN_CONCURRENCY, + (source) => discoverRemoteSourceCandidates({ source, context, issues }), + args.signal ) ) .flat() @@ -64,7 +91,12 @@ export async function scanRemoteAiVaultSessions(args: { } ) - const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) + const parsed = await parseRemoteSessionCandidates({ + candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), + context, + issues, + limit + }) const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) const cappedSessions = parsedSessions .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) @@ -78,12 +110,15 @@ export async function scanRemoteAiVaultSessions(args: { context, issues, scopePaths, + limit, alreadyParsedFilePaths: parsed.parsedFilePaths }) const scopeSessions = dedupeCodexSessionsBySessionId([ ...parsedScopeSessions, ...extraScopeSessions ]) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) return { sessions: mergeRemoteSessions(cappedSessions, scopeSessions), @@ -107,19 +142,27 @@ async function parseRemoteSessionCandidates(args: { break } - const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + const remaining = args.candidates.length - index + const needed = Math.max(args.limit - sessions.length, 1) + const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining) + const batch = args.candidates.slice(index, index + batchSize) for (const candidate of batch) { parsedFilePaths.add(candidate.file.path) } + throwIfAiVaultScanCancelled(args.context.signal) const results = await Promise.all( batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) ) sessions.push(...results.filter(isAiVaultSession)) const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) sessions.splice(0, sessions.length, ...uniqueSessions) - index += batch.length + index += batchSize + await yieldToEventLoop() } + // The loop can terminate on the yield after its final batch, so re-check + // rather than letting a cancelled scan return a partial parse as a success. + throwIfAiVaultScanCancelled(args.context.signal) return { sessions, parsedFilePaths } } @@ -128,21 +171,29 @@ async function scanRemoteInScopeSessions(args: { context: RemoteScannerContext issues: AiVaultScanIssue[] scopePaths: readonly string[] + limit: number alreadyParsedFilePaths: ReadonlySet }): Promise { if (args.scopePaths.length === 0) { return [] } - const candidates = args.candidates - .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)) - .slice(0, REMOTE_SCOPE_PARSE_LIMIT) + const candidates = args.candidates.filter( + (candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path) + ) + const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT) const sessions: AiVaultSession[] = [] + let index = 0 - for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) { - const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) - const results = await Promise.all( - batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + // Keep reading newest-first until the scope has its requested number of + // sessions; out-of-scope candidates no longer end the search. + while (index < bound && sessions.length < args.limit) { + const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound) + const results = await mapRemoteScanBatches( + candidates.slice(index, batchEnd), + REMOTE_SCAN_CONCURRENCY, + (candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues), + args.context.signal ) sessions.push( ...results.filter( @@ -150,6 +201,17 @@ async function scanRemoteInScopeSessions(args: { isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) ) ) + index = batchEnd + } + + if (index < candidates.length && sessions.length < args.limit) { + recordRemoteSessionScanIssue(args.issues, { + executionHostId: args.context.executionHostId, + agent: 'codex', + kind: 'scope', + path: 'Agent Session History scan', + message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.` + }) } return sessions @@ -161,11 +223,14 @@ async function parseRemoteSessionCandidate( issues: AiVaultScanIssue[] ): Promise { try { + throwIfAiVaultScanCancelled(context.signal) const read = await context.provider.readFile(candidate.file.path) + throwIfAiVaultScanCancelled(context.signal) if (read.isBinary) { return null } const session = await candidate.source.parse(candidate.file, read.content, context) + throwIfAiVaultScanCancelled(context.signal) // Mirror the local rule: every session carries its sibling subagent // transcript count (row badge; recoverable signal at zero turns). The // walk listing supplies it — the parser can't readdir a remote disk. @@ -175,7 +240,8 @@ async function parseRemoteSessionCandidate( } return session } catch (err) { - issues.push({ + throwIfAiVaultScanCancelled(context.signal) + recordRemoteSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, path: candidate.file.path, @@ -232,15 +298,3 @@ function canStopParsingRemoteSessions( function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { return Boolean(session) } - -async function mapRemoteScanConcurrently( - items: readonly T[], - mapper: (item: T) => Promise -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) { - const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY) - results.push(...(await Promise.all(batch.map(mapper)))) - } - return results -} diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 1c77130c528..922a6af96d7 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -68,6 +68,21 @@ describe('runtime AI Vault session scanner', () => { ) }) + it('surfaces project scope truncation from the runtime transport bound', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/srv/repo-${index}`) + + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', { + scopePaths + }) + + expect(scanResult.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + it('stamps sessions and issues returned for a different execution host', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 4ead287bb64..1c1c248ee31 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,18 +1,18 @@ import { z } from 'zod' import { - AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListArgs, type AiVaultListResult, type AiVaultSession } from '../../shared/ai-vault-types' -import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import { parseAiVaultListResult } from './session-list-result-validation' export type RuntimeAiVaultHostInfo = { environmentId: string @@ -23,90 +23,6 @@ export type RuntimeAiVaultScanOptions = { timeoutMs?: number } -const nodePlatformSchema = z.enum([ - 'aix', - 'android', - 'darwin', - 'freebsd', - 'haiku', - 'linux', - 'openbsd', - 'sunos', - 'win32', - 'cygwin', - 'netbsd' -] satisfies NodeJS.Platform[]) - -const aiVaultSessionPreviewMessageSchema = z.object({ - role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), - text: z.string(), - timestamp: z.string().nullable() -}) - -const executionHostIdSchema = z.string().transform((value, ctx) => { - const normalized = normalizeExecutionHostId(value) - if (normalized) { - return normalized - } - ctx.addIssue({ - code: 'custom', - message: 'Invalid execution host id' - }) - return z.NEVER -}) - -const aiVaultListResultSchema = z.object({ - sessions: z.array( - z.object({ - id: z.string(), - executionHostId: executionHostIdSchema, - executionHostPlatform: nodePlatformSchema.nullable().optional(), - agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string(), - title: z.string(), - cwd: z.string().nullable(), - branch: z.string().nullable(), - model: z.string().nullable(), - filePath: z.string(), - codexHome: z.string().nullable(), - createdAt: z.string().nullable(), - updatedAt: z.string().nullable(), - modifiedAt: z.string(), - messageCount: z.number(), - totalTokens: z.number(), - previewMessages: z.array(aiVaultSessionPreviewMessageSchema), - // Optional keeps paired hosts on older builds compatible. - previewMessagesTruncated: z.boolean().optional(), - firstUserPrompt: z.string().nullable().optional(), - lastUserPrompt: z.string().nullable().optional(), - // Default keeps remote hosts running an older build (no recoverable-signal - // fields) parseable; they simply report no recoverable-empty sessions. - queuedMessageCount: z.number().default(0), - subagentTranscriptCount: z.number().default(0), - resumeCommand: z.string(), - // The default keeps remote hosts running an older build (no subagent - // field) parseable; scanned top-level sessions carry null anyway. - subagent: z - .object({ - parentSessionId: z.string(), - agentType: z.string().nullable(), - status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable() - }) - .nullable() - .default(null) - }) - ), - issues: z.array( - z.object({ - executionHostId: executionHostIdSchema.optional(), - agent: z.enum(AI_VAULT_AGENTS), - path: z.string(), - message: z.string() - }) - ), - scannedAt: z.string() -}) - // Why: zod strips unknown keys, so the repin home must be declared or the // parent would silently drop it and resume under the wrong account's home. const aiVaultPrepareSessionResumeResultSchema = z.object({ @@ -136,6 +52,7 @@ export async function scanRuntimeAiVaultSessions( 'aiVault.listSessions', { limit: args.limit, + unlimited: args.unlimited, force: args.force, // Why: cap here so the set of scanned paths is explicit on this side — // the RPC schema CLAMPS to the same bound anyway (older hosts had no @@ -147,17 +64,36 @@ export async function scanRuntimeAiVaultSessions( options.timeoutMs ) if (response.ok === true) { - const parsed = aiVaultListResultSchema.safeParse(response.result) - if (parsed.success) { - return withRuntimeExecutionHost(parsed.data, executionHostId) + try { + const result = withRuntimeExecutionHost( + parseAiVaultListResult(response.result), + executionHostId + ) + if (!args.scopePaths || args.scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT) { + return result + } + return { + ...result, + issues: [ + ...result.issues, + { + executionHostId, + agent: 'codex', + kind: 'scope', + path: environmentId, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } + ] + } + } catch (error) { + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: `Invalid aiVault.listSessions response: ${ + error instanceof Error ? error.message : 'unexpected result shape' + }` + }) } - return runtimeScanIssueResult({ - executionHostId, - environmentId, - message: `Invalid aiVault.listSessions response: ${ - parsed.error.issues[0]?.message ?? 'unexpected result shape' - }` - }) } return runtimeScanIssueResult({ executionHostId, @@ -224,6 +160,7 @@ function runtimeScanIssueResult(args: { { executionHostId: args.executionHostId, agent: 'codex', + kind: 'host', path: args.environmentId, message: args.message } diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts new file mode 100644 index 00000000000..9cdc22a8fb4 --- /dev/null +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { parseAiVaultListResult } from './session-list-result-validation' + +describe('parseAiVaultListResult', () => { + it('keeps valid sessions when another wire entry is malformed', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession(), { id: 42 }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]?.sessionId).toBe('session-1') + expect(parsed.issues).toContainEqual( + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ) + }) + + it('preserves the optional session fields the panel renders', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { + ...validSession(), + previewMessagesTruncated: true, + firstUserPrompt: 'first', + lastUserPrompt: 'last' + } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions[0]).toMatchObject({ + previewMessagesTruncated: true, + firstUserPrompt: 'first', + lastUserPrompt: 'last' + }) + }) + + it('rejects a malformed result envelope', () => { + expect(() => parseAiVaultListResult({ sessions: [] })).toThrow() + }) + + it('rejects a nonempty sessions array when every row is invalid', () => { + expect(() => + parseAiVaultListResult({ + sessions: [{ id: 42 }, null], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + ).toThrow('all supplied Agent Session History sessions were invalid') + }) +}) + +function validSession(): Record { + return { + id: 'local:codex:session-1:/tmp/session-1.jsonl', + executionHostId: 'local', + executionHostPlatform: 'linux', + agent: 'codex', + sessionId: 'session-1', + title: 'Session one', + cwd: '/repo', + branch: null, + model: null, + filePath: '/tmp/session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-27T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume session-1', + subagent: null + } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts new file mode 100644 index 00000000000..6d69a06af45 --- /dev/null +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const nodePlatformSchema = z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +] satisfies NodeJS.Platform[]) + +const executionHostIdSchema = z.string().transform((value, ctx) => { + const normalized = normalizeExecutionHostId(value) + if (normalized) { + return normalized + } + ctx.addIssue({ code: 'custom', message: 'Invalid execution host id' }) + return z.NEVER +}) + +const sessionPreviewMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), + text: z.string(), + timestamp: z.string().nullable() +}) + +const aiVaultSessionSchema = z.object({ + id: z.string(), + executionHostId: executionHostIdSchema, + executionHostPlatform: nodePlatformSchema.nullable().optional(), + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + model: z.string().nullable(), + filePath: z.string(), + codexHome: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + modifiedAt: z.string(), + messageCount: z.number(), + totalTokens: z.number(), + previewMessages: z.array(sessionPreviewMessageSchema), + previewMessagesTruncated: z.boolean().optional(), + firstUserPrompt: z.string().nullable().optional(), + lastUserPrompt: z.string().nullable().optional(), + queuedMessageCount: z.number().default(0), + subagentTranscriptCount: z.number().default(0), + resumeCommand: z.string(), + subagent: z + .object({ + parentSessionId: z.string(), + agentType: z.string().nullable(), + status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable() + }) + .nullable() + .default(null) +}) + +const aiVaultScanIssueSchema = z.object({ + executionHostId: executionHostIdSchema.optional(), + agent: z.enum(AI_VAULT_AGENTS), + kind: z.enum(['host', 'scope', 'notice']).optional(), + path: z.string(), + message: z.string() +}) + +const aiVaultListResultEnvelopeSchema = z.object({ + sessions: z.array(z.unknown()), + issues: z.array(z.unknown()), + scannedAt: z.string() +}) + +export function parseAiVaultListResult(value: unknown): AiVaultListResult { + const envelope = aiVaultListResultEnvelopeSchema.safeParse(value) + if (!envelope.success) { + throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape') + } + const sessions = envelope.data.sessions.flatMap((session) => { + const parsed = aiVaultSessionSchema.safeParse(session) + return parsed.success ? [parsed.data] : [] + }) + if (envelope.data.sessions.length > 0 && sessions.length === 0) { + throw new Error('all supplied Agent Session History sessions were invalid') + } + const issues = envelope.data.issues.flatMap((issue) => { + const parsed = aiVaultScanIssueSchema.safeParse(issue) + return parsed.success ? [parsed.data] : [] + }) + const invalidCount = + envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length) + if (invalidCount > 0) { + issues.push({ + agent: 'codex', + path: 'aiVault.listSessions', + message: `Skipped ${invalidCount} invalid Agent Session History result ${invalidCount === 1 ? 'entry' : 'entries'}.` + }) + } + return { sessions, issues, scannedAt: envelope.data.scannedAt } +} diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts new file mode 100644 index 00000000000..ef0c578f662 --- /dev/null +++ b/src/main/ai-vault/session-list-results.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { mergeAiVaultListResults } from './session-list-results' + +function listResult(issues: AiVaultScanIssue[]): AiVaultListResult { + return { sessions: [], issues, scannedAt: '2026-08-02T00:00:00.000Z' } +} + +function session(index: number): AiVaultSession { + const id = `session-${index}` + const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString() + return { + id, + executionHostId: 'local', + agent: 'codex', + sessionId: id, + title: id, + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/${id}.jsonl`, + codexHome: null, + createdAt: timestamp, + updatedAt: timestamp, + modifiedAt: timestamp, + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: id, + subagent: null + } +} + +const SCOPE_TRUNCATION: AiVaultScanIssue = { + executionHostId: 'ssh:dev-box', + agent: 'codex', + kind: 'scope', + path: '/home/ada', + message: 'Only the first 64 project paths were scanned.' +} + +describe('mergeAiVaultListResults', () => { + it('does not cap an Unlimited all-host merge', () => { + const sessions = Array.from({ length: 1001 }, (_, index) => session(index)) + const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true) + + expect(merged.sessions).toHaveLength(1001) + }) + + it('keeps a per-host scope truncation notice when merging all-host results', () => { + const merged = mergeAiVaultListResults( + [listResult([]), listResult([SCOPE_TRUNCATION])], + undefined + ) + + expect(merged.issues).toEqual([SCOPE_TRUNCATION]) + }) + + it('keeps one scope notice per host rather than collapsing them', () => { + const otherHost: AiVaultScanIssue = { ...SCOPE_TRUNCATION, executionHostId: 'ssh:build-box' } + + const merged = mergeAiVaultListResults( + [listResult([SCOPE_TRUNCATION]), listResult([otherHost])], + undefined + ) + + expect(merged.issues.map((issue) => issue.executionHostId)).toEqual([ + 'ssh:dev-box', + 'ssh:build-box' + ]) + }) + + it('keeps a scope notice alongside a failing host so one bad host is not the whole story', () => { + const hostDown: AiVaultScanIssue = { + executionHostId: 'ssh:build-box', + agent: 'codex', + kind: 'host', + path: 'build-box', + message: 'Remote connection dropped.' + } + + const merged = mergeAiVaultListResults( + [listResult([SCOPE_TRUNCATION]), listResult([hostDown])], + undefined + ) + + expect(merged.issues).toEqual([SCOPE_TRUNCATION, hostDown]) + // Kinded issues render as their own banner rows, never as skipped transcripts. + expect(merged.issues.filter((issue) => !issue.kind)).toEqual([]) + }) +}) diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index 06f8df9c39a..54e2fff7d5a 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' +import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId @@ -17,6 +18,7 @@ export function aiVaultScanIssueResult(args: { { ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), agent: 'codex', + kind: 'host', path: args.path, message: args.message } @@ -25,6 +27,12 @@ export function aiVaultScanIssueResult(args: { } } +// A superseded scan has no findings to report; the flag tells the renderer to +// keep the list it already has rather than paint this empty body. +export function cancelledAiVaultListResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: new Date().toISOString(), cancelled: true } +} + // Why: the serving-side scan is host-local and cached once for every caller // (desktop parent, web, mobile), so callers that address this host by a runtime // id get the cached result restamped on the way out instead of a per-host scan. @@ -50,9 +58,10 @@ export function restampAiVaultListResult( export function mergeAiVaultListResults( results: readonly AiVaultListResult[], - rawLimit: number | undefined + rawLimit: number | undefined, + unlimited = false ): AiVaultListResult { - const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const limit = aiVaultScanLimit({ limit: rawLimit, unlimited }) const byId = new Map() const issues: AiVaultScanIssue[] = [] for (const result of results) { diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 2e3a3a08b4a..818167fbd0c 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -36,11 +37,12 @@ export async function parseAntigravitySessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseAntigravitySessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 7e08ec9d92e..74e92165c61 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -31,6 +31,7 @@ import { parseJsonObject, subtractCodexUsage } from './session-scanner-values' +import { remoteSessionContentLines } from './remote-session-content-lines' export async function parseCodexSessionFile( file: FileWithMtime, @@ -61,10 +62,11 @@ export async function parseCodexSessionContent(args: { executionHostId?: ExecutionHostId executionHostPlatform?: NodeJS.Platform | null readIndexedTitle?: (sessionId: string) => Promise + signal?: AbortSignal }): Promise { return parseCodexSessionLines({ file: args.file, - lines: args.content.split(/\r?\n/), + lines: remoteSessionContentLines(args.content, args.signal), platform: args.platform ?? process.platform, codexHome: args.codexHome ?? null, executionHostId: args.executionHostId, diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 3bb0b4ce1f0..4d0140fc3df 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -44,11 +45,12 @@ export async function parseCopilotSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseCopilotSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index 44274d3b671..b18402584e5 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -42,11 +43,12 @@ export async function parseCursorSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseCursorSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 2abaa7b8193..557920e7f40 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -44,11 +45,12 @@ export async function parseDroidSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseDroidSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index 6f0b9fdc7c1..c3cda8856bb 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' @@ -40,12 +41,13 @@ export async function parseGeminiSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ResumableParseFinalizeOptions = {} + options: ResumableParseFinalizeOptions = {}, + signal?: AbortSignal ): Promise { if (file.path.endsWith('.jsonl')) { return parseGeminiJsonlSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 04601eacde5..b4d0c171171 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' @@ -184,12 +185,13 @@ export async function parseMessageGraphSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseMessageGraphSessionLines({ agent, file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts index 7f50a220247..87cc8eb2f47 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts @@ -135,6 +135,22 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { ]) }) + it('omits the SQL limit for an Unlimited scan', async () => { + const { db, path } = createTempDb() + applySchema(db) + for (let i = 0; i < 5; i++) { + insertSession(db, `ses_${i}`, 1_777_634_000_000 + i * 1000) + } + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: Number.POSITIVE_INFINITY, + issues: [] + }) + expect(candidates).toHaveLength(5) + }) + it('uses time_created recency when time_updated is not positive', async () => { const { db, path } = createTempDb() applySchema(db) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 5d6c6202824..4b86d04161a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -32,7 +32,7 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean { ) } -function buildSessionListQuery(db: SyncDatabase): string { +function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { const parentIdPredicate = columnExists(db, 'session', 'parent_id') ? 'AND parent_id IS NULL' : '' const archivedPredicate = columnExists(db, 'session', 'time_archived') ? 'AND time_archived IS NULL' @@ -45,7 +45,7 @@ function buildSessionListQuery(db: SyncDatabase): string { FROM session WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC - LIMIT ?` + ${limited ? 'LIMIT ?' : ''}` } function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { @@ -105,7 +105,9 @@ export async function listOpenCodeSqliteSessions(args: { if (!canReadOpenCodeSessions(db)) { continue } - const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + const limited = Number.isFinite(args.limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] for (const row of rows) { candidates.push(rowToCandidate(row, dbPath)) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 537055d87d4..92db2241320 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -219,11 +220,12 @@ export async function parseClaudeSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseClaudeSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7d4de3ef294..13b40698cfd 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -34,12 +34,16 @@ export type AiVaultScanOptions = { droidProjectsDir?: string kimiSessionsDir?: string limit?: number + unlimited?: boolean limitPerAgent?: number // Active workspace/project paths whose sessions must be included regardless of // the recency cap (see discoverInScopeClaudeFiles). scopePaths?: readonly string[] platform?: NodeJS.Platform executionHostId?: ExecutionHostId + // Superseded/cancelled scans stop between parse batches instead of parsing + // every remaining transcript for a caller that already left. + signal?: AbortSignal } export type FileWithMtime = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 7c472d6f3eb..e6d49d1b965 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -154,7 +154,9 @@ describe('scanAiVaultSessions', () => { const result = await scanAiVaultSessions({ ...roots, - platform: 'darwin' + platform: 'darwin', + limit: 1, + unlimited: true }) expect(result.issues).toEqual([]) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f1979c67626..3a98b7018ac 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -39,13 +39,11 @@ import type { SessionParseResult } from './session-scanner-types' import { clampPositiveInteger, errorMessage } from './session-scanner-values' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' -const DEFAULT_LIMIT = 1000 -const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 -// Upper bound on extra in-scope transcripts discovered and parsed past the -// recency cap; guards against a pathological scoped history directory. -const SCOPE_PARSE_LIMIT = 2000 +const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 /** * Scan all supported AI agent session stores and return a unified, sorted, @@ -63,8 +61,12 @@ export async function scanAiVaultSessions( // "one core pegged" reports need to show whether transcript scanning is the // subsystem burning CPU, and how much of each scan the cache absorbed. return withSpan('aiVault.scan', async (span) => { - const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) - const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) + const limit = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) + const limitPerAgent = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) const platform = options.platform ?? process.platform const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] @@ -72,8 +74,10 @@ export async function scanAiVaultSessions( const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) // Why: persisted entries must be seeded before any candidate is parsed, or // the cold scan gains nothing from the cache file (#9210). + throwIfAiVaultScanCancelled(options.signal) await ensureSessionParseCacheLoaded() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + throwIfAiVaultScanCancelled(options.signal) const candidates = dedupeCodexRolloutFileAliases( discoveries @@ -106,12 +110,13 @@ export async function scanAiVaultSessions( ) const parsedSessions = await parseSessionCandidates({ - candidates, + candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), limit, platform, executionHostId, issues, parseStats, + signal: options.signal, antigravityWorkspaceResolver }) @@ -122,12 +127,17 @@ export async function scanAiVaultSessions( const scopeSessions = await scanInScopeSessions({ discoveries, scopePaths: options.scopePaths ?? [], + limit, alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), platform, executionHostId, issues, - parseStats + parseStats, + signal: options.signal }) + // Scope discovery can return without parsing anything, so an abort landing + // here would otherwise persist and return a cancelled scan as complete. + throwIfAiVaultScanCancelled(options.signal) span.setAttribute('candidates', candidates.length) span.setAttribute('reused', parseStats.reused) @@ -169,11 +179,13 @@ function mergeSessions( async function scanInScopeSessions(args: { discoveries: SessionFileDiscovery[] scopePaths: readonly string[] + limit: number alreadyParsedFilePaths: ReadonlySet platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal }): Promise { if (args.scopePaths.length === 0) { return [] @@ -184,7 +196,7 @@ async function scanInScopeSessions(args: { const files = await discoverInScopeClaudeFiles({ rootDirs: claudeRootDirs, scopePaths: args.scopePaths, - limit: SCOPE_PARSE_LIMIT, + limit: args.limit, excludedFilePaths: args.alreadyParsedFilePaths, issues: args.issues }) @@ -201,7 +213,8 @@ async function scanInScopeSessions(args: { platform: args.platform, executionHostId: args.executionHostId, issues: args.issues, - parseStats: args.parseStats + parseStats: args.parseStats, + signal: args.signal }) } @@ -212,12 +225,14 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 while (index < args.candidates.length) { + throwIfAiVaultScanCancelled(args.signal) if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { break } @@ -255,6 +270,9 @@ async function parseSessionCandidates(args: { index += batchSize } + // An abort can land while the final batch settles; observe it here so a + // partial parse is never cached or returned as a complete scan. + throwIfAiVaultScanCancelled(args.signal) return sessions } diff --git a/src/main/ai-vault/ssh-session-list.test.ts b/src/main/ai-vault/ssh-session-list.test.ts new file mode 100644 index 00000000000..4d1abd483c3 --- /dev/null +++ b/src/main/ai-vault/ssh-session-list.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' + +const requestActiveSshAiVaultSessionList = vi.fn() +const getActiveSshAiVaultHostInfo = vi.fn() +const getSshFilesystemProvider = vi.fn() +const scanRemoteAiVaultSessions = vi.fn() + +vi.mock('../ipc/ssh', () => ({ + requestActiveSshAiVaultSessionList: (...args: unknown[]) => + requestActiveSshAiVaultSessionList(...args), + getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args), + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.' +})) + +vi.mock('./remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args) +})) + +const { scanSshAiVaultSessions } = await import('./ssh-session-list') + +describe('scanSshAiVaultSessions', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' }) + getSshFilesystemProvider.mockReturnValue({}) + }) + + it('returns at the deadline when the legacy crawl ignores abort', async () => { + // Relay without the method resolves null, so the leg falls through to the + // desktop crawl — which used to run unbounded even under an all-host budget. + vi.useFakeTimers() + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + let fallbackSignal: AbortSignal | undefined + scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + fallbackSignal = signal + return new Promise(() => {}) + }) + + const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 }) + await vi.advanceTimersByTimeAsync(20) + const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)]) + + expect(result).not.toBe('still-pending') + expect(fallbackSignal?.aborted).toBe(true) + if (result === 'still-pending') { + return + } + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'ssh:dev-box', + kind: 'host', + message: 'Agent Session History scan timed out after 20ms on this SSH host.' + }) + ]) + }) + + it('leaves the crawl unbounded when no budget was requested', async () => { + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + await scanSshAiVaultSessions('dev-box') + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ signal: undefined }) + ) + }) + + it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => { + // Why: `all` scope awaits every host leg together, so a throw here would + // discard the local sessions alongside this host's. + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up')) + + const result = await scanSshAiVaultSessions('dev-box') + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' }) + ]) + }) + + it('bounds the relay round trip separately from the whole leg', async () => { + // The relay needs seconds to walk a real remote home; only the leg total + // has to stay short enough that one host cannot hold the merge open. + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + expect.any(Object), + expect.objectContaining({ timeoutMs: 15_000 }) + ) + }) + + it('keeps a relay scan that outran the old three-second bound', async () => { + // Regression: the shared 3s budget emptied healthy hosts in the all-hosts + // view. A relay answering inside the scan budget must keep its sessions. + requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId: string, _params: unknown, options: { timeoutMs?: number }) => + (options.timeoutMs ?? 0) > 3_000 + ? Promise.resolve({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + : Promise.reject(relayTimeoutError()) + ) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + + it('reports a host issue when the relay timed out on a real scan budget', async () => { + // A relay that had a fair scan window will not answer faster over the far + // slower filesystem crawl, so retrying it only stalls the merge. + requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) + ]) + }) + + it('still falls back when the relay budget was too short for a fair attempt', async () => { + requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + scanRemoteAiVaultSessions.mockResolvedValue({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 3_000, + relayTimeoutMs: 2_000 + }) + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => { + requestActiveSshAiVaultSessionList.mockRejectedValue( + new Error('the remote agent timed out after loading its index') + ) + scanRemoteAiVaultSessions.mockResolvedValue({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('reports the relay error when the fallback crawl recovered nothing', async () => { + // Otherwise a broken relay over an empty crawl reads as a healthy but empty + // host, and the panel shows no reason for the missing sessions. + requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded')) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + const result = await scanSshAiVaultSessions('dev-box') + + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' }) + ]) + }) + + it('still propagates a caller cancellation', async () => { + const controller = new AbortController() + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {})) + + const result = scanSshAiVaultSessions('dev-box', undefined, { + signal: controller.signal, + timeoutMs: 5_000 + }) + await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)) + controller.abort() + + await expect(result).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +function emptyResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' } +} + +/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not + * on the message text, so a look-alike message must not take that branch. */ +function relayTimeoutError(): Error { + return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + +// Relay rows are re-validated before they are trusted, so this has to satisfy +// the full session schema rather than a partial stub. +function remoteSession(): AiVaultSession { + return { + id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl', + executionHostId: 'ssh:dev-box', + agent: 'codex', + sessionId: 'remote-session', + title: 'remote-session', + cwd: '/home/dev/repo', + branch: null, + model: null, + filePath: '/home/dev/remote-session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: '2026-08-02T00:00:00.000Z', + modifiedAt: '2026-08-02T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume remote-session', + subagent: null + } +} diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts new file mode 100644 index 00000000000..7a02883f812 --- /dev/null +++ b/src/main/ai-vault/ssh-session-list.ts @@ -0,0 +1,241 @@ +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultScanIssue +} from '../../shared/ai-vault-types' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' +import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh' +import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer' +import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { parseAiVaultListResult } from './session-list-result-validation' +import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results' + +/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone; + * `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */ +export type SshAiVaultScanBudget = { + signal?: AbortSignal + timeoutMs?: number + relayTimeoutMs?: number +} + +// Why: a relay that was given a real scan budget and still timed out will not +// answer faster over the slower filesystem crawl, so that leg reports a host +// issue. A relay cut short below this never got a fair attempt — those fall +// back rather than emptying a healthy host's sessions (#12178 follow-up). +const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000 + +export async function scanSshAiVaultSessions( + targetId: string, + args?: AiVaultListArgs, + options: SshAiVaultScanBudget = {} +): Promise { + const executionHostId = toSshExecutionHostId(targetId) + // Why: in `all` scope every host leg is awaited together, so an unexpected + // throw here (not a caller cancellation) would discard the local results too. + try { + return await scanOneSshHost(targetId, executionHostId, args, options) + } catch (error) { + if (isAbortError(error)) { + throw error + } + return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) + } +} + +async function scanOneSshHost( + targetId: string, + executionHostId: `ssh:${string}`, + args: AiVaultListArgs | undefined, + options: SshAiVaultScanBudget +): Promise { + const startedAt = Date.now() + // Both legs scan the same capped set, so the fallback can't quietly scan more + // paths — or skip the truncation notice — than the relay leg would. + const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) + const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT + let relayError: unknown + const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs + try { + const params = { + limit: args?.limit, + ...(args?.unlimited === true ? { unlimited: true } : {}), + ...(args?.force === true ? { force: true } : {}), + scopePaths, + ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) + } + const relayResult = + options.signal || relayTimeoutMs !== undefined + ? await requestActiveSshAiVaultSessionList(targetId, params, { + signal: options.signal, + timeoutMs: relayTimeoutMs + }) + : await requestActiveSshAiVaultSessionList(targetId, params) + if (relayResult !== null) { + return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId) + } + } catch (error) { + if (isAbortError(error)) { + throw error + } + if ( + isSshMuxRequestTimeoutError(error) && + (relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS) + ) { + return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) + } + relayError = error + } + const hostInfo = getActiveSshAiVaultHostInfo(targetId) + const provider = getSshFilesystemProvider(targetId) + if (!hostInfo || !provider) { + return sshScanIssueResult( + executionHostId, + targetId, + relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + ) + } + // Why: `timeoutMs` bounded only the relay round trip, so a host on a relay + // without the list method fell through to the unbounded desktop crawl and one + // stalled SSH file stream could hold every other host's results hostage. + const fallbackResult = await scanRemoteSessionsWithinBudget({ + scan: (signal) => + scanRemoteAiVaultSessions({ + provider, + executionHostId, + remoteHome: hostInfo.remoteHome, + hostPlatform: hostInfo.hostPlatform, + limit: args?.limit, + unlimited: args?.unlimited, + scopePaths, + signal + }), + signal: options.signal, + remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt) + }) + if (!fallbackResult) { + return sshScanIssueResult( + executionHostId, + targetId, + `Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.` + ) + } + const scopeIssues = scopePathsTruncated + ? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)] + : [] + // An empty remote home and "the relay method failed and the crawl found + // nothing" look identical, so a fallback that recovered nothing still reports + // the relay error instead of presenting a broken relay as an empty host. + if (!relayError || fallbackResult.sessions.length > 0) { + return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] } + } + return { + ...fallbackResult, + issues: [ + ...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues, + ...fallbackResult.issues, + ...scopeIssues + ] + } +} + +/** Budget left for the legacy crawl after the relay attempt spent part of it. */ +function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null { + if (timeoutMs === undefined) { + return null + } + return Math.max(0, timeoutMs - (Date.now() - startedAt)) +} + +/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is + * spent. Caller cancellation still propagates as an AbortError. */ +async function scanRemoteSessionsWithinBudget(args: { + scan: (signal?: AbortSignal) => Promise + signal?: AbortSignal + remainingMs: number | null +}): Promise { + const remainingMs = args.remainingMs + if (remainingMs === null) { + return args.scan(args.signal) + } + if (args.signal?.aborted) { + throw createAiVaultScanCancelledError() + } + if (remainingMs === 0) { + return null + } + const controller = new AbortController() + return new Promise((resolve, reject) => { + let settled = false + const finish = (settle: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + args.signal?.removeEventListener('abort', onCallerAbort) + settle() + } + const onCallerAbort = (): void => { + controller.abort() + finish(() => reject(createAiVaultScanCancelledError())) + } + const timer = setTimeout(() => { + controller.abort() + finish(() => resolve(null)) + }, remainingMs) + args.signal?.addEventListener('abort', onCallerAbort, { once: true }) + if (args.signal?.aborted) { + onCallerAbort() + return + } + try { + const scan = args.scan(controller.signal) + // The SSH provider may ignore abort; observe any eventual rejection after + // this caller has already returned its timeout or cancellation result. + void scan.then( + (result) => finish(() => resolve(result)), + (error: unknown) => finish(() => reject(error)) + ) + } catch (error) { + finish(() => reject(error)) + } + }) +} + +// Mirrors the notice the relay leg appends so both legs report the same cap. +function scopeTruncationIssue( + executionHostId: `ssh:${string}`, + remoteHome: string +): AiVaultScanIssue { + return { + executionHostId, + agent: 'codex', + kind: 'scope', + path: remoteHome, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } +} + +function sshScanIssueResult( + executionHostId: `ssh:${string}`, + targetId: string, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ executionHostId, path: targetId, message }) +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +function errorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'Agent Session History scan failed on the SSH target.' +} diff --git a/src/main/attribution/terminal-attribution-path-env-casing.test.ts b/src/main/attribution/terminal-attribution-path-env-casing.test.ts new file mode 100644 index 00000000000..95ff6fdc2e3 --- /dev/null +++ b/src/main/attribution/terminal-attribution-path-env-casing.test.ts @@ -0,0 +1,169 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { applyTerminalAttributionEnv } from './terminal-attribution' + +describe('applyTerminalAttributionEnv PATH key casing', () => { + let tmpRoot: string | null = null + + afterEach(() => { + if (tmpRoot) { + rmSync(tmpRoot, { force: true, recursive: true }) + tmpRoot = null + } + }) + + function makeTmpRoot(): string { + tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-')) + return tmpRoot + } + + // Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher + // with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046). + function pathEnvKeys(env: Record): string[] { + return Object.keys(env).filter((key) => /^path$/i.test(key)) + } + + it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { + Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd', + SystemRoot: 'C:\\Windows' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.Path.split(';') + + expect(pathEnvKeys(baseEnv)).toEqual(['Path']) + expect(pathEntries[0]).toBe(win32Dir) + expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd') + }) + + // Why: Win32 resolves a duplicated variable by taking the first match in the block, so the + // shim must land on the first-listed spelling and the shadowed one must keep its value — + // dropping it would promote it the moment anything downstream removes the live key. + it.each([ + { first: 'PATH', second: 'Path' }, + { first: 'Path', second: 'PATH' } + ])( + 'prepends the shim onto the first-listed `$first` and leaves `$second` untouched', + ({ first, second }) => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { + [first]: 'C:\\tools\\bin;C:\\Windows\\system32', + [second]: 'C:\\Windows\\system32' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + expect(baseEnv[first].split(';')).toEqual([ + win32Dir, + 'C:\\tools\\bin', + 'C:\\Windows\\system32' + ]) + expect(baseEnv[second]).toBe('C:\\Windows\\system32') + } + ) + + it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const baseEnv: Record = { Path: shimDir, PATH: 'C:\\Windows\\system32' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: false, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual(['PATH']) + expect(baseEnv.PATH).toBe('C:\\Windows\\system32') + }) + + it('strips shim entries from the inherited `Path` when attribution is disabled', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const baseEnv: Record = { Path: `${shimDir};C:\\Windows\\system32` } + + applyTerminalAttributionEnv(baseEnv, { + enabled: false, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual(['Path']) + expect(baseEnv.Path).toBe('C:\\Windows\\system32') + }) + + // Why: the daemon receives a sparse env patch with no path key and re-merges its own block + // underneath it, so guessing the spelling here hands the child both. + it.each([ + { hostKey: 'PATH', otherKey: 'Path' }, + { hostKey: 'Path', otherKey: 'PATH' } + ])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const inherited = { PATH: process.env.PATH, Path: process.env.Path } + delete process.env.PATH + delete process.env.Path + process.env[hostKey] = 'C:\\Windows\\system32' + + try { + const baseEnv: Record = { SystemRoot: 'C:\\Windows' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual([hostKey]) + expect(baseEnv[otherKey]).toBeUndefined() + } finally { + delete process.env[hostKey] + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + process.env[key] = value + } + } + } + }) + + it('leaves a case-sensitive POSIX `Path` variable alone', () => { + const root = makeTmpRoot() + const baseEnv: Record = { + PATH: '/usr/bin:/bin', + Path: '/decoy' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'linux', + userDataPath: join(root, 'user-data') + }) + + expect(baseEnv.Path).toBe('/decoy') + expect(baseEnv.PATH.split(':')).toContain('/usr/bin') + }) +}) diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index d1c4dd099fe..136a01b82f2 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -5,6 +5,7 @@ instead of scattering generated shell fragments across files. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, win32 as pathWin32 } from 'node:path' import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' +import { resolvePathEnvKey } from '../pty/windows-environment-path' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' const ATTRIBUTION_SHIM_VERSION = '6' @@ -73,7 +74,8 @@ export function applyTerminalAttributionEnv( } const pathDelimiter = platform === 'win32' ? ';' : ':' - const basePath = baseEnv.PATH ?? process.env.PATH ?? '' + const pathKey = resolvePathEnvKey(baseEnv, platform) + const basePath = baseEnv[pathKey] ?? process.env[pathKey] ?? '' // Why: resolve real Windows commands before prepending shims so cmd wrappers // cannot recursively point ORCA_REAL_* at themselves. const resolvedGit = platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null @@ -102,7 +104,7 @@ export function applyTerminalAttributionEnv( // shim directory here keeps the attribution behavior scoped to Orca's live // terminal environment instead of mutating global git/gh config or the // user's external shell PATH. - baseEnv.PATH = [...prependDirs, cleanedBasePath].filter(Boolean).join(pathDelimiter) + baseEnv[pathKey] = [...prependDirs, cleanedBasePath].filter(Boolean).join(pathDelimiter) baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION = '1' baseEnv.ORCA_GIT_COMMIT_TRAILER = ORCA_GIT_COMMIT_TRAILER baseEnv.ORCA_GH_PR_FOOTER = ORCA_GH_FOOTER @@ -131,11 +133,12 @@ function clearTerminalAttributionEnv( delete baseEnv[key] } const pathDelimiter = platform === 'win32' ? ';' : ':' - const cleanedPath = stripAttributionPathEntries(baseEnv.PATH ?? '', pathDelimiter) + const pathKey = resolvePathEnvKey(baseEnv, platform) + const cleanedPath = stripAttributionPathEntries(baseEnv[pathKey] ?? '', pathDelimiter) if (cleanedPath) { - baseEnv.PATH = cleanedPath + baseEnv[pathKey] = cleanedPath } else { - delete baseEnv.PATH + delete baseEnv[pathKey] } } diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index f491cb0771d..2eec4fee577 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -16,12 +16,15 @@ describe('packaged Windows CLI launcher asset', () => { const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') const source = readFileSync(sourcePath, 'utf8') + // Why: the marker and command name must ride the launcher's own environment, never + // ProcessStartInfo's case-insensitive copy of a PATH/Path block (stablyai/orca#12046). expect(source).toContain( - 'startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";' + 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - expect(source).toContain('Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"') - expect(source).toContain('? "orca-ide"') - expect(source).toContain(': "orca";') + expect(source).toContain( + 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' + ) + expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') }) diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index e8d094f03b6..5c62dbfe272 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -29,8 +29,17 @@ export function codexAuthMatchesManagedAccount( normalizeField(account.workspaceAccountId), managedIdentity?.workspaceAccountId ) - const emailMatches = Boolean(selectedEmail && identity.email && selectedEmail === identity.email) - if (selectedEmail && identity.email && selectedEmail !== identity.email) { + const emailMatches = identityFieldsAgree(selectedEmail, identity.email) + if ( + identityContradictsSelection( + { + email: selectedEmail, + providerAccountId: selectedProviderId, + workspaceAccountId: selectedWorkspaceId + }, + identity + ) + ) { return false } if (!identityFieldMatches(selectedProviderId, identity.providerAccountId)) { @@ -50,6 +59,26 @@ export function codexAuthMatchesManagedAccount( ) } +// Why: an unreadable managed home cannot be compared byte-for-byte, so only the +// account record itself can rule that home out as the credential's owner. +export function codexAuthCouldBelongToManagedAccount( + runtimeAuthContents: string, + account: CodexManagedAccount +): boolean { + const identity = readIdentityFromAuthContents(runtimeAuthContents) + return ( + !identity || + !identityContradictsSelection( + { + email: normalizeField(account.email), + providerAccountId: normalizeField(account.providerAccountId), + workspaceAccountId: normalizeField(account.workspaceAccountId) + }, + identity + ) + ) +} + // Why: the shared mirror may still hold managed credentials; only the same // positively identified system account may ever be read back to ~/.codex. export function codexAuthMatchesSystemDefaultIdentity( @@ -240,3 +269,33 @@ function firstNonNull(...values: (string | null | undefined)[]): string | null { function identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean { return !selectedField || Boolean(runtimeField && selectedField === runtimeField) } + +// Why: only a claim both sides carry can rule an account out — a credential with +// no identity claims (api key, PAT, bedrock) contradicts nothing. A ChatGPT +// rename leaves the record email stale, so a matching account id outranks it. +function identityContradictsSelection( + selected: CodexAuthIdentity, + identity: CodexAuthIdentity +): boolean { + if ( + identityFieldsConflict(selected.providerAccountId, identity.providerAccountId) || + identityFieldsConflict(selected.workspaceAccountId, identity.workspaceAccountId) + ) { + return true + } + return ( + identityFieldsConflict(selected.email, identity.email) && + !identityFieldsAgree(selected.providerAccountId, identity.providerAccountId) + ) +} + +function identityFieldsAgree(selectedField: string | null, runtimeField: string | null): boolean { + return Boolean(selectedField && runtimeField && selectedField === runtimeField) +} + +function identityFieldsConflict( + selectedField: string | null, + runtimeField: string | null +): boolean { + return Boolean(selectedField && runtimeField && selectedField !== runtimeField) +} diff --git a/src/main/codex-accounts/codex-credential-absence-grace.test.ts b/src/main/codex-accounts/codex-credential-absence-grace.test.ts index 6672bb7676a..37e360a3a6e 100644 --- a/src/main/codex-accounts/codex-credential-absence-grace.test.ts +++ b/src/main/codex-accounts/codex-credential-absence-grace.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -57,6 +57,21 @@ describe('CodexCredentialAbsenceGrace', () => { }) }) + it('keeps a permission-denied read inside the grace window before it turns durable', () => { + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + const grace = new CodexCredentialAbsenceGrace() + writeFileSync(authPath, VALID_AUTH, 'utf-8') + chmodSync(authPath, 0o000) + + expect(grace.assess(authPath, 1_000)).toEqual({ state: 'unreadable', durable: false }) + expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({ + state: 'unreadable', + durable: true + }) + }) + it('reports settled credential-free JSON as durable immediately', () => { const grace = new CodexCredentialAbsenceGrace() writeFileSync(authPath, '{}', 'utf-8') diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index 4de1da70396..abd6a4881ee 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -208,6 +208,18 @@ describe('waitForManagedCodexAuthReady', () => { ) }) + it('separates an absent credential from one that cannot be read', () => { + const fixture = createFixture() + expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('missing') + + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + writeAuth(fixture.home, testChatGptAuth) + chmodSync(join(fixture.home, 'auth.json'), 0o000) + expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable') + }) + it('does not gate system, WSL, or unmanaged custom homes', async () => { const fixture = createFixture() await waitForManagedCodexAuthReady({ diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d4ab194705d..3449dbd28f4 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -16,7 +16,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/types' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { @@ -259,6 +259,25 @@ function createManagedAuth(rootDir: string, accountId: string, auth: string): st return managedHomePath } +function createCodexAccountRecord( + id: string, + email: string, + providerAccountId: string, + managedHomePath: string +): CodexManagedAccount { + return { + id, + email, + managedHomePath, + providerAccountId, + workspaceLabel: null, + workspaceAccountId: providerAccountId, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + function encodeJwtPart(value: Record): string { return Buffer.from(JSON.stringify(value)).toString('base64url') } @@ -467,6 +486,557 @@ describe('CodexRuntimeHomeService', () => { expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() }) + it.each([ + ['missing', (authPath: string) => rmSync(authPath, { force: true })], + [ + 'unreadable', + (authPath: string) => { + chmodSync(authPath, 0o000) + } + ] + ])( + 'never launches the previously synced account when the selected account auth is %s', + async (label, breakAuth) => { + if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { + return + } + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') + const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-a', + email: 'a@example.com', + managedHomePath: homeA, + providerAccountId: 'acct-a', + workspaceLabel: null, + workspaceAccountId: 'acct-a', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-b', + email: 'b@example.com', + managedHomePath: homeB, + providerAccountId: 'acct-b', + workspaceLabel: null, + workspaceAccountId: 'acct-b', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) + + store.updateSettings({ + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + breakAuth(join(homeB, 'auth.json')) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + + // The selection survives the grace window, but the launch must never run + // as account A while the UI says account B. + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') + expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( + authA + ) + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' + ) + } + ) + + it.each([ + ['missing', (authPath: string) => rmSync(authPath, { force: true })], + [ + 'unreadable', + (authPath: string) => { + chmodSync(authPath, 0o000) + } + ] + ])( + 'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s', + async (label, breakAuth) => { + if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { + return + } + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1) + const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2) + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // A crash left account A's refreshed tokens in the mirror while settings + // already select account B, whose own auth.json cannot be read. + writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, + 'utf-8' + ) + breakAuth(join(homeB, 'auth.json')) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-a', + email: 'a@example.com', + managedHomePath: homeA, + providerAccountId: 'acct-a', + workspaceLabel: null, + workspaceAccountId: 'acct-a', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-b', + email: 'b@example.com', + managedHomePath: homeB, + providerAccountId: 'acct-b', + workspaceLabel: null, + workspaceAccountId: 'acct-b', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') + } + ) + + it('keeps the runtime auth through the grace window while the selected account rotates', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-user', + workspaceLabel: null, + workspaceAccountId: 'acct-user', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + + // Codex rewrites the selected account's auth.json in place: the runtime home + // already holds that same account, so its credentials must survive the race. + writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + + it('keeps runtime auth the selected account still owns when provenance is unreadable', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + // Cold start: the mirror holds the selected account's own credential, but a + // torn provenance file can no longer say so and its auth.json reads torn. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') + writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8') + writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + + it('never launches another account on a cold start with no provenance record', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Pre-provenance install: the mirror still holds account A, settings already + // select account B, and nothing on disk records who wrote those bytes. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, authA, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( + authA + ) + expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') + }) + + it('persists a mirror-only system-default refresh before dropping unproven runtime auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system', 1) + const systemAuthRefreshed = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refreshed', + 2 + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Codex rotated the system-default credential inside the mirror; ~/.codex is + // still the older copy Orca mirrored, so the refresh lives only here. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, systemAuthRefreshed, 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n`, + 'utf-8' + ) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuthRefreshed) + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('survives a runtime auth delete the filesystem refuses', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const homeA = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'a') + ) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Stands in for a Windows AV/EBUSY lock: the mirror auth.json cannot be + // removed, and the branch is only reached because reads are already failing. + mkdirSync(runtimeAuthPath, { recursive: true }) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, + 'utf-8' + ) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(() => service.prepareForCodexLaunch()).not.toThrow() + // The bytes survived the failed delete, so they must at least be fenced off. + expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({ + owner: 'fenced' + }) + rmSync(runtimeAuthPath, { recursive: true, force: true }) + }) + + it('refuses to read runtime auth back into a duplicate account while a home is unreadable', async () => { + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authX = createCodexAuthJson('x@example.com', 'acct-x', 'x', 1) + const authXRefreshed = createCodexAuthJson('x@example.com', 'acct-x', 'x-refreshed', 2) + // Two records for the same identity: one home unreadable, one readable. + const homeX1 = createManagedAuth(testState.userDataDir, 'account-x1', authX) + const homeX2 = createManagedAuth(testState.userDataDir, 'account-x2', authX) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, authXRefreshed, 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'managed', accountId: 'account-x1' })}\n`, + 'utf-8' + ) + chmodSync(join(homeX1, 'auth.json'), 0o000) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-x1', 'x@example.com', 'acct-x', homeX1), + createCodexAccountRecord('account-x2', 'x@example.com', 'acct-x', homeX2), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(join(homeX2, 'auth.json'), 'utf-8')).toBe(authX) + }) + + it('drops another account from the mirror even when the fence write fails', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Stands in for a Windows AV lock on the provenance file: the fence cannot + // be written, while auth.json itself is perfectly deletable. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, authA, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) + mkdirSync(getSharedRuntimeAuthProvenancePath(), { recursive: true }) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( + authA + ) + }) + + it('keeps a mirrored api-key credential no identity claim can attribute', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const apiKeyAuth = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-managed' })}\n` + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', apiKeyAuth) + // Pre-provenance install, cold start: the mirror holds the selected account's + // own api-key credential — bytes carrying no id_token to identify — while its + // auth.json reads torn mid-rotation. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, apiKeyAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) + writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(apiKeyAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + + it('reads a renamed account refresh back into its own home despite a stale record email', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authA = createCodexAuthJson('new@example.com', 'acct-a', 'a', 1) + const authARefreshed = createCodexAuthJson('new@example.com', 'acct-a', 'a-refreshed', 2) + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Account A was renamed on the ChatGPT side after it was added, so its record + // email is stale while the mirrored refresh carries the new one. + writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, + 'utf-8' + ) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'old@example.com', 'acct-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-b', + activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('keeps the mirror of a renamed account whose record email is stale', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const renamedAuth = createCodexAuthJson('new@example.com', 'acct-user', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', renamedAuth) + // Torn provenance can no longer vouch for the mirror, and the only remaining + // evidence is a credential whose email no longer matches the record. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, renamedAuth, 'utf-8') + writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8') + writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'old@example.com', 'acct-user', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(renamedAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + it('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => { const legacyLaunchHomePath = join( testState.userDataDir, diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fac946c51b2..7ab8f8a9766 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -72,6 +72,7 @@ import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-pa import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { + codexAuthCouldBelongToManagedAccount, codexAuthIsFresher, codexAuthMatchesManagedAccount, codexAuthMatchesSystemDefaultIdentity @@ -741,11 +742,21 @@ export class CodexRuntimeHomeService { const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath) if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') { if (!authAbsence.durable) { - // Why: mid-rotation reads look missing/unreadable for a moment; skip - // this sync without deselecting and let a settled read decide later. + if (this.sharedRuntimeAuthBelongsToAccount(activeAccount)) { + // Why: mid-rotation reads look missing/unreadable for a moment; skip + // this sync without deselecting and let a settled read decide later. + console.warn( + '[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window' + ) + return + } + // Why: the runtime home still holds another account, so riding out the + // grace would launch that account under this selection. Not being able + // to read the selected account is no license to run a different one. console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window' + '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' ) + this.clearRuntimeAuthForUnprovenSelection() return } console.warn( @@ -920,6 +931,116 @@ export class CodexRuntimeHomeService { } } + // Why: an unreadable managed auth.json says nothing about who the shared + // runtime home currently holds. The credential's own identity claims answer + // that without touching the unreadable home; Orca's write records and its + // provenance file are corroboration, not the only evidence — a torn or fenced + // provenance file is no proof the bytes belong to somebody else. + private sharedRuntimeAuthBelongsToAccount(account: CodexManagedAccount): boolean { + if (!existsSync(this.getRuntimeAuthPath())) { + return true + } + const runtimeAuth = this.readRuntimeAuthForProvenance() + if (runtimeAuth !== null) { + if (codexAuthMatchesManagedAccount(runtimeAuth, account, null)) { + return true + } + // Why: Orca itself mirrored these exact bytes for this account this run. + if (this.lastSyncedAccountId === account.id && this.lastWrittenAuthJson === runtimeAuth) { + return true + } + } + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if (status.kind === 'committed') { + return status.provenance.owner === 'managed' && status.provenance.accountId === account.id + } + // Why: with no committed record left, only claims that contradict this + // account prove the mirror is somebody else's. Deleting bytes nothing can + // attribute logs the user out over the very absence the grace window covers. + return runtimeAuth !== null && codexAuthCouldBelongToManagedAccount(runtimeAuth, account) + } + + // Why: the absence may still heal, so keep the selection — but leave no other + // identity's credentials behind for the launch. Logged out beats logged in as + // someone else, and fencing stops later syncs adopting the removed bytes. + private clearRuntimeAuthForUnprovenSelection(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + try { + // Why: a refresh Codex wrote into the mirror belongs to whoever owns + // those bytes; persist it to that owner's home — managed or ~/.codex — + // first, then fence so a delete the OS refuses (Windows AV lock, EBUSY) + // still leaves the surviving bytes marked untrusted. + const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: false + }) + if (readBackResult === 'rejected') { + this.readBackRefreshedSystemDefaultAuth() + } + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + } finally { + // Why: neither the rescue nor the fence may cancel the delete — leaving + // another identity's credentials for the launch is the defect itself. + this.lastWrittenAuthJson = null + rmSync(runtimeAuthPath, { force: true }) + } + } catch (error) { + // Why: this branch only runs because the filesystem already refused a + // read; a second failure must not throw out of launch preparation. + console.warn('[codex-runtime-home] Failed to clear unproven runtime auth:', error) + } + } + + // Why: a token Codex refreshed inside the mirror has no managed home to fall + // back to when the mirror holds the user's own ~/.codex credential, so persist + // it there before the unproven mirror is dropped. + private readBackRefreshedSystemDefaultAuth(): void { + const runtimeAuth = this.readRuntimeAuthForProvenance() + const systemDefaultAuth = this.readSystemDefaultAuth() + if (runtimeAuth === null || systemDefaultAuth === null || runtimeAuth === systemDefaultAuth) { + return + } + const claim = this.resolveSystemDefaultMirrorClaim( + runtimeAuth, + this.resolveSharedRuntimeAuthProvenanceStatus() + ) + if ( + !claim.ownershipProven || + claim.mirroredAuthJson === null || + systemDefaultAuth !== claim.mirroredAuthJson || + !this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemDefaultAuth) + ) { + return + } + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + } + + // Why: which ~/.codex bytes the mirror was seeded from, and whether the system + // default can be proven to own the mirror at all. + private resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection() @@ -1234,6 +1355,7 @@ export class CodexRuntimeHomeService { managedAuthPath: string managedAuthContents: string }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false for (const account of this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue @@ -1242,12 +1364,30 @@ export class CodexRuntimeHomeService { if (!existsSync(managedAuthPath)) { continue } - const managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } } + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } if (matches.length === 1) { return { kind: 'matched', ...matches[0] } } @@ -1652,21 +1792,10 @@ export class CodexRuntimeHomeService { }) return } - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const snapshotAuth = snapshot?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - const systemDefaultOwnershipProven = - provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven - const mirroredSystemDefaultAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) if (!existsSync(systemDefaultAuthPath)) { if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) diff --git a/src/main/codex-cli/codex-home-process-lock.test.ts b/src/main/codex-cli/codex-home-process-lock.test.ts index 7287d011b42..e5a7eee7976 100644 --- a/src/main/codex-cli/codex-home-process-lock.test.ts +++ b/src/main/codex-cli/codex-home-process-lock.test.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' import { homedir } from 'node:os' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveCodexHomeProcessLockKey, resolveCodexHomeProcessLockKeyForSpawnEnv, @@ -70,6 +70,30 @@ describe('withCodexHomeProcessLock', () => { await expect(withCodexHomeProcessLock('home-a', async () => 'after')).resolves.toBe('after') }) + it('does not release a running lock based on elapsed time', async () => { + vi.useFakeTimers() + try { + const events: string[] = [] + const gate = deferred() + const first = withCodexHomeProcessLock('home-long-running', async () => { + events.push('first:start') + await gate.promise + }) + const second = withCodexHomeProcessLock('home-long-running', async () => { + events.push('second:start') + }) + + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(events).toEqual(['first:start']) + + gate.resolve() + await Promise.all([first, second]) + expect(events).toEqual(['first:start', 'second:start']) + } finally { + vi.useRealTimers() + } + }) + it('keys explicit and default host homes consistently', () => { const previousCodexHome = process.env.CODEX_HOME delete process.env.CODEX_HOME diff --git a/src/main/daemon/daemon-audit-eligibility-event.test.ts b/src/main/daemon/daemon-audit-eligibility-event.test.ts index 52171e14853..13d3e0f39ed 100644 --- a/src/main/daemon/daemon-audit-eligibility-event.test.ts +++ b/src/main/daemon/daemon-audit-eligibility-event.test.ts @@ -4,7 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validate } from '../telemetry/validator' import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier' +import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence' import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { PROTOCOL_VERSION } from './daemon-protocol-version' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' @@ -25,6 +27,16 @@ const context: DaemonAuditContext = { profileScope: '/profile' } +const exactIncarnation: ExactDaemonIncarnation = { + identity: { + pid: 92_847_561, + startedAtMs: 1_786_000_123_456, + launchNonce: 'private-launch-nonce' + }, + linuxStartTicks: '8642097531', + bootId: 'private-boot-id' +} + beforeEach(() => { trackMock.mockReset() }) @@ -42,12 +54,70 @@ describe('daemon audit eligibility telemetry', () => { reason: 'authenticated_inventory', evidence_sources: ['authenticated_inventory'], protocol_generation: 23, + generation_role: 'legacy', exact_incarnation: 'unavailable', process_reason: null }) + expect(props).not.toHaveProperty('exact_incarnation_correlation') expect(validate('daemon_audit_eligibility', props).ok).toBe(true) }) + it('correlates an exact incarnation across app trackers without raw identity fields', () => { + const currentContext = { ...context, protocolGeneration: PROTOCOL_VERSION } + const firstAppTracker = createDaemonAuditEligibilityTracker() + const secondAppTracker = createDaemonAuditEligibilityTracker() + + firstAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation)) + secondAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation)) + secondAppTracker( + recordAuthenticatedInventory(currentContext, { + ...exactIncarnation, + identity: { ...exactIncarnation.identity, launchNonce: 'another-private-launch-nonce' } + }) + ) + + const firstProperties = trackMock.mock.calls[0][1] + const secondProperties = trackMock.mock.calls[1][1] + const replacementProperties = trackMock.mock.calls[2][1] + expect(firstProperties).toMatchObject({ + generation_role: 'current', + exact_incarnation: 'endpoint-identity-linux-ticks', + exact_incarnation_correlation: 'v1:2751326d0f457808fe11a03ce2f6e732' + }) + expect(secondProperties.exact_incarnation_correlation).toBe( + firstProperties.exact_incarnation_correlation + ) + expect(replacementProperties.exact_incarnation_correlation).not.toBe( + firstProperties.exact_incarnation_correlation + ) + expect(validate('daemon_audit_eligibility', firstProperties).ok).toBe(true) + + const serializedProperties = JSON.stringify(firstProperties) + for (const rawIdentity of [ + exactIncarnation.identity.launchNonce, + String(exactIncarnation.identity.pid), + String(exactIncarnation.identity.startedAtMs), + exactIncarnation.linuxStartTicks, + exactIncarnation.bootId + ]) { + expect(serializedProperties).not.toContain(rawIdentity) + } + }) + + it('does not mislabel a future protocol generation as legacy', () => { + const trackEligibility = createDaemonAuditEligibilityTracker() + + expect(() => + trackEligibility( + recordAuthenticatedInventory( + { ...context, protocolGeneration: PROTOCOL_VERSION + 1 }, + exactIncarnation + ) + ) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + it('cannot affect callers when telemetry throws', () => { trackMock.mockImplementation(() => { throw new Error('transport failed') @@ -83,13 +153,13 @@ describe('daemon audit eligibility telemetry', () => { for (let index = 0; index < 60; index += 1) { nowMs += 1_000 - trackEligibility(recordAuthenticatedInventory(context, null)) + trackEligibility(recordAuthenticatedInventory(context, exactIncarnation)) } expect(trackMock).toHaveBeenCalledOnce() nowMs += 5 * 60_000 - trackEligibility(recordAuthenticatedInventory(context, null)) + trackEligibility(recordAuthenticatedInventory(context, exactIncarnation)) expect(trackMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/daemon/daemon-audit-eligibility-event.ts b/src/main/daemon/daemon-audit-eligibility-event.ts index 3afaaa8aad5..e99ac903eb8 100644 --- a/src/main/daemon/daemon-audit-eligibility-event.ts +++ b/src/main/daemon/daemon-audit-eligibility-event.ts @@ -1,15 +1,22 @@ +import { createHash } from 'node:crypto' import { track } from '../telemetry/client' import type { EventProps } from '../../shared/telemetry-events' import type { DaemonAuditObservation } from './daemon-audit-classifier' +import { PROTOCOL_VERSION } from './daemon-protocol-version' // Why: a steady daemon repeats a byte-identical observation on every listProcesses call, so // repeats are re-sent only as an occasional heartbeat — the shared per-session telemetry // ceiling is 1,000 events for the whole app and audit data must not crowd it out. const REPEATED_OBSERVATION_INTERVAL_MS = 5 * 60_000 +const INCARNATION_CORRELATION_DOMAIN = 'orca:daemon-audit-eligibility:incarnation:v1' +type AuditEligibilityCommonProperties = Omit< + Extract, { exact_incarnation: 'unavailable' }>, + 'exact_incarnation' +> export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void { try { - track('daemon_audit_eligibility', auditEligibilityProperties(observation)) + track('daemon_audit_eligibility', auditEligibilityProperties(observation, hashLaunchNonce)) } catch { // Audit telemetry cannot affect daemon availability. } @@ -22,11 +29,13 @@ export function createDaemonAuditEligibilityTracker( ): (observation: DaemonAuditObservation) => void { let lastProperties: string | null = null let lastTrackedAtMs = 0 + const correlateLaunchNonce = createLaunchNonceCorrelationCache() return (observation) => { // Why: the rate-limit bookkeeping runs inside the daemon's inventory path, so it is guarded // together with the emit — audit telemetry cannot affect daemon availability. try { - const properties = JSON.stringify(auditEligibilityProperties(observation)) + const eventProperties = auditEligibilityProperties(observation, correlateLaunchNonce) + const properties = JSON.stringify(eventProperties) const observedAtMs = monotonicNowMs() const elapsedMs = observedAtMs - lastTrackedAtMs if ( @@ -38,7 +47,7 @@ export function createDaemonAuditEligibilityTracker( } lastProperties = properties lastTrackedAtMs = observedAtMs - trackDaemonAuditEligibility(observation) + track('daemon_audit_eligibility', eventProperties) } catch { // Audit telemetry cannot affect daemon availability. } @@ -46,33 +55,73 @@ export function createDaemonAuditEligibilityTracker( } function auditEligibilityProperties( - observation: DaemonAuditObservation + observation: DaemonAuditObservation, + correlateLaunchNonce: (launchNonce: string) => string ): EventProps<'daemon_audit_eligibility'> { - return { + const generationRole = generationRoleForProtocol(observation.context.protocolGeneration) + const commonProperties: AuditEligibilityCommonProperties = { state: observation.state, reason: observation.reason, trigger: observation.trigger, evidence_sources: [...observation.evidenceSources], protocol_generation: observation.context.protocolGeneration, + generation_role: generationRole, provider: observation.context.provider, endpoint_kind: observation.context.endpointKind, profile_scope: observation.context.profileScope ? 'configured' : 'unspecified', - exact_incarnation: exactIncarnationKind(observation), reachability: observation.reachability, inventory_authority: observation.inventoryAuthority, process_liveness: observation.processLiveness, process_reason: observation.processReason, endpoint_state: observation.endpointState } + if (!observation.exactIncarnation) { + return { ...commonProperties, exact_incarnation: 'unavailable' } + } + return { + ...commonProperties, + exact_incarnation: exactIncarnationKind(observation.exactIncarnation), + exact_incarnation_correlation: correlateLaunchNonce( + observation.exactIncarnation.identity.launchNonce + ) + } +} + +function generationRoleForProtocol(protocolGeneration: number): 'current' | 'legacy' { + if (protocolGeneration === PROTOCOL_VERSION) { + return 'current' + } + if (protocolGeneration > 0 && protocolGeneration < PROTOCOL_VERSION) { + return 'legacy' + } + throw new Error('unsupported_daemon_audit_protocol_generation') } function exactIncarnationKind( - observation: DaemonAuditObservation -): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' { - if (!observation.exactIncarnation) { - return 'unavailable' - } - return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId + exactIncarnation: NonNullable +): 'endpoint-identity' | 'endpoint-identity-linux-ticks' { + return exactIncarnation.linuxStartTicks && exactIncarnation.bootId ? 'endpoint-identity-linux-ticks' : 'endpoint-identity' } + +function createLaunchNonceCorrelationCache(): (launchNonce: string) => string { + let cachedNonce: string | null = null + let cachedCorrelation = '' + return (launchNonce) => { + if (launchNonce !== cachedNonce) { + cachedNonce = launchNonce + cachedCorrelation = hashLaunchNonce(launchNonce) + } + return cachedCorrelation + } +} + +function hashLaunchNonce(launchNonce: string): string { + const digest = createHash('sha256') + .update(INCARNATION_CORRELATION_DOMAIN) + .update('\0') + .update(launchNonce) + .digest('hex') + return `v1:${digest.slice(0, 32)}` +} diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index c2bbc8bfa34..07f291a60ff 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(31) + expect(PROTOCOL_VERSION).toBe(32) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -14,5 +14,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index aa8b58e11ca..53ad0c06341 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -6,6 +6,7 @@ import { GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, @@ -14,7 +15,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(31) + expect(PROTOCOL_VERSION).toBe(32) + expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32) expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31) expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) @@ -23,7 +25,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 30 }, (_, index) => index + 1) + Array.from({ length: 31 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 1f4195af37b..61cabf2fc15 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v31 exposes attach-only PTY adoption so a mount cannot create over a live stable pane. -export const PROTOCOL_VERSION = 31 +// v32 carries the corrected terminal snapshot serializer; older owners remain attachable. +export const PROTOCOL_VERSION = 32 +export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 @@ -23,7 +24,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30 + 28, 29, 30, 31 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 8525542a9eb..5dc807459be 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -848,13 +848,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('delegates scan authority to a v29 daemon, which can retract', () => { + it('delegates scan authority to a v32 daemon with faithful snapshots', () => { const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 32 }) try { - current.setPtyBackgrounded('v29-session', true) + current.setPtyBackgrounded('v32-session', true) expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { - sessionId: 'v29-session', + sessionId: 'v32-session', background: true }) } finally { @@ -939,8 +939,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('background stream thinning compatibility', () => { - it('reports authoritative snapshot support only for protocol v20 and newer', () => { - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + it('reports authoritative snapshot support only for the corrected serializer protocol', () => { + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { expect(legacy.canProvideAuthoritativeBufferSnapshot('legacy-session')).toBe(false) expect(adapter.canProvideAuthoritativeBufferSnapshot('current-session')).toBe(true) @@ -962,9 +962,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => { + it('keeps preserved v31 sessions unthinned because their snapshots can corrupt replay', () => { const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { legacy.setPtyBackgrounded('legacy-session', true) expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { @@ -977,7 +977,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('clears a preserved v19 background hint before attaching its stream', async () => { + it('clears a preserved v31 background hint before attaching its stream', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -988,7 +988,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } as never) const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) @@ -1009,7 +1009,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it.each([ { protocolVersion: 28, clearsHint: true }, - { protocolVersion: 29, clearsHint: false } + { protocolVersion: 29, clearsHint: true }, + { protocolVersion: 31, clearsHint: true }, + { protocolVersion: 32, clearsHint: false } ])( 'uses protocol v$protocolVersion background authority before spawn', async ({ protocolVersion, clearsHint }) => { @@ -1085,7 +1087,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => { + it('leaves a v32 background hint alone on attach', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -1096,13 +1098,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } as never) const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 32 }) try { - await current.attach('preserved-v29-session') + await current.attach('preserved-v32-session') expect(notifySpy).not.toHaveBeenCalledWith( 'setSessionBackground', - expect.objectContaining({ sessionId: 'preserved-v29-session' }) + expect.objectContaining({ sessionId: 'preserved-v32-session' }) ) } finally { current.dispose() @@ -1112,7 +1114,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('still returns the v19 attach snapshot for a desktop renderer replay', async () => { + it('still returns the v31 attach snapshot so desktop replay retains shell history', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -1129,7 +1131,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { rows: 24 } } as never) - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { const result = await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 012e5c74b01..2bba3c467dc 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -38,6 +38,7 @@ import { } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { @@ -276,7 +277,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 this.supportsProducerFlowControl = this.protocolVersion >= 19 - this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.supportsAuthoritativeBufferSnapshots = + this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) this.client.onDisconnected(() => { if (!this.respawnAdoptionClosed) { @@ -959,7 +961,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!this.supportsProducerFlowControl) { return } - // Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too. + // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never // retract it, so a TUI exiting while hidden would strand the subscription and the diff --git a/src/main/daemon/daemon-pty-router-history-handoff.test.ts b/src/main/daemon/daemon-pty-router-history-handoff.test.ts index a53a4b29b63..07c2a5033d5 100644 --- a/src/main/daemon/daemon-pty-router-history-handoff.test.ts +++ b/src/main/daemon/daemon-pty-router-history-handoff.test.ts @@ -121,9 +121,7 @@ describe('DaemonPtyRouter history handoff', () => { 'finishHistorySeedTransfer' ]) ) - await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({ - data: expect.stringContaining(marker) - }) + await expect(router.getBufferSnapshot(sessionId)).resolves.toBeNull() expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true) }) }) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 17ad0dd835c..2bee5164ce9 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -7,7 +7,12 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' -import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void @@ -49,7 +54,8 @@ function createAdapter( protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, providesAgentSessionOwnerListings: () => protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => protocolVersion >= 20, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { const id = opts.sessionId ?? `${label}-new` sessions.push(id) @@ -336,8 +342,13 @@ describe('DaemonPtyRouter', () => { }) it('reports snapshot capability for the adapter that owns each session', async () => { - const current = createAdapter('current', ['current-session'], undefined, 22) - const legacy = createAdapter('legacy', ['legacy-session'], undefined, 19) + const current = createAdapter('current', ['current-session'], undefined, PROTOCOL_VERSION) + const legacy = createAdapter( + 'legacy', + ['legacy-session'], + undefined, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + ) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await router.discoverLegacySessions() @@ -371,21 +382,36 @@ describe('DaemonPtyRouter', () => { expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') }) - it('routes existing legacy sessions to their old daemon and new sessions to current daemon', async () => { - const current = createAdapter('current') - const legacy = createAdapter('legacy', ['legacy-session']) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + it('preserves v30/v31 session owners and routes new sessions to v32', async () => { + const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) + const legacyV30 = createAdapter( + 'v30', + ['v30-session'], + undefined, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION + ) + const legacyV31 = createAdapter( + 'v31', + ['v31-session'], + undefined, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + ) + const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31] }) await router.discoverLegacySessions() - await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v30-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 }) const fresh = await router.spawn({ cols: 80, rows: 24 }) - router.write('legacy-session', 'old\n') + router.write('v30-session', 'old-v30\n') + router.write('v31-session', 'old-v31\n') router.write(fresh.id, 'new\n') - expect(legacy.spawn).toHaveBeenCalledWith({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 }) + expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 }) expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) - expect(legacy.write).toHaveBeenCalledWith('legacy-session', 'old\n') + expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n') + expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n') expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts new file mode 100644 index 00000000000..8b324043358 --- /dev/null +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonServer } from './daemon-server' +import { SessionNotFoundError, type DaemonRequest } from './types' + +type DaemonServerPrivate = { + host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise } + pendingPtySpawnPreparations: Map> + routeRequest(clientId: string, request: DaemonRequest): Promise +} + +describe('daemon kill attribution', () => { + let server: DaemonServer + let dir: string + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('records the requesting control client in the session-killed log', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + let finishKill!: () => void + const killFinished = new Promise((resolve) => { + finishKill = resolve + }) + vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished) + + const request = daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + + await vi.waitFor(() => expect(daemon.host.kill).toHaveBeenCalledOnce()) + expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything()) + finishKill() + await request + + expect(killLog.log).toHaveBeenCalledWith('session-killed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + }) + + it('attributes a failed kill without claiming the session was killed', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused')) + + await expect( + daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + ).rejects.toThrow('kill refused') + + expect(killLog.log).toHaveBeenCalledWith('session-kill-failed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything()) + }) + + it('attributes a tolerated kill after canceling a pending spawn', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + const pendingPreparation = { canceled: false, clientId: 'control-42' } + daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation])) + vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session')) + + await daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + + expect(pendingPreparation.canceled).toBe(true) + expect(killLog.log).toHaveBeenCalledWith('session-killed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + expect(killLog.log).not.toHaveBeenCalledWith('session-kill-failed', expect.anything()) + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 90b3195766d..4c9ffd5098e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -935,18 +935,22 @@ export class DaemonServer { request.payload.sessionId ) this.lastInputAtBySessionId.delete(request.payload.sessionId) - this.log.log('session-killed', { + const attribution = { sessionId: request.payload.sessionId, - immediate: request.payload.immediate === true - }) + immediate: request.payload.immediate === true, + // Daemon control identity, not the paired-device bearer credential. + clientId + } try { await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) } catch (error) { // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { + this.log.log('session-kill-failed', attribution) throw error } } + this.log.log('session-killed', attribution) return {} } diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts index 3eb708b3cf2..7642e958eec 100644 --- a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -10,10 +10,10 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, SNAPSHOT_REPLAY_PREAMBLE_ALT, SNAPSHOT_REPLAY_PREAMBLE_NORMAL, - bufferHasSerializeHostileWrappedRow, createRendererParityTerminal, cursorPosition, normalBufferRowsTrimmed, + visibleRowWraps, visibleRowStyles, visibleRows, writeChunksToTerminal @@ -56,8 +56,6 @@ type FidelityDiff = { stage: string expected: unknown actual: unknown - /** True when the buffer trips the known upstream addon-serialize blank-leading-wrapped-row bug (see bufferHasSerializeHostileWrappedRow). */ - knownSerializeWrapBug?: boolean } function buildCase(seed: number): FidelityCase { @@ -155,9 +153,6 @@ async function runFidelityCase(testCase: FidelityCase): Promise { }) it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { - // Bug A (serialize wrap) is tolerated + counted so deep mode surfaces only new divergences; B and C are fixed, so their regressions fail loudly. - let knownSerializeWrapBugHits = 0 for (let i = 0; i < ITERATIONS; i++) { const seed = FIXED_SEED ?? 1 + i const testCase = buildCase(seed) const diff = await runFidelityCase(testCase) - if (diff?.knownSerializeWrapBug) { - knownSerializeWrapBugHits += 1 - continue - } if (diff) { const minimized = await minimizeFailure(testCase) const minimizedDiff = await runFidelityCase(minimized) expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) } } - // Guard the tolerance: the predicate tripping on most seeds means the gate has gone degenerate. - expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) }, 600_000) - // HEADLINE FINDING (do not delete while unfixed upstream): @xterm/addon-serialize 0.15.0-beta.287 - // doesn't round-trip null cells at a soft-wrap boundary, garbling every snapshot restore of a TUI that - // erased inside a soft-wrapped line. Repros below — V1 cell loss (seed 31, SerializeAddon.ts ~L214 - // wrap-validity ternary), V2 stray '-' filler (seed 157). Unskip once the upstream fix lands. - it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + it('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) const control = createRendererParityTerminal({ cols: 20, rows: 6 }) const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) @@ -267,6 +250,9 @@ describe('headless emulator snapshot fidelity fuzz', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY ]) expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(control.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) } finally { emulator.dispose() control.terminal.dispose() @@ -274,8 +260,7 @@ describe('headless emulator snapshot fidelity fuzz', () => { } }) - // V2 repro of the headline finding (stray '-' on a fully erased wrapped source row); unskip with V1. - it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + it('round-trips a wrapped line whose source row was fully erased', async () => { const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) const control = createRendererParityTerminal({ cols: 20, rows: 6 }) const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) @@ -292,8 +277,10 @@ describe('headless emulator snapshot fidelity fuzz', () => { snapshot.rehydrateSequences + snapshot.snapshotAnsi, POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY ]) - // Fails today: restored row 0 shows '-' where the live row is blank. expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(control.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) } finally { emulator.dispose() control.terminal.dispose() diff --git a/src/main/daemon/hibernation-cold-restore-repro.test.ts b/src/main/daemon/hibernation-cold-restore-repro.test.ts index 561c7de261e..c8e8ea232f4 100644 --- a/src/main/daemon/hibernation-cold-restore-repro.test.ts +++ b/src/main/daemon/hibernation-cold-restore-repro.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { HeadlessEmulator } from './headless-emulator' +import { POST_REPLAY_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' // Reproduces the "blank pane after agent hibernation" bug: alt-screen TUI snapshots have scrollbackAnsi='', so the adapter's // `if (scrollback)` gate (daemon-pty-adapter.ts:230) dropped the cold-restore payload and repainted blank despite an intact snapshotAnsi. @@ -95,9 +96,6 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => { expect(adapterScrollback).not.toBeNull() // Must end in the normal buffer (no alt-screen re-entry) so it won't fight the agent's own repaint when resume relaunches it. - // POST_REPLAY_MODE_RESET copied literally from renderer layout-serialization.ts (main-process test can't import renderer) — keep in sync. - const POST_REPLAY_MODE_RESET = - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' const fresh = new HeadlessEmulator({ cols: 80, rows: 24 }) fresh.writeSync('\x1b[2J\x1b[3J\x1b[H') fresh.writeSync(adapterScrollback as string) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 5af4aa3e374..fdde466e9eb 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2146,6 +2146,77 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() }) + it('collapses its own env merge onto the requested Windows `Path` spelling', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + // Why: buildPtyHostEnv collapses Windows PATH onto one spelling before the daemon wire; + // the daemon then spreads its own block underneath and can re-mint the other one. + env: { + Path: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(Object.keys(env).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(env.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + }) + + it('keeps the daemon `PATH` block when the requested env has no path key', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: { FOO: 'bar' } }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.PATH).toBe(process.env.PATH) + }) + + it('preserves a duplicated path block supplied by main', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { PATH: 'C:\\Live', Path: 'C:\\Shadowed' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.PATH).toBe('C:\\Live') + expect(env.Path).toBe('C:\\Shadowed') + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 95958b12b93..ca8527fb11f 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -32,6 +32,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home- import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { resolvePathEnvKey } from '../pty/windows-environment-path' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { @@ -70,6 +71,7 @@ import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' +import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -77,6 +79,7 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_WORKTREE_ID', 'ORCA_AGENT_LAUNCH_TOKEN' ] as const +const WINDOWS_PATH_ENV_KEY_RE = /^path$/i const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 // Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry. @@ -165,6 +168,36 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** Removes the second PATH key only when the daemon's env merge created it. */ +function collapseWindowsPathEnvKeys( + env: Record, + requestedEnv: Record | undefined +): void { + if (process.platform !== 'win32') { + return + } + const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + if (pathKeys.length < 2) { + return + } + // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. + const requestedKeys = requestedEnv + ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + : [] + if (requestedKeys.length !== 1) { + return + } + const survivingKey = requestedKeys[0] + if (!survivingKey || env[survivingKey] === undefined) { + return + } + for (const key of pathKeys) { + if (key !== survivingKey) { + delete env[key] + } + } +} + /** * Promotes the agent-teams shim path ahead of inherited PATH entries. */ @@ -184,8 +217,9 @@ function promoteAgentTeamsShimPath( if (!shimDir) { return } - const currentParts = env.PATH?.split(pathDelimiter).filter(Boolean) ?? [] - env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) } /** @@ -776,8 +810,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ) { addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) } + const requestedEnv = opts.env expandWindowsPathEnvironmentVariables(env) - promoteAgentTeamsShimPath(env, opts.env?.PATH) + // Why: collapse before promoting so the shim lands on the spelling the child actually inherits. + collapseWindowsPathEnvKeys(env, requestedEnv) + const requestedPath = requestedEnv + ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] + : undefined + promoteAgentTeamsShimPath(env, requestedPath) // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. ensureNodePtySpawnHelperExecutable() @@ -1000,9 +1040,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } }) + const slavePath = readPtySlavePath(proc) return { pid: proc.pid, shellPath, + ...(slavePath ? { slavePath } : {}), ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead. diff --git a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts new file mode 100644 index 00000000000..0e99e002941 --- /dev/null +++ b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' +import { Terminal } from '@xterm/headless' +import { Session } from './session' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { iterateTerminalHistorySeedChunks } from './terminal-history-seed-chunks' + +// Repro for #12101: a TUI that armed DECSET mouse tracking (Claude Code, vim, +// htop) is force-killed and never emits the matching DECRST. Nothing in the +// daemon substitutes the reset, so `TerminalModes.mouseTracking` is latched into +// the on-disk checkpoint and then re-derived — from the DEAD process's own bytes — +// into every subsequent fresh emulator. The revived pane runs a bare shell that +// never armed mouse reporting, yet its snapshot re-arms it, so pointer motion +// echoes literal SGR reports (^[[<35;col;rowM) into the prompt. +// +// This drives the REAL Session / HeadlessEmulator / TerminalMouseModeMirror / +// buildRehydrateSequences / HistoryManager / HistoryReader, with a fake +// subprocess standing in for node-pty (a real PTY can't be SIGKILLed +// deterministically mid-DECSET in vitest). The kill goes through Session.kill()'s +// real teardown; only killWithDescendantSweep is stubbed so the test never +// signals a real pid. + +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +// The variant `reattachReplayResetSequence` picks when the pane still looks like a +// live agent, which is exactly the stale state a SIGKILLed agent leaves behind: it +// deliberately OMITS RESET_MOUSE_REPORTING to keep real TUI scroll gestures alive. +import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET } from '../../shared/terminal-mode-reset-profiles' + +const ANY_MOTION_TRACKING_ON = '\x1b[?1003h' +const SGR_ENCODING_ON = '\x1b[?1006h' +const ANY_MOTION_TRACKING_OFF = '\x1b[?1003l' +const SGR_ENCODING_OFF = '\x1b[?1006l' + +function createFakeSubprocess(foregroundProcess: string) { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + const written: string[] = [] + const signals: string[] = [] + return { + written, + signals, + forceKilled: false, + pid: 4242, + getForegroundProcess: () => foregroundProcess, + write: (data: string) => void written.push(data), + resize: () => {}, + kill: () => {}, + forceKill(this: { forceKilled: boolean }) { + this.forceKilled = true + }, + signal: (sig: string) => void signals.push(sig), + onData: (cb: (data: string) => void) => void (onData = cb), + onExit: (cb: (code: number) => void) => void (onExit = cb), + dispose: () => {}, + /** PTY output — same channel node-pty uses, so Session's real ingest runs. */ + emit: (data: string) => onData?.(data), + /** SIGKILL reaped: exit fires with no DECRST ever sent. */ + simulateKilledExit: () => onExit?.(-1) + } +} + +/** Session.emitSubprocessOutput awaits xterm's async parse; poll until the + * emulator has committed the bytes so snapshots are taken on a settled stream. */ +async function waitForEmulatorParse(session: Session): Promise { + for (let i = 0; i < 200; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + if (session.getSnapshot()?.snapshotAnsi.includes('$ ')) { + return + } + } + throw new Error('setup: emulator never parsed the agent output') +} + +describe('#12101 mouse tracking survives the death of the process that armed it', () => { + let dir: string + const sessionId = 'repo-1::/Users/dev/feature-branch' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'repro-12101-')) + killWithDescendantSweepMock.mockReset() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.useRealTimers() + }) + + it('re-arms mouse reporting in the fresh shell that replaces a force-killed agent', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + // 1. A live agent TUI arms any-motion tracking + SGR encoding. + const agentPty = createFakeSubprocess('claude') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + launchAgent: 'claude', + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ % claude\r\nclaude> analyzing...\r\nuser@host ~ $ ') + await waitForEmulatorParse(agent) + + const armed = agent.getSnapshot() + expect(armed?.modes.mouseTracking).toBe(true) + expect(armed?.modes.mouseTrackingMode).toBe('any') + expect(armed?.rehydrateSequences).toContain(ANY_MOTION_TRACKING_ON) + + // 2. Sleep/hibernation takes its final teardown checkpoint while the agent + // is still alive with mouse armed (daemon-pty-adapter's {final,teardown}). + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, armed!) + + // 3. Force-kill. Real Session teardown; the child is reaped without ever + // emitting DECRST, and nothing writes one on its behalf. + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() // TerminalHost.reapSession + expect(killWithDescendantSweepMock).toHaveBeenCalled() + const teardownBytes = agentPty.written.join('') + expect(teardownBytes).not.toContain(ANY_MOTION_TRACKING_OFF) + expect(teardownBytes).not.toContain(SGR_ENCODING_OFF) + + // 4. Wake: the session is still cold-restore eligible (killed with + // keepHistory, so endedAt stays null) and a FRESH shell takes its place, + // seeded with the recovered history exactly as daemon-server does. + const restoreInfo = await reader.detectColdRestore(sessionId) + expect(restoreInfo).not.toBeNull() + const seedChunks = [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: seedChunks + }) + const revived = shell.getSnapshot() + try { + // #12101: the replacement shell's OWN state says mouse tracking is on, + // so every snapshot it serves — reattach, checkpoint, mobile — re-arms it. + // Soft so one run reports both re-arming channels, not just the first. + expect.soft(revived?.modes.mouseTracking).toBe(false) + expect.soft(revived?.modes.mouseTrackingMode).toBe('none') + expect.soft(revived?.rehydrateSequences).toBe('') + // Independent second channel: SerializeAddon's own mode trailer re-emits + // the DECSET from xterm's mouseTrackingMode, so dropping rehydrate alone + // would not disarm the pane. + expect.soft(revived?.snapshotAnsi).not.toContain(ANY_MOTION_TRACKING_ON) + } finally { + shell.dispose() + } + }) + + it('leaves the reattached renderer xterm armed against a dead agent (SGR reports into the prompt)', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + const agentPty = createFakeSubprocess('claude') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + launchAgent: 'claude', + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ $ ') + await waitForEmulatorParse(agent) + + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, agent.getSnapshot()!) + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() + + const restoreInfo = await reader.detectColdRestore(sessionId) + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + }) + const revived = shell.getSnapshot()! + + // Reattach paint into a REAL renderer xterm, using the weakest profile in the + // family — the one a stale agent title used to select here. The renderer now + // forces the fresh-shell reset on a cold restore, so this pins the independent + // half: the seed alone must leave the revived session unarmed, because the + // daemon emulator's own state is what mobile and every other consumer read. + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await new Promise((resolve) => term.write('\x1b[2J\x1b[3J\x1b[H', resolve)) + await new Promise((resolve) => + term.write(revived.rehydrateSequences + revived.snapshotAnsi, resolve) + ) + await new Promise((resolve) => + term.write(POST_REPLAY_LIVE_AGENT_REATTACH_RESET, resolve) + ) + // The observable symptom: a bare `zsh` pane whose xterm reports pointer + // motion, so every mouse move types ^[[<35;col;rowM at the prompt. + expect(term.modes.mouseTrackingMode).toBe('none') + } finally { + term.dispose() + shell.dispose() + } + }) + + it('control: a TUI that DID emit DECRST before exiting restores a clean shell', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + const agentPty = createFakeSubprocess('vim') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ $ ') + await waitForEmulatorParse(agent) + agentPty.emit(`${ANY_MOTION_TRACKING_OFF}${SGR_ENCODING_OFF}`) + for (let i = 0; i < 50 && agent.getSnapshot()?.modes.mouseTracking !== false; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, agent.getSnapshot()!) + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() + + const restoreInfo = await reader.detectColdRestore(sessionId) + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + }) + try { + expect(shell.getSnapshot()?.modes.mouseTracking).toBe(false) + expect(shell.getSnapshot()?.rehydrateSequences).toBe('') + } finally { + shell.dispose() + } + }) +}) diff --git a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts index 8f8256519a4..d42fa33edaf 100644 --- a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts +++ b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { HeadlessEmulator } from './headless-emulator' +// The reattach path the remote onSnapshot uses. +import { POST_REPLAY_REATTACH_RESET } from '../../shared/terminal-mode-reset-profiles' // Repro for #7329: "remote server + terminal" — typing gets escape sequences // injected/wrapped around it and follow-up commands are corrupted. @@ -11,12 +13,6 @@ import { HeadlessEmulator } from './headless-emulator' // This test drives the REAL daemon serializer and a REAL renderer-side xterm to // see what the user's terminal ends up looking like after a subscribe/reattach. -const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' -const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' -const RESET_MOUSE_REPORTING = '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' -// Verbatim from layout-serialization.ts (the reattach path the remote onSnapshot uses). -const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` - function writeXterm(term: Terminal, data: string): Promise { return new Promise((resolve) => term.write(data, resolve)) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7d96484edea..d9c5553d3e8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -26,6 +26,7 @@ import type { TerminalSnapshot } from './types' import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' +import { createPtySlaveEchoProbe } from '../../shared/pty-slave-line-discipline-echo' const SHELL_READY_TIMEOUT_MS = 15_000 // Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex. @@ -53,6 +54,9 @@ export type SubprocessHandle = { /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready * assumption against it so a fallback shell without a ready marker never gates startup commands. */ shellPath?: string + /** Slave device path, so startup replies can read the line discipline's ECHO bit before + * writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */ + slavePath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so a flooding child blocks on write. Optional: @@ -164,11 +168,13 @@ export class Session { } this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) + const echoProbe = createPtySlaveEchoProbe(this.subprocess.slavePath) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.emitSubprocessOutput(emission) + onEmission: (emission) => this.emitSubprocessOutput(emission), + ...(echoProbe ? { echoProbe } : {}) }) this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts new file mode 100644 index 00000000000..7852a7eac89 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' +import type { TerminalModes } from './types' + +const ARMED_MODES: TerminalModes = { + bracketedPaste: false, + mouseTracking: true, + mouseTrackingMode: 'any', + sgrMouseMode: true, + applicationCursor: false, + alternateScreen: false +} + +// Why import rather than restate: the exact bytes are pinned in +// terminal-mode-reset-profiles.test.ts; this suite pins placement within the seed. +const MOUSE_OFF = COLD_RESTORE_SEED_MODE_RESET + +function restoreInfo(overrides: Partial = {}): ColdRestoreInfo { + return { + snapshotAnsi: 'user@host ~ $ \x1b[?1003h', + scrollbackAnsi: 'user@host ~ $ ', + rehydrateSequences: buildRehydrateSequences(ARMED_MODES), + cwd: '/w', + cols: 80, + rows: 24, + modes: ARMED_MODES, + ...overrides + } +} + +describe('getRecoveredHistorySeedSegments', () => { + it('disarms mouse reporting after the snapshot but before the torn escape tail', () => { + const segments = getRecoveredHistorySeedSegments( + restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' }) + ) + expect(segments).toEqual([ + '\x1b[?1003h\x1b[?1006h', + 'user@host ~ $ \x1b[?1003h', + MOUSE_OFF, + '\x1b[3' + ]) + }) + + it('disarms mouse reporting on the alt-screen normal-buffer branch too', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } }) + ) + ).toEqual(['user@host ~ $ ', MOUSE_OFF]) + }) + + it('stays empty when there is no recovered normal buffer', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + modes: { ...ARMED_MODES, alternateScreen: true }, + scrollbackAnsi: '', + snapshotAnsi: '' + }) + ) + ).toEqual([]) + }) + + it('keeps the empty "nothing to recover" sentinel on the normal-screen branch', () => { + // Why: daemon-pty-adapter keys the probe-race kill+respawn and the history + // re-anchor on `length === 0`, so the reset must never be the only segment. + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + modes: { ...ARMED_MODES, mouseTracking: false, mouseTrackingMode: 'none' }, + scrollbackAnsi: '', + snapshotAnsi: '', + rehydrateSequences: '' + }) + ) + ).toEqual([]) + }) + + it('keeps a torn escape last when it is the only recovered data', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + scrollbackAnsi: '', + snapshotAnsi: '', + rehydrateSequences: '', + pendingEscapeTailAnsi: '\x1b[3' + }) + ) + ).toEqual([MOUSE_OFF, '\x1b[3']) + }) + + it('leaves the revived emulator unarmed while preserving scrollback (#12101)', () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + try { + for (const segment of getRecoveredHistorySeedSegments(restoreInfo())) { + expect(emulator.writeSync(segment)).toBe(true) + } + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.mouseTrackingMode).toBe('none') + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).toBe('') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?1003h') + expect(snapshot.snapshotAnsi).toContain('user@host ~ $') + } finally { + emulator.dispose() + } + }) + + it('does not touch the live-session reattach payload (mobile scroll gestures)', () => { + // Why: only recovery seeding knows the arming TUI is dead; live reattach + // snapshots must keep re-arming or an alt-screen TUI loses scroll forever. + expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h') + }) +}) diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 78732e81931..670cef152b8 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -1,13 +1,29 @@ import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' +import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' + +// Why the reset belongs in the seed and not only at replay: the recovered stream +// re-arms mouse reporting from two independent sources (rehydrateSequences AND +// SerializeAddon's own mode trailer inside snapshotAnsi), and the seed is what +// feeds the daemon's emulator — so without it every downstream consumer that +// re-serializes from that emulator, including mobile, inherits the dead TUI's +// modes no matter which profile the desktop renderer applies. See +// COLD_RESTORE_SEED_MODE_RESET for the precondition and the choice of bits. export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] { if (restoreInfo.modes.alternateScreen) { const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi - return normalBuffer ? [normalBuffer] : [] + return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : [] } - return [ - restoreInfo.rehydrateSequences, - restoreInfo.snapshotAnsi, - ...(restoreInfo.pendingEscapeTailAnsi ? [restoreInfo.pendingEscapeTailAnsi] : []) - ].filter((segment) => segment.length > 0) + const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter( + (segment) => segment.length > 0 + ) + const escapeTail = restoreInfo.pendingEscapeTailAnsi + // Why: an empty list is daemon-pty-adapter's "nothing to recover" sentinel (it gates + // the probe-race respawn and the history re-anchor), so the reset must never pad it. + if (recovered.length === 0 && !escapeTail) { + return [] + } + // Why after the snapshot: it must undo the snapshot's own mode trailer, and + // pendingEscapeTailAnsi is a torn escape that has to stay at the very end. + return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] } diff --git a/src/main/daemon/terminal-snapshot-color-parity.test.ts b/src/main/daemon/terminal-snapshot-color-parity.test.ts new file mode 100644 index 00000000000..576a20db431 --- /dev/null +++ b/src/main/daemon/terminal-snapshot-color-parity.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + normalBufferStylesTrimmed, + visibleRowStyles, + visibleRowWraps, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +type ParityTerminal = ReturnType + +async function restoreSerializedTerminal(source: ParityTerminal): Promise { + const snapshot = buildParityMainBufferSnapshot(source, 1, { scrollbackRows: 5000 }) + const restored = createRendererParityTerminal({ cols: snapshot.cols, rows: snapshot.rows }) + const preamble = + snapshot.alternateScreen && snapshot.scrollbackAnsi !== undefined + ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + : snapshot.alternateScreen + ? SNAPSHOT_REPLAY_PREAMBLE_ALT + : SNAPSHOT_REPLAY_PREAMBLE_NORMAL + await writeChunksToTerminal(restored.terminal, [ + preamble, + snapshot.data, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + return restored +} + +function expectActiveBufferParity(source: ParityTerminal, restored: ParityTerminal): void { + expect(visibleRows(restored.terminal)).toEqual(visibleRows(source.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(source.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(source.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(source.terminal)) +} + +describe('terminal snapshot color parity', () => { + it('preserves Codex truecolor backgrounds, BCE rows, and default trailing cells', async () => { + const source = createRendererParityTerminal({ cols: 24, rows: 6 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + '\x1b[48;2;33;58;43m\x1b[2K\x1b[0m', + '\x1b[2;1H\x1b[48;2;74;34;29mremoved\x1b[0m', + '\x1b[3;1H\x1b[48;2;65;69;76m> prompt\x1b[0m', + '\x1b[4;1H\x1b[48;2;33;58;43madded\x1b[K\x1b[0m' + ]) + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const buffer = restored.terminal.buffer.active + expect(buffer.getLine(0)?.getCell(0)?.getBgColor()).toBe(0x213a2b) + expect(buffer.getLine(0)?.getCell(23)?.getBgColor()).toBe(0x213a2b) + expect(buffer.getLine(1)?.getCell(6)?.getBgColor()).toBe(0x4a221d) + expect(buffer.getLine(1)?.getCell(7)?.getBgColor()).toBe(-1) + expect(buffer.getLine(2)?.getCell(0)?.getBgColor()).toBe(0x41454c) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves styled scrollback in the normal buffer', async () => { + const source = createRendererParityTerminal({ cols: 18, rows: 3 }) + let restored: ParityTerminal | undefined + try { + const lines = Array.from( + { length: 7 }, + (_, index) => + `\x1b[48;2;${33 + index};${58 + index};${43 + index}mline-${index}\x1b[0m${index < 6 ? '\r\n' : ''}` + ) + await writeChunksToTerminal(source.terminal, lines) + restored = await restoreSerializedTerminal(source) + + expect(source.terminal.buffer.normal.baseY).toBeGreaterThan(0) + expectActiveBufferParity(source, restored) + expect(normalBufferRowsTrimmed(restored.terminal)).toEqual( + normalBufferRowsTrimmed(source.terminal) + ) + expect(normalBufferStylesTrimmed(restored.terminal)).toEqual( + normalBufferStylesTrimmed(source.terminal) + ) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves inverse null backgrounds without materializing wide-wrap padding', async () => { + const source = createRendererParityTerminal({ cols: 10, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [`\x1b[7m${'A'.repeat(9)}你\x1b[0m`]) + const sourcePadding = source.terminal.buffer.active.getLine(0)?.getCell(9) + expect(sourcePadding?.getChars()).toBe('') + expect(sourcePadding?.isInverse()).toBeTruthy() + expect(source.terminal.buffer.active.getLine(1)?.isWrapped).toBe(true) + + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const restoredPadding = restored.terminal.buffer.active.getLine(0)?.getCell(9) + expect(restoredPadding?.getChars()).toBe('') + expect(restoredPadding?.isInverse()).toBeTruthy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('materializes final-column nulls that do not match the next wrapped glyph', async () => { + const source = createRendererParityTerminal({ cols: 10, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, ['123456789你\x1b[1;9H\x1b[7m界\x1b[1;9HX']) + const sourceNull = source.terminal.buffer.active.getLine(0)?.getCell(9) + const nextGlyph = source.terminal.buffer.active.getLine(1)?.getCell(0) + expect(sourceNull?.getChars()).toBe('') + expect(sourceNull?.isInverse()).toBeTruthy() + expect(nextGlyph?.getWidth()).toBe(2) + expect(nextGlyph?.isInverse()).toBeFalsy() + + restored = await restoreSerializedTerminal(source) + + expect(visibleRows(restored.terminal).map((row) => row.trimEnd())).toEqual( + visibleRows(source.terminal).map((row) => row.trimEnd()) + ) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(source.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(source.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(source.terminal)) + const restoredBlank = restored.terminal.buffer.active.getLine(0)?.getCell(9) + expect(restoredBlank?.getChars()).toBe(' ') + expect(restoredBlank?.isInverse()).toBeTruthy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('suppresses null-cell decorations when materializing an inverse background', async () => { + const source = createRendererParityTerminal({ cols: 40, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + `\x1b[1;30H你\x1b[H\x1b[7;4;9;53m${'A'.repeat(30)}\x1b[0m\x1b[CZ` + ]) + const sourceNull = source.terminal.buffer.active.getLine(0)?.getCell(30) + expect(sourceNull?.getChars()).toBe('') + expect(sourceNull?.isInverse()).toBeTruthy() + expect(sourceNull?.isUnderline()).toBeTruthy() + expect(sourceNull?.isStrikethrough()).toBeTruthy() + expect(sourceNull?.isOverline()).toBeTruthy() + + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const restoredBlank = restored.terminal.buffer.active.getLine(0)?.getCell(30) + expect(restoredBlank?.getChars()).toBe(' ') + expect(restoredBlank?.isInverse()).toBeTruthy() + expect(restoredBlank?.isUnderline()).toBeFalsy() + expect(restoredBlank?.isStrikethrough()).toBeFalsy() + expect(restoredBlank?.isOverline()).toBeFalsy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves the normal buffer and colored alternate-screen frame', async () => { + const source = createRendererParityTerminal({ cols: 24, rows: 5 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + '\x1b[48;2;74;34;29mshell history\x1b[0m\r\nsecond line', + '\x1b[?1049h\x1b[2J\x1b[H', + '\x1b[48;2;65;69;76m\x1b[2K\x1b[0m', + '\x1b[2;1H\x1b[48;2;33;58;43mworking\x1b[K\x1b[0m' + ]) + restored = await restoreSerializedTerminal(source) + + expect(source.terminal.buffer.active.type).toBe('alternate') + expect(restored.terminal.buffer.active.type).toBe('alternate') + expectActiveBufferParity(source, restored) + expect(normalBufferRowsTrimmed(restored.terminal)).toEqual( + normalBufferRowsTrimmed(source.terminal) + ) + expect(normalBufferStylesTrimmed(restored.terminal)).toEqual( + normalBufferStylesTrimmed(source.terminal) + ) + expect(restored.terminal.buffer.active.getLine(1)?.getCell(23)?.getBgColor()).toBe(0x213a2b) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) +}) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 24772ebee4b..07472e5c096 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -38,7 +38,7 @@ import { import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { sliceCheckLogTail } from './check-job-log-tail-slice' +import { sliceCheckLogTail } from '../../shared/check-job-log-tail-slice' import { classifyPRRefreshError, safePRRefreshErrorMessage diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index de8d36494d8..c3c4de25a3f 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -1090,6 +1090,32 @@ describe('gitlab client — MR operations', () => { ) }) + // #7732: a job canceled before it started 404s; the Checks panel must show its + // benign empty state, not the issue-edit copy classifyGlabError would produce. + it('reports a 404 trace as an empty log rather than an error', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404 Not Found')) + + await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: true, + trace: '' + }) + }) + + it('keeps a missing project and a scope failure as errors, in job-log wording', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Project Not Found')) + await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: false, + error: "Could not find this job's GitLab project." + }) + + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 insufficient_scope')) + const forbidden = await getJobTrace('/repo', 99, 'upstream', 'conn-1') + expect(forbidden).toEqual({ + ok: false, + error: "You don't have permission to read this job's log. Check your GitLab token scopes." + }) + }) + it('retries a job through the selected SSH GitLab host', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 2ff7f618488..74815729ebc 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -25,7 +25,9 @@ import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } import { acquire, classifyGlabError, + classifyJobLogError, classifyListIssuesError, + isMissingJobLogError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, @@ -1236,7 +1238,12 @@ export async function getJobTrace( return { ok: true, trace: stdout } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } + // A job with no log is an empty log, not a failure: surfacing the 404 would + // pin an error on the Checks row for a job canceled before it started. + if (isMissingJobLogError(msg)) { + return { ok: true, trace: '' } + } + return { ok: false, error: classifyJobLogError(msg).message } } finally { release() } diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index cc4ecf5fad2..577baf9ad67 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,4 +1,5 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' @@ -8,8 +9,10 @@ export type LocalGitExecOptions = { } const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 +const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128 const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext: CoalescedProbes = new Map() +const unauthenticatedHostExpiries = new Map() function knownHostsExecutionKey( connectionId?: string | null, @@ -26,6 +29,63 @@ function knownHostsExecutionKey( export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() knownHostsInFlightByExecutionContext.clear() + unauthenticatedHostExpiries.clear() +} + +/** @internal - exposed for tests only */ +export function _resetGlabUnauthenticatedHosts(): void { + unauthenticatedHostExpiries.clear() +} + +function unauthenticatedHostKey( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return `${knownHostsExecutionKey(connectionId, localGitOptions)}\0${normalizeGitLabHost(host)}` +} + +/** + * Why: `glab auth status --hostname` is how a self-hosted instance that plain + * `glab auth status` did not list gets discovered, so a remote that is not + * GitLab at all runs it too. Project-ref negatives expire now, and without this + * that becomes one `glab` spawn per repo per interval on the hosted-review poll. + * The answer is per host, not per repo, and expires on the same clock so a + * login still lands within an interval. + */ +export function isGlabHostKnownUnauthenticated( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): boolean { + const key = unauthenticatedHostKey(host, connectionId, localGitOptions) + const expiresAt = unauthenticatedHostExpiries.get(key) + if (expiresAt === undefined) { + return false + } + if (expiresAt > Date.now()) { + return true + } + unauthenticatedHostExpiries.delete(key) + return false +} + +export function rememberGlabHostUnauthenticated( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): void { + unauthenticatedHostExpiries.set( + unauthenticatedHostKey(host, connectionId, localGitOptions), + Date.now() + NEGATIVE_ENTRY_TTL_MS + ) + while (unauthenticatedHostExpiries.size > UNAUTHENTICATED_HOSTS_MAX_ENTRIES) { + const oldestKey = unauthenticatedHostExpiries.keys().next().value + if (oldestKey === undefined) { + return + } + unauthenticatedHostExpiries.delete(oldestKey) + } } export function rememberGlabKnownHost( @@ -52,6 +112,9 @@ export function rememberGlabKnownHosts( } seen.add(normalizedHost) additions.push(normalizedHost) + unauthenticatedHostExpiries.delete( + unauthenticatedHostKey(normalizedHost, connectionId, localGitOptions) + ) } if (additions.length === 0) { return diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 4b33a4c438f..ee7203ce668 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -1,9 +1,14 @@ import { glabExecFileAsync } from '../git/runner' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import type { IssueSourcePreference } from '../../shared/types' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { + _resetGlabUnauthenticatedHosts, + isGlabHostKnownUnauthenticated, parseGlabAuthStatusHosts, + rememberGlabHostUnauthenticated, rememberGlabKnownHost, type LocalGitExecOptions } from './gitlab-known-host-probe' @@ -25,12 +30,16 @@ export { export type { LocalGitExecOptions } from './gitlab-known-host-probe' const PROJECT_REF_CACHE_MAX_ENTRIES = 512 -const projectRefCache = new Map() + +type CachedProjectRef = { value: ProjectRef | null; expiresAt: number } + +const projectRefCache = new Map() /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { projectRefCache.clear() clearProjectRefInFlight() + _resetGlabUnauthenticatedHosts() } /** @internal - exposed for tests only */ @@ -39,7 +48,14 @@ export function _getProjectRefCacheSize(): number { } function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { - projectRefCache.set(cacheKey, value) + // Why: "not GitLab" only holds until someone configures `origin` or logs into + // `glab` — a repo probed before either kept hosted-review detection stale for + // the life of the process. Negatives expire the way every other forge's do; + // positives still stay (see `createRemoteRefProbeCache`). + projectRefCache.set(cacheKey, { + value, + expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY + }) while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { const oldestKey = projectRefCache.keys().next().value if (oldestKey === undefined) { @@ -56,19 +72,30 @@ export async function getProjectRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + // Why: a reconnect replaces the host an answer came from under the same id, so + // the generation is part of the signature; `knownHosts` carries the glab auth + // state, so logging into a self-hosted instance re-asks rather than reusing a + // ref resolved while that host was unknown. + const runtimeKey = connectionId + ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` + : `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` - if (projectRefCache.has(cacheKey)) { - return projectRefCache.get(cacheKey)! + const cached = projectRefCache.get(cacheKey) + if (cached) { + if (cached.expiresAt > Date.now()) { + return cached.value + } + projectRefCache.delete(cacheKey) } - return runProjectRefProbeOnce(cacheKey, () => + return runProjectRefProbeOnce(cacheKey, (ownsKey) => resolveProjectRefForRemote( repoPath, remoteName, knownHosts, connectionId, cacheKey, + ownsKey, localGitOptions ) ) @@ -80,8 +107,17 @@ async function resolveProjectRefForRemote( knownHosts: readonly string[], connectionId: string | null | undefined, cacheKey: string, + ownsKey: () => boolean, localGitOptions: LocalGitExecOptions ): Promise { + // Why: a probe abandoned as stale still runs, and the repo state it read is + // older than whatever its successor already published. It may answer its own + // callers; it may not overwrite the cache. + const publish = (value: ProjectRef | null): void => { + if (ownsKey()) { + rememberProjectRefCacheEntry(cacheKey, value) + } + } try { const stdout = await readRemoteUrl( { @@ -96,7 +132,7 @@ async function resolveProjectRefForRemote( } const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { - rememberProjectRefCacheEntry(cacheKey, result) + publish(result) return result } const remoteCandidate = parseRemoteProjectRefCandidate(stdout) @@ -110,17 +146,20 @@ async function resolveProjectRefForRemote( )) ) { rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) - rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + publish(remoteCandidate) return remoteCandidate } } catch (error) { // Why: a wedged or killed probe is not evidence the remote is not GitLab — - // caching it would misdetect the forge for the life of the process (P1-D). + // caching it would misdetect the forge until the negative expires (P1-D). + // SSH failures stay uncached outright rather than adopting the generic + // cache's stable-missing-remote exception: keeping a connected host's + // detection fresh is worth the extra probe. if (connectionId || isTransientGitProbeError(error)) { return null } } - rememberProjectRefCacheEntry(cacheKey, null) + publish(null) return null } @@ -229,12 +268,22 @@ async function isGlabConfiguredForRemoteHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + // Why: this probe is per host, but the project-ref miss that reaches it is per + // repo — without the memo, every non-GitLab repo re-spawns `glab` each time + // its negative expires. + if (isGlabHostKnownUnauthenticated(projectRef.host, connectionId, localGitOptions)) { + return false + } try { const result = await glabExecFileAsync( ['auth', 'status', '--hostname', projectRef.host], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - return result !== undefined + if (result === undefined) { + rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions) + return false + } + return true } catch (error) { const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } const output = @@ -242,6 +291,10 @@ async function isGlabConfiguredForRemoteHost( .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .join('\n') || String(error) const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) - return hosts.includes(normalizeGitLabHost(projectRef.host)) + if (hosts.includes(normalizeGitLabHost(projectRef.host))) { + return true + } + rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions) + return false } } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 23e159671c6..c2396c9aa7d 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -16,8 +16,10 @@ import { _resetKnownHostsCache, _resetProjectRefCache, classifyGlabError, + classifyJobLogError, classifyListIssuesError, getIssueProjectRef, + isMissingJobLogError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, @@ -28,16 +30,19 @@ import { import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + glabExecFileAsyncMock.mockReset() sshExecMock.mockReset() unregisterSshGitProvider('conn-1') _resetProjectRefCache() }) afterEach(() => { + vi.useRealTimers() unregisterSshGitProvider('conn-1') }) @@ -211,6 +216,110 @@ describe('gitlab project ref resolution', () => { path: 'remote/orca' }) }) + + it('does not cache a local probe killed on its deadline as a definitive miss', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('git timed out.')) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toBeNull() + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('re-probes a repo whose GitLab remote could have been added since the miss', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error("error: No such remote 'origin'")) + + await expect(getProjectRef('/repo')).resolves.toBeNull() + await expect(getProjectRef('/repo')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + + // Nothing watches `.git/config`, and SSH/WSL repos have no file to watch, so + // a remote configured after the miss is only visible once the negative ages out. + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a resolved project ref past the negative interval', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS * 10) + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('re-resolves a self-hosted remote once glab auth knows its host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.internal:team/orca.git\n' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) + + await expect(getProjectRefForRemote('/repo', 'origin', ['gitlab.com'])).resolves.toBeNull() + await expect( + getProjectRefForRemote('/repo', 'origin', ['gitlab.com', 'gitlab.internal']) + ).resolves.toEqual({ host: 'gitlab.internal', path: 'team/orca' }) + }) + + it('asks glab about an unauthenticated host once per interval, not once per repo', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) + + // Expiring project-ref negatives must not turn the hosted-review poll into a + // `glab auth status` spawn per repo per interval — the answer is per host. + for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { + await expect(getProjectRef(repoPath)).resolves.toBeNull() + } + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { + await expect(getProjectRef(repoPath)).resolves.toBeNull() + } + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not serve a project ref resolved on a retired SSH connection', async () => { + sshExecMock + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:before/orca.git\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:after/orca.git\n', stderr: '' }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'before/orca' + }) + + // A reconnect can swap the execution host under the same connection id. + unregisterSshGitProvider('conn-1') + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'after/orca' + }) + expect(sshExecMock).toHaveBeenCalledTimes(2) + }) }) describe('resolveIssueSource', () => { @@ -314,6 +423,18 @@ describe('glab error classification', () => { expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i) expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.') }) + + it('rewrites issue-edit copy for job logs via classifyJobLogError', () => { + expect(classifyJobLogError('HTTP 403').message).toMatch(/permission to read this job's log/i) + expect(classifyJobLogError('HTTP 403').message).not.toMatch(/issue/i) + expect(classifyJobLogError('boom').message).toBe('Failed to load the job log: boom') + }) + + it('treats a 404 job log as missing, but keeps a missing project an error', () => { + expect(isMissingJobLogError('HTTP 404 Not Found')).toBe(true) + expect(isMissingJobLogError('HTTP 404: Project Not Found')).toBe(false) + expect(isMissingJobLogError('HTTP 403 Forbidden')).toBe(false) + }) }) describe('glab auth status host parsing', () => { diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index a8a7b5705a0..6531793dac6 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -2,7 +2,12 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' export { glabExecFileAsync, gitExecFileAsync } -export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' +export { + classifyGlabError, + classifyJobLogError, + classifyListIssuesError, + isMissingJobLogError +} from './glab-error-classification' export { DEFAULT_GITLAB_HOSTS, _getProjectRefCacheSize, diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts index e1f740749e0..d769bdd108c 100644 --- a/src/main/gitlab/glab-error-classification.ts +++ b/src/main/gitlab/glab-error-classification.ts @@ -50,3 +50,38 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { } return { type: c.type, message: readMessages[c.type] } } + +// Why: a job trace is a read on a pipeline job, so classifyGlabError's issue-edit +// copy ("permission to edit this issue") would land verbatim on a Checks row. +export function classifyJobLogError(stderr: string): ClassifiedError { + const c = classifyGlabError(stderr) + const trimmed = stderr.trim() + const logMessages: Record = { + permission_denied: + "You don't have permission to read this job's log. Check your GitLab token scopes.", + // A missing log is already reported as an empty log, so a 404 that reaches here + // means the project itself is gone or invisible to this token. + not_found: "Could not find this job's GitLab project.", + issues_disabled: `Failed to load the job log: ${trimmed}`, + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load the job log: ${trimmed}` + } + return { type: c.type, message: logMessages[c.type] } +} + +/** + * Whether a failed trace fetch means "this job has no log", not "the fetch broke". + * + * GitLab 404s the trace endpoint for a job canceled before it started and for a log + * that was erased or expired. A missing *project* is a real failure, and GitLab also + * masks unauthorized projects as 404, so keep that one an error. + * + * Deliberately broad: GitLab returns the same bare 404 for an unknown job id, so a + * stale/foreign id reads as an empty log rather than an error. Losing that diagnostic + * beats pinning "not found" on every job that was canceled before it produced a log. + */ +export function isMissingJobLogError(stderr: string): boolean { + return classifyGlabError(stderr).type === 'not_found' && !/project\s+not\s+found/i.test(stderr) +} diff --git a/src/main/gitlab/project-ref-inflight.ts b/src/main/gitlab/project-ref-inflight.ts index a9de9d75797..cea4173c48f 100644 --- a/src/main/gitlab/project-ref-inflight.ts +++ b/src/main/gitlab/project-ref-inflight.ts @@ -9,7 +9,7 @@ export function clearProjectRefInFlight(): void { export async function runProjectRefProbeOnce( cacheKey: string, - createProbe: () => Promise + createProbe: (ownsKey: () => boolean) => Promise ): Promise { // Why: joining only a probe that is still young keeps a wedged host's dead // promise from pinning every later retry for the process lifetime (P1-D). diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index 79827207aba..5ca5903d66f 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -1,3 +1,8 @@ +import type * as NodeChildProcess from 'node:child_process' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + import type { Repo } from '../shared/types' import { describe, expect, it, vi } from 'vitest' @@ -94,7 +99,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, 'C:\\repo\\feature', - 'pnpm install', + '#!/usr/bin/env bash\npnpm install', undefined, { family: 'posix' } ) @@ -186,6 +191,55 @@ describe('createSetupRunnerScript', () => { expect(runner).toContain('call echo hello!world!') }) + it('refuses to run a shebang script as a batch command', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + // Regression: a POSIX script reaching the cmd runner (no Git Bash terminal, or an SSH + // Windows host) must not have its interpreter-agnostic prefix executed under cmd — + // `pnpm install` would run and only a later bash-only line would fail, leaving the + // worktree half set up. + const runner = buildWindowsRunnerScript('#!/usr/bin/env bash\npnpm install\nsource .env') + + expect(runner).not.toContain('call pnpm install') + expect(runner).not.toContain('call source .env') + expect(runner).toContain('needs a POSIX shell') + expect(runner).toContain('exit /b 1') + }) + + it('keeps a `#` comment that is not an interpreter line', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + const runner = buildWindowsRunnerScript('# not a shebang\nrem still batch') + + expect(runner).toContain('call # not a shebang') + expect(runner).toContain('call rem still batch') + }) + + it.skipIf(process.platform === 'win32')( + 'runs a script whose shebang carries invocation-only bash flags', + async () => { + // Regression: `set` rejects `-l`/`-s`/`-i` with exit 2, and the runner's own `set -e` turns + // that into an aborted setup before the first user line. + const { buildPosixRunnerScript } = await import('./hooks') + const { mkdtempSync, rmSync, writeFileSync } = await vi.importActual('node:fs') + const { execFileSync } = await vi.importActual('node:child_process') + + const dir = mkdtempSync(join(tmpdir(), 'orca-posix-runner-')) + try { + const runnerPath = join(dir, 'setup-runner.sh') + writeFileSync( + runnerPath, + buildPosixRunnerScript('#!/bin/bash -l\necho SETUP_RAN\n'), + 'utf-8' + ) + + expect(execFileSync('bash', [runnerPath], { encoding: 'utf-8' })).toContain('SETUP_RAN') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => { const originalPlatform = process.platform diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index b51a38e3321..c39ea86cf2f 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1255,7 +1255,7 @@ describe('createSetupRunnerScript', () => { } }) as unknown as Repo - it('writes POSIX setup runners for Git Bash on native Windows paths', async () => { + it('writes POSIX setup runners for shebang-declared scripts on native Windows paths', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') const fs = await import('node:fs') @@ -1271,7 +1271,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( makeRepo(), 'C:\\repo-worktree', - 'pnpm install\r\nnpm run build', + '#!/usr/bin/env bash\r\npnpm install\r\nnpm run build', undefined, { family: 'posix' } ) @@ -1295,6 +1295,108 @@ describe('createSetupRunnerScript', () => { } }) + it('keeps batch setup scripts on cmd.exe when the terminal is Git Bash', async () => { + // Regression (#6967): a Git Bash terminal preference used to hand pre-existing + // batch setup scripts to bash, where `copy`/`xcopy`/`if errorlevel` do not exist. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'copy .env.example .env\r\nxcopy /E assets dist', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call copy .env.example .env\r\n'), + 'utf-8' + ) + expect(result).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + // Why: `shell` is the pane that types the launch command — still Git Bash here. The + // runner's own .cmd extension is what says the file is batch. + shell: { family: 'posix' } + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('hands a Git Bash pane a launch command MSYS cannot rewrite for a cmd runner', async () => { + // Regression (#6896): `cmd.exe /c "C:\...\setup-runner.cmd"` typed into Git Bash has its + // `/c` switch rewritten into a drive path, so cmd opens interactively and setup never runs. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const { buildSetupRunnerCommand } = await import('../shared/setup-runner-command') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'copy .env.example .env', + undefined, + { family: 'posix' } + ) + + const command = buildSetupRunnerCommand(result.runnerScriptPath, 'windows', result.shell) + + expect(command).not.toContain('cmd.exe /c') + // Why: the batch runner must never be handed to bash either. + expect(command).not.toContain('bash ') + expect(command).toContain('powershell.exe') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('replays interpreter flags declared on the shebang line', async () => { + // Regression: the runner is launched as `bash `, so `-euo pipefail` on the script's + // own `#!` line never reaches the interpreter unless the runner re-applies it. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + '#!/usr/bin/env -S bash -euo pipefail\nmake build | tee build.log', + undefined, + { family: 'posix' } + ) + + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\nset -euo pipefail\nmake build | tee build.log\n', + 'utf-8' + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('preserves cmd.exe setup runner semantics for configured cmd users', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') @@ -1409,7 +1511,7 @@ describe('createIssueCommandRunnerScript', () => { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } }) as unknown as Repo - it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => { + it('writes a POSIX issue-command runner when a shebang declares bash and setup resolves to Git Bash', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n') const fs = await import('node:fs') @@ -1418,6 +1520,37 @@ describe('createIssueCommandRunnerScript', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + '#!/usr/bin/env bash\ngh issue view 42', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'posix' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps a plain issue command on the cmd runner under a Git Bash terminal', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { const { createIssueCommandRunnerScript } = await import('./hooks') const result = createIssueCommandRunnerScript( @@ -1429,14 +1562,10 @@ describe('createIssueCommandRunnerScript', () => { ) expect(gitExecFileSyncMock).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + ['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'], { cwd: 'C:\\repo-worktree' } ) - expect(writeFileSyncMock).toHaveBeenCalledWith( - 'C:\\repo\\.git\\orca\\issue-command-runner.sh', - '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', - 'utf-8' - ) + // Why: the runner file is batch (.cmd) while the pane that launches it is still Git Bash. expect(result.shell).toEqual({ family: 'posix' }) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ad73003778f..21b56b8a597 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -9,6 +9,12 @@ import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-star import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' import { parseOrcaYaml } from '../shared/orca-yaml' import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command' +import { + isShebangLine, + parseSetupScriptShebang, + scriptDeclaresPosixShell, + stripLeadingShebangLine +} from '../shared/setup-script-shebang' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync, promptGuardShellEnv } from './git/runner' @@ -397,13 +403,32 @@ function getHookWslContext( } } +// Why: cmd cannot run a POSIX script, and executing its interpreter-agnostic prefix (`pnpm +// install`, `git submodule update`) before dying on the first bash-only line is worse than not +// starting: half-applied setup looks like a working worktree. +const WINDOWS_RUNNER_SHEBANG_REFUSAL = [ + 'echo Orca setup: this script starts with a "#!" interpreter line, so it needs a POSIX shell. 1>&2', + 'echo Orca setup: this worktree runs setup through cmd.exe, which cannot execute it. 1>&2', + 'echo Orca setup: set the Windows terminal shell to Git Bash, or rewrite the script in cmd syntax. 1>&2', + 'exit /b 1', + '' +].join('\r\n') + export function buildWindowsRunnerScript(script: string): string { // Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an // inherited delayed-expansion state — without this, every `!` in a user setup line is eaten. - let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' + const header = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' + let runnerScript = header + let isFirstLine = true for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() + if (isFirstLine) { + isFirstLine = false + if (isShebangLine(command)) { + return `${header}${WINDOWS_RUNNER_SHEBANG_REFUSAL}` + } + } if (!command) { runnerScript += '\r\n' continue @@ -462,7 +487,15 @@ export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record< } export function buildPosixRunnerScript(script: string): string { - return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n` + // Why: the runner is always launched as `bash `, so flags on the script's own `#!` line + // are never seen by the interpreter — replay them through `set` or a script that asked for + // `-euo pipefail` silently loses pipefail, and drop the now-duplicate interpreter line. + const shebang = parseSetupScriptShebang(script) + const declaredOptions = shebang?.shellOptions.length + ? `set ${shebang.shellOptions.join(' ')}\n` + : '' + const body = shebang ? stripLeadingShebangLine(script) : script + return `#!/usr/bin/env bash\nset -e\n${declaredOptions}${normalizeCrlfScriptLineEndings(body)}\n` } function normalizeCrlfScriptLineEndings(script: string): string { @@ -500,8 +533,8 @@ export function createIssueCommandRunnerScript( script: command, runnerBaseName: 'issue-command-runner', runtimeTarget: getHookRuntimeTarget(projectRuntime), - // Why: issue commands run in the same terminal as setup, so a Git Bash setup - // runner must not be paired with a cmd issue runner in one session. + // Why: issue commands share the setup session's shell resolution; the runner still + // needs its own `#!` line to be treated as bash. setupShell }) } @@ -528,11 +561,21 @@ function createWorktreeRunnerScript(args: { // Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree + // Why: the terminal-shell preference says nothing about the language a project's script is + // written in, and every pre-existing Windows script was authored against the cmd runner. Only a + // `#!` line opts a script into bash, so the same orca.yaml runs identically for every Windows + // user of the repo instead of following whichever terminal each of them happens to prefer. const runnerShell: SetupRunnerShell = nativeWindowsWorktree - ? (setupShell ?? { family: 'cmd' }) + ? setupShell?.family === 'posix' && scriptDeclaresPosixShell(script) + ? setupShell + : { family: 'cmd' } : { family: 'posix' } + // Why: `shell` tells the launcher which shell types the command, not which format the runner + // file is in — the .cmd/.sh extension already carries that. Reporting the runner family here + // would make a Git Bash pane receive `cmd.exe /c ...`, whose `/c` MSYS rewrites into a drive + // path (issue #6896), so setup would open an interactive cmd and never run. const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree - ? runnerShell + ? (setupShell ?? { family: 'cmd' }) : process.platform === 'win32' && runtimeTarget?.wslDistro ? { family: 'posix', executable: 'wsl.exe' } : undefined @@ -583,8 +626,7 @@ function createWorktreeRunnerScript(args: { envVars, // Why: WSL git returns /mnt paths that Node converts back to C:\ for file // writes; retain the runtime signal so launch converts them to /mnt again. - // Issue-command runners take the same resolved shell, so one session never - // mixes a bash setup runner with a cmd issue runner. + // On native Windows it is the terminal's family, i.e. the shell that types the command. ...(launchShell ? { shell: launchShell } : {}), ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) } @@ -615,9 +657,9 @@ export function resolveSetupRunnerShell( options.resolveGitBashShellPath ?? ((shell: string) => resolveWindowsGitBashShellPath(shell, { platform })) if (resolveGitBashShellPath(configuredShell)) { - // Note: Git Bash users with batch-syntax orca.yaml setup content get a bash - // interpreter from here on. The flip is intentional and documented in - // docs/reference/windows-setup-shell.md. + // Note: this reports the terminal's family — the shell that types the launch command, and + // therefore also that a bash runner *could* launch here. Whether one is written is decided + // per script by its `#!` line — see docs/reference/windows-setup-shell.md. return { family: 'posix' } } } diff --git a/src/main/index.ts b/src/main/index.ts index aa3c2b7d493..39fc076be0a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -1406,6 +1406,7 @@ function openMainWindow(): BrowserWindow { providerSession, providerSessionOnly, promptInteractionKey, + restoredUnconfirmed, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1442,6 +1443,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) @@ -2895,6 +2897,9 @@ void app.whenReady().then(async () => { provisionRelay: (context, params) => relayService.provisionRelay(context, params) }) relayService.start() + // Why: sleeping past relay-token expiry kills the broker with no retry + // timer; resume is the moment that state becomes recoverable. + powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) } catch (error) { console.warn( '[relay] Desktop relay startup unavailable:', diff --git a/src/main/ipc/ai-vault-host-discovery.ts b/src/main/ipc/ai-vault-host-discovery.ts new file mode 100644 index 00000000000..e8f1f51fb23 --- /dev/null +++ b/src/main/ipc/ai-vault-host-discovery.ts @@ -0,0 +1,29 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' + +export type AiVaultHostDiscoveryResult = { + hostInfos: readonly T[] + issue?: AiVaultListResult +} + +/** + * Why: enumerating runtime/SSH hosts reads live session state and can throw. In + * 'all' scope that rejection would take down the whole Promise.all and drop the + * local sessions too, so a broken enumerator degrades to one issue row instead. + */ +export function discoverAiVaultHosts( + enumerate: () => readonly T[], + args: { path: string; fallbackMessage: string } +): AiVaultHostDiscoveryResult { + try { + return { hostInfos: enumerate() } + } catch (error) { + return { + hostInfos: [], + issue: aiVaultScanIssueResult({ + path: args.path, + message: error instanceof Error ? error.message : args.fallbackMessage + }) + } + } +} diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts new file mode 100644 index 00000000000..9911e81e98b --- /dev/null +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -0,0 +1,78 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { + aiVaultSessionDepthCovers, + truncateAiVaultListResult, + type AiVaultSessionDepth +} from '../../shared/ai-vault-session-depth' + +export const AI_VAULT_CACHE_TTL_MS = 15_000 + +type CachedHostLeg = { + depth: AiVaultSessionDepth + result: AiVaultListResult + expiresAt: number +} + +// Why: the merged result is only cacheable when every host answered, so one +// flaky host used to force a full rescan of every healthy host on each panel +// open. Legs are cached individually and the failing host is the only one +// rescanned. +const cachedHostLegs = new Map() + +/** Serves one host's leg from the per-host TTL cache, and stores it only when + * that host answered without a host issue — a failing host is retried on the + * next open while its healthy neighbours stay cached. */ +export async function scanHostLegWithCache(args: { + cacheKey: string + depth: AiVaultSessionDepth + scopePaths?: readonly string[] + force: boolean + scan: () => Promise +}): Promise { + const now = Date.now() + const cached = cachedHostLegs.get(args.cacheKey) + if ( + !args.force && + cached && + cached.expiresAt > now && + aiVaultSessionDepthCovers(cached.depth, args.depth) + ) { + return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) + } + const result = await args.scan() + if (result.issues.some((issue) => issue.kind === 'host')) { + const current = cachedHostLegs.get(args.cacheKey) + if (!current || current.expiresAt <= Date.now()) { + cachedHostLegs.delete(args.cacheKey) + } + return result + } + pruneExpiredHostLegs(now) + const current = cachedHostLegs.get(args.cacheKey) + if ( + !args.force && + current && + current.expiresAt > Date.now() && + aiVaultSessionDepthCovers(current.depth, args.depth) + ) { + return result + } + cachedHostLegs.set(args.cacheKey, { + depth: args.depth, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + }) + return result +} + +function pruneExpiredHostLegs(now: number): void { + for (const [cacheKey, entry] of cachedHostLegs) { + if (entry.expiresAt <= now) { + cachedHostLegs.delete(cacheKey) + } + } +} + +export function resetAiVaultHostLegCacheForTests(): void { + cachedHostLegs.clear() +} diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts new file mode 100644 index 00000000000..f9432f143a8 --- /dev/null +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -0,0 +1,95 @@ +import { + isAiVaultScanCancelledError, + type AiVaultListArgs, + type AiVaultListResult +} from '../../shared/ai-vault-types' +import { + abandonRemoteSessionScanOnCancel, + throwIfAiVaultScanCancelled +} from '../ai-vault/ai-vault-scan-cancellation' +import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' + +export type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + +export type RuntimeAiVaultScanOptions = { + timeoutMs?: number +} + +export type RuntimeAiVaultScanner = ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions +) => Promise + +/** + * Why: an unreachable Orca server must cost this host's row, not the whole + * multi-host list, so every failure except cancellation degrades to an issue. + */ +export async function scanRuntimeAiVaultSessions(args: { + hostInfo: RuntimeAiVaultHostInfo + scanner: RuntimeAiVaultScanner | undefined + listArgs?: AiVaultListArgs + options?: RuntimeAiVaultScanOptions & { signal?: AbortSignal } +}): Promise { + const { signal, ...scannerOptions } = args.options ?? {} + throwIfAiVaultScanCancelled(signal) + if (!args.scanner) { + return runtimeScanIssueResult( + args.hostInfo, + 'Agent Session History is not available for this execution host.' + ) + } + try { + return await abandonRemoteSessionScanOnCancel( + args.scanner(args.hostInfo.environmentId, runtimeScanArgs(args.hostInfo, args.listArgs), { + ...scannerOptions + }), + signal + ) + } catch (error) { + // RPC rejections keep the message but lose Error.name, so classify on both. + if (isAiVaultScanCancelledError(error)) { + throw error + } + return runtimeScanIssueResult( + args.hostInfo, + error instanceof Error ? error.message : 'Remote Orca server is unavailable.' + ) + } +} + +// Optional keys are copied only when present so the RPC schema never sees an +// explicit undefined for limit/force/scopePaths. +function runtimeScanArgs( + hostInfo: RuntimeAiVaultHostInfo, + listArgs: AiVaultListArgs | undefined +): AiVaultListArgs { + const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (listArgs?.limit !== undefined) { + scanArgs.limit = listArgs.limit + } + if (listArgs?.unlimited !== undefined) { + scanArgs.unlimited = listArgs.unlimited + } + if (listArgs?.force !== undefined) { + scanArgs.force = listArgs.force + } + if (listArgs?.scopePaths !== undefined) { + scanArgs.scopePaths = listArgs.scopePaths + } + return scanArgs +} + +function runtimeScanIssueResult( + hostInfo: RuntimeAiVaultHostInfo, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ + executionHostId: hostInfo.executionHostId, + path: hostInfo.environmentId, + message + }) +} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts new file mode 100644 index 00000000000..3a8cd3bd149 --- /dev/null +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn(), + scanRemoteAiVaultSessions: vi.fn(), + scanRuntimeAiVaultSessions: vi.fn(), + getSshFilesystemProvider: vi.fn(), + getActiveSshAiVaultHostInfo: vi.fn(), + getActiveSshAiVaultHostInfos: vi.fn(), + requestActiveSshAiVaultSessionList: vi.fn(), + ipcHandle: vi.fn() +})) + +vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } })) +vi.mock('../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) +vi.mock('../ai-vault/remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions +})) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(), + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH unavailable', + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) +vi.mock('./ssh', () => ({ + getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos, + requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList +})) + +const { _internals, registerAiVaultHandlers } = await import('./ai-vault') +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' +} + +beforeEach(() => { + vi.clearAllMocks() + _internals.resetAiVaultCacheForTests() + mocks.scanAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider) + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo()) + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo()]) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null) +}) + +describe('Agent Session History scan coalescing', () => { + it.each([ + ['local', mocks.scanAiVaultSessions], + ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] + ] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => { + let resolveScan: ((result: AiVaultListResult) => void) | undefined + scan.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve + }) + ) + registerRuntimeHost() + const firstController = new AbortController() + const first = _internals.listAiVaultSessions( + { executionHostScope: scope }, + { signal: firstController.signal } + ) + const second = _internals.listAiVaultSessions({ executionHostScope: scope }) + await vi.waitFor(() => expect(resolveScan).toBeDefined()) + + firstController.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(scan).toHaveBeenCalledTimes(1) + resolveScan?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + }) + + it('coalesces every all-host leg while isolating caller cancellation', async () => { + let resolveRuntime: ((result: AiVaultListResult) => void) | undefined + mocks.scanRuntimeAiVaultSessions.mockImplementation( + () => + new Promise((resolve) => { + resolveRuntime = resolve + }) + ) + registerRuntimeHost() + const controller = new AbortController() + + const first = _internals.listAiVaultSessions( + { executionHostScope: 'all' }, + { signal: controller.signal } + ) + const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' }) + const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) + await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) + controller.abort() + await firstRejection + resolveRuntime?.(EMPTY_RESULT) + await expect(second).resolves.toMatchObject({ sessions: [], issues: [] }) + }) + + it('keeps a shared multi-window scan alive when one window cancels', async () => { + let resolveRelay: ((result: AiVaultListResult) => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + () => + new Promise((resolve) => { + resolveRelay = resolve + }) + ) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + const cancel = ipcHandler('aiVault:cancelListSessions') + const firstEvent = { sender: { id: 1 } } + const secondEvent = { sender: { id: 2 } } + const first = list(firstEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + const second = list(secondEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + await vi.waitFor(() => expect(resolveRelay).toBeDefined()) + + cancel(firstEvent, { requestToken: 'scan' }) + + // Electron logs every rejected handler, so a cancelled scan resolves instead. + await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) + resolveRelay?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + }) + + it('reports a real scan failure as a host issue rather than cancellation', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed')) + mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed')) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + + // SSH host legs convert unexpected throws into scan issues so an `all` + // multi-host list still returns the other hosts' sessions. + const result = await list( + { sender: { id: 1 } }, + { executionHostScope: 'ssh:dev-box', requestToken: 'scan' } + ) + expect(result).toMatchObject({ + sessions: [], + issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })] + }) + expect(result).not.toHaveProperty('cancelled') + }) + + it('still rejects the handler when a scan fails for a non-cancellation reason', async () => { + mocks.scanAiVaultSessions.mockRejectedValue(new Error('transcript root is unreadable')) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + + await expect( + list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' }) + ).rejects.toThrow('transcript root is unreadable') + }) + + it('re-joins a preempted same-scope caller onto the forced refresh', async () => { + const signals: AbortSignal[] = [] + let resolveForced: ((result: AiVaultListResult) => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId, _params, options: { signal: AbortSignal }) => { + signals.push(options.signal) + return new Promise((resolve) => { + if (signals.length === 1) { + options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + } else { + resolveForced = resolve + } + }) + } + ) + const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await vi.waitFor(() => expect(signals).toHaveLength(1)) + + const forced = _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + force: true + }) + await vi.waitFor(() => expect(signals).toHaveLength(2)) + + expect(signals[0]?.aborted).toBe(true) + resolveForced?.(EMPTY_RESULT) + // Another window's Refresh must not surface as this caller's cancellation. + await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + }) +}) + +function registerRuntimeHost(): void { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [ + { environmentId: 'remote-server', executionHostId: 'runtime:remote-server' } + ], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) +} + +function hostInfo() { + return { + targetId: 'dev-box', + executionHostId: 'ssh:dev-box' as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + } +} + +function ipcHandler(channel: string): (...args: unknown[]) => unknown { + const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) + if (!registration) { + throw new Error(`${channel} was not registered`) + } + return registration[1] +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 5f4153106fd..1ecc3bb0a89 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), getActiveSshAiVaultHostInfos: vi.fn(), + requestActiveSshAiVaultSessionList: vi.fn(), ipcHandle: vi.fn() })) @@ -47,7 +49,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, - getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos, + requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList })) const { _internals, registerAiVaultHandlers } = await import('./ai-vault') @@ -66,6 +69,7 @@ beforeEach(() => { result([session('runtime:remote-server', 'runtime-session')]) ) mocks.getSshFilesystemProvider.mockReturnValue(provider) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null) mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) }) @@ -101,11 +105,160 @@ describe('listAiVaultSessions host routing', () => { ) }) + it('uses one target-side relay scan when the SSH relay supports it', async () => { + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( + result([session('local', 'remote-session')]) + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo'] + }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + { + limit: undefined, + scopePaths: ['/home/ada/repo'] + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(scanned.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + id: expect.stringContaining('ssh:dev-box:') + }) + }) + + it('marks oversized project scopes when sending their bounded relay form', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([])) + + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths + }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + { + limit: undefined, + scopePaths: scopePaths.slice(0, 64), + scopePathsTruncated: true + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) }) + ) + expect(scanned.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + + it('coalesces concurrent cancellable requests into one scan', async () => { + let resolveRelay: (() => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + () => + new Promise((resolve) => { + resolveRelay = () => resolve(result([])) + }) + ) + const args = { executionHostScope: 'ssh:dev-box' as const } + + const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) + const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) + await vi.waitFor(() => expect(resolveRelay).toBeDefined()) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) + resolveRelay?.() + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + }) + + it('does not start a second remote crawl after the relay scan budget expires', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(scanned.sessions).toEqual([]) + expect(scanned.issues[0]?.message).toContain('timed out') + }) + + it('does not cache a host-level relay failure', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2) + }) + + it('falls back to the filesystem crawl after a non-timeout relay failure', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue( + new Error('Invalid aiVault.listSessions response') + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('falls back when a nonempty relay sessions array contains no valid rows', async () => { + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({ + sessions: [{ id: 42 }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('uses the relay scan without requiring the fallback filesystem provider', async () => { + mocks.getSshFilesystemProvider.mockReturnValue(undefined) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( + result([session('local', 'remote-session')]) + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(scanned.sessions).toHaveLength(1) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + it('merges local plus connected SSH targets for all hosts', async () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + expect.any(Object), + expect.objectContaining({ timeoutMs: 15_000 }) + ) expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) @@ -159,6 +312,50 @@ describe('listAiVaultSessions host routing', () => { ]) }) + it('keeps SSH results when the local scan itself throws', async () => { + // Why: `all` awaits every leg together, so an unguarded local throw (parse + // cache load, WSL home resolution) would discard every host's sessions. + mocks.scanAiVaultSessions.mockRejectedValue(new Error('session parse cache is corrupt')) + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box']) + expect(result.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'local', + kind: 'host', + path: 'this computer', + message: 'session parse cache is corrupt' + }) + ]) + }) + + it('keeps local results when SSH host discovery fails', async () => { + mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => { + throw new Error('relay session map is unavailable') + }) + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'codex', + path: 'SSH hosts', + message: 'relay session map is unavailable' + }) + ]) + }) + it('keeps direct runtime host scans on the normal runtime timeout', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], @@ -205,6 +402,63 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) + + it('caches completed SSH scans by host and workspace scope', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b'] + }) + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a'] + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('serves lower SSH depths from a larger completed scan', async () => { + const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] } + await _internals.listAiVaultSessions({ ...base, limit: 1000 }) + await _internals.listAiVaultSessions({ ...base, limit: 250 }) + await _internals.listAiVaultSessions({ ...base, limit: 500 }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('threads renderer cancellation into the SSH relay request', async () => { + let relaySignal: AbortSignal | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId, _params, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + relaySignal = options.signal + options.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + registerAiVaultHandlers() + const event = { sender: { id: 7 } } + const pending = getIpcHandler('aiVault:listSessions')(event, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan-1' + }) + await vi.waitFor(() => expect(relaySignal).toBeDefined()) + + await getIpcHandler('aiVault:cancelListSessions')(event, { + requestToken: 'scan-1' + }) + + expect(relaySignal?.aborted).toBe(true) + // Resolved, not rejected: Electron logs every rejected handler, and a + // superseded scan is normal control flow rather than a failure. + await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] }) + }) }) describe('prepareSessionResume IPC', () => { @@ -280,6 +534,14 @@ function getPrepareSessionResumeHandler(): ( return registration[1] } +function getIpcHandler(channel: string): (...args: unknown[]) => unknown { + const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) + if (!registration) { + throw new Error(`${channel} was not registered`) + } + return registration[1] +} + describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') @@ -365,6 +627,14 @@ function hostInfo(targetId: string) { } } +/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on + * the message text. */ +function relayTimeoutError(): Error { + return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + function result(sessions: AiVaultSession[]): AiVaultListResult { return { sessions, issues: [], scannedAt: new Date().toISOString() } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index e103b7b5cd4..5d3b0bd2e79 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -7,17 +7,24 @@ import { resetAiVaultSessionListCacheForTests, type AiVaultSessionSources } from '../ai-vault/cached-session-list' -import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import type { - AiVaultFirstUserPromptArgs, - AiVaultListArgs, - AiVaultListResult, - AiVaultSubagentListArgs, - AiVaultSubagentListResult +import { + aiVaultScanIssueResult, + cancelledAiVaultListResult, + mergeAiVaultListResults +} from '../ai-vault/session-list-results' +import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' +import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + isAiVaultScanCancelledError, + type AiVaultFirstUserPromptArgs, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultSubagentListArgs, + type AiVaultSubagentListResult } from '../../shared/ai-vault-types' import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' @@ -29,126 +36,150 @@ import { toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' +import { getActiveSshAiVaultHostInfos } from './ssh' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery' import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' + scanRuntimeAiVaultSessions, + type RuntimeAiVaultHostInfo, + type RuntimeAiVaultScanner +} from './ai-vault-runtime-scan' +import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache' +import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' -const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 +// Why: a remote home with many agent roots routinely needs seconds to walk, +// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the +// all-hosts view; the relay gets a real scan budget and the whole leg (relay +// attempt plus any legacy crawl) stays bounded so one host can't hold the +// merge open. +const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000 +const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000 type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] - scanRuntimeAiVaultSessions?: ( - environmentId: string, - args: AiVaultListArgs, - options?: RuntimeAiVaultScanOptions - ) => Promise + scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner } -type RuntimeAiVaultScanOptions = { - timeoutMs?: number -} - -type CachedAiVaultList = { - key: string - result: AiVaultListResult - expiresAt: number -} - -type RuntimeAiVaultHostInfo = { - environmentId: string - executionHostId: `runtime:${string}` -} - -let cachedList: CachedAiVaultList | null = null -let inflightList: Promise | null = null -let inflightKey: string | null = null +let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} +const listCancellations = createSenderScopedRequestCancellations() -async function listAiVaultSessions(args?: AiVaultListArgs): Promise { +async function listAiVaultSessions( + args?: AiVaultListArgs, + options: { signal?: AbortSignal } = {} +): Promise { const executionHostScope = normalizeExecutionHostScope( args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID ) - // Why: local-scope scans go straight to the shared cache module (also used by - // the runtime RPC method), so the desktop panel and a paired mobile client - // never double-scan the same transcripts; the cache below only has to dedupe - // the multi-host (ssh/runtime/all) merges that exist on the desktop side. - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args) - } // Scope paths change the result set, so they must be part of the cache key. + // A scanner consumes at most 64 paths, so smaller equivalent workspace sets + // can share a snapshot regardless of which worktree was selected first. + const scopePaths = args?.scopePaths ?? [] const key = JSON.stringify({ - limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [], + scopePaths: + scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT + ? [...new Set(scopePaths)].sort() + : scopePaths, executionHostScope }) - const now = Date.now() - // Why: opening this panel repeatedly should not re-parse hundreds of JSONL - // transcripts; explicit refreshes bypass the cache but not an active scan. - if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { - return cachedList.result - } - if (inflightList && inflightKey === key) { - return inflightList - } - - inflightKey = key - inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope) - .then((result) => { - cachedList = { - key, - result, - expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + const depth = requestedAiVaultSessionDepth(args) + const scanKey = JSON.stringify({ key, depth }) + // Why: every renderer request carries its own cancellation signal, so + // coalescing has to survive them — the coordinator hands all same-key callers + // one scan and only aborts it once every one of them has cancelled. + return scanCoordinator.run({ + key: scanKey, + force: args?.force, + signal: options.signal, + start: (scanSignal) => { + const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key) + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scan() } - return result - }) - .finally(() => { - // Only clear tracking if it still refers to this request: a concurrent - // different-scope scan may have replaced it and must stay dedupable. - if (inflightKey === key) { - inflightKey = null - inflightList = null - } - }) - return inflightList + return scanHostLegWithCache({ + cacheKey: key, + depth, + scopePaths, + force: args?.force === true, + scan + }) + } + }) } async function scanAiVaultSessionsByHostScope( args: AiVaultListArgs | undefined, - executionHostScope: ExecutionHostScope + executionHostScope: ExecutionHostScope, + signal?: AbortSignal, + cacheKey = '' ): Promise { + const depth = requestedAiVaultSessionDepth(args) + const scopePaths = args?.scopePaths ?? [] + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scanLocalAiVaultSessions(args, signal) + } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() - const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] + const sshHosts = getActiveSshAiVaultHostInfosResult() + const runtimeResults = [ + ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), + ...(sshHosts.issue ? [sshHosts.issue] : []) + ] const scannedResults = await Promise.all([ - scanLocalAiVaultSessions(args), - ...getActiveSshAiVaultHostInfos().map((hostInfo) => - scanSshAiVaultSessions(hostInfo.targetId, args) + scanLocalAiVaultSessionsForAllScope(args, signal), + ...sshHosts.hostInfos.map((hostInfo) => + scanHostLegWithCache({ + cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, + depth, + scopePaths, + force: args?.force === true, + scan: () => + scanSshAiVaultSessions(hostInfo.targetId, args, { + signal, + timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS, + relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS + }) + }) ), ...runtimeHosts.hostInfos.map((hostInfo) => - scanRuntimeAiVaultSessions(hostInfo, args, { - timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + scanHostLegWithCache({ + cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, + depth, + scopePaths, + force: args?.force === true, + scan: () => + scanRuntimeAiVaultSessions({ + hostInfo, + scanner: handlerOptions.scanRuntimeAiVaultSessions, + listArgs: args, + options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } + }) }) ) ]) - return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit) + return mergeAiVaultListResults( + [...scannedResults, ...runtimeResults], + args?.limit, + args?.unlimited + ) } const parsed = parseExecutionHostId(executionHostScope) if (parsed?.kind === 'ssh') { - return scanSshAiVaultSessions(parsed.targetId, args) + return scanSshAiVaultSessions(parsed.targetId, args, { signal }) } if (parsed?.kind === 'runtime') { - return scanRuntimeAiVaultSessions( - { + return scanRuntimeAiVaultSessions({ + hostInfo: { environmentId: parsed.environmentId, executionHostId: toRuntimeExecutionHostId(parsed.environmentId) }, - args - ) + scanner: handlerOptions.scanRuntimeAiVaultSessions, + listArgs: args, + options: { signal } + }) } return aiVaultScanIssueResult({ @@ -158,118 +189,57 @@ async function scanAiVaultSessionsByHostScope( }) } -function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] { - return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [] -} - -function getActiveRuntimeAiVaultHostInfosResult(): { - hostInfos: readonly RuntimeAiVaultHostInfo[] - issue?: AiVaultListResult -} { - try { - return { hostInfos: getActiveRuntimeAiVaultHostInfos() } - } catch (error) { - return { - hostInfos: [], - issue: runtimeHostDiscoveryIssueResult( - error instanceof Error ? error.message : 'Runtime hosts are unavailable.' - ) - } - } -} - -async function scanRuntimeAiVaultSessions( - hostInfo: RuntimeAiVaultHostInfo, - args?: AiVaultListArgs, - options: RuntimeAiVaultScanOptions = {} -): Promise { - const scanner = handlerOptions.scanRuntimeAiVaultSessions - if (!scanner) { - return runtimeScanIssueResult( - hostInfo, - 'Agent Session History is not available for this execution host.' - ) - } - const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } - if (args?.limit !== undefined) { - scanArgs.limit = args.limit - } - if (args?.force !== undefined) { - scanArgs.force = args.force - } - if (args?.scopePaths !== undefined) { - scanArgs.scopePaths = args.scopePaths - } - try { - return await scanner(hostInfo.environmentId, scanArgs, options) - } catch (error) { - return runtimeScanIssueResult( - hostInfo, - error instanceof Error ? error.message : 'Remote Orca server is unavailable.' - ) - } -} - -function runtimeScanIssueResult( - hostInfo: RuntimeAiVaultHostInfo, - message: string -): AiVaultListResult { - return aiVaultScanIssueResult({ - executionHostId: hostInfo.executionHostId, - path: hostInfo.environmentId, - message +function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult { + return discoverAiVaultHosts(() => handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [], { + path: 'runtime environments', + fallbackMessage: 'Runtime hosts are unavailable.' }) } -function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { - return aiVaultScanIssueResult({ path: 'runtime environments', message }) +function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> { + return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, { + path: 'SSH hosts', + fallbackMessage: 'SSH hosts are unavailable.' + }) } -async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { +// Why: the SSH legs already degrade to an issue row so one bad host can't take +// the shared Promise.all down; the local leg can throw too (parse-cache load, +// WSL home resolution) and would otherwise discard every host's sessions. +async function scanLocalAiVaultSessionsForAllScope( + args: AiVaultListArgs | undefined, + signal: AbortSignal | undefined +): Promise { + try { + return await scanLocalAiVaultSessions(args, signal) + } catch (error) { + if (isAiVaultScanCancelledError(error)) { + throw error + } + return aiVaultScanIssueResult({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + path: 'this computer', + message: error instanceof Error ? error.message : 'Local session scan failed.' + }) + } +} + +async function scanLocalAiVaultSessions( + args?: AiVaultListArgs, + signal?: AbortSignal +): Promise { // Why: the shared cache module owns codex-home/WSL sourcing and the local // scan cache, so the desktop IPC path and the runtime RPC method (mobile) // share one cache instance and one source of managed-Codex homes. - return listCachedLocalAiVaultSessions({ - limit: args?.limit, - force: args?.force, - scopePaths: args?.scopePaths - }) -} - -async function scanSshAiVaultSessions( - targetId: string, - args?: AiVaultListArgs -): Promise { - const executionHostId = toSshExecutionHostId(targetId) - const hostInfo = getActiveSshAiVaultHostInfo(targetId) - const provider = getSshFilesystemProvider(targetId) - if (!hostInfo || !provider) { - return sshScanIssueResult({ - executionHostId, - targetId, - message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE - }) - } - return scanRemoteAiVaultSessions({ - provider, - executionHostId: hostInfo.executionHostId, - remoteHome: hostInfo.remoteHome, - hostPlatform: hostInfo.hostPlatform, - limit: args?.limit, - scopePaths: args?.scopePaths - }) -} - -function sshScanIssueResult(args: { - executionHostId: `ssh:${string}` - targetId: string - message: string -}): AiVaultListResult { - return aiVaultScanIssueResult({ - executionHostId: args.executionHostId, - path: args.targetId, - message: args.message - }) + return listCachedLocalAiVaultSessions( + { + limit: args?.limit, + unlimited: args?.unlimited, + force: args?.force, + scopePaths: args?.scopePaths + }, + { signal } + ) } export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { @@ -279,8 +249,32 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo // WSL injection. The runtime also configures these sources from its deps // (serve-mode reachable); this desktop path supplies the same source. configureAiVaultSessionSources(options) - ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => - listAiVaultSessions(args) + ipcMain.handle('aiVault:listSessions', async (event, args?: AiVaultListArgs) => { + const requestToken = + typeof args?.requestToken === 'string' && args.requestToken.length <= 128 + ? args.requestToken + : undefined + const controller = listCancellations.begin(event, requestToken) + try { + return await listAiVaultSessions(args, { signal: controller?.signal }) + } catch (error) { + // Why: superseding a scan is normal control flow, but Electron logs every + // rejected handler — report it as a result so the log stays truthful. + if (!isAiVaultScanCancelledError(error)) { + throw error + } + return cancelledAiVaultListResult() + } finally { + listCancellations.finish(event, requestToken, controller) + } + }) + ipcMain.handle( + 'aiVault:cancelListSessions', + (event, args: { requestToken?: string } | undefined): void => { + if (typeof args?.requestToken === 'string' && args.requestToken.length <= 128) { + listCancellations.cancel(event, args.requestToken) + } + } ) registerAiVaultResumeHandler(options) ipcMain.handle( @@ -335,9 +329,8 @@ async function listAiVaultSubagentSessions( } function resetAiVaultCacheForTests(): void { - cachedList = null - inflightList = null - inflightKey = null + resetAiVaultHostLegCacheForTests() + scanCoordinator = new AiVaultScanCoordinator() handlerOptions = {} // The local leg delegates to the shared cache module; reset it too so tests // never see a scan cached by an earlier case. diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 7002f31d853..2ff888a7bd8 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -675,4 +675,32 @@ describe('GitLab IPC handlers', () => { localGitOptions ) }) + + // Regression for #7732: raw CI traces routinely exceed the 1 MB runtime transport + // frame cap, so the Checks panel opts into a main-side excerpt. + it('bounds the job trace in main when the caller asks for a log excerpt', async () => { + const noisyTrace = [ + 'section_start:1699000000:build\r\u001b[0K$ pnpm build', + ...Array.from({ length: 400 }, (_, index) => `line ${index}`), + '\u001b[0;31mERROR: Job failed: exit code 1\u001b[0m' + ].join('\n') + getJobTraceMock.mockResolvedValue({ ok: true, trace: noisyTrace }) + registerGitLabHandlers(storeWithRepos([repo()]) as Store) + + const raw = (await ipcHandlers.get('gitlab:jobTrace')?.(null, { + repoPath: '/local/orca', + jobId: 99 + })) as { ok: true; trace: string } + const excerpt = (await ipcHandlers.get('gitlab:jobTrace')?.(null, { + repoPath: '/local/orca', + jobId: 99, + logExcerpt: true + })) as { ok: true; trace: string } + + expect(raw.trace).toBe(noisyTrace) + expect(excerpt.trace).toContain('ERROR: Job failed: exit code 1') + expect(excerpt.trace).not.toContain('section_start') + expect(excerpt.trace).not.toContain('line 0\n') + expect(excerpt.trace.length).toBeLessThan(noisyTrace.length) + }) }) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index f400ce7a5ce..e68c4b8f6d8 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -3,6 +3,7 @@ GitLab IPC handlers co-located keeps the repo-path validation pattern reviewable as one surface. */ import { ipcMain } from 'electron' import { resolve } from 'node:path' +import { toGitLabJobLogExcerptResult } from '../../shared/gitlab-job-log-excerpt' import type { GitLabIssueUpdate, GitLabMRInlineCommentInput, @@ -520,10 +521,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:jobTrace', async ( _event, - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null } + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: ProjectRef | null + logExcerpt?: boolean + } ) => { const repo = assertRegisteredRepo(args, store) - return getJobTrace( + const result = await getJobTrace( repo.path, args.jobId, repo.issueSourcePreference, @@ -531,6 +536,7 @@ export function registerGitLabHandlers(store: Store): void { args.projectRef, ...localGitOptionArgs(store, repo) ) + return args.logExcerpt ? toGitLabJobLogExcerptResult(result) : result } ) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 54458541f92..9f81c3a6516 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as NodeOs from 'node:os' const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,12 +18,21 @@ vi.mock('qrcode', () => ({ } })) -vi.mock('os', () => ({ +// Why: only the interface enumeration is faked; the real `os` stays available for the integration +// test below, which needs tmpdir() for a real runtime's user data directory. +vi.mock('os', async (importOriginal) => ({ + ...(await importOriginal()), networkInterfaces: networkInterfacesMock })) +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { registerMobileHandlers } from './mobile' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { WebSocketTransport } from '../runtime/rpc/ws-transport' describe('registerMobileHandlers', () => { const handlers = new Map unknown>() @@ -308,7 +318,8 @@ describe('registerMobileHandlers', () => { address: '100.64.1.20', rotate: true, name: expect.stringMatching(/^Runtime /), - scope: 'runtime' + scope: 'runtime', + reach: 'network' }) // Why: STA-2370 — generating a runtime offer must widen the listener BEFORE advertising its endpoint, // or a client could read the URL and connect before the LAN bind exists. Assert call ORDER, not just @@ -319,6 +330,111 @@ describe('registerMobileHandlers', () => { ) }) + const stubRuntimePairingServer = ( + address: string + ): { createPairingOffer: Mock; ensureNetworkExposure: Mock } => ({ + createPairingOffer: vi.fn().mockReturnValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: `http://${address}/web-index.html?pairing=runtime`, + endpoint: `ws://${address}`, + deviceId: 'runtime-local' + }), + ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) + }) + + // Why: every loopback form the address field accepts must behave identically once the user declared + // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on + // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. + it.each([ + '127.0.0.1', + 'localhost', + '::1', + '127.0.0.1:8443', + 'localhost:8443', + '[::1]:6768', + 'ws://127.0.0.1:6768' + ])('mints a %s runtime link without widening the listener off-host', async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true, deviceId: 'runtime-local' }) + + // Why: "This computer only" exists so nothing is exposed off-host — a loopback link is already + // served by the loopback listener, and the widen is one-way for the life of the process. + expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'this-computer' }) + ) + }) + + // Why: the Custom field is documented as "SSH tunnel, reverse proxy, or custom hostname", so a loopback + // front-end there still needs the listener open behind it — gating the widen on the address SHAPE broke + // `ssh -L 8443::6768` (sshd dials the LAN address on this host and gets refused). + it.each(['127.0.0.1:8443', 'localhost', 'ws://127.0.0.1:6768'])( + 'widens the listener for a custom %s address that fronts a tunnel', + async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'network' + }) + ).resolves.toMatchObject({ available: true }) + + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + } + ) + + it('widens for a loopback address when the caller declares no reach', async () => { + const rpcServer = stubRuntimePairingServer('127.0.0.1:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { address: '127.0.0.1', rotate: true }) + ).resolves.toMatchObject({ available: true }) + + // Why: only an explicit "This computer only" declines remote reach; an undeclared caller keeps the + // pre-STA-2370 opt-in behavior rather than silently minting a link the widen never backed. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + }) + + it('widens when a this-computer reach carries an off-host address', async () => { + const rpcServer = stubRuntimePairingServer('192.168.1.5:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '192.168.1.5', + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true }) + + // Why: the declared reach and the advertised address disagree; honoring the declaration would mint a + // LAN link with no LAN listener behind it, so the address it actually publishes wins. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + }) + it('reports the runtime pairing url unavailable and mints no offer when the widen fails', async () => { const createPairingOffer = vi.fn() // Why: STA-2370 — a failed widen leaves the listener on loopback, so the handler must NOT advertise a @@ -496,3 +612,127 @@ describe('registerMobileHandlers', () => { expect(runPowerShell).not.toHaveBeenCalled() }) }) + +describe('runtime pairing bind host', () => { + const handlers = new Map unknown>() + + const wsTransportOf = (server: OrcaRuntimeRpcServer): WebSocketTransport | undefined => + (server['activeTransports'] as unknown[]).find( + (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport + ) + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + networkInterfacesMock.mockReset() + networkInterfacesMock.mockReturnValue({}) + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + const startServer = async (userDataPath: string): Promise => { + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + return server + } + + it('keeps a real listener on loopback for a local link and widens it only for an off-host one', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + + try { + registerMobileHandlers(server) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true }) + // Why: the exposure regression — picking "This computer only" used to rebind the listener to + // 0.0.0.0 and leave it there, publishing the runtime to the whole LAN for the rest of the process. + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true, + reach: 'network' + }) + ).resolves.toMatchObject({ available: true }) + // Why: the LAN/Tailscale choice still opts in — a client off this host cannot reach a loopback bind. + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + + // Why: the whole guarantee is worthless if it lasts one process — the local web client authenticating + // marks its grant lastSeenAt > 0, which used to make the NEXT launch bind every interface. + it('still binds loopback on the next launch after the local link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + let deviceId: string + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true, + reach: 'this-computer' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + deviceId = offer.deviceId + // Exactly what MobileSocketWiring does for every authenticated socket, local browser included. + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + expect( + relaunched + .getDeviceRegistry() + ?.listDevices() + .some((device) => device.deviceId === deviceId && device.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('127.0.0.1') + } finally { + await relaunched.stop() + } + }) + + it('binds all interfaces on the next launch after a network link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true, + reach: 'network' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + server.getDeviceRegistry()?.updateLastSeen(offer.deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + // Why: the reconnect widen must survive — a client that really did connect from off-host has to + // find the listener at launch without the user re-pairing. + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ff4c5c6d3ec..35584f951a4 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,9 +2,12 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' @@ -77,6 +80,18 @@ function getDefaultPairingAddress(): string | null { return ifaces.length > 0 ? ifaces[0]!.address : null } +// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it +// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise +// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address +// that fronts an SSH tunnel or reverse proxy, still opts in. +function servesThisComputerOnly(reach: RuntimePairingReach | undefined, address: string): boolean { + if (reach !== 'this-computer') { + return false + } + const hostname = resolveAdvertisedPairingHostname(address) + return hostname !== null && classifyRemotePairingHostname(hostname) === 'loopback' +} + function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { return { deviceId: device.deviceId, @@ -175,7 +190,7 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getRuntimePairingUrl', - async (_event, args?: { address?: string; rotate?: boolean }) => { + async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => { const ip = args?.address ?? getDefaultPairingAddress() if (!ip) { return { available: false as const } @@ -184,19 +199,24 @@ export function registerMobileHandlers( // Why: STA-2370 — generating a runtime pairing offer is the user's explicit opt-in to remote // reach, so widen the loopback listener before advertising its LAN endpoint. If the widen fails the // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. - try { - await rpcServer.ensureNetworkExposure() - } catch (error) { - console.error( - '[mobile] Network exposure failed while creating a runtime pairing offer:', - error - ) - // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so - // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. - return { - available: false as const, - reason: 'network_exposure_failed' as const, - guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + // "This computer only" is the opposite opt-in: the loopback listener already serves it, and the widen + // never narrows back, so that pick alone must not expose the runtime off-host. + const thisComputerOnly = servesThisComputerOnly(args?.reach, ip) + if (!thisComputerOnly) { + try { + await rpcServer.ensureNetworkExposure() + } catch (error) { + console.error( + '[mobile] Network exposure failed while creating a runtime pairing offer:', + error + ) + // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so + // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. + return { + available: false as const, + reason: 'network_exposure_failed' as const, + guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + } } } @@ -206,7 +226,10 @@ export function registerMobileHandlers( address: ip, rotate: args?.rotate, name: `Runtime ${new Date().toLocaleDateString()}`, - scope: 'runtime' + scope: 'runtime', + // Why: a grant that only ever pointed at loopback must not make the next launch bind every + // interface when its local client reconnects (that would restore the exposure one restart later). + reach: thisComputerOnly ? 'this-computer' : 'network' }) if (!offer.available) { return { available: false as const } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 16d4717040e..257250a9858 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6754,6 +6754,64 @@ describe('registerPtyHandlers', () => { ]) }) + it('waits for local provider startup before resolving snapshot capability', async () => { + const barrier = makeDeferred() + const awaitLocalPtyProviderStartup = vi.fn(() => barrier.promise) + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup } + ) + const pending = Promise.resolve( + handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, { + ids: ['restored-local-pty'] + }) + ) + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + installDaemonTestProvider({ canProvideAuthoritativeBufferSnapshot: () => true }) + barrier.resolve() + + await expect(pending).resolves.toEqual([{ id: 'restored-local-pty', authoritative: true }]) + }) + + it('does not gate remote snapshot capability on local provider startup', async () => { + const awaitLocalPtyProviderStartup = vi.fn(() => new Promise(() => {})) + registerSshPtyProvider('ssh-1', { + canProvideAuthoritativeBufferSnapshot: () => false + } as never) + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup } + ) + + const result = await handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, { + ids: ['remote:environment@@pty-1', 'ssh:ssh-1@@pty-2'] + }) + + expect(awaitLocalPtyProviderStartup).not.toHaveBeenCalled() + expect(result).toEqual([ + { id: 'remote:environment@@pty-1', authoritative: false }, + { id: 'ssh:ssh-1@@pty-2', authoritative: false } + ]) + }) + it('answers false, not null, for a resolved provider with no snapshot capability', async () => { // Null is never cached, so missing optional methods must resolve false. registerPtyHandlers(mainWindow as never) @@ -12121,9 +12179,12 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' mockProc.emitData(sourceData) + // Why: the reply leaves the query's own turn so a still-cooked tty cannot + // echo it back as text instead of delivering it to the agent (#12112). + expect(mockProc.proc.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready', @@ -12158,9 +12219,11 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?;?\x1b\\ready' mockProc.emitData(sourceData) + // Why: both slots of a duplicate-slot query leave the query's own turn too (#12112). + expect(mockProc.proc.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2608e54f386..cd01a985243 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -147,7 +147,7 @@ import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' -import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' import { beginTerminalInstall } from './watcher-removal-gate' @@ -1011,7 +1011,8 @@ export type BuildPtyHostEnvOptions = { } function readInheritedPath(baseEnv: Record): string { - return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? '' + const pathKey = resolvePathEnvKey(baseEnv, process.platform) + return baseEnv[pathKey] ?? process.env[pathKey] ?? '' } function firstPathEntry(pathValue: string | undefined): string | null { @@ -1675,7 +1676,9 @@ export function buildPtyHostEnv( const devCliBin = join(opts.userDataPath, 'cli', 'bin') const inheritedPath = readInheritedPath(baseEnv) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. - baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${devCliBin}${delimiter}${inheritedPath}` + : devCliBin } else if (process.platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) @@ -4338,7 +4341,9 @@ export function registerPtyHandlers( let env: Record | undefined = claudeAuth ? { ...sshScopedEnv, ...claudeAuth.envPatch } : sshScopedEnv - const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined + const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID + ? env[resolvePathEnvKey(env, process.platform)] + : undefined env = stripSequencedStartupResumeArgv(env, codexResumeLaunch) if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } @@ -5667,7 +5672,9 @@ export function registerPtyHandlers( } } } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined + const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? baseEnv[resolvePathEnvKey(baseEnv, process.platform)] + : undefined const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] : undefined @@ -6969,8 +6976,24 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', - (_event, args: { ids?: unknown }) => { + async (_event, args: { ids?: unknown }) => { const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] + const hasLocalPtyId = ids.some((value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.startsWith('remote:') || + parseAppSshPtyId(value) + ) { + return false + } + const ownedConnectionId = ptyOwnership.get(value) + return ownedConnectionId === undefined || ownedConnectionId === null + }) + if (hasLocalPtyId) { + await getLocalPtyProviderStartupPromise() + } const capabilities: { id: string; authoritative: boolean | null }[] = [] const seen = new Set() for (const value of ids) { diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 78accf8abe1..abbb77ab087 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { syncHandlers } = vi.hoisted(() => ({ +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ syncHandlers: new Map< string, (event: { returnValue?: unknown }, args: Record) => void - >() + >(), + invokeHandlers: new Map Promise<{ ok: boolean }>>() })) vi.mock('electron', () => ({ @@ -16,15 +17,25 @@ vi.mock('electron', () => ({ ) => { syncHandlers.set(channel, handler) } - ) + ), + handle: vi.fn((channel: string, handler: () => Promise<{ ok: boolean }>) => { + invokeHandlers.set(channel, handler) + }) } })) -import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' +import { + registerRendererShutdownCheckpointHandler, + SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS +} from './renderer-shutdown-checkpoint' + +const AWAIT_CHANNEL = 'app:await-before-unload-checkpoint' describe('registerRendererShutdownCheckpointHandler', () => { beforeEach(() => { syncHandlers.clear() + invokeHandlers.clear() + vi.restoreAllMocks() }) it('stages every shutdown mutation before queueing persistence', () => { @@ -34,7 +45,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { callOrder.push(`session:${hostId ?? 'local'}`) }), updateUI: vi.fn(() => callOrder.push('ui')), - flushPendingAsync: vi.fn(() => { + flushPendingOrThrowAsync: vi.fn(() => { callOrder.push('persist') return Promise.resolve() }) @@ -62,7 +73,11 @@ describe('registerRendererShutdownCheckpointHandler', () => { 'runtime:host-1' ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) - expect(store.flushPendingAsync).toHaveBeenCalledTimes(1) + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + // Why: Store fences the staged generation without draining unrelated live mutations. + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledWith( + expect.objectContaining({ drainToStableGeneration: false }) + ) expect(callOrder).toEqual(['session:local', 'session:runtime:host-1', 'ui', 'persist']) expect(event.returnValue).toEqual({ ok: true }) }) @@ -73,7 +88,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { updateUI: vi.fn(() => { throw new Error('disk full') }), - flushPendingAsync: vi.fn(() => Promise.resolve()) + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) @@ -84,11 +99,11 @@ describe('registerRendererShutdownCheckpointHandler', () => { expect(event.returnValue).toEqual({ ok: false }) }) - it('does not queue persistence when staging is incomplete', () => { + it('does not queue persistence when staging is incomplete', async () => { const store = { stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushPendingAsync: vi.fn(() => Promise.resolve()) + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) @@ -99,19 +114,16 @@ describe('registerRendererShutdownCheckpointHandler', () => { const event: { returnValue?: unknown } = {} handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) - expect(store.flushPendingAsync).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() expect(event.returnValue).toEqual({ ok: false }) + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: false }) }) - it('returns before the asynchronous persistence settles', () => { - let resolve!: () => void - const pending = new Promise((next) => { - resolve = next - }) + it('stages synchronously without waiting on the durable write', () => { const store = { stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushPendingAsync: vi.fn(() => pending) + flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) } registerRendererShutdownCheckpointHandler(store as never) @@ -120,6 +132,84 @@ describe('registerRendererShutdownCheckpointHandler', () => { handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) expect(event.returnValue).toEqual({ ok: true }) - resolve() + }) + + it('holds the checkpoint open until the durable write settles', async () => { + let resolveFlush!: () => void + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn( + () => + new Promise((next) => { + resolveFlush = next + }) + ) + } + registerRendererShutdownCheckpointHandler(store as never) + + syncHandlers.get('app:stage-before-unload-sync')?.({}, { sessions: [], ui: {} }) + const checkpoint = invokeHandlers.get(AWAIT_CHANNEL)?.() + let settled: unknown = 'pending' + void checkpoint?.then((result) => { + settled = result + }) + + await Promise.resolve() + expect(settled).toBe('pending') + + resolveFlush() + await expect(checkpoint).resolves.toEqual({ ok: true }) + }) + + it('reports a failed durable write instead of a successful checkpoint', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.reject(new Error('disk full'))) + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + registerRendererShutdownCheckpointHandler(store as never) + + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')?.(event, { sessions: [], ui: {} }) + + expect(event.returnValue).toEqual({ ok: true }) + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: false }) + }) + + it('fails the checkpoint when the durable write outlives its deadline', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn( + (_options: { signal: AbortSignal }) => new Promise(() => {}) + ) + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.useFakeTimers() + try { + registerRendererShutdownCheckpointHandler(store as never) + syncHandlers.get('app:stage-before-unload-sync')?.({}, { sessions: [], ui: {} }) + const checkpoint = invokeHandlers.get(AWAIT_CHANNEL)?.() + + await vi.advanceTimersByTimeAsync(SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS) + + await expect(checkpoint).resolves.toEqual({ ok: false }) + expect(store.flushPendingOrThrowAsync.mock.calls[0]?.[0]?.signal.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('reports success before any checkpoint is staged', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) + } + registerRendererShutdownCheckpointHandler(store as never) + + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: true }) }) }) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index a1fafa16634..121ff86b2e3 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -8,7 +8,42 @@ type StageBeforeUnloadSyncArgs = { ui: Partial } +export type ShutdownCheckpointResult = { ok: boolean } + +/** Matches the will-quit teardown budget so a stalled disk can't strand a restart. */ +export const SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS = 20_000 + +function flushStagedStateWithDeadline(store: Store): Promise { + const controller = new AbortController() + let timer: ReturnType | null = null + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort() + console.error('[app] Timed out persisting staged renderer state') + resolve({ ok: false }) + }, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS) + }) + // Why not drain to stable: Store retries a superseded staged write without + // chasing unrelated live mutations, which the deadline would otherwise cut off. + const flush = store + .flushPendingOrThrowAsync({ signal: controller.signal, drainToStableGeneration: false }) + .then((): ShutdownCheckpointResult => ({ ok: true })) + .catch((error): ShutdownCheckpointResult => { + console.error('[app] Failed to persist staged renderer state:', error) + return { ok: false } + }) + return Promise.race([flush, deadline]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + export function registerRendererShutdownCheckpointHandler(store: Store): void { + // Why: beforeunload cannot await, so the sync reply only reports staging. + // Durability is joined out-of-band by paths that are about to navigate. + let pendingCheckpoint: Promise = Promise.resolve({ ok: true }) + ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let ok = true try { @@ -20,11 +55,12 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { console.error('[app] Failed to stage renderer state before unload:', error) ok = false } - if (ok) { - void store.flushPendingAsync().catch((error) => { - console.error('[app] Failed to persist staged renderer state:', error) - }) - } + pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false }) event.returnValue = { ok } }) + + ipcMain.handle( + 'app:await-before-unload-checkpoint', + (): Promise => pendingCheckpoint + ) } diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index d0101ee3968..0e3739d6ccb 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -1689,6 +1689,363 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) + it('tells the renderer when retiring the transport closes its streaming subscription', async () => { + // Why: disconnect advances the transport generation before closing sockets. + // Gating the terminal 'close' on that generation stranded the renderer with a + // handle it believed was open, so every later subscribe wrote into a socket + // main no longer owned — blank, wedged remote terminals after a reconnect. + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + const close = vi.fn(() => { + transportCallbacks?.onClose() + }) + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'multiplex-1', close, sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const subscribed = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'multiplex-sub' + } + ) + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + disconnect(null, { selector: added.environment.id }) + + const closeEvents = senderSend.mock.calls.filter( + (call) => + call[0] === 'runtimeEnvironments:subscriptionEvent' && + (call[1] as { type?: string }).type === 'close' + ) + expect(closeEvents).toEqual([ + [ + 'runtimeEnvironments:subscriptionEvent', + { subscriptionId: subscribed.subscriptionId, type: 'close' } + ] + ]) + }) + + it("retires an environment's remaining subscriptions when one teardown throws", async () => { + // Why: the sweep exists to retire dead handles, so a single failing teardown + // must not strand the very sockets it was called to close. + registerRuntimeEnvironmentHandlers(store as never) + const closeCalls: string[] = [] + let streamCount = 0 + subscribeRemoteRuntimeRequestMock.mockImplementation(async () => { + streamCount += 1 + const requestId = `stream-${streamCount}` + return { + requestId, + close: () => { + closeCalls.push(requestId) + if (requestId === 'stream-1') { + throw new Error('socket teardown exploded') + } + }, + sendBinary: vi.fn() + } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const sender = { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + } + await subscribe(sender, { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'doomed-sub' + }) + await subscribe(sender, { + selector: added.environment.id, + method: 'browser.screencast', + params: {}, + subscriptionId: 'sibling-sub' + }) + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + expect(() => disconnect(null, { selector: added.environment.id })).not.toThrow() + + expect(closeCalls).toEqual(['stream-1', 'stream-2']) + expect( + senderSend.mock.calls + .filter((call) => (call[1] as { type?: string }).type === 'close') + .map((call) => (call[1] as { subscriptionId: string }).subscriptionId) + ).toEqual(['doomed-sub', 'sibling-sub']) + + // Both entries are gone, so a later unsubscribe finds nothing to release. + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'sibling-sub' })).toEqual({ + unsubscribed: false + }) + }) + + it('contains a throwing renderer send on a host-initiated close', async () => { + // Why: this is the notifyClosed call site with no surrounding guard. A host + // close arriving on a disposed render frame would otherwise throw out through + // the transport's onClose and into the WebSocket close handler. + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'host-closed-stream', close: vi.fn(), sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn((_channel: string, payload: { type: string }) => { + if (payload.type === 'close') { + throw new Error('Render frame was disposed') + } + }) + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'host-closed-sub' + } + ) + + // The host closes the stream on its own; nothing wraps this call site. + expect(() => transportCallbacks!.onClose()).not.toThrow() + expect( + senderSend.mock.calls.filter((call) => (call[1] as { type?: string }).type === 'close') + ).toHaveLength(1) + + // The entry is still released, so a later unsubscribe finds nothing. + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'host-closed-sub' })).toEqual( + { + unsubscribed: false + } + ) + }) + + it('retires remaining subscriptions when a liveness probe inside notifyClosed throws', async () => { + // Why: notifyClosed guards its own send, but the sweep must not depend on + // everything else inside it staying throw-free -- that is how the abandoned + // -siblings defect comes back the next time a line is added there. + registerRuntimeEnvironmentHandlers(store as never) + const closedStreams: string[] = [] + let streamCount = 0 + subscribeRemoteRuntimeRequestMock.mockImplementation(async () => { + streamCount += 1 + const requestId = `stream-${streamCount}` + return { + requestId, + close: () => closedStreams.push(requestId), + sendBinary: vi.fn() + } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const deliveredCloses: string[] = [] + const senderSend = vi.fn( + (_channel: string, payload: { subscriptionId: string; type: string }) => { + if (payload.type === 'close') { + deliveredCloses.push(payload.subscriptionId) + } + } + ) + let probeShouldThrow = false + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const sender = { + sender: { + id: 1, + isDestroyed: () => { + if (probeShouldThrow) { + throw new Error('WebContents liveness probe exploded') + } + return false + }, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + } + await subscribe(sender, { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'probe-throws-sub' + }) + await subscribe(sender, { + selector: added.environment.id, + method: 'browser.screencast', + params: {}, + subscriptionId: 'surviving-sub' + }) + // Why: arm only after subscribe, whose own isDestroyed checks must succeed. + probeShouldThrow = true + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + expect(() => disconnect(null, { selector: added.environment.id })).not.toThrow() + + // Both transports still closed even though every notifyClosed probe threw. + expect(closedStreams).toEqual(['stream-1', 'stream-2']) + expect(deliveredCloses).toEqual([]) + }) + + it('suppresses stale payloads from a retired transport but never re-sends its close', async () => { + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'multiplex-2', close: vi.fn(), sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'multiplex-stale' + } + ) + + invalidateRuntimeEnvironmentTransport(added.environment.id) + senderSend.mockClear() + // A late frame from the retired socket must not reach the renderer... + transportCallbacks!.onResponse({ + id: 'r1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-a' } + }) + // ...and its late close must not re-fire after the retirement already sent one. + transportCallbacks!.onClose() + expect(senderSend).not.toHaveBeenCalled() + }) + it('rejects cross-window streaming subscription control', async () => { registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 6bf71edd634..e31f8569dec 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -25,6 +25,7 @@ type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { environmentId: string ownerWebContentsId: number removeDestroyedListener: () => void + notifyClosed: () => void } const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') @@ -36,7 +37,22 @@ function closeSubscriptionsForEnvironment(environmentId: string): void { continue } remoteRuntimeSubscriptions.delete(subscriptionId) - subscription.close() + // Why: one failing teardown must not abandon this environment's other + // sockets -- that strands exactly the dead handles this sweep exists to + // retire. Guard the two steps independently so neither can skip the other, + // and so the isolation stays structural rather than resting on a claim that + // nothing inside notifyClosed will ever throw. + try { + subscription.close() + } catch (error) { + console.warn('[runtime-environments] subscription close failed during retirement:', error) + } + try { + // Why: a shared-control logical close never calls back, so notify directly. + subscription.notifyClosed() + } catch (error) { + console.warn('[runtime-environments] subscription close notice failed:', error) + } } } export function invalidateRuntimeEnvironmentTransport(environmentId: string): void { @@ -120,6 +136,21 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { removeDestroyedListener() subscription?.close() } + // Why: the renderer treats close as terminal and drops its handle, so send it once. + // Latch before sending so a re-entrant call cannot duplicate it, and never + // throw: a dying renderer must not abort its siblings' retirement. + let closeNotified = false + const notifyClosed = (): void => { + if (closeNotified || sender.isDestroyed()) { + return + } + closeNotified = true + try { + sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) + } catch { + // The renderer is gone; there is no one left to tell. + } + } sender.once('destroyed', closeSubscription) destroyedListenerAttached = true try { @@ -131,6 +162,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { args.timeoutMs, { onEvent: (payload) => { + if (payload.type === 'close') { + // Why: retirement advances the generation before closing, so gating + // close on it stranded the renderer with a dead subscription. + notifyClosed() + return + } if (transportIsCurrent() && !sender.isDestroyed()) { sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, @@ -172,6 +209,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { environmentId: environment.id, ownerWebContentsId, removeDestroyedListener, + notifyClosed, sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, close: () => { removeDestroyedListener() diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index c7f850452c5..7a7b01563e1 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -20,6 +20,8 @@ const { mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, + mockListConfigHosts, + mockResolveConfigHost, mockNextConnectionManagers, mockNextPortForwardManagers } = vi.hoisted(() => ({ @@ -28,6 +30,7 @@ const { powerMonitorOnMock: vi.fn(), mockSshStore: { listTargets: vi.fn().mockReturnValue([]), + listSuppressedSshConfigAliases: vi.fn().mockReturnValue([]), getTarget: vi.fn(), addTarget: vi.fn(), updateTarget: vi.fn(), @@ -88,10 +91,23 @@ const { callbacksRef: { current: null as unknown } }, mockPortScannerCallbacks: new Map(), + mockListConfigHosts: vi.fn().mockReturnValue({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + mockResolveConfigHost: vi.fn().mockResolvedValue(null), mockNextConnectionManagers: [] as unknown[], mockNextPortForwardManagers: [] as unknown[] })) +vi.mock('../ssh/ssh-config-host-picker', () => ({ + listUserSshConfigHostSummaries: mockListConfigHosts, + resolveUserSshConfigHost: mockResolveConfigHost +})) + vi.mock('electron', () => ({ ipcMain: { handle: handleMock, @@ -436,6 +452,8 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:updateTarget') expect(channels).toContain('ssh:removeTarget') expect(channels).toContain('ssh:importConfig') + expect(channels).toContain('ssh:listConfigHosts') + expect(channels).toContain('ssh:resolveConfigHost') expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') expect(channels).toContain('ssh:terminateSessions') @@ -549,6 +567,35 @@ describe('SSH IPC handlers', () => { expect(result).toEqual({ targets: imported, repoReadoptions: [] }) }) + it('ssh:listConfigHosts loads summaries against current targets', async () => { + mockSshStore.listTargets.mockReturnValue([ + { id: 'ssh-1', label: 'other', host: 'other.com', port: 22, username: 'x' } + ]) + + const result = await handlers.get('ssh:listConfigHosts')!(null, { query: 'oth' }) + + expect(mockSshStore.listTargets).toHaveBeenCalled() + expect(mockListConfigHosts).toHaveBeenCalledWith(mockSshStore.listTargets(), 'oth', [], { + refresh: false + }) + expect(result).toMatchObject({ hosts: [], hasMore: false }) + }) + + // Only a picker (re)open re-reads ~/.ssh/config; filter keystrokes reuse the parse. + it('ssh:listConfigHosts refreshes the parsed config only when asked', async () => { + mockSshStore.listTargets.mockReturnValue([]) + + await handlers.get('ssh:listConfigHosts')!(null, { query: '', refresh: true }) + + expect(mockListConfigHosts).toHaveBeenCalledWith([], '', [], { refresh: true }) + }) + + it('ssh:resolveConfigHost resolves only the selected alias', async () => { + await handlers.get('ssh:resolveConfigHost')!(null, { alias: 'prod' }) + + expect(mockResolveConfigHost).toHaveBeenCalledWith('prod') + }) + it('ssh:connect throws for unknown targetId', async () => { mockSshStore.getTarget.mockReturnValue(undefined) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 995d3e88b72..0dd32ce534e 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -3,15 +3,21 @@ import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' import { appendFileSync } from 'node:fs' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' +import { + listUserSshConfigHostSummaries, + resolveUserSshConfigHost +} from '../ssh/ssh-config-host-picker' import type { SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' +import type { SshAiVaultRelayListParams } from '../../shared/ssh-ai-vault-relay' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, EnrichedDetectedPort, SavedPortForward, + SshConfigHostListArgs, SshRepoReadoption, SshTarget, SshConnectionStatus, @@ -70,6 +76,8 @@ const SSH_IPC_CHANNELS = [ 'ssh:updateTarget', 'ssh:removeTarget', 'ssh:importConfig', + 'ssh:listConfigHosts', + 'ssh:resolveConfigHost', 'ssh:connect', 'ssh:disconnect', 'ssh:terminateSessions', @@ -161,6 +169,21 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { }) } +export async function requestActiveSshAiVaultSessionList( + targetId: string, + params: SshAiVaultRelayListParams, + options: { signal?: AbortSignal; timeoutMs?: number } = {} +): Promise { + if (isRuntimeOwnedSshTargetId(targetId)) { + return null + } + const session = activeSessions.get(targetId) + if (!session) { + throw new Error('SSH relay is not ready') + } + return session.requestAiVaultSessionList(params, options) +} + function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { const prior = targetLifecycleInFlight.get(targetId) const operationPromise = (async () => { @@ -967,6 +990,21 @@ export function registerSshHandlers( return { targets, repoReadoptions } }) + // Why: add-host dialog picks one config entry to prefill the form; does not + // mutate the target store (bulk sync stays on Settings → Import). + ipcMain.handle('ssh:listConfigHosts', (_event, args?: SshConfigHostListArgs) => { + return listUserSshConfigHostSummaries( + sshStore!.listTargets(), + args?.query, + sshStore!.listSuppressedSshConfigAliases(), + { refresh: args?.refresh === true } + ) + }) + + ipcMain.handle('ssh:resolveConfigHost', (_event, args: { alias: string }) => { + return resolveUserSshConfigHost(args.alias) + }) + // ── Connection lifecycle ─────────────────────────────────────────── async function connectTarget(targetId: string): Promise { diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 1f412a6d0e9..2645d98dff5 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -160,6 +160,7 @@ describe('telemetry IPC handlers', () => { count_bucket: 'count_1', bucket_source: 'crossed_now' }) + handler({}, 'daemon_audit_eligibility', {}) expect(trackMock).not.toHaveBeenCalled() expect(getCohortAtEmitMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 02f1b9e16be..c1fe7d0f68d 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,6 +24,7 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_audit_eligibility', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 62be1f63fa1..c83eece63b6 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -8350,6 +8350,7 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { runtime: runtimeStub, + resolvedWorktreeId: worktreeId, localProvider: ptyProvider, onPtyStopped: clearProviderPtyStateMock }) @@ -8364,6 +8365,69 @@ describe('registerWorktreeHandlers', () => { }) }) + // Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:` + // — reusable across hosts — so the sweep must name the owning connection. + it('fences an SSH folder workspace PTY sweep to the owning connection', async () => { + const sshPtyProvider = { id: 'ssh-pty-provider' } as never + const worktreeId = 'repo-folder::/remote/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder', + connectionId: 'conn-1' + }) + getSshPtyProviderMock.mockReturnValue(sshPtyProvider) + // One global meta key can describe the same-id local copy; the resolved repo still owns this delete. + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(getSshPtyProviderMock).toHaveBeenCalledWith('conn-1') + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + resolvedWorktreeId: worktreeId, + resolvedConnectionId: 'conn-1', + localProvider: sshPtyProvider, + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: true, + includeLocalRegistry: false + }) + }) + + it('fences a mirrored runtime folder workspace sweep to its environment', async () => { + const runtimePtyProvider = {} as never + const worktreeId = 'repo-folder::/runtime/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/runtime/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder', + executionHostId: 'runtime:env-1' + }) + getLocalPtyProviderMock.mockReturnValue(runtimePtyProvider) + + await handlers['worktrees:remove'](null, { + worktreeId, + hostId: 'runtime:env-1' + }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + resolvedWorktreeId: worktreeId, + resolvedRuntimeEnvironmentId: 'env-1', + localProvider: runtimePtyProvider, + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: false, + includeLocalRegistry: false + }) + expect(getSshPtyProviderMock).not.toHaveBeenCalled() + }) + it('runs the archive hook on remove when skipArchive is not set', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) @@ -9950,6 +10014,70 @@ describe('registerWorktreeHandlers', () => { expect(callOrder).toEqual(['preflight', 'kill', 'git']) }) + // Regression: `repoId::path` ids repeat across hosts, so an SSH delete used to reach the + // runtime's same-id local (or other-connection) terminals and stop them. + it('fences an SSH worktree delete PTY sweep to the owning connection', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const sshPtyProvider = { id: 'ssh-pty-provider' } as never + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { path: '/remote/repo', head: 'main', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue({}), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + }) + getSshPtyProviderMock.mockReturnValue(sshPtyProvider) + getEffectiveHooksFromConfigMock.mockReturnValue(null) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-ssh::/remote/feature-wt' }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', { + runtime: runtimeStub, + resolvedWorktreeId: 'repo-ssh::/remote/feature-wt', + resolvedConnectionId: 'conn-1', + localProvider: sshPtyProvider, + onPtyStopped: clearProviderPtyStateMock, + requirePhysicalStop: true, + includeLocalRegistry: false + }) + }) + + // The local counterpart still identifies itself by exact id so a selector that resolves + // two hosts can no longer decide which workspace loses its terminals. + it('pins a local worktree delete PTY sweep to the exact worktree id', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.objectContaining({ resolvedWorktreeId: 'repo-1::/workspace/feature-wt' }) + ) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.not.objectContaining({ resolvedConnectionId: expect.anything() }) + ) + }) + // Why (#11960): the PTY gate previously had no escape hatch at all, so a // workspace with an unprovable PTY was unremovable forever. it('forwards an explicit Force Delete to the PTY gate', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b9f2a57cec3..c8dceec6bed 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -169,6 +169,11 @@ async function stopPtysForDestructiveWorktreeRemoval( } const teardownResult = await killAllProcessesForWorktree(worktreeId, { runtime, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection — and the selector lookup this replaces + // throws `selector_ambiguous` the moment two hosts own the id. + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), localProvider: provider, onPtyStopped: clearProviderPtyState, requirePhysicalStop: true, @@ -2290,10 +2295,30 @@ export function registerWorktreeHandlers( } // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { + // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly + // like the git paths — the local inventory must never reach a remote workspace's id. + // The resolved repo is authoritative here: path-derived metadata is shared by + // same-id host copies and can describe a different owner's workspace. + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' await killAllProcessesForWorktree(args.worktreeId, { runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: + ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) diff --git a/src/main/linux-package-update-recovery.test.ts b/src/main/linux-package-update-recovery.test.ts index e3c4a27533f..1d9bf8d8650 100644 --- a/src/main/linux-package-update-recovery.test.ts +++ b/src/main/linux-package-update-recovery.test.ts @@ -611,6 +611,31 @@ describePosix('validation coalescing', () => { expect(hashPasses.count).toBe(2) }) + // Why: a verdict handed to a root package manager must cover the bytes as of the click, not the + // bytes a Copy click started streaming seconds earlier. + it('never reuses an in-flight pass for a pre-install re-proof', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) + const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) + + await expect(installPass).resolves.toEqual({ ok: true }) + await expect(copyPass).resolves.toMatchObject({ ok: true }) + expect(hashPasses.count).toBe(2) + }) + + it('lets a later Copy click join the pre-install pass', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) + const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) + + await Promise.all([installPass, copyPass]) + expect(hashPasses.count).toBe(1) + }) + it('does not reuse an in-flight pass for a different artifact', async () => { await writePackage('orca.deb') await writePackage('orca-next.deb') @@ -629,6 +654,39 @@ describePosix('validation coalescing', () => { }) }) +describePosix('revalidateLinuxPackageForInstall', () => { + it('proves the retained package still matches its release digest', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: true + }) + }) + + it('rejects a package swapped after the download was verified', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await writePackage('orca.deb', 'attacker supplied package') + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: false, + reason: 'hash-mismatch' + }) + }) + + it('reports a package deleted from the cache as missing', async () => { + const filePath = await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await fsp.rm(filePath) + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: false, + reason: 'missing' + }) + }) +}) + describePosix('revealLinuxPackage', () => { it('reveals a verified package on the machine that owns it', async () => { const filePath = await writePackage('orca.deb') diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index 83aca03cb3f..f854b63c5b8 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -241,6 +241,29 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise { + const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` + if (!options?.fresh && inFlightValidation?.key === key) { + return inFlightValidation.promise + } + const promise: Promise = validateArtifact(artifact).finally(() => { + // Why: identity, not key — a fresh install pass may already have replaced this entry. + if (inFlightValidation?.promise === promise) { + inFlightValidation = null + } + }) + inFlightValidation = { key, promise } + return promise +} + /** * Revalidates the retained package against the digest captured from the release metadata. This is * an integrity check against that HTTPS metadata, not a package signature and not a privilege @@ -257,17 +280,7 @@ async function validateTrackedArtifact( ) { return { ok: false, reason: 'missing' } } - const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` - if (inFlightValidation?.key === key) { - return inFlightValidation.promise - } - const promise = validateArtifact(artifact).finally(() => { - if (inFlightValidation?.key === key) { - inFlightValidation = null - } - }) - inFlightValidation = { key, promise } - return promise + return runValidation(artifact) } export async function resolveLinuxPackageInstallInstructions( @@ -289,6 +302,23 @@ export async function resolveLinuxPackageInstallInstructions( } } +/** + * Re-proves the retained package immediately before the privileged installer consumes it. + * + * The cache path is user-writable, so a digest checked when the download finished says nothing + * about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close the race — + * only an immutable handoff would — but it shrinks the window from "since the download" to + * "since this call", and it catches the artifact being swapped or deleted outright. Takes the + * artifact rather than a recovery so both the retry and the plain "Restart to Update" install + * are covered. + */ +export async function revalidateLinuxPackageForInstall( + artifact: LinuxPackageArtifact +): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> { + const validation = await runValidation(artifact, { fresh: true }) + return validation.ok ? { ok: true } : { ok: false, reason: validation.reason } +} + export async function revealLinuxPackage( recovery: LinuxPackageInstallRecovery ): Promise { diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 2b3a6ea68b1..2d6d581599e 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -143,7 +143,7 @@ type TestStore = { waitForPendingWrite(): Promise flushOrThrow(): void flushPendingAsync(): Promise - flushPendingOrThrowAsync(): Promise + flushPendingOrThrowAsync(options?: { drainToStableGeneration?: boolean }): Promise upsertSshPtyConsumerRecovery(record: { targetId: string clientInstanceId: string @@ -185,6 +185,36 @@ function dataFile(dir: string): string { return join(dir, 'orca-data.json') } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function recordFsCalls(dir: string): void { + fsCalls.dirPrefix = dir + fsCalls.recording = true +} + +function delayNextDataFileRename(dir: string): ReturnType & { + started: Promise +} { + const release = deferred() + const started = deferred() + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { + return null + } + held = true + started.resolve() + return release.promise + } + return { ...release, started: started.promise } +} + function seedStaleBackup(dir: string): void { const path = `${dataFile(dir)}.bak.0` writeFileSync(path, '{"stale":true}', 'utf-8') @@ -232,8 +262,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { sidebarWidth: number ): Promise { store.updateUI({ sidebarWidth }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) await store.waitForPendingWrite() @@ -322,8 +351,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') store.updateUI({ sidebarWidth: 361 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) let flushed = false fsCalls.beforeAsync = (fn, target) => { if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) { @@ -351,34 +379,19 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('a sync checkpoint vetoes an async write already parked on rename', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - fsCalls.waitAsync = (fn, target) => { - if (fn !== 'rename' || target === dataFile(dir) || !target.startsWith(dataFile(dir))) { - return null - } - signalRename() - return renameRelease - } + const rename = delayNextDataFileRename(dir) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 501 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const pending = store.waitForPendingWrite() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 502 }) store.flushOrThrow() expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) - releaseRename() + rename.resolve() await pending fsCalls.recording = false fsCalls.waitAsync = null @@ -391,8 +404,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const dir = makeDir() const store = await createStore(dir) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) fsCalls.failAsync = (fn, target) => fn === 'rename' && target.startsWith(dataFile(dir)) && !target.includes('.bak.') ? Object.assign(new Error('mount disappeared'), { code: 'ENOENT' }) @@ -413,31 +425,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('the throwing async barrier drains mutations made during its write', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - signalRename() - return renameRelease - } - fsCalls.dirPrefix = dir - fsCalls.recording = true + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) store.updateUI({ sidebarWidth: 601 }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 602 }) - releaseRename() + rename.resolve() await barrier fsCalls.recording = false @@ -447,31 +442,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('bounds a best-effort flush to one state generation', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - signalRename() - return renameRelease - } - fsCalls.dirPrefix = dir - fsCalls.recording = true + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) store.updateUI({ sidebarWidth: 621 }) const flush = store.flushPendingAsync() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 622 }) - releaseRename() + rename.resolve() await flush fsCalls.recording = false @@ -481,33 +459,96 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(622) }) + it('keeps a bounded barrier open when its staged writer is superseded before rename', async () => { + const dir = makeDir() + const store = await createStore(dir) + const openRelease = deferred() + const openStarted = deferred() + const renameRelease = deferred() + const renameStarted = deferred() + let heldOpen = false + let heldRename = false + fsCalls.waitAsync = (fn, target) => { + if ( + !heldOpen && + fn === 'open' && + target.startsWith(dataFile(dir)) && + target.endsWith('.tmp') + ) { + heldOpen = true + openStarted.resolve() + return openRelease.promise + } + if (!heldRename && fn === 'rename' && target.startsWith(dataFile(dir))) { + heldRename = true + renameStarted.resolve() + return renameRelease.promise + } + return null + } + recordFsCalls(dir) + + store.updateUI({ sidebarWidth: 631 }) + const barrier = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await openStarted.promise + store.updateUI({ sidebarWidth: 632 }) + openRelease.resolve() + const firstOutcome = await Promise.race([ + barrier.then(() => 'settled' as const), + renameStarted.promise.then(() => 'retrying' as const) + ]) + + expect(firstOutcome).toBe('retrying') + renameRelease.resolve() + await barrier + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(632) + }) + + it('rewrites a matching hash after a superseded rename installed stale state', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 641 }) + await store.flushPendingOrThrowAsync() + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) + + store.updateUI({ sidebarWidth: 642 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const staleWrite = store.waitForPendingWrite() + await rename.started + store.updateUI({ sidebarWidth: 641 }) + rename.resolve() + await staleWrite + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(642) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(641) + }) + it('the throwing async barrier drains mutations made during sidecar I/O', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) + const renameRelease = deferred() + const renameStarted = deferred() fsCalls.waitAsync = (fn, target) => { if (fn !== 'rename' || !target.includes('orca-github-cache.json.')) { return null } - signalRename() - return renameRelease + renameStarted.resolve() + return renameRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 611 }) store.setGitHubCache({ pr: {}, issue: {} }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted + await renameStarted.promise store.updateUI({ sidebarWidth: 612 }) - releaseRename() + renameRelease.resolve() await barrier fsCalls.recording = false @@ -522,42 +563,35 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds) const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') const statCall = `stat:${dataFile(dir)}.bak.0` - let releaseRotation!: () => void - const rotationRelease = new Promise((resolve) => { - releaseRotation = resolve - }) - let signalRotation!: () => void - const rotationStarted = new Promise((resolve) => { - signalRotation = resolve - }) + const rotationRelease = deferred() + const rotationStarted = deferred() let held = false fsCalls.waitAsync = (fn, target) => { if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) { return null } held = true - signalRotation() - return rotationRelease + rotationStarted.resolve() + return rotationRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 371 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const firstWrite = store.waitForPendingWrite() let allWrites = firstWrite try { - await rotationStarted + await rotationStarted.promise store.updateUI({ sidebarWidth: 372 }) store.flushOrThrow() store.updateUI({ sidebarWidth: 373 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) allWrites = store.waitForPendingWrite() expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) - releaseRotation() + rotationRelease.resolve() await Promise.all([firstWrite, allWrites]) } finally { - releaseRotation() + rotationRelease.resolve() await allWrites fsCalls.recording = false fsCalls.waitAsync = null @@ -579,8 +613,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const expectedTarget = `${dataFile(dir)}${targetSuffix}` store.updateUI({ sidebarWidth: 363 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) let flushed = false fsCalls.beforeAsync = (fn, target) => { if (flushed || fn !== expectedFn || target !== expectedTarget) { @@ -688,8 +721,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const dir = makeDir() const store = await createStore(dir) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) } finally { @@ -710,8 +742,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) const writeError = Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) fsCalls.failAsync = (fn, target) => fn === 'open' && target.startsWith(`${dataFile(dir)}.`) ? writeError : null @@ -730,8 +761,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.removeSshPtyConsumerRecovery('ssh-1') } finally { @@ -750,8 +780,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached') } finally { @@ -774,8 +803,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { // Why: a PTY that exits mid-reattach is terminated before the batch write lands; it must stay dead. store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-4', state: 'terminated' }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4']) } finally { @@ -799,39 +827,32 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('keeps async writers serialized across a synchronous shutdown flush', async () => { const dir = makeDir() const store = await createStore(dir) - let signalFirstOpen!: () => void - const firstOpen = new Promise((resolve) => { - signalFirstOpen = resolve - }) - let releaseFirstOpen!: () => void - const firstOpenRelease = new Promise((resolve) => { - releaseFirstOpen = resolve - }) + const firstOpen = deferred() + const firstOpenRelease = deferred() let held = false fsCalls.waitAsync = (fn, target) => { if (held || fn !== 'open' || !target.endsWith('.tmp')) { return null } held = true - signalFirstOpen() - return firstOpenRelease + firstOpen.resolve() + return firstOpenRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { const firstWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - await firstOpen + await firstOpen.promise store.flushOrThrow() const secondWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2')) await Promise.resolve() await Promise.resolve() expect(fsCalls.asyncCalls.filter((call) => call.startsWith('open:'))).toHaveLength(1) - releaseFirstOpen() + firstOpenRelease.resolve() await Promise.all([firstWrite, secondWrite]) } finally { - releaseFirstOpen() + firstOpenRelease.resolve() fsCalls.recording = false fsCalls.waitAsync = null } @@ -885,8 +906,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { seedStaleBackup(dir) store.updateUI({ sidebarWidth: 331 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { store.flushOrThrow() } finally { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 40660eb8a1c..f74f5d92644 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2786,6 +2786,7 @@ export class Store { private quitFlushPromise: Promise | null = null // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null + private lastDurableWriteGeneration = -1 private firstPendingSaveAt: number | null = null private githubCacheDirty = false private githubCacheGeneration = 0 @@ -3941,6 +3942,7 @@ export class Store { const { payload, stateHash } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) return } const dataFile = this.dataFile @@ -3976,9 +3978,14 @@ export class Store { this.inFlightAsyncTmpFile = null } } - // Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it. + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + } else if (renamed) { + this.lastWrittenStateHash = null + } + if (renamed) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) } } finally { if (!renamed) { @@ -4020,6 +4027,10 @@ export class Store { writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash + this.lastDurableWriteGeneration = Math.max( + this.lastDurableWriteGeneration, + this.writeGeneration + ) } finally { if (!renamed) { try { @@ -7291,11 +7302,13 @@ export class Store { return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) } - flushPendingOrThrowAsync(options: { signal?: AbortSignal } = {}): Promise { + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { if (this.writesFrozen || this.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return this.flushCurrentStateAsync(false, options.signal) + return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) } // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are @@ -7321,8 +7334,10 @@ export class Store { private async flushCurrentStateAsync( final: boolean, signal?: AbortSignal, - drainToStableGeneration = true + drainToStableGeneration = true, + requireInitialGenerationDurable = false ): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null for (;;) { if (signal?.aborted) { throw new Error('Persistence flush aborted') @@ -7349,7 +7364,16 @@ export class Store { if (signal?.aborted) { throw new Error('Persistence flush aborted') } - if (!drainToStableGeneration || generation === this.writeGeneration) { + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + this.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === this.writeGeneration) { break } } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index b7dcb4399fa..e1c62e9f3cb 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -805,6 +805,31 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + // Why: attribution collapses Windows PATH onto `Path` and prepends its own shim dir. + delete env.PATH + env.Path = `/tmp/orca-attribution:${env.Path ?? ''}` + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + env: { + Path: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + + const spawnEnv = spawnMock.mock.calls.at(-1)![2].env + expect(Object.keys(spawnEnv).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(spawnEnv.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 12418877003..d43158482c0 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' import { @@ -65,6 +66,10 @@ import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { + createPtySlaveEchoProbe, + readPtySlavePath +} from '../../shared/pty-slave-line-discipline-echo' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables @@ -171,8 +176,9 @@ function promoteAgentTeamsShimPath( if (!shimDir) { return } - const currentParts = env.PATH?.split(pathDelimiter).filter(Boolean) ?? [] - env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) } /** @@ -804,8 +810,12 @@ export class LocalPtyProvider implements IPtyProvider { shellReadyLaunch = args.command ? shellLaunch : null } } + const requestedEnv = args.env expandWindowsPathEnvironmentVariables(finalEnv) - promoteAgentTeamsShimPath(finalEnv, args.env?.PATH) + promoteAgentTeamsShimPath( + finalEnv, + requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined + ) // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). const worktreeId = args.worktreeId @@ -916,6 +926,7 @@ export class LocalPtyProvider implements IPtyProvider { ) } } + const startupEchoProbe = createPtySlaveEchoProbe(readPtySlavePath(proc)) const startupIngress = new PtyStartupIngress({ ...(args.startupIngress ? { intent: args.startupIngress } : {}), ownerBackend: resolvePtyOwnerBackend({ @@ -924,7 +935,8 @@ export class LocalPtyProvider implements IPtyProvider { wslDistro: spawnedWslDistro }), write: (data) => proc.write(data), - onEmission: emitIngressData + onEmission: emitIngressData, + ...(startupEchoProbe ? { echoProbe: startupEchoProbe } : {}) }) startupIngressByPty.set(id, startupIngress) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index 4ecf3200e9a..d639ee604ce 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -15,7 +15,8 @@ import { mergePersistedWindowsPath, mergePersistedWindowsPathAsync, readPersistedWindowsPathSegments, - readPersistedWindowsPathSegmentsAsync + readPersistedWindowsPathSegmentsAsync, + resolvePathEnvKey } from './windows-environment-path' type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void @@ -309,4 +310,53 @@ describe('mergePersistedWindowsPath', () => { expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' }) }) + + it('reads the live host spelling when a path-less env inherits duplicate keys', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env: Record = {} + + mergePersistedWindowsPath(env, { + platform: 'win32', + execFileSync, + env: { ROOT: 'C:\\Root', Path: '%ROOT%\\Live', PATH: 'C:\\Shadowed' } + }) + + expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' }) + }) +}) + +describe('resolvePathEnvKey', () => { + it('uses whichever Windows spelling is present on the env', () => { + expect(resolvePathEnvKey({ Path: 'C:\\Windows' }, 'win32')).toBe('Path') + expect(resolvePathEnvKey({ PATH: 'C:\\Windows' }, 'win32')).toBe('PATH') + expect(resolvePathEnvKey({ path: 'C:\\Windows' }, 'win32')).toBe('path') + expect(resolvePathEnvKey({ PaTh: 'C:\\Windows' }, 'win32')).toBe('PaTh') + }) + + // Why: Win32 returns the first case-insensitive match in the block, so a dual-cased env is + // resolved by position; picking by casing would target the spelling the child never sees. + it('resolves a dual-cased Windows env by block order, not casing', () => { + expect(resolvePathEnvKey({ PATH: 'C:\\Live', Path: 'C:\\Shadowed' }, 'win32')).toBe('PATH') + expect(resolvePathEnvKey({ Path: 'C:\\Live', PATH: 'C:\\Shadowed' }, 'win32')).toBe('Path') + }) + + it('skips a path key explicitly set to undefined', () => { + expect(resolvePathEnvKey({ PATH: undefined, Path: 'C:\\Windows' }, 'win32')).toBe('Path') + }) + + it('falls back to the host block spelling for a Windows env with no path key', () => { + // Why: a sparse patch that guesses wrong leaves the daemon's own merge holding both keys. + expect(resolvePathEnvKey({}, 'win32', { PATH: 'C:\\Windows' })).toBe('PATH') + expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Windows' })).toBe('Path') + expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Live', PATH: 'C:\\Shadowed' })).toBe('Path') + expect(resolvePathEnvKey({}, 'win32', {})).toBe('Path') + }) + + it('always resolves `PATH` off Windows so a POSIX `Path` variable is untouched', () => { + expect(resolvePathEnvKey({ Path: '/decoy' }, 'linux')).toBe('PATH') + expect(resolvePathEnvKey({ Path: '/decoy', PATH: '/usr/bin' }, 'darwin')).toBe('PATH') + }) }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 07d68fbd9dc..36032da9aec 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -236,6 +236,32 @@ export function __resetPersistedWindowsPathCacheForTests(): void { pendingPersistedWindowsPathRefresh = undefined } +/** Resolves the first PATH key a Windows child reads from an env block. */ +export function resolvePathEnvKey( + env: NodeJS.ProcessEnv | Record, + platform: NodeJS.Platform, + hostEnv: NodeJS.ProcessEnv = process.env +): string { + if (platform !== 'win32') { + return 'PATH' + } + // Why: the daemon receives a sparse env patch and re-merges its own block underneath it; + // matching the block's spelling stops that merge from resurrecting the other one. + return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' +} + +// Why: Win32 resolves the first case-insensitive key, and object order preserves block order. +function firstWindowsPathEnvKey( + env: NodeJS.ProcessEnv | Record +): string | undefined { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path' && env[key] !== undefined) { + return key + } + } + return undefined +} + function mergeWindowsPathSegments( env: NodeJS.ProcessEnv, persistedSegments: string[], @@ -243,9 +269,10 @@ function mergeWindowsPathSegments( sourceEnv: NodeJS.ProcessEnv ): void { expandWindowsPathEnvironmentVariables(env, platform) - const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathKey = resolvePathEnvKey(env, platform, sourceEnv) const pathDelimiter = getPathDelimiter(platform) - const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentPath = + env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) const currentSegments = splitPathSegments(currentPath, pathDelimiter) const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) const missing = persistedSegments.filter((segment) => { diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index f02d470fc8a..a41668932f0 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -248,4 +248,24 @@ describe('ClaudeAgentTeamsService', () => { ) ).resolves.toMatchObject({ ok: false, exitCode: 1 }) }) + + it('keeps the inherited Windows `Path` instead of minting a truncated `PATH`', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const launch = new ClaudeAgentTeamsService().createLaunchEnv({ + leaderHandle: 'leader-handle', + baseEnv: { Path: 'C:\\Windows\\system32' }, + shimDir: 'C:\\orca-shim', + shimBin: 'C:\\orca.exe' + }) + + expect(Object.keys(launch.env).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(launch.env.Path).toBe('C:\\orca-shim;C:\\Windows\\system32') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) }) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1da810e4a33..1b0199c6bd0 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { splitTmuxCommand } from '../../shared/claude-agent-teams-tmux-compat' import { ClaudeAgentTeamsTmuxDispatcher } from './claude-agent-teams-tmux-dispatcher' +import { resolvePathEnvKey } from '../pty/windows-environment-path' import type { AgentTeam, AgentTeamsLaunchEnv, @@ -30,13 +31,15 @@ export class ClaudeAgentTeamsService { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') const leaderPane = '%1' - const pathValue = [args.shimDir, args.baseEnv.PATH] + // Why: Windows callers pass an env spelt `Path`; reading `PATH` there truncated the launch PATH to just the shim dir. + const pathKey = resolvePathEnvKey(args.baseEnv, process.platform) + const pathValue = [args.shimDir, args.baseEnv[pathKey]] .filter(Boolean) .join(process.platform === 'win32' ? ';' : ':') const tmuxValue = `/tmp/orca-claude-agent-teams/${teamId},0,1` const env: Record = { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - PATH: pathValue, + [pathKey]: pathValue, TMUX: tmuxValue, TMUX_PANE: leaderPane, TERM: 'screen-256color', diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 959b52c9474..7de8af08fde 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -10,6 +10,7 @@ import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' export type { DeviceScope } @@ -22,6 +23,9 @@ export type DeviceEntry = { lastSeenAt: number relayBinding?: RelayDeviceBinding mobilePairingConnectionMode?: MobilePairingConnectionMode + // Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its + // client connects, so the bind decision must be able to tell it apart from a LAN/phone grant. + pairingReach?: RuntimePairingReach } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -52,14 +56,19 @@ export class DeviceRegistry { this.load() } - addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { - return this.createAndPersistDevice(this.devices, name, scope) + addDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { + return this.createAndPersistDevice(this.devices, name, scope, pairingReach) } private createAndPersistDevice( existingDevices: DeviceEntry[], name: string, - scope: DeviceScope + scope: DeviceScope, + pairingReach: RuntimePairingReach ): DeviceEntry { const entry: DeviceEntry = { deviceId: randomUUID(), @@ -67,7 +76,8 @@ export class DeviceRegistry { token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), - lastSeenAt: 0 + lastSeenAt: 0, + pairingReach } const nextDevices = [...existingDevices, entry] // Why: a credential is not valid until its durable registry write succeeds. @@ -82,12 +92,32 @@ export class DeviceRegistry { // copy-button flow that encourages regeneration) leaves an orphaned token // forever. Returns an existing never-scanned entry if present; otherwise // mints a new one and drops any stale pending entries. - getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + getOrCreatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) if (existing) { - return existing + // Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it, + // or a link already handed out for off-host use would stop being served after the next launch. + return pairingReach === 'network' && existing.pairingReach === 'this-computer' + ? this.setPairingReach(existing, 'network') + : existing } - return this.addDevice(name, scope) + return this.addDevice(name, scope, pairingReach) + } + + private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry { + const updated: DeviceEntry = { ...existing, pairingReach } + const nextDevices = this.devices.map((device) => + device.deviceId === existing.deviceId ? updated : device + ) + // Why: persist before the memory swap so a failed write cannot leave the bind decision reading a + // reach that never reached disk. + this.save(nextDevices) + this.devices = nextDevices + return updated } // Why: explicit rotation path for "Regenerate QR" — invalidates any @@ -96,9 +126,13 @@ export class DeviceRegistry { // this, getOrCreatePendingDevice keeps returning the same token forever // until a phone actually pairs, so users have no way to revoke a leaked // pre-pairing token. - rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + rotatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) - return this.createAndPersistDevice(retainedDevices, name, scope) + return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) } removeDevice(deviceId: string): boolean { @@ -197,7 +231,10 @@ export class DeviceRegistry { scope: device.scope === 'runtime' ? 'runtime' : 'mobile', relayBinding: validRelayBinding(device.relayBinding, device.deviceId), mobilePairingConnectionMode: - device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' + device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', + // Why: registries written before this field existed only ever held network-reach grants (phones and + // LAN links), so a missing value must keep binding every interface on reconnect. + pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' })) } catch { this.devices = [] diff --git a/src/main/runtime/forced-sweep-settlement.ts b/src/main/runtime/forced-sweep-settlement.ts new file mode 100644 index 00000000000..e53a0e66ce0 --- /dev/null +++ b/src/main/runtime/forced-sweep-settlement.ts @@ -0,0 +1,122 @@ +import { describeError } from './unstopped-pty-verification' + +// Why: an abandoned sweep is one whose provider ignored the deadline it was already given, +// so this is a second chance, not a second budget — long enough for a shutdown() that is +// mid-kill to finish releasing handles, short enough that Force Delete still feels forced. +export const ABANDONED_SWEEP_GRACE_MS = 2_000 + +export type WorktreeSweepTracker = { + /** Wraps a sweep body so the promise stays observable after its deadline abandons it. */ + track: (run: () => Promise) => () => Promise + /** Resolves when every tracked sweep settled, or the grace expired; true if one is still running. */ + awaitAbandonedSweeps: (graceMs?: number) => Promise +} + +/** + * Keeps the real sweep promises reachable after `settleBeforeDeadline` stops waiting on them. + * + * Why: that helper rejects on its timer without cancelling `run()`, so by construction the + * deadline error can only fire while work is still in flight — and forced removal deletes + * the worktree directory the moment teardown returns. + */ +export function createWorktreeSweepTracker(): WorktreeSweepTracker { + const running = new Set>() + return { + track: + (run: () => Promise) => + () => { + const sweep = run() + running.add(sweep) + // Why: the tracked copy is observed here too, so a rejection this call abandons + // never surfaces as an unhandled rejection in whatever ran next. + void sweep.catch(() => undefined).finally(() => running.delete(sweep)) + return sweep + }, + awaitAbandonedSweeps: async (graceMs = ABANDONED_SWEEP_GRACE_MS) => { + const pending = [...running] + if (pending.length === 0) { + return false + } + let expiry: ReturnType | undefined + const expired = new Promise((resolve) => { + expiry = setTimeout(() => resolve(true), graceMs) + expiry.unref?.() + }) + const stillRunning = await Promise.race([ + Promise.allSettled(pending).then(() => false), + expired + ]) + clearTimeout(expiry) + return stillRunning + } + } +} + +export type WorktreeSweepPromises = { + runtime: Promise<{ stopped: number }> + provider: Promise + registry: Promise +} + +export type ForcedSweepSettlement = { + stopped: { runtimeStopped: number; providerStopped: number; registryStopped: number } + /** A sweep failed outright, so no per-PTY verdict below it can be trusted. */ + incomplete: boolean +} + +/** + * Reconciles the three sweeps for a removal carrying the Force Delete waiver (#11960). + * + * Force goes on to delete files, so every sweep must finish releasing handles first — + * `Promise.all` would abandon the siblings of the first rejection while they were still + * inside shutdown(). Waiting on the tracked promises rather than only on their deadline + * wrappers is what makes that true for the timeout case as well. + */ +export async function settleSweepsForForcedRemoval( + worktreeId: string, + sweeps: WorktreeSweepPromises, + tracker: WorktreeSweepTracker, + deadlineError: Error +): Promise { + const settled = await Promise.allSettled([sweeps.runtime, sweeps.provider, sweeps.registry]) + const [runtimeSettled, providerSettled, registrySettled] = settled + // Report what the surviving sweeps actually stopped rather than a flat zero. + const stopped = { + runtimeStopped: runtimeSettled.status === 'fulfilled' ? runtimeSettled.value.stopped : 0, + providerStopped: providerSettled.status === 'fulfilled' ? providerSettled.value : 0, + registryStopped: registrySettled.status === 'fulfilled' ? registrySettled.value : 0 + } + const reasons = settled.flatMap((result) => + result.status === 'rejected' ? [result.reason as unknown] : [] + ) + const stillRunning = await tracker.awaitAbandonedSweeps() + if (reasons.length === 0 && !stillRunning) { + return { stopped, incomplete: false } + } + // Why (#11960): a sweep that cannot even complete — unresponsive daemon, dropped SSH + // channel — fails before the unproven-stop gate could offer its escape hatch, so an + // explicit Force Delete has to survive it. Report every reason, specific ones first: the + // shared deadline sentinel only says that *something* timed out, so leading with it would + // bury the provider error that actually explains the failure. This warning is the only + // trace of a removal that deleted files without proving a single stop. + const detail = [ + ...reasons.filter((candidate) => candidate !== deadlineError), + ...reasons.filter((candidate) => candidate === deadlineError) + ].map(describeError) + if (stillRunning) { + // Force must never wedge, so the wait is bounded — but "we stopped waiting" is not + // "handles released", and on Windows/WSL that is the difference between a clean + // removal and a half-deleted directory. Say so instead of implying the sweep finished. + detail.push( + `a sweep was still running after the ${ABANDONED_SWEEP_GRACE_MS}ms grace, so its PTY handles may outlive the delete` + ) + } + console.warn( + `[worktree-teardown] forcing removal after an incomplete PTY sweep for ${worktreeId} — ${detail.join('; ')}` + ) + // Reporting `incomplete` does skip the per-PTY verdict, which could still have named live + // PTYs when only one sweep failed — accepted for now because this path is behind an + // explicit Force Delete, deletes either way, and clears no registry rows, so the cost is + // diagnosability rather than safety. + return { stopped, incomplete: reasons.length > 0 } +} diff --git a/src/main/runtime/graph-sync-payload-partition.test.ts b/src/main/runtime/graph-sync-payload-partition.test.ts new file mode 100644 index 00000000000..6533a53f9fb --- /dev/null +++ b/src/main/runtime/graph-sync-payload-partition.test.ts @@ -0,0 +1,264 @@ +/** + * The renderer withholds unchanged mobile-session snapshots from the graph + * payload. Main must treat those worktrees as live rather than pruning them as + * removed, and must ask for a republish of any it no longer holds. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot, + RuntimeSyncWindowGraphResult +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const WT_A = 'repo-1::/tmp/worktree-a' +const WT_B = 'repo-1::/tmp/worktree-b' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeSession(overrides: Partial = {}): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT_A, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function makeTerminalTab(id: string, ptyId: string) { + return { + id, + ptyId, + worktreeId: WT_A, + title: `Terminal ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map +} + +function createRuntime() { + let session = makeSession() + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const setSession = (next: WorkspaceSessionState): void => { + session = next + } + const sync = ( + mobileSessionTabs: RuntimeMobileSessionTabsSnapshot[], + unchangedMobileSessionWorktrees?: string[] + ): RuntimeSyncWindowGraphResult => + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs, + ...(unchangedMobileSessionWorktrees ? { unchangedMobileSessionWorktrees } : {}) + }) + return { runtime, events, sync, setSession, internals: runtime as unknown as RuntimeInternals } +} + +function makeSnapshot(worktree: string, version: number): RuntimeMobileSessionTabsSnapshot { + return { + worktree, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: `Terminal ${worktree}`, + isActive: true + } + ] + } +} + +describe('graph-sync mobile payload partition', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps a withheld worktree alive instead of pruning it as removed', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + const storedB = internals.mobileSessionTabsByWorktree.get(WT_B) + expect(storedB).toBeDefined() + events.length = 0 + + // A publishes a change; B is withheld as unchanged rather than omitted. + sync([makeSnapshot(WT_A, 3)], [WT_B]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.get(WT_B)).toBe(storedB) + expect(events.filter((event) => 'removed' in event && event.removed === true)).toEqual([]) + expect(events.map((event) => event.worktree)).toEqual([WT_A]) + }) + + it('still prunes a worktree the renderer omits from both lists', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + sync([makeSnapshot(WT_A, 3)], []) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + const removed = events.filter((event) => 'removed' in event && event.removed === true) + expect(removed.map((event) => event.worktree)).toEqual([WT_B]) + }) + + it('asks for a republish of a withheld worktree it dropped, and accepts the resend', () => { + const { sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + + // Main drops B on its own (worktree metadata removal) while the renderer + // still believes B is delivered. + internals.mobileSessionTabsByWorktree.delete(WT_B) + + const result = sync([], [WT_A, WT_B]) + vi.advanceTimersByTime(300) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_B]) + expect(internals.mobileSessionTabsByWorktree.has(WT_A)).toBe(true) + + // The renderer republishes B at the SAME (epoch, version) it last sent. The + // accept gate must no longer treat that as an already-accepted no-op. + const resend = sync([makeSnapshot(WT_B, 2)], [WT_A]) + vi.advanceTimersByTime(300) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect(internals.mobileSessionTabsByWorktree.get(WT_B)?.tabs).toEqual([ + expect.objectContaining({ parentTabId: 'tab-1' }) + ]) + }) + + it('asks for a republish when headless hydration masks a dropped renderer snapshot', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + internals.mobileSessionTabsByWorktree.delete(WT_A) + + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.publicationEpoch).toMatch( + /^headless-hydrated:/ + ) + + const resend = sync([makeSnapshot(WT_A, 1)], []) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id)) + ).toEqual(['tab-1', 'serve-tab']) + }) + + it('requests the accepted revision again to prune a stale preserved tab', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + + setSession(makeSession()) + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + + sync([makeSnapshot(WT_A, 1)], []) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(false) + }) + + it('does not request a renderer tab rejected by the retirement fence', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + terminalTopologyRevisionByRepoId: { 'repo-1': 1 } + }) + ) + + sync([makeSnapshot(WT_A, 1)]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.tabs).toEqual([]) + + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + }) + + it('leaves the legacy full-payload contract untouched when no list is sent', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + // No unchangedMobileSessionWorktrees field at all: omission still prunes. + sync([makeSnapshot(WT_A, 3)]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + expect( + events.filter((event) => 'removed' in event && event.removed === true).map((e) => e.worktree) + ).toEqual([WT_B]) + }) +}) diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts index f218f3a2c4f..22cf484a13e 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -64,14 +64,23 @@ describe('stopMissingWorktreeTerminals', () => { const localProvider = createProvider([`${deletedId}@@local-session`]) const sshProvider = createProvider([`${deletedId}@@ssh-session`]) const getSshProvider = vi.fn(() => sshProvider) + const runtime = createRuntime() await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], { - runtime: createRuntime(), + runtime, getLocalProvider: () => localProvider, getSshProvider }) expect(getSshProvider).toHaveBeenCalledWith('ssh-1') + // The runtime graph holds both hosts' terminals under one id, so the sweep must fence to this one. + expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith( + deletedId, + expect.objectContaining({ + resolvedWorktreeId: deletedId, + resolvedConnectionId: 'ssh-1' + }) + ) expect(sshProvider.shutdown).toHaveBeenCalledWith( `${deletedId}@@ssh-session`, expect.objectContaining({ immediate: true }) @@ -95,7 +104,12 @@ describe('stopMissingWorktreeTerminals', () => { ) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId) + // The graph fallback still names the owning connection: this repo's inventory must not + // stop a same-id workspace's terminals on another host. + expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId, { + resolvedWorktreeId: deletedId, + resolvedConnectionId: 'ssh-1' + }) }) // Why: an agent cleaning up workspaces deletes many at once. Enumerating the diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts index b25c4fc9f83..6c4f7f762a4 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts @@ -46,6 +46,18 @@ function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider { }) } +// Why: `repoId::path` ids repeat across hosts, so a sweep driven by one repo's inventory +// must name its owner or it stops a same-id workspace's terminals on another host. +function hostFence( + repo: Repo, + worktreeId: string +): { resolvedWorktreeId: string; resolvedConnectionId?: string } { + return { + resolvedWorktreeId: worktreeId, + ...(repo.connectionId ? { resolvedConnectionId: repo.connectionId } : {}) + } +} + type MissingWorktreeTerminalReconciliationDeps = { runtime: OrcaRuntimeService getLocalProvider: () => IPtyProvider | null @@ -83,7 +95,7 @@ export async function stopMissingWorktreeTerminals( MISSING_WORKTREE_TEARDOWN_CONCURRENCY, async (worktreeId) => { try { - await deps.runtime.stopTerminalsForWorktree(worktreeId) + await deps.runtime.stopTerminalsForWorktree(worktreeId, hostFence(repo, worktreeId)) return worktreeId } catch { return null @@ -102,6 +114,7 @@ export async function stopMissingWorktreeTerminals( try { await killAllProcessesForWorktree(worktreeId, { runtime: deps.runtime, + ...hostFence(repo, worktreeId), localProvider: provider, onPtyStopped: deps.onPtyStopped, // Why: the shared process snapshot is only valid while nothing needs a diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 379372dd0ac..e1da3403d80 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -141,9 +141,11 @@ function mockLocalPathStats(entries: Record) { function createRuntimeFileCommands(options?: { path?: string + hostId?: string openFile?: ReturnType openDiff?: ReturnType resolveRuntimeFileTarget?: ReturnType + resolveKnownWorkspaceFileTarget?: ReturnType resolveRuntimeGitTarget?: ReturnType resolveTerminalCwd?: ReturnType resolveTerminalContext?: ReturnType @@ -157,7 +159,8 @@ function createRuntimeFileCommands(options?: { const worktree = { id: 'wt-1', repoId: 'repo-1', - path + path, + ...(options?.hostId ? { hostId: options.hostId } : {}) } const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', @@ -169,6 +172,9 @@ function createRuntimeFileCommands(options?: { worktree, connectionId: store.getRepo(worktree.repoId)?.connectionId })), + ...(options?.resolveKnownWorkspaceFileTarget + ? { resolveKnownWorkspaceFileTarget: options.resolveKnownWorkspaceFileTarget } + : {}), resolveTerminalCwd: options?.resolveTerminalCwd ?? vi.fn(() => path), resolveTerminalContext: options?.resolveTerminalContext ?? @@ -882,6 +888,194 @@ describe('RuntimeFileCommands', () => { }) }) + it('resolves an absolute path through a known sibling workspace', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + relativePath: 'docs/readme.md' + })) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget + }) + statAsFile() + + const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/sibling/docs/readme.md', + 'local' + ) + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + absolutePath: '/sibling/docs/readme.md', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'docs/readme.md', + absolutePath: '/sibling/docs/readme.md' + } + }) + }) + + it('resolves an exact local sibling workspace root as a directory without a grant', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + relativePath: '' + })) + const hasRecentTerminalOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget, + hasRecentTerminalOutputPath + }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => true }) + + const result = await resolveTerminalArtifactPath(commands, '/sibling') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'local') + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: '', + absolutePath: '/sibling', + exists: true, + isDirectory: true, + openTarget: undefined + }) + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() + }) + + it('stats a sibling SSH workspace through its owning provider', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + hostId: 'ssh:ssh-1', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + connectionId: 'ssh-1', + relativePath: 'docs/readme.md' + })) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const remoteStat = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 3 }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) + + const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/sibling/docs/readme.md', + 'ssh:ssh-1' + ) + expect(remoteStat).toHaveBeenCalledWith('/sibling/docs/readme.md') + expect(statMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + exists: true, + openTarget: { provider: 'ssh' } + }) + }) + + it('stats an exact SSH sibling root as a directory through its owning provider', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + hostId: 'ssh:ssh-1', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + connectionId: 'ssh-1', + relativePath: '' + })) + const hasRecentTerminalOutputPath = vi.fn(() => true) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget, + hasRecentTerminalOutputPath + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const remoteStat = vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 3 }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) + + const result = await resolveTerminalArtifactPath(commands, '/sibling') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'ssh:ssh-1') + expect(remoteStat).toHaveBeenCalledWith('/sibling') + expect(statMock).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: '', + absolutePath: '/sibling', + exists: true, + isDirectory: true, + openTarget: undefined + }) + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() + }) + + it('scopes sibling lookup to the selected worktree execution host', async () => { + const resolveKnownWorkspaceFileTarget = vi.fn(async () => null) + const { commands } = createRuntimeFileCommands({ + path: '/repo-a', + hostId: 'runtime:env-a', + resolveKnownWorkspaceFileTarget + }) + + await commands.resolveTerminalPath('id:wt-1', '/repo-b/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/repo-b/docs/readme.md', + 'runtime:env-a' + ) + }) + it('resolves a relative path against the provided cwd', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) statAsFile() @@ -950,14 +1144,44 @@ describe('RuntimeFileCommands', () => { expect(result).toMatchObject({ relativePath: 'docs/readme.md', exists: true }) }) - it('reports a directory', async () => { + it('reports directories including the workspace root', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) statMock.mockResolvedValue({ isDirectory: () => true }) - const result = await commands.resolveTerminalPath('id:wt-1', '/repo/src') + const nestedResult = await commands.resolveTerminalPath('id:wt-1', '/repo/src') + const rootResult = await commands.resolveTerminalPath('id:wt-1', '/repo') - expect(result).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true }) + expect(nestedResult).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true }) + expect(rootResult).toMatchObject({ + relativePath: '', + isDirectory: true, + exists: true, + openTarget: undefined + }) + }) + + it('keeps an exact selected nested root instead of resolving it to a parent workspace', async () => { + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-parent', repoId: 'repo-parent', path: '/repo' }, + relativePath: 'nested' + })) + const { commands } = createRuntimeFileCommands({ + path: '/repo/nested', + resolveKnownWorkspaceFileTarget + }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => true }) + + const result = await commands.resolveTerminalPath('id:wt-1', '/repo/nested') + + expect(resolveKnownWorkspaceFileTarget).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: '', + isDirectory: true, + openTarget: undefined + }) }) it('returns an absolute open target for an existing local temp path outside the worktree', async () => { diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 1ea6769ff4c..08f18da2de0 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -87,7 +87,7 @@ import { } from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -490,11 +490,24 @@ export type ResolvedRuntimeFileTarget = { connectionId?: string } +export function getRuntimeFileTargetExecutionHostId( + target: ResolvedRuntimeFileTarget +): ExecutionHostId { + return ( + target.worktree.hostId ?? + (target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local') + ) +} + export type RuntimeFileCommandHost = { getRuntimeId(): string requireStore(): Store resolveWorktreeSelector(selector: string): Promise resolveRuntimeFileTarget(selector: string): Promise + resolveKnownWorkspaceFileTarget?( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null> resolveTerminalCwd?(terminalHandle: string): string | null | Promise resolveTerminalContext?( terminalHandle: string @@ -744,15 +757,28 @@ export class RuntimeFileCommands { terminalFileUriHostname }) const relativePath = relativePathInsideRoot(worktree.path, absolutePath) + const knownWorkspaceTarget = + relativePath === null + ? await this.host.resolveKnownWorkspaceFileTarget?.( + absolutePath, + getRuntimeFileTargetExecutionHostId(target) + ) + : null + const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree + const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId + const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath try { - if (relativePath !== null && relativePath !== '' && isSafeMobileRelativePath(relativePath)) { - const stats = connectionId - ? await this.statRemoteTerminalPath(absolutePath, connectionId) + if ( + ownedRelativePath !== null && + (ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath)) + ) { + const stats = ownedConnectionId + ? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId) : await stat(await resolveAuthorizedPath(absolutePath, store)) return { - worktree: worktree.id, - relativePath, + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, absolutePath, exists: true, isDirectory: stats.isDirectory(), @@ -760,8 +786,8 @@ export class RuntimeFileCommands { ? undefined : { kind: 'worktree-file', - provider: connectionId ? 'ssh' : 'local', - relativePath, + provider: ownedConnectionId ? 'ssh' : 'local', + relativePath: ownedRelativePath, absolutePath } } @@ -832,9 +858,14 @@ export class RuntimeFileCommands { // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. if ( isENOENT(error) || - (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + (ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) ) { - return { ...empty, relativePath, absolutePath } + return { + ...empty, + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, + absolutePath + } } throw error } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 47891ff1f4c..722acf67937 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3943,7 +3943,9 @@ describe('OrcaRuntimeService', () => { displayName: 'Folder', badgeColor: 'blue', addedAt: 1, - kind: 'folder' as const + kind: 'folder' as const, + // removeManagedWorktree executes inside this selected runtime, where PTYs are local ids. + executionHostId: 'runtime:env-1' as const } const rootWorktreeId = 'folder-repo::/workspace/folder' const rootPriorWorktreeIds = ['folder-repo::/workspace/old-folder'] @@ -5923,6 +5925,66 @@ describe('OrcaRuntimeService', () => { expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`) }) + // Regression: `repoId::path` ids repeat across hosts, so the SSH delete's runtime sweep used to + // stop the same-id local workspace's terminals too. + it('leaves a same-id local terminal running when the SSH copy is removed', async () => { + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const remoteStore = { ...store, getRepos: () => [remoteRepo], getRepo: () => remoteRepo } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const ptyProvider = { + listProcesses: vi.fn().mockResolvedValue([]), + shutdown: vi.fn().mockResolvedValue(undefined) + } + const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { + getSshProvider: () => ptyProvider as never + }) + const stopAndWait = vi.fn(async () => true) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, null) + runtime.registerPty('pty-remote', `${TEST_REPO_ID}::/remote/feature`, 'ssh-1') + runtime.registerPty('pty-local-same-id', `${TEST_REPO_ID}::/remote/feature`, null) + + try { + await runtime.removeManagedWorktree('path:/remote/feature', true, false) + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(stopAndWait).toHaveBeenCalledWith('pty-remote', expect.anything()) + expect(stopAndWait).not.toHaveBeenCalledWith('pty-local-same-id', expect.anything()) + }) + it('rejects SSH-backed runtime removal of the main worktree before provider deletion', async () => { const remoteStore = { ...store, @@ -10849,6 +10911,62 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not treat a restored-unconfirmed hook row as live terminal status', async () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'waiting', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + restoredUnconfirmed: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + }) + it('does not let stale wait text override a fresh explicit working state', async () => { const leafId = '11111111-1111-4111-8111-111111111111' const paneKey = makePaneKey('tab-1', leafId) @@ -34439,6 +34557,31 @@ describe('OrcaRuntimeService', () => { expect(kill).not.toHaveBeenCalled() }) + it('stops only the owning connection when one worktree id lives on two hosts', async () => { + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, null) + // The store keeps one `repoId::path` per host, so deleting the SSH copy must leave the + // local copy's terminals running — the fence the destructive removal paths now supply. + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-1') + runtime.registerPty('pty-local', TEST_WORKTREE_ID, null) + + await expect( + runtime.stopTerminalsForWorktree(TEST_WORKTREE_ID, { + resolvedWorktreeId: TEST_WORKTREE_ID, + resolvedConnectionId: 'ssh-1' + }) + ).resolves.toEqual({ stopped: 1 }) + expect(kill).toHaveBeenCalledWith('pty-ssh') + expect(kill).not.toHaveBeenCalledWith('pty-local') + }) + it('awaits physical PTY stop when destructive teardown supplies shared dedupe', async () => { const runtime = new OrcaRuntimeService(store) const physicalStop = makeDeferred() @@ -37801,6 +37944,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-1', + run_id: 'run-1', task_id: 'task-1', assignee_handle: workerHandle, status: 'dispatched' @@ -37811,6 +37955,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-done', + run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', @@ -37820,14 +37965,16 @@ describe('OrcaRuntimeService', () => { ), getTask: vi.fn(() => ({ id: 'task-1', + run_id: 'run-1', task_title: 'Dispatch prompt work', display_name: 'Review dispatch prompts and make worker labels distinct', spec: 'Review dispatch prompts\n\nand make worker labels distinct', created_by_terminal_handle: coordinatorHandle })), - getActiveCoordinatorRun: vi.fn(() => ({ + getRun: vi.fn(() => ({ id: 'run-1', - coordinator_handle: coordinatorHandle + coordinator_handle: coordinatorHandle, + legacy: 0 })) } as never) runtime.attachWindow(1) @@ -37882,6 +38029,513 @@ describe('OrcaRuntimeService', () => { }) }) + it.each([ + ['fails closed when a modern dispatch owning Run is missing', 'run-missing', 'run-missing'], + ['fails closed when Task and Dispatch Runs disagree', 'run-dispatch', 'run-task'] + ])('%s', (_name, dispatchRunId, taskRunId) => { + const runtime = new OrcaRuntimeService(store) + const workerLeafId = '77777777-7777-4777-8777-777777777777' + const coordinatorLeafId = '88888888-8888-4888-8888-888888888888' + const workerPaneKey = makePaneKey('tab-worker', workerLeafId) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + const getActiveCoordinatorRun = vi.fn(() => ({ + id: 'run-legacy-unrelated', + coordinator_handle: coordinatorHandle + })) + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn((handle: string) => + handle === workerHandle + ? { + id: 'ctx-missing-run', + run_id: dispatchRunId, + task_id: 'task-missing-run', + assignee_handle: workerHandle, + status: 'dispatched' + } + : undefined + ), + getLatestDispatchForTerminal: vi.fn(() => undefined), + getTask: vi.fn(() => ({ + id: 'task-missing-run', + run_id: taskRunId, + spec: 'modern task without proven Run', + created_by_terminal_handle: coordinatorHandle + })), + getRun: vi.fn(() => undefined), + getActiveCoordinatorRun + } as never) + runtime.attachWindow(1) + + const result = runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + title: 'Worker', + activeLeafId: workerLeafId, + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + title: 'Coordinator', + activeLeafId: coordinatorLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + leafId: workerLeafId, + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + leafId: coordinatorLeafId, + paneRuntimeId: 2, + ptyId: 'pty-coordinator', + paneTitle: null + } + ] + }) + + expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({ + taskId: 'task-missing-run', + dispatchId: 'ctx-missing-run', + dispatchStatus: 'dispatched', + taskTitle: 'modern task without proven Run', + displayName: 'modern task without proven Run' + }) + expect(getActiveCoordinatorRun).not.toHaveBeenCalled() + }) + + it('uses durable Run ownership before worktree-scoped legacy attribution', () => { + const childWorktreeId = `${TEST_REPO_ID}::${join(tmpdir(), 'workspaces', 'run-a-worker')}` + const folderWorktreeId = `${TEST_REPO_ID}::${join(tmpdir(), 'folder')}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111` + const meta = store.getAllWorktreeMeta()[TEST_WORKTREE_ID] + const metaById = { + ...store.getAllWorktreeMeta(), + [childWorktreeId]: meta, + [folderWorktreeId]: meta + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + const terminals = [ + { + name: 'coordinator-a', + worktreeId: TEST_WORKTREE_ID, + leafId: '11111111-1111-4111-8111-111111111111' + }, + { + name: 'coordinator-b', + worktreeId: TEST_WORKTREE_ID, + leafId: '22222222-2222-4222-8222-222222222222' + }, + { + name: 'worker-cross-worktree', + worktreeId: childWorktreeId, + leafId: '33333333-3333-4333-8333-333333333333' + }, + { + name: 'worker-same-worktree', + worktreeId: TEST_WORKTREE_ID, + leafId: '44444444-4444-4444-8444-444444444444' + }, + { + name: 'worker-folder', + worktreeId: folderWorktreeId, + leafId: '55555555-5555-4555-8555-555555555555' + }, + { + name: 'legacy-worker', + worktreeId: childWorktreeId, + leafId: '66666666-6666-4666-8666-666666666666' + } + ].map((terminal, index) => ({ + ...terminal, + tabId: `tab-${terminal.name}`, + ptyId: `pty-${terminal.name}`, + paneRuntimeId: index + 1 + })) + const terminalByName = Object.fromEntries( + terminals.map((terminal) => [terminal.name, terminal]) + ) + const handles = Object.fromEntries( + terminals.map((terminal) => [terminal.name, runtime.preAllocateHandleForPty(terminal.ptyId)]) + ) + const paneKey = (name: string): string => { + const terminal = terminalByName[name] + return makePaneKey(terminal.tabId, terminal.leafId) + } + const db = new OrchestrationDb(':memory:') + try { + const runA = db.createRun({ + objective: 'coordinate run A', + coordinatorHandle: handles['coordinator-a'], + coordinatorPaneKey: paneKey('coordinator-a') + }) + const runB = db.createRun({ + objective: 'coordinate run B', + coordinatorHandle: handles['coordinator-b'], + coordinatorPaneKey: paneKey('coordinator-b') + }) + const dispatches = Object.fromEntries( + [ + ['worker-cross-worktree', runA.id], + ['worker-same-worktree', runA.id], + ['worker-folder', runB.id] + ].map(([name, runId]) => { + const task = db.createTask({ spec: name, runId }) + return [name, db.createDispatchContext(task.id, handles[name], paneKey(name))] + }) + ) + const legacyTask = db.createTask({ spec: 'legacy worker' }) + const legacyDispatch = db.createDispatchContext( + legacyTask.id, + handles['legacy-worker'], + paneKey('legacy-worker') + ) + db.createCoordinatorRun({ + spec: 'unrelated legacy coordinator', + coordinatorHandle: handles['coordinator-b'] + }) + const getActiveCoordinatorRun = vi.spyOn(db, 'getActiveCoordinatorRun') + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + + const result = runtime.syncWindowGraph(1, { + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: terminal.worktreeId, + title: terminal.name, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: terminal.worktreeId, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + }) + + for (const [name, run, coordinator] of [ + ['worker-cross-worktree', runA, 'coordinator-a'], + ['worker-same-worktree', runA, 'coordinator-a'], + ['worker-folder', runB, 'coordinator-b'] + ] as const) { + expect(result.agentOrchestrationByPaneKey?.[paneKey(name)]).toMatchObject({ + taskId: dispatches[name].task_id, + dispatchId: dispatches[name].id, + dispatchStatus: 'dispatched', + parentTerminalHandle: handles[coordinator], + parentPaneKey: paneKey(coordinator), + coordinatorHandle: handles[coordinator], + orchestrationRunId: run.id + }) + } + const legacyContext = result.agentOrchestrationByPaneKey?.[paneKey('legacy-worker')] + expect(legacyContext).toMatchObject({ + taskId: legacyTask.id, + dispatchId: legacyDispatch.id, + dispatchStatus: 'dispatched' + }) + expect(legacyContext).not.toHaveProperty('parentTerminalHandle') + expect(legacyContext).not.toHaveProperty('coordinatorHandle') + expect(legacyContext).not.toHaveProperty('orchestrationRunId') + expect(getActiveCoordinatorRun).toHaveBeenCalledOnce() + } finally { + db.close() + } + }) + + it('uses the still-bound owning Run coordinator after a creator pane rebinds', () => { + const runtime = new OrcaRuntimeService(store) + const terminals = [ + { + name: 'coordinator', + leafId: '11111111-1111-4111-8111-111111111111' + }, + { + name: 'creator', + leafId: '22222222-2222-4222-8222-222222222222' + }, + { + name: 'worker', + leafId: '33333333-3333-4333-8333-333333333333' + }, + { + name: 'coordinator-created-worker', + leafId: '44444444-4444-4444-8444-444444444444' + } + ].map((terminal, index) => ({ + ...terminal, + tabId: `tab-${terminal.name}`, + ptyId: `pty-${terminal.name}`, + paneRuntimeId: index + 1 + })) + const terminalByName = Object.fromEntries( + terminals.map((terminal) => [terminal.name, terminal]) + ) + const handles = Object.fromEntries( + terminals.map((terminal) => [terminal.name, runtime.preAllocateHandleForPty(terminal.ptyId)]) + ) + const paneKey = (name: string): string => { + const terminal = terminalByName[name] + return makePaneKey(terminal.tabId, terminal.leafId) + } + const graph = () => ({ + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + title: terminal.name, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + }) + const db = new OrchestrationDb(':memory:') + try { + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, graph()) + const runA = db.createRun({ + objective: 'own the nested worker', + coordinatorHandle: handles.coordinator, + coordinatorPaneKey: paneKey('coordinator') + }) + const creatorAuthority = runtime.getOrchestrationDispatchAuthority(handles.creator) + const coordinatorAuthority = runtime.getOrchestrationDispatchAuthority(handles.coordinator) + expect(creatorAuthority?.processIncarnation).toBeTruthy() + expect(coordinatorAuthority?.processIncarnation).toBeTruthy() + const creatorTask = db.createTask({ spec: 'create nested work', runId: runA.id }) + db.createDispatchContext( + creatorTask.id, + handles.creator, + paneKey('creator'), + undefined, + creatorAuthority?.processIncarnation ?? undefined + ) + const workerTask = db.createTask({ + spec: 'nested work', + runId: runA.id, + createdByTerminalHandle: handles.creator, + createdByPaneKey: paneKey('creator'), + createdByProcessIncarnation: creatorAuthority?.processIncarnation ?? undefined, + createdByRunGeneration: runA.consumer_generation + }) + const workerDispatch = db.createDispatchContext( + workerTask.id, + handles.worker, + paneKey('worker') + ) + const coordinatorCreatedTask = db.createTask({ + spec: 'coordinator-created work', + runId: runA.id, + createdByTerminalHandle: handles.coordinator, + createdByPaneKey: paneKey('coordinator'), + createdByProcessIncarnation: coordinatorAuthority?.processIncarnation ?? undefined, + createdByRunGeneration: runA.consumer_generation + }) + const coordinatorCreatedDispatch = db.createDispatchContext( + coordinatorCreatedTask.id, + handles['coordinator-created-worker'], + paneKey('coordinator-created-worker') + ) + expect( + runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[paneKey('worker')] + ).toMatchObject({ + parentTerminalHandle: handles.creator, + parentPaneKey: paneKey('creator'), + coordinatorHandle: handles.coordinator, + orchestrationRunId: runA.id + }) + + const oldCreatorPaneKey = paneKey('creator') + terminalByName.creator.tabId = 'tab-creator-reminted' + terminalByName.creator.ptyId = 'pty-creator-reminted' + const remintedCreatorHandle = runtime.preAllocateHandleForPty(terminalByName.creator.ptyId) + runtime.syncWindowGraph(1, graph()) + const runB = db.createRun({ + objective: 'rebind the creator pane', + coordinatorHandle: remintedCreatorHandle, + coordinatorPaneKey: paneKey('creator') + }) + const reboundContext = runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[ + paneKey('worker') + ] + + expect(db.getRun(runA.id)).toMatchObject({ + coordinator_handle: handles.coordinator, + consumer_generation: 1 + }) + expect(oldCreatorPaneKey).not.toBe(paneKey('creator')) + expect(db.getRun(runB.id)).toMatchObject({ coordinator_handle: remintedCreatorHandle }) + expect(reboundContext).toMatchObject({ + taskId: workerTask.id, + dispatchId: workerDispatch.id, + dispatchStatus: 'dispatched', + parentTerminalHandle: handles.coordinator, + parentPaneKey: paneKey('coordinator'), + coordinatorHandle: handles.coordinator, + orchestrationRunId: runA.id + }) + + db.createRun({ + objective: 'rebind the original coordinator pane', + coordinatorHandle: handles.coordinator, + coordinatorPaneKey: paneKey('coordinator') + }) + const unboundContext = runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[ + paneKey('coordinator-created-worker') + ] + + expect(db.getRun(runA.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + consumer_generation: 2 + }) + expect(unboundContext).toEqual({ + taskId: coordinatorCreatedTask.id, + dispatchId: coordinatorCreatedDispatch.id, + dispatchStatus: 'dispatched', + taskTitle: 'coordinator-created work', + displayName: 'coordinator-created work', + orchestrationRunId: runA.id + }) + } finally { + db.close() + } + }) + + it('queries each stable terminal handle once while publishing orchestration context', () => { + const runtime = new OrcaRuntimeService(store) + const terminals = Array.from({ length: 100 }, (_, index) => ({ + tabId: `tab-query-${index}`, + leafId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ptyId: `pty-query-${index}`, + paneRuntimeId: index + 1 + })) + const handles = terminals.map((terminal) => runtime.preAllocateHandleForPty(terminal.ptyId)) + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'query count oracle', + coordinatorHandle: handles[99], + coordinatorPaneKey: makePaneKey(terminals[99].tabId, terminals[99].leafId) + }) + const task = db.createTask({ spec: 'one dispatched terminal', runId: run.id }) + const dispatch = db.createDispatchContext( + task.id, + handles[0], + makePaneKey(terminals[0].tabId, terminals[0].leafId) + ) + const getActiveDispatchForTerminal = vi.spyOn(db, 'getActiveDispatchForTerminal') + const getLatestDispatchForTerminal = vi.spyOn(db, 'getLatestDispatchForTerminal') + const getTask = vi.spyOn(db, 'getTask') + const getRun = vi.spyOn(db, 'getRun') + const getActiveCoordinatorRun = vi.spyOn(db, 'getActiveCoordinatorRun') + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + + const graph = { + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + title: terminal.tabId, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + } + runtime.syncWindowGraph(1, graph) + + const queryCounts = { + activeDispatch: getActiveDispatchForTerminal.mock.calls.length, + latestDispatch: getLatestDispatchForTerminal.mock.calls.length, + task: getTask.mock.calls.length, + run: getRun.mock.calls.length, + legacyCoordinator: getActiveCoordinatorRun.mock.calls.length + } + + db.completeDispatch(dispatch.id) + vi.useFakeTimers() + vi.setSystemTime(Date.now() + AGENT_STATUS_STALE_AFTER_MS + 5_000) + for (const query of [ + getActiveDispatchForTerminal, + getLatestDispatchForTerminal, + getTask, + getRun, + getActiveCoordinatorRun + ]) { + query.mockClear() + } + runtime.syncWindowGraph(1, graph) + + const historicalQueryCounts = { + activeDispatch: getActiveDispatchForTerminal.mock.calls.length, + latestDispatch: getLatestDispatchForTerminal.mock.calls.length, + task: getTask.mock.calls.length, + run: getRun.mock.calls.length, + legacyCoordinator: getActiveCoordinatorRun.mock.calls.length + } + expect({ + active: { + ...queryCounts, + total: Object.values(queryCounts).reduce((sum, n) => sum + n) + }, + historical: { + ...historicalQueryCounts, + total: Object.values(historicalQueryCounts).reduce((sum, n) => sum + n) + } + }).toEqual({ + active: { + activeDispatch: 100, + latestDispatch: 99, + task: 1, + run: 1, + legacyCoordinator: 0, + total: 201 + }, + historical: { + activeDispatch: 100, + latestDispatch: 100, + task: 0, + run: 0, + legacyCoordinator: 0, + total: 200 + } + }) + } finally { + vi.useRealTimers() + db.close() + } + }) + it('returns completed orchestration context for renderer-synced terminal leaves', () => { const runtime = new OrcaRuntimeService(store) const workerLeafId = '33333333-3333-4333-8333-333333333333' @@ -37896,6 +38550,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-done', + run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', @@ -37905,7 +38560,13 @@ describe('OrcaRuntimeService', () => { ), getTask: vi.fn(() => ({ id: 'task-done', + run_id: 'run-1', created_by_terminal_handle: coordinatorHandle + })), + getRun: vi.fn(() => ({ + id: 'run-1', + coordinator_handle: coordinatorHandle, + legacy: 0 })) } as never) runtime.attachWindow(1) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 15ed6e170af..849cfdc9c72 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -106,6 +106,7 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' +import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' import { OrchestrationError } from './orchestration/orchestration-error' import { planLegacyWorkerTerminalRecovery, @@ -424,6 +425,7 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { @@ -514,7 +516,7 @@ import { } from './workspace-session-terminal-membership-authority' import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' -import { RuntimeFileCommands } from './orca-runtime-files' +import { getRuntimeFileTargetExecutionHostId, RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { activateClientSessionTabSelection, @@ -2723,7 +2725,12 @@ export class OrcaRuntimeService { // resend (or stale) and is skipped without touching the stored entry. private acceptedRendererMobileSnapshotByWorktree = new Map< string, - { publicationEpoch: string; rendererVersion: number } + { + publicationEpoch: string + rendererVersion: number + rendererTabCount: number + rendererTabIdentityKeys: ReadonlySet + } >() private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the @@ -3381,6 +3388,10 @@ export class OrcaRuntimeService { } const teardownResult = await killAllProcessesForWorktree(worktreeId, { runtime: this, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection (mirrors the IPC removal path). + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), localProvider: provider, onPtyStopped: this.onPtyStopped ?? undefined, requirePhysicalStop: true, @@ -4503,6 +4514,11 @@ export class OrcaRuntimeService { deferredDispatchIds: [...deferredDispatchIds] } this.updateLegacyWorkerTerminalRecoveryRetry(plan, deferredDispatchIds, options) + // Why: previously requested releases may only finish after the owning provider's terminals + // are rediscovered; this pass runs per scope (local and each reconnected provider). + void reconcileRequestedWorkerTerminalReleases(this).catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) return result } @@ -5375,7 +5391,12 @@ export class OrcaRuntimeService { const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) - const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) + const mobileSessionResyncWorktrees = new Set() + const changedMobileWorktrees = this.syncMobileSessionTabs( + graph.mobileSessionTabs, + graph.unchangedMobileSessionWorktrees, + mobileSessionResyncWorktrees + ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -5551,7 +5572,10 @@ export class OrcaRuntimeService { return { ...this.getStatus(), ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), - ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), + ...(mobileSessionResyncWorktrees.size > 0 + ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } + : {}) } } @@ -8519,6 +8543,8 @@ export class OrcaRuntimeService { requireStore: () => this.requireStore(), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), + resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) => + this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId), resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), resolveTerminalFileUriHostname: (terminalHandle) => @@ -8836,6 +8862,72 @@ export class OrcaRuntimeService { return { worktree, connectionId: repo?.connectionId ?? undefined } } + private async resolveKnownWorkspaceFileTarget( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<{ + worktree: ResolvedWorktree + connectionId?: string + relativePath: string + } | null> { + const targets = new Map< + string, + { + worktree: ResolvedWorktree + connectionId?: string + executionHostId: ExecutionHostId + } + >() + for (const worktree of await this.listResolvedWorktrees()) { + if (!this.isRuntimeWorktreeVisible(worktree)) { + continue + } + const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } + for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { + try { + const candidateConnectionId = + this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined + const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace) + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } catch { + // An ambiguous folder workspace has no single filesystem authority. + } + } + + const owner = findRuntimeWorkspaceFileOwner( + [...targets.values()].map((target) => ({ + workspaceId: target.worktree.id, + rootPath: target.worktree.path, + executionHostId: target.executionHostId + })), + absolutePath, + executionHostId + ) + if (!owner) { + return null + } + const target = targets.get(`${owner.executionHostId}\0${owner.workspaceId}`) + return target ? { ...target, relativePath: owner.relativePath } : null + } + onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void, clientNavigationId?: string @@ -16316,9 +16408,10 @@ export class OrcaRuntimeService { const consider = ( state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined + updatedAt: number | null | undefined, + restoredUnconfirmed = false ): void => { - if (!state) { + if (!state || restoredUnconfirmed) { return } if (typeof updatedAt !== 'number' || now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { @@ -16342,7 +16435,7 @@ export class OrcaRuntimeService { if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { continue } - consider(entry.state, entry.receivedAt) + consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed) } return bestStatus ? { status: bestStatus, updatedAt: bestUpdatedAt } : null @@ -17157,6 +17250,7 @@ export class OrcaRuntimeService { interrupted: boolean stateStartedAt: number updatedAt: number + restoredUnconfirmed?: boolean } >() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { @@ -17201,7 +17295,8 @@ export class OrcaRuntimeService { toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt + updatedAt: entry.receivedAt, + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) }) } if (rowSources.size === 0) { @@ -17265,7 +17360,8 @@ export class OrcaRuntimeService { toolInput: src.toolInput, interrupted: src.interrupted, stateStartedAt: src.stateStartedAt, - updatedAt: src.updatedAt + updatedAt: src.updatedAt, + ...(src.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) } // Why: SSH/runtime projections can spell an equivalent path differently; // bucket by the canonical summary id so mobile keeps the agent activity. @@ -23197,6 +23293,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) @@ -23414,14 +23511,28 @@ export class OrcaRuntimeService { 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - const localProvider = this.getLocalProvider() - if (localProvider) { + // This service runs inside the selected runtime, so runtime-stamped repos use its + // local PTY namespace; only a direct SSH connection is external from here. + const folderConnectionId = repo.connectionId?.trim() || null + const folderSshPtyProvider = folderConnectionId + ? this.getSshProviderFn?.(folderConnectionId) + : undefined + const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() + if (folderPtyProvider) { // Why: folder workspace deletion has no Git removal phase where PTYs // would otherwise be swept; tear them down before hiding the workspace. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, - localProvider, - onPtyStopped: this.onPtyStopped ?? undefined + resolvedWorktreeId: removalTarget.id, + ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), + localProvider: folderPtyProvider, + onPtyStopped: this.onPtyStopped ?? undefined, + ...(folderConnectionId + ? { + includeProviderInventory: Boolean(folderSshPtyProvider), + includeLocalRegistry: false + } + : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) @@ -28839,7 +28950,9 @@ export class OrcaRuntimeService { // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. private syncMobileSessionTabs( - snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, + unchangedWorktreeIds?: string[], + resyncWorktreeIds: Set = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -28874,6 +28987,38 @@ export class OrcaRuntimeService { }) } const nextWorktrees = new Set() + const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + // Why: the renderer withholds unchanged snapshots to keep the graph payload + // small, so these worktrees are still live and must not fall into the prune + // below. Ask for a republish when main no longer holds that accepted renderer + // publication or a formerly-preserved runtime tab has gone stale. + for (const worktreeId of unchangedWorktreeIds ?? []) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + if (existing) { + nextWorktrees.add(worktreeId) + } + if ( + existing && + accepted && + (existing.publicationEpoch === accepted.publicationEpoch || + existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && + existing.tabs.length >= accepted.rendererTabCount && + (existing.tabs.length === accepted.rendererTabCount || + !this.storedMobileSnapshotHasStalePreservedTab( + existing, + accepted.rendererTabIdentityKeys + )) + ) { + continue + } + if (!incomingWorktreeIds.has(worktreeId)) { + resyncWorktreeIds.add(worktreeId) + } + // Why: the accept gate compares against the renderer's last accepted pair, + // which outlives the dropped snapshot and would reject the republish. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + } for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) @@ -28898,7 +29043,7 @@ export class OrcaRuntimeService { !( existing && snapshot.snapshotVersion === accepted.rendererVersion && - this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot) + this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) ) ) { continue @@ -28922,7 +29067,11 @@ export class OrcaRuntimeService { ) this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { publicationEpoch: snapshot.publicationEpoch, - rendererVersion: snapshot.snapshotVersion + rendererVersion: snapshot.snapshotVersion, + rendererTabCount: fencedSnapshot.tabs.length, + rendererTabIdentityKeys: new Set( + fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { @@ -29044,22 +29193,19 @@ export class OrcaRuntimeService { } // Why: the accepted-revision no-op gate must not fossilize preserved runtime - // tabs. A stored merged snapshot's tabs that are absent from the incoming - // renderer publication exist only via preservation; if any such tab no longer + // tabs. A stored merged snapshot's tabs absent from the accepted renderer + // publication exist only via preservation; if any such tab no longer // passes the preservation predicate (binding removed from the live PTY table - // and persisted session, or browser page closed), the stored snapshot is - // stale even though the renderer revision is unchanged. + // and persisted session, or browser page closed), the stored snapshot is stale. private storedMobileSnapshotHasStalePreservedTab( existing: RuntimeMobileSessionTabsSnapshot, - incoming: RuntimeMobileSessionTabsSnapshot + rendererTabIdentityKeys: ReadonlySet ): boolean { - const incomingIds = new Set( - incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) - ) return existing.tabs.some( (tab) => - !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id)) && - !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + rendererTabIdentityKeys.has(id) + ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) ) } @@ -29894,11 +30040,13 @@ export class OrcaRuntimeService { return undefined } const contexts: Record = {} + const queriedHandles = new Set() for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { continue } const handle = this.issueHandle(leaf) + queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[this.makeRuntimePaneKey(leaf)] = context @@ -29909,6 +30057,10 @@ export class OrcaRuntimeService { continue } const handle = this.issuePtyHandle(pty) + if (queriedHandles.has(handle)) { + continue + } + queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[pty.paneKey] = context @@ -29928,7 +30080,7 @@ export class OrcaRuntimeService { if (!dispatch) { return undefined } - const task = db?.getTask?.(dispatch.task_id) + const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) const display = typeof task?.spec === 'string' ? buildOrchestrationTaskDisplayMetadata({ @@ -29937,15 +30089,68 @@ export class OrcaRuntimeService { displayName: task.display_name }) : { taskTitle: '', displayName: '' } - const activeRun = - dispatch.status === 'pending' || dispatch.status === 'dispatched' + const owningRun = + task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined + const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined + const legacyActiveRun = + owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') ? db?.getActiveCoordinatorRun?.() : undefined + // Why: legacy coordinator runs have no durable task ownership, so fail closed across worktrees. + const handleWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(handle) : null + const legacyCoordinatorWorktreeId = legacyActiveRun + ? this.getWorktreeIdForTerminalHandle(legacyActiveRun.coordinator_handle) + : null + const scopedLegacyActiveRun = + legacyActiveRun && + handleWorktreeId && + legacyCoordinatorWorktreeId && + runtimeWorktreeIdsEqual(legacyCoordinatorWorktreeId, handleWorktreeId) + ? legacyActiveRun + : undefined + const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyActiveRun?.coordinator_handle + const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyActiveRun?.id + const creatorPaneKey = task?.created_by_pane_key + const creatorPaneHandle = creatorPaneKey + ? this.getTerminalHandleForPaneKey(creatorPaneKey) + : null + const creatorAuthority = creatorPaneHandle + ? this.getOrchestrationDispatchAuthority(creatorPaneHandle) + : null + const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null + const currentCreatorPane = creatorAuthority?.paneKey + ? parsePaneKey(creatorAuthority.paneKey) + : null + const sameCreatorPane = Boolean( + creatorPaneKey && + creatorAuthority?.paneKey && + (creatorPaneKey === creatorAuthority.paneKey || + (storedCreatorPane && + currentCreatorPane && + storedCreatorPane.leafId === currentCreatorPane.leafId)) + ) + const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined + const sameRunCreatorDispatch = Boolean( + task?.creator_dispatch_id && + task.creator_dispatch_run_id === owningRun?.id && + task.creator_dispatch_pane_key && + task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && + parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId + ) + const currentCreatorHandle = + owningRun?.legacy === 0 && + task?.created_by_run_generation === owningRun.consumer_generation && + task.created_by_process_incarnation === creatorAuthority?.processIncarnation && + sameCreatorPane && + (paneRun + ? paneRun.id === owningRun.id && + paneRun.consumer_generation === task.created_by_run_generation + : sameRunCreatorDispatch) + ? (creatorPaneHandle ?? undefined) + : undefined const parentTerminalHandle = - task?.created_by_terminal_handle ?? - (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle - ? activeRun.coordinator_handle - : undefined) + currentCreatorHandle ?? + (coordinatorHandle && coordinatorHandle !== handle ? coordinatorHandle : undefined) const parentPaneKey = parentTerminalHandle ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) : undefined @@ -29958,8 +30163,8 @@ export class OrcaRuntimeService { ...(display.displayName ? { displayName: display.displayName } : {}), ...(parentTerminalHandle ? { parentTerminalHandle } : {}), ...(parentPaneKey ? { parentPaneKey } : {}), - ...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}), - ...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {}) + ...(coordinatorHandle ? { coordinatorHandle } : {}), + ...(orchestrationRunId ? { orchestrationRunId } : {}) } } @@ -30012,10 +30217,21 @@ export class OrcaRuntimeService { return pty } leafPty = pty ?? null + for (const candidate of this.leaves.values()) { + if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { + continue + } + const remintedPty = this.ptysById.get(candidate.ptyId) + if (remintedPty?.connected) { + return remintedPty + } + leafPty ??= remintedPty ?? null + } } let newestMatch: RuntimePtyWorktreeRecord | null = null for (const pty of this.ptysById.values()) { - if (pty.paneKey === paneKey) { + const ptyPane = parsePaneKey(pty.paneKey ?? '') + if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { if (pty.connected) { return pty } @@ -30040,6 +30256,18 @@ export class OrcaRuntimeService { return makePaneKey(record.tabId, record.leafId) } + private getWorktreeIdForTerminalHandle(handle: string): string | null { + const livePty = this.getLivePtyForHandle(handle) + if (livePty?.pty.worktreeId) { + return livePty.pty.worktreeId + } + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + return null + } + return record.worktreeId + } + private setPtyManagementTitleFromObservedTitle( pty: RuntimePtyWorktreeRecord, title: string | null | undefined, diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 31e9180c93e..cbb3c3f8011 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -177,6 +177,42 @@ describe('Coordinator', () => { }) expect(db.getDispatchContext(task.id)?.assignee_pane_key).toBe('tab_a:leaf_a') + expect(db.getDispatchContext(task.id)?.process_incarnation).toBeNull() + + insertWorkerDone(db, { taskId: task.id }) + await runPromise + }) + + it('records authenticated process authority for automatic dispatch', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + const withAuthority = Object.assign(runtime, { + getOrchestrationDispatchAuthority: (handle: string) => + handle === 'term_a' + ? { + paneKey: 'tab_a:leaf_a', + processIncarnation: 'pty_a:incarnation-a', + launchTokenHash: 'launch-token-hash' + } + : null + }) + const task = db.createTask({ spec: 'implement feature' }) + const coordinator = new Coordinator(db, withAuthority, { + spec: 'build it', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + expect(db.getDispatchContext(task.id)).toMatchObject({ + assignee_pane_key: 'tab_a:leaf_a', + process_incarnation: 'pty_a:incarnation-a', + launch_token_hash: 'launch-token-hash' + }) insertWorkerDone(db, { taskId: task.id }) await runPromise diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 7fcd5288f5d..df69ed9ef7b 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -26,8 +26,14 @@ export type CoordinatorRuntime = { behind: number recentSubjects: string[] } | null> - // Why: optional so lightweight runtime fakes keep compiling; when present, dispatch records the assignee's remint-stable pane identity. + // Why: pane-only fallback preserves reservation identity for lightweight runtime fakes. getTerminalPaneKey?(handle: string): string | null + // Why: automatic dispatch persists the same authenticated pane/process tuple as manual dispatch. + getOrchestrationDispatchAuthority?(handle: string): { + paneKey: string | null + processIncarnation: string | null + launchTokenHash: string | null + } | null // Why: Windows can host native and WSL workers at once, so the worker pane (not the coordinator) picks the packaged CLI name. getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide' } @@ -419,10 +425,19 @@ export class Coordinator { } } + const dispatchAuthority = this.runtime.getOrchestrationDispatchAuthority?.(targetHandle) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined const dispatch = this.db.createDispatchContext( task.id, targetHandle, - this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined + assigneePaneKey, + dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation ) // Why: dispatched agents use orca-dev in dev mode to reach the dev runtime's socket, not production (Section 6.4). diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index ddaeb172a72..3608ee7d754 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -42,6 +42,15 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { + deriveWorkerTerminalListState, + type WorkerTerminalResourceRow, + type WorkerTerminalArchiveRow, + type WorkerTerminalArchiveStatus, + type WorkerTerminalListState, + type WorkerTerminalOwnershipState, + type WorkerTerminalRetainedReason +} from './worker-terminal-ownership' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' @@ -57,8 +66,10 @@ function isEquivalentPaneKey(a: string, b: string): boolean { // Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the // text after the first ':', so this narrows candidates without deciding equivalence itself. -const PANE_KEY_MATCH_SUFFIX_SQL = +const RUN_PANE_KEY_MATCH_SUFFIX_SQL = "substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1)" +const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL = + "substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1)" function paneKeyMatchSuffix(paneKey: string): string { const colon = paneKey.indexOf(':') @@ -254,13 +265,20 @@ export type RunListPage = { nextCursor: string | null } +export type TaskRuntimeLineageRow = TaskRow & { + creator_dispatch_id: string | null + creator_dispatch_run_id: string | null + creator_dispatch_pane_key: string | null + creator_dispatch_process_incarnation: string | null +} + type RunListCursor = { createdAt: string id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup. -const SCHEMA_VERSION = 22 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. +const SCHEMA_VERSION = 25 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -387,6 +405,51 @@ export class OrchestrationDb { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS worker_terminal_resources ( + id TEXT PRIMARY KEY, + origin_dispatch_id TEXT NOT NULL, + owner_dispatch_id TEXT NOT NULL, + prior_owner_dispatch_ids TEXT NOT NULL DEFAULT '[]', + worktree_id TEXT, + terminal_handle TEXT NOT NULL, + pane_key TEXT, + process_incarnation TEXT, + host_scope TEXT, + ownership_state TEXT NOT NULL DEFAULT 'owned' + CHECK(ownership_state IN ('owned', 'transferred', 'user_owned', 'external', 'released')), + release_state TEXT NOT NULL DEFAULT 'not_requested' + CHECK(release_state IN ( + 'not_requested', 'retained', 'requested', 'releasing', 'released', 'unknown' + )), + retained_reason TEXT, + release_requested_at TEXT, + release_completed_at TEXT, + release_error TEXT, + archive_source TEXT, + archive_status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_terminal_resources_owner + ON worker_terminal_resources(owner_dispatch_id); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_handle + ON worker_terminal_resources(terminal_handle); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_pane + ON worker_terminal_resources(pane_key); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_identity + ON worker_terminal_resources(process_incarnation, host_scope); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_release + ON worker_terminal_resources(release_state); + + CREATE TABLE IF NOT EXISTS worker_terminal_archives ( + dispatch_id TEXT PRIMARY KEY, + resource_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS federated_dispatches ( dispatch_id TEXT PRIMARY KEY, environment_id TEXT NOT NULL, @@ -463,6 +526,9 @@ export class OrchestrationDb { run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', parent_id TEXT, created_by_terminal_handle TEXT, + created_by_pane_key TEXT, + created_by_process_incarnation TEXT, + created_by_run_generation INTEGER, task_title TEXT, display_name TEXT, spec TEXT NOT NULL, @@ -522,7 +588,7 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status); CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane_leaf - ON runs(${PANE_KEY_MATCH_SUFFIX_SQL}) + ON runs(${RUN_PANE_KEY_MATCH_SUFFIX_SQL}) WHERE coordinator_pane_key IS NOT NULL; CREATE TABLE IF NOT EXISTS coordinator_runs ( @@ -892,6 +958,32 @@ export class OrchestrationDb { ON dispatch_contexts(assignee_handle); `) } + if (current < 23) { + this.backfillWorkerTerminalResources() + } + if (current < 24) { + if (!this.hasColumn('tasks', 'created_by_pane_key')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_pane_key TEXT') + } + if (!this.hasColumn('tasks', 'created_by_process_incarnation')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_process_incarnation TEXT') + } + if (!this.hasColumn('tasks', 'created_by_run_generation')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_run_generation INTEGER') + } + } + if (current < 25) { + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_active_assignee_handle + ON dispatch_contexts(assignee_handle) + WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); + `) + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf + ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) + WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched'); + `) this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -2366,7 +2458,7 @@ export class OrchestrationDb { .prepare( `SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 - AND ${PANE_KEY_MATCH_SUFFIX_SQL} = ? + AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` ) .all(paneKeyMatchSuffix(paneKey)) as RunRow[] @@ -3686,6 +3778,9 @@ export class OrchestrationDb { deps?: string[] parentId?: string createdByTerminalHandle?: string + createdByPaneKey?: string + createdByProcessIncarnation?: string + createdByRunGeneration?: number runId?: string }): TaskRow { const runId = task.runId ?? LEGACY_RUN_ID @@ -3713,13 +3808,20 @@ export class OrchestrationDb { }) this.db .prepare( - 'INSERT INTO tasks (id, run_id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + `INSERT INTO tasks ( + id, run_id, parent_id, created_by_terminal_handle, created_by_pane_key, + created_by_process_incarnation, created_by_run_generation, + task_title, display_name, spec, status, deps + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, runId, task.parentId ?? null, task.createdByTerminalHandle ?? null, + task.createdByPaneKey ?? null, + task.createdByProcessIncarnation ?? null, + task.createdByRunGeneration ?? null, display.taskTitle || null, display.displayName || null, task.spec, @@ -3729,8 +3831,33 @@ export class OrchestrationDb { return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } - getTask(id: string): TaskRow | undefined { - return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + // Why: return the active creator Dispatch proof with the Task read; runtime still owns pane/process currency. + getTask(id: string): TaskRow | undefined + getTask(id: string, dispatchRunId: string): TaskRuntimeLineageRow | undefined + getTask(id: string, dispatchRunId?: string): TaskRow | TaskRuntimeLineageRow | undefined { + if (dispatchRunId === undefined) { + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + } + return this.db + .prepare( + `SELECT t.*, + creator.id AS creator_dispatch_id, + creator.run_id AS creator_dispatch_run_id, + creator.assignee_pane_key AS creator_dispatch_pane_key, + creator.process_incarnation AS creator_dispatch_process_incarnation + FROM tasks t + LEFT JOIN dispatch_contexts creator ON creator.rowid = ( + SELECT candidate.rowid + FROM dispatch_contexts candidate + WHERE candidate.assignee_handle = t.created_by_terminal_handle + AND candidate.run_id = ? + AND candidate.status IN ('pending', 'dispatched') + ORDER BY candidate.rowid DESC + LIMIT 1 + ) + WHERE t.id = ?` + ) + .get(dispatchRunId, id) as TaskRuntimeLineageRow | undefined } listTasks(filter?: { status?: TaskStatus; ready?: boolean; runId?: string }): TaskRow[] { @@ -4054,6 +4181,11 @@ export class OrchestrationDb { worktreeId: string effects: unknown[] setupState: string + hostScope?: string | null + // 'created': this worker-start operation created the agent terminal (including agent-first + // worktree creation, whose effects receipt says 'reused_agent_terminal'). 'external': an + // explicit --terminal reuse; ownership transfers only from an exact owned settled resource. + terminalOwnership?: 'created' | 'external' }): string { const dispatch = this.getDispatchContextById(params.dispatchId) const worker = this.getWorkerDispatch(params.dispatchId) @@ -4116,6 +4248,46 @@ export class OrchestrationDb { ), params.dispatchId ) + if (params.terminalOwnership && !this.getWorkerTerminalResourceByOwner(params.dispatchId)) { + if (params.terminalOwnership === 'created') { + this.createWorkerTerminalResourceStatement({ + dispatchId: params.dispatchId, + worktreeId: params.worktreeId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope, + ownership: 'owned' + }) + } else { + const transferable = this.findTransferableWorkerTerminalResource({ + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope ?? null + }) + if (transferable) { + this.transferWorkerTerminalResourceStatement({ + resourceId: transferable.id, + toDispatchId: params.dispatchId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope ?? null + }) + } else { + this.createWorkerTerminalResourceStatement({ + dispatchId: params.dispatchId, + worktreeId: params.worktreeId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope, + ownership: 'external' + }) + } + } + } this.db.exec('COMMIT') return capability } catch (error) { @@ -5419,12 +5591,575 @@ export class OrchestrationDb { } } + // --- Worker terminal resources (schema v23) --------------------------------------------------- + + // Historical renderer input and reuse cannot be proven, so pre-v23 terminals stay external. + private backfillWorkerTerminalResources(): void { + const rows = this.db + .prepare( + `SELECT w.dispatch_id, w.worktree_id, w.agent_terminal_handle, + d.assignee_pane_key, d.process_incarnation + FROM worker_dispatches w + JOIN dispatch_contexts d ON d.id = w.dispatch_id + WHERE w.agent_terminal_handle IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM federated_dispatches f WHERE f.dispatch_id = w.dispatch_id) + AND NOT EXISTS ( + SELECT 1 FROM worker_terminal_resources r WHERE r.owner_dispatch_id = w.dispatch_id + )` + ) + .all() as { + dispatch_id: string + worktree_id: string | null + agent_terminal_handle: string + assignee_pane_key: string | null + process_incarnation: string | null + }[] + const insert = this.db.prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle, + pane_key, process_incarnation, ownership_state, release_state, retained_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + for (const row of rows) { + insert.run( + generateId('wtr'), + row.dispatch_id, + row.dispatch_id, + row.worktree_id, + row.agent_terminal_handle, + row.assignee_pane_key, + row.process_incarnation, + 'external', + 'retained', + 'legacy_ambiguous' + ) + } + } + + // No transaction: composes inside worker-start's authority transaction. + createWorkerTerminalResourceStatement(params: { + dispatchId: string + worktreeId: string | null + terminalHandle: string + paneKey: string | null + processIncarnation: string | null + hostScope?: string | null + ownership: Extract + }): WorkerTerminalResourceRow { + const id = generateId('wtr') + this.db + .prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle, + pane_key, process_incarnation, host_scope, ownership_state, release_state, + retained_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_requested', ?)` + ) + .run( + id, + params.dispatchId, + params.dispatchId, + params.worktreeId, + params.terminalHandle, + params.paneKey, + params.processIncarnation, + params.hostScope ?? null, + params.ownership, + params.ownership === 'external' ? 'external_terminal' : null + ) + return this.getWorkerTerminalResource(id) as WorkerTerminalResourceRow + } + + getWorkerTerminalResource(id: string): WorkerTerminalResourceRow | undefined { + return this.db.prepare('SELECT * FROM worker_terminal_resources WHERE id = ?').get(id) as + | WorkerTerminalResourceRow + | undefined + } + + getWorkerTerminalResourceByOwner(dispatchId: string): WorkerTerminalResourceRow | undefined { + return this.db + .prepare('SELECT * FROM worker_terminal_resources WHERE owner_dispatch_id = ?') + .get(dispatchId) as WorkerTerminalResourceRow | undefined + } + + getWorkerTerminalResourceFormerlyOwnedBy( + dispatchId: string + ): WorkerTerminalResourceRow | undefined { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE prior_owner_dispatch_ids LIKE ? + ORDER BY updated_at DESC LIMIT 1` + ) + .get(`%"${dispatchId}"%`) as WorkerTerminalResourceRow | undefined + } + + // Reusable exact settled terminal: transfers cleanup ownership to the new Dispatch and fences + // release through the old owner. No transaction: composes inside the authority transaction. + transferWorkerTerminalResourceStatement(params: { + resourceId: string + toDispatchId: string + terminalHandle: string + paneKey: string + processIncarnation: string + hostScope: string | null + }): WorkerTerminalResourceRow { + const resource = this.getWorkerTerminalResource(params.resourceId) + if (!resource) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker terminal resource ${params.resourceId} was not found.` + ) + } + const priorOwners = JSON.parse(resource.prior_owner_dispatch_ids) as string[] + priorOwners.push(resource.owner_dispatch_id) + this.db + .prepare( + `UPDATE worker_terminal_resources + SET owner_dispatch_id = ?, prior_owner_dispatch_ids = ?, release_state = 'not_requested', + retained_reason = NULL, release_requested_at = NULL, release_completed_at = NULL, + release_error = NULL, terminal_handle = ?, pane_key = ?, process_incarnation = ?, + host_scope = ?, updated_at = datetime('now') + WHERE id = ? AND ownership_state = 'owned'` + ) + .run( + params.toDispatchId, + JSON.stringify(priorOwners), + params.terminalHandle, + params.paneKey, + params.processIncarnation, + params.hostScope, + params.resourceId + ) + return this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow + } + + // Finds an owned, settled, exact-match resource for an explicitly reused terminal. + findTransferableWorkerTerminalResource(params: { + terminalHandle: string + paneKey: string | null + processIncarnation: string | null + hostScope: string | null + }): WorkerTerminalResourceRow | undefined { + if (!params.paneKey || !params.processIncarnation) { + return undefined + } + const candidates = this.db + .prepare( + `SELECT r.* FROM worker_terminal_resources r + JOIN worker_dispatches w ON w.dispatch_id = r.owner_dispatch_id + WHERE r.process_incarnation = ? AND r.host_scope IS ? + AND r.ownership_state != 'released'` + ) + .all(params.processIncarnation, params.hostScope) as WorkerTerminalResourceRow[] + const exact = candidates.filter( + (candidate) => + candidate.pane_key && + params.paneKey && + isEquivalentPaneKey(candidate.pane_key, params.paneKey) && + candidate.process_incarnation === params.processIncarnation && + candidate.host_scope === params.hostScope + ) + if ( + exact.some((candidate) => + ['requested', 'releasing', 'unknown'].includes(candidate.release_state) + ) + ) { + throw new OrchestrationError( + 'terminal_release_in_progress', + `Terminal ${params.terminalHandle} has a release in progress; wait for cleanup or use another terminal.` + ) + } + return exact.find( + (candidate) => + candidate.ownership_state === 'owned' && + ['not_requested', 'retained'].includes(candidate.release_state) && + ['succeeded', 'failed', 'stopped', 'abandoned'].includes( + this.getWorkerDispatch(candidate.owner_dispatch_id)?.state ?? '' + ) + ) + } + + workerTerminalResourceHasIdentityConflict(resourceId: string): boolean { + const resource = this.getWorkerTerminalResource(resourceId) + if (!resource?.pane_key || !resource.process_incarnation) { + return true + } + const candidates = this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? + AND id != ? AND ownership_state != 'released'` + ) + .all( + resource.process_incarnation, + resource.host_scope, + resource.id + ) as WorkerTerminalResourceRow[] + return candidates.some( + (candidate) => + candidate.pane_key && + isEquivalentPaneKey(candidate.pane_key, resource.pane_key as string) && + candidate.process_incarnation === resource.process_incarnation && + candidate.host_scope === resource.host_scope + ) + } + + requestWorkerTerminalRelease(dispatchId: string): + | { disposition: 'requested'; resource: WorkerTerminalResourceRow } + | { disposition: 'already_released'; resource: WorkerTerminalResourceRow } + | { + disposition: 'retained' + resource: WorkerTerminalResourceRow | null + reason: WorkerTerminalRetainedReason + } { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + if (!worker) { + throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) + } + if (!['succeeded', 'failed'].includes(worker.state)) { + // Why: release is post-completion cleanup only; recording intent for an unsettled or + // uncertain worker would let recovery close a terminal the coordinator never reviewed. + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${dispatchId} is ${worker.state}; only a succeeded or failed worker can release. Use worker-stop to cancel an active worker.` + ) + } + const resource = this.getWorkerTerminalResourceByOwner(dispatchId) + if (!resource) { + const transferred = this.getWorkerTerminalResourceFormerlyOwnedBy(dispatchId) + this.db.exec('COMMIT') + return transferred + ? { disposition: 'retained', resource: transferred, reason: 'ownership_transferred' } + : { disposition: 'retained', resource: null, reason: 'no_owned_resource' } + } + if (resource.release_state === 'released' || resource.ownership_state === 'released') { + this.db.exec('COMMIT') + return { disposition: 'already_released', resource } + } + if (resource.ownership_state === 'external') { + this.db.exec('COMMIT') + return { + disposition: 'retained', + resource, + reason: (resource.retained_reason as WorkerTerminalRetainedReason) ?? 'external_terminal' + } + } + if (resource.ownership_state === 'user_owned') { + this.db.exec('COMMIT') + return { disposition: 'retained', resource, reason: 'user_takeover' } + } + if (resource.ownership_state === 'transferred') { + this.db.exec('COMMIT') + return { disposition: 'retained', resource, reason: 'ownership_transferred' } + } + if ( + resource.release_state === 'unknown' || + (resource.release_state === 'retained' && resource.retained_reason === 'user_requested') + ) { + this.db + .prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?') + .run(dispatchId) + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = CASE + WHEN release_state = 'releasing' THEN 'releasing' + ELSE 'requested' + END, + retained_reason = NULL, + release_requested_at = COALESCE(release_requested_at, datetime('now')), + release_error = NULL, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested', 'releasing', 'unknown')` + ) + .run(resource.id) + this.db.exec('COMMIT') + return { + disposition: 'requested', + resource: this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + storeWorkerTerminalArchive(params: { + dispatchId: string + resourceId: string + kind: 'transcript_pin' | 'terminal_tail' + content: string + }): void { + this.db + .prepare( + `INSERT INTO worker_terminal_archives (dispatch_id, resource_id, kind, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(dispatch_id) DO UPDATE SET + resource_id = excluded.resource_id, kind = excluded.kind, content = excluded.content` + ) + .run(params.dispatchId, params.resourceId, params.kind, params.content) + } + + commitWorkerTerminalArchiveForRelease(params: { + dispatchId: string + resourceId: string + kind?: 'transcript_pin' | 'terminal_tail' + content?: string + archiveSource: 'transcript' | 'terminal' + archiveStatus: Extract + }): WorkerTerminalResourceRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const resource = this.getWorkerTerminalResource(params.resourceId) + if ( + resource?.owner_dispatch_id === params.dispatchId && + resource.ownership_state === 'owned' && + resource.release_state === 'requested' + ) { + if (params.kind && params.content !== undefined) { + this.storeWorkerTerminalArchive({ + dispatchId: params.dispatchId, + resourceId: params.resourceId, + kind: params.kind, + content: params.content + }) + } + const archive = this.getWorkerTerminalArchive(params.dispatchId) + if (!archive || archive.resource_id !== params.resourceId) { + throw new OrchestrationError( + 'archive_failed', + `Output could not be preserved for Dispatch ${params.dispatchId}; the terminal was retained.` + ) + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'releasing', archive_source = ?, archive_status = ?, + updated_at = datetime('now') + WHERE id = ? AND owner_dispatch_id = ? AND ownership_state = 'owned' + AND release_state = 'requested'` + ) + .run(params.archiveSource, params.archiveStatus, params.resourceId, params.dispatchId) + } + const updated = this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow + this.db.exec('COMMIT') + return updated + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getWorkerTerminalArchive(dispatchId: string): WorkerTerminalArchiveRow | undefined { + return this.db + .prepare('SELECT * FROM worker_terminal_archives WHERE dispatch_id = ?') + .get(dispatchId) as WorkerTerminalArchiveRow | undefined + } + + settleWorkerTerminalRelease(resourceId: string): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'released', ownership_state = 'released', + release_completed_at = datetime('now'), release_error = NULL, + updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing', 'unknown')` + ) + .run(resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + markWorkerTerminalReleaseUnknown(resourceId: string, reason: string): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'unknown', release_error = ?, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing')` + ) + .run(reason, resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + revertWorkerTerminalReleaseToRetained( + resourceId: string, + reason: WorkerTerminalRetainedReason + ): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = ?, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing')` + ) + .run(reason, resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + retainWorkerTerminalResource( + dispatchId: string + ): + | { disposition: 'retained'; resource: WorkerTerminalResourceRow } + | { disposition: 'already_released'; resource: WorkerTerminalResourceRow } + | { disposition: 'release_committed'; resource: WorkerTerminalResourceRow } + | { disposition: 'no_owned_resource'; resource: null } { + this.db.exec('BEGIN IMMEDIATE') + try { + const resource = this.getWorkerTerminalResourceByOwner(dispatchId) + if (!resource) { + this.db.exec('COMMIT') + return { disposition: 'no_owned_resource', resource: null } + } + if (resource.release_state === 'released') { + this.db.exec('COMMIT') + return { disposition: 'already_released', resource } + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = 'user_requested', + updated_at = datetime('now') + WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested')` + ) + .run(resource.id) + const updated = this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow + if (updated.release_state !== 'retained') { + this.db.exec('COMMIT') + return { disposition: 'release_committed', resource: updated } + } + this.db.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?').run(dispatchId) + this.db.exec('COMMIT') + return { disposition: 'retained', resource: updated } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + // Real user input relinquishes orchestration ownership durably; programmatic prompt delivery, + // query auto-replies, resize, and output never reach this path. + markWorkerTerminalUserOwned(paneKey: string): number { + this.db.exec('BEGIN IMMEDIATE') + try { + const exact = this.db + .prepare( + `SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources + WHERE pane_key = ? AND ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested')` + ) + .all(paneKey) as { id: string; owner_dispatch_id: string; pane_key: string }[] + const candidates = + exact.length > 0 + ? exact + : ( + this.db + .prepare( + `SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources + WHERE ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested') + AND pane_key IS NOT NULL` + ) + .all() as { id: string; owner_dispatch_id: string; pane_key: string }[] + ).filter((candidate) => isEquivalentPaneKey(candidate.pane_key, paneKey)) + const update = this.db.prepare( + `UPDATE worker_terminal_resources + SET ownership_state = 'user_owned', release_state = 'retained', + retained_reason = 'user_takeover', updated_at = datetime('now') + WHERE id = ? AND ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested')` + ) + let changed = 0 + for (const candidate of candidates) { + const result = Number(update.run(candidate.id).changes) + if (result > 0) { + this.db + .prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?') + .run(candidate.owner_dispatch_id) + changed += result + } + } + this.db.exec('COMMIT') + return changed + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + listWorkerTerminalReleaseBacklog(): WorkerTerminalResourceRow[] { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE release_state IN ('requested', 'releasing') + ORDER BY release_requested_at ASC` + ) + .all() as WorkerTerminalResourceRow[] + } + + listWorkerTerminalResources(params: { runId?: string } = {}): { + dispatchId: string + taskId: string + runId: string + workerState: WorkerDispatchState + dispatchStatus: DispatchStatus + agentTerminalHandle: string | null + terminalState: WorkerTerminalListState | null + resource: WorkerTerminalResourceRow | null + }[] { + const rows = this.db + .prepare( + `SELECT w.dispatch_id, w.state AS worker_state, w.agent_terminal_handle, + d.task_id, d.run_id, d.status AS dispatch_status + FROM worker_dispatches w + JOIN dispatch_contexts d ON d.id = w.dispatch_id + ${params.runId ? 'WHERE d.run_id = ?' : ''} + ORDER BY w.created_at ASC` + ) + .all(...(params.runId ? [params.runId] : [])) as { + dispatch_id: string + worker_state: WorkerDispatchState + agent_terminal_handle: string | null + task_id: string + run_id: string + dispatch_status: DispatchStatus + }[] + const resources = this.db + .prepare( + `SELECT r.* FROM worker_terminal_resources r + JOIN dispatch_contexts d ON d.id = r.owner_dispatch_id + ${params.runId ? 'WHERE d.run_id = ?' : ''}` + ) + .all(...(params.runId ? [params.runId] : [])) as WorkerTerminalResourceRow[] + const resourceByOwner = new Map( + resources.map((resource) => [resource.owner_dispatch_id, resource]) + ) + return rows.map((row) => { + const resource = resourceByOwner.get(row.dispatch_id) ?? null + return { + dispatchId: row.dispatch_id, + taskId: row.task_id, + runId: row.run_id, + workerState: row.worker_state, + dispatchStatus: row.dispatch_status, + agentTerminalHandle: row.agent_terminal_handle, + terminalState: deriveWorkerTerminalListState({ + workerState: row.worker_state, + agentTerminalHandle: row.agent_terminal_handle, + resource + }), + resource + } + }) + } + createDispatchContext( taskId: string, assigneeHandle: string, // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. assigneePaneKey?: string, - launchTokenHash?: string + launchTokenHash?: string, + processIncarnation?: string ): DispatchContextRow { const task = this.getTask(taskId) if (!task) { @@ -5454,8 +6189,9 @@ export class OrchestrationDb { .prepare( `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, status, failure_count, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))` + assignee_handle, assignee_pane_key, process_incarnation, + status, failure_count, dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) .run( id, @@ -5465,6 +6201,7 @@ export class OrchestrationDb { launchTokenHash ?? null, assigneeHandle, assigneePaneKey ?? null, + processIncarnation ?? null, priorFailures ) this.hasAnyDispatchContextsCache = true @@ -5636,9 +6373,12 @@ export class OrchestrationDb { const actives = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched')" + `SELECT * FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL + AND status IN ('pending', 'dispatched') + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?` ) - .all() as DispatchContextRow[] + .all(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow[] for (const row of actives) { if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { @@ -6016,6 +6756,8 @@ export class OrchestrationDb { DELETE FROM federation_relay_items; DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; + DELETE FROM worker_terminal_archives; + DELETE FROM worker_terminal_resources; DELETE FROM worker_dispatches; DELETE FROM dispatch_contexts; DELETE FROM tasks; @@ -6040,6 +6782,8 @@ export class OrchestrationDb { DELETE FROM federation_relay_items; DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; + DELETE FROM worker_terminal_archives; + DELETE FROM worker_terminal_resources; DELETE FROM worker_dispatches; DELETE FROM dispatch_contexts; DELETE FROM tasks; diff --git a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts new file mode 100644 index 00000000000..d13344004c3 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +const CREATOR_PANE = 'tab-creator:11111111-1111-4111-8111-111111111111' +const CREATOR_PROCESS = 'pty-creator:incarnation-a' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +describe('creator authority lookup performance', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('uses bounded creator-handle and pane-leaf indexes', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + const taskPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT t.*, creator.id + FROM tasks t + LEFT JOIN dispatch_contexts creator ON creator.rowid = ( + SELECT candidate.rowid + FROM dispatch_contexts candidate + WHERE candidate.assignee_handle = t.created_by_terminal_handle + AND candidate.run_id = ? + AND candidate.status IN ('pending', 'dispatched') + ORDER BY candidate.rowid DESC LIMIT 1 + ) + WHERE t.id = ?` + ) + .all('run-owner', 'task-worker') as { detail: string }[] + const panePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT * FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL + AND status IN ('pending', 'dispatched') + AND substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1) = ?` + ) + .all('11111111-1111-4111-8111-111111111111') as { detail: string }[] + const taskDetails = taskPlan.map((row) => row.detail).join(' | ') + const paneDetails = panePlan.map((row) => row.detail).join(' | ') + + expect(taskDetails).toContain('idx_dispatch_active_assignee_handle') + expect(taskDetails).not.toMatch(/SCAN (?:runs|rebound)/) + expect(paneDetails).toContain('idx_dispatch_assignee_pane_leaf') + }) + + it('keeps 300 Task reads bounded with 50,000 retained Runs', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'owner', + coordinatorHandle: 'term-coordinator', + coordinatorPaneKey: 'tab-coordinator:22222222-2222-4222-8222-222222222222' + }) + const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) + db.createDispatchContext( + creatorTask.id, + 'term-creator', + CREATOR_PANE, + undefined, + CREATOR_PROCESS + ) + const workerTask = db.createTask({ + spec: 'worker', + runId: run.id, + createdByTerminalHandle: 'term-creator', + createdByPaneKey: CREATOR_PANE, + createdByProcessIncarnation: CREATOR_PROCESS, + createdByRunGeneration: run.consumer_generation + }) + sqliteFor(db) + .prepare( + `WITH RECURSIVE run_numbers(value) AS ( + VALUES (1) UNION ALL SELECT value + 1 FROM run_numbers WHERE value < ? + ) + INSERT INTO runs (id, objective, coordinator_handle, consumer_generation, legacy) + SELECT printf('retained_%05d', value), 'retained', printf('term_%05d', value), 1, 0 + FROM run_numbers` + ) + .run(50_000) + + for (let index = 0; index < 10; index += 1) { + db.getTask(workerTask.id, run.id) + } + const startedAt = performance.now() + for (let index = 0; index < 300; index += 1) { + expect(db.getTask(workerTask.id, run.id)?.creator_dispatch_run_id).toBe(run.id) + } + const elapsedMs = performance.now() - startedAt + + expect(elapsedMs).toBeLessThan(200) + }) + + it.each([20_000, 50_000])( + 'keeps active creator lookup bounded with %i retained same-handle Dispatches', + (retainedDispatchCount) => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'owner', + coordinatorHandle: 'term-coordinator', + coordinatorPaneKey: 'tab-coordinator:22222222-2222-4222-8222-222222222222' + }) + sqliteFor(db) + .prepare( + `WITH RECURSIVE dispatch_numbers(value) AS ( + VALUES (1) UNION ALL SELECT value + 1 FROM dispatch_numbers WHERE value < ? + ) + INSERT INTO dispatch_contexts ( + id, run_id, task_id, assignee_handle, status, completed_at + ) + SELECT printf('retained_ctx_%05d', value), ?, printf('retained_task_%05d', value), + 'term-creator', 'completed', datetime('now') + FROM dispatch_numbers` + ) + .run(retainedDispatchCount, run.id) + const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) + const creatorDispatch = db.createDispatchContext( + creatorTask.id, + 'term-creator', + CREATOR_PANE, + undefined, + CREATOR_PROCESS + ) + const workerTask = db.createTask({ + spec: 'worker', + runId: run.id, + createdByTerminalHandle: 'term-creator', + createdByPaneKey: CREATOR_PANE, + createdByProcessIncarnation: CREATOR_PROCESS, + createdByRunGeneration: run.consumer_generation + }) + + for (let index = 0; index < 10; index += 1) { + db.getTask(workerTask.id, run.id) + db.getActiveDispatchForTerminal('term-creator') + } + const startedAt = performance.now() + for (let index = 0; index < 300; index += 1) { + expect(db.getTask(workerTask.id, run.id)?.creator_dispatch_id).toBe(creatorDispatch.id) + expect(db.getActiveDispatchForTerminal('term-creator')?.id).toBe(creatorDispatch.id) + } + const elapsedMs = performance.now() - startedAt + + const competingTask = db.createTask({ spec: 'competing creator', runId: run.id }) + expect(() => db!.createDispatchContext(competingTask.id, 'term-creator')).toThrow( + `Terminal term-creator already has an active dispatch (${creatorDispatch.id}` + ) + expect(elapsedMs).toBeLessThan(200) + } + ) +}) diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 914321e0646..8cb0547ee27 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -218,7 +218,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { } }) - it('migrates a populated v21 database idempotently', () => { + it('migrates a populated upstream v23 database idempotently', () => { tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-')) const dbPath = join(tempDir, 'orchestration.db') db = new OrchestrationDb(dbPath) @@ -228,14 +228,25 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = undefined const oldDb = new Database(dbPath) - oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle') - oldDb.pragma('user_version = 21') + oldDb.exec(` + DROP INDEX IF EXISTS idx_dispatch_active_assignee_handle; + DROP INDEX IF EXISTS idx_dispatch_assignee_pane_leaf; + ALTER TABLE tasks DROP COLUMN created_by_pane_key; + ALTER TABLE tasks DROP COLUMN created_by_process_incarnation; + ALTER TABLE tasks DROP COLUMN created_by_run_generation; + `) + oldDb.pragma('user_version = 23') oldDb.close() db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(22) + expect(sqlite.pragma('user_version', { simple: true })).toBe(25) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) + expect(db.getTask(task.id)).toMatchObject({ + created_by_pane_key: null, + created_by_process_incarnation: null, + created_by_run_generation: null + }) expect( sqlite .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") @@ -249,12 +260,75 @@ describe('OrchestrationDb dispatch assignee index migration', () => { ) .all('term_worker') as { detail: string }[] expect(plan.map((row) => row.detail).join('\n')).toContain( - 'USING INDEX idx_dispatch_assignee_handle' + 'USING INDEX idx_dispatch_active_assignee_handle' ) + expect( + sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_active_assignee_handle') + ).toMatchObject({ + sql: expect.stringContaining("status IN ('pending', 'dispatched')") + }) + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_assignee_pane_leaf') + ).toBeDefined() db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) + + it('adds the active-handle index to a populated v24 database idempotently', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-active-dispatch-index-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + const run = db.createRun({ + objective: 'retained v24 authority', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ + spec: 'indexed lookup', + runId: run.id, + createdByTerminalHandle: 'term_creator', + createdByPaneKey: 'tab_creator:leaf_creator', + createdByProcessIncarnation: 'pty_creator:incarnation-a', + createdByRunGeneration: run.consumer_generation + }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_active_assignee_handle') + oldDb.pragma('user_version = 24') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(db.getTask(task.id)).toMatchObject({ + created_by_pane_key: 'tab_creator:leaf_creator', + created_by_process_incarnation: 'pty_creator:incarnation-a', + created_by_run_generation: 1 + }) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + assignee_handle: 'term_worker' + }) + expect( + sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_active_assignee_handle') + ).toMatchObject({ + sql: expect.stringContaining('assignee_handle IS NOT NULL') + }) + + db.close() + db = new OrchestrationDb(dbPath) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') + }) }) diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts index baa5af51cf3..73f29371b50 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -64,6 +64,19 @@ describe('orchestration setup completion signal', () => { expect(command).toContain('bash /c/repo/.git/orca/setup-runner.sh') }) + it('keeps a batch runner on the Windows completion path from a Git Bash pane', () => { + // Regression (#6896): a Git Bash terminal with a batch setup script still gets a .cmd + // runner; observing it must not shell out to bash or type a bare `cmd.exe /c` switch. + const runnerPath = 'C:\\repo\\.git\\orca\\setup-runner.cmd' + const observed = buildObservedSetupCommand(runnerPath, 'windows', 'token-git-bash-cmd', { + family: 'posix' + }) + + expect(observed.command).toContain('powershell.exe -NoLogo -NoProfile -NonInteractive') + expect(observed.command).not.toContain('bash ') + expect(observed.env).toEqual({ ORCA_SETUP_RUNNER_PATH: runnerPath }) + }) + it('recognizes one completion signal across output chunk boundaries', () => { const onComplete = vi.fn() const scanner = createSetupCompletionScanner('token-chunks', onComplete) diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 76b3d536fe8..e399b13ce49 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -246,6 +246,9 @@ export type TaskRow = { run_id: string parent_id: string | null created_by_terminal_handle: string | null + created_by_pane_key: string | null + created_by_process_incarnation: string | null + created_by_run_generation: number | null task_title: string | null display_name: string | null spec: string diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts new file mode 100644 index 00000000000..c6fe9970feb --- /dev/null +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -0,0 +1,132 @@ +import type { AgentType, NativeChatMessage } from '../../../shared/native-chat-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationError } from './orchestration-error' +import { + MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT, + redactWorkerTerminalLines +} from './worker-transcript-payload' +import { readWorkerTranscript } from './worker-transcript-read' + +// Bound the durable copy of raw terminal output; the tail end is the evidence that matters. +const TERMINAL_ARCHIVE_MAX_CHARS = 262_144 + +export type WorkerTranscriptPinArchive = { + agent: AgentType + providerSessionKey: string + providerSessionId: string + transcriptPath: string | null + processIncarnation: string + observedAfter: number + endOffset?: number +} + +export type WorkerTranscriptSnapshotArchive = { + version: 2 + agent: AgentType + processIncarnation: string + messages: NativeChatMessage[] + limited: boolean + warnings: string[] +} + +export type WorkerTerminalTailArchive = { + lines: string[] + truncated: boolean + terminalStatus: string + warnings: string[] +} + +export type WorkerOutputArchiveCapture = + | { + kind: 'transcript_pin' + content: WorkerTranscriptSnapshotArchive + status: 'captured' + } + | { kind: 'terminal_tail'; content: WorkerTerminalTailArchive; status: 'captured' | 'empty' } + +// Freezes an inspectable output source before the live PTY is closed. Prefers the exact +// hook-reported provider transcript; falls back to bounded redacted terminal output. Throws +// typed archive_failed so release retains the live terminal when no evidence can be preserved. +export async function captureWorkerOutputArchive(args: { + runtime: OrcaRuntimeService + dispatchId: string + terminalHandle: string + attachedAtMs: number +}): Promise { + const session = args.runtime.getExactWorkerProviderSession(args.terminalHandle, args.attachedAtMs) + if (session) { + const snapshot = await readWorkerTranscript({ + agent: session.agent, + sessionId: session.providerSession.id, + transcriptPath: session.providerSession.transcriptPath, + limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT + }).catch(() => null) + if (snapshot?.ok && snapshot.messages.length > 0) { + return { + kind: 'transcript_pin', + status: 'captured', + content: { + version: 2, + agent: session.agent, + processIncarnation: session.processIncarnation, + messages: snapshot.messages, + limited: snapshot.limited, + warnings: snapshot.warnings + } + } + } + } + let terminal + try { + terminal = await args.runtime.readTerminal(args.terminalHandle, {}) + } catch (error) { + throw new OrchestrationError( + 'archive_failed', + `Output could not be preserved for Dispatch ${args.dispatchId}; the terminal was retained. ${error instanceof Error ? error.message : String(error)}` + ) + } + const redacted = redactWorkerTerminalLines(terminal.tail) + const bounded = boundArchiveLines(redacted.lines) + // Why: an exited PTY zeroes its tail immediately, so an empty capture is a distinct receipt, + // not silent success — worker-read must be able to say why nothing is there. + const empty = bounded.lines.every((line) => line.trim() === '') + return { + kind: 'terminal_tail', + status: empty ? 'empty' : 'captured', + content: { + lines: bounded.lines, + truncated: terminal.truncated || bounded.truncated, + terminalStatus: terminal.status, + warnings: empty + ? [ + ...redacted.warnings, + 'The live terminal buffer was empty at release; structured transcript output was unavailable.' + ] + : redacted.warnings + } + } +} + +function boundArchiveLines(lines: string[]): { lines: string[]; truncated: boolean } { + let total = 0 + for (const line of lines) { + total += line.length + 1 + } + if (total <= TERMINAL_ARCHIVE_MAX_CHARS) { + return { lines, truncated: false } + } + const kept: string[] = [] + let budget = TERMINAL_ARCHIVE_MAX_CHARS + for (let index = lines.length - 1; index >= 0; index -= 1) { + const cost = lines[index].length + 1 + if (cost > budget) { + if (kept.length === 0 && budget > 1) { + kept.unshift(lines[index].slice(-(budget - 1))) + } + break + } + kept.unshift(lines[index]) + budget -= cost + } + return { lines: kept, truncated: true } +} diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts new file mode 100644 index 00000000000..d6f160cda4a --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -0,0 +1,134 @@ +import type { WorkerDispatchState } from './types' + +export type WorkerTerminalOwnershipState = + | 'owned' + | 'transferred' + | 'user_owned' + | 'external' + | 'released' + +export type WorkerTerminalReleaseState = + | 'not_requested' + | 'retained' + | 'requested' + | 'releasing' + | 'released' + | 'unknown' + +export type WorkerTerminalRetainedReason = + | 'external_terminal' + | 'ownership_transferred' + | 'user_takeover' + | 'user_requested' + | 'no_owned_resource' + | 'identity_unproven' + | 'legacy_ambiguous' + | 'federation_unsupported' + +export type WorkerTerminalArchiveStatus = 'captured' | 'empty' | 'unavailable' + +export type WorkerTerminalResourceRow = { + id: string + origin_dispatch_id: string + owner_dispatch_id: string + prior_owner_dispatch_ids: string + worktree_id: string | null + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + host_scope: string | null + ownership_state: WorkerTerminalOwnershipState + release_state: WorkerTerminalReleaseState + retained_reason: string | null + release_requested_at: string | null + release_completed_at: string | null + release_error: string | null + archive_source: string | null + archive_status: WorkerTerminalArchiveStatus | null + created_at: string + updated_at: string +} + +// Terminal state exposed by worker-list; process accounting, never Task/Dispatch outcome. +export type WorkerTerminalListState = + | 'active' + | 'reclaimable' + | 'retained' + | 'release_pending' + | 'release_unknown' + | 'released' + +export type WorkerTerminalArchiveRow = { + dispatch_id: string + resource_id: string + kind: 'transcript_pin' | 'terminal_tail' + content: string + created_at: string +} + +export const WORKER_SETTLED_STATES: readonly WorkerDispatchState[] = [ + 'succeeded', + 'failed', + 'stopped', + 'abandoned' +] + +export const WORKER_RELEASABLE_STATES: readonly WorkerDispatchState[] = ['succeeded', 'failed'] + +// Same-operation creation evidence: worker-start labels its own agent terminal 'created' +// (existing worktree) or 'reused_agent_terminal' (agent-first worktree creation). An explicit +// --terminal reuse is labeled 'reused' and never claims ownership. +export function residualResourcesClaimAgentTerminal( + residualResourcesJson: string, + terminalHandle: string +): boolean { + let parsed: unknown + try { + parsed = JSON.parse(residualResourcesJson) + } catch { + return false + } + if (!Array.isArray(parsed)) { + return false + } + return parsed.some((effect) => { + if (!effect || typeof effect !== 'object') { + return false + } + const candidate = effect as { kind?: unknown; role?: unknown; action?: unknown; id?: unknown } + return ( + candidate.kind === 'terminal' && + candidate.role === 'agent' && + candidate.id === terminalHandle && + (candidate.action === 'created' || candidate.action === 'reused_agent_terminal') + ) + }) +} + +// Process accounting for worker-list; deliberately independent of Task/Dispatch outcome. +export function deriveWorkerTerminalListState(params: { + workerState: WorkerDispatchState + agentTerminalHandle: string | null + resource: WorkerTerminalResourceRow | null +}): WorkerTerminalListState | null { + const { resource } = params + if (!resource) { + return params.agentTerminalHandle ? 'retained' : null + } + if (resource.release_state === 'released') { + return 'released' + } + if (resource.release_state === 'unknown') { + return 'release_unknown' + } + if (resource.release_state === 'requested' || resource.release_state === 'releasing') { + return 'release_pending' + } + if (resource.ownership_state !== 'owned' || resource.release_state === 'retained') { + return 'retained' + } + if (WORKER_RELEASABLE_STATES.includes(params.workerState)) { + return 'reclaimable' + } + return WORKER_SETTLED_STATES.includes(params.workerState) ? 'retained' : 'active' +} diff --git a/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts b/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts new file mode 100644 index 00000000000..86a10ed06de --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts @@ -0,0 +1,101 @@ +import type { OrcaRuntimeService } from '../orca-runtime' +import { completeWorkerTerminalRelease } from '../rpc/methods/orchestration-worker-release-completion' + +export type WorkerTerminalReleaseReconciliationResult = { + attempted: number + released: number + pending: number + unknown: number + retained: number +} + +type ActiveReconciliation = { + rerunRequested: boolean + promise?: Promise +} + +const activeReconciliationByRuntime = new WeakMap() + +// Finishes ONLY previously requested releases after startup/reconnect terminal discovery. +// It never invents release intent: resources outside requested/releasing are untouched, and +// unresolved identity defers (release_pending) rather than settling or broadening the close. +export function reconcileRequestedWorkerTerminalReleases( + runtime: OrcaRuntimeService +): Promise { + const active = activeReconciliationByRuntime.get(runtime) + if (active?.promise) { + active.rerunRequested = true + return active.promise + } + const state: ActiveReconciliation = { rerunRequested: false } + const reconciliation = runReconciliationPasses(runtime, state).finally(() => { + if (activeReconciliationByRuntime.get(runtime) === state) { + activeReconciliationByRuntime.delete(runtime) + } + }) + state.promise = reconciliation + activeReconciliationByRuntime.set(runtime, state) + return reconciliation +} + +async function runReconciliationPasses( + runtime: OrcaRuntimeService, + state: ActiveReconciliation +): Promise { + const combined = emptyResult() + do { + state.rerunRequested = false + const pass = await reconcileRequestedWorkerTerminalReleasesOnce(runtime) + combined.attempted += pass.attempted + combined.released += pass.released + combined.pending += pass.pending + combined.unknown += pass.unknown + combined.retained += pass.retained + } while (state.rerunRequested) + if (combined.attempted > 0) { + // Structured counts only; never transcript content or paths. + console.info('[orchestration] worker terminal release reconciliation', combined) + } + return combined +} + +async function reconcileRequestedWorkerTerminalReleasesOnce( + runtime: OrcaRuntimeService +): Promise { + const db = runtime.getOrchestrationDb() + const backlog = db.listWorkerTerminalReleaseBacklog() + const result = { ...emptyResult(), attempted: backlog.length } + for (const resource of backlog) { + try { + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId: resource.owner_dispatch_id, + resource, + mode: 'recovery' + }) + if (receipt.state === 'released' || receipt.state === 'already_released') { + result.released += 1 + } else if (receipt.state === 'release_pending') { + result.pending += 1 + } else if (receipt.state === 'release_unknown') { + result.unknown += 1 + } else { + result.retained += 1 + } + } catch (error) { + // Archive or endpoint failure: durable intent stays requested; a later discovery retries. + console.warn('[orchestration] worker terminal release retry failed', { + resourceId: resource.id, + dispatchId: resource.owner_dispatch_id, + error: error instanceof Error ? error.message : String(error) + }) + result.pending += 1 + } + } + return result +} + +function emptyResult(): WorkerTerminalReleaseReconciliationResult { + return { attempted: 0, released: 0, pending: 0, unknown: 0, retained: 0 } +} diff --git a/src/main/runtime/orchestration/worker-transcript-read.test.ts b/src/main/runtime/orchestration/worker-transcript-read.test.ts index 0bcceda11f5..48500734a5f 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.test.ts @@ -77,6 +77,49 @@ describe('worker transcript reads', () => { }) }) + it('pins archived reads to the transcript offset observed before release', async () => { + await writeFile( + transcriptPath, + `${codexMessage('one', 'before release')}\n${codexMessage('two', 'release boundary')}\n` + ) + const snapshot = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + limit: 1 + }) + if (!snapshot.ok) { + throw new Error('Expected the release transcript probe') + } + await appendFile(transcriptPath, `${codexMessage('three', 'after release')}\n`) + + await expect( + readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + endOffset: snapshot.nextOffset, + limit: 10 + }) + ).resolves.toMatchObject({ + ok: true, + messages: [ + { id: 'one', blocks: [{ type: 'text', text: 'before release' }] }, + { id: 'two', blocks: [{ type: 'text', text: 'release boundary' }] } + ] + }) + await expect( + readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: snapshot.nextOffset, + endOffset: snapshot.nextOffset, + limit: 10 + }) + ).resolves.toMatchObject({ ok: true, messages: [], nextOffset: snapshot.nextOffset }) + }) + it('reports source changes and unsupported providers without guessing', async () => { await writeFile(transcriptPath, `${codexMessage('one', 'first')}\n`) diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts index d893c35f995..f63e073b825 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -39,6 +39,7 @@ export async function readWorkerTranscript(args: { sessionId: string transcriptPath?: string offset?: number + endOffset?: number limit?: number }): Promise { const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) @@ -64,8 +65,8 @@ export async function readWorkerTranscript(args: { try { const page = args.offset === undefined - ? await readInitialPage(filePath, limit, decode) - : await readForwardPage(filePath, args.offset, limit, decode) + ? await readInitialPage(filePath, limit, decode, args.endOffset) + : await readForwardPage(filePath, args.offset, limit, decode, args.endOffset) if (!page.ok) { return page } @@ -96,9 +97,13 @@ export async function readWorkerTranscript(args: { async function readInitialPage( filePath: string, limit: number, - decode: NativeChatLineDecoder -): Promise { - const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false) + decode: NativeChatLineDecoder, + endOffset?: number +): Promise { + if (endOffset !== undefined && (await stat(filePath)).size < endOffset) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false, endOffset) return { ok: true, filePath, @@ -113,9 +118,14 @@ async function readForwardPage( filePath: string, startOffset: number, limit: number, - decode: NativeChatLineDecoder + decode: NativeChatLineDecoder, + endOffset?: number ): Promise { - const fileSize = (await stat(filePath)).size + const currentFileSize = (await stat(filePath)).size + if (endOffset !== undefined && currentFileSize < endOffset) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const fileSize = Math.min(currentFileSize, endOffset ?? Number.MAX_SAFE_INTEGER) if (startOffset > fileSize) { return { ok: false, reason: 'source_changed', warnings: [] } } diff --git a/src/main/runtime/pairing-endpoint.test.ts b/src/main/runtime/pairing-endpoint.test.ts index d43c95d7873..5fcebe0afd8 100644 --- a/src/main/runtime/pairing-endpoint.test.ts +++ b/src/main/runtime/pairing-endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { + resolveAdvertisedPairingEndpoint, + resolveAdvertisedPairingHostname +} from './pairing-endpoint' import { PAIRING_OFFER_VERSION, PairingOfferSchema } from '../../shared/mobile-relay-pairing-offer' import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' import { parseManualNetworkAddress } from '../../shared/network/manual-address' @@ -100,3 +103,31 @@ describe('resolveAdvertisedPairingEndpoint', () => { expect(PairingOfferSchema.safeParse(offer(aboveLimit)).success).toBe(false) }) }) + +describe('resolveAdvertisedPairingHostname', () => { + // Why: the loopback/off-host decision must read the host a link will advertise, not the raw string the + // user typed — `127.0.0.1:8443` and `ws://127.0.0.1:6768` both publish a loopback host. + it.each([ + ['127.0.0.1', '127.0.0.1'], + ['127.0.0.1:8443', '127.0.0.1'], + ['localhost', 'localhost'], + ['localhost:8443', 'localhost'], + ['::1', '::1'], + ['[::1]:6768', '::1'], + ['ws://127.0.0.1:6768', '127.0.0.1'], + ['http://localhost:6768/web-index.html', 'localhost'], + ['100.64.1.20', '100.64.1.20'], + ['192.168.1.5:6768', '192.168.1.5'], + ['desktop', 'desktop'], + ['wss://proxy.example.test:8443/orca', 'proxy.example.test'] + ])('reads the advertised host of %s', (input, hostname) => { + expect(resolveAdvertisedPairingHostname(input)).toBe(hostname) + }) + + it.each([null, undefined, '', ' ', 'host.example.test:', 'ftp://proxy.example.test'])( + 'returns null for the unusable address %j', + (input) => { + expect(resolveAdvertisedPairingHostname(input)).toBeNull() + } + ) +}) diff --git a/src/main/runtime/pairing-endpoint.ts b/src/main/runtime/pairing-endpoint.ts index c7af166f191..75ef7ae68c6 100644 --- a/src/main/runtime/pairing-endpoint.ts +++ b/src/main/runtime/pairing-endpoint.ts @@ -40,6 +40,23 @@ export function resolveAdvertisedPairingEndpoint( return valid(formatWebSocketUrl(endpoint)) } +// Why: callers that must reason about WHERE a link points (loopback vs off-host) need the hostname the +// offer will advertise, not the raw string the user typed — `127.0.0.1:8443`, `[::1]:6768` and +// `ws://127.0.0.1:6768` all advertise a loopback host but none of them parse as one on their own. +export function resolveAdvertisedPairingHostname( + advertisedAddress: string | null | undefined +): string | null { + const override = advertisedAddress?.trim() + if (!override) { + return null + } + if (override.includes('://')) { + const normalized = normalizePairingUrl(override) + return normalized ? unbracketIpv6(new URL(normalized).hostname) : null + } + return parseHostOverride(override)?.hostname ?? null +} + function resolveFullUrl(value: string): PairingEndpointResolution { const endpoint = normalizePairingUrl(value) return endpoint ? valid(endpoint) : invalid() diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index c52d80a8fe4..04236fea2b7 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -42,12 +42,18 @@ export function pairingAuthorizationForContext( : null } +// Why: a broker that died without arming a retry (sleep past token expiry, +// transient auth read) must not stay dead until the user clicks Retry. The +// cadence is slow because it is a safety net, not the primary retry path. +const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000 + export class DesktopRelayService { private readonly coordinator: RelayAuthCoordinator private readonly revokeOutbox: RelayRevokeOutbox private readonly runtimeRpc: OrcaRuntimeRpcServer private readonly demandLedger: RelayDemandLedger private demandExpiryTimer: ReturnType | null = null + private livenessTimer: ReturnType | null = null private stopped = false constructor(options: DesktopRelayServiceOptions) { @@ -90,6 +96,16 @@ export class DesktopRelayService { start(): void { this.refreshDemand() + if (!this.livenessTimer) { + this.livenessTimer = setInterval(() => this.ensureLive(), RELAY_LIVENESS_INTERVAL_MS) + } + } + + // Safe to call from any wake signal (power resume, network change). + ensureLive(): void { + if (!this.stopped) { + this.coordinator.ensureLive() + } } authMutated(): void { @@ -217,6 +233,10 @@ export class DesktopRelayService { clearTimeout(this.demandExpiryTimer) this.demandExpiryTimer = null } + if (this.livenessTimer) { + clearInterval(this.livenessTimer) + this.livenessTimer = null + } this.coordinator.stop() } diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 29b1d3596da..8bfc71688e0 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -257,3 +257,77 @@ describe('RelayAuthCoordinator transient recovery', () => { expect(openBroker).toHaveBeenCalledTimes(2) }) }) + +describe('RelayAuthCoordinator liveness safety net', () => { + it('reopens a broker that died leaving no retry timer behind', async () => { + // Why: an auth refresh failing past token expiry closes the broker with + // no timer; only ensureLive (periodic/power-resume) can revive it. + vi.useFakeTimers() + const dead = { closeNow: vi.fn(), isLive: () => false } + const live = { closeNow: vi.fn(), isLive: () => true } + const openBroker = vi.fn().mockResolvedValueOnce(dead).mockResolvedValueOnce(live) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(dead) + await vi.advanceTimersByTimeAsync(600_000) + expect(openBroker).toHaveBeenCalledOnce() + + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + expect(dead.closeNow).toHaveBeenCalled() + expect(coordinator.getActiveBroker()).toBe(live) + }) + + it('does not disturb a live broker, a scheduled retry, or an open in flight', async () => { + vi.useFakeTimers() + const broker = { closeNow: vi.fn(), isLive: () => true } + let releaseOpen: ((value: typeof broker) => void) | undefined + const openBroker = vi + .fn() + .mockRejectedValueOnce(new RelayHttpError('assignment', 503, 30_000)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseOpen = resolve + }) + ) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledOnce() + + // A scheduled Retry-After timer owns recovery; ensureLive must not race it. + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledOnce() + + // The retry fires into a slow open; ensureLive must not preempt it. + await vi.advanceTimersByTimeAsync(30_000) + expect(openBroker).toHaveBeenCalledTimes(2) + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + releaseOpen?.(broker) + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(broker) + + // A live broker needs nothing. + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index a09c8f6374a..f6539842f8a 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -92,6 +92,22 @@ export class RelayAuthCoordinator { return this.ownership?.valid ? this.ownership.broker : null } + // Why: some broker deaths end with no retry timer — an auth refresh that + // fails past token expiry (laptop sleep), or a transient context read that + // returned null at open. Periodic/power-resume callers use this as a + // dead-man's switch; it never disturbs a live broker, a scheduled retry, + // or an open already in flight. + ensureLive(): void { + if (this.stopped || this.retryTimer || this.pendingOwnerships.size > 0) { + return + } + const ownership = this.ownership + if (ownership?.valid && (ownership.broker?.isLive?.() ?? true)) { + return + } + this.beginReconcile(false) + } + async waitForActiveBroker(): Promise { while (!this.stopped) { const broker = this.getActiveBroker() diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index a622ea4f835..606972f4249 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -85,6 +85,11 @@ describe('aiVault.listSessions params schema', () => { expect(parsed.success).toBe(false) }) + it('accepts Unlimited without applying the numeric cap', () => { + const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000, unlimited: true }) + expect(parsed.success).toBe(true) + }) + it('clamps scopePaths past the cap instead of rejecting', () => { // Why: uncapped producers (web client, pre-cap desktop parents) may exceed // the bound; scope paths only widen discovery, so truncation is safe. @@ -181,6 +186,33 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('serves lower depths from a larger completed scan', async () => { + await listAiVaultSessions({ limit: 1000 }) + await listAiVaultSessions({ limit: 250 }) + await listAiVaultSessions({ limit: 500 }) + + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('shares a cache entry across equivalent scope path ordering', async () => { + await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] }) + await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] }) + + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('forwards Unlimited without a numeric limit', async () => { + const dispatcher = makeDispatcher() + const response = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { limit: 5000, unlimited: true }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(scanAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ limit: undefined, unlimited: true }) + ) + }) + it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { // Why: the resolving scan's cleanup must not clear tracking a concurrent // different-key scan replaced, or re-requests start a duplicate rescan. diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index e494148ef7f..70acf5dab67 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -23,27 +23,34 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri return z.NEVER }) -export const AiVaultListSessionsParams = z.object({ - limit: z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()])) - .optional(), - force: OptionalBoolean, - scopePaths: z - .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) - // Why: clamp instead of reject — scope paths only ever widen discovery, and - // rejecting would hard-break older/uncapped producers (web client, pre-cap - // desktop parents) that send more than the bound. - .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) - .optional(), - // Why: desktop/web callers name the runtime host they are addressing; mobile - // omits it. The scan itself is host-local either way, so the id must never - // change what is scanned — it only restamps the shared cached result. - executionHostId: executionHostIdSchema.optional() -}) +export const AiVaultListSessionsParams = z + .object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int(), z.undefined()])) + .optional(), + unlimited: OptionalBoolean, + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() + }) + .superRefine((params, ctx) => { + if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { + ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) + } + }) export const AiVaultPrepareSessionResumeParams = z.object({ agent: z.enum(AI_VAULT_AGENTS), @@ -58,7 +65,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ params: AiVaultListSessionsParams, handler: async (params, { runtime }) => { const result = await runtime.listAiVaultSessions({ - limit: params.limit, + limit: params.unlimited ? undefined : params.limit, + unlimited: params.unlimited, force: params.force, scopePaths: params.scopePaths }) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index af254ca02e5..0e07476b2e6 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -223,6 +223,7 @@ const UiUpdateFields = z showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), hideCliCreatedWorkspaces: z.boolean().optional(), hideDetachedHeadWorkspaces: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3e3bbd9ba35..2a4b17b455e 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -555,7 +555,8 @@ describe('client UI RPC methods', () => { ], ['browserImportHintHidden', { browserImportHintHidden: true }], ['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }], - ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }] + ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }], + ['alwaysShowDefaultBranchWorkspace', { alwaysShowDefaultBranchWorkspace: false }] ])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -592,6 +593,7 @@ describe('client UI RPC methods', () => { showSleepingWorkspaces: true, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: { 'repo::/worktree': true }, filterRepoIds: ['repo-1'], acknowledgedAgentsByPaneKey: { 'pane-1': 123 } diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index ca429f3d492..fe1ecd113c7 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -313,4 +313,32 @@ describe('gitlab RPC methods', () => { ) expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1) }) + + // Regression for #7732: the WS/relay transports close the connection on frames + // over 1 MB, so the excerpt must be produced before the response is serialised. + it('bounds the job trace before it crosses the transport when logExcerpt is set', async () => { + const noisyTrace = [ + 'section_start:1699000000:build\r\u001b[0K$ pnpm build', + ...Array.from({ length: 400 }, (_, index) => `line ${index}`), + 'ERROR: Job failed: exit code 1' + ].join('\n') + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitLabRepoJobTrace: vi.fn().mockResolvedValue({ ok: true, trace: noisyTrace }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) + + const raw = await dispatcher.dispatch( + makeRequest('gitlab.jobTrace', { repo: 'id:repo-1', jobId: 99 }) + ) + const excerpt = await dispatcher.dispatch( + makeRequest('gitlab.jobTrace', { repo: 'id:repo-1', jobId: 99, logExcerpt: true }) + ) + + expect((raw as { result: { trace: string } }).result.trace).toBe(noisyTrace) + const bounded = (excerpt as { result: { trace: string } }).result.trace + expect(bounded).toContain('ERROR: Job failed: exit code 1') + expect(bounded).not.toContain('section_start') + expect(bounded).not.toContain('line 0\n') + }) }) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index c10a7eee7b5..81c298d57bb 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' +import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -16,12 +17,14 @@ const GitLabRateLimit = z .optional() .default({}) +// nullish, not optional: renderer callers normalise a missing ref to `null` +// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. const GitLabProjectRef = z .object({ host: requiredString('Missing GitLab host'), path: requiredString('Missing GitLab project path') }) - .optional() + .nullish() const WorkItemsList = RepoSelector.extend({ state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), @@ -121,7 +124,10 @@ const ResolveMRDiscussion = RepoSelector.extend({ const JobTrace = RepoSelector.extend({ jobId: z.number().int().positive(), - projectRef: GitLabProjectRef + projectRef: GitLabProjectRef, + // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers + // that only render an excerpt ask main to bound it before it crosses the wire. + logExcerpt: z.boolean().optional() }) const RetryJob = RepoSelector.extend({ @@ -245,8 +251,14 @@ export const GITLAB_METHODS: RpcMethod[] = [ defineMethod({ name: 'gitlab.jobTrace', params: JobTrace, - handler: async (params, { runtime }) => - runtime.getGitLabRepoJobTrace(params.repo, params.jobId, params.projectRef) + handler: async (params, { runtime }) => { + const result = await runtime.getGitLabRepoJobTrace( + params.repo, + params.jobId, + params.projectRef + ) + return params.logExcerpt ? toGitLabJobLogExcerptResult(result) : result + } }), defineMethod({ name: 'gitlab.retryJob', diff --git a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts new file mode 100644 index 00000000000..29e01e45558 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts @@ -0,0 +1,226 @@ +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../../shared/orchestration-worker-output' +import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { + WorkerTerminalArchiveRow, + WorkerTerminalResourceRow +} from '../../orchestration/worker-terminal-ownership' +import type { + WorkerTerminalTailArchive, + WorkerTranscriptPinArchive, + WorkerTranscriptSnapshotArchive +} from '../../orchestration/worker-output-archive' +import { clampWorkerTranscriptLimit } from '../../orchestration/worker-transcript-payload' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import { readWorkerTranscript } from '../../orchestration/worker-transcript-read' + +const ARCHIVED_TERMINAL_PAGE_LINES = 2_000 + +// Serves the frozen output source after the live PTY is gone. Transcript pins read the exact +// provider transcript directly; terminal archives page the stored redacted tail. Cursors stay +// Dispatch-scoped and source-pinned exactly like live reads. +export async function readArchivedWorkerOutput(args: { + db: OrchestrationDb + dispatchId: string + workerState: string + resource: WorkerTerminalResourceRow + source?: OrchestrationWorkerReadSource + cursor?: string | number + limit?: number +}): Promise { + const archive = args.db.getWorkerTerminalArchive(args.dispatchId) + if (!archive) { + throw new OrchestrationError( + 'archive_unavailable', + `Dispatch ${args.dispatchId} was released without a preserved output archive.` + ) + } + if (archive.kind === 'transcript_pin') { + if (args.source === 'terminal') { + throw new OrchestrationError( + 'archive_unavailable', + `Dispatch ${args.dispatchId} preserved structured transcript output only; terminal output was released.` + ) + } + const content = JSON.parse(archive.content) as + | WorkerTranscriptPinArchive + | WorkerTranscriptSnapshotArchive + return isTranscriptSnapshot(content) + ? readFrozenTranscript(args, archive, content) + : readLegacyPinnedTranscript(args, content) + } + if (args.source === 'transcript') { + throw new OrchestrationError( + 'transcript_required', + `Structured output is unavailable for released Dispatch ${args.dispatchId}: the archive holds terminal output only.` + ) + } + return readArchivedTerminalTail(args, archive) +} + +function readFrozenTranscript( + args: Parameters[0], + archive: WorkerTerminalArchiveRow, + snapshot: WorkerTranscriptSnapshotArchive +): OrchestrationWorkerReadResult { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-transcript-snapshot', + args.resource.id, + snapshot.processIncarnation, + archive.created_at + ]) + if (cursor && (cursor.source !== 'transcript' || cursor.sourceIdentity !== sourceIdentity)) { + throw sourceChanged() + } + const start = Math.min(cursor?.position ?? 0, snapshot.messages.length) + const end = Math.min(start + clampWorkerTranscriptLimit(args.limit), snapshot.messages.length) + const nextCursor = encodeWorkerOutputCursor(args.dispatchId, 'transcript', sourceIdentity, end) + return { + dispatchId: args.dispatchId, + source: 'transcript', + sourceIdentity, + provider: snapshot.agent, + transcript: { + messages: snapshot.messages.slice(start, end), + nextCursor, + limited: end < snapshot.messages.length, + returnedMessageCount: end - start + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: [ + ...snapshot.warnings, + ...(snapshot.limited + ? ['Older transcript messages were omitted from the bounded archive.'] + : []) + ], + archived: true + } +} + +async function readLegacyPinnedTranscript( + args: Parameters[0], + pin: WorkerTranscriptPinArchive +): Promise { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-transcript', + pin.processIncarnation, + pin.agent, + pin.providerSessionKey, + pin.providerSessionId, + pin.transcriptPath ?? '', + String(pin.endOffset) + ]) + if (cursor && cursor.source !== 'transcript') { + throw sourceChanged() + } + if (cursor && cursor.sourceIdentity !== sourceIdentity) { + throw sourceChanged() + } + const transcript = await readWorkerTranscript({ + agent: pin.agent, + sessionId: pin.providerSessionId, + transcriptPath: pin.transcriptPath ?? undefined, + offset: cursor?.position, + endOffset: pin.endOffset, + limit: args.limit + }) + if (!transcript.ok) { + throw new OrchestrationError( + 'transcript_required', + `The pinned transcript for released Dispatch ${args.dispatchId} is unavailable: ${transcript.reason}.`, + { reason: transcript.reason } + ) + } + const nextCursor = encodeWorkerOutputCursor( + args.dispatchId, + 'transcript', + sourceIdentity, + transcript.nextOffset + ) + return { + dispatchId: args.dispatchId, + source: 'transcript', + sourceIdentity, + provider: pin.agent, + transcript: { + messages: transcript.messages, + nextCursor, + limited: transcript.limited, + returnedMessageCount: transcript.messages.length + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: transcript.warnings, + archived: true + } +} + +function isTranscriptSnapshot( + content: WorkerTranscriptPinArchive | WorkerTranscriptSnapshotArchive +): content is WorkerTranscriptSnapshotArchive { + return 'version' in content && content.version === 2 +} + +function readArchivedTerminalTail( + args: Parameters[0], + archive: WorkerTerminalArchiveRow +): OrchestrationWorkerReadResult { + const content = JSON.parse(archive.content) as WorkerTerminalTailArchive + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-terminal', + args.resource.id, + archive.created_at + ]) + if (cursor && cursor.source !== 'terminal') { + throw sourceChanged() + } + if (cursor && (cursor.legacy || cursor.sourceIdentity !== sourceIdentity)) { + throw sourceChanged() + } + const start = Math.min(cursor?.position ?? 0, content.lines.length) + const pageSize = Math.max(1, Math.min(args.limit ?? ARCHIVED_TERMINAL_PAGE_LINES, 10_000)) + const end = Math.min(start + pageSize, content.lines.length) + const tail = content.lines.slice(start, end) + const nextCursor = + end < content.lines.length + ? encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, end) + : null + return { + dispatchId: args.dispatchId, + source: 'terminal', + sourceIdentity, + terminal: { + handle: args.resource.terminal_handle, + status: 'exited', + tail, + truncated: content.truncated, + nextCursor, + returnedLineCount: tail.length + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: content.warnings, + archived: true + } +} + +function sourceChanged(): OrchestrationError { + return new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index ae64f967e06..23233b63965 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -3,14 +3,8 @@ import { ORCHESTRATION_WORKER_READ_SOURCES, type OrchestrationWorkerReadResult } from '../../../../shared/orchestration-worker-output' -import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' import { OrchestrationError } from '../../orchestration/orchestration-error' import { syncFederatedDispatch } from '../../orchestration/federation-sync' -import { - createWorkerOutputSourceIdentity, - decodeWorkerOutputCursor, - encodeWorkerOutputCursor -} from '../../orchestration/worker-output-cursor' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, requiredString } from '../schemas' import { @@ -19,7 +13,10 @@ import { inspectWorkerTerminal, resolvePinnedFederatedServer } from './orchestration-worker-observation' +import { readArchivedWorkerOutput } from './orchestration-worker-archive-read' +import { readLegacyFederatedTerminal } from './orchestration-worker-legacy-federated-read' import { readExactWorkerOutput } from './orchestration-worker-output' +import { exposeWorkerTerminalResource } from './orchestration-worker-release-completion' const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) const WorkerReadParams = WorkerDispatchParams.extend({ @@ -119,11 +116,13 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ } } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + const resource = db.getWorkerTerminalResourceByOwner(params.dispatch) return { dispatch, worker: exposeWorker(worker), terminal: observation.exact ? observation.terminal : null, - observation: { status: observation.status, exactWorker: observation.exact } + observation: { status: observation.status, exactWorker: observation.exact }, + terminalResource: resource ? exposeWorkerTerminalResource(resource) : null } } }), @@ -175,6 +174,18 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ `Worker Dispatch ${params.dispatch} has no agent terminal.` ) } + const resource = db.getWorkerTerminalResourceByOwner(params.dispatch) + if (resource && ['releasing', 'released'].includes(resource.release_state)) { + return readArchivedWorkerOutput({ + db, + dispatchId: params.dispatch, + workerState: worker.state, + resource, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) if (!observation.exact) { throw new OrchestrationError( @@ -227,68 +238,3 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ } }) ] - -async function readLegacyFederatedTerminal(args: { - runtime: Parameters[0] - server: ReturnType - federated: Parameters[1] - workerState: string - dispatchId: string - source: (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number] | undefined - cursor: string | number | undefined - limit: number | undefined -}) { - const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) - if (args.source === 'transcript' || cursor?.source === 'transcript') { - throw new OrchestrationError( - 'transcript_required', - `Connected server ${args.server.name} does not support structured worker output.`, - { reason: 'remote_capability_unavailable' } - ) - } - const remote = (await args.runtime.callOrchestrationWorkerServer( - args.server.environmentId, - 'orchestration.federationRead', - { - dispatchId: args.dispatchId, - cursor: cursor?.source === 'terminal' ? cursor.position : undefined, - limit: args.limit - }, - 15_000 - )) as { runtimeEpoch: string; terminal: RuntimeTerminalRead } - const sourceIdentity = createWorkerOutputSourceIdentity([ - 'legacy-remote-terminal', - args.federated.peer_fingerprint, - args.dispatchId, - remote.runtimeEpoch - ]) - if ( - cursor?.source === 'terminal' && - cursor.sourceIdentity !== null && - cursor.sourceIdentity !== sourceIdentity - ) { - throw new OrchestrationError( - 'source_changed', - 'The worker output source changed. Start a fresh worker-read without the old cursor.' - ) - } - const nextPosition = - remote.terminal.nextCursor !== null && /^\d+$/.test(remote.terminal.nextCursor) - ? Number.parseInt(remote.terminal.nextCursor, 10) - : null - return { - dispatchId: args.dispatchId, - source: 'terminal' as const, - sourceIdentity, - terminal: remote.terminal, - cursor: - nextPosition === null - ? null - : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, nextPosition), - status: { worker: args.workerState, terminal: remote.terminal.status }, - fallbackReason: 'remote_capability_unavailable' as const, - warnings: [], - server: { environmentId: args.server.environmentId, name: args.server.name }, - remoteRuntimeEpoch: remote.runtimeEpoch - } -} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts new file mode 100644 index 00000000000..87935f07173 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts @@ -0,0 +1,76 @@ +import type { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' +import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import type { resolvePinnedFederatedServer } from './orchestration-worker-observation' + +// Pre-structured-output servers only expose raw terminal reads; keep that path fenced and +// cursor-scoped so an old peer never silently degrades a transcript cursor. +export async function readLegacyFederatedTerminal(args: { + runtime: Parameters[0] + server: ReturnType + federated: Parameters[1] + workerState: string + dispatchId: string + source: (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number] | undefined + cursor: string | number | undefined + limit: number | undefined +}) { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + if (args.source === 'transcript' || cursor?.source === 'transcript') { + throw new OrchestrationError( + 'transcript_required', + `Connected server ${args.server.name} does not support structured worker output.`, + { reason: 'remote_capability_unavailable' } + ) + } + const remote = (await args.runtime.callOrchestrationWorkerServer( + args.server.environmentId, + 'orchestration.federationRead', + { + dispatchId: args.dispatchId, + cursor: cursor?.source === 'terminal' ? cursor.position : undefined, + limit: args.limit + }, + 15_000 + )) as { runtimeEpoch: string; terminal: RuntimeTerminalRead } + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'legacy-remote-terminal', + args.federated.peer_fingerprint, + args.dispatchId, + remote.runtimeEpoch + ]) + if ( + cursor?.source === 'terminal' && + cursor.sourceIdentity !== null && + cursor.sourceIdentity !== sourceIdentity + ) { + throw new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) + } + const nextPosition = + remote.terminal.nextCursor !== null && /^\d+$/.test(remote.terminal.nextCursor) + ? Number.parseInt(remote.terminal.nextCursor, 10) + : null + return { + dispatchId: args.dispatchId, + source: 'terminal' as const, + sourceIdentity, + terminal: remote.terminal, + cursor: + nextPosition === null + ? null + : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, nextPosition), + status: { worker: args.workerState, terminal: remote.terminal.status }, + fallbackReason: 'remote_capability_unavailable' as const, + warnings: [], + server: { environmentId: args.server.environmentId, name: args.server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-methods.ts b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts index 341521323fd..c613732d263 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts @@ -1,10 +1,12 @@ import type { RpcMethod } from '../core' import { ORCHESTRATION_WORKER_CONTROL_METHODS } from './orchestration-worker-control' +import { ORCHESTRATION_WORKER_RELEASE_METHODS } from './orchestration-worker-release' import { ORCHESTRATION_WORKER_STOP_METHODS } from './orchestration-worker-stop' import { ORCHESTRATION_WORKER_START_METHODS } from './orchestration-workers' export const ORCHESTRATION_WORKER_METHODS: RpcMethod[] = [ ...ORCHESTRATION_WORKER_START_METHODS, ...ORCHESTRATION_WORKER_CONTROL_METHODS, - ...ORCHESTRATION_WORKER_STOP_METHODS + ...ORCHESTRATION_WORKER_STOP_METHODS, + ...ORCHESTRATION_WORKER_RELEASE_METHODS ] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.ts index 60e3f942839..2b222f01586 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.ts @@ -181,7 +181,7 @@ function assertCursorSourceMatchesRequest( } } -function orchestrationTimestampToMs(value: string): number { +export function orchestrationTimestampToMs(value: string): number { const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts new file mode 100644 index 00000000000..4beb0323fd1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts @@ -0,0 +1,292 @@ +import type { OrchestrationDb } from '../../orchestration/db' +import type { + WorkerTerminalArchiveRow, + WorkerTerminalArchiveStatus, + WorkerTerminalResourceRow, + WorkerTerminalRetainedReason +} from '../../orchestration/worker-terminal-ownership' +import { + captureWorkerOutputArchive, + type WorkerTerminalTailArchive +} from '../../orchestration/worker-output-archive' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { inspectWorkerTerminal } from './orchestration-worker-observation' +import { orchestrationTimestampToMs } from './orchestration-worker-output' + +export type WorkerReleaseReceipt = { + dispatchId: string + state: 'released' | 'already_released' | 'retained' | 'release_pending' | 'release_unknown' + reason?: WorkerTerminalRetainedReason + processAction: 'closed_agent_terminal' | 'closed_exited_terminal' | 'none' + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +type WorkerTerminalReleaseArgs = { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + resource: WorkerTerminalResourceRow + mode?: 'interactive' | 'recovery' +} + +const activeReleaseByRuntime = new WeakMap< + OrcaRuntimeService, + Map> +>() + +export function exposeWorkerTerminalResource(resource: WorkerTerminalResourceRow): { + id: string + ownershipState: string + releaseState: string + retainedReason: string | null + terminalHandle: string + worktreeId: string | null + originDispatchId: string + ownerDispatchId: string + releaseRequestedAt: string | null + releaseCompletedAt: string | null + releaseError: string | null + archive: { source: string | null; status: string | null } +} { + return { + id: resource.id, + ownershipState: resource.ownership_state, + releaseState: resource.release_state, + retainedReason: resource.retained_reason, + terminalHandle: resource.terminal_handle, + worktreeId: resource.worktree_id, + originDispatchId: resource.origin_dispatch_id, + ownerDispatchId: resource.owner_dispatch_id, + releaseRequestedAt: resource.release_requested_at, + releaseCompletedAt: resource.release_completed_at, + releaseError: resource.release_error, + archive: { source: resource.archive_source, status: resource.archive_status } + } +} + +export function archiveSummary( + resource: WorkerTerminalResourceRow | null +): { source: string | null; status: string | null } | null { + if (!resource) { + return null + } + if (!resource.archive_source && !resource.archive_status) { + return null + } + return { source: resource.archive_source, status: resource.archive_status } +} + +// Completes a durably requested release: re-prove exact identity, freeze output, close only the +// exact agent terminal, settle. Shared between the RPC method and the startup reconciler. +export function completeWorkerTerminalRelease( + args: WorkerTerminalReleaseArgs +): Promise { + let activeByResource = activeReleaseByRuntime.get(args.runtime) + if (!activeByResource) { + activeByResource = new Map() + activeReleaseByRuntime.set(args.runtime, activeByResource) + } + const active = activeByResource.get(args.resource.id) + if (active) { + return active + } + const release = completeWorkerTerminalReleaseOnce(args).finally(() => { + if (activeByResource?.get(args.resource.id) === release) { + activeByResource.delete(args.resource.id) + } + }) + activeByResource.set(args.resource.id, release) + return release +} + +async function completeWorkerTerminalReleaseOnce( + args: WorkerTerminalReleaseArgs +): Promise { + const { runtime, db, dispatchId, resource } = args + const worker = db.getWorkerDispatch(dispatchId) + if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + const observation = await inspectWorkerTerminal(runtime, db, dispatchId) + if (observation.status === 'identity_changed') { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource)) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + if (observation.status === 'missing' || observation.status === 'unattached') { + if (args.mode === 'recovery') { + // Inventory may still be incomplete during startup/reconnect discovery; defer. + return { + dispatchId, + state: 'release_pending', + processAction: 'none', + archive: archiveSummary(resource), + recovery: + 'The recorded terminal has not been rediscovered yet; recovery will retry after the next terminal inventory.' + } + } + // Why: the handle resolves nowhere, but the PTY could have been re-homed after a restart — + // claiming released would hide a live process; only an exact observation may settle it. + const unknown = db.markWorkerTerminalReleaseUnknown( + resource.id, + 'The recorded terminal no longer resolves; whether its process is gone cannot be proven.' + ) + return { + dispatchId, + state: 'release_unknown', + processAction: 'none', + archive: archiveSummary(unknown), + lastError: unknown.release_error ?? undefined, + recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request. Never substitute a broad terminal close.` + } + } + + const archive = db.getWorkerTerminalArchive(dispatchId) + let archiveSource = resource.archive_source as 'transcript' | 'terminal' | null + let archiveStatus: WorkerTerminalArchiveStatus | null = resource.archive_status + let capturedArchive: { kind: 'transcript_pin' | 'terminal_tail'; content: string } | undefined + if (!archive) { + const captured = await captureWorkerOutputArchive({ + runtime, + dispatchId, + terminalHandle: resource.terminal_handle, + attachedAtMs: orchestrationTimestampToMs(worker.created_at) + }) + capturedArchive = { kind: captured.kind, content: JSON.stringify(captured.content) } + archiveSource = captured.kind === 'transcript_pin' ? 'transcript' : 'terminal' + archiveStatus = captured.status + } else { + const stored = summarizeStoredArchive(archive) + archiveSource ??= stored.source + archiveStatus ??= stored.status + } + const releasing = db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: resource.id, + ...capturedArchive, + archiveSource, + archiveStatus: archiveStatus === 'empty' ? 'empty' : 'captured' + }) + if (releasing.ownership_state !== 'owned' || releasing.release_state !== 'releasing') { + return { + dispatchId, + state: 'retained', + reason: retainedReason(releasing), + processAction: 'none', + archive: archiveSummary(releasing) + } + } + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing)) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + + try { + await runtime.closeTerminal(resource.terminal_handle) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + if (/disposed|not connected|unavailable/i.test(reason)) { + // Durable intent exists; the owning endpoint is temporarily unreachable. Recovery retries. + return { + dispatchId, + state: 'release_pending', + processAction: 'none', + archive: { source: archiveSource, status: archiveStatus }, + lastError: reason, + recovery: + 'The owning endpoint is temporarily unavailable; recovery will retry this release after reconnect without another coordinator decision.' + } + } + const unknown = db.markWorkerTerminalReleaseUnknown(resource.id, reason) + return { + dispatchId, + state: 'release_unknown', + processAction: 'none', + archive: { source: archiveSource, status: archiveStatus }, + lastError: unknown.release_error ?? reason, + recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request. Never substitute a broad terminal close.` + } + } + const released = db.settleWorkerTerminalRelease(resource.id) + runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') + return { + dispatchId, + state: 'released', + processAction: + observation.status === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal', + archive: archiveSummary(released) + } +} + +function workerTerminalLeaseIsCurrent( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatchId: string, + resource: WorkerTerminalResourceRow +): boolean { + const worker = db.getWorkerDispatch(dispatchId) + const authority = runtime.getOrchestrationDispatchAuthority(resource.terminal_handle) + return Boolean( + worker?.agent_terminal_handle === resource.terminal_handle && + authority && + resource.host_scope === JSON.stringify(authority.hostScope) && + db.isDispatchProcessCurrent({ + dispatchId, + paneKey: runtime.getTerminalPaneKey(resource.terminal_handle), + processIncarnation: runtime.getTerminalProcessIncarnation(resource.terminal_handle) + }) && + !db.workerTerminalResourceHasIdentityConflict(resource.id) + ) +} + +function summarizeStoredArchive(archive: WorkerTerminalArchiveRow): { + source: 'transcript' | 'terminal' + status: Extract +} { + if (archive.kind === 'transcript_pin') { + return { source: 'transcript', status: 'captured' } + } + const content = JSON.parse(archive.content) as WorkerTerminalTailArchive + const empty = content.lines.every((line) => line.trim() === '') + return { source: 'terminal', status: empty ? 'empty' : 'captured' } +} + +function retainedReason(resource: WorkerTerminalResourceRow): WorkerTerminalRetainedReason { + if (resource.retained_reason) { + return resource.retained_reason as WorkerTerminalRetainedReason + } + if (resource.ownership_state === 'user_owned') { + return 'user_takeover' + } + return 'identity_unproven' +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts new file mode 100644 index 00000000000..175cac9edac --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationDb } from '../../orchestration/db' +import { reconcileRequestedWorkerTerminalReleases } from '../../orchestration/worker-terminal-release-reconciliation' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +describe('orchestration worker release recovery', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : handle === 'term_worker' ? workerPaneKey : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' + ? ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + closed: true + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release recovery test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + async function call(name: string, params: Record) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + async function startWorker(): Promise<{ taskId: string; dispatchId: string }> { + const task = db.createTask({ spec: 'release recovery fixture task', runId: activeRunId }) + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded' + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker() + expect( + db.settleWorkerReport({ + taskId: worker.taskId, + dispatchId: worker.dispatchId, + outcome, + result: `worker ${outcome}` + }).action + ).toBe('settled') + return worker + } + + it('finishes a requested release after restart-style interruption', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + const interrupted = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(interrupted.state).toBe('release_pending') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result).toMatchObject({ attempted: 1, released: 1 }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(2) + }) + + it('defers instead of settling unknown while inventory is incomplete', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + await call('orchestration.workerRelease', { dispatch: dispatchId }) + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result).toMatchObject({ attempted: 1, pending: 1, unknown: 0 }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + }) + + it('never touches resources without requested releases', async () => { + setup() + await startSettledWorker() + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result.attempted).toBe(0) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('coalesces overlapping reconciliation passes and closes each resource once', async () => { + setup() + const { dispatchId } = await startSettledWorker() + expect(db.requestWorkerTerminalRelease(dispatchId).disposition).toBe('requested') + const pendingClose = deferred>>() + vi.mocked(runtime.closeTerminal).mockReturnValue(pendingClose.promise) + + const first = reconcileRequestedWorkerTerminalReleases(runtime) + await vi.waitFor(() => expect(runtime.closeTerminal).toHaveBeenCalledTimes(1)) + const second = reconcileRequestedWorkerTerminalReleases(runtime) + expect(second).toBe(first) + pendingClose.resolve({ handle: 'term_worker', tabId: 'tab-worker', ptyKilled: true }) + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ attempted: 1, released: 1 }), + expect.objectContaining({ attempted: 1, released: 1 }) + ]) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('keeps live terminals bounded across 50 settled workers while controls survive', async () => { + setup() + for (let wave = 0; wave < 50; wave += 1) { + const worker = await startSettledWorker(wave % 2 === 0 ? 'succeeded' : 'failed') + const receipt = (await call('orchestration.workerRelease', { + dispatch: worker.dispatchId + })) as { state: string } + expect(receipt.state).toBe('released') + } + expect(runtime.closeTerminal).toHaveBeenCalledTimes(50) + + const control = await startWorker() + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + counts: Record + } + expect(listed.counts).toMatchObject({ released: 50, active: 1 }) + expect( + listed.workers.find((worker) => worker.dispatchId === control.dispatchId)?.terminalState + ).toBe('active') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(50) + }) + + it('backfills legacy terminal resources as retained external evidence', () => { + setup() + const insertLegacy = (dispatchId: string, handle: string, paneKey: string | null): void => { + const task = db.createTask({ spec: `legacy ${dispatchId}`, runId: activeRunId }) + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO dispatch_contexts (id, run_id, task_id, contract_version, assignee_pane_key, process_incarnation, status) + VALUES (?, ?, ?, 2, ?, ?, 'completed')` + ) + .run(dispatchId, activeRunId, task.id, paneKey, paneKey ? `inc:${dispatchId}` : null) + raw + .prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, stage, agent_terminal_handle, residual_resources) + VALUES (?, 'succeeded', 'settled', ?, ?)` + ) + .run( + dispatchId, + handle, + JSON.stringify([{ kind: 'terminal', role: 'agent', action: 'created', id: handle }]) + ) + } + insertLegacy('ctx_unique', 'term_unique', 'tab_u:leaf_u') + insertLegacy('ctx_shared_a', 'term_shared', 'tab_s:leaf_s') + insertLegacy('ctx_shared_b', 'term_shared', 'tab_s:leaf_s') + insertLegacy('ctx_no_identity', 'term_bare', null) + ;( + db as unknown as { backfillWorkerTerminalResources: () => void } + ).backfillWorkerTerminalResources() + + for (const ambiguous of ['ctx_unique', 'ctx_shared_a', 'ctx_shared_b', 'ctx_no_identity']) { + expect(db.getWorkerTerminalResourceByOwner(ambiguous)).toMatchObject({ + ownership_state: 'external', + release_state: 'retained', + retained_reason: 'legacy_ambiguous' + }) + } + }) + + it('backfills a legacy creator plus explicit reuser as ambiguous', () => { + setup() + const insertLegacy = (dispatchId: string, action: 'created' | 'reused'): void => { + const task = db.createTask({ spec: dispatchId, runId: activeRunId }) + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO dispatch_contexts (id, run_id, task_id, contract_version, assignee_pane_key, process_incarnation, status) + VALUES (?, ?, ?, 2, ?, ?, ?)` + ) + .run( + dispatchId, + activeRunId, + task.id, + workerPaneKey, + 'runtime_test:term_worker:1', + action === 'created' ? 'completed' : 'dispatched' + ) + raw + .prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, stage, agent_terminal_handle, residual_resources) + VALUES (?, ?, 'legacy', 'term_worker', ?)` + ) + .run( + dispatchId, + action === 'created' ? 'succeeded' : 'ready', + JSON.stringify([{ kind: 'terminal', role: 'agent', action, id: 'term_worker' }]) + ) + } + insertLegacy('ctx_creator', 'created') + insertLegacy('ctx_reuser', 'reused') + ;( + db as unknown as { backfillWorkerTerminalResources: () => void } + ).backfillWorkerTerminalResources() + + for (const dispatchId of ['ctx_creator', 'ctx_reuser']) { + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'external', + release_state: 'retained', + retained_reason: 'legacy_ambiguous' + }) + } + }) + + it('removes terminal authority and archived output on orchestration reset', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + expect(db.getWorkerTerminalArchive(dispatchId)).toBeDefined() + + db.resetTasks() + + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toBeUndefined() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts new file mode 100644 index 00000000000..25670dda1e7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -0,0 +1,763 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import type { RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +describe('orchestration worker release', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' || handle === 'term_reminted' + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' + ? ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + closed: true + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + function findMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + async function startWorker(options: { terminal?: string } = {}): Promise<{ + taskId: string + dispatchId: string + }> { + const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + function settle(taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed'): void { + const settlement = db.settleWorkerReport({ + taskId, + dispatchId, + outcome, + result: `worker ${outcome}` + }) + expect(settlement.action).toBe('settled') + } + + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded', + options: { terminal?: string } = {} + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker(options) + settle(worker.taskId, worker.dispatchId, outcome) + return worker + } + + it('creates an owned resource for a fresh worker terminal', async () => { + setup() + const { dispatchId } = await startWorker() + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource).toMatchObject({ + ownership_state: 'owned', + release_state: 'not_requested', + terminal_handle: 'term_worker', + pane_key: workerPaneKey, + process_incarnation: 'runtime_test:term_worker:1' + }) + }) + + it('releases a succeeded worker: archives then closes exactly the agent terminal', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded') + + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + archive: { source: string | null; status: string | null } | null + } + + expect(receipt).toMatchObject({ + state: 'released', + processAction: 'closed_agent_terminal', + archive: { source: 'terminal', status: 'captured' } + }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource?.release_state).toBe('released') + expect(resource?.ownership_state).toBe('released') + // Outcome is untouched by release. + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('succeeded') + }) + + it('releases a failed worker the same way', async () => { + setup() + const { dispatchId } = await startSettledWorker('failed') + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(receipt.state).toBe('released') + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('failed') + }) + + it('is idempotent: a duplicate release returns already_released without another close', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + const second = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + expect(second).toMatchObject({ state: 'already_released', processAction: 'none' }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('rejects an active worker without recording release intent', async () => { + setup() + const { dispatchId } = await startWorker() + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /only a succeeded or failed worker can release/ + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('not_requested') + }) + + it('retains an explicitly reused external terminal without closing it', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded', { terminal: 'term_worker' }) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'external_terminal' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('retains a user-taken-over terminal durably', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: workerPaneKey + })) as { changed: number } + expect(changed.changed).toBe(1) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'user_takeover' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') + }) + + it('lets user takeover cancel a release while output capture is pending', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: workerPaneKey + })) as { changed: number } + expect(changed.changed).toBe(1) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['captured before takeover'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ state: 'retained', reason: 'user_takeover' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) + + it('lets an explicit retain cancel a release while output capture is pending', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + await expect( + call('orchestration.workerRetain', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'user_requested' }) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['captured before retention'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ state: 'retained', reason: 'user_requested' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) + + it('does not claim retention succeeded after terminal close was committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingClose = deferred>>() + vi.mocked(runtime.closeTerminal).mockReturnValue(pendingClose.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.closeTerminal).toHaveBeenCalledTimes(1)) + await expect( + call('orchestration.workerRetain', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + pendingClose.resolve({ handle: 'term_worker', tabId: 'tab-worker', ptyKilled: true }) + + await expect(release).resolves.toMatchObject({ state: 'released' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('released') + }) + + it('never marks takeover for panes without an owned resource', async () => { + setup() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + })) as { changed: number } + expect(changed.changed).toBe(0) + }) + + it('preserves takeover across a reminted tab key for the same pane leaf', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: 'tab_reminted:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + })) as { changed: number } + + expect(changed.changed).toBe(1) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') + }) + + it('retains when the exact process identity changed instead of closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.getTerminalProcessIncarnation).mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:2' : null + ) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('retains when the terminal host scope changed instead of closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue({ + terminalHandle: 'term_worker', + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'ssh', targetId: 'replacement-host' } + } as never) + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('re-proves process identity after archive capture before closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + vi.mocked(runtime.getTerminalProcessIncarnation).mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:2' : null + ) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['output from the old process'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ + state: 'retained', + reason: 'identity_unproven' + }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('returns release_unknown when the terminal no longer resolves, then completes a retry', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + recovery?: string + } + expect(receipt.state).toBe('release_unknown') + expect(receipt.recovery).toContain('worker-show') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + const retry = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(retry.state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('retains the live terminal when output capture fails', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.readTerminal).mockRejectedValue(new Error('read exploded')) + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /Output could not be preserved/ + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + // Durable intent survives for recovery. + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('requested') + }) + + it('marks release_unknown when the close itself fails', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValue(new Error('close exploded')) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + lastError?: string + } + expect(receipt.state).toBe('release_unknown') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('records an explicitly empty archive for an already-exited worker process', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never + ) + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'exited', + tail: [], + truncated: false, + nextCursor: null + }) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + archive: { status: string | null } | null + } + expect(receipt).toMatchObject({ + state: 'released', + processAction: 'closed_exited_terminal', + archive: { status: 'empty' } + }) + }) + + it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const suffix = 'meaningful-tail' + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: [`${'x'.repeat(300_000)}${suffix}`], + truncated: false, + nextCursor: '1' + }) + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { status: string | null } | null + } + const read = (await call('orchestration.workerRead', { dispatch: dispatchId })) as { + terminal: { tail: string[]; truncated: boolean } + warnings: string[] + } + + expect(release.archive?.status).toBe('captured') + expect(read.terminal.tail).toHaveLength(1) + expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`)) + expect(read.terminal.truncated).toBe(true) + expect(read.warnings).not.toContain( + 'The live terminal buffer was empty at release; structured transcript output was unavailable.' + ) + }) + + it('serves the frozen redacted archive through worker-read after release, with cursors', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], + truncated: false, + nextCursor: '3' + }) + await call('orchestration.workerRelease', { dispatch: dispatchId }) + vi.mocked(runtime.readTerminal).mockClear() + + const page1 = (await call('orchestration.workerRead', { + dispatch: dispatchId, + limit: 2 + })) as { archived?: boolean; terminal: { tail: string[] }; cursor: string | null } + expect(page1.archived).toBe(true) + expect(page1.terminal.tail).toEqual([ + 'first line', + 'capability [dispatch capability redacted] leaked' + ]) + expect(page1.cursor).not.toBeNull() + + const page2 = (await call('orchestration.workerRead', { + dispatch: dispatchId, + cursor: page1.cursor as string + })) as { terminal: { tail: string[] }; cursor: string | null } + expect(page2.terminal.tail).toEqual(['last line']) + expect(page2.cursor).toBeNull() + // The live terminal is never consulted after release. + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('reads an immutable transcript snapshot after the provider file disappears', async () => { + setup() + const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-')) + const transcriptPath = join(directory, 'rollout.jsonl') + try { + await writeFile( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-08-03T12:00:00.000Z', + type: 'event_msg', + payload: { id: 'snapshot-message', type: 'agent_message', message: 'frozen output' } + })}\n` + ) + vi.mocked(runtime.getExactWorkerProviderSession).mockReturnValue({ + agent: 'codex', + processIncarnation: 'runtime_test:term_worker:1', + providerSession: { + key: 'codex:snapshot-session', + id: 'snapshot-session', + transcriptPath + } + } as never) + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + await rm(transcriptPath) + + await expect( + call('orchestration.workerRead', { dispatch: dispatchId }) + ).resolves.toMatchObject({ + archived: true, + source: 'transcript', + transcript: { + messages: [{ id: 'snapshot-message', blocks: [{ type: 'text', text: 'frozen output' }] }] + } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a legacy live-terminal cursor after output moves to the archive', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + + await expect( + call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 }) + ).rejects.toThrow(/source changed/i) + }) + + it('recovers archive metadata when a prior attempt committed only the archive row', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const requested = db.requestWorkerTerminalRelease(dispatchId) + expect(requested.disposition).toBe('requested') + if (requested.disposition !== 'requested') { + throw new Error('release request was not recorded') + } + db.storeWorkerTerminalArchive({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ + lines: ['archive survived the interrupted attempt'], + truncated: false, + terminalStatus: 'running', + warnings: [] + }) + }) + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { source: string | null; status: string | null } | null + } + + expect(release.archive).toEqual({ source: 'terminal', status: 'captured' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + archive_source: 'terminal', + archive_status: 'captured' + }) + }) + + it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => { + setup() + const first = await startSettledWorker('succeeded') + const originalResource = db.getWorkerTerminalResourceByOwner(first.dispatchId) + expect(originalResource?.ownership_state).toBe('owned') + + const second = await startWorker({ terminal: 'term_reminted' }) + const transferred = db.getWorkerTerminalResourceByOwner(second.dispatchId) + expect(transferred?.id).toBe(originalResource?.id) + expect(transferred?.terminal_handle).toBe('term_reminted') + expect(db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined() + + const oldRelease = (await call('orchestration.workerRelease', { + dispatch: first.dispatchId + })) as { state: string; reason?: string } + expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + + settle(second.taskId, second.dispatchId, 'succeeded') + const newRelease = (await call('orchestration.workerRelease', { + dispatch: second.dispatchId + })) as { state: string } + expect(newRelease.state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + }) + + it('rejects exact reuse after release intent instead of closing the new worker', async () => { + setup() + const first = await startSettledWorker('succeeded') + expect(db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested') + const nextTask = db.createTask({ spec: 'racing reuse', runId: activeRunId }) + + const attempted = (await call('orchestration.workerStart', { + task: nextTask.id, + from: 'term_coord', + terminal: 'term_worker' + })) as { state: string; lastError?: string } + + expect(attempted).toMatchObject({ state: 'failed' }) + expect(attempted.lastError).toMatch(/release.*progress/i) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + await expect( + call('orchestration.workerRelease', { dispatch: first.dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('retains when persisted state has another resource for the exact terminal identity', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key, + process_incarnation, host_scope, ownership_state, release_state, retained_reason + ) VALUES ( + 'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?, + 'external', 'retained', 'legacy_ambiguous' + )` + ) + .run( + workerPaneKey, + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('worker-retain records a durable user exception that release can later replace', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const retained = (await call('orchestration.workerRetain', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained') + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(release.state).toBe('released') + }) + + it('worker-list separates terminal accounting from Task outcome', async () => { + setup() + const active = await startWorker() + const perWorkerLookup = vi.spyOn(db, 'getWorkerTerminalResourceByOwner') + perWorkerLookup.mockClear() + const result1 = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null; workerState: string }[] + counts: Record + } + expect(result1.workers).toHaveLength(1) + expect(result1.workers[0]).toMatchObject({ + dispatchId: active.dispatchId, + terminalState: 'active', + workerState: 'ready' + }) + expect(perWorkerLookup).not.toHaveBeenCalled() + + settle(active.taskId, active.dispatchId, 'succeeded') + const result2 = (await call('orchestration.workerList', { + run: activeRunId, + terminalState: 'reclaimable' + })) as { workers: { dispatchId: string }[]; counts: Record } + expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId]) + expect(result2.counts).toMatchObject({ reclaimable: 1 }) + + await call('orchestration.workerRelease', { dispatch: active.dispatchId }) + const result3 = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { terminalState: string | null; workerState: string }[] + } + expect(result3.workers[0]).toMatchObject({ + terminalState: 'released', + workerState: 'succeeded' + }) + }) + + it('reports abandoned workers as retained instead of reclaimable', async () => { + setup() + const { dispatchId } = await startWorker() + await call('orchestration.workerAbandon', { dispatch: dispatchId }) + + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + } + + expect(listed.workers).toContainEqual( + expect.objectContaining({ dispatchId, terminalState: 'retained' }) + ) + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /only a succeeded or failed worker can release/ + ) + }) + + it('worker-show exposes the terminal resource', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const shown = (await call('orchestration.workerShow', { dispatch: dispatchId })) as { + terminalResource: { ownershipState: string; releaseState: string } | null + } + expect(shown.terminalResource).toMatchObject({ + ownershipState: 'owned', + releaseState: 'not_requested' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.ts new file mode 100644 index 00000000000..f01cd72de47 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.ts @@ -0,0 +1,161 @@ +import { z } from 'zod' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { WorkerTerminalListState } from '../../orchestration/worker-terminal-ownership' +import { defineMethod, type RpcMethod } from '../core' +import { requiredString } from '../schemas' +import { + archiveSummary, + completeWorkerTerminalRelease, + exposeWorkerTerminalResource, + type WorkerReleaseReceipt +} from './orchestration-worker-release-completion' + +const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +const WorkerListParams = z.object({ + run: z.string().min(1).optional(), + terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional() +}) + +export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.workerRelease', + params: WorkerDispatchParams, + handler: async (params, { runtime }): Promise => { + const db = runtime.getOrchestrationDb() + if (db.getFederatedDispatch(params.dispatch)) { + // Fail closed: the worker server owns that terminal; a home-side close would be a guess. + return { + dispatchId: params.dispatch, + state: 'retained', + reason: 'federation_unsupported', + processAction: 'none', + archive: null, + recovery: + 'Connected-server workers do not support release yet; inspect the worker server directly.' + } + } + const requested = db.requestWorkerTerminalRelease(params.dispatch) + if (requested.disposition === 'already_released') { + return { + dispatchId: params.dispatch, + state: 'already_released', + processAction: 'none', + archive: archiveSummary(requested.resource) + } + } + if (requested.disposition === 'retained') { + return { + dispatchId: params.dispatch, + state: 'retained', + reason: requested.reason, + processAction: 'none', + archive: archiveSummary(requested.resource) + } + } + return completeWorkerTerminalRelease({ + runtime, + db, + dispatchId: params.dispatch, + resource: requested.resource + }) + } + }), + defineMethod({ + name: 'orchestration.workerRetain', + params: WorkerDispatchParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!db.getWorkerDispatch(params.dispatch)) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker Dispatch ${params.dispatch} was not found.` + ) + } + const retained = db.retainWorkerTerminalResource(params.dispatch) + if (retained.disposition === 'already_released') { + return { + dispatchId: params.dispatch, + state: 'already_released' as const, + processAction: 'none' as const, + archive: archiveSummary(retained.resource) + } + } + if (retained.disposition === 'no_owned_resource') { + return { + dispatchId: params.dispatch, + state: 'retained' as const, + reason: 'no_owned_resource' as const, + processAction: 'none' as const, + archive: null + } + } + if (retained.disposition === 'release_committed') { + const unknown = retained.resource.release_state === 'unknown' + return { + dispatchId: params.dispatch, + state: unknown ? ('release_unknown' as const) : ('release_pending' as const), + processAction: 'none' as const, + archive: archiveSummary(retained.resource), + ...(retained.resource.release_error + ? { lastError: retained.resource.release_error } + : {}), + recovery: + 'Terminal release was already committed and could not be changed to retained; inspect worker-show before taking further action.' + } + } + return { + dispatchId: params.dispatch, + state: 'retained' as const, + reason: 'user_requested' as const, + processAction: 'none' as const, + archive: archiveSummary(retained.resource) + } + } + }), + defineMethod({ + name: 'orchestration.workerList', + params: WorkerListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const rows = db.listWorkerTerminalResources({ runId: params.run }) + const workers = rows + .filter((row) => !params.terminalState || row.terminalState === params.terminalState) + .map((row) => ({ + dispatchId: row.dispatchId, + taskId: row.taskId, + runId: row.runId, + workerState: row.workerState, + dispatchStatus: row.dispatchStatus, + agentTerminalHandle: row.agentTerminalHandle, + terminalState: row.terminalState, + resource: row.resource ? exposeWorkerTerminalResource(row.resource) : null + })) + const counts: Partial> = {} + for (const row of rows) { + if (row.terminalState) { + counts[row.terminalState] = (counts[row.terminalState] ?? 0) + 1 + } + } + return { workers, counts } + } + }), + defineMethod({ + name: 'orchestration.workerTerminalUserInput', + params: z.object({ paneKey: requiredString('Missing paneKey') }), + // Real user keystrokes durably relinquish orchestration ownership on the owning runtime, so + // restarts, SSH drops, remote viewing, and renderer remounts cannot erase the takeover. + handler: (params, { runtime }) => ({ + changed: runtime.getOrchestrationDb().markWorkerTerminalUserOwned(params.paneKey) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index ffacf571d1b..6ecf8613a9b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -47,7 +47,8 @@ export function requireWorkerAuthority(runtime: OrcaRuntimeService, terminalHand return { paneKey, processIncarnation, - ...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {}) + ...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {}), + ...(authority?.hostScope ? { hostScope: JSON.stringify(authority.hostScope) } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 894be342f81..dc659773a8a 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -243,7 +243,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ ...terminalAuthority, worktreeId: resolvedWorktree.id, effects, - setupState: setupReceipt.state + setupState: setupReceipt.state, + terminalOwnership: params.terminal ? 'external' : 'created' }) failedStage = 'dispatch_input' diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index ad663269a37..8ba154c48f7 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -108,7 +108,11 @@ describe('orchestration RPC methods', () => { it('registers all expected methods', () => { const registry = buildRegistry(ORCHESTRATION_METHODS) - expect(registry.size).toBe(34) + expect(registry.size).toBe(38) + expect(registry.has('orchestration.workerRelease')).toBe(true) + expect(registry.has('orchestration.workerRetain')).toBe(true) + expect(registry.has('orchestration.workerList')).toBe(true) + expect(registry.has('orchestration.workerTerminalUserInput')).toBe(true) expect(registry.has('orchestration.runCreate')).toBe(true) expect(registry.has('orchestration.runUse')).toBe(true) expect(registry.has('orchestration.runCurrent')).toBe(true) @@ -1759,17 +1763,27 @@ describe('orchestration RPC methods', () => { expect(result.task.status).toBe('pending') }) - it('records the caller terminal handle when creating a task', async () => { + it('records the caller pane, process, and Run generation when creating a task', async () => { setup() vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_creator' ? coordinatorPaneKey : null ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ + terminalHandle: 'term_creator', + paneKey: coordinatorPaneKey, + processIncarnation: 'pty-creator:incarnation-a' + } as never) const result = (await call('orchestration.taskCreate', { spec: 'spawn related workspace', callerTerminalHandle: 'term_creator' })) as { task: { id: string } } - expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator') + expect(db.getTask(result.task.id)).toMatchObject({ + created_by_terminal_handle: 'term_creator', + created_by_pane_key: coordinatorPaneKey, + created_by_process_incarnation: 'pty-creator:incarnation-a', + created_by_run_generation: 1 + }) }) it('rejects invalid deps JSON', async () => { @@ -1894,6 +1908,16 @@ describe('orchestration RPC methods', () => { vi.mocked(runtime.getTerminalPaneKey).mockImplementation((candidate) => candidate === handle ? `tab_worker:${handle}` : coordinatorPaneKey ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((candidate) => + candidate === handle + ? ({ + terminalHandle: handle, + paneKey: `tab_worker:${handle}`, + processIncarnation: `runtime_test:${handle}:1`, + launchTokenHash: null + } as never) + : null + ) } it('dispatches a task to a terminal', async () => { @@ -1925,7 +1949,7 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(result.dispatch.id)?.assignee_pane_key).toBe('tab_w:leaf_w') }) - it('commits the target process launch token on a manual dispatch', async () => { + it('commits authenticated process authority on a manual dispatch', async () => { setup() vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ runtimeId: runtime.getRuntimeId(), @@ -1944,9 +1968,24 @@ describe('orchestration RPC methods', () => { to: 'term_a' })) as { dispatch: { id: string } } - expect(db.getDispatchContextById(result.dispatch.id)?.launch_token_hash).toBe( - 'launch-token-hash' - ) + expect(db.getDispatchContextById(result.dispatch.id)).toMatchObject({ + assignee_pane_key: 'tab_w:leaf_w', + process_incarnation: 'runtime_test:term_a:1', + launch_token_hash: 'launch-token-hash' + }) + }) + + it('does not infer manual process authority from an unauthenticated handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a' + })) as { dispatch: { id: string } } + + expect(runtime.getTerminalProcessIncarnation('term_a')).toBe('runtime_test:term_a:1') + expect(db.getDispatchContextById(result.dispatch.id)?.process_incarnation).toBeNull() }) it('rejects dispatch for a pending task', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 34ea1f36860..0bdb24543a7 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1100,6 +1100,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ throw new Error('Invalid --deps: must be a JSON array of task IDs') } } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null const task = db.createTask({ spec: params.spec, taskTitle: params.taskTitle, @@ -1107,13 +1117,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ deps, parentId: params.parent, createdByTerminalHandle: params.callerTerminalHandle, - runId: resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }).id + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id }) return { task } } @@ -1256,9 +1267,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const assigneePaneKey = dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined const processIncarnation = - dispatchAuthority?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(to) ?? - undefined + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined if (params.inject && (!assigneePaneKey || !processIncarnation)) { throw new OrchestrationError( 'stable_pane_required', @@ -1271,7 +1282,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params.task, to, assigneePaneKey, - dispatchAuthority?.launchTokenHash ?? undefined + dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation ) const dispatchCapability = params.inject ? db.mintDispatchCapability({ diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index eabffa24a73..8b17d946bdf 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -43,7 +43,11 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ kind: 'client', attributes: { attribution: 'session-tab-close', + runtimeId: context.runtime.getRuntimeId(), origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + worktree: params.worktree, + tabId: params.tabId, closeReason: params.reason ?? (requiresIntent @@ -85,7 +89,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ kind: 'client', attributes: { attribution: 'session-tab-lifecycle-close', + runtimeId: context.runtime.getRuntimeId(), origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + worktree: params.worktree, + tabId: params.tabId, + terminal: params.terminal, closeReason: params.reason, connectionGeneration: context.connectionId ?? 'in-process', requestId: context.requestId ?? 'in-process', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 450437f58be..edef5f44083 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -72,6 +72,7 @@ import { type TerminalOutputSourceRange } from '../../../../shared/terminal-output-source-range' import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' +import { withTerminalCloseAttribution } from '../terminal-close-attribution' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 @@ -1499,15 +1500,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.close', params: TerminalHandle, - handler: async (params, { runtime }) => ({ - close: await runtime.closeTerminal(params.terminal) + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.close', + context, + 'terminal', + params.terminal, + () => context.runtime.closeTerminal(params.terminal) + ) }) }), defineMethod({ name: 'terminal.closeTab', params: TerminalHandle, - handler: async (params, { runtime }) => ({ - close: await runtime.closeTerminalTab(params.terminal) + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.closeTab', + context, + 'terminal-tab', + params.terminal, + () => context.runtime.closeTerminalTab(params.terminal) + ) }) }), defineMethod({ @@ -2974,8 +2987,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ serialized: serialized?.data, oscLinks: serialized?.oscLinks, cwd: serialized?.cwd, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, displayMode, seq }) @@ -3381,8 +3396,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) const snapshotStats = sendSnapshotFrames(sendFrame, { kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, + // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has + // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, displayMode, seq: snapshotFrameSeq, cwd: serialized?.cwd, diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index be5628df496..468f261b725 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -628,6 +628,16 @@ describe('legacy coordinator takeover races', () => { handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === targetHandle ? targetPane : null ) vi.spyOn(harness.runtime, 'getTerminalProcessIncarnation').mockReturnValue('current-process') + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === targetHandle + ? ({ + terminalHandle: targetHandle, + paneKey: targetPane, + processIncarnation: 'current-process', + launchTokenHash: null + } as never) + : null + ) let resolveDetection: ((detected: boolean) => void) | undefined let signalDetectionStarted: (() => void) | undefined const detectionStarted = new Promise((resolve) => { diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 66918510b56..2334cd709eb 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -54,6 +54,8 @@ export class OrchestrationMutationExecutor { return { disposition: row.state, row } })() : db.beginMutationReceipt(identity) + const resumedPendingMutation = + begun.disposition === 'pending' && request.method === 'orchestration.workerRelease' if (begun.disposition === 'completed') { const active = this.inFlight.get(key) @@ -67,33 +69,35 @@ export class OrchestrationMutationExecutor { if (active) { return attachMutationReceipt(await active, requestId, true) } - const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) - throw new OrchestrationError( - 'operation_unknown', - recovery - ? `Worker start ${requestId} was accepted as Dispatch ${recovery.dispatchId} before restart. Inspect that Dispatch; do not start another worker.` - : `Mutation ${requestId} may have been accepted before restart. Retry inspection or recovery with the same request ID.`, - recovery - ? { - requestId, - dispatchId: recovery.dispatchId, - recoveryCommand: `orca orchestration worker-show --dispatch ${recovery.dispatchId} --json` - } - : { requestId } - ) + if (request.method !== 'orchestration.workerRelease') { + const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) + throw new OrchestrationError( + 'operation_unknown', + recovery + ? `Worker start ${requestId} was accepted as Dispatch ${recovery.dispatchId} before restart. Inspect that Dispatch; do not start another worker.` + : `Mutation ${requestId} may have been accepted before restart. Retry inspection or recovery with the same request ID.`, + recovery + ? { + requestId, + dispatchId: recovery.dispatchId, + recoveryCommand: `orca orchestration worker-show --dispatch ${recovery.dispatchId} --json` + } + : { requestId } + ) + } } const recordReceipt = (result: unknown): void => { db.completeMutationReceipt({ ...identity, - receipt: JSON.stringify(attachMutationReceipt(result, requestId, false)) + receipt: JSON.stringify(attachMutationReceipt(result, requestId, resumedPendingMutation)) }) } const active = Promise.resolve().then(() => invoke({ identity, recordReceipt })) this.inFlight.set(key, active) try { const result = await active - const receipted = attachMutationReceipt(result, requestId, false) + const receipted = attachMutationReceipt(result, requestId, resumedPendingMutation) db.completeMutationReceipt({ ...identity, receipt: JSON.stringify(receipted) }) return receipted } catch (error) { diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 8e02d9c8e24..4062dacf9a1 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -196,6 +196,54 @@ describe('durable orchestration mutation ledger', () => { db.close() }) + it('resumes a pending idempotent worker release after restart', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const params = { dispatch: 'ctx_release' } + const callerFingerprint = createHash('sha256').update('caller-token').digest('hex') + const payloadHash = createHash('sha256') + .update(JSON.stringify({ method: 'orchestration.workerRelease', params })) + .digest('hex') + db.beginMutationReceipt({ + callerFingerprint, + requestId: 'mutation_release', + method: 'orchestration.workerRelease', + payloadHash + }) + const effect = vi.fn().mockReturnValue({ state: 'release_pending' }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'orchestration.workerRelease', + params: z.object({ dispatch: z.string() }), + handler: effect + }) + ] + }) + + const result = await dispatcher.dispatch({ + id: 'rpc_release_retry', + authToken: 'caller-token', + method: 'orchestration.workerRelease', + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'mutation_release' + }) + + expect(result).toMatchObject({ + ok: true, + result: { + state: 'release_pending', + mutation: { requestId: 'mutation_release', replayed: true } + } + }) + expect(effect).toHaveBeenCalledTimes(1) + expect(db.getMutationReceipt(callerFingerprint, 'mutation_release')?.state).toBe('completed') + db.close() + }) + it('returns the accepted Dispatch when worker-start was interrupted by restart', async () => { const db = new OrchestrationDb(':memory:') const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts new file mode 100644 index 00000000000..4e1232e7358 --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { SESSION_TAB_METHODS } from './methods/session-tabs' +import { TERMINAL_METHODS } from './methods/terminal' + +const BEARER_CLIENT_ID = 'secret-bearer-client-id' + +type SpanRecord = { + name: string + attributes: Record + exit: { _tag: string } +} + +function request(id: string, method: string, params: unknown): RpcRequest { + return { id, authToken: 'test-token', method, params } +} + +describe('runtime close attribution topology', () => { + const records: SpanRecord[] = [] + + beforeEach(() => { + records.length = 0 + setActiveSink({ + push: (record) => records.push(record as SpanRecord), + flush: vi.fn(), + close: vi.fn() + } satisfies TracerSink) + }) + + afterEach(() => _resetTracerForTests()) + + it('identifies the authoritative runtime and target for remote, stale, and legacy closes', async () => { + const closeMobileSessionTab = vi + .fn() + .mockResolvedValueOnce({ closed: true, refused: true, refusalReason: 'stale-terminal' }) + .mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'runtime-owner-1', + closeMobileSessionTab, + refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + request('stale-1', 'session.tabs.closeLifecycle', { + worktree: 'id:worktree-a', + tabId: 'tab-a', + reason: 'cleanup', + publicationEpoch: 'epoch-old', + terminal: 'terminal-old' + }), + vi.fn(), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-1', + connectionId: 'connection-before-reconnect', + clientKind: 'runtime' + } + ) + await dispatcher.dispatchStreaming( + request('legacy-1', 'session.tabs.close', { + worktree: 'id:worktree-b', + tabId: 'tab-b' + }), + vi.fn(), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-1', + connectionId: 'connection-after-reconnect', + clientKind: 'runtime', + clientCapabilities: [] + } + ) + + expect(records).toHaveLength(2) + expect(records[0]).toMatchObject({ + name: 'runtime.session-tabs.close-lifecycle', + attributes: { + runtimeId: 'runtime-owner-1', + worktree: 'id:worktree-a', + tabId: 'tab-a', + terminal: 'terminal-old', + deviceId: 'device-1', + connectionGeneration: 'connection-before-reconnect', + requestId: 'stale-1', + decision: 'refused-stale-terminal' + }, + exit: { _tag: 'Success' } + }) + expect(records[1]).toMatchObject({ + name: 'runtime.session-tabs.close', + attributes: { + runtimeId: 'runtime-owner-1', + worktree: 'id:worktree-b', + tabId: 'tab-b', + deviceId: 'device-1', + connectionGeneration: 'connection-after-reconnect', + requestId: 'legacy-1', + closeReason: 'legacy-runtime-user', + decision: 'allowed' + } + }) + expect(JSON.stringify(records)).not.toContain(BEARER_CLIENT_ID) + }) + + it('keeps concurrent cross-worktree request and target identities distinct', async () => { + const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'runtime-owner-2', + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await Promise.all( + ( + [ + ['request-a', 'device-a', 'connection-a', 'worktree-a', 'tab-a'], + ['request-b', 'device-b', 'connection-b', 'worktree-b', 'tab-b'] + ] as const + ).map(([id, deviceId, connectionId, worktree, tabId]) => + dispatcher.dispatchStreaming( + request(id, 'session.tabs.close', { + worktree: `id:${worktree}`, + tabId, + reason: 'user' + }), + vi.fn(), + { clientKind: 'runtime', pairedDeviceId: deviceId, connectionId } + ) + ) + ) + + expect(records.map((record) => record.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + runtimeId: 'runtime-owner-2', + worktree: 'id:worktree-a', + tabId: 'tab-a', + deviceId: 'device-a', + connectionGeneration: 'connection-a', + requestId: 'request-a' + }), + expect.objectContaining({ + runtimeId: 'runtime-owner-2', + worktree: 'id:worktree-b', + tabId: 'tab-b', + deviceId: 'device-b', + connectionGeneration: 'connection-b', + requestId: 'request-b' + }) + ]) + ) + expect(closeMobileSessionTab).toHaveBeenCalledTimes(2) + }) + + it('records resolved terminal outcome and failed unowned requests without extra inventory', async () => { + const closeTerminal = vi + .fn() + .mockResolvedValueOnce({ handle: 'terminal-live', tabId: 'tab-live', ptyKilled: true }) + .mockRejectedValueOnce(new Error('terminal_handle_stale')) + const listSessions = vi.fn() + const runtime = { + getRuntimeId: () => 'runtime-owner-3', + closeTerminal, + listSessions + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const context = { + clientKind: 'runtime' as const, + pairedDeviceId: 'device-terminal', + connectionId: 'connection-terminal' + } + + await dispatcher.dispatchStreaming( + request('terminal-live-request', 'terminal.close', { terminal: 'terminal-live' }), + vi.fn(), + context + ) + await dispatcher.dispatchStreaming( + request('terminal-stale-request', 'terminal.close', { terminal: 'terminal-stale' }), + vi.fn(), + context + ) + + expect(records[0]).toMatchObject({ + attributes: { + runtimeId: 'runtime-owner-3', + terminal: 'terminal-live', + tabId: 'tab-live', + ptyKilled: true, + decision: 'allowed', + outcome: 'succeeded' + }, + exit: { _tag: 'Success' } + }) + expect(records[1]).toMatchObject({ + attributes: { + runtimeId: 'runtime-owner-3', + terminal: 'terminal-stale', + decision: 'allowed', + outcome: 'failed' + }, + exit: { _tag: 'Failure' } + }) + expect(closeTerminal).toHaveBeenCalledTimes(2) + expect(listSessions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts new file mode 100644 index 00000000000..0073348ec56 --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' +import type { OrcaRuntimeService } from '../orca-runtime' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { SESSION_TAB_METHODS } from './methods/session-tabs' +import { TERMINAL_METHODS } from './methods/terminal' + +const BEARER_CLIENT_ID = 'secret-bearer-client-id' + +type CapturingSink = TracerSink & { records: unknown[] } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + return { + records, + push: (record) => records.push(record), + flush: vi.fn(), + close: vi.fn() + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'close-request-1', authToken: 'test-token', method, params } +} + +describe('runtime close attribution', () => { + let sink: CapturingSink + + beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + }) + + afterEach(() => { + _resetTracerForTests() + }) + + it.each([ + ['terminal.close', 'closeTerminal', 'terminal'], + ['terminal.closeTab', 'closeTerminalTab', 'terminal-tab'] + ] as const)( + 'records %s with the device identity and without the bearer credential', + async (method, call, targetKind) => { + const close = vi.fn().mockResolvedValue({ handle: 'term-1', ptyKilled: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + [call]: close + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + request(method, { terminal: 'term-1' }), + (reply) => replies.push(reply), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-uuid-123', + connectionId: 'connection-7', + clientKind: 'runtime' + } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true }) + expect(close).toHaveBeenCalledWith('term-1') + expect(sink.records).toEqual([ + expect.objectContaining({ + type: 'effect-span', + name: method, + attributes: expect.objectContaining({ + attribution: 'terminal-close', + origin: 'runtime', + deviceId: 'device-uuid-123', + connectionGeneration: 'connection-7', + requestId: 'close-request-1', + targetKind, + terminal: 'term-1', + decision: 'allowed' + }), + exit: { _tag: 'Success' } + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID) + } + ) + + it.each([ + [ + 'session.tabs.close', + { worktree: 'id:wt-1', tabId: 'tab-1', reason: 'user' }, + 'runtime.session-tabs.close' + ], + [ + 'session.tabs.closeLifecycle', + { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }, + 'runtime.session-tabs.close-lifecycle' + ] + ] as const)( + 'records the device identity for %s without the bearer credential', + async (method, params, spanName) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming(request(method, params), vi.fn(), { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-uuid-123', + connectionId: 'connection-7', + clientKind: 'runtime' + }) + + expect(sink.records).toEqual([ + expect.objectContaining({ + name: spanName, + attributes: expect.objectContaining({ deviceId: 'device-uuid-123' }) + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID) + } + ) +}) diff --git a/src/main/runtime/rpc/terminal-close-attribution.ts b/src/main/runtime/rpc/terminal-close-attribution.ts new file mode 100644 index 00000000000..01eaa4e05a4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-close-attribution.ts @@ -0,0 +1,50 @@ +import { withSpan } from '../../observability/tracer' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import type { RpcContext } from './core' + +type TerminalCloseMethod = 'terminal.close' | 'terminal.closeTab' +type TerminalCloseTargetKind = 'terminal' | 'terminal-tab' + +export function withTerminalCloseAttribution( + method: TerminalCloseMethod, + context: Pick< + RpcContext, + 'runtime' | 'clientKind' | 'pairedDeviceId' | 'connectionId' | 'requestId' + >, + targetKind: TerminalCloseTargetKind, + terminal: string, + close: () => Promise +): Promise { + return withSpan( + method, + async (span) => { + span.setAttribute('decision', 'allowed') + try { + const result = await close() + span.setAttribute('outcome', 'succeeded') + span.setAttribute('tabId', result.tabId) + span.setAttribute('ptyKilled', result.ptyKilled) + if (result.closeMode) { + span.setAttribute('closeMode', result.closeMode) + } + return result + } catch (error) { + span.setAttribute('outcome', 'failed') + throw error + } + }, + { + kind: 'client', + attributes: { + attribution: 'terminal-close', + runtimeId: context.runtime.getRuntimeId(), + origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + connectionGeneration: context.connectionId ?? 'in-process', + requestId: context.requestId ?? 'in-process', + targetKind, + terminal + } + } + ) +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 609079305c5..55d03efbd50 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -5616,6 +5616,149 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('stays on loopback at startup after a "This computer only" grant has connected', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: the local web client authenticating marks its grant lastSeenAt > 0 like any other socket, so a + // blanket "any connected device" widen republished the runtime on every interface one launch later — + // exactly what the user declined by picking "This computer only". + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('Runtime local', 'runtime', 'this-computer') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('binds all interfaces at startup for a connected device paired before pairingReach existed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: registries written by older desktops only ever held network-reach grants; a missing field must + // keep the reconnect widen or an already-paired phone would be stranded by the upgrade. + const legacyDevice = { + deviceId: 'legacy-device', + name: 'Legacy phone', + token: 'legacy-token', + scope: 'mobile', + pairedAt: Date.now(), + lastSeenAt: Date.now() + } + await writeFile( + join(userDataPath, DEVICE_REGISTRY_FILENAME), + JSON.stringify([legacyDevice]), + 'utf-8' + ) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + + it('upgrades a reused pending grant to network reach so its link survives a relaunch', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + let deviceId: string + try { + const local = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'runtime', + reach: 'this-computer' + }) + expect(local.available).toBe(true) + // Why: without `rotate` the same pending token is re-advertised, now for off-host reach. The mark must + // widen with it — keeping it this-computer would leave the LAN link unserved after the next launch. + const network = server.createPairingOffer({ + address: '100.64.1.20', + scope: 'runtime', + reach: 'network' + }) + expect(network.available).toBe(true) + deviceId = network.available ? network.deviceId : '' + expect(deviceId).toBe(local.available ? local.deviceId : '') + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await relaunched.start() + try { + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) + + it('keeps the pinned port when a later widen tears down a live loopback client', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const loopbackPort = wsTransportOf(server)!.resolvedPort + // Why: a "This computer only" link never widens, so unlike before, a local client can already be + // connected when a later LAN offer opts in. The rebind terminates it (ws cannot move a listener), so + // the port must be reused or the already-issued local link could never reconnect. + const client = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + client.once('open', () => resolve()) + client.once('error', reject) + }) + const closed = new Promise((resolve) => client.once('close', () => resolve())) + + await server.ensureNetworkExposure() + await closed + + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort) + + const reconnected = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + reconnected.once('open', () => resolve()) + reconnected.once('error', reject) + }) + reconnected.close() + } finally { + await server.stop() + } + }) + it('keeps the same MobileSocketWiring instance across a pairing widen (relay capture stays valid)', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f6274c3a996..7056f4bfe8f 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -30,6 +30,7 @@ import { } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { mobileRelayMintFailureFromUnknown, type MobileRelayMintFailure @@ -661,6 +662,9 @@ export class OrcaRuntimeRpcServer { name?: string rotate?: boolean scope?: DeviceScope + // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the + // next launch bind every interface. Defaults to network reach, which is what every other caller means. + reach?: RuntimePairingReach }): | PairingOfferUnavailable | { @@ -697,9 +701,10 @@ export class OrcaRuntimeRpcServer { const scope = args.scope ?? 'runtime' let device: DeviceEntry try { + const reach = args.reach ?? 'network' device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) } catch (error) { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) @@ -1228,13 +1233,17 @@ export class OrcaRuntimeRpcServer { // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. + // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so + // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } - const hasConnectedDevice = - this.deviceRegistry?.listDevices().some((device) => device.lastSeenAt > 0) ?? false - return hasConnectedDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK + const hasConnectedNetworkDevice = + this.deviceRegistry + ?.listDevices() + .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false + return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK } // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket @@ -1340,9 +1349,10 @@ export class OrcaRuntimeRpcServer { } // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing - // offer's advertised LAN endpoint is reachable. Idempotent and only ever runs on the first opt-in - // (before any device has connected), so no live socket is disrupted; the resolved port is reused so - // an already-issued endpoint stays valid. + // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first + // pairing action: a "This computer only" link never widens, so live loopback clients can already be + // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), + // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { if ( !this.enableWebSocket || diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index b0df601f133..068334eb98d 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -1,5 +1,9 @@ import type { IPtyProvider } from '../providers/types' -import { UNSTOPPED_PTY_REMOVAL_PREFIX } from '../../shared/worktree-removal' +import { + UNSTOPPED_PTY_DETAIL_SEPARATOR, + UNSTOPPED_PTY_LIVE_DETAIL_PREFIX, + UNSTOPPED_PTY_REMOVAL_PREFIX +} from '../../shared/worktree-removal' import { settleBeforeDeadline } from './settle-before-deadline' // Floor for the verification window when the sweep ran on a very short budget. @@ -60,9 +64,21 @@ export function describeUnstoppedPtys( ): string { const detail = verdict.status === 'live' - ? `still live: ${verdict.ptyIds.join(', ')}` + ? `${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` : `could not verify these exited: ${failedPtyIds.join(', ')} (${verdict.reason})` - return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId} — ${detail}` + return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${detail}` +} + +/** + * Words a sweep that never produced a per-PTY verdict — a wedged daemon, a dropped SSH + * channel — as the unstopped-PTY failure it is. + * + * Why (#11960): this rejection carried only the provider's own wording, which the force + * classifier cannot recognise, so the wedge the escape hatch exists for was the one case + * that never got offered it. The provider's message stays in the text; only the shape changes. + */ +export function describeFailedPtySweep(worktreeId: string, error: unknown): string { + return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}the terminal sweep failed: ${describeError(error)}` } export function describeError(error: unknown): string { diff --git a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts index 47852b45985..ca09e01b2d8 100644 --- a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts +++ b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts @@ -9,6 +9,11 @@ vi.mock('../memory/pty-registry', () => ({ })) import { killAllProcessesForWorktree, WORKTREE_PROCESS_SWEEP_TIMEOUT_MS } from './worktree-teardown' +import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' +import { + classifyWorktreeForceDeleteReason, + isProvenLivePtyRemovalError +} from '../../shared/worktree-removal' import type { IPtyProvider, PtyProcessInfo } from '../providers/types' // Why: these tests advance fake timers *before* awaiting the teardown, so a @@ -243,6 +248,13 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { providerStopped: 0, registryStopped: 0 }) + // Why: the deadline gave up on this sweep without cancelling it, so the grace + // expired with the provider still inside listProcesses. Force deletes the + // directory anyway, and this warning is the only record that a PTY handle may + // have outlived it — a silent "incomplete sweep" reads as if it had finished. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`still running after the ${ABANDONED_SWEEP_GRACE_MS}ms grace`) + ) } finally { releaseList([]) await vi.advanceTimersByTimeAsync(0) @@ -337,6 +349,79 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { ).rejects.toThrow('ssh channel closed') }) + // Why (#11960): the wedge the escape hatch exists for — an unresponsive daemon, a dropped + // SSH channel — rejects the sweep in the provider's own words, which the force classifier + // cannot recognise. Failing closed with no Force Delete button is the dead end itself. + it('offers force delete for a sweep-level failure without losing the provider wording', async () => { + listRegisteredPtysMock.mockReturnValue([]) + const localProvider = createProviderStub(async () => { + throw new Error('SSH channel closed while listing processes') + }) + + const error = await killAllProcessesForWorktree('repo-1::/w', { + localProvider, + requirePhysicalStop: true + }).then( + () => new Error('expected a rejection'), + (rejection: Error) => rejection + ) + expect(error.message).toContain('SSH channel closed while listing processes') + expect(classifyWorktreeForceDeleteReason(error.message)).toBe('unstopped-pty') + // Nothing was verified here, so it must not read as the proven-live verdict. + expect(isProvenLivePtyRemovalError(error.message)).toBe(false) + }) + + // Why: the outer deadline rejects without cancelling the sweep it gave up on, so force + // could return — and the caller start deleting the directory — while shutdown() was still + // killing the PTY holding it open. On Windows that rmdir fails and half-deletes the + // workspace, which is exactly what the ordering at the removal call site exists to avoid. + it('waits for a shutdown the deadline abandoned before force returns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let releaseShutdown: () => void = () => {} + try { + listRegisteredPtysMock.mockReturnValue([]) + const localProvider = createProviderStub(async () => [ + { id: 'w1@@live-1', cwd: '/tmp/w1', title: 'shell' } + ]) + let shutdownFinished = false + ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( + async () => + await new Promise((resolve) => { + releaseShutdown = () => { + shutdownFinished = true + resolve() + } + }) + ) + + const teardown = settleTeardown( + killAllProcessesForWorktree('w1', { + localProvider, + timeoutMs: 30, + requirePhysicalStop: true, + allowUnverifiedStop: true + }) + ) + let returned = false + void teardown.then(() => { + returned = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(shutdownFinished).toBe(false) + expect(returned).toBe(false) + releaseShutdown() + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 0 + }) + } finally { + releaseShutdown() + warn.mockRestore() + } + }) + it('lets an explicit force removal proceed past PTYs it could not stop', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 45929e21f98..993a263d348 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -4,10 +4,16 @@ import { listRegisteredPtys } from '../memory/pty-registry' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { WORKTREE_TEARDOWN_FORCE_HINT } from '../../shared/worktree-removal' +import { + isUnstoppedPtyRemovalError, + WORKTREE_TEARDOWN_FORCE_HINT, + WORKTREE_TEARDOWN_TIMEOUT_PREFIX +} from '../../shared/worktree-removal' import { settleBeforeDeadline } from './settle-before-deadline' +import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' import { describeError, + describeFailedPtySweep, describeUnstoppedPtys, verifyUnstoppedPtys, type UnstoppedPtyVerdict @@ -87,7 +93,10 @@ export async function killAllProcessesForWorktree( ): Promise { const sweepBudgetMs = Math.max(1, deps.timeoutMs ?? WORKTREE_PROCESS_SWEEP_TIMEOUT_MS) const deadline = Date.now() + sweepBudgetMs - const deadlineError = new Error(`Timed out waiting for physical PTY teardown: ${worktreeId}`) + const deadlineError = new Error( + `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}` + ) + const sweeps = createWorktreeSweepTracker() const stopAttempts = new Map>() const stopPty = ( ptyId: string, @@ -116,7 +125,7 @@ export async function killAllProcessesForWorktree( // destructive removal closed. const runtimeSweep = deps.runtime ? settleBeforeDeadline( - () => + sweeps.track(() => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty, @@ -127,7 +136,8 @@ export async function killAllProcessesForWorktree( ...(deps.resolvedRuntimeEnvironmentId ? { resolvedRuntimeEnvironmentId: deps.resolvedRuntimeEnvironmentId } : {}) - }), + }) + ), { stopped: 0 }, deadline, deps.requirePhysicalStop ? deadlineError : undefined, @@ -142,7 +152,7 @@ export async function killAllProcessesForWorktree( deps.includeProviderInventory === false ? Promise.resolve(0) : settleBeforeDeadline( - () => + sweeps.track(() => sweepProviderByPrefix( worktreeId, deps.localProvider, @@ -150,7 +160,8 @@ export async function killAllProcessesForWorktree( stopPty, deps.onPtyStopped, deps.requirePhysicalStop - ), + ) + ), 0, deadline, deps.requirePhysicalStop ? deadlineError : undefined @@ -159,14 +170,15 @@ export async function killAllProcessesForWorktree( deps.includeLocalRegistry === false ? Promise.resolve(0) : settleBeforeDeadline( - () => + sweeps.track(() => sweepRegistryForWorktree( worktreeId, deps.localProvider, deadline, stopPty, deps.onPtyStopped - ), + ) + ), 0, deadline, deps.requirePhysicalStop ? deadlineError : undefined @@ -180,54 +192,39 @@ export async function killAllProcessesForWorktree( let providerStopped: number let registryStopped: number if (deps.allowUnverifiedStop) { - // Why: force goes on to delete files, so every sweep must finish releasing - // handles first — Promise.all would abandon the siblings of the first - // rejection while they were still inside shutdown(), racing the delete. - const settled = await Promise.allSettled([runtimeSweep, providerSweep, registrySweep]) - const [runtimeSettled, providerSettled, registrySettled] = settled - const reasons = settled.flatMap((result) => - result.status === 'rejected' ? [result.reason as unknown] : [] + const forced = await settleSweepsForForcedRemoval( + worktreeId, + { runtime: runtimeSweep, provider: providerSweep, registry: registrySweep }, + sweeps, + deadlineError ) - if (reasons.length > 0) { - // Why (#11960): a sweep that cannot even complete — unresponsive daemon, - // dropped SSH channel — fails before the unproven-stop gate below could - // offer its escape hatch, so an explicit Force Delete has to survive it. - // Report every reason, specific ones first: the shared deadline sentinel - // only says that *something* timed out, so leading with it would bury the - // provider error that actually explains the failure. This warning is the - // only trace of a removal that deleted files without proving a single stop. - const detail = [ - ...reasons.filter((candidate) => candidate !== deadlineError), - ...reasons.filter((candidate) => candidate === deadlineError) - ] - .map(describeError) - .join('; ') - console.warn( - `[worktree-teardown] forcing removal after an incomplete PTY sweep for ${worktreeId} — ${detail}` - ) - // Returning here does skip the verdict below, which could still have named - // live PTYs when only one sweep failed — accepted for now because this path - // is behind an explicit Force Delete, deletes either way, and clears no - // registry rows, so the cost is diagnosability rather than safety. - // Report what the surviving sweeps actually stopped rather than a flat zero. - return { - runtimeStopped: runtimeSettled.status === 'fulfilled' ? runtimeSettled.value.stopped : 0, - providerStopped: providerSettled.status === 'fulfilled' ? providerSettled.value : 0, - registryStopped: registrySettled.status === 'fulfilled' ? registrySettled.value : 0 - } + if (forced.incomplete) { + return forced.stopped } - runtimeResult = (runtimeSettled as PromiseFulfilledResult<{ stopped: number }>).value - providerStopped = (providerSettled as PromiseFulfilledResult).value - registryStopped = (registrySettled as PromiseFulfilledResult).value + runtimeResult = { stopped: forced.stopped.runtimeStopped } + providerStopped = forced.stopped.providerStopped + registryStopped = forced.stopped.registryStopped } else { // Why: without the waiver a rejection aborts the removal and nothing is // deleted, so failing fast is safe — and keeps a dead host reporting // immediately instead of after the full sweep budget. - ;[runtimeResult, providerStopped, registryStopped] = await Promise.all([ - runtimeSweep, - providerSweep, - registrySweep - ]) + try { + ;[runtimeResult, providerStopped, registryStopped] = await Promise.all([ + runtimeSweep, + providerSweep, + registrySweep + ]) + } catch (error) { + // Why (#11960): this rejection is the provider's own wording, which the force + // classifier cannot recognise — so the wedge Force Delete exists for was the one + // failure that never offered it. Re-word it, keeping the original as the cause. + throw deps.requirePhysicalStop && !isUnstoppedPtyRemovalError(describeError(error)) + ? new Error( + `${describeFailedPtySweep(worktreeId, error)}. ${WORKTREE_TEARDOWN_FORCE_HINT}`, + { cause: error } + ) + : error + } } if (deps.requirePhysicalStop) { const stopResults = await Promise.all( diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 575decba730..206d008c151 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -48,6 +48,24 @@ const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1 // (system sleep, App Nap timer throttling) — not that the link is dead (#7773). const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3 +// Why: callers branch on "the request timed out" (fall back to a slower path, +// report a host issue). Matching the message text made every unrelated error +// carrying the same phrase take the timeout branch. +export const SSH_MUX_REQUEST_TIMEOUT_CODE = 'SSH_MUX_REQUEST_TIMEOUT' + +function sshMuxRequestTimeoutError(method: string, timeoutMs: number): Error { + return Object.assign(new Error(`Request "${method}" timed out after ${timeoutMs}ms`), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + +export function isSshMuxRequestTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + (error as Error & { code?: unknown }).code === SSH_MUX_REQUEST_TIMEOUT_CODE + ) +} + export class SshChannelMultiplexer { private decoder: FrameDecoder private transport: MultiplexerTransport @@ -231,7 +249,7 @@ export class SshChannelMultiplexer { this.notify('rpc.cancel', { id }) } this.pendingRequests.delete(id) - reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) + reject(sshMuxRequestTimeoutError(method, timeoutMs)) }, timeoutMs) if (options?.signal) { diff --git a/src/main/ssh/ssh-config-host-picker.test.ts b/src/main/ssh/ssh-config-host-picker.test.ts new file mode 100644 index 00000000000..4d7666fa457 --- /dev/null +++ b/src/main/ssh/ssh-config-host-picker.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SshConfigParser from './ssh-config-parser' +import type { SshConfigHost } from './ssh-config-parser' + +const loadUserSshConfigMock = vi.hoisted(() => vi.fn<() => SshConfigHost[]>(() => [])) +vi.mock('./ssh-config-parser', async (importOriginal) => ({ + ...(await importOriginal()), + loadUserSshConfig: loadUserSshConfigMock +})) + +import { SSH_CONFIG_HOST_RESULT_LIMIT } from '../../shared/ssh-types' +import { + invalidateUserSshConfigHostCache, + listUserSshConfigHostSummaries, + resolveUserSshConfigHost, + searchSshConfigHosts +} from './ssh-config-host-picker' +import type { SshResolvedConfig } from './ssh-g-config-resolution' + +function resolved(overrides: Partial = {}): SshResolvedConfig { + return { + hostname: 'prod.internal', + user: 'deploy', + port: 2222, + identityFile: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +describe('SSH config host picker search', () => { + it('bounds results while searching aliases beyond the first page', () => { + const hosts = Array.from({ length: 150_000 }, (_, index) => ({ + host: `generated-${index}` + })) + const initial = searchSshConfigHosts(hosts, []) + const last = searchSshConfigHosts(hosts, [], 'generated-149999') + + expect(initial.hosts).toHaveLength(SSH_CONFIG_HOST_RESULT_LIMIT) + expect(initial).toMatchObject({ + totalHostCount: 150_000, + newHostCount: 150_000, + matchCount: 150_000, + hasMore: true + }) + expect(last.hosts.map((host) => host.alias)).toEqual(['generated-149999']) + expect(last.hasMore).toBe(false) + }) + + it('counts de-duplicated and existing aliases while matching effective display fields', () => { + const result = searchSshConfigHosts( + [ + { host: 'prod', hostname: 'prod.internal', user: 'deploy' }, + { host: 'prod', hostname: 'ignored.internal' }, + { host: 'stage', hostname: 'stage.internal', user: 'ops' } + ], + [{ configHost: 'prod', label: 'Production' }], + 'internal' + ) + + expect(result).toMatchObject({ + totalHostCount: 2, + newHostCount: 1, + matchCount: 2, + hasMore: false + }) + expect(result.hosts[0]).toMatchObject({ alias: 'prod', alreadyInOrca: true }) + }) + + it('marks a case-only alias variant as already in Orca', () => { + const result = searchSshConfigHosts([{ host: 'prod' }], [{ configHost: 'Prod', label: 'Prod' }]) + + expect(result).toMatchObject({ totalHostCount: 1, newHostCount: 0 }) + expect(result.hosts[0]).toMatchObject({ alias: 'prod', alreadyInOrca: true }) + }) + + it('lists suppressed aliases for re-pick but excludes them from Add-all newHostCount', () => { + const result = searchSshConfigHosts([{ host: 'removed' }, { host: 'active' }], [], '', [ + 'removed' + ]) + + expect(result).toMatchObject({ totalHostCount: 2, newHostCount: 1, matchCount: 2 }) + expect(result.hosts).toEqual([ + expect.objectContaining({ alias: 'removed', previouslyRemoved: true, alreadyInOrca: false }), + expect.objectContaining({ alias: 'active', alreadyInOrca: false }) + ]) + expect(result.hosts.find((host) => host.alias === 'active')?.previouslyRemoved).toBeUndefined() + }) + + it('does not mark a host as previously removed when it is already in Orca', () => { + const result = searchSshConfigHosts( + [{ host: 'prod' }], + [{ configHost: 'prod', label: 'Production' }], + '', + ['prod'] + ) + + expect(result.hosts[0]).toMatchObject({ + alias: 'prod', + alreadyInOrca: true + }) + expect(result.hosts[0]?.previouslyRemoved).toBeUndefined() + expect(result.newHostCount).toBe(0) + }) +}) + +describe('listUserSshConfigHostSummaries caching', () => { + beforeEach(() => { + loadUserSshConfigMock.mockClear() + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }, { host: 'stage' }]) + invalidateUserSshConfigHostCache() + }) + + it('parses ~/.ssh/config once per picker session and filters in memory', () => { + listUserSshConfigHostSummaries([], '', [], { refresh: true }) + const filtered = listUserSshConfigHostSummaries([], 'stage') + listUserSshConfigHostSummaries([], 'sta') + + expect(loadUserSshConfigMock).toHaveBeenCalledTimes(1) + expect(filtered.hosts.map((host) => host.alias)).toEqual(['stage']) + }) + + it('re-reads the file when the picker reopens', () => { + listUserSshConfigHostSummaries([], '', [], { refresh: true }) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }, { host: 'stage' }, { host: 'added' }]) + const reopened = listUserSshConfigHostSummaries([], '', [], { refresh: true }) + + expect(loadUserSshConfigMock).toHaveBeenCalledTimes(2) + expect(reopened.totalHostCount).toBe(3) + }) +}) + +describe('resolveUserSshConfigHost', () => { + it('returns effective endpoint and the complete authentication contract', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('prod', resolver, () => [ + { host: 'prod', gssapiAuthentication: true } + ]) + ).resolves.toEqual({ + alias: 'prod', + hostname: 'prod.internal', + username: 'deploy', + port: 2222, + identityFiles: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false + }) + expect(resolver).toHaveBeenCalledWith('prod') + }) + + it('ignores the system-wide GSSAPIAuthentication default that ssh -G reports', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('prod', resolver, () => [ + { host: 'prod', hostname: 'prod.internal' } + ]) + ).resolves.toMatchObject({ gssapiAuthentication: false }) + }) + + it('honours a case-only variant of the Host entry that requests GSSAPI', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('PROD', resolver, () => [ + { host: 'prod', gssapiAuthentication: true } + ]) + ).resolves.toMatchObject({ gssapiAuthentication: true }) + }) + + it('returns null for an alias the config no longer declares, even though ssh -G answers', async () => { + const resolver = vi.fn().mockResolvedValue(resolved({ hostname: 'deleted' })) + + await expect( + resolveUserSshConfigHost('deleted', resolver, () => [{ host: 'prod' }]) + ).resolves.toBeNull() + expect(resolver).not.toHaveBeenCalled() + }) + + it('returns null when OpenSSH cannot resolve an endpoint', async () => { + await expect( + resolveUserSshConfigHost( + 'missing', + async () => null, + () => [{ host: 'missing' }] + ) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/ssh/ssh-config-host-picker.ts b/src/main/ssh/ssh-config-host-picker.ts new file mode 100644 index 00000000000..d1870ad1b69 --- /dev/null +++ b/src/main/ssh/ssh-config-host-picker.ts @@ -0,0 +1,167 @@ +import type { + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigHostSummary, + SshTarget +} from '../../shared/ssh-types' +import { SSH_CONFIG_HOST_RESULT_LIMIT } from '../../shared/ssh-types' +import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' +import { loadUserSshConfig, type SshConfigHost } from './ssh-config-parser' +import { resolveWithSshG, type SshResolvedConfig } from './ssh-g-config-resolution' + +// Why: every keystroke in the picker filter re-queries the main process. Parsing (and +// Include-expanding) ~/.ssh/config per keystroke is the whole cost, and the file cannot +// change between them, so hold the parse for the picker session and refresh on reopen. +let cachedConfigHosts: SshConfigHost[] | null = null + +export function invalidateUserSshConfigHostCache(): void { + cachedConfigHosts = null +} + +function getUserSshConfigHosts(refresh: boolean): SshConfigHost[] { + if (refresh || cachedConfigHosts === null) { + cachedConfigHosts = loadUserSshConfig() + } + return cachedConfigHosts +} + +export function searchSshConfigHosts( + hosts: SshConfigHost[], + existingTargets: readonly Pick[], + query = '', + suppressedAliases: readonly string[] = [] +): SshConfigHostListResult { + const existingAliases = new Set( + existingTargets.flatMap((target) => + [target.configHost, target.label].map(normalizeSshConfigAlias).filter(Boolean) + ) + ) + const normalizedQuery = query.trim().toLowerCase() + const suppressedAliasSet = new Set(suppressedAliases.map(normalizeSshConfigAlias)) + const summaries: SshConfigHostSummary[] = [] + const seenAliases = new Set() + let totalHostCount = 0 + let newHostCount = 0 + let matchCount = 0 + + for (const entry of hosts) { + const normalizedAlias = normalizeSshConfigAlias(entry.host) + if (seenAliases.has(normalizedAlias)) { + continue + } + seenAliases.add(normalizedAlias) + const alreadyInOrca = existingAliases.has(normalizedAlias) + // Why: tombstones only block passive bulk import — the picker still lists the + // Host so deleting one Orca target never looks like "~/.ssh/config is empty". + const previouslyRemoved = !alreadyInOrca && suppressedAliasSet.has(normalizedAlias) + totalHostCount += 1 + // Why: "Add all" must match importFromSshConfig without reAdopt (tombstones stay). + newHostCount += alreadyInOrca || previouslyRemoved ? 0 : 1 + if (!matchesQuery(entry, normalizedQuery)) { + continue + } + matchCount += 1 + if (summaries.length < SSH_CONFIG_HOST_RESULT_LIMIT) { + summaries.push(toSummary(entry, alreadyInOrca, previouslyRemoved)) + } + } + + return { + hosts: summaries, + totalHostCount, + newHostCount, + matchCount, + hasMore: matchCount > summaries.length + } +} + +export function listUserSshConfigHostSummaries( + existingTargets: readonly Pick[], + query?: string, + suppressedAliases?: readonly string[], + options?: { refresh?: boolean } +): SshConfigHostListResult { + return searchSshConfigHosts( + getUserSshConfigHosts(options?.refresh === true), + existingTargets, + query, + suppressedAliases + ) +} + +export async function resolveUserSshConfigHost( + alias: string, + resolver: (host: string) => Promise = resolveWithSshG, + // Why: read the file again rather than the picker-session cache — the point of the check + // below is to catch a row the user edited out of ~/.ssh/config after opening the picker. + loadConfigHosts: () => SshConfigHost[] = () => getUserSshConfigHosts(true) +): Promise { + const configHosts = loadConfigHosts() + // Why: `ssh -G` answers for unknown aliases too, echoing the alias back as the hostname. + // Resolving one would mint a target for a host that is no longer configured. + if (!configHostMatches(configHosts, alias)) { + return null + } + const resolved = await resolver(alias) + if (!resolved?.hostname) { + return null + } + return { + alias, + hostname: resolved.hostname, + port: resolved.port, + username: resolved.user ?? '', + identityFiles: resolved.identityFile, + ...(resolved.identityAgent ? { identityAgent: resolved.identityAgent } : {}), + identitiesOnly: resolved.identitiesOnly, + forwardAgent: resolved.forwardAgent, + // Why: `ssh -G` reports the effective value, which on many distros is the /etc/ssh + // default `GSSAPIAuthentication yes`. Stamping that onto a manual target would force + // a GSSAPI-first handshake the user never asked for, so honour the Host entry only. + gssapiAuthentication: configHostRequestsGssapi(configHosts, alias), + ...(resolved.proxyCommand ? { proxyCommand: resolved.proxyCommand } : {}), + proxyUseFdpass: resolved.proxyUseFdpass, + ...(resolved.proxyJump ? { jumpHost: resolved.proxyJump } : {}) + } +} + +function configHostMatches(hosts: readonly SshConfigHost[], alias: string): boolean { + const normalized = normalizeSshConfigAlias(alias) + return Boolean(normalized) && hosts.some((entry) => matchesAlias(entry, normalized)) +} + +function configHostRequestsGssapi(hosts: readonly SshConfigHost[], alias: string): boolean { + const normalized = normalizeSshConfigAlias(alias) + return hosts.some((entry) => matchesAlias(entry, normalized) && entry.gssapiAuthentication) +} + +function matchesAlias(entry: SshConfigHost, normalizedAlias: string): boolean { + return normalizeSshConfigAlias(entry.host) === normalizedAlias +} + +function toSummary( + entry: SshConfigHost, + alreadyInOrca: boolean, + previouslyRemoved = false +): SshConfigHostSummary { + return { + alias: entry.host, + hostname: entry.hostname || entry.host, + port: entry.port ?? 22, + username: entry.user ?? '', + ...(entry.identityFile ? { identityFile: entry.identityFile } : {}), + ...(entry.proxyCommand ? { proxyCommand: entry.proxyCommand } : {}), + ...(entry.proxyJump ? { jumpHost: entry.proxyJump } : {}), + alreadyInOrca, + ...(previouslyRemoved ? { previouslyRemoved: true } : {}) + } +} + +function matchesQuery(entry: SshConfigHost, query: string): boolean { + return ( + !query || + entry.host.toLowerCase().includes(query) || + (entry.hostname ?? entry.host).toLowerCase().includes(query) || + (entry.user ?? '').toLowerCase().includes(query) + ) +} diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index e48b77baab9..8625a553ad1 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser' +import { searchSshConfigHosts } from './ssh-config-host-picker' + +const sshConfigHostsToSummaries = ( + hosts: Parameters[0], + targets: Parameters[1] +) => searchSshConfigHosts(hosts, targets).hosts vi.mock('os', () => ({ homedir: () => '/home/testuser' @@ -460,6 +466,60 @@ describe('sshConfigHostsToTargets', () => { }) }) +describe('sshConfigHostsToSummaries', () => { + it('maps hosts for the picker and flags aliases already in Orca', () => { + const hosts = [ + { + host: 'staging', + hostname: 'staging.internal', + user: 'ubuntu', + port: 22, + identityFile: '/home/me/.ssh/staging' + }, + { host: 'prod', hostname: 'prod.example', user: 'ops', port: 2222, proxyJump: 'bastion' } + ] + const summaries = sshConfigHostsToSummaries(hosts, [ + { label: 'staging', configHost: 'staging' } + ]) + expect(summaries).toEqual([ + { + alias: 'staging', + hostname: 'staging.internal', + port: 22, + username: 'ubuntu', + identityFile: '/home/me/.ssh/staging', + alreadyInOrca: true + }, + { + alias: 'prod', + hostname: 'prod.example', + port: 2222, + username: 'ops', + jumpHost: 'bastion', + alreadyInOrca: false + } + ]) + }) + + it('de-dupes aliases and defaults missing fields', () => { + const hosts = [ + { host: 'box' }, + { host: 'box', hostname: 'second.example' }, + { host: 'other', user: 'me' } + ] + const summaries = sshConfigHostsToSummaries(hosts, []) + expect(summaries).toHaveLength(2) + expect(summaries[0]).toEqual({ + alias: 'box', + hostname: 'box', + port: 22, + username: '', + alreadyInOrca: false + }) + expect(summaries[1].alias).toBe('other') + }) +}) + // ── parseSshGOutput ────────────────────────────────────────────────── describe('parseSshGOutput', () => { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 90d92a1499e..9acba4d18eb 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -85,6 +85,22 @@ describe('SshConnectionStore', () => { expect(mockStore.getSshTargets).toHaveBeenCalled() }) + it('lists picker suppression aliases without consulting re-adoption tombstones', () => { + mockStore.addDeletedSshConfigAlias('config-removed') + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-manual', + configHost: 'manual-removed', + host: 'manual.internal', + port: 22, + username: 'deploy', + label: 'Manual', + removedAt: 1 + }) + + expect(sshStore.listSuppressedSshConfigAliases()).toEqual(['config-removed']) + expect(mockStore.getRemovedSshTargetTombstones).not.toHaveBeenCalled() + }) + it('getTarget delegates to store', () => { sshStore.getTarget('test-id') expect(mockStore.getSshTarget).toHaveBeenCalledWith('test-id') @@ -357,6 +373,65 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) + // SSH matches Host patterns case-insensitively, so `Prod` and `prod` are one host + // everywhere else in the picker — import ownership must agree. + it('treats a case-only alias variant as owned by the existing manual target', () => { + mockStore.addSshTarget({ + id: 'ssh-m', + label: 'Prod', + configHost: 'Prod', + host: 'manual.example.com', + port: 22, + username: 'me', + source: 'manual' + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) + expect(result).toEqual([]) + }) + + it('keeps a case-only alias variant suppressed after the host was deleted', () => { + const added = sshStore.addTarget({ + label: 'Prod', + configHost: 'Prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + sshStore.removeTarget(added.id) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })]) + + const result = sshStore.importFromSshConfig() + + expect(result).toEqual([]) + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) + }) + + it('lifts a tombstone stored under different casing when the host is re-added', () => { + const added = sshStore.addTarget({ + label: 'Prod', + configHost: 'Prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + sshStore.removeTarget(added.id) + sshStore.addTarget({ + label: 'prod', + configHost: 'prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + + expect(sshStore.listSuppressedSshConfigAliases()).toEqual([]) + }) + it('does not rewrite an unchanged config-sourced target', () => { mockStore.addSshTarget({ id: 'ssh-1', @@ -423,7 +498,7 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) - it('does not tombstone a manual target on delete', () => { + it('suppresses a deleted manual target from config discovery', () => { mockStore.addSshTarget({ id: 'ssh-1', label: 'mini', @@ -435,7 +510,7 @@ describe('SshConnectionStore', () => { }) sshStore.removeTarget('ssh-1') - expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') }) it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { @@ -557,6 +632,7 @@ describe('SshConnectionStore', () => { sshStore.removeTarget('runtime-ssh-abc') expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled() + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() }) it('re-adopts orphaned repos when the same host is re-added', () => { diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 408b4522afa..97fc2e3b32e 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -1,6 +1,7 @@ import type { Store } from '../persistence' import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' +import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' import { buildRemovedSshTargetTombstone, @@ -25,6 +26,10 @@ export class SshConnectionStore { return labels } + listSuppressedSshConfigAliases(): string[] { + return this.store.getDeletedSshConfigAliases() + } + getTarget(id: string): SshTarget | undefined { return this.store.getSshTarget(id) } @@ -87,29 +92,29 @@ export class SshConnectionStore { removeTarget(id: string): void { const target = this.store.getSshTarget(id) - // Why: deleting a config-managed target must record a tombstone; otherwise - // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still - // exists on disk) and the host reappears. Manual targets need no tombstone — - // sync never re-adds them. - if (target && isConfigManagedTarget(target)) { + if (target && !isRuntimeOwnedSshTarget(target)) { const alias = target.configHost ?? target.label if (alias) { + // Why: tombstone so passive ~/.ssh/config sync does not resurrect the host. + // The config picker still lists it so re-pick/save can reclaim the alias. this.store.addDeletedSshConfigAlias(alias) } - } - // Why: record the removed target's host identity (for ALL user-facing - // targets, config-managed or manual) so a later re-add of the same host can - // re-adopt any workspaces orphaned on this id. Runtime-owned targets manage - // their own lifecycle and are never re-adopted. - if (target && !isRuntimeOwnedSshTarget(target)) { this.store.addRemovedSshTargetTombstone(buildRemovedSshTargetTombstone(target, Date.now())) } this.store.removeSshTarget(id) } private reclaimAlias(alias: string | undefined): void { - if (alias) { - this.store.removeDeletedSshConfigAlias(alias) + const normalized = normalizeSshConfigAlias(alias) + if (!normalized) { + return + } + // Why: tombstones persisted before alias folding (and hosts written with different + // casing) must all be lifted, or a re-add stays suppressed for its case variants. + for (const stored of this.store.getDeletedSshConfigAliases()) { + if (normalizeSshConfigAlias(stored) === normalized) { + this.store.removeDeletedSshConfigAlias(stored) + } } } @@ -126,7 +131,11 @@ export class SshConnectionStore { if (options?.reAdopt) { this.store.clearDeletedSshConfigAliases() } - const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) + // Why: aliases are compared case-insensitively everywhere else (picker, duplicate + // check, tombstones); a case-sensitive Set here would double-insert `Prod` vs `prod`. + const deletedAliases = new Set( + this.store.getDeletedSshConfigAliases().map((alias) => normalizeSshConfigAlias(alias)) + ) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -136,7 +145,7 @@ export class SshConnectionStore { const syncableByAlias = new Map() const manualAliases = new Set() for (const existing of existingTargets) { - const alias = existing.configHost ?? existing.label + const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label) if ( existing.source === 'manual' || (existing.source === undefined && !isLegacyConfigImportTarget(existing)) @@ -160,7 +169,7 @@ export class SshConnectionStore { const processedAliases = new Set() for (const candidate of candidates) { - const alias = candidate.configHost ?? candidate.label + const alias = normalizeSshConfigAlias(candidate.configHost ?? candidate.label) if (manualAliases.has(alias)) { // A manual target owns this alias — never clobber it. continue @@ -230,16 +239,6 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } -function isConfigManagedTarget(target: SshTarget): boolean { - // Why: a target is subject to config sync (and therefore needs a tombstone on - // delete) when it is explicitly config-sourced, or a legacy import that sync - // still adopts. Manual targets are excluded — sync never re-adds them. - return ( - target.source === 'ssh-config' || - (target.source === undefined && isLegacyConfigImportTarget(target)) - ) -} - function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index bdb38f83466..f53190621ab 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1998,6 +1998,30 @@ describe('SshConnection', () => { ) }) + it('tries GSSAPI first for a manually owned config-picker target', async () => { + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const conn = new SshConnection( + createTarget({ + source: 'manual', + configHost: 'prod', + host: 'prod.internal', + gssapiAuthentication: true + }), + createCallbacks() + ) + + await conn.connect() + + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ source: 'manual', configHost: 'prod' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ gssapiOnly: true, wrapCommand: false }) + ) + expect(clientInstances).toHaveLength(0) + }) + it('falls back to ssh2 when the GSSAPI-first system SSH attempt fails', async () => { spawnSystemSshCommandMock.mockImplementation(() => createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,publickey)') diff --git a/src/main/ssh/ssh-g-config-resolution.test.ts b/src/main/ssh/ssh-g-config-resolution.test.ts new file mode 100644 index 00000000000..4f21c896376 --- /dev/null +++ b/src/main/ssh/ssh-g-config-resolution.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, homedirMock, userInfoMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn<(path: string) => boolean>(() => true), + homedirMock: vi.fn<() => string>(() => '/home/env'), + userInfoMock: vi.fn<() => { homedir: string }>(() => ({ homedir: '/home/env' })) +})) + +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) +vi.mock('node:os', () => ({ homedir: homedirMock, userInfo: userInfoMock })) + +import { sshGArgsForHost } from './ssh-g-config-resolution' + +describe('sshGArgsForHost', () => { + beforeEach(() => { + existsSyncMock.mockReset().mockReturnValue(true) + homedirMock.mockReset().mockReturnValue('/home/env') + userInfoMock.mockReset().mockReturnValue({ homedir: '/home/env' }) + }) + + it('keeps OpenSSH default resolution when HOME matches the passwd home', () => { + // Why: the default search still reads /etc/ssh/ssh_config, which the + // SshResolvedConfig.gssapiAuthentication contract depends on. + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('pins the HOME config when it diverges from the passwd home', () => { + homedirMock.mockReturnValue('/tmp/e2e-home') + + expect(sshGArgsForHost('prod')).toEqual(['-F', '/tmp/e2e-home/.ssh/config', '-G', '--', 'prod']) + expect(existsSyncMock).toHaveBeenCalledWith('/tmp/e2e-home/.ssh/config') + }) + + it('falls back to passwd-home resolution when the HOME config is absent', () => { + // Why: ssh exits 255 on a missing -F file, so a divergent HOME without a + // config must not pin one. The picker lists nothing here, so the wider + // passwd-home resolution never mints a target. + homedirMock.mockReturnValue('/tmp/e2e-home') + existsSyncMock.mockReturnValue(false) + + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('treats an unavailable passwd entry as a HOME match', () => { + userInfoMock.mockImplementation(() => { + throw new Error('getpwuid failed') + }) + + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('does not let a leading-dash alias become a flag', () => { + homedirMock.mockReturnValue('/tmp/e2e-home') + + expect(sshGArgsForHost('-oProxyCommand=touch /tmp/pwned')).toEqual([ + '-F', + '/tmp/e2e-home/.ssh/config', + '-G', + '--', + '-oProxyCommand=touch /tmp/pwned' + ]) + }) +}) diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index 29c6d5eaae3..e329806ef3c 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -1,4 +1,7 @@ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir, userInfo } from 'node:os' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' export type SshResolvedConfig = { @@ -9,7 +12,10 @@ export type SshResolvedConfig = { identityAgent?: string identitiesOnly: boolean forwardAgent: boolean - /** Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults. */ + /** + * Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults — except on the + * HOME-divergent `-F` path (see sshGArgsForHost), where OpenSSH skips the system config. + */ gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass: boolean @@ -21,6 +27,31 @@ export type SshResolvedConfig = { const SSH_G_TIMEOUT_MS = 5000 +function passwdHomeSshConfigPath(): string { + try { + return join(userInfo().homedir, '.ssh', 'config') + } catch { + return join(homedir(), '.ssh', 'config') + } +} + +export function sshGArgsForHost(host: string): string[] { + // Why: OpenSSH resolves the default user config via getpwuid, not $HOME. + // Node's loadUserSshConfig uses os.homedir() (HOME-aware). When those differ + // (E2E HOME isolation, sandboxes), pass -F so resolve sees the same file the + // picker listed. Leave the default path alone when HOME matches passwd home + // so /etc/ssh/ssh_config still participates. + // -F also suppresses /etc/ssh/ssh_config, so that path resolves user-only. + // existsSync is load-bearing: ssh exits 255 on a missing -F file. Without a HOME + // config we fall back to passwd-home resolution, whose aliases the picker cannot + // list — resolveUserSshConfigHost rejects those before trusting ssh -G. + const homeConfigPath = join(homedir(), '.ssh', 'config') + if (existsSync(homeConfigPath) && homeConfigPath !== passwdHomeSshConfigPath()) { + return ['-F', homeConfigPath, '-G', '--', host] + } + return ['-G', '--', host] +} + // Why: `ssh -G ` asks OpenSSH for the effective config, including // Include/Match/wildcard inheritance, without reimplementing OpenSSH matching. export function resolveWithSshG(host: string): Promise { @@ -48,13 +79,18 @@ export function resolveWithSshG(host: string): Promise // Why: '--' prevents host labels starting with '-' from becoming SSH flags. // execFile's timeout only signals ssh; keep the null fallback for stuck callbacks. try { - child = execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { - if (err) { - settle(() => resolve(null)) - return + child = execFile( + 'ssh', + sshGArgsForHost(host), + { timeout: SSH_G_TIMEOUT_MS }, + (err, stdout) => { + if (err) { + settle(() => resolve(null)) + return + } + settle(() => resolve(parseSshGOutput(stdout))) } - settle(() => resolve(parseSshGOutput(stdout))) - }) + ) } catch { settle(() => resolve(null)) } diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts index 693e754b661..32942facd27 100644 --- a/src/main/ssh/ssh-multi-key-authentication.test.ts +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -127,6 +127,29 @@ describe('ordered SSH private-key authentication', () => { expect(unresolvedImport.authHandler).toBeUndefined() }) + it('offers every resolved key for a manually owned config-picker target', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'manual', + configHost: 'prod', + host: 'prod.internal', + identityFile: undefined + }), + makeResolved(), + { includeAgent: false, includePrivateKey: true } + ) + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/authorized-second') + }) + }) + it('resets ordered authentication for credential retries without extra key reads', () => { const config = buildConnectConfig(makeTarget(), makeResolved(), { includeAgent: false, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d413f45f044..c8440f6c239 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -15,6 +15,7 @@ import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notific import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' +import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' @@ -86,6 +87,11 @@ import { parseRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, + type SshAiVaultRelayListParams +} from '../../shared/ssh-ai-vault-relay' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { @@ -272,6 +278,7 @@ export class SshRelaySession { private currentConnection: SshConnection | null = null private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null + private aiVaultListMethodSupported: boolean | null = null private pendingPtyReattaches = new Map() private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget() private activePtyProviderGeneration: number | null = null @@ -377,6 +384,33 @@ export class SshRelaySession { } } + async requestAiVaultSessionList( + params: SshAiVaultRelayListParams, + options: { signal?: AbortSignal; timeoutMs?: number } = {} + ): Promise { + if (this.aiVaultListMethodSupported === false) { + return null + } + const mux = this.mux + if (!mux || mux.isDisposed() || this._state !== 'ready') { + throw new Error('SSH relay is not ready') + } + try { + const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS + }) + this.aiVaultListMethodSupported = true + return result + } catch (error) { + if (isMethodNotFoundError(error)) { + this.aiVaultListMethodSupported = false + return null + } + throw error + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -395,6 +429,7 @@ export class SshRelaySession { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' + this.aiVaultListMethodSupported = null this.currentConnection = conn try { @@ -528,6 +563,7 @@ export class SshRelaySession { this.abortController = abortController this._state = 'reconnecting' + this.aiVaultListMethodSupported = null this.currentConnection = conn // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 5c21ffa231e..3d435ee9477 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -540,6 +540,38 @@ describe('discoverCommitMessageModelsLocal', () => { } }) + it('releases the Codex home after a discovery timeout once the child exits', async () => { + vi.useFakeTimers() + const firstChild = createMockDiscoveryChild() + const secondChild = createMockDiscoveryChild() + spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) + const env = { CODEX_HOME: '/managed/codex-discovery-descendant-home' } + + try { + const first = discoverCommitMessageModelsLocal('codex', env) + await vi.advanceTimersByTimeAsync(0) + const second = discoverCommitMessageModelsLocal('codex', env) + await vi.advanceTimersByTimeAsync(60_000) + await expect(first).resolves.toMatchObject({ success: false }) + expect(spawnMock).toHaveBeenCalledTimes(1) + + // A grandchild kept the inherited stdout open, so the killed child reports + // 'exit' and 'close' never arrives. + firstChild.emit('exit', null, 'SIGKILL') + await vi.advanceTimersByTimeAsync(0) + expect(spawnMock).toHaveBeenCalledTimes(2) + + secondChild.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ models: [{ slug: 'gpt-5.5', display_name: 'GPT-5.5' }] })) + ) + secondChild.emit('close', 0) + await expect(second).resolves.toMatchObject({ success: true, defaultModelId: 'gpt-5.5' }) + } finally { + vi.useRealTimers() + } + }) + it('settles and detaches model discovery when output exceeds the limit', async () => { const child = createMockDiscoveryChild() spawnMock.mockReturnValue(child as never) @@ -1692,6 +1724,39 @@ describe('generateCommitMessageFromContext', () => { await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) }) + it('releases the Codex home lock when the child exits with a descendant holding its stdio', async () => { + const firstChild = createMockDiscoveryChild() + const secondChild = createMockDiscoveryChild() + spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) + const env = { CODEX_HOME: '/managed/codex-descendant-home' } + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex' as const, model: 'gpt-5.5' } + + const first = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/descendant-repo', + env + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + cancelGenerateCommitMessageLocal('/descendant-repo') + await expect(first).resolves.toMatchObject({ canceled: true }) + expectChildTerminated(firstChild) + + // SIGKILL reaches the codex process but not a grandchild that inherited its + // stdout, so 'exit' arrives and 'close' never does. + firstChild.emit('exit', null, 'SIGKILL') + + const second = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/descendant-repo-2', + env + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + secondChild.stdout.emit('data', Buffer.from('Update README\n')) + secondChild.emit('close', 0) + await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) + }) + it('holds the Codex home lock until Windows tree termination and wrapper close', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 8410231dc40..98314f6975b 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -452,6 +452,10 @@ export async function discoverCommitMessageModelsLocal( if (agentId === 'codex') { // Result publication stays prompt while the home lock follows the // process lifetime after asynchronous timeout/output-limit kills. + // Why: 'close' also waits on descendants that inherited this child's + // stdio, so a surviving MCP helper would hold the home forever; at + // 'exit' the codex process is gone and can no longer rotate auth.json. + child.once('exit', markClosedAfterTermination) child.once('close', markClosedAfterTermination) } child.on('error', onError) @@ -604,7 +608,7 @@ function runLocalPlan( emptyResultName = 'message', operation: TextGenerationOperation = 'commit-message', wslDistro?: string, - holdHomeLockUntilClose = false + holdHomeLockUntilExit = false ): LocalProcessExecution { const { binary, args, stdinPayload, label } = plan let markProcessClosed!: () => void @@ -685,7 +689,7 @@ function runLocalPlan( if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { cancelTokensByLane.delete(laneKey) } - if (!holdHomeLockUntilClose) { + if (!holdHomeLockUntilExit) { markProcessClosed() } resolve(result) @@ -769,7 +773,11 @@ function runLocalPlan( } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) - if (holdHomeLockUntilClose) { + if (holdHomeLockUntilExit) { + // Why: 'close' also waits on descendants that inherited this child's + // stdio, so a surviving MCP helper would hold the home forever; at 'exit' + // the codex process is gone and can no longer rotate auth.json. + child.once('exit', markClosedAfterTermination) child.once('close', markClosedAfterTermination) } child.on('error', onError) @@ -801,7 +809,7 @@ function runLocalPlanForAgent( operation: TextGenerationOperation ): Promise { const start = ( - holdHomeLockUntilClose = false + holdHomeLockUntilExit = false ): LocalProcessExecution => runLocalPlan( plan, @@ -810,7 +818,7 @@ function runLocalPlanForAgent( emptyResultName, operation, target.wslDistro, - holdHomeLockUntilClose + holdHomeLockUntilExit ) if (agentId !== 'codex') { // Why: no extra promise hops here — cancellation timing for non-codex diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 91b2423c817..75f593b7f57 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -9,6 +9,7 @@ const { getTrackedLinuxPackageArtifactMock, recordUpdaterLifecycleMock, resolveLinuxPackageInstallInstructionsMock, + revalidateLinuxPackageForInstallMock, revealLinuxPackageMock, resetHandlers } = vi.hoisted(() => { @@ -42,6 +43,7 @@ const { getTrackedLinuxPackageArtifactMock: vi.fn(), recordUpdaterLifecycleMock: vi.fn(), resolveLinuxPackageInstallInstructionsMock: vi.fn(), + revalidateLinuxPackageForInstallMock: vi.fn(), revealLinuxPackageMock: vi.fn(), resetHandlers: () => updaterHandlers.clear() } @@ -82,6 +84,7 @@ vi.mock('./linux-package-update-recovery', () => ({ clearTrackedLinuxPackageArtifactForOtherVersion: vi.fn(), getTrackedLinuxPackageArtifact: getTrackedLinuxPackageArtifactMock, resolveLinuxPackageInstallInstructions: resolveLinuxPackageInstallInstructionsMock, + revalidateLinuxPackageForInstall: revalidateLinuxPackageForInstallMock, revealLinuxPackage: revealLinuxPackageMock })) @@ -113,6 +116,7 @@ describe('linux package recovery actions', () => { resolveLinuxPackageInstallInstructionsMock .mockReset() .mockResolvedValue({ ok: true, command: "sudo apt install -- ''", packageFileName: 'p' }) + revalidateLinuxPackageForInstallMock.mockReset().mockResolvedValue({ ok: true }) revealLinuxPackageMock.mockReset().mockResolvedValue({ ok: true }) }) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 35a8c4c45bc..a0ab479343d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,6 +1,53 @@ /* eslint-disable max-lines */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as UpdaterModule from './updater' +import type * as RecoveryModule from './linux-package-update-recovery' +import type { UpdateStatus } from '../shared/types' + +type RevalidationVerdict = Awaited< + ReturnType +> + +// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv. +const realSetTimeout = globalThis.setTimeout + +type StagedLinuxPackages = { + cacheRoot: string + debPath: string + debSha512: string + rpmPath: string + rpmSha512: string +} + +/** + * Stages real packages inside a real updater cache: every install re-proves the retained digest by + * streaming the file off disk, so a path that never existed would abort before reaching the native + * updater. Returns the actual digests for the download events. + */ +function stageLinuxUpdateCache(): StagedLinuxPackages { + const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-')) + const pendingDir = join(cacheRoot, 'orca-updater', 'pending') + mkdirSync(pendingDir, { recursive: true }) + const stagePackage = (fileName: string): { path: string; sha512: string } => { + const packagePath = join(pendingDir, fileName) + const bytes = Buffer.from(`orca test package ${fileName}`) + writeFileSync(packagePath, bytes) + return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') } + } + const deb = stagePackage('orca-ide_1.0.61_amd64.deb') + const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm') + return { + cacheRoot, + debPath: deb.path, + debSha512: deb.sha512, + rpmPath: rpm.path, + rpmSha512: rpm.sha512 + } +} const { appMock, @@ -1780,8 +1827,12 @@ describe('updater', () => { 'updater:status', expect.objectContaining({ state: 'error', - // Why: a pre-commit install failure is not fixed by restarting, so the copy must not suggest it. - message: 'Could not start the update installer. Orca remains open.' + // Why: a pre-commit install failure is not fixed by restarting, so the copy must not + // suggest it — except on macOS, where quitting does re-stage a Squirrel update. + message: + process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' }) ) }) @@ -3794,29 +3845,93 @@ describe('updater', () => { }) describe('linux root package install recovery', () => { - // Real 64-byte SHA-512 values; capture rejects a digest that cannot decode to one. - const DEB_SHA512 = - 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' - const RPM_SHA512 = - 'W2U3AUPfVpc0Ia1qX/VJ5+8aW+yOFhysT6ryodo7A6DTZKqL6RYLK53U6ShGx+9f4lb35qtd4tOp5jzJZZdAfQ==' + let staged: StagedLinuxPackages + let EXIT_127: string + + // Why: the pre-install digest re-proof streams the package off real disk. Fake timers never + // advance libuv, so the quit timer needs fake time while the read needs real event-loop turns. + const settleQuitAndInstall = async (): Promise => { + await vi.advanceTimersByTimeAsync(100) + for (let turn = 0; turn < 40; turn += 1) { + await new Promise((resolve) => realSetTimeout(resolve, 0)) + } + await vi.advanceTimersByTimeAsync(0) + } + + beforeEach(() => { + staged = stageLinuxUpdateCache() + vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot) + EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127` + }) + + afterEach(() => { + vi.doUnmock('./linux-package-update-recovery') + vi.unstubAllEnvs() + rmSync(staged.cacheRoot, { recursive: true, force: true }) + }) + + /** + * Holds the pre-install re-proof open so a verdict can be delivered at an exact point in the + * cycle. Only that call is replaced — the artifact state stays real. Must precede startUpdater. + */ + const holdRevalidation = (): { + settle: (verdict: RevalidationVerdict) => void + fail: (error: Error) => void + } => { + let pending: { + resolve: (verdict: RevalidationVerdict) => void + reject: (error: Error) => void + } | null = null + vi.doMock('./linux-package-update-recovery', async () => { + const actual = await vi.importActual( + './linux-package-update-recovery' + ) + return { + ...actual, + revalidateLinuxPackageForInstall: vi.fn( + () => + new Promise((resolve, reject) => { + pending = { resolve, reject } + }) + ) + } + }) + return { + settle: (verdict) => { + pending?.resolve(verdict) + pending = null + }, + fail: (error) => { + pending?.reject(error) + pending = null + } + } + } + + const lastStatus = (send: ReturnType): UpdateStatus | undefined => + send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + // Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. const PRE_COMMIT_FAILURE_MESSAGE = process.platform === 'darwin' ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' : 'Could not start the update installer. Orca remains open.' - const DEB_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb' - const RPM_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide-1.0.61.x86_64.rpm' const AGENT_STDERR = 'pkexec: Error executing command as another user: No authentication agent found.' - const EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${DEB_PATH}, exited with code 127` const downloadedEvent = (overrides?: Record): Record => ({ version: '1.0.61', - downloadedFile: DEB_PATH, - files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: DEB_SHA512 }], + downloadedFile: staged.debPath, + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }], ...overrides }) + const rpmDownloadedEvent = (): Record => + downloadedEvent({ + downloadedFile: staged.rpmPath, + files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }] + }) + const startUpdater = async ( packageType: 'deb' | 'rpm' | null ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> => { @@ -3902,12 +4017,12 @@ describe('updater', () => { const { send, updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`) + autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) throw new Error(EXIT_127) }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() // Why: the sync throw ends capture before the catch, so the stashed text must survive. expect(send).toHaveBeenCalledWith('updater:status', { @@ -3926,19 +4041,13 @@ describe('updater', () => { const openWindow = { removeAllListeners: vi.fn() } browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never) const { send, updater } = await startUpdater('rpm') - await reachDownloaded( - updater, - downloadedEvent({ - downloadedFile: RPM_PATH, - files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: RPM_SHA512 }] - }) - ) + await reachDownloaded(updater, rpmDownloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1')) }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', @@ -3968,7 +4077,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', @@ -3992,7 +4101,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() expect(send).toHaveBeenCalledWith('updater:status', { @@ -4012,7 +4121,7 @@ describe('updater', () => { send.mockClear() updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'post_commit_cleanup_failed', @@ -4030,7 +4139,7 @@ describe('updater', () => { await reachDownloaded(updater, downloadedEvent()) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(killAllPtyMock).toHaveBeenCalledTimes(1) send.mockClear() @@ -4048,9 +4157,9 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() // Why: a retry usually fails identically; a deduped status would strand the preload restart relay. expect( @@ -4069,16 +4178,205 @@ describe('updater', () => { }) }) - it('records classification-only lifecycle data for a package install failure', async () => { - const { updater } = await startUpdater('deb') + // Why: the cache path is user-writable, so the bytes verified when the recovery card + // rendered are not necessarily the bytes a root package manager would read on retry. + it('aborts the retry when the retained package no longer matches its digest', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + // The escalation fails, which is what puts the recovery card (and its retry) on screen. + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(EXIT_127)) + }) + updater.quitAndInstall() + await settleQuitAndInstall() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + + // A local process swaps the verified package for its own between failure and retry. + writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) + send.mockClear() + killAllPtyMock.mockClear() + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(updater.isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' + }) + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + // Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same + // user-writable package reaches a root installer with a far longer window than any retry. + it('aborts the first install when the downloaded package was swapped', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) + send.mockClear() + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(updater.isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' + }) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + it('installs normally when the retained package still matches its digest', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + // Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer + // prompt against the install that is already committed. + expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.anything(), + expect.anything() + ) + }) + + // Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled + // while it runs — a verdict from the old cycle must not replace the card that took over. + it('drops an abort verdict once a newer check replaced the card', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + // The user gives up waiting and checks again; that check owns the card from here. + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) + + revalidation.settle({ ok: false, reason: 'hash-mismatch' }) + await settleQuitAndInstall() + + // The install is still abandoned — only the stale status is withheld. + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) + // Withholding the status must not also withhold the abort: the renderer armed its restart and + // would otherwise skip its unsaved-work prompt for the rest of the session. + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + // Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim + // the package changed and the card must keep the actions that still work. + it('keeps the recovery card usable when the re-proof cannot read the package', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`) autoUpdaterMock.emit('error', new Error(EXIT_127)) }) + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: true }) + await settleQuitAndInstall() + send.mockClear() + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: false, reason: 'read-failed' }) + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(lastStatus(send)).toEqual({ + state: 'error', + message: + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'package-install-failed', + version: '1.0.61' + } + }) + }) + + // Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection + // there would strand the quit timer and make every later install a silent no-op. + it('stays installable after a re-proof that rejects outright', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.fail(new Error('hash worker crashed')) + await settleQuitAndInstall() + + // Fails closed: an unprovable package is not handed to a root package manager. + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(lastStatus(send)).toMatchObject({ + state: 'error', + message: + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.' + }) updater.quitAndInstall() await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: true }) + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + // Why: a second click during the multi-second hash must not schedule a parallel install. + it('ignores a second install request while the digest re-proof runs', async () => { + const { updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + // Fires the quit timer, which starts the hash; the read itself is still outstanding. + await vi.advanceTimersByTimeAsync(100) + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('records classification-only lifecycle data for a package install failure', async () => { + const { updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) + autoUpdaterMock.emit('error', new Error(EXIT_127)) + }) + + updater.quitAndInstall() + await settleQuitAndInstall() const failure = recordUpdaterLifecycleMock.mock.calls.find( ([event]) => event === 'linux_package_install_failed' @@ -4091,7 +4389,7 @@ describe('updater', () => { errorType: 'Error' }) const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls) - expect(durable).not.toContain(DEB_PATH) + expect(durable).not.toContain(staged.debPath) expect(durable).not.toContain('authentication agent') }) @@ -4103,7 +4401,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() const failure = recordUpdaterLifecycleMock.mock.calls.find( ([event]) => event === 'linux_package_install_failed' diff --git a/src/main/updater.ts b/src/main/updater.ts index 6bb26d84dd5..302591286f3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -45,7 +45,9 @@ import { clearTrackedLinuxPackageArtifact, getTrackedLinuxPackageArtifact, resolveLinuxPackageInstallInstructions, + revalidateLinuxPackageForInstall, revealLinuxPackage, + type LinuxPackageArtifact, type LinuxPackageRecoveryUnavailableReason } from './linux-package-update-recovery' import { @@ -124,6 +126,9 @@ let autoUpdateCheckTimer: ReturnType | null = null let nudgeCheckTimer: ReturnType | null = null let pendingQuitAndInstallTimer: ReturnType | null = null let quitAndInstallInProgress = false +// Why: the pre-install digest re-proof streams the whole package, so a second install request can +// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. +let linuxPackageRevalidationInFlight = false let updateInstallMode: UpdateInstallMode = 'interactive' let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. @@ -693,7 +698,7 @@ function clearPrereleaseFallbackContextIfSettled(): void { } async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress) { + if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) return } @@ -707,6 +712,17 @@ async function performQuitAndInstall(): Promise { if (deferHeadlessServeInstall('install', pendingVersion)) { return } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } quitAndInstallInProgress = true markMacQuitAndInstallInFlight() @@ -1801,6 +1817,108 @@ function failLinuxPackageRecovery( throw new Error(message) } +/** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ +function getInstallCycleSignature(): string { + const recovery = getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return currentStatus.state === 'downloaded' + ? `downloaded:${currentStatus.version}` + : `state:${currentStatus.state}` +} + +/** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ +async function proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = getActiveLinuxPackageRecovery() + const cycle = getInstallCycleSignature() + const reason = await revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false +} + +/** The failing reason, or null when the retained package still matches its release digest. */ +async function revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact +): Promise { + linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + linuxPackageRevalidationInFlight = false + } +} + +function reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle +}: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string +}): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (getInstallCycleSignature() !== cycle) { + return + } + sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) +} + export async function getLinuxPackageInstallInstructions(): Promise { const recovery = getActiveLinuxPackageRecovery() if (!recovery) { @@ -1849,7 +1967,10 @@ export function quitAndInstall(): void { localBuildSelectionInProgress || pinnedBuildSelectionInProgress || pendingQuitAndInstallTimer || - quitAndInstallInProgress + quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so + // without this a second click would schedule a parallel install of the same package. + linuxPackageRevalidationInFlight ) { return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0675e398d47..c0c57731e1e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -49,11 +49,15 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, SshConfigImportResult, SshTargetAddResult, SshTarget, @@ -890,6 +894,7 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + cancelListSessions: (args: { requestToken: string }) => Promise prepareSessionResume: ( args: AiVaultPrepareSessionResumeArgs ) => Promise @@ -986,6 +991,9 @@ export type AppApi = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial }) => void + /** Resolves once the last staged checkpoint is durably written; rejects if that + * write failed, so a reload/restart can abort instead of losing the snapshot. */ + awaitBeforeUnloadCheckpoint: () => Promise /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise @@ -2122,6 +2130,8 @@ export type PreloadApi = { args: GitLabRepoSelectorArgs & { jobId: number projectRef?: GitLabProjectRef | null + /** Bound the trace in main to a readable excerpt (see gitLabJobTraceToLogExcerpt). */ + logExcerpt?: boolean } ) => Promise retryJob: ( @@ -3424,6 +3434,8 @@ export type PreloadApi = { }) => Promise removeTarget: (args: { id: string }) => Promise importConfig: (args?: { reAdopt?: boolean }) => Promise + listConfigHosts: (args?: SshConfigHostListArgs) => Promise + resolveConfigHost: (args: { alias: string }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise @@ -3639,7 +3651,11 @@ export type PreloadApi = { { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } > openWindowsNetworkSettings: () => Promise - getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise< + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + reach?: RuntimePairingReach + }) => Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { available: true diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts new file mode 100644 index 00000000000..9f64569e3ca --- /dev/null +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' +import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT +} from '../shared/updater-renderer-events' + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('native preload destructive app actions', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + let eventTarget: EventTarget + + beforeEach(() => { + vi.resetModules() + exposeInMainWorld.mockReset() + invoke.mockReset() + on.mockReset() + removeListener.mockReset() + send.mockReset() + sendSync.mockReset() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + eventTarget = new EventTarget() + vi.stubGlobal('window', eventTarget) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + const loadApi = async (): Promise => { + await import('./index') + return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + } + + for (const action of ['reload', 'relaunch'] as const) { + it(`prepares and awaits durability before ${action}`, async () => { + const api = await loadApi() + const calls: string[] = [] + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, () => calls.push('prepared')) + invoke.mockImplementation(async (channel: string) => { + calls.push(channel) + return channel === 'app:await-before-unload-checkpoint' ? { ok: true } : undefined + }) + + await api.app[action]() + + expect(calls).toEqual(['prepared', 'app:await-before-unload-checkpoint', `app:${action}`]) + }) + + it(`refuses ${action} when the durable checkpoint fails`, async () => { + const api = await loadApi() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + invoke.mockImplementation(async (channel: string) => + channel === 'app:await-before-unload-checkpoint' ? { ok: false } : undefined + ) + + await expect(api.app[action]()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + + expect(invoke).not.toHaveBeenCalledWith(`app:${action}`) + expect(aborted).toHaveBeenCalledTimes(1) + }) + } +}) diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 28c6f763e0f..8fbe1105af8 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -154,7 +154,7 @@ export const glApi = { ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), retryJob: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index aaeb5403890..d54af396aca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,11 +22,15 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, SshConfigImportResult, SshTargetAddResult, SshTarget, @@ -238,11 +242,7 @@ import type { } from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' -import { - ORCA_APP_RESTART_ABORTED_EVENT, - ORCA_APP_RESTART_STARTED_EVENT, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -} from '../shared/updater-renderer-events' +import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -278,15 +278,24 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' +import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' import { - createUpdaterQuitAbortRelay, - prepareRendererForAppRestart -} from '../shared/renderer-restart-preparation' -import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' +// Why: the sync checkpoint only stages; this joins its durable write so a +// navigating path can abort instead of losing the staged session. +async function awaitBeforeUnloadCheckpoint(): Promise { + const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + ok?: unknown + } + if (result?.ok !== true) { + throw new Error('Failed to persist renderer state before unload.') + } +} + type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] @@ -472,20 +481,24 @@ const api = { getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), - relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), - restart: async (): Promise => { - await prepareRendererForAppRestart(window, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT - }) - try { - return await ipcRenderer.invoke('app:restart') - } catch (error) { - window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) - throw error - } - }, - reload: (): Promise => ipcRenderer.invoke('app:reload'), + relaunch: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:relaunch'), + awaitBeforeUnloadCheckpoint + ), + restart: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:restart'), + awaitBeforeUnloadCheckpoint + ), + reload: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:reload'), + awaitBeforeUnloadCheckpoint + ), stageBeforeUnloadSync: (args: Parameters[0]) => { const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown @@ -2971,8 +2984,11 @@ const api = { showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), quitAndInstall: (): Promise => - prepareAndInvokeUpdaterInstall(window, updaterQuitAbortRelay, () => - ipcRenderer.invoke('updater:quitAndInstall') + prepareAndInvokeUpdaterInstall( + window, + updaterQuitAbortRelay, + () => ipcRenderer.invoke('updater:quitAndInstall'), + awaitBeforeUnloadCheckpoint ), onStatus: (callback) => { @@ -4164,6 +4180,8 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => ipcRenderer.invoke('aiVault:listSessions', args), + cancelListSessions: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('aiVault:cancelListSessions', args), prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => @@ -4410,6 +4428,12 @@ const api = { importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), + listConfigHosts: (args?: SshConfigHostListArgs): Promise => + ipcRenderer.invoke('ssh:listConfigHosts', args), + + resolveConfigHost: (args: { alias: string }): Promise => + ipcRenderer.invoke('ssh:resolveConfigHost', args), + connect: async (args: { targetId: string }): Promise => { const state: unknown = await ipcRenderer.invoke('ssh:connect', args) return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null @@ -4628,6 +4652,9 @@ const api = { getRuntimePairingUrl: (args?: { address?: string rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach }): Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 56cd5d3dd9e..bd896109b57 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,16 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' -import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT +} from '../shared/updater-renderer-events' import { prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' describe('renderer restart wiring', () => { - it('relays updater status and prevented unload events', () => { + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() + const restartAborted = vi.fn() const handleStatus = vi.fn() + const abort = vi.fn() const listeners = new Map void>() const ipcRenderer = { on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { @@ -19,14 +24,19 @@ describe('renderer restart wiring', () => { }) } as unknown as Parameters[0] eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus }) + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + // Why: main abandons an install without any status when its verdict outlived the cycle. + listeners.get('updater:quitAndInstallAborted')?.({}) listeners.get('window:unload-prevented')?.({}) - expect(ipcRenderer.on).toHaveBeenCalledTimes(2) + expect(ipcRenderer.on).toHaveBeenCalledTimes(3) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) + expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) + expect(restartAborted).toHaveBeenCalledTimes(1) }) it('marks preparation before invoking main and aborts on IPC failure', async () => { @@ -44,10 +54,27 @@ describe('renderer restart wiring', () => { throw new Error('IPC failed') }) - await expect(prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke)).rejects.toThrow( - 'IPC failed' - ) + await expect( + prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke, async () => { + calls.push('checkpoint-flushed') + }) + ).rejects.toThrow('IPC failed') - expect(calls).toEqual(['prepared', 'marked', 'invoked', 'aborted']) + expect(calls).toEqual(['prepared', 'checkpoint-flushed', 'marked', 'invoked', 'aborted']) + }) + + it('never installs the update when the shutdown checkpoint fails to persist', async () => { + const eventTarget = new EventTarget() + const invoke = vi.fn(() => Promise.resolve()) + const relay = { markPrepared: vi.fn(), abort: vi.fn() } + + await expect( + prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke, () => + Promise.reject(new Error('Failed to persist renderer state before unload.')) + ) + ).rejects.toThrow('Failed to persist renderer state before unload.') + + expect(invoke).not.toHaveBeenCalled() + expect(relay.markPrepared).not.toHaveBeenCalled() }) }) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index fbd3e26a71d..0a177dd55b0 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -6,6 +6,8 @@ import { } from '../shared/renderer-restart-preparation' import type { UpdateStatus } from '../shared/types' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -13,24 +15,31 @@ import { export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, - relay: Pick + relay: Pick ): void { ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => { relay.handleStatus(status) }) + // Why: main abandons some installs without an error status, and only this tells the renderer. + ipcRenderer.on('updater:quitAndInstallAborted', () => { + relay.abort() + }) ipcRenderer.on('window:unload-prevented', () => { eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) }) } export async function prepareAndInvokeUpdaterInstall( eventTarget: EventTarget, relay: Pick, - invoke: () => Promise + invoke: () => Promise, + awaitCheckpoint: () => Promise ): Promise { await prepareRendererForAppRestart(eventTarget, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, - abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + awaitCheckpoint }) relay.markPrepared() try { @@ -40,3 +49,21 @@ export async function prepareAndInvokeUpdaterInstall( throw error } } + +export async function prepareAndInvokeAppRestart( + eventTarget: EventTarget, + invoke: () => Promise, + awaitCheckpoint: () => Promise +): Promise { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) + try { + await invoke() + } catch (error) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + throw error + } +} diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts new file mode 100644 index 00000000000..6a04b5a0bd5 --- /dev/null +++ b/src/relay/ai-vault-handler.test.ts @@ -0,0 +1,260 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import { SSH_AI_VAULT_LIST_SESSIONS_METHOD } from '../shared/ssh-ai-vault-relay' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { AiVaultHandler } from './ai-vault-handler' + +type RequestHandler = (params: Record, context: RequestContext) => Promise + +const temporaryHomes: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('AiVaultHandler', () => { + it('discovers and parses sessions entirely on the relay host', async () => { + const remoteHome = await makeTemporaryHome() + const transcriptPath = join( + remoteHome, + '.codex', + 'sessions', + '2026', + '07', + '26', + 'rollout-test.jsonl' + ) + await mkdir(dirname(transcriptPath), { recursive: true }) + await writeFile( + transcriptPath, + [ + JSON.stringify({ + timestamp: '2026-07-26T01:00:00.000Z', + type: 'session_meta', + payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') } + }), + JSON.stringify({ + timestamp: '2026-07-26T01:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Scan on the SSH target' }] + } + }) + ].join('\n') + ) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome, + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 20 + })) as AiVaultListResult + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'local', + executionHostPlatform: 'linux', + sessionId: 'ssh-session', + title: 'Scan on the SSH target', + filePath: transcriptPath + }) + }) + + it('bounds relay scan parameters before touching the target filesystem', async () => { + const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + }) + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + + await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 50_000, + scopePaths + }) + + expect(scanRemoteSessions).toHaveBeenCalledWith( + expect.objectContaining({ + executionHostId: 'local', + limit: 1000, + scopePaths: scopePaths.slice(0, 64) + }) + ) + const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + scopePaths + })) as AiVaultListResult + expect(result.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + + it('forwards Unlimited without the relay numeric cap', async () => { + const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + }) + + await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 50_000, + unlimited: true + }) + + expect(scanRemoteSessions).toHaveBeenCalledWith( + expect.objectContaining({ limit: undefined, unlimited: true }) + ) + }) + + it('coalesces identical in-flight scans without coupling caller cancellation', async () => { + let resolveScan: ((result: AiVaultListResult) => void) | undefined + let sharedSignal: AbortSignal | undefined + const scanRemoteSessions = vi.fn( + (args: { signal?: AbortSignal }) => + new Promise((resolve) => { + sharedSignal = args.signal + resolveScan = resolve + }) + ) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions: scanRemoteSessions as never + }) + const firstController = new AbortController() + const first = dispatcher.call( + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + { limit: 20 }, + firstController.signal + ) + const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) + await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1)) + + firstController.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(sharedSignal?.aborted).toBe(false) + + resolveScan?.(emptyResult()) + await expect(second).resolves.toEqual(emptyResult()) + }) + + it('re-joins a preempted relay caller onto the forced refresh', async () => { + const signals: AbortSignal[] = [] + let resolveForced: ((result: AiVaultListResult) => void) | undefined + const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => { + signals.push(args.signal) + return new Promise((resolve) => { + if (signals.length === 1) { + args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true }) + } else { + resolveForced = resolve + } + }) + }) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions: scanRemoteSessions as never + }) + const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) + await vi.waitFor(() => expect(signals).toHaveLength(1)) + + const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 20, + force: true + }) + await vi.waitFor(() => expect(signals).toHaveLength(2)) + + expect(signals[0]?.aborted).toBe(true) + resolveForced?.(emptyResult()) + // The desktop caller that did not ask for a refresh still gets sessions. + await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()]) + }) + + it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + + expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow() + + expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) + } finally { + stderr.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('stops a relay-local scan when the owning request is cancelled', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + const controller = new AbortController() + controller.abort() + + await expect( + dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +async function makeTemporaryHome(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-')) + temporaryHomes.push(path) + return path +} + +function emptyResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' } +} + +function createMockDispatcher(): { + value: RelayDispatcher + call: (method: string, params: Record, signal?: AbortSignal) => Promise +} { + const handlers = new Map() + const value = { + onRequest(method: string, handler: RequestHandler) { + handlers.set(method, handler) + } + } as RelayDispatcher + return { + value, + call(method, params, signal) { + const handler = handlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params, { + clientId: 1, + isStale: () => signal?.aborted ?? false, + signal + }) + } + } +} diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts new file mode 100644 index 00000000000..80feae8ea8c --- /dev/null +++ b/src/relay/ai-vault-handler.ts @@ -0,0 +1,159 @@ +import { lstat, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { + SSH_AI_VAULT_LIST_LIMIT_MAX, + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH, + type SshAiVaultRelayListParams +} from '../shared/ssh-ai-vault-relay' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' +import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' +import { readRelayFileContent } from './fs-handler-file-read' +import { relayLogLine } from './relay-diagnostic-log' +import type { RelayDispatcher } from './dispatcher' +import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' + +type ScanRemoteSessions = typeof scanRemoteAiVaultSessions + +type AiVaultHandlerOptions = { + remoteHome?: string + hostPlatform?: RemoteHostPlatform + scanRemoteSessions?: ScanRemoteSessions +} + +export class AiVaultHandler { + private readonly remoteHome: string + private readonly scanRemoteSessions: ScanRemoteSessions + private readonly provider: RemoteSessionFilesystemProvider + private readonly scanCoordinator = new AiVaultScanCoordinator() + + constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { + this.remoteHome = options.remoteHome ?? homedir() + this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions + this.provider = createRelayAiVaultFilesystemProvider() + const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() + // Why: an OS/arch this build has no path flavor for must not abort relay + // startup — leaving the method unregistered soft-disables the feature and + // the host falls back to its own SSH filesystem scan. + if (!hostPlatform) { + relayLogLine( + `[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}` + ) + return + } + dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) => + this.listSessions(hostPlatform, params, context.signal) + ) + } + + private async listSessions( + hostPlatform: RemoteHostPlatform, + rawParams: Record, + signal?: AbortSignal + ): Promise { + const params = normalizeSshAiVaultRelayListParams(rawParams) + const result = await this.scanCoordinator.run({ + key: JSON.stringify({ + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + scopePathsTruncated: params.scopePathsTruncated + }), + force: params.force, + signal, + start: (scanSignal) => + this.scanRemoteSessions({ + provider: this.provider, + executionHostId: LOCAL_EXECUTION_HOST_ID, + remoteHome: this.remoteHome, + hostPlatform, + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + signal: scanSignal + }) + }) + if (!params.scopePathsTruncated) { + return result + } + return { + ...result, + issues: [ + ...result.issues, + { + executionHostId: LOCAL_EXECUTION_HOST_ID, + agent: 'codex', + kind: 'scope', + path: this.remoteHome, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } + ] + } + } +} + +export function normalizeSshAiVaultRelayListParams( + params: Record +): SshAiVaultRelayListParams { + const rawLimit = params.limit + const unlimited = params.unlimited === true + const limit = + !unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX) + : undefined + const scopePaths = Array.isArray(params.scopePaths) + ? params.scopePaths + .slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) + .filter( + (path): path is string => + typeof path === 'string' && + path.trim().length > 0 && + path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH + ) + : undefined + const scopePathsTruncated = + params.scopePathsTruncated === true || + (Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) + return { + ...(unlimited ? { unlimited: true } : {}), + ...(limit === undefined ? {} : { limit }), + ...(params.force === true ? { force: true } : {}), + ...(scopePaths === undefined ? {} : { scopePaths }), + ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) + } +} + +function currentRelayHostPlatform(): RemoteHostPlatform | null { + const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null +} + +function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { + return { + async readDir(dirPath) { + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + }, + readFile: readRelayFileContent, + async stat(filePath) { + const stats = await lstat(filePath) + return { + size: stats.size, + type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } + } + } +} diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 13267b5847d..6e8116158b6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -69,6 +69,7 @@ import { isAgentSessionSurfaceBinding, type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' +import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -689,11 +690,13 @@ export class PtyHandler { : {} ) } + const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(managed.pty)) managed.startupIngress ??= new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), - onEmission: emitIngressData + onEmission: emitIngressData, + ...(echoProbe ? { echoProbe } : {}) }) managed.pty.onData((data: string) => { const startup = managed.startupCommand diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 4bfc8bf3e92..d5003bdf649 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -32,6 +32,7 @@ import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' +import { AiVaultHandler } from './ai-vault-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import { PluginOverlayManager } from './plugin-overlay' import { @@ -663,6 +664,9 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + const _aiVaultHandler = new AiVaultHandler(dispatcher) + void _aiVaultHandler + // Why: relay-hosted plugin provisioning is a later phase. Register the // enforcement boundary now with no consented identities or runtime services. registerRelayPluginHostCallHandlers( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a57e7c90f53..9874acecd66 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -207,6 +207,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog' import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { @@ -215,7 +216,7 @@ import { } from './components/terminal/background-terminal-worktree-mount' import { collectTerminalProviderSnapshotPtyIds, - synchronizeTerminalProviderSnapshotCapabilities + refreshTerminalProviderSnapshotCapabilities } from './components/terminal/terminal-provider-snapshot-capability' import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers' @@ -660,6 +661,7 @@ function App(): React.JSX.Element { const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) @@ -1081,7 +1083,7 @@ function App(): React.JSX.Element { window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { - return synchronizeTerminalProviderSnapshotCapabilities( + return refreshTerminalProviderSnapshotCapabilities( collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) ) }) @@ -1170,6 +1172,9 @@ function App(): React.JSX.Element { try { await window.api.app.awaitFirstWindowStartupServices() await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + await refreshTerminalProviderSnapshotCapabilities( + collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + ) await actions.reconnectPersistedTerminals(abortController.signal) await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() } catch (reconnectErr) { @@ -1387,6 +1392,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, // Why (#9002): activeView is deliberately NOT included here. It used to @@ -1420,6 +1426,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey @@ -2786,6 +2793,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index bfe17737970..80bb0037eac 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -139,6 +139,10 @@ describe('renderer startup runtime routing', () => { 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', servicesIndex ) + const capabilityRefreshIndex = source.indexOf( + 'refreshTerminalProviderSnapshotCapabilities(', + preReconnectRecoveryIndex + ) const reconnectIndex = source.indexOf( 'actions.reconnectPersistedTerminals(abortController.signal)', preReconnectRecoveryIndex @@ -150,10 +154,40 @@ describe('renderer startup runtime routing', () => { expect(servicesIndex).toBeGreaterThanOrEqual(0) expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) - expect(reconnectIndex).toBeGreaterThan(preReconnectRecoveryIndex) + expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) + expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) expect(postReconnectRecoveryIndex).toBeGreaterThan(reconnectIndex) }) + it('refreshes terminal snapshot capability before degraded reconnect', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const degradedStart = source.indexOf( + '[startup] Workspace session hydration failed; leaving disk state untouched:' + ) + const servicesIndex = source.indexOf( + 'await window.api.app.awaitFirstWindowStartupServices()', + degradedStart + ) + const recoveryIndex = source.indexOf( + 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', + servicesIndex + ) + const capabilityRefreshIndex = source.indexOf( + 'refreshTerminalProviderSnapshotCapabilities(', + recoveryIndex + ) + const reconnectIndex = source.indexOf( + 'actions.reconnectPersistedTerminals(abortController.signal)', + recoveryIndex + ) + + expect(degradedStart).toBeGreaterThanOrEqual(0) + expect(servicesIndex).toBeGreaterThan(degradedStart) + expect(recoveryIndex).toBeGreaterThan(servicesIndex) + expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) + expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) + }) + it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { const source = readFileSync( join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 315dd34c67a..85d6a2c116a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1455,6 +1455,26 @@ html.native-shell .app-layout { grid-template-rows: 1fr; } +/* Why: must stay a compositor-driven CSS animation — a JS clock writing + per-element transforms blocks the renderer input thread (STA-3328: 41 + spinners ⇒ ~490 style writes/s, keystroke p90 363ms). The component anchors + each animation's startTime once; steps(12) preserves the retired cadence. */ +@keyframes agent-spinner-rotate { + to { + transform: rotate(360deg); + } +} + +.agent-working-spinner { + animation: agent-spinner-rotate 1s steps(12, end) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .agent-working-spinner { + animation: none; + } +} + /* Why: keyframes run when the content mounts already in its final state. */ @keyframes compact-agent-expansion-reveal { from { @@ -1628,6 +1648,30 @@ html.native-shell .app-layout { animation: settings-shell-enter 180ms ease-out; } +.repo-icon-emoji-picker .EmojiPickerReact { + --epr-bg-color: var(--card); + --epr-category-label-bg-color: var(--card); + --epr-text-color: var(--card-foreground); + --epr-search-input-bg-color: var(--input); + --epr-search-input-bg-color-active: var(--input); + --epr-search-border-color: var(--border); + --epr-search-border-color-active: var(--ring); + --epr-hover-bg-color: var(--accent); + --epr-highlight-color: var(--ring); + --epr-picker-border-color: var(--border); + border: none; +} + +/* The library sets `font-family: sans-serif` on every descendant, so inheriting from the root never lands. */ +.repo-icon-emoji-picker .EmojiPickerReact * { + font-family: var(--font-sans); +} + +.repo-icon-emoji-picker .epr-body { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 34%, transparent) transparent; +} + .collapsible-height-content { overflow: hidden; } diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 72fbc6223a6..b721c3d8a01 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -22,15 +22,13 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - // Why: rotation comes from the shared agent-spinner clock (which also - // honors prefers-reduced-motion), not a per-element CSS animation that - // would keep the compositor awake. + // Why: rotation must come from the compositor-driven CSS animation, not a + // JS clock writing per-element styles on the input thread (STA-3328). + expect(markup).toContain('agent-working-spinner') expect(markup).toContain('data-agent-spinner') // Why: under reduced motion the top border is filled so the static ring // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') - expect(markup).not.toContain('animate-spin') - expect(markup).not.toContain('animation:spin') }) it('renders done as an emerald check icon', () => { diff --git a/src/renderer/src/components/AgentWorkingSpinner.test.tsx b/src/renderer/src/components/AgentWorkingSpinner.test.tsx new file mode 100644 index 00000000000..25c04f39783 --- /dev/null +++ b/src/renderer/src/components/AgentWorkingSpinner.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AgentWorkingSpinner } from './AgentWorkingSpinner' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +describe('AgentWorkingSpinner', () => { + it('hooks the compositor-driven CSS animation, not a JS clock', () => { + const markup = renderToStaticMarkup(React.createElement(AgentWorkingSpinner)) + + expect(markup).toContain('agent-working-spinner') + expect(markup).toContain('data-agent-spinner') + expect(markup).toContain('border-yellow-500') + expect(markup).toContain('border-t-transparent') + expect(markup).toContain('motion-reduce:border-t-yellow-500') + }) + + it('renders when the Web Animations API is unavailable', async () => { + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: undefined + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + await act(async () => { + root.render() + }) + expect(container.querySelector('[data-agent-spinner]')).not.toBeNull() + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + it('anchors mount and restart animations to the shared document epoch', async () => { + const animation = { + animationName: 'agent-spinner-rotate', + startTime: 321 + } as unknown as Animation + const getAnimations = vi.fn(() => [animation]) + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: getAnimations + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + await act(async () => { + root.render() + }) + expect(animation.startTime).toBe(0) + + animation.startTime = 654 + const restart = new Event('animationstart', { bubbles: true }) + Object.defineProperty(restart, 'animationName', { value: 'agent-spinner-rotate' }) + await act(async () => { + container.querySelector('[data-agent-spinner]')!.dispatchEvent(restart) + }) + expect(animation.startTime).toBe(0) + expect(getAnimations).toHaveBeenCalledTimes(2) + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + // Why: the class only spins if main.css defines it — pin the wiring across + // both files so neither side can be renamed or dropped alone (STA-3328 + // regressed typing latency when rotation moved onto the input thread). + it('is backed by a steps(12) keyframe animation in main.css', () => { + const css = readFileSync(join(__dirname, '../assets/main.css'), 'utf8') + + const rule = css.match(/\.agent-working-spinner\s*\{[^}]*\}/)?.[0] + expect(rule).toBeDefined() + expect(rule).toContain('animation: agent-spinner-rotate 1s steps(12, end) infinite') + expect(css).toContain('@keyframes agent-spinner-rotate') + + const reducedMotionBlock = css.match( + /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.agent-working-spinner\s*\{[^}]*\}/ + )?.[0] + expect(reducedMotionBlock).toContain('animation: none') + }) +}) diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx index 7e3d3302a7e..9b20a862b09 100644 --- a/src/renderer/src/components/AgentWorkingSpinner.tsx +++ b/src/renderer/src/components/AgentWorkingSpinner.tsx @@ -1,21 +1,46 @@ import React from 'react' import { cn } from '@/lib/utils' -import { agentSpinnerRef } from '@/lib/agent-spinner-clock' -// Why: the working-state ring must not carry its own infinite CSS animation — -// that keeps the compositor awake per element for the whole agent run. The -// shared agent-spinner clock rotates every mounted ring in phase and stops -// cleanly when nothing can be seen. Callers size it via className (size-2 etc.). +const SPINNER_ANIMATION_NAME = 'agent-spinner-rotate' + +// Why: CSS assigns per-element start times after refs run; anchoring the Web +// Animation timeline gives late mounts exact phase sync without recurring JS. +function syncSpinnerPhase(el: HTMLSpanElement | null): void { + if (el === null || typeof el.getAnimations !== 'function') { + return + } + + const animation = el + .getAnimations() + .find( + (candidate) => + 'animationName' in candidate && candidate.animationName === SPINNER_ANIMATION_NAME + ) + if (animation !== undefined) { + animation.startTime = 0 + } +} + +function handleSpinnerAnimationStart(event: React.AnimationEvent): void { + if (event.animationName === SPINNER_ANIMATION_NAME) { + syncSpinnerPhase(event.currentTarget) + } +} + +// Why: the working-state ring animates via CSS (.agent-working-spinner in +// main.css) so rotation runs on the compositor and never touches the input +// thread. Callers size it via className (size-2 etc.). export function AgentWorkingSpinner({ className }: { className?: string }): React.JSX.Element { return ( diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 5e2ef2a6398..919b908f375 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -133,6 +133,11 @@ import { getCommentReplyTargetCandidates, resolveCommentReplyTarget } from '@/components/comment-reply-target-state' +import { + attachPRReviewReplyParent, + canPostPRReviewThreadReply +} from '@/components/right-sidebar/pr-comments-ai-launch-ack' +import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -2125,29 +2130,32 @@ function ConversationTab({ ) return false } - const result = - comment.path && item.type === 'pr' - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - body: `@${comment.author} ${replyBody}`, - type: item.type, - prRepo - }) + // Why: nest under review threads (path/threadId/discussion_r); never post a + // separate top-level conversation comment for those. + const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) + const result = isReviewThreadReply + ? await addPRReviewCommentReplyForRepo({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + prNumber: item.number, + prRepo, + commentId: comment.id, + body: replyBody, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }) + : await addIssueCommentForRepo({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + number: item.number, + // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. + body: buildPRCommentConversationReplyBody(comment.author, replyBody), + type: item.type, + prRepo + }) if (!result.ok) { toast.error( @@ -2156,7 +2164,9 @@ function ConversationTab({ ) return false } - onCommentAdded(result.comment) + onCommentAdded( + isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment + ) setReplyingTo(null) toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) return true diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx index b42844611b4..e0685b55fbe 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx @@ -484,6 +484,9 @@ describe('LinuxPackageInstallRecoveryCard retry', () => { fireEvent.click(button('Try Automatic Install Again')) await flushActions() + // Why: the retry now re-proves the package digest before quitting, so the click must report + // progress instead of leaving three inert buttons for the length of the hash. + expect(isAriaDisabled(button('Checking package...'))).toBe(true) // Why: quitAndInstall resolves as soon as main schedules the install, so a resolved promise is // not an outcome — the slot stays held until a real status arrives. expect(isAriaDisabled(button('Copy Install Command'))).toBe(true) diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx index 96d6b3cd5ee..3d89b7ee6b7 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx @@ -198,6 +198,10 @@ export function LinuxPackageInstallRecoveryCard({ 'auto.components.LinuxPackageInstallRecoveryCard.3da99454c6', 'Try Automatic Install Again' ), + // Why: the retry re-proves the package digest before it quits, so the click is no longer + // instant — without this the card would just go inert for the length of the hash. + pendingLabel: CHECKING_LABEL, + isPending: pendingAction === 'retry', disabled: pendingAction !== null, onClick: handleRetryAutomatic } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index e7001fcc783..ea41091f6a3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -153,7 +153,7 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = { projectId: 'project-group:platform', hostId: 'ssh:devbox', label: 'Devbox', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true, attention: false } @@ -581,7 +581,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect(devboxItem?.textContent).toContain('Project not set up on this host') + expect(devboxItem?.textContent).toContain('Project location not set') // Not-connected rows stay highlightable (never `disabled`) so they hover like // the other rows; they're quieted visually instead. expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 43198a9be9d..4b46d0148e5 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -126,6 +126,11 @@ import { type PRCommentGroup } from '@/lib/pr-comment-groups' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' +import { + attachPRReviewReplyParent, + canPostPRReviewThreadReply +} from '@/components/right-sidebar/pr-comments-ai-launch-ack' +import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -2273,29 +2278,32 @@ function ConversationTab({ ) return false } - const result = - comment.path && item.type === 'pr' - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - body: `@${comment.author} ${replyBody}`, - type: item.type, - prRepo - }) + // Why: nest under review threads (path/threadId/discussion_r); never post a + // separate top-level conversation comment for those. + const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) + const result = isReviewThreadReply + ? await addPRReviewCommentReplyForRepo({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + prNumber: item.number, + prRepo, + commentId: comment.id, + body: replyBody, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }) + : await addIssueCommentForRepo({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + number: item.number, + // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. + body: buildPRCommentConversationReplyBody(comment.author, replyBody), + type: item.type, + prRepo + }) if (!result.ok) { toast.error( @@ -2304,7 +2312,9 @@ function ConversationTab({ ) return false } - onCommentAdded(result.comment) + onCommentAdded( + isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment + ) setReplyingTo(null) toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.')) return true diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 816ae982485..dc39c3a0644 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -49,10 +49,12 @@ import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverl import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' import { isBrowserAutomationVisible, + onBrowserAutomationVisibilityChange, useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility' import { isBrowserPageMobileDriven, + onBrowserDriverChange, useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' @@ -788,6 +790,7 @@ function Terminal(): React.JSX.Element | null { const measurableBackgroundWorktreeTimersRef = useRef(new Map()) const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [browserGuestRetentionRevision, setBrowserGuestRetentionRevision] = useState(0) const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( () => new Set() ) @@ -1166,7 +1169,19 @@ function Terminal(): React.JSX.Element | null { ]) // Why here: downloads outlive the pane-local state of hidden (unmounted) // BrowserPanes, and the eviction veto below must see them. - useEffect(() => installBrowserPageDownloadActivityTracking(), []) + useEffect(() => { + const invalidateRetention = (): void => { + setBrowserGuestRetentionRevision((revision) => revision + 1) + } + const removeDownloadTracking = installBrowserPageDownloadActivityTracking(invalidateRetention) + const removeAutomationTracking = onBrowserAutomationVisibilityChange(invalidateRetention) + const removeMobileTracking = onBrowserDriverChange(invalidateRetention) + return () => { + removeDownloadTracking() + removeAutomationTracking() + removeMobileTracking() + } + }, []) // Browser-guest retention budget (#12137 follow-up): hidden worktrees keep // webview guests alive for instant revisits, but only the most recently // activated few. Older ones have every guest FULLY destroyed through the @@ -1176,7 +1191,7 @@ function Terminal(): React.JSX.Element | null { // and terminal panes/watchers/capture contracts are untouched. A revisit // rebuilds guests from store state. useEffect(() => { - if (!renderedActiveWorktreeId || !browserGuestRetentionBudgetEnabled) { + if (!renderedActiveWorktreeId) { return } const recency = browserGuestWorktreeRecencyRef.current @@ -1187,6 +1202,11 @@ function Terminal(): React.JSX.Element | null { recency.splice(index, 1) } } + // Why after the bookkeeping: recency must track activation order even while + // the kill switch is off, or re-enabling would evict by worktree-list order. + if (!browserGuestRetentionBudgetEnabled) { + return + } const state = useAppStore.getState() // Why appended: a mounted worktree missing from recency (background mount) ranks oldest. const recencyIds = new Set(recency) @@ -1226,7 +1246,12 @@ function Terminal(): React.JSX.Element | null { worktreeId ) } - }, [renderedActiveWorktreeId, workspaceSurfaces, browserGuestRetentionBudgetEnabled]) + }, [ + renderedActiveWorktreeId, + workspaceSurfaces, + browserGuestRetentionBudgetEnabled, + browserGuestRetentionRevision + ]) // Why: a slow post-reconnect step exposes workspaceSessionReady before hydration can populate snapshot capabilities. if ( renderedActiveWorktreeId && @@ -1772,7 +1797,8 @@ function Terminal(): React.JSX.Element | null { ) { if (unifiedTab.contentType === 'terminal') { // Why: paired-host bulk close must revoke renderer resume and hook authority, not just remove the host session tab. - closeTerminalTab(unifiedTab.entityId) + // No running-process prompt: "Close Others" over N busy tabs would be a modal storm. + closeTerminalTab(unifiedTab.entityId, { skipRunningProcessConfirm: true }) } else { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx new file mode 100644 index 00000000000..7d84be81be1 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -0,0 +1,351 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactI18Next from 'react-i18next' +import type { Repo, Worktree } from '../../../shared/types' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import WorktreeJumpPalette from './WorktreeJumpPalette' + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ + useSettingsNavigationMetadata: () => [] +})) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repos/repo-1', + displayName: 'Repo 1', + badgeColor: '#000000', + addedAt: 0 + } +} + +function makeWorktree( + id: string, + displayName: string, + overrides: Partial = {} +): Worktree { + return { + id, + repoId: 'repo-1', + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getWorktreeRows(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( + (node) => node.textContent ?? '' + ) +} + +describe('WorktreeJumpPalette', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('keeps every inactive main workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + // Why kept: the exemption keys on isMainWorktree, not the branch name, so a + // branchless folder workspace is the project's entry point too. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('keeps the explicit default-branch filter authoritative', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch] }, + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: true + }) + + expect(testContainer.textContent).not.toContain('Default branch workspace') + }) + + it('keeps an active non-default workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false, + browserTabsByWorktree: { + feature: [ + { + id: 'browser-tab-1', + worktreeId: 'feature', + url: 'https://example.com', + title: 'example.com', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + } + }) + + expect(testContainer.textContent).toContain('Feature workspace') + expect(testContainer.textContent).toContain('Default branch workspace') + // Why kept: same isMainWorktree exemption — folder workspaces are covered. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('sweeps the sleeping main workspace once the exemption is opted out', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: false + }) + + expect(getWorktreeRows()).toEqual([]) + expect(testContainer.textContent).not.toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + }) + + it('keeps the show-sleeping baseline and empty-query ordering intact', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: true, + lastVisitedAtByWorktreeId: { + feature: 300, + 'default-branch': 200, + 'folder-main': 100 + } + }) + + expect(getWorktreeRows()).toEqual([ + expect.stringContaining('Feature workspace'), + expect.stringContaining('Default branch workspace'), + expect.stringContaining('Folder workspace') + ]) + }) + + it('keeps typed-query results on the full non-archived scope', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).not.toContain('Feature workspace') + + expect(setCommandQuery).not.toBeNull() + + await act(async () => { + setCommandQuery?.('Feature') + }) + await flushEffects() + + expect(testContainer.textContent).toContain('Feature workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c85a2d700e7..19e352f7669 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -31,7 +31,8 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace, isDetachedHeadWorkspace, - isDefaultBranchWorkspace + isDefaultBranchWorkspace, + isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' @@ -404,6 +405,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) const openNewBrowserTabInActiveWorkspace = useAppStore( @@ -501,6 +503,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( !showSleepingWorkspaces && + // Why the exemption here too: Cmd+J re-implements the sidebar's + // filter pass, so the shared predicate is what keeps them in step. + !isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) && isInactiveWorkspace( worktree.id, tabsByWorktree, @@ -515,6 +520,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }), [ allWorktrees, + alwaysShowDefaultBranchWorkspace, browserTabsByWorktree, hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index a7b9151fbfc..91c9e88c592 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -1,17 +1,11 @@ -/* eslint-disable no-control-regex -- terminal snapshots normalize ANSI/control output. */ import type { AutomationRunOutputSnapshot } from '../../../../shared/automations-types' +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../../../shared/ansi-escape-sequences' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 -// Why: Codex/Claude TUIs emit OSC title/progress frames in hidden automation -// PTYs; saved run output should keep command text, not terminal metadata. -const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g -const STRING_SEQUENCE_PATTERN = - /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g -const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g -const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g -const CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g - export type AutomationRunOutputSnapshotBuffer = { append: (chunk: string) => void snapshot: () => AutomationRunOutputSnapshot | null @@ -43,14 +37,10 @@ export function selectAutomationRunOutputSnapshot( } function stripTerminalControls(value: string): string { - return value - .replace(OSC_SEQUENCE_PATTERN, '') - .replace(STRING_SEQUENCE_PATTERN, '') - .replace(CSI_SEQUENCE_PATTERN, '') - .replace(ESCAPE_SEQUENCE_PATTERN, '') + return stripAnsiEscapeSequences(value) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') - .replace(CONTROL_PATTERN, '') + .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') } export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSnapshotBuffer { diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts index dd15257ac87..14f4d8c9976 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts @@ -19,8 +19,11 @@ describe('browser automation visibility leases', () => { const { acquireBrowserAutomationVisibility, isBrowserAutomationVisible, + onBrowserAutomationVisibilityChange, releaseBrowserAutomationVisibility } = await import('./browser-automation-visibility') + const listener = vi.fn() + const unsubscribe = onBrowserAutomationVisibilityChange(listener) const first = acquireBrowserAutomationVisibility('page-1') const second = acquireBrowserAutomationVisibility('page-1') @@ -32,6 +35,11 @@ describe('browser automation visibility leases', () => { expect(releaseBrowserAutomationVisibility(second)).toBe(true) expect(isBrowserAutomationVisible('page-1')).toBe(false) + expect(listener).toHaveBeenCalledTimes(4) + + unsubscribe() + acquireBrowserAutomationVisibility('page-2') + expect(listener).toHaveBeenCalledTimes(4) }) it('installs a main-process bridge that keeps the page visible while waiting for paint', async () => { diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index 6b092f2c950..f810d22f024 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -34,6 +34,10 @@ function subscribe(listener: () => void): () => void { } } +export function onBrowserAutomationVisibilityChange(listener: () => void): () => void { + return subscribe(listener) +} + function getSnapshot(): number { return version } diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts index 5653f0b5f43..cfd9c3f2e0c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts @@ -1,34 +1,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type DownloadRequestedCallback = (event: { downloadId: string; browserPageId: string }) => void +type DownloadProgressCallback = (event: { + downloadId: string + state: 'progressing' | 'interrupted' | null +}) => void type DownloadFinishedCallback = (event: { downloadId: string }) => void describe('browser page download activity', () => { let requestedCallbacks: DownloadRequestedCallback[] + let progressCallbacks: DownloadProgressCallback[] let finishedCallbacks: DownloadFinishedCallback[] - let removedRequested: boolean - let removedFinished: boolean + let removedListenerCount: number beforeEach(() => { vi.resetModules() requestedCallbacks = [] + progressCallbacks = [] finishedCallbacks = [] - removedRequested = false - removedFinished = false + removedListenerCount = 0 + const removeListener = (): void => { + removedListenerCount += 1 + } vi.stubGlobal('window', { api: { browser: { onDownloadRequested: (callback: DownloadRequestedCallback) => { requestedCallbacks.push(callback) - return () => { - removedRequested = true - } + return removeListener + }, + onDownloadProgress: (callback: DownloadProgressCallback) => { + progressCallbacks.push(callback) + return removeListener }, onDownloadFinished: (callback: DownloadFinishedCallback) => { finishedCallbacks.push(callback) - return () => { - removedFinished = true - } + return removeListener } } } @@ -64,19 +71,59 @@ describe('browser page download activity', () => { expect(hasActiveBrowserPageDownload('page-2')).toBe(false) }) - it('ignores duplicate start events and unknown finish events', async () => { + it('deactivates an interrupted download (no finished event ever fires) and reactivates on resume', async () => { + const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = + await import('./browser-page-download-activity') + const onEvictionVetoChange = vi.fn() + installBrowserPageDownloadActivityTracking(onEvictionVetoChange) + + requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(2) + + progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + + // Duplicate progress and null transport noise are not transitions. + progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) + progressCallbacks[0]({ downloadId: 'dl-1', state: null }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(3) + + finishedCallbacks[0]({ downloadId: 'dl-1' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(4) + }) + + it('ignores duplicate start events, unknown progress and unknown finish events', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') installBrowserPageDownloadActivityTracking() requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-unknown', state: 'interrupted' }) finishedCallbacks[0]({ downloadId: 'dl-unknown' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) finishedCallbacks[0]({ downloadId: 'dl-1' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) }) + it('finishing an already-interrupted download stays balanced', async () => { + const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = + await import('./browser-page-download-activity') + installBrowserPageDownloadActivityTracking() + + requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + requestedCallbacks[0]({ downloadId: 'dl-2', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) + finishedCallbacks[0]({ downloadId: 'dl-1' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + finishedCallbacks[0]({ downloadId: 'dl-2' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + }) + it('unsubscribes and clears tracked state on cleanup', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') @@ -85,8 +132,7 @@ describe('browser page download activity', () => { requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) cleanup() - expect(removedRequested).toBe(true) - expect(removedFinished).toBe(true) + expect(removedListenerCount).toBe(3) // A host remount tears down every guest; stale entries must not veto eviction. expect(hasActiveBrowserPageDownload('page-1')).toBe(false) }) diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts index 454aba9ad1d..4c2b270ed8c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts @@ -3,52 +3,91 @@ // hidden pages are still writing downloads — main treats a guest unregister as // a tab close and cancels their active downloads (browser-manager // unregisterGuest), so eviction has to skip those pages until they finish. -const pageIdByActiveDownloadId = new Map() +type TrackedDownload = { browserPageId: string; active: boolean } + +const trackedDownloadsById = new Map() const activeDownloadCountByPageId = new Map() export function hasActiveBrowserPageDownload(browserPageId: string): boolean { return (activeDownloadCountByPageId.get(browserPageId) ?? 0) > 0 } -function trackDownloadStarted(downloadId: string, browserPageId: string): void { - if (pageIdByActiveDownloadId.has(downloadId)) { +function setDownloadActive( + download: TrackedDownload, + active: boolean, + onEvictionVetoChange: () => void +): void { + if (download.active === active) { return } - pageIdByActiveDownloadId.set(downloadId, browserPageId) - activeDownloadCountByPageId.set( - browserPageId, - (activeDownloadCountByPageId.get(browserPageId) ?? 0) + 1 - ) + download.active = active + const count = (activeDownloadCountByPageId.get(download.browserPageId) ?? 0) + (active ? 1 : -1) + if (count <= 0) { + activeDownloadCountByPageId.delete(download.browserPageId) + } else { + activeDownloadCountByPageId.set(download.browserPageId, count) + } + onEvictionVetoChange() } -function trackDownloadFinished(downloadId: string): void { - const browserPageId = pageIdByActiveDownloadId.get(downloadId) - if (browserPageId === undefined) { +function trackDownloadStarted( + downloadId: string, + browserPageId: string, + onEvictionVetoChange: () => void +): void { + if (trackedDownloadsById.has(downloadId)) { return } - pageIdByActiveDownloadId.delete(downloadId) - const remaining = (activeDownloadCountByPageId.get(browserPageId) ?? 1) - 1 - if (remaining <= 0) { - activeDownloadCountByPageId.delete(browserPageId) - } else { - activeDownloadCountByPageId.set(browserPageId, remaining) + const download: TrackedDownload = { browserPageId, active: false } + trackedDownloadsById.set(downloadId, download) + setDownloadActive(download, true, onEvictionVetoChange) +} + +// Why: an interrupted-resumable download never fires Chromium's 'done' (no +// finished event), and Orca has no resume path — without this transition the +// veto would outlive the download for the whole session. A resumed download +// re-actives on its next 'progressing' tick, restoring the veto. +function trackDownloadProgress( + downloadId: string, + state: 'progressing' | 'interrupted' | null, + onEvictionVetoChange: () => void +): void { + const download = trackedDownloadsById.get(downloadId) + if (!download || state === null) { + return } + setDownloadActive(download, state === 'progressing', onEvictionVetoChange) +} + +function trackDownloadFinished(downloadId: string, onEvictionVetoChange: () => void): void { + const download = trackedDownloadsById.get(downloadId) + if (!download) { + return + } + setDownloadActive(download, false, onEvictionVetoChange) + trackedDownloadsById.delete(downloadId) } /** App-lifetime tracking; install once from the surface host (Terminal). The * cleanup clears tracked state — a host unmount tears down every guest, so * stale entries must not veto eviction after a remount. */ -export function installBrowserPageDownloadActivityTracking(): () => void { +export function installBrowserPageDownloadActivityTracking( + onEvictionVetoChange: () => void = () => {} +): () => void { const removeRequested = window.api.browser.onDownloadRequested((event) => { - trackDownloadStarted(event.downloadId, event.browserPageId) + trackDownloadStarted(event.downloadId, event.browserPageId, onEvictionVetoChange) + }) + const removeProgress = window.api.browser.onDownloadProgress((event) => { + trackDownloadProgress(event.downloadId, event.state, onEvictionVetoChange) }) const removeFinished = window.api.browser.onDownloadFinished((event) => { - trackDownloadFinished(event.downloadId) + trackDownloadFinished(event.downloadId, onEvictionVetoChange) }) return () => { removeRequested() + removeProgress() removeFinished() - pageIdByActiveDownloadId.clear() + trackedDownloadsById.clear() activeDownloadCountByPageId.clear() } } diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts index 8ea69a07c8c..d6def5c80e5 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts @@ -121,6 +121,27 @@ describe('syncBrowserPageChromeInset', () => { const viewport = ensureBrowserPageViewport('page-1', 'workspace-1')! expect(viewport.chromeInset.style.height).toBe('48px') }) + + it('restores the inset when guest recovery rebuilds the shell', () => { + mountSlotViewport('workspace-1') + ensureBrowserPageViewport('page-1', 'workspace-1') + syncBrowserPageChromeInset('page-1', 48) + // Guest replacement tears the shell down; the recovery re-render rebuilds it without re-measuring the chrome. + removeBrowserPageViewport('page-1') + + const rebuilt = ensureBrowserPageViewport('page-1', 'workspace-1')! + + expect(rebuilt.chromeInset.style.height).toBe('48px') + }) + + it('applies an inset measured before the shell existed', () => { + syncBrowserPageChromeInset('page-2', 40) + mountSlotViewport('workspace-1') + + const viewport = ensureBrowserPageViewport('page-2', 'workspace-1')! + + expect(viewport.chromeInset.style.height).toBe('40px') + }) }) describe('applyBrowserPageViewportLayout', () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.ts index d34fbf744f4..e4a2441c755 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.ts @@ -14,6 +14,11 @@ type BrowserPageViewport = { const browserPageViewports = new Map() +// Why: React measures the chrome only on mount, but shells are rebuilt without a +// re-measure (guest recovery/replacement, profile switch, slot remount). Remembering +// the inset keeps geometry a property of attaching a guest, not of the first mount. +const browserPageChromeInsetHeights = new Map() + const slotRootListeners = new Map void>>() function notifySlotRootListeners(workspaceTabId: string): void { @@ -88,6 +93,10 @@ export function ensureBrowserPageViewport( const chromeInset = document.createElement('div') chromeInset.dataset.browserPageChromeInset = '' chromeInset.className = 'shrink-0' + const rememberedInsetHeight = browserPageChromeInsetHeights.get(browserPageId) + if (rememberedInsetHeight !== undefined) { + chromeInset.style.height = `${rememberedInsetHeight}px` + } const container = document.createElement('div') container.dataset.browserPageContainer = '' @@ -141,11 +150,13 @@ export function applyBrowserPageViewportLayout( } export function syncBrowserPageChromeInset(browserPageId: string, heightPx: number): void { + const insetHeight = Math.max(0, heightPx) + browserPageChromeInsetHeights.set(browserPageId, insetHeight) const viewport = browserPageViewports.get(browserPageId) if (!viewport) { return } - viewport.chromeInset.style.height = `${Math.max(0, heightPx)}px` + viewport.chromeInset.style.height = `${insetHeight}px` } export function parkBrowserPageViewport(browserPageId: string): void { diff --git a/src/renderer/src/components/editor/check-run-details-tab.test.ts b/src/renderer/src/components/editor/check-run-details-tab.test.ts index c6beb882802..5646f523263 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.test.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.test.ts @@ -38,6 +38,18 @@ describe('check-run-details-tab', () => { ).toBe('url:https://github.com/acme/widgets/actions/runs/1') }) + it('keys GitLab jobs by job id so same-named stages do not share one tab', () => { + const base = { status: 'completed', conclusion: 'failure', url: null } as const + + expect(getCheckRunTabIdentity({ ...base, name: 'test: unit', gitlabJobId: 42 })).toBe( + 'gitlab-job:42' + ) + // Why: GitLab reuses `stage: name` across pipeline runs, so the name alone collides. + expect( + buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 42 }) + ).not.toBe(buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 43 })) + }) + it('uses the check name for the tab label', () => { expect( getCheckRunDetailsTabLabel({ diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts index 0acd550f7b2..bbc8fc1180b 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -1,3 +1,4 @@ +import type { GitLabProjectRef } from '../../../../shared/gitlab-types' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' export type OpenCheckRunDetailsState = { @@ -6,6 +7,20 @@ export type OpenCheckRunDetailsState = { details: PRCheckRunDetails | null loading: boolean error: string | null + /** Why: fork/cross-project MR jobs live outside the repo's own project, so reloads need the pipeline's project. */ + gitlabProjectRef?: GitLabProjectRef | null +} + +export type CheckRunDetailsTabPatch = Pick< + OpenCheckRunDetailsState, + 'details' | 'loading' | 'error' | 'gitlabProjectRef' +> + +export function isSameGitLabProjectRef( + a: GitLabProjectRef | null, + b: GitLabProjectRef | null +): boolean { + return a === b || (a?.host === b?.host && a?.path === b?.path) } export function getCheckRunTabIdentity(check: PRCheckDetail): string { @@ -15,6 +30,11 @@ export function getCheckRunTabIdentity(check: PRCheckDetail): string { if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}` } + // Why: GitLab jobs reuse `stage: name` across pipeline runs and can lack a web_url, + // so the numeric job id is the only stable per-job tab identity. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}` + } if (check.url) { return `url:${check.url}` } diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 5020bf5e8f8..78e83706e52 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -511,6 +511,53 @@ describe('attachEditorAutosaveController', () => { } }) + it('drops an autosave already queued when owner migration starts', async () => { + let releaseFirstWrite!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + const writeFile = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { fs: { writeFile } } + } satisfies WindowStub) + + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + const cleanup = attachEditorAutosaveController(store) + try { + store.getState().setEditorDraft('/repo/file.ts', 'first autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + expect(writeFile).toHaveBeenCalledTimes(1) + + store.getState().setEditorDraft('/repo/file.ts', 'queued autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + store.getState().setRestoredEditorOwnerMigrationPending('/repo/file.ts', true) + releaseFirstWrite() + for (let index = 0; index < 6; index += 1) { + await Promise.resolve() + } + + expect(writeFile).toHaveBeenCalledTimes(1) + expect(store.getState().editorDrafts['/repo/file.ts']).toBe('queued autosave') + } finally { + cleanup() + } + }) + it('leaves dirty editor drafts ready for the combined hot-exit checkpoint', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const setSync = vi.fn() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 3326d93492d..271786a74c1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -92,6 +92,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + if (liveFile.pendingOwnerMigration === true) { + if (trigger === 'autosave') { + return + } + throw new Error('This file is still restoring its workspace owner. Try saving again.') + } + // Why: only autosave is blocked while suspended; explicit user saves proceed (the banner warned). if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { return @@ -341,6 +348,10 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { detail.resolve() return } + if (file.pendingOwnerMigration === true) { + detail.reject('This file is still restoring its workspace owner. Try saving again.') + return + } flushPendingEditorChange(file.id) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 01ac40151e5..efeda658724 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -89,13 +89,17 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean { export function isAutosaveSuspendedForFile( file: Pick< OpenFile, - 'externalMutation' | 'pendingDiskBaselineVerification' | 'pendingLiveDiskVerification' + | 'externalMutation' + | 'pendingDiskBaselineVerification' + | 'pendingLiveDiskVerification' + | 'pendingOwnerMigration' > ): boolean { return ( file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true || - file.pendingLiveDiskVerification === true + file.pendingLiveDiskVerification === true || + file.pendingOwnerMigration === true ) } diff --git a/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts new file mode 100644 index 00000000000..86fbbfa63f1 --- /dev/null +++ b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts @@ -0,0 +1,87 @@ +import { useAppStore } from '@/store' +import { + findWorkspaceFileRoute, + type RuntimeWorkspaceFileRoute +} from '@/lib/runtime-workspace-file-route' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance, + type EditorFileOperationProvenance +} from '@/lib/editor-file-operation-owner' +import { requestEditorSaveQuiesce } from './editor-autosave' +import type { RestoredEditorOwnerResult } from '@/store/slices/editor' + +export type RestoredEditorOwnerMigrationResult = RestoredEditorOwnerResult + +export async function migrateRestoredEditorFileOwner( + fileId: string, + route: RuntimeWorkspaceFileRoute, + runtimeEnvironmentId: string | null +): Promise { + const state = useAppStore.getState() + const source = state.openFiles.find((file) => file.id === fileId) + const initialRoute = source + ? findWorkspaceFileRoute(state, route.executionHostId, source.filePath) + : null + if (!source || !routesMatch(initialRoute, route)) { + return { ok: false, reason: 'stale' } + } + let targetOperationProvenance: EditorFileOperationProvenance + try { + targetOperationProvenance = captureEditorFileOperationProvenance( + state, + route.worktreeId, + runtimeEnvironmentId, + true + ) + } catch { + return { ok: false, reason: 'owner-changed' } + } + if (!state.setRestoredEditorOwnerMigrationPending(fileId, true)) { + return { ok: false, reason: 'stale' } + } + + try { + await requestEditorSaveQuiesce({ fileId }) + } catch (error) { + useAppStore.getState().setRestoredEditorOwnerMigrationPending(fileId, false) + throw error + } + const currentState = useAppStore.getState() + const currentSource = currentState.openFiles.find((file) => file.id === fileId) + const currentRoute = currentSource + ? findWorkspaceFileRoute(currentState, route.executionHostId, currentSource.filePath) + : null + try { + if ( + currentSource?.filePath !== source.filePath || + !routesMatch(currentRoute, route) || + assertEditorFileOperationCurrent(currentState, route.worktreeId, targetOperationProvenance) + .runtimeEnvironmentId !== runtimeEnvironmentId + ) { + throw new Error('stale route') + } + } catch { + currentState.setRestoredEditorOwnerMigrationPending(fileId, false) + return { ok: false, reason: 'owner-changed' } + } + return currentState.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: route.worktreeId, + targetRelativePath: route.relativePath, + targetExecutionHostId: route.executionHostId, + targetRuntimeEnvironmentId: runtimeEnvironmentId, + targetOperationProvenance + }) +} + +function routesMatch( + current: RuntimeWorkspaceFileRoute | null, + expected: RuntimeWorkspaceFileRoute +): boolean { + return ( + current?.worktreeId === expected.worktreeId && + current.relativePath === expected.relativePath && + current.executionHostId === expected.executionHostId + ) +} diff --git a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts new file mode 100644 index 00000000000..1d5ccf262b3 --- /dev/null +++ b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts @@ -0,0 +1,287 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { requestEditorFileSave } from './editor-autosave' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' + +const mocks = vi.hoisted(() => ({ writeRuntimeFile: vi.fn() })) + +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => ({ + ...(await importOriginal()), + writeRuntimeFile: mocks.writeRuntimeFile +})) + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/file.md' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function seed(filePath = FILE_PATH): string { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: true, editorAutoSaveDelayMs: 250 }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map() + } as unknown as Partial) + return useAppStore.getState().openFile( + { + filePath, + relativePath: filePath, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) +} + +describe('restored editor owner save lifecycle', () => { + let detach: (() => void) | null = null + + beforeEach(() => { + vi.useFakeTimers() + mocks.writeRuntimeFile.mockReset() + }) + + afterEach(() => { + detach?.() + detach = null + vi.useRealTimers() + }) + + it('drains the old save before reparent, then routes explicit save and autosave to the destination', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise).mockResolvedValue(undefined) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { + worktreeId: TARGET, + relativePath: 'file.md', + executionHostId: 'local' + }, + null + ) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + expect(useAppStore.getState().openFiles[0]?.worktreeId).toBe(SOURCE) + await expect(requestEditorFileSave({ fileId: oldId })).rejects.toThrow( + 'still restoring its workspace owner' + ) + + firstWrite.resolve() + await sourceSave + const migrated = await migration + expect(migrated.ok).toBe(true) + if (!migrated.ok) { + return + } + expect(mocks.writeRuntimeFile.mock.calls[0]?.[0]).toMatchObject({ worktreeId: SOURCE }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'explicit destination save') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await requestEditorFileSave({ fileId: migrated.fileId }) + expect(mocks.writeRuntimeFile.mock.calls[1]?.[0]).toMatchObject({ worktreeId: TARGET }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'autosave destination') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3)) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET }) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination') + }) + + it('rejects a concurrent migration without clearing the active migration gate', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const route = { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' as const } + const firstMigration = migrateRestoredEditorFileOwner(oldId, route, null) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + await expect(migrateRestoredEditorFileOwner(oldId, route, null)).resolves.toEqual({ + ok: false, + reason: 'stale' + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + firstWrite.resolve() + await sourceSave + await expect(firstMigration).resolves.toMatchObject({ ok: true }) + }) + + it('fails closed when the sibling workspace disappears during save quiescence', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + worktreesByRepo: { ...state.worktreesByRepo, 'repo-b': [] } + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when a folder root changes during save quiescence', async () => { + const oldId = seed('/notes/todo.md') + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'notes-group', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'notes-group', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: 'folder:notes', relativePath: 'todo.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + folderWorkspaces: state.folderWorkspaces.map((workspace) => ({ + ...workspace, + folderPath: '/renamed-notes' + })) + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when direct-SSH authority reconnects during save quiescence', async () => { + const oldId = seed() + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => + repo.id === 'repo-b' + ? { ...repo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' } + : repo + ), + worktreesByRepo: { + ...state.worktreesByRepo, + 'repo-b': state.worktreesByRepo['repo-b'].map((worktree) => ({ + ...worktree, + hostId: 'ssh:ssh-1' + })) + } + })) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'ssh:ssh-1' }, + null + ) + await Promise.resolve() + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 3920caa89a8..3a5ed862d8e 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -41,6 +41,10 @@ vi.mock('@/lib/connection-context', () => ({ isWorktreeConnectionResolved: mocks.isWorktreeConnectionResolved })) +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: vi.fn(() => null) +})) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index d7c62b6e6ed..f9cda5dc753 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -9,6 +9,12 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' import { getRuntimeGitBranchDiff, getRuntimeGitCommitDiff, @@ -31,6 +37,7 @@ import { } from './useEditorPanelExternalContentEvents' import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' import { useLocalLogTail } from './useLocalLogTail' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' const inFlightFileReads = new Map>() const inFlightDiffReads = new Map>() @@ -143,6 +150,8 @@ export function useEditorPanelContentState({ const isLiveTailLogTab = restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true let readConnectionId = connectionId + let readWorktreeId = worktreeId + let readRelativePath = restoredOpenFile?.relativePath ?? relativePath if ( resolvedConnectionId === undefined && !readSettings?.activeRuntimeEnvironmentId?.trim() && @@ -160,18 +169,52 @@ export function useEditorPanelContentState({ const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || (isLiveTailLogTab ? undefined : connectionId) - if (!externalSshOwnerId && readSettings?.activeRuntimeEnvironmentId?.trim()) { - // Why: runtime file RPCs are worktree-scoped, so a client-local absolute - // path has no route into a remote runtime workspace. - throw new Error('External local files are not available for remote workspaces.') - } - if (!externalSshOwnerId) { - // Why: client-local external tabs need their main-process path grant - // refreshed because that authorization is only held in memory. + const runtimeEnvironmentId = isLiveTailLogTab + ? undefined + : readSettings?.activeRuntimeEnvironmentId?.trim() + if (isLiveTailLogTab) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - // Why: that grant covers the client path, so this read must stay off the - // worktree's SSH host. readConnectionId = undefined + } else { + const currentState = useAppStore.getState() + const executionHostId = externalSshOwnerId + ? toSshExecutionHostId(externalSshOwnerId) + : runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + const route = findWorkspaceFileRoute(currentState, executionHostId, filePath) + if (route && route.worktreeId !== worktreeId) { + const migration = await migrateRestoredEditorFileOwner( + id, + route, + runtimeEnvironmentId ?? null + ) + fileReadGenerationRef.current[id] = ++fileReadGenerationCounterRef.current + if (!migration.ok) { + throw new Error( + migration.reason === 'collision' + ? 'The sibling file is already open; close one tab before restoring it.' + : 'The sibling file owner changed while the tab was restoring.' + ) + } + setFileContents((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + return + } + if (runtimeEnvironmentId && !route) { + throw new Error('External local files are not available for remote workspaces.') + } + if (!externalSshOwnerId) { + // Why: client-local external tabs need their main-process path grant + // refreshed because that authorization is only held in memory. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + // Why: that grant covers the client path, so this read must stay off the + // worktree's SSH host. + readConnectionId = undefined + } } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) @@ -186,8 +229,8 @@ export function useEditorPanelContentState({ pending = readRuntimeFileContent({ settings: readSettings, filePath, - relativePath: restoredOpenFile?.relativePath ?? relativePath, - worktreeId, + relativePath: readRelativePath, + worktreeId: readWorktreeId, connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: isLiveTailLogTab diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx new file mode 100644 index 00000000000..4576b137432 --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + findWorkspaceFileRoute: vi.fn(), + migrateRestoredEditorFileOwner: vi.fn(), + getState: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + getRuntimeFileReadScope: vi.fn( + ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + connectionId?: string + ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ), + readRuntimeFileContent: mocks.readRuntimeFileContent, + subscribeRuntimeFileChanges: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => ({ + getRuntimeGitBranchDiff: vi.fn(), + getRuntimeGitCommitDiff: vi.fn(), + getRuntimeGitDiff: vi.fn(), + getRuntimeGitScope: vi.fn(() => null) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(), + getConnectionIdForFile: vi.fn(), + isWorktreeConnectionResolved: vi.fn(() => true) +})) + +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: mocks.findWorkspaceFileRoute +})) + +vi.mock('./migrate-restored-editor-file-owner', () => ({ + migrateRestoredEditorFileOwner: mocks.migrateRestoredEditorFileOwner +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: vi.fn(() => 'local') +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) + +vi.mock('./useEditorPanelExternalContentEvents', () => ({ + useEditorPanelExternalContentEvents: vi.fn(), + usePruneClosedEditorContent: vi.fn() +})) + +vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() })) +vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) + +import { useEditorPanelContentState } from './useEditorPanelContentState' + +const authorizeExternalPath = vi.fn() +let latestFileContents: Record = {} + +function createOpenFile(overrides: Partial): OpenFile { + return { + id: '/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +function HookProbe({ activeFile }: { activeFile: OpenFile }): null { + latestFileContents = useEditorPanelContentState({ + activeFile, + isChangesMode: false, + openFiles: [activeFile], + gitStatusEntries: undefined, + editorViewMode: {} + }).fileContents + return null +} + +describe('remote sibling editor content routing', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + beforeEach(() => { + latestFileContents = {} + authorizeExternalPath.mockReset() + authorizeExternalPath.mockResolvedValue(undefined) + ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + mocks.readRuntimeFileContent.mockReset() + mocks.findWorkspaceFileRoute.mockReset() + mocks.findWorkspaceFileRoute.mockReturnValue(null) + mocks.migrateRestoredEditorFileOwner.mockReset() + mocks.migrateRestoredEditorFileOwner.mockResolvedValue({ ok: true, fileId: 'owned-file' }) + mocks.getState.mockReset() + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + }) + + it('keeps a client-local live-tail log on the client', async () => { + const logPath = '/Users/me/.codex/sessions/session.jsonl' + const activeFile = createOpenFile({ + id: logPath, + filePath: logPath, + relativePath: logPath, + worktreeId: 'repo-runtime::/work/demo-project', + readOnly: true, + liveTail: true + }) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'log line', isBinary: false }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) + expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled() + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + ) + }) + + it('atomically reparents an unstamped sibling file before reading', async () => { + const activeFile = createOpenFile({ + id: '/work/repo-b/docs/readme.md', + filePath: '/work/repo-b/docs/readme.md', + relativePath: '/work/repo-b/docs/readme.md', + worktreeId: 'repo-a::/work/repo-a' + }) + const route = { + worktreeId: 'repo-b::/work/repo-b', + relativePath: 'docs/readme.md', + executionHostId: 'runtime:runtime-1' + } as const + mocks.findWorkspaceFileRoute.mockReturnValue(route) + + await act(async () => root?.render()) + + await vi.waitFor(() => + expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith( + activeFile.id, + route, + 'runtime-1' + ) + ) + expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts index da666d54523..248783de9e0 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -import { buildRemediation } from './GhAuthErrorHelp' +import { buildRemediation, reloadOrcaRenderer } from './GhAuthErrorHelp' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) function diagnostic(overrides: Partial = {}): GhAuthDiagnostic { return { @@ -57,4 +62,20 @@ describe('GitHub Project auth remediation host routing', () => { } ]) }) + + it('does not force navigation when the checkpointed reload is refused', async () => { + const reload = vi.fn(() => Promise.reject(new Error('checkpoint failed'))) + const locationReload = vi.fn() + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { app: { reload } }, + location: { reload: locationReload } + }) + + reloadOrcaRenderer() + await vi.waitFor(() => expect(errors).toHaveBeenCalledTimes(1)) + + expect(reload).toHaveBeenCalledTimes(1) + expect(locationReload).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx index 30d1ea61b16..7bbeff291d7 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx @@ -40,14 +40,12 @@ function refreshCommandForHost(host: string | null | undefined): string { // macOS/Linux vs PowerShell on Windows. const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent) -function reloadOrcaRenderer(): void { - const reload = window.api.app.reload - if (typeof reload !== 'function') { - window.location.reload() - return - } - void reload().catch(() => { - window.location.reload() +export function reloadOrcaRenderer(): void { + void window.api.app.reload().catch((error) => { + console.error( + '[github-projects] Renderer reload refused:', + error instanceof Error ? error.name : typeof error + ) }) } diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx index 7f2edffe8eb..06b1267f80b 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -61,7 +61,7 @@ const needsSetupOption: NeedsSetupProjectHostOption = { projectId: 'project-1', hostId: 'ssh:builder', label: 'Builder', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true, attention: false } @@ -129,7 +129,7 @@ describe('ProjectHostSetupCombobox', () => { expect( container.querySelector('[data-command-value="needs-setup:ssh:builder"]') ).toBeNull() - expect(container.textContent).not.toContain('Project not set up on this host') + expect(container.textContent).not.toContain('Project location not set') expect(onValueChange).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/pr-checks-fix-prompt.test.ts b/src/renderer/src/components/pr-checks-fix-prompt.test.ts index 2884138c0ef..b3f2eadf600 100644 --- a/src/renderer/src/components/pr-checks-fix-prompt.test.ts +++ b/src/renderer/src/components/pr-checks-fix-prompt.test.ts @@ -34,6 +34,27 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('getCheckDetailsPromptKey', () => { + // Regression for #7732: a GitLab job with no web_url would otherwise key on its + // list index, so its freshly loaded log would never reach the fix prompt. + it('keys GitLab jobs by job id ahead of the index fallback', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 987654 + } + + expect(getCheckDetailsPromptKey(manualJob, 0)).toBe('gitlab-job:987654:deploy: production') + expect(getCheckDetailsPromptKey(manualJob, 3)).toBe(getCheckDetailsPromptKey(manualJob, 0)) + }) + + it('leaves GitHub check identities untouched', () => { + expect(getCheckDetailsPromptKey(failingCheck, 0)).toBe('check-run:11') + }) +}) + describe('buildFixBrokenChecksPrompt', () => { it('keeps names-only broken check data when no details are provided', () => { const prompt = buildPrompt({}) diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.ts b/src/renderer/src/components/pr-comments-resolution-prompt.ts index 94b6a363e7c..8aecb0ad3e2 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.ts @@ -150,7 +150,7 @@ export function buildPRCommentsResolutionPrompt({ '- For outdated comments, inspect the current file and nearby code before editing. Apply the reviewer intent only if it still matches the current code.', '- Keep changes minimal and coherent. If multiple selected comments conflict or require a larger design decision, stop and report the tradeoff instead of guessing.', '- Preserve unrelated staged and unstaged work. Do not run destructive cleanup commands such as git reset --hard, git checkout ., git restore ., or git stash.', - '- Host thread resolution is handled by Orca after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.', + '- Host thread resolution and any fixing replies are handled by Orca after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.', '- Do not push, create commits, or rewrite history.', '- Run git diff --check before finishing. Run the most focused relevant tests, typecheck, or lint command you can reasonably identify; if validation is impractical, explain why.', '', diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 0113f0d22e9..e048925c734 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -48,6 +48,7 @@ import { } from './ai-vault-host-scope' import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' +import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,9 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { sort, group, hideEmptySessions, + sessionLimit, setSort, setGroup, setHideEmptySessions, + setSessionLimit, setAgentEnabled, setAllAgentsEnabled, resetViewOptions @@ -141,9 +144,10 @@ export default function AiVaultPanel(): React.JSX.Element { ) const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( scopePaths, - executionHostScope + executionHostScope, + sessionLimit ) - // Deliberately blind to the active repo/worktree: rebuilding these ~500-entry + // Deliberately blind to the active repo/worktree: rebuilding these session // maps on every worktree switch is what made switching visibly slow (#10841 era). const sessionProjectById = useMemo( () => @@ -180,7 +184,8 @@ export default function AiVaultPanel(): React.JSX.Element { agents, sort, group, - hideEmptySessions + hideEmptySessions, + sessionLimit }) // Workspace is the preferred default, but unavailable context still falls back to All. @@ -316,6 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element { sort={sort} group={group} hideEmptySessions={hideEmptySessions} + sessionLimit={sessionLimit} adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} @@ -325,6 +331,7 @@ export default function AiVaultPanel(): React.JSX.Element { onSortChange={setSort} onGroupChange={setGroup} onHideEmptySessionsChange={setHideEmptySessions} + onSessionLimitChange={setSessionLimit} onReset={resetViewOptions} onRefresh={() => void refresh({ force: true })} /> @@ -335,15 +342,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) : null} - {scanResult && scanResult.issues.length > 0 ? ( -
- {translate( - 'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped', - '{{count}} transcript skipped', - { count: scanResult.issues.length } - )} -
- ) : null} + -
- - - {translate( - 'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions', - 'Scanning sessions' - )} - -
-
- {Array.from({ length: 6 }, (_, index) => ( -
-
-
-
-
-
-
-
- ))} -
-
- ) -} - export function VaultScopeSwitch({ scope, workspaceAvailable, @@ -235,24 +208,28 @@ export function VaultViewMenu({ sort, group, hideEmptySessions, + sessionLimit, adjustmentCount, onAgentEnabledChange, onAllAgentsEnabledChange, onSortChange, onGroupChange, onHideEmptySessionsChange, + onSessionLimitChange, onReset }: { agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup hideEmptySessions: boolean + sessionLimit: AiVaultSessionLimit adjustmentCount: number onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onAllAgentsEnabledChange: (enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onSessionLimitChange: (limit: AiVaultSessionLimit) => void onReset: () => void }): React.JSX.Element { const allAgentsSelected = agents.length === AI_VAULT_AGENTS.length @@ -388,6 +365,10 @@ export function VaultViewMenu({ 'Hide empty sessions' )} + {adjustmentCount > 0 ? ( <> @@ -403,12 +384,3 @@ export function VaultViewMenu({ ) } - -export function EmptyState({ title }: { title: string }): React.JSX.Element { - return ( -
- -

{title}

-
- ) -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 86c728aa8a4..83b190a0800 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -10,6 +10,7 @@ import type { import type { ExecutionHostScope } from '../../../../shared/execution-host' import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' import type { AiVaultHostScopeOption } from './ai-vault-host-scope' +import type { AiVaultSessionLimit } from './ai-vault-session-limit' type AiVaultPanelHeaderProps = { query: string @@ -26,6 +27,7 @@ type AiVaultPanelHeaderProps = { sort: AiVaultSort group: AiVaultGroup hideEmptySessions: boolean + sessionLimit: AiVaultSessionLimit adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void @@ -35,6 +37,7 @@ type AiVaultPanelHeaderProps = { onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onSessionLimitChange: (limit: AiVaultSessionLimit) => void onReset: () => void onRefresh: () => void } @@ -54,6 +57,7 @@ export function AiVaultPanelHeader({ sort, group, hideEmptySessions, + sessionLimit, adjustmentCount, onQueryChange, onScopeChange, @@ -63,6 +67,7 @@ export function AiVaultPanelHeader({ onSortChange, onGroupChange, onHideEmptySessionsChange, + onSessionLimitChange, onReset, onRefresh }: AiVaultPanelHeaderProps): React.JSX.Element { @@ -119,12 +124,14 @@ export function AiVaultPanelHeader({ sort={sort} group={group} hideEmptySessions={hideEmptySessions} + sessionLimit={sessionLimit} adjustmentCount={adjustmentCount} onAgentEnabledChange={onAgentEnabledChange} onAllAgentsEnabledChange={onAllAgentsEnabledChange} onSortChange={onSortChange} onGroupChange={onGroupChange} onHideEmptySessionsChange={onHideEmptySessionsChange} + onSessionLimitChange={onSessionLimitChange} onReset={onReset} />
)} - {hasLogTail && ( -
- {translate( - 'auto.components.right.sidebar.checks.panel.content.2524d1fb83', - 'Log tail available in full details.' - )} -
- )} - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
{getCheckConclusion(detailsStatusCheck) === 'action_required' @@ -965,7 +970,8 @@ export function ChecksList({ checkDetailsContextKey, onLoadCheckDetails, worktreeId: worktreeIdOverride, - detailsStickySurface = 'sidebar' + detailsStickySurface = 'sidebar', + getGitLabProjectRef }: { checks: PRCheckDetail[] checksLoading: boolean @@ -974,6 +980,8 @@ export function ChecksList({ /** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */ worktreeId?: string detailsStickySurface?: CheckDetailsStickySurface + /** Why: a getter, not a value — the source ref is filled by an async fetch and would read stale during render. */ + getGitLabProjectRef?: () => GitLabProjectRef | null }): React.JSX.Element { const activeWorktree = useActiveWorktree() const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null @@ -1041,13 +1049,20 @@ export function ChecksList({ const next: Record = { ...current } for (const row of rows) { const cached = next[row.key] - if (!cached?.details) { + if (!cached || cached.loading) { continue } - if ( - cached.details.status !== row.check.status || - cached.details.conclusion !== row.check.conclusion - ) { + // Why: a failed load (GitLab auth blip, 404, offline) otherwise pins its error + // forever — there is no retry affordance — so re-arm it once the job moves on. + const stale = cached.details + ? cached.details.status !== row.check.status || + cached.details.conclusion !== row.check.conclusion + : Boolean( + cached.errorAt && + (cached.errorAt.status !== row.check.status || + cached.errorAt.conclusion !== row.check.conclusion) + ) + if (stale) { delete next[row.key] changed = true } @@ -1061,7 +1076,12 @@ export function ChecksList({ if (detailsByCheckKey[row.key]?.loading || detailsByCheckKey[row.key]?.details) { return } - if (!row.check.checkRunId && !row.check.workflowRunId && !row.check.url) { + if ( + !row.check.checkRunId && + !row.check.workflowRunId && + !row.check.url && + !row.check.gitlabJobId + ) { setDetailsByCheckKey((current) => ({ ...current, [row.key]: { @@ -1104,7 +1124,16 @@ export function ChecksList({ [row.key]: { loading: false, details, - error: details ? null : 'No inline details are available for this check.' + error: details + ? null + : translate( + 'auto.components.right.sidebar.checks.panel.content.e15a8b77ef', + 'No inline details are available for this check.' + ), + // Why: a detail-less result is only final for this status — re-arm the retry once the job moves on. + errorAt: details + ? undefined + : { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1117,7 +1146,14 @@ export function ChecksList({ [row.key]: { loading: false, details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.right.sidebar.checks.panel.content.checkDetailsLoadFailed', + 'Failed to load check details.' + ), + errorAt: { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1148,12 +1184,14 @@ export function ChecksList({ patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, { details: detailsState.details ?? null, loading: detailsState.loading ?? false, - error: detailsState.error ?? null + error: detailsState.error ?? null, + gitlabProjectRef: getGitLabProjectRef?.() ?? null }) } }, [ checkDetailsContextKey, detailsByCheckKey, + getGitLabProjectRef, patchOpenCheckRunDetails, resolvedWorktreeId, rows @@ -1329,6 +1367,7 @@ export function ChecksList({ checkDetailsContextKey={checkDetailsContextKey} worktreeId={resolvedWorktreeId} detailsStickySurface={detailsStickySurface} + getGitLabProjectRef={getGitLabProjectRef} /> )}
@@ -2018,9 +2057,16 @@ function PRCommentGroupView({ }): React.JSX.Element { // Reply targets a specific comment id so any comment in a thread — root or // nested reply — can be replied to, not just the thread root. - const renderReplyComposer = (comment: PRComment): React.ReactNode => + const renderReplyComposer = (comment: PRComment, nested = false): React.ReactNode => replyingCommentId === comment.id && onReply ? ( -
+
- {renderReplyComposer(reply)} + {renderReplyComposer(reply, true)} ))}
diff --git a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts new file mode 100644 index 00000000000..757c5abc303 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts @@ -0,0 +1,75 @@ +import type { PRComment } from '../../../../shared/types' + +/** Posted when a selected review comment is sent to AI. */ +export const PR_COMMENT_AI_FIXING_REPLY = 'Fixing. Will be in the next commit' + +/** + * GitHub App logins carry a `[bot]` suffix that does not resolve as an @-mention + * (`@coderabbitai[bot]` renders literally; `@coderabbitai` reaches the bot). + */ +export function formatPRCommentMentionHandle(author: string | undefined): string { + return (author ?? '').replace(/\[bot\]$/i, '').trim() +} + +/** Top-level conversation reply body, addressed to the comment author. */ +export function buildPRCommentConversationReplyBody( + author: string | undefined, + body: string +): string { + const handle = formatPRCommentMentionHandle(author) + return handle ? `@${handle} ${body}` : body +} + +const ACK_SNIPPET_MAX_LENGTH = 72 + +/** First readable line of a comment body, minus HTML comments and markdown markers. */ +function summarizePRCommentBody(body: string): string { + const line = body + .replace(//g, ' ') + .split('\n') + .map((candidate) => + candidate + .replace(/^[\s>#*\-_`]+/, '') + .replace(/\s+/g, ' ') + .trim() + ) + .find((candidate) => candidate.length > 0) + if (!line) { + return '' + } + return line.length > ACK_SNIPPET_MAX_LENGTH + ? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…` + : line +} + +/** Short "what this was" label so the batched reply names each item without quoting it whole. */ +export function describePRCommentAckTarget(comment: PRComment): string { + const kind = + typeof comment.url === 'string' && comment.url.includes('pullrequestreview') + ? 'review summary' + : comment.path + ? `comment on ${comment.path}${comment.line == null ? '' : `:${comment.line}`}` + : 'comment' + const snippet = summarizePRCommentBody(comment.body) + return snippet ? `${kind} — ${snippet}` : kind +} + +/** + * One conversation comment covering every selected comment that has no nested-reply + * endpoint, so a bulk send leaves a single readable ack instead of N "Fixing." posts. + */ +export function buildPRCommentBatchConversationReplyBody(comments: readonly PRComment[]): string { + if (comments.length === 0) { + return '' + } + const first = comments[0]! + if (comments.length === 1) { + return buildPRCommentConversationReplyBody(first.author, PR_COMMENT_AI_FIXING_REPLY) + } + const items = comments.map((comment) => { + const handle = formatPRCommentMentionHandle(comment.author) + const label = describePRCommentAckTarget(comment) + return handle ? `- @${handle}: ${label}` : `- ${label}` + }) + return `Fixing:\n${items.join('\n')}\n\nWill be in the next commit.` +} diff --git a/src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts index 388ad914dd0..5df8debe7c8 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts @@ -22,6 +22,10 @@ describe('pr-comment-presentation', () => { expect(cards.group).toContain('shadow-xs') expect(cards.avatar).toContain('border-border') expect(cards.avatar).toContain('bg-background') + // Why: replies must read nested under the root (GitHub-style), not full-width siblings. + expect(cards.repliesContainer).toContain('ml-3') + expect(cards.repliesContainer).toContain('border-l-2') + expect(cards.commentRowReply).toContain('pl-3') const focus = getPRCommentPresentationClasses('focus') expect(focus.useCardLayout).toBe(true) @@ -37,6 +41,8 @@ describe('pr-comment-presentation', () => { expect(focus.commentHeaderReply).toContain('px-3 py-2') expect(focus.commentHeaderMeta).toContain('pl-7') expect(focus.commentHeaderMetaWithSelection).toContain('pl-[3.25rem]') + expect(focus.repliesContainer).toContain('ml-3') + expect(focus.repliesContainer).toContain('border-l-2') }) it('restores block flow for span-rendered markdown paragraphs and headings', () => { diff --git a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts index aa754f61ecb..c6f6e8df702 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts @@ -209,7 +209,8 @@ export function getPRCommentPresentationClasses( groupStandalone: '', groupThread: '', commentRow: 'group/comment', - commentRowReply: `border-t ${COMMENT_CARD_DIVIDER} bg-muted/25 dark:bg-muted/10`, + // Why: nest replies under the root like GitHub threads (indent + left rail), not full-width siblings. + commentRowReply: `border-t ${COMMENT_CARD_DIVIDER} bg-muted/25 pl-3 dark:bg-muted/10`, commentHeader: `flex flex-col gap-1 border-b ${COMMENT_CARD_DIVIDER} ${CARD_COMMENT_HEADER_PADDING}`, commentHeaderReply: `flex min-w-0 items-center gap-2 ${CARD_COMMENT_HEADER_PADDING}`, commentBody: `${CARD_COMMENT_BODY_PADDING} ${CARD_COMMENT_BODY_SIZE} text-foreground`, @@ -224,7 +225,7 @@ export function getPRCommentPresentationClasses( pathBadge: 'min-w-0 max-w-full truncate font-mono text-muted-foreground', time: 'shrink-0 text-[11px] text-muted-foreground', resolvedContainer: 'opacity-60', - repliesContainer: 'flex flex-col', + repliesContainer: 'ml-3 flex flex-col border-l-2 border-border/50', resolvedSection: 'mt-1 border-t border-border pt-1', resolvedSectionTrigger: RESOLVED_SECTION_TRIGGER, resolvedSectionContent: 'flex flex-col gap-2 pb-1 pt-1', diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts new file mode 100644 index 00000000000..2f4708f212a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts @@ -0,0 +1,704 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PRComment } from '../../../../shared/types' +import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import { + acknowledgePRCommentsAfterAiLaunch, + attachPRReviewReplyParent, + canPostPRReviewThreadReply, + checksPanelReviewStableKey, + clearPendingPRCommentAiAck, + getPRCommentGroupReplyTarget, + PR_COMMENT_ACK_MAX_CONCURRENCY, + resolvePRReviewReplyThreadId, + setPendingPRCommentAiAck, + takePendingPRCommentAiAck +} from './pr-comments-ai-launch-ack' +import type { PendingPRCommentAiAck } from './pr-comments-ai-launch-ack' +import { + buildPRCommentBatchConversationReplyBody, + buildPRCommentConversationReplyBody, + formatPRCommentMentionHandle, + PR_COMMENT_AI_FIXING_REPLY +} from './pr-comment-fixing-reply-body' + +function comment(overrides: Partial = {}): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +type PRCommentThreadGroup = Extract +type PRCommentStandaloneGroup = Extract + +function openThread(threadId: string, id = 10): PRCommentThreadGroup { + return { + kind: 'thread', + threadId, + root: comment({ id, threadId, path: 'src/a.ts', isResolved: false }), + replies: [] + } +} + +function standalone(id = 20): PRCommentStandaloneGroup { + return { + kind: 'standalone', + comment: comment({ + id, + body: 'Overall looks good; one nit.', + url: 'https://github.com/x/y/pull/1#issuecomment-9' + }) + } +} + +function codeRabbitReviewSummary(id = 30): PRCommentStandaloneGroup { + return { + kind: 'standalone', + comment: comment({ + id, + author: 'coderabbitai', + isBot: true, + body: '## Nitpick comments (2)\n\n...', + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }) + } +} + +describe('canPostPRReviewThreadReply', () => { + it('allows review-thread comments with threadId, path, or discussion_r url', () => { + expect(canPostPRReviewThreadReply(comment({ id: 1, threadId: 'T1' }))).toBe(true) + expect(canPostPRReviewThreadReply(comment({ id: 2, path: 'a.ts' }))).toBe(true) + expect( + canPostPRReviewThreadReply( + comment({ id: 3, url: 'https://github.com/acme/widgets/pull/42#discussion_r99' }) + ) + ).toBe(true) + }) + + it('rejects conversation comments, review summaries, and invalid ids', () => { + expect( + canPostPRReviewThreadReply( + comment({ id: 3, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + ) + ).toBe(false) + expect( + canPostPRReviewThreadReply( + comment({ + id: 4, + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }) + ) + ).toBe(false) + expect(canPostPRReviewThreadReply(comment({ id: 0, threadId: 'T1' }))).toBe(false) + }) + + // Why: CodeRabbit review summaries can still carry path/threadId metadata; the + // pullrequestreview anchor must win or the replies endpoint 404s. + it('rejects review summaries even when thread metadata is present', () => { + expect( + canPostPRReviewThreadReply( + comment({ + id: 5, + threadId: 'T1', + path: 'src/a.ts', + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }) + ) + ).toBe(false) + }) +}) + +describe('buildPRCommentConversationReplyBody', () => { + it('strips the [bot] login suffix so the mention resolves', () => { + expect(formatPRCommentMentionHandle('coderabbitai[bot]')).toBe('coderabbitai') + expect( + buildPRCommentConversationReplyBody('coderabbitai[bot]', PR_COMMENT_AI_FIXING_REPLY) + ).toBe(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) + }) + + it('keeps human logins and drops the mention when the author is unknown', () => { + expect(buildPRCommentConversationReplyBody('alice', 'Fixing.')).toBe('@alice Fixing.') + expect(buildPRCommentConversationReplyBody(undefined, 'Fixing.')).toBe('Fixing.') + }) +}) + +describe('buildPRCommentBatchConversationReplyBody', () => { + it('returns an empty body for an empty selection', () => { + expect(buildPRCommentBatchConversationReplyBody([])).toBe('') + }) + + it('keeps the plain @-reply for a single target', () => { + expect( + buildPRCommentBatchConversationReplyBody([comment({ author: 'coderabbitai[bot]' })]) + ).toBe(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) + }) + + it('lists every target once with author, kind, and a short label', () => { + const body = buildPRCommentBatchConversationReplyBody([ + comment({ + author: 'coderabbitai[bot]', + body: '\n## Nitpick comments (2)\n\ndetails', + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }), + comment({ + author: 'alice', + body: 'Please rename this variable.', + url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' + }), + comment({ author: 'bob', body: 'Off by one.', path: 'src/a.ts', line: 12 }) + ]) + + expect(body).toBe( + [ + 'Fixing:', + '- @coderabbitai: review summary — Nitpick comments (2)', + '- @alice: comment — Please rename this variable.', + '- @bob: comment on src/a.ts:12 — Off by one.', + '', + 'Will be in the next commit.' + ].join('\n') + ) + }) + + it('truncates long bodies instead of quoting the whole comment', () => { + const body = buildPRCommentBatchConversationReplyBody([ + comment({ author: 'alice', body: 'a'.repeat(200) }), + comment({ author: 'bob', body: '' }) + ]) + + expect(body).toContain(`- @alice: comment — ${'a'.repeat(71)}…`) + expect(body).toContain('- @bob: comment\n') + }) +}) + +describe('getPRCommentGroupReplyTarget', () => { + // Why: GitHub's replies endpoint keys off the top-level review comment id. + it('uses the thread root even when later replies exist', () => { + const group: PRCommentGroup = { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'a.ts' }), + replies: [ + comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'first' }), + comment({ id: 12, threadId: 'T1', path: 'a.ts', body: 'latest' }) + ] + } + expect(getPRCommentGroupReplyTarget(group).id).toBe(10) + }) + + it('falls back to the root when there are no replies', () => { + const group = openThread('T1', 10) + expect(getPRCommentGroupReplyTarget(group).id).toBe(10) + }) +}) + +describe('attachPRReviewReplyParent', () => { + it('fills missing thread metadata from the parent', () => { + const parent = comment({ + id: 10, + threadId: 'T1', + path: 'src/a.ts', + line: 4, + isResolved: false + }) + const reply = comment({ id: 99, body: 'Fixing.' }) + expect(attachPRReviewReplyParent(reply, parent)).toMatchObject({ + id: 99, + body: 'Fixing.', + threadId: 'T1', + path: 'src/a.ts', + line: 4, + isResolved: false + }) + }) + + it('does not overwrite explicit reply metadata', () => { + const parent = comment({ id: 10, threadId: 'T1', path: 'src/a.ts', line: 4 }) + const reply = comment({ id: 99, threadId: 'T2', path: 'src/b.ts', line: 9 }) + expect(attachPRReviewReplyParent(reply, parent)).toMatchObject({ + threadId: 'T2', + path: 'src/b.ts', + line: 9 + }) + }) +}) +describe('resolvePRReviewReplyThreadId', () => { + it('prefers the parent threadId', () => { + expect( + resolvePRReviewReplyThreadId({ + parent: comment({ id: 1, threadId: 'T1', path: 'a.ts' }), + existingComments: [] + }) + ).toBe('T1') + }) + + it('recovers threadId from existing comments by id or path/line', () => { + const existing = [ + comment({ id: 1, threadId: 'T_path', path: 'src/a.ts', line: 3 }), + comment({ id: 2, threadId: 'T_id', path: 'src/b.ts', line: 9 }) + ] + expect( + resolvePRReviewReplyThreadId({ + parent: comment({ id: 2, path: 'src/b.ts', line: 9 }), + existingComments: existing + }) + ).toBe('T_id') + expect( + resolvePRReviewReplyThreadId({ + parent: comment({ id: 50, path: 'src/a.ts', line: 3 }), + existingComments: existing + }) + ).toBe('T_path') + }) + + it('refuses a path-only match when the file holds several threads', () => { + const existing = [ + comment({ id: 1, threadId: 'T_one', path: 'src/a.ts', line: 3 }), + comment({ id: 2, threadId: 'T_two', path: 'src/a.ts', line: 40 }) + ] + expect( + resolvePRReviewReplyThreadId({ + parent: comment({ id: 50, path: 'src/a.ts', line: undefined }), + existingComments: existing + }) + ).toBeUndefined() + // A line-scoped parent still matches its own thread. + expect( + resolvePRReviewReplyThreadId({ + parent: comment({ id: 50, path: 'src/a.ts', line: 40 }), + existingComments: existing + }) + ).toBe('T_two') + }) +}) + +describe('checksPanelReviewStableKey', () => { + it('drops the trailing headSha segment', () => { + expect(checksPanelReviewStableKey('repo::main::owner/repo::12::abc123')).toBe( + 'repo::main::owner/repo::12' + ) + }) + + it('passes through a key with no separator', () => { + expect(checksPanelReviewStableKey('solo')).toBe('solo') + }) +}) + +describe('acknowledgePRCommentsAfterAiLaunch', () => { + it('replies in-thread and resolves host-resolvable threads', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) + const replyInThread = vi.fn().mockResolvedValue(true) + const replyAsConversation = vi.fn().mockResolvedValue(true) + const group = openThread('T1') + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group, openThread('T2', 11)], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread, + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 2, replied: 2, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledTimes(2) + expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + expect(replyAsConversation).not.toHaveBeenCalled() + expect(resolveThread).toHaveBeenCalledTimes(2) + }) + + // Why: on GitHub canReply is true; a failed resolve still leaves the thread open and + // must count as failed or the success toast lies. + it('still replies in-thread when resolve fails, and counts the failed resolve', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const group = openThread('T1') + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn().mockResolvedValue(false), + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 1 }) + expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + }) + + it('posts a conversation @-reply for standalone conversation comments', async () => { + const replyAsConversation = vi.fn().mockResolvedValue(true) + const group = standalone(42) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn(), + canReply: true, + replyInThread: vi.fn(), + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyAsConversation).toHaveBeenCalledTimes(1) + expect(replyAsConversation).toHaveBeenCalledWith(`@alice ${PR_COMMENT_AI_FIXING_REPLY}`) + }) + + it('posts no conversation comment when nothing in the selection needs one', async () => { + const replyAsConversation = vi.fn() + + await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1'), openThread('T2', 11)], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn().mockResolvedValue(true), + canReply: true, + replyInThread: vi.fn().mockResolvedValue(true), + replyAsConversation + } + }) + + expect(replyAsConversation).not.toHaveBeenCalled() + }) + + it('posts a conversation @-reply for CodeRabbit review summaries', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const replyAsConversation = vi.fn().mockResolvedValue(true) + const group = codeRabbitReviewSummary(77) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn(), + canReply: true, + replyInThread, + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyInThread).not.toHaveBeenCalled() + expect(replyAsConversation).toHaveBeenCalledWith(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) + }) + + // Why: the reported bug — N selected summaries used to produce N near-identical + // "Fixing." conversation comments; one combined post covers the whole set. + it('combines every unresolvable review summary into one conversation comment', async () => { + const replyAsConversation = vi.fn().mockResolvedValue(true) + const resolveThread = vi.fn() + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [codeRabbitReviewSummary(30), codeRabbitReviewSummary(31), standalone(32)], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => false, + resolveThread, + canReply: true, + replyInThread: vi.fn(), + replyAsConversation + } + }) + + // Why: one host POST, so replied counts 1 — not one per selected comment. + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyAsConversation).toHaveBeenCalledTimes(1) + expect(replyAsConversation.mock.calls[0]?.[0]).toBe( + [ + 'Fixing:', + '- @coderabbitai: review summary — Nitpick comments (2)', + '- @coderabbitai: review summary — Nitpick comments (2)', + '- @alice: comment — Overall looks good; one nit.', + '', + 'Will be in the next commit.' + ].join('\n') + ) + expect(resolveThread).not.toHaveBeenCalled() + }) + + it('mixes in-thread and conversation replies in a single selection', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const replyAsConversation = vi.fn().mockResolvedValue(true) + const thread = openThread('T1', 10) + const summary = codeRabbitReviewSummary(30) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [thread, summary], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn().mockResolvedValue(true), + canReply: true, + replyInThread, + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 1, replied: 2, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledWith(thread.root, PR_COMMENT_AI_FIXING_REPLY) + expect(replyAsConversation).toHaveBeenCalledWith(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`) + }) + + // Why: nestable threads keep one reply each; only the conversation half is batched. + it('replies once per thread and once for the whole conversation set', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const replyAsConversation = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [ + openThread('T1', 10), + codeRabbitReviewSummary(30), + openThread('T2', 11), + standalone(31) + ], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn().mockResolvedValue(true), + canReply: true, + replyInThread, + replyAsConversation + } + }) + + expect(result).toEqual({ resolved: 2, replied: 3, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledTimes(2) + expect(replyAsConversation).toHaveBeenCalledTimes(1) + expect(replyAsConversation.mock.calls[0]?.[0]).toContain('Fixing:') + }) + + it('counts a failed conversation reply instead of silently reporting success', async () => { + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [codeRabbitReviewSummary(30)], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => false, + resolveThread: vi.fn(), + canReply: true, + replyInThread: vi.fn(), + replyAsConversation: vi.fn().mockResolvedValue(false) + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + }) + + it('replies to the thread root in a multi-comment thread', async () => { + const replyInThread = vi.fn().mockResolvedValue(true) + const group: PRCommentGroup = { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'a.ts', isResolved: false }), + replies: [comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'prior reply' })] + } + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [group], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => false, + resolveThread: vi.fn(), + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 }) + expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY) + }) + + it('skips when reply is unavailable and resolve cannot run', async () => { + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [standalone()], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn(), + canReply: false, + replyInThread: vi.fn(), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 1, failed: 0 }) + }) + + it('counts a failure when resolve fails and reply is unavailable', async () => { + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1')], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: vi.fn().mockResolvedValue(false), + canReply: false, + replyInThread: vi.fn(), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + }) + + it('keeps a durable pending payload across take', () => { + const payload: PendingPRCommentAiAck = { + reviewContextKey: 'repo::main::owner/repo::12::abc', + provider: 'github', + selectedThreadIds: ['T1'], + selectedGroups: [openThread('T1')] + } + clearPendingPRCommentAiAck() + setPendingPRCommentAiAck(payload) + expect(takePendingPRCommentAiAck()).toBe(payload) + expect(takePendingPRCommentAiAck()).toBeNull() + }) + + it('still posts all replies when the review context becomes stale mid-loop', async () => { + let live = true + const replyInThread = vi.fn().mockImplementation(async () => { + live = false + return true + }) + const resolveThread = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1'), openThread('T2', 11), openThread('T3', 12)], + deps: { + isStillCurrent: () => live, + isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread, + replyAsConversation: vi.fn() + } + }) + + // Why: replies must not depend on checks-panel key stability; resolve stops after context + // goes stale, and each skipped resolve is reported rather than silently counted as success. + expect(replyInThread).toHaveBeenCalledTimes(3) + expect(resolveThread).toHaveBeenCalledTimes(0) + expect(result).toEqual({ resolved: 0, replied: 3, skipped: 3, failed: 0 }) + }) + + // Why: resolving after a failed reply closes the thread with no ack at all. + it('leaves a thread open when its fixing reply failed', async () => { + const resolveThread = vi.fn().mockResolvedValue(true) + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1')], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread: vi.fn().mockResolvedValue(false), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + expect(resolveThread).not.toHaveBeenCalled() + }) + + it('does not resolve batched conversation groups when the combined post failed', async () => { + const resolveThread = vi.fn() + const summaryThread: PRCommentGroup = { + kind: 'thread', + threadId: 'T_summary', + root: comment({ + id: 30, + threadId: 'T_summary', + isResolved: false, + url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999' + }), + replies: [] + } + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [summaryThread], + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread: vi.fn(), + replyAsConversation: vi.fn().mockResolvedValue(false) + } + }) + + expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 }) + expect(resolveThread).not.toHaveBeenCalled() + }) + + it('reports a resolvable thread as skipped when the panel context moved', async () => { + const resolveThread = vi.fn() + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups: [openThread('T1')], + deps: { + isStillCurrent: () => false, + isThreadStillResolvable: () => true, + resolveThread, + canReply: true, + replyInThread: vi.fn().mockResolvedValue(true), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 0, replied: 1, skipped: 1, failed: 0 }) + expect(resolveThread).not.toHaveBeenCalled() + }) + + it('runs groups with bounded concurrency, replying before resolving in each group', async () => { + const groups = Array.from({ length: 9 }, (_, index) => openThread(`T${index}`, 100 + index)) + const order: string[] = [] + let inFlight = 0 + let peakInFlight = 0 + + const track = async (label: string, value: T): Promise => { + inFlight += 1 + peakInFlight = Math.max(peakInFlight, inFlight) + order.push(label) + await Promise.resolve() + inFlight -= 1 + return value + } + + const result = await acknowledgePRCommentsAfterAiLaunch({ + groups, + deps: { + isStillCurrent: () => true, + isThreadStillResolvable: () => true, + resolveThread: (threadId) => track(`resolve:${threadId}`, true), + canReply: true, + replyInThread: (comment) => track(`reply:${comment.threadId}`, true), + replyAsConversation: vi.fn() + } + }) + + expect(result).toEqual({ resolved: 9, replied: 9, skipped: 0, failed: 0 }) + expect(peakInFlight).toBeGreaterThan(1) + expect(peakInFlight).toBeLessThanOrEqual(PR_COMMENT_ACK_MAX_CONCURRENCY) + for (const group of groups) { + expect(order.indexOf(`reply:${group.threadId}`)).toBeLessThan( + order.indexOf(`resolve:${group.threadId}`) + ) + } + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts new file mode 100644 index 00000000000..c73ab1aee1d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -0,0 +1,343 @@ +import type { PRComment, PRInfo } from '../../../../shared/types' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { getPRCommentGroupRoot, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' +import { + buildPRCommentBatchConversationReplyBody, + PR_COMMENT_AI_FIXING_REPLY +} from './pr-comment-fixing-reply-body' + +export type PRCommentAiLaunchAckCounts = { + resolved: number + replied: number + skipped: number + failed: number +} + +export type PRCommentAiLaunchAckDeps = { + isStillCurrent: () => boolean + /** True when the thread is still open and host-resolvable in live comment state. */ + isThreadStillResolvable: (threadId: string) => boolean + resolveThread: (threadId: string) => Promise + /** Provider supports posting a reply (GitHub in the Checks panel today). */ + canReply: boolean + /** + * Nested review-thread reply (pulls/.../comments/{id}/replies). + * Only call when canPostPRReviewThreadReply(comment) is true. + */ + replyInThread: (comment: PRComment, body: string) => Promise + /** + * One top-level PR conversation comment (issues/.../comments) covering every selected + * comment with no nested-reply endpoint (review summaries, CodeRabbit, plain comments). + */ + replyAsConversation: (body: string) => Promise +} + +/** + * True when GitHub can nest a reply under this comment (inline review-thread comment). + * Conversation comments and PR review summaries (CodeRabbit, etc.) cannot use that API. + */ +export function canPostPRReviewThreadReply( + comment: Pick +): boolean { + if (!Number.isSafeInteger(comment.id) || comment.id <= 0) { + return false + } + // Why: review-summary anchors use #pullrequestreview-N; that id is not a review + // comment id and will 404 on the replies endpoint. + if (typeof comment.url === 'string' && comment.url.includes('pullrequestreview')) { + return false + } + // Why: threadId is the GraphQL thread key; path marks review comments even when + // thread metadata is missing; #discussion_r is the REST anchor for review comments. + if (Boolean(comment.threadId) || Boolean(comment.path)) { + return true + } + return typeof comment.url === 'string' && comment.url.includes('discussion_r') +} + +/** + * Reply to the thread root: GitHub's replies endpoint keys off the top-level review + * comment id, and a later reply's id can 404 there. + */ +export function getPRCommentGroupReplyTarget(group: PRCommentGroup): PRComment { + return getPRCommentGroupRoot(group) +} + +/** + * Attach parent review-thread metadata so the sidebar groups the reply under the + * original comment immediately (GitHub REST replies often omit threadId/path). + */ +export function attachPRReviewReplyParent( + reply: PRComment, + parent: Pick +): PRComment { + return { + ...reply, + threadId: reply.threadId ?? parent.threadId, + path: reply.path ?? parent.path, + line: reply.line ?? parent.line, + startLine: reply.startLine ?? parent.startLine, + isResolved: reply.isResolved ?? parent.isResolved, + isOutdated: reply.isOutdated ?? parent.isOutdated + } +} + +/** + * Resolve a threadId for a reply target from the live comment list when the + * target itself is missing one (path-only review comments). + */ +export function resolvePRReviewReplyThreadId(args: { + parent: Pick + existingComments: readonly PRComment[] +}): string | undefined { + if (args.parent.threadId) { + return args.parent.threadId + } + const byId = args.existingComments.find( + (comment) => comment.id === args.parent.id && Boolean(comment.threadId) + ) + if (byId?.threadId) { + return byId.threadId + } + if (!args.parent.path) { + return undefined + } + const siblingThreadIds = new Set( + args.existingComments.flatMap((comment) => + comment.threadId && + comment.path === args.parent.path && + (args.parent.line == null || comment.line === args.parent.line) + ? [comment.threadId] + : [] + ) + ) + // Why: several threads on one file give no reliable parent; a wrong threadId misfiles the reply. + return siblingThreadIds.size === 1 ? [...siblingThreadIds][0] : undefined +} + +/** + * Drop the trailing headSha segment from a checks-panel async key. + * Agent launch (submit-after-ready) can take long enough for PR head to move; + * ack must not abort just because headSha churned. + */ +export function checksPanelReviewStableKey(asyncResultKey: string): string { + const parts = asyncResultKey.split('::') + if (parts.length <= 1) { + return asyncResultKey + } + return parts.slice(0, -1).join('::') +} + +/** + * Bulk ack fans out over the host API (`gh`), which is slow on SSH; the shared + * client already caps itself at 4 in-flight requests, so match that ceiling. + */ +export const PR_COMMENT_ACK_MAX_CONCURRENCY = 4 + +const EMPTY_ACK_COUNTS: PRCommentAiLaunchAckCounts = { + resolved: 0, + replied: 0, + skipped: 0, + failed: 0 +} + +/** Fixed-size worker pool; results stay in input order. */ +async function mapWithBoundedConcurrency( + items: readonly T[], + limit: number, + run: (item: T) => Promise +): Promise { + const results = Array.from({ length: items.length }) + let next = 0 + const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => { + while (next < items.length) { + const index = next + next += 1 + results[index] = await run(items[index]!) + } + }) + await Promise.all(workers) + return results +} + +type BatchedConversationReply = { + counts: PRCommentAiLaunchAckCounts + handled: ReadonlySet + replyOk: boolean +} + +const NO_BATCHED_CONVERSATION_REPLY: BatchedConversationReply = { + counts: EMPTY_ACK_COUNTS, + handled: new Set(), + replyOk: false +} + +/** + * After launching an agent for selected review feedback: + * post a fixing reply (one nested reply per review thread, one combined conversation + * comment for everything that has no nested-reply endpoint), then resolve host threads. + * + * Why: replies use a snapshotted GitHub target and must not abort just because + * the checks-panel async key churned mid-launch. isStillCurrent only gates + * host-thread resolve + UI refresh, not the reply posts themselves. + * + * Groups run through a bounded pool; reply-before-resolve stays serial *within* + * a group because the resolve decision reads live comment state the reply mutates. + */ +export async function acknowledgePRCommentsAfterAiLaunch(args: { + groups: readonly PRCommentGroup[] + deps: PRCommentAiLaunchAckDeps +}): Promise { + // Why: conversation comments have no replies endpoint, so one post per selected item + // would spam the PR timeline with near-identical "Fixing." comments. + const conversationGroups = args.deps.canReply + ? args.groups.filter( + (group) => !canPostPRReviewThreadReply(getPRCommentGroupReplyTarget(group)) + ) + : [] + const batch = await postBatchedConversationReply(conversationGroups, args.deps) + + const perGroup = await mapWithBoundedConcurrency( + args.groups, + PR_COMMENT_ACK_MAX_CONCURRENCY, + (group) => acknowledgePRCommentGroup(group, args.deps, batch) + ) + + return [batch.counts, ...perGroup].reduce( + (totals, counts) => ({ + resolved: totals.resolved + counts.resolved, + replied: totals.replied + counts.replied, + skipped: totals.skipped + counts.skipped, + failed: totals.failed + counts.failed + }), + EMPTY_ACK_COUNTS + ) +} + +async function postBatchedConversationReply( + groups: readonly PRCommentGroup[], + deps: PRCommentAiLaunchAckDeps +): Promise { + if (groups.length === 0) { + return NO_BATCHED_CONVERSATION_REPLY + } + const replyOk = await deps.replyAsConversation( + buildPRCommentBatchConversationReplyBody(groups.map(getPRCommentGroupReplyTarget)) + ) + return { + // Why: one host POST covers the whole set, so the toast must count it once. + counts: { ...EMPTY_ACK_COUNTS, replied: replyOk ? 1 : 0, failed: replyOk ? 0 : 1 }, + handled: new Set(groups), + replyOk + } +} + +async function acknowledgePRCommentGroup( + group: PRCommentGroup, + deps: PRCommentAiLaunchAckDeps, + batch: BatchedConversationReply +): Promise { + const counts = { ...EMPTY_ACK_COUNTS } + const threadId = + group.kind === 'thread' && isResolvablePRCommentGroup(group) ? group.threadId : null + + if (deps.canReply) { + const replyOk = batch.handled.has(group) + ? batch.replyOk + : await postInThreadFixingReply(group, deps, counts) + // Why: resolving a thread whose fixing reply never landed closes it silently — + // the user was promised an ack, so leave it open and let the toast report the failure. + if (!replyOk) { + return counts + } + } + + if (threadId == null || !deps.isThreadStillResolvable(threadId)) { + if (!deps.canReply) { + counts.skipped += 1 + } + return counts + } + + // Why: resolve is UI/host-state sensitive; skip it if the panel context moved. + // Replies already used a snapshotted target and must not be gated the same way. + if (!deps.isStillCurrent()) { + counts.skipped += 1 + return counts + } + + if (await deps.resolveThread(threadId)) { + counts.resolved += 1 + } else { + // Why: a resolve failure leaves the thread open on the host regardless of whether + // the reply landed; counting it only when !canReply made GitHub failures invisible. + counts.failed += 1 + } + return counts +} + +/** Mutates counts in place; returns whether the nested reply landed. */ +async function postInThreadFixingReply( + group: PRCommentGroup, + deps: PRCommentAiLaunchAckDeps, + counts: PRCommentAiLaunchAckCounts +): Promise { + const replyOk = await deps.replyInThread( + getPRCommentGroupReplyTarget(group), + PR_COMMENT_AI_FIXING_REPLY + ) + if (replyOk) { + counts.replied += 1 + } else { + counts.failed += 1 + } + return replyOk +} + +/** Snapshotted at queue time so the post-launch ack does not depend on live React state. */ +export type PendingPRCommentAiAckGithubTarget = { + repoPath: string + repoId: string + prNumber: number + prRepo: NonNullable +} + +/** What a queued comment-resolution launch must still ack once its prompt is delivered. */ +export type PendingPRCommentAiAck = { + reviewContextKey: string + provider: HostedReviewInfo['provider'] + selectedThreadIds: string[] + selectedGroups: PRCommentGroup[] + githubTarget?: PendingPRCommentAiAckGithubTarget +} + +/** Durable pending ack payload so dialog close / re-renders cannot drop it before launch. */ +let pendingAiCommentAck: PendingPRCommentAiAck | null = null + +/** + * The slot only ever holds an *unclaimed* queue: an accepted launch takes ownership via + * takePendingPRCommentAiAck and parks the payload itself. Replacing an unclaimed payload is + * how a re-opened composer re-queues, so log it — a clobbered accepted launch would be a bug. + */ +export function setPendingPRCommentAiAck(payload: PendingPRCommentAiAck): void { + if (pendingAiCommentAck && pendingAiCommentAck !== payload) { + console.warn( + 'Replacing an unclaimed PR comment ack payload', + pendingAiCommentAck.reviewContextKey, + '->', + payload.reviewContextKey + ) + } + pendingAiCommentAck = payload +} + +export function takePendingPRCommentAiAck(): PendingPRCommentAiAck | null { + const payload = pendingAiCommentAck + pendingAiCommentAck = null + return payload +} + +export function clearPendingPRCommentAiAck(): void { + pendingAiCommentAck = null +} diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts index 345f1b6a09b..64f191c007a 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -71,6 +71,122 @@ describe('runSourceControlAgentActionStart', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it('notifies onLaunchAccepted as soon as the agent tab is created, before prompt delivery', async () => { + let resolveDelivery: (value: { + delivered: boolean + failureNotified: boolean + }) => void = () => {} + const promptDeliveryResult = new Promise<{ delivered: boolean; failureNotified: boolean }>( + (resolve) => { + resolveDelivery = resolve + } + ) + const onLaunchAccepted = vi.fn() + const onLaunchAborted = vi.fn() + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult + }) + + const launchPromise = runSourceControlAgentActionStart( + buildArgs({ onLaunchAccepted, onLaunchAborted }) + ) + + await vi.waitFor(() => { + expect(onLaunchAccepted).toHaveBeenCalledTimes(1) + }) + expect(mocks.onLaunched).not.toHaveBeenCalled() + + resolveDelivery({ delivered: true, failureNotified: false }) + await expect(launchPromise).resolves.toBe(true) + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + expect(onLaunchAborted).not.toHaveBeenCalled() + }) + + // Why: onLaunchAccepted only parks launch-scoped state; firing it twice or on a dead + // launch would leave a caller believing an agent is running. + it('fires onLaunchAccepted exactly once and only when a tab was created', async () => { + const onLaunchAccepted = vi.fn() + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) + }) + + await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted }))).resolves.toBe( + true + ) + expect(onLaunchAccepted).toHaveBeenCalledTimes(1) + + vi.clearAllMocks() + mocks.launchAgentInNewTab.mockReturnValue(null) + + await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted }))).resolves.toBe( + false + ) + expect(onLaunchAccepted).not.toHaveBeenCalled() + }) + + // Why: irreversible host writes (fixing replies, thread resolves) hang off onLaunched, so a + // launch whose prompt never landed must report the abort and never reach onLaunched. + it('aborts an accepted launch when deferred prompt delivery fails', async () => { + const onLaunchAccepted = vi.fn() + const onLaunchAborted = vi.fn() + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true }) + }) + + await expect( + runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted, onLaunchAborted })) + ).resolves.toBe(false) + + expect(onLaunchAccepted).toHaveBeenCalledTimes(1) + expect(onLaunchAborted).toHaveBeenCalledTimes(1) + expect(mocks.onLaunched).not.toHaveBeenCalled() + }) + + it('aborts an accepted launch when promptDeliveryResult rejects', async () => { + const onLaunchAccepted = vi.fn() + const onLaunchAborted = vi.fn() + const originalConsole = console + vi.stubGlobal('console', { ...originalConsole, error: vi.fn() }) + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.reject(new Error('boom')) + }) + + try { + await expect( + runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted, onLaunchAborted })) + ).resolves.toBe(false) + + expect(onLaunchAccepted).toHaveBeenCalledTimes(1) + expect(onLaunchAborted).toHaveBeenCalledTimes(1) + expect(mocks.onLaunched).not.toHaveBeenCalled() + } finally { + vi.stubGlobal('console', originalConsole) + } + }) + + it('does not report an abort when no tab was ever created', async () => { + const onLaunchAborted = vi.fn() + mocks.launchAgentInNewTab.mockReturnValue(null) + + await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAborted }))).resolves.toBe( + false + ) + + expect(onLaunchAborted).not.toHaveBeenCalled() + }) + it('keeps the source-control dialog open when deferred prompt delivery fails', async () => { mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1', @@ -168,4 +284,77 @@ describe('runSourceControlAgentActionStart', () => { expect(mocks.onLaunched).toHaveBeenCalledTimes(1) expect(mocks.onClose).toHaveBeenCalledTimes(1) }) + + // Why: the onStart branch never awaits a delivery result, so acceptance is terminal there. + it('accepts an onStart launch and never aborts it', async () => { + const onLaunchAccepted = vi.fn() + const onLaunchAborted = vi.fn() + const onStart = vi.fn().mockResolvedValue(true) + + await expect( + runSourceControlAgentActionStart( + buildArgs({ + onStart, + worktreeId: undefined, + groupId: undefined, + onLaunchAccepted, + onLaunchAborted + }) + ) + ).resolves.toBe(true) + + expect(onLaunchAccepted).toHaveBeenCalledTimes(1) + expect(onLaunchAborted).not.toHaveBeenCalled() + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + }) + + it('reports neither callback when onStart declines the launch', async () => { + const onLaunchAccepted = vi.fn() + const onLaunchAborted = vi.fn() + const onStart = vi.fn().mockResolvedValue(false) + + await expect( + runSourceControlAgentActionStart( + buildArgs({ + onStart, + worktreeId: undefined, + groupId: undefined, + onLaunchAccepted, + onLaunchAborted + }) + ) + ).resolves.toBe(false) + + expect(onLaunchAccepted).not.toHaveBeenCalled() + expect(onLaunchAborted).not.toHaveBeenCalled() + }) + + // Why: a rejected recipe save after acceptance would otherwise strand the caller with + // neither onLaunched nor onLaunchAborted, permanently disabling the action. + it('completes the launch when saving the agent default rejects', async () => { + const onLaunchAborted = vi.fn() + const originalConsole = console + const consoleError = vi.fn() + vi.stubGlobal('console', { ...originalConsole, error: consoleError }) + mocks.onSaveAgentDefault.mockRejectedValue(new Error('settings not loaded')) + mocks.launchAgentInNewTab.mockReturnValue({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) + }) + + try { + await expect( + runSourceControlAgentActionStart(buildArgs({ saveTargetValue: 'global', onLaunchAborted })) + ).resolves.toBe(true) + + expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1) + expect(mocks.onLaunched).toHaveBeenCalledTimes(1) + expect(mocks.onClose).toHaveBeenCalledTimes(1) + expect(onLaunchAborted).not.toHaveBeenCalled() + } finally { + vi.stubGlobal('console', originalConsole) + } + }) }) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index ee6c24e326c..278117bb68b 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -37,6 +37,15 @@ type RunSourceControlAgentActionStartArgs = { actionId: SourceControlLaunchActionId, recipe: SourceControlActionRecipe ) => void | Promise + /** + * Fires as soon as the agent tab/session is created, before deferred + * submit-after-ready prompt delivery finishes. Reversible bookkeeping only — + * irreversible side effects (posting host replies, resolving threads) belong in + * onLaunched, which only fires once the prompt actually reached the agent. + */ + onLaunchAccepted?: () => void + /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ + onLaunchAborted?: () => void onLaunched?: () => void onClose: () => void } @@ -58,17 +67,30 @@ export async function runSourceControlAgentActionStart({ launchSource, onStart, onSaveAgentDefault, + onLaunchAccepted, + onLaunchAborted, onLaunched, onClose }: RunSourceControlAgentActionStartArgs): Promise { let launched = false let launchFailureNotified = false + let launchAcceptedNotified = false + const notifyLaunchAccepted = (): void => { + if (launchAcceptedNotified) { + return + } + launchAcceptedNotified = true + onLaunchAccepted?.() + } if (onStart) { launched = await onStart({ agent: selectedAgent, commandInput: trimmedCommandInput, agentArgs }) + if (launched) { + notifyLaunchAccepted() + } } else if (worktreeId) { const result = launchAgentInNewTab({ agent: selectedAgent, @@ -84,6 +106,11 @@ export async function runSourceControlAgentActionStart({ if (result?.tabId) { focusTerminalTabSurface(result.tabId) } + // Why: lets callers park launch-scoped state before submit-after-ready finishes + // (can take tens of seconds); host mutations still wait for delivery below. + if (launched) { + notifyLaunchAccepted() + } if (result?.promptDeliveryResult) { try { const deliveryResult = await result.promptDeliveryResult @@ -96,6 +123,9 @@ export async function runSourceControlAgentActionStart({ } } if (!launched) { + if (launchAcceptedNotified) { + onLaunchAborted?.() + } if (!launchFailureNotified) { toast.error( translate( @@ -124,7 +154,13 @@ export async function runSourceControlAgentActionStart({ }) ) if (saveTarget && onSaveAgentDefault && !launchRecipeAlreadySaved) { - await onSaveAgentDefault(saveTarget, actionId, launchRecipe) + try { + await onSaveAgentDefault(saveTarget, actionId, launchRecipe) + } catch (error) { + // Why: the prompt already reached the agent; a failed recipe save must not strand the + // caller's accepted-launch bookkeeping (neither onLaunched nor onLaunchAborted would fire). + console.error('onSaveAgentDefault failed', error) + } } onLaunched?.() onClose() diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx index 3acc9ae44be..76b2af907aa 100644 --- a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx @@ -24,6 +24,7 @@ describe('usePersistedAiVaultViewOptions', () => { first.result.current.setSort('created') first.result.current.setGroup('folder') first.result.current.setHideEmptySessions(true) + first.result.current.setSessionLimit('unlimited') }) first.unmount() @@ -32,6 +33,7 @@ describe('usePersistedAiVaultViewOptions', () => { expect(restored.result.current.sort).toBe('created') expect(restored.result.current.group).toBe('folder') expect(restored.result.current.hideEmptySessions).toBe(true) + expect(restored.result.current.sessionLimit).toBe('unlimited') }) it('allows clearing every agent so a single agent can be re-enabled', () => { @@ -81,6 +83,7 @@ describe('usePersistedAiVaultViewOptions', () => { hook.result.current.setSort('created') hook.result.current.setGroup('agent') hook.result.current.setHideEmptySessions(true) + hook.result.current.setSessionLimit(1000) hook.result.current.resetViewOptions() }) @@ -88,6 +91,7 @@ describe('usePersistedAiVaultViewOptions', () => { expect(hook.result.current.sort).toBe('updated') expect(hook.result.current.group).toBe('project') expect(hook.result.current.hideEmptySessions).toBe(false) + expect(hook.result.current.sessionLimit).toBe(250) hook.unmount() const restored = renderHook(() => usePersistedAiVaultViewOptions()) @@ -95,5 +99,6 @@ describe('usePersistedAiVaultViewOptions', () => { expect(restored.result.current.sort).toBe('updated') expect(restored.result.current.group).toBe('project') expect(restored.result.current.hideEmptySessions).toBe(false) + expect(restored.result.current.sessionLimit).toBe(250) }) }) diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts index 0315c242af7..e87588f0015 100644 --- a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts @@ -12,6 +12,7 @@ import { writeAiVaultViewOptions, type AiVaultViewOptions } from './ai-vault-view-options-persistence' +import type { AiVaultSessionLimit } from './ai-vault-session-limit' type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOptions @@ -20,9 +21,11 @@ export function usePersistedAiVaultViewOptions(): { sort: AiVaultSort group: AiVaultGroup hideEmptySessions: boolean + sessionLimit: AiVaultSessionLimit setSort: (sort: AiVaultSort) => void setGroup: (group: AiVaultGroup) => void setHideEmptySessions: (hide: boolean) => void + setSessionLimit: (limit: AiVaultSessionLimit) => void setAgentEnabled: (agent: AiVaultAgent, enabled: boolean) => void setAllAgentsEnabled: (enabled: boolean) => void resetViewOptions: () => void @@ -65,6 +68,13 @@ export function usePersistedAiVaultViewOptions(): { ), [updateOptions] ) + const setSessionLimit = useCallback( + (sessionLimit: AiVaultSessionLimit) => + updateOptions((current) => + current.sessionLimit === sessionLimit ? current : { ...current, sessionLimit } + ), + [updateOptions] + ) const setAgentEnabled = useCallback( (agent: AiVaultAgent, enabled: boolean) => { updateOptions((current) => { @@ -110,9 +120,11 @@ export function usePersistedAiVaultViewOptions(): { sort: options.sort, group: options.group, hideEmptySessions: options.hideEmptySessions, + sessionLimit: options.sessionLimit, setSort, setGroup, setHideEmptySessions, + setSessionLimit, setAgentEnabled, setAllAgentsEnabled, resetViewOptions diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 74e7403cd9d..4c129d862e4 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -37,6 +37,8 @@ export function useSourceControlAgentActionDialog({ launchSource, savedAgentId, onSaveAgentDefault, + onLaunchAccepted, + onLaunchAborted, onLaunched, onStart }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { @@ -187,6 +189,8 @@ export function useSourceControlAgentActionDialog({ refreshDetectedAgents, onStart, onSaveAgentDefault, + onLaunchAccepted, + onLaunchAborted, onLaunched, onClose: closeDialog }) @@ -247,33 +251,24 @@ export function useSourceControlAgentActionDialog({ detecting }) - const onSelectedAgentChange = useCallback( - (agent: TuiAgent | null) => { - setSelectedAgent(agent) - resetDeliveryPlan() - }, + // Why: editing any launch field invalidates the previewed delivery plan. + const resetPlanAfter = useCallback( + (apply: (value: T) => void) => + (value: T): void => { + apply(value) + resetDeliveryPlan() + }, [resetDeliveryPlan] ) - const onAgentArgsChange = useCallback( - (value: string) => { - setAgentArgs(value) - resetDeliveryPlan() - }, - [resetDeliveryPlan] + const onSelectedAgentChange = useMemo(() => resetPlanAfter(setSelectedAgent), [resetPlanAfter]) + const onAgentArgsChange = useMemo(() => resetPlanAfter(setAgentArgs), [resetPlanAfter]) + const onCommandTemplateChange = useMemo( + () => resetPlanAfter(setCommandTemplate), + [resetPlanAfter] ) - const onCommandTemplateChange = useCallback( - (value: string) => { - setCommandTemplate(value) - resetDeliveryPlan() - }, - [resetDeliveryPlan] - ) - const onSaveLaunchRecipeChange = useCallback( - (value: boolean) => { - setSaveLaunchRecipe(value) - resetDeliveryPlan() - }, - [resetDeliveryPlan] + const onSaveLaunchRecipeChange = useMemo( + () => resetPlanAfter(setSaveLaunchRecipe), + [resetPlanAfter] ) const agentScopeNote = useMemo(() => { diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 0233518a8ca..3336348a2e5 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -43,6 +43,8 @@ type UseSourceControlAgentActionStartArgs = { actionId: SourceControlLaunchActionId, recipe: SourceControlActionRecipe ) => void | Promise + onLaunchAccepted?: () => void + onLaunchAborted?: () => void onLaunched?: () => void onClose: () => void } @@ -84,6 +86,8 @@ export function useSourceControlAgentActionStart({ refreshDetectedAgents, onStart, onSaveAgentDefault, + onLaunchAccepted, + onLaunchAborted, onLaunched, onClose }: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult { @@ -158,6 +162,8 @@ export function useSourceControlAgentActionStart({ launchSource, onStart, onSaveAgentDefault, + onLaunchAccepted, + onLaunchAborted, onLaunched, onClose: () => { resetDeliveryPlan() @@ -179,6 +185,8 @@ export function useSourceControlAgentActionStart({ launchSource, launchPlatform, onClose, + onLaunchAborted, + onLaunchAccepted, onLaunched, onSaveAgentDefault, onStart, diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 1b325ed4766..15677927ad3 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -42,8 +42,8 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { const [appVersion, setAppVersion] = useState(null) const updateCheckHint = getUpdateCheckHint() // Why: channel switching is a power-user escape hatch that can downgrade the app - // onto an unvetted build. Option/Alt-clicking the header reveals it, matching the - // Help menu's hidden admin options rather than shipping it on the default surface. + // onto an unvetted build. Option/Alt-clicking the header reveals it rather than + // shipping it on the default surface. const [channelSwitcherRevealed, setChannelSwitcherRevealed] = useState(false) useEffect(() => { diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 2008bd70154..f91d183bebb 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -80,6 +80,7 @@ export function RepositoryHostSetupsSection({ buildExecutionHostRegistry({ repos, settings, + hostSource: 'configured-only', sshTargetLabels, sshConnectionStates, runtimeEnvironments, diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx new file mode 100644 index 00000000000..da0292e631b --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { RepositoryIconEmojiPicker } from './RepositoryIconEmojiPicker' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: toastMocks.error } +})) + +// Mock store exposing only the theme value, to verify the minimal-selector subscription. +const storeMocks = vi.hoisted(() => ({ + state: { settings: { theme: 'light' } } as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(storeMocks.state) +})) + +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) + +/** Signature of the onEmojiClick callback captured by the emoji-picker-react mock. */ +type MockEmojiClickHandler = (data: { emoji: string }) => void + +const emojiPickerMocks = vi.hoisted(() => ({ + onEmojiClick: null as MockEmojiClickHandler | null, + searchPlaceholder: null as string | null +})) + +vi.mock('emoji-picker-react', () => ({ + __esModule: true, + default: (props: { onEmojiClick: MockEmojiClickHandler; searchPlaceholder: string }) => { + emojiPickerMocks.onEmojiClick = props.onEmojiClick + emojiPickerMocks.searchPlaceholder = props.searchPlaceholder + return null + }, + EmojiStyle: { NATIVE: 'native' }, + Theme: { DARK: 'dark', LIGHT: 'light' } +})) + +let container: HTMLDivElement +let root: Root + +/** Renders RepositoryIconEmojiPicker with default props and returns the spy callback. */ +function renderPicker(overrides: Partial[0]> = {}) { + const onSetIcon = vi.fn() + act(() => { + root.render() + }) + return { onSetIcon } +} + +describe('RepositoryIconEmojiPicker', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + emojiPickerMocks.onEmojiClick = null + emojiPickerMocks.searchPlaceholder = null + toastMocks.error.mockReset() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('renders the full native emoji picker in place of the static grid', () => { + renderPicker() + expect(emojiPickerMocks.onEmojiClick).not.toBeNull() + expect(emojiPickerMocks.searchPlaceholder).toBe('Search emoji') + expect(container.querySelector('.repo-icon-emoji-picker')).not.toBeNull() + }) + + it('saves a valid emoji click through the existing RepoIcon contract', () => { + const { onSetIcon } = renderPicker() + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: '🚀' }) + }) + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ type: 'emoji', emoji: '🚀' }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('rejects an emoji sequence over the sanitizeRepoIcon 16-char cap instead of silently dropping it', () => { + const { onSetIcon } = renderPicker() + // Two ZWJ family sequences back to back: valid codepoints, well over 16 UTF-16 units. + const overlong = '👨‍👩‍👧‍👦👨‍👩‍👧‍👦' + expect(overlong.length).toBeGreaterThan(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: overlong }) + }) + + expect(onSetIcon).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + + // Guards the skinTonesDisabled removal: skin-toned picks must save like any other emoji, not be blocked. + it('saves a valid skin-tone emoji selection instead of blocking it for global inclusivity', () => { + const { onSetIcon } = renderPicker() + // Base emoji + skin-tone modifier: well within the 16 UTF-16 unit cap. + const thumbsUpMediumSkinTone = '👍🏽' + expect(thumbsUpMediumSkinTone.length).toBeLessThanOrEqual(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: thumbsUpMediumSkinTone }) + }) + + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ + type: 'emoji', + emoji: thumbsUpMediumSkinTone + }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('shows the currently selected emoji as trailing metadata', () => { + renderPicker({ selectedEmoji: '🎨' }) + expect(container.textContent).toContain('🎨') + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx new file mode 100644 index 00000000000..9a97d07fbf6 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx @@ -0,0 +1,76 @@ +import { toast } from 'sonner' +import EmojiPicker, { EmojiStyle, Theme, type EmojiClickData } from 'emoji-picker-react' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { useAppStore } from '../../store' +import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { translate } from '@/i18n/i18n' + +type RepositoryIconEmojiPickerProps = { + selectedEmoji: string + onSetIcon: (repoIcon: RepoIcon | null) => void +} + +/** + * Full native emoji picker for the repo icon "Emoji" tab. Kept in its own + * module (lazy-loaded by RepositoryIconTabs) so emoji-picker-react's ~500KB + * chunk only loads once this tab actually renders. + */ +export function RepositoryIconEmojiPicker({ + selectedEmoji, + onSetIcon +}: RepositoryIconEmojiPickerProps): React.JSX.Element { + // Minimal selector: only the theme string is needed; default to system before settings load. + const settingsTheme = useAppStore((state) => state.settings?.theme ?? 'system') + const systemPrefersDark = useSystemPrefersDark() + // Theme.AUTO only follows the OS setting, which can disagree with Orca's manual theme. + const isDarkTheme = settingsTheme === 'dark' || (settingsTheme === 'system' && systemPrefersDark) + + /** Saves the picked emoji, rejecting anything over sanitizeRepoIcon's 16-char cap. */ + const handleEmojiClick = (emojiData: EmojiClickData): void => { + // ZWJ/skin-tone sequences can exceed the 16-char cap even without skinTonesDisabled. + const repoIcon = sanitizeRepoIcon({ type: 'emoji', emoji: emojiData.emoji }) + if (!repoIcon) { + toast.error( + translate( + 'auto.components.settings.RepositoryIconPicker.emojiTooLongForRepoIcon', + "This emoji can't be used as a repo icon." + ) + ) + return + } + onSetIcon(repoIcon) + } + + return ( + <> +
+ +
+ {selectedEmoji ? ( +

+ {translate( + 'auto.components.settings.RepositoryIconPicker.currentEmojiSelection', + 'Current: {{value0}}', + { value0: selectedEmoji } + )} +

+ ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconTabs.tsx b/src/renderer/src/components/settings/RepositoryIconTabs.tsx index d0507a89db4..1d695da6085 100644 --- a/src/renderer/src/components/settings/RepositoryIconTabs.tsx +++ b/src/renderer/src/components/settings/RepositoryIconTabs.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { Suspense, useState } from 'react' import { toast } from 'sonner' import { Github, Image, Link2 } from 'lucide-react' import type { RepoIcon } from '../../../../shared/repo-icon' @@ -10,8 +10,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { getRepoLucideIconOptions } from '../repo/repo-icon' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' -const EMOJI_OPTIONS = ['🚀', '✨', '💻', '🧠', '📦', '🔧', '🎨', '🌐', '📊', '🔒', '⚡', '✅'] +// Deferred so emoji-picker-react's ~500KB chunk only loads once the Emoji tab renders. +const RepositoryIconEmojiPicker = lazyWithRetry( + () => + import('./RepositoryIconEmojiPicker').then((module) => ({ + default: module.RepositoryIconEmojiPicker + })), + { reloadKey: 'repo-icon-emoji-picker' } +) type RepositoryIconTabsProps = { initialTab: 'avatar' | 'icon' | 'emoji' @@ -180,24 +188,10 @@ export function RepositoryIconTabs({
- - {EMOJI_OPTIONS.map((emoji) => ( - - ))} + + + + ) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index f0bb4ba08df..0b32a439dbd 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -7,15 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ listNetworkInterfaces: vi.fn(), - listRuntimeAccessGrants: vi.fn() + listRuntimeAccessGrants: vi.fn(), + getRuntimePairingUrl: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) vi.mock('./RuntimePairingGeneratorForm', () => ({ - RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( -
{props.selectedAddress}
+ RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => ( +
+
{props.selectedAddress}
+ +
) })) @@ -33,12 +39,20 @@ describe('RuntimePairingUrlGenerator', () => { runtimePairingLinkCache.runtimePairingDeviceId = null mocks.listNetworkInterfaces.mockReset() mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + mocks.getRuntimePairingUrl.mockReset().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: 'http://127.0.0.1:6768/web-index.html?pairing=runtime', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'runtime-1' + }) Object.defineProperty(window, 'api', { configurable: true, value: { mobile: { listNetworkInterfaces: mocks.listNetworkInterfaces, - listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants, + getRuntimePairingUrl: mocks.getRuntimePairingUrl } } }) @@ -67,4 +81,28 @@ describe('RuntimePairingUrlGenerator', () => { expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') ) }) + + // Why: the main process gates the one-way network widen on the declared reach, so dropping it (as the + // component used to) leaves main guessing from the address string — "This computer only" then widened, + // and a Custom loopback tunnel front-end would not. + it.each([ + ['local' as const, '127.0.0.1', 'this-computer'], + ['another' as const, '100.76.32.125', 'network'], + ['custom' as const, '127.0.0.1:8443', 'network'] + ])('sends the %s reach with the address', async (intent, address, reach) => { + runtimePairingLinkCache.intent = intent + runtimePairingLinkCache.selectedAddress = address + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + screen.getByRole('button', { name: 'Generate' }).click() + + await waitFor(() => + expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach }) + ) + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 54c75c7ac92..c6d927de593 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -11,6 +11,7 @@ import { cacheGeneratedRuntimePairingLink, clearGeneratedRuntimePairingLink, runtimePairingLinkCache, + runtimePairingReachForIntent, selectRuntimePairingIntent, type RuntimePairingIntent, type RuntimePairingUrlGeneratorProps @@ -187,7 +188,10 @@ export function RuntimePairingUrlGenerator({ try { const result = await window.api.mobile.getRuntimePairingUrl({ address, - rotate: true + rotate: true, + // Why: main gates the one-way network widen on this, so the declared choice must travel with the + // address — the address alone cannot tell "This computer only" from a loopback tunnel front-end. + reach: runtimePairingReachForIntent(intent) }) if (!result.available) { clearGeneratedUrls() diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index 0b3a1230fb9..ff777fc72fe 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -1,7 +1,15 @@ +import type { RuntimePairingReach } from '../../../../shared/runtime-pairing-reach' + export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' export type RuntimePairingIntent = 'another' | 'local' | 'custom' +// Why: only "This computer only" declines off-host reach. Custom is the SSH-tunnel/reverse-proxy field, so +// even a loopback-looking custom address (`127.0.0.1:8443`) needs the listener open behind the tunnel. +export function runtimePairingReachForIntent(intent: RuntimePairingIntent): RuntimePairingReach { + return intent === 'local' ? 'this-computer' : 'network' +} + export type RuntimePairingUrlGeneratorProps = { framed?: boolean showHeader?: boolean diff --git a/src/renderer/src/components/settings/ssh-target-draft.test.ts b/src/renderer/src/components/settings/ssh-target-draft.test.ts index d393d9ffeab..9de02d2d35a 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.test.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.test.ts @@ -3,7 +3,9 @@ import { EMPTY_FORM, applyParsedSshHostInput, getEditingTargetForSshTarget, + getEditingTargetFromSshConfigHost, getSshTargetDraftConnectionFields, + hasAdvancedConnectionValues, isSshTargetFormDirty, parseRelayGracePeriodSeconds, parseSshHostInput, @@ -247,6 +249,63 @@ describe('getEditingTargetForSshTarget', () => { }) }) +describe('getEditingTargetFromSshConfigHost', () => { + it('prefills connection fields from a config summary', () => { + const draft = getEditingTargetFromSshConfigHost({ + alias: 'prod-bastion', + hostname: 'bastion.prod.example', + port: 2222, + username: 'ops', + identityFiles: ['~/.ssh/prod', '~/.ssh/fallback'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false, + jumpHost: 'edge' + }) + + expect(draft).toMatchObject({ + label: 'prod-bastion', + configHost: 'prod-bastion', + host: 'bastion.prod.example', + port: '2222', + username: 'ops', + identityFile: '', + gssapiAuthentication: true, + jumpHost: 'edge' + }) + expect(getSshTargetDraftConnectionFields(draft)).toEqual({ + host: 'bastion.prod.example', + configHost: 'prod-bastion', + username: 'ops', + port: 2222 + }) + expect(hasAdvancedConnectionValues(draft)).toBe(true) + }) + + it('keeps alias as host when HostName is omitted', () => { + const draft = getEditingTargetFromSshConfigHost({ + alias: 'gpu-lab', + hostname: 'gpu-lab', + port: 22, + username: 'jinjing', + identityFiles: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false + }) + + expect(draft.configHost).toBe('') + expect(draft.host).toBe('gpu-lab') + expect(getSshTargetDraftConnectionFields(draft)).toEqual({ + host: 'gpu-lab', + configHost: 'gpu-lab', + username: 'jinjing', + port: 22 + }) + }) +}) + describe('isSshTargetFormDirty', () => { it('is clean when the draft matches the baseline', () => { expect(isSshTargetFormDirty(EMPTY_FORM, EMPTY_FORM)).toBe(false) @@ -261,6 +320,7 @@ describe('isSshTargetFormDirty', () => { { port: '2222' }, { username: 'deploy' }, { identityFile: '~/.ssh/id_ed25519' }, + { gssapiAuthentication: true }, { proxyCommand: 'nc %h %p' }, { jumpHost: 'bastion' }, { systemSshConnectionReuse: false }, diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index 245a90eb0fa..a20541dfe60 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -3,6 +3,7 @@ import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + type SshConfigHostResolution, type SshTarget } from '../../../../shared/ssh-types' @@ -13,6 +14,7 @@ export type EditingTarget = { port: string username: string identityFile: string + gssapiAuthentication: boolean proxyCommand: string jumpHost: string systemSshConnectionReuse: boolean @@ -27,6 +29,7 @@ export const EMPTY_FORM: EditingTarget = { port: '22', username: '', identityFile: '', + gssapiAuthentication: false, proxyCommand: '', jumpHost: '', systemSshConnectionReuse: true, @@ -45,6 +48,7 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { port: String(target.port), username: target.username, identityFile: target.identityFile ?? '', + gssapiAuthentication: target.gssapiAuthentication === true, proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', systemSshConnectionReuse: target.systemSshConnectionReuse !== false, @@ -58,6 +62,26 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { } } +/** Prefill the add-host form from a ~/.ssh/config Host entry (does not save). */ +export function getEditingTargetFromSshConfigHost(host: SshConfigHostResolution): EditingTarget { + const configHost = host.alias !== host.hostname ? host.alias : '' + return { + ...EMPTY_FORM, + label: host.alias, + configHost, + // Why: keep the config alias in Host when there is no HostName so OpenSSH + // still resolves User/Port/Identity from ~/.ssh/config on connect. + host: host.hostname, + port: String(host.port), + username: host.username, + // Why: no scalar override lets connection-time ssh -G retain every IdentityFile. + identityFile: '', + gssapiAuthentication: host.gssapiAuthentication === true, + proxyCommand: host.proxyCommand ?? '', + jumpHost: host.jumpHost ?? '' + } +} + export type ParsedSshHostInput = { host: string username?: string @@ -153,6 +177,7 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa current.port !== baseline.port || current.username !== baseline.username || current.identityFile !== baseline.identityFile || + current.gssapiAuthentication !== baseline.gssapiAuthentication || current.proxyCommand !== baseline.proxyCommand || current.jumpHost !== baseline.jumpHost || current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse || diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts index 89909f886f1..7133daf6db2 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -61,6 +61,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay host, port, username, + ...(form.gssapiAuthentication ? { gssapiAuthentication: true } : {}), relayGracePeriodSeconds: graceSeconds, ...(identityFile ? { identityFile } : {}), ...(proxyCommand ? { proxyCommand } : {}), @@ -77,6 +78,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay // Why: updateTarget merges partially, so explicit undefined values are // required to clear optional fields inherited from ~/.ssh/config. identityFile, + gssapiAuthentication: form.gssapiAuthentication || undefined, proxyCommand, jumpHost, systemSshConnectionReuse, diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx new file mode 100644 index 00000000000..90886e07cc0 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConfigHostResolution } from '../../../../shared/ssh-types' + +const toastMocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), toastMocks) })) + +const storeActions = vi.hoisted(() => ({ + setSshTargetsMetadata: vi.fn(), + recordSshRepoReadoptions: vi.fn(), + setRuntimeEnvironments: vi.fn(), + setRuntimeEnvironmentStatus: vi.fn(), + recordFeatureInteraction: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector(storeActions) +})) + +import { AddRemoteHostDialog } from './AddRemoteHostDialog' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function resolution(alias: string, hostname: string): SshConfigHostResolution { + return { + alias, + hostname, + port: 22, + username: 'deploy', + identityFiles: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false + } +} + +const listConfigHosts = vi.fn() +const resolveConfigHost = vi.fn() +const importConfig = vi.fn() +const listTargets = vi.fn() + +function configHost( + alias: string, + overrides: Record = {} +): Record { + return { + alias, + hostname: `${alias}.internal`, + port: 22, + username: 'deploy', + alreadyInOrca: false, + ...overrides + } +} + +beforeEach(() => { + vi.clearAllMocks() + listConfigHosts.mockResolvedValue({ + hosts: [configHost('alpha'), configHost('bravo')], + totalHostCount: 2, + newHostCount: 2, + matchCount: 2, + hasMore: false + }) + importConfig.mockResolvedValue({ targets: [], repoReadoptions: [] }) + listTargets.mockResolvedValue([]) + Object.defineProperty(window, 'api', { + configurable: true, + value: { ssh: { listConfigHosts, resolveConfigHost, importConfig, listTargets } } + }) +}) + +afterEach(() => { + cleanup() +}) + +async function openPicker(): Promise> { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /Fill from/ })) + await screen.findByRole('button', { name: /alpha/ }) + return user +} + +async function openPickerWith(result: Record): Promise { + listConfigHosts.mockResolvedValue({ hasMore: false, ...result }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /Fill from/ })) +} + +describe('SSH config picker host selection', () => { + it('drops a resolve that lands after the user backs out of the picker', async () => { + const pending = deferred() + resolveConfigHost.mockReturnValueOnce(pending.promise) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + await user.click(screen.getByRole('button', { name: 'Back' })) + pending.resolve(resolution('alpha', 'alpha.internal')) + await waitFor(() => expect(screen.getByLabelText('Host or alias')).toBeDefined()) + + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe('') + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('keeps the host the user settled on when an earlier resolve lands late', async () => { + const slowAlpha = deferred() + resolveConfigHost.mockReturnValueOnce(slowAlpha.promise) + resolveConfigHost.mockResolvedValueOnce(resolution('bravo', 'bravo.internal')) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + await user.click(screen.getByRole('button', { name: 'Back' })) + await user.click(screen.getByRole('button', { name: /Fill from/ })) + await user.click(await screen.findByRole('button', { name: /bravo/ })) + await waitFor(() => + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe( + 'bravo.internal' + ) + ) + + await act(async () => { + slowAlpha.resolve(resolution('alpha', 'alpha.internal')) + await slowAlpha.promise + }) + + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe( + 'bravo.internal' + ) + }) + + it('freezes the other rows while a pick is resolving', async () => { + const pending = deferred() + resolveConfigHost.mockReturnValueOnce(pending.promise) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + + await waitFor(() => + expect(screen.getByRole('button', { name: /bravo/ }).hasAttribute('disabled')).toBe(true) + ) + await act(async () => { + pending.resolve(resolution('alpha', 'alpha.internal')) + await pending.promise + }) + }) +}) + +describe('SSH config picker tombstoned hosts', () => { + it('keeps a previously removed host pickable while an adopted one stays disabled', async () => { + await openPickerWith({ + hosts: [ + configHost('removed', { previouslyRemoved: true }), + configHost('kept', { alreadyInOrca: true }) + ], + totalHostCount: 2, + newHostCount: 0, + matchCount: 2 + }) + + const removed = await screen.findByRole('button', { name: /removed/ }) + expect(removed.hasAttribute('disabled')).toBe(false) + expect(removed.textContent).toContain('Removed from Orca') + + const kept = screen.getByRole('button', { name: /kept/ }) + expect(kept.hasAttribute('disabled')).toBe(true) + expect(kept.textContent).toContain('In Orca') + }) + + it('never claims the config is empty when every host is only tombstoned', async () => { + await openPickerWith({ + hosts: [configHost('removed', { previouslyRemoved: true })], + totalHostCount: 1, + newHostCount: 0, + matchCount: 1 + }) + + const addAll = await screen.findByRole('button', { name: 'No new hosts to add' }) + expect(addAll.hasAttribute('disabled')).toBe(true) + expect(screen.queryByText('No hosts in ~/.ssh/config')).toBeNull() + }) + + it('still shows the empty state when the config really has no hosts', async () => { + await openPickerWith({ hosts: [], totalHostCount: 0, newHostCount: 0, matchCount: 0 }) + + expect(await screen.findByText('No hosts in ~/.ssh/config')).toBeDefined() + expect(screen.getByRole('button', { name: 'Add all to Orca' }).hasAttribute('disabled')).toBe( + true + ) + }) +}) + +describe('SSH config picker bulk add', () => { + it('adds only new hosts and never re-adopts deleted aliases', async () => { + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /Add all 2 to Orca/ })) + + await waitFor(() => expect(importConfig).toHaveBeenCalled()) + expect(importConfig).toHaveBeenCalledWith() + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 5e2527bf0d6..03658792d94 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -1,30 +1,24 @@ -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { - EMPTY_FORM, - getSshTargetDraftConnectionFields, - isRelayGracePeriodValid, - parseRelayGracePeriodSeconds, - type EditingTarget -} from '../settings/ssh-target-draft' -import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { EMPTY_FORM, type EditingTarget } from '../settings/ssh-target-draft' +import type { SshConfigHostSummary } from '../../../../shared/ssh-types' import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' import { translateHostAccessLinkError, translateRemotePairingFailureDescription } from '@/lib/remote-pairing-copy' -import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields' +import { AddRemoteHostSshConfigPicker } from './AddRemoteHostSshConfigPicker' +import { AddRemoteHostSshFormPanel } from './AddRemoteHostSshFormPanel' +import { AddRemoteHostServerFormPanel } from './AddRemoteHostServerFormPanel' +import { + addAllSshConfigHostsToOrca, + loadSshConfigHostsForPicker, + prefillFormFromSshConfigHost, + saveNewSshHostFromForm +} from './add-remote-host-ssh-actions' export type AddRemoteHostMode = 'ssh' | 'server' @@ -33,6 +27,8 @@ type AddRemoteHostDialogProps = { onOpenChange: (mode: AddRemoteHostMode | null) => void } +type SshDialogView = 'form' | 'config-picker' + export function AddRemoteHostDialog({ mode, onOpenChange @@ -46,11 +42,24 @@ export function AddRemoteHostDialog({ setRenderMode(mode) } const [sshForm, setSshForm] = useState(EMPTY_FORM) + const [sshView, setSshView] = useState('form') + const [configHosts, setConfigHosts] = useState([]) + const [configHostCount, setConfigHostCount] = useState(0) + const [newConfigHostCount, setNewConfigHostCount] = useState(0) + const [configHostMatchesTruncated, setConfigHostMatchesTruncated] = useState(false) + const [isLoadingConfigHosts, setIsLoadingConfigHosts] = useState(false) + const [resolvingConfigAlias, setResolvingConfigAlias] = useState(null) + const [isBulkImporting, setIsBulkImporting] = useState(false) + const [configHostsError, setConfigHostsError] = useState(null) + const [preferAdvancedOpen, setPreferAdvancedOpen] = useState(false) + const [configFilledAlias, setConfigFilledAlias] = useState(null) const [serverName, setServerName] = useState('') const [pairingCode, setPairingCode] = useState('') const [allowLoopback, setAllowLoopback] = useState(false) const [isSaving, setIsSaving] = useState(false) - const [isImporting, setIsImporting] = useState(false) + const configSearchGeneration = useRef(0) + const configSearchQuery = useRef('') + const configResolveGeneration = useRef(0) const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode]) const serverFormCanSubmit = serverName.trim() !== '' && @@ -62,136 +71,169 @@ export function AddRemoteHostDialog({ const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const close = () => { - if (isSaving || isImporting) { - return - } - onOpenChange(null) + const busy = isSaving || isBulkImporting || resolvingConfigAlias !== null + + // Why: a pending resolve must never write into a form the user has moved on from. + const invalidatePendingConfigResolve = () => { + configResolveGeneration.current += 1 + setResolvingConfigAlias(null) } const reset = () => { setSshForm(EMPTY_FORM) + setSshView('form') + setConfigHosts([]) + setConfigHostCount(0) + setNewConfigHostCount(0) + setConfigHostMatchesTruncated(false) + setConfigHostsError(null) + configSearchQuery.current = '' + invalidatePendingConfigResolve() + setPreferAdvancedOpen(false) + setConfigFilledAlias(null) + setIsBulkImporting(false) setServerName('') setPairingCode('') setAllowLoopback(false) } - const refreshSshTargetMetadata = async () => { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - setSshTargetsMetadata(targets) + const close = () => { + // Why: a stuck resolve must not trap the dialog open — reset() invalidates it instead. + if (isSaving || isBulkImporting) { + return + } + reset() + onOpenChange(null) } const saveSshHost = async () => { - const { host, configHost, username, port } = getSshTargetDraftConnectionFields(sshForm) - if (!host) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshHostRequired', - 'Host or SSH config alias is required.' - ) - ) - return - } - if (Number.isNaN(port) || port < 1 || port > 65535) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshPortInvalid', - 'Port must be between 1 and 65535.' - ) - ) - return - } - const graceSeconds = parseRelayGracePeriodSeconds(sshForm) - if (!isRelayGracePeriodValid(sshForm, graceSeconds)) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshRelayGraceInvalid', - 'Terminal timeout must be between 60 and {{value0}} seconds.', - { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } - ) - ) - return - } - - const identityFile = sshForm.identityFile.trim() || undefined - const proxyCommand = sshForm.proxyCommand.trim() || undefined - const jumpHost = sshForm.jumpHost.trim() || undefined - const systemSshConnectionReuse = sshForm.systemSshConnectionReuse ? undefined : false - const target = { - label: sshForm.label.trim() || (username ? `${username}@${host}` : configHost), - configHost, - host, - port, - username, - relayGracePeriodSeconds: graceSeconds, - ...(identityFile ? { identityFile } : {}), - ...(proxyCommand ? { proxyCommand } : {}), - ...(jumpHost ? { jumpHost } : {}), - ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) - } - setIsSaving(true) try { - const result = await window.api.ssh.addTarget({ target }) - recordSshRepoReadoptions(result.repoReadoptions) - await refreshSshTargetMetadata() - recordFeatureInteraction('ssh') - toast.success( - translate('auto.components.sidebar.AddRemoteHostDialog.sshSaved', 'SSH host added.') - ) - reset() - onOpenChange(null) - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshSaveFailed', - 'Failed to add SSH host.' - ) - ) + const outcome = await saveNewSshHostFromForm({ + form: sshForm, + ssh: window.api.ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction + }) + if (outcome === 'saved') { + reset() + onOpenChange(null) + } } finally { setIsSaving(false) } } - const importSshConfig = async () => { - setIsImporting(true) + const loadSshConfigHosts = async (query = '', options?: { refresh?: boolean }) => { + configSearchQuery.current = query + const generation = configSearchGeneration.current + 1 + configSearchGeneration.current = generation + setIsLoadingConfigHosts(true) + setConfigHostsError(null) + const result = await loadSshConfigHostsForPicker(window.api.ssh, { + query, + ...(options?.refresh ? { refresh: true } : {}) + }) + if (generation !== configSearchGeneration.current) { + return + } + if (result.ok) { + setConfigHosts(result.result.hosts) + setConfigHostCount(result.result.totalHostCount) + setNewConfigHostCount(result.result.newHostCount) + setConfigHostMatchesTruncated(result.result.hasMore) + } else { + setConfigHosts([]) + setConfigHostsError(result.error) + } + setIsLoadingConfigHosts(false) + } + + const openSshConfigPicker = async () => { + setSshView('config-picker') + // Why: re-read ~/.ssh/config on open; the filter keystrokes reuse that parse. + await loadSshConfigHosts('', { refresh: true }) + } + + const leaveSshConfigPicker = () => { + invalidatePendingConfigResolve() + setSshView('form') + } + + const selectSshConfigHost = async (host: SshConfigHostSummary) => { + const generation = configResolveGeneration.current + 1 + configResolveGeneration.current = generation + setResolvingConfigAlias(host.alias) + // Why: a slower earlier pick must not overwrite the host the user settled on. + const isStale = () => generation !== configResolveGeneration.current + let resolved: Awaited> try { - const result = await window.api.ssh.importConfig() - const synced = result.targets - recordSshRepoReadoptions(result.repoReadoptions) - await refreshSshTargetMetadata() - recordFeatureInteraction('ssh') - if (synced.length === 0) { - toast( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportAlreadySynced', - '~/.ssh/config already in sync.' - ) - ) - } else { - toast.success( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced', - 'Synced {{value0}} host{{value1}}.', - { value0: synced.length, value1: synced.length > 1 ? 's' : '' } - ) - ) - reset() - onOpenChange(null) - } + resolved = await prefillFormFromSshConfigHost(host, window.api.ssh) } catch (error) { + if (isStale()) { + return + } + setResolvingConfigAlias(null) toast.error( error instanceof Error ? error.message : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportFailed', - 'Failed to import SSH config.' + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerResolveFailed', + 'Failed to resolve that SSH config host.' ) ) + return + } + if (isStale()) { + return + } + setResolvingConfigAlias(null) + if (!resolved) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerResolveFailed', + 'Failed to resolve that SSH config host.' + ) + ) + return + } + const { form, preferAdvancedOpen: openAdvanced } = resolved + setSshForm(form) + setPreferAdvancedOpen(openAdvanced) + setConfigFilledAlias(host.alias) + setSshView('form') + recordFeatureInteraction('ssh') + toast.success( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilled', + 'Filled from {{value0}}. Review and Save.', + { value0: host.alias } + ) + ) + } + + const addAllConfigHostsToOrca = async () => { + setIsBulkImporting(true) + try { + const result = await addAllSshConfigHostsToOrca({ + ssh: window.api.ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction + }) + if (result.kind === 'added') { + reset() + onOpenChange(null) + return + } + if (result.kind === 'already-synced') { + // Why: reuse the loader so the refresh keeps the active filter and stays inside the + // generation guard against an in-flight debounced search. + await loadSshConfigHosts(configSearchQuery.current) + } } finally { - setIsImporting(false) + setIsBulkImporting(false) } } @@ -264,6 +306,8 @@ export function AddRemoteHostDialog({ } } + const showSshConfigPicker = renderMode === 'ssh' && sshView === 'config-picker' + return ( - - - - {renderMode === 'server' - ? translate( - 'auto.components.sidebar.AddRemoteHostDialog.serverTitle', - 'Add remote server' - ) - : translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} - - - {renderMode === 'server' - ? translate( - 'auto.components.sidebar.AddRemoteHostDialog.serverDescription', - 'Pair with Orca running on another computer.' - ) - : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', - 'Add a persistent machine you can log into over SSH.' - )} - - - - {renderMode === 'server' ? ( - + {showSshConfigPicker ? ( +
+ void selectSshConfigHost(host)} + onQueryChange={(query) => void loadSshConfigHosts(query)} + onRetry={() => void loadSshConfigHosts(configSearchQuery.current, { refresh: true })} + onBack={leaveSshConfigPicker} + onAddAllToOrca={() => void addAllConfigHostsToOrca()} + /> +
+ ) : renderMode === 'ssh' ? ( + void saveSshHost()} + onCancel={close} + onFillFromConfig={() => void openSshConfigPicker()} + /> + ) : ( + { setPairingCode(value) setAllowLoopback(false) }} - allowLoopback={allowLoopback} onAllowLoopbackChange={setAllowLoopback} onSubmit={() => void saveRemoteServer()} - /> - ) : ( - void saveSshHost()} + onCancel={close} /> )} - - - {renderMode === 'ssh' ? ( - - ) : ( - - )} -
- - -
-
) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index 60a940dd0e0..cdab890d459 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -15,15 +15,21 @@ import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields' export function SshHostFields({ form, disabled, + preferAdvancedOpen = false, + configIdentityAlias = null, onFormChange, onSubmit }: { form: EditingTarget disabled: boolean + /** When true after a config pick, expand Advanced so proxy/jump stay visible. */ + preferAdvancedOpen?: boolean + /** Alias this form was filled from, so an empty Identity file can be explained. */ + configIdentityAlias?: string | null onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void onSubmit: () => void }) { - const [advancedOpen, setAdvancedOpen] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(preferAdvancedOpen) return (
+ {configIdentityAlias && form.identityFile.trim() === '' ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.identityFileFromConfigHint', + 'Left empty on purpose: Orca uses every key ~/.ssh/config resolves for {{value0}}. Type a path to use just that key.', + { value0: configIdentityAlias } + )} +

+ ) : null}
void + onPairingCodeChange: (value: string) => void + onAllowLoopbackChange: (value: boolean) => void + onSubmit: () => void + onCancel: () => void +}): React.JSX.Element { + return ( + <> + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverTitle', + 'Add remote server' + )} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverDescription', + 'Pair with Orca running on another computer.' + )} + + + + + + + +
+ + +
+
+ + ) +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx b/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx new file mode 100644 index 00000000000..964fa2357f3 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx @@ -0,0 +1,276 @@ +import { useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { + SSH_CONFIG_HOST_RESULT_LIMIT, + type SshConfigHostSummary +} from '../../../../shared/ssh-types' +import { cn } from '@/lib/utils' + +const EMPTY_CONFIG_HOSTS: SshConfigHostSummary[] = [] + +export function AddRemoteHostSshConfigPicker({ + hosts = EMPTY_CONFIG_HOSTS, + totalHostCount = 0, + newHostCount = 0, + matchesTruncated = false, + isLoading, + isBulkImporting, + resolvingAlias = null, + loadError, + onSelect, + onQueryChange, + onRetry, + onBack, + onAddAllToOrca +}: { + hosts: SshConfigHostSummary[] + totalHostCount: number + newHostCount: number + matchesTruncated: boolean + isLoading: boolean + isBulkImporting: boolean + /** Alias whose `ssh -G` resolve is in flight; picking is frozen until it settles. */ + resolvingAlias: string | null + loadError: string | null + onSelect: (host: SshConfigHostSummary) => void + onQueryChange: (query: string) => void + onRetry: () => void + onBack: () => void + onAddAllToOrca: () => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const queryTimer = useRef | null>(null) + useEffect( + () => () => { + if (queryTimer.current) { + clearTimeout(queryTimer.current) + } + }, + [] + ) + const isResolving = resolvingAlias != null + // Why: filtering re-runs against a cached parse, so keep the field usable while a + // refresh (or a failed load the user can retry) is outstanding. + const filterDisabled = isBulkImporting || isResolving + const picksDisabled = isBulkImporting || isResolving + const canAddAll = + !isLoading && !isBulkImporting && !isResolving && loadError == null && newHostCount > 0 + + return ( +
+ + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerTitle', + 'Choose from ~/.ssh/config' + )} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerDescription', + 'Pick a host to fill the form, or add every new host to Orca’s host list.' + )} + + + + { + const value = event.target.value + setQuery(value) + if (queryTimer.current) { + clearTimeout(queryTimer.current) + } + queryTimer.current = setTimeout(() => onQueryChange(value), 200) + }} + placeholder={translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilter', + 'Filter hosts…' + )} + autoFocus + disabled={filterDisabled} + aria-label={translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilter', + 'Filter hosts…' + )} + /> + +
+ {loadError != null ? ( +
+

{loadError}

+ +
+ ) : isLoading && hosts.length === 0 ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerLoading', + 'Reading ~/.ssh/config…' + )} +

+ ) : hosts.length === 0 ? ( +
+

+ {totalHostCount === 0 + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerEmpty', + 'No hosts in ~/.ssh/config' + ) + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerNoMatch', + 'No matching hosts' + )} +

+

+ {totalHostCount === 0 + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerEmptyHint', + 'Add a Host entry there, or go back and type the details manually.' + ) + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerNoMatchHint', + 'Try another filter, or go back and type manually.' + )} +

+
+ ) : ( + // Why: these rows act on click and there is no selected row to track, so they stay + // plain buttons rather than a listbox whose aria-selected would always be a lie. +
    + {hosts.map((host) => ( +
  • + +
  • + ))} +
+ )} +
+ + {matchesTruncated ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerMoreResults', + 'Showing the first {{value0}} matches. Narrow your filter to find more.', + { value0: SSH_CONFIG_HOST_RESULT_LIMIT } + )} +

+ ) : null} + +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx new file mode 100644 index 00000000000..fe93ba105ae --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx @@ -0,0 +1,75 @@ +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { EditingTarget } from '../settings/ssh-target-draft' +import { SshHostFields } from './AddRemoteHostFields' + +export function AddRemoteHostSshFormPanel({ + form, + disabled, + preferAdvancedOpen, + configIdentityAlias, + onFormChange, + onSubmit, + onCancel, + onFillFromConfig +}: { + form: EditingTarget + disabled: boolean + preferAdvancedOpen: boolean + configIdentityAlias: string | null + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void + onSubmit: () => void + onCancel: () => void + onFillFromConfig: () => void +}): React.JSX.Element { + return ( + <> + + + {translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', + 'Add a persistent machine you can log into over SSH.' + )} + + + + + + + +
+ + +
+
+ + ) +} diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx new file mode 100644 index 00000000000..60930a19c44 --- /dev/null +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +/** + * Switch row shared by the two workspace-filter surfaces (the sidebar filter + * dropdown and the workspace options menu). Extracted so the two copies cannot + * drift as rows are added. + */ +export function FilterToggleRow({ + icon, + label, + ariaLabel, + checked, + onChange, + shortcutLabel, + indented = false +}: { + icon: React.ReactNode + label: string + /** Full sentence for assistive tech when `label` only reads in visual context. */ + ariaLabel?: string + checked: boolean + onChange: (next: boolean) => void + shortcutLabel?: string + /** Renders the row as a sub-option of the row above it. */ + indented?: boolean +}) { + return ( + + ) +} + +export default FilterToggleRow diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 06c445564a7..1efbf6d0016 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -23,14 +23,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, - DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { FilterToggleRow } from './FilterToggleRow' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' -import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { translate } from '@/i18n/i18n' @@ -62,6 +61,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -114,6 +117,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + // Why counted: turning the exemption off is the only way this row narrows + // the list, so the badge and Reset filters must both notice it. + !alwaysShowDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + @@ -121,6 +127,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (alwaysShowDefaultBranchWorkspace ? 0 : 1) + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) @@ -136,6 +143,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces(false) setHideCliCreatedWorkspaces(false) setHideDetachedHeadWorkspaces(false) + setAlwaysShowDefaultBranchWorkspace(true) setFilterRepoIds([]) }, [ setShowSleepingWorkspaces, @@ -143,6 +151,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setFilterRepoIds ]) @@ -212,6 +221,20 @@ const SidebarFilter = React.memo(function SidebarFilter({ onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> + } + label={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> } label={translate( @@ -372,50 +395,4 @@ const SidebarFilter = React.memo(function SidebarFilter({ ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange, - shortcutLabel -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void - shortcutLabel?: string -}) { - return ( - - ) -} - export default SidebarFilter diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx index 7875deea064..b88bc1c102e 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx @@ -222,9 +222,14 @@ describe('SidebarSettingsHelpMenu', () => { expect(html).not.toContain('Milestones') }) - it('hides the Onboarding admin entry by default', () => { + it('renders the Onboarding menu item by default', () => { const html = renderToStaticMarkup() - expect(html).not.toContain('Onboarding') + expect(html).toContain('Onboarding') + }) + + it('renders Restart Orca by default', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Restart Orca') }) it('renders Docs link', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index 9fee62dde2a..fc4975eb474 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -95,7 +95,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const settingsShortcut = useShortcutKeyDetails('app.settings') const [menuOpen, setMenuOpen] = useState(false) const [feedbackOpen, setFeedbackOpen] = useState(false) - const [showAdminOptions, setShowAdminOptions] = useState(false) const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) @@ -108,15 +107,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const handleMenuOpenChange = (open: boolean): void => { setMenuOpen(open) updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS - if (!open) { - setShowAdminOptions(false) - } - } - - const revealAdminOptions = (altKey: boolean): void => { - // Why: onboarding replay and restart stay off the default Help menu; holding - // Option/Alt before opening is an intentional power-user affordance. - setShowAdminOptions(altKey) } const handleShowOnboarding = (): void => { @@ -221,8 +211,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { 'Help' )} className="text-muted-foreground" - onPointerDown={(event) => revealAdminOptions(event.altKey)} - onClick={(event) => revealAdminOptions(event.altKey)} > @@ -268,19 +256,17 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { /> ) : null} - {showAdminOptions ? ( - - - {translate( - 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', - 'Onboarding' - )} - - ) : null} + + + {translate( + 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', + 'Onboarding' + )} + - {showAdminOptions ? ( - <> - - - - {translate( - 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', - 'Restart Orca' - )} - - - ) : null} + + + + {translate( + 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', + 'Restart Orca' + )} +
diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 20cddcfae49..e06f81420fb 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,8 +1,8 @@ import React from 'react' import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { FilterToggleRow } from './FilterToggleRow' const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) @@ -17,6 +17,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) return ( <> @@ -34,6 +38,20 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> } label={translate( @@ -74,45 +92,4 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void -}) { - return ( - - ) -} - export default SidebarWorkspaceFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index b087a3fb8f5..5f3f65dcea6 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -40,6 +40,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -84,6 +85,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + !alwaysShowDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = @@ -92,6 +94,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (alwaysShowDefaultBranchWorkspace ? 0 : 1) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 871687ccbe1..be3e5e2a4c1 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -17,19 +17,18 @@ function renderDotClassNames(status: Status): string[] { } describe('StatusIndicator', () => { - it('renders working as a clock-driven yellow spinner ring', () => { + it('renders working as a yellow spinner ring', () => { const markup = renderMarkup('working') expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - // Why: rotation comes from the shared agent-spinner clock, not a - // per-element CSS animation that would keep the compositor awake. + // Why: rotation must come from the compositor-driven CSS animation, not a + // JS clock writing per-element styles on the input thread (STA-3328). + expect(markup).toContain('agent-working-spinner') expect(markup).toContain('data-agent-spinner') // Why: under reduced motion the top border is filled so the static ring // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') - expect(markup).not.toContain('animate-spin') - expect(markup).not.toContain('animation:spin') }) it('renders permission as an amber question glyph', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e21ccdd01e9..422b765f082 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5222,6 +5222,7 @@ const WorktreeList = React.memo(function WorktreeList({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -5509,6 +5510,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -5526,6 +5528,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -6487,6 +6490,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope }), @@ -6497,6 +6501,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope ] @@ -6509,6 +6514,9 @@ const WorktreeList = React.memo(function WorktreeList({ ) const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -6532,6 +6540,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDetachedHeadWorkspaces) { setHideDetachedHeadWorkspaces(false) } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -6542,6 +6553,7 @@ const WorktreeList = React.memo(function WorktreeList({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts new file mode 100644 index 00000000000..3ab43cd5488 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../../../shared/ssh-types' +import { EMPTY_FORM } from '../settings/ssh-target-draft' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), toastMocks) +})) + +import { + addAllSshConfigHostsToOrca, + loadSshConfigHostsForPicker, + prefillFormFromSshConfigHost, + saveNewSshHostFromForm +} from './add-remote-host-ssh-actions' + +describe('individual SSH config host selection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('saves effective values while leaving all config identities authoritative', async () => { + let savedTarget: Omit | undefined + const ssh = { + resolveConfigHost: vi.fn().mockResolvedValue({ + alias: 'prod', + hostname: 'prod.internal', + port: 2222, + username: 'deploy', + identityFiles: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false + }), + listTargets: vi + .fn() + .mockImplementation(async () => + savedTarget ? [{ ...savedTarget, id: 'ssh-prod', source: 'manual' as const }] : [] + ), + addTarget: vi.fn().mockImplementation(async ({ target }) => { + savedTarget = target + return { target: { ...target, id: 'ssh-prod', source: 'manual' }, repoReadoptions: [] } + }), + listConfigHosts: vi.fn(), + importConfig: vi.fn() + } + const selection = await prefillFormFromSshConfigHost( + { + alias: 'prod', + hostname: '%h.internal', + port: 22, + username: '', + identityFile: '/keys/second', + alreadyInOrca: false + }, + ssh + ) + + expect(selection).not.toBeNull() + const outcome = await saveNewSshHostFromForm({ + form: selection!.form, + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(outcome).toBe('saved') + expect(ssh.resolveConfigHost).toHaveBeenCalledWith({ alias: 'prod' }) + expect(savedTarget).toMatchObject({ + label: 'prod', + configHost: 'prod', + host: 'prod.internal', + port: 2222, + username: 'deploy', + gssapiAuthentication: true + }) + expect(savedTarget).not.toHaveProperty('identityFile') + expect(savedTarget).not.toHaveProperty('source') + }) +}) + +describe('manual SSH host label fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('labels a bare host with its hostname instead of an empty string', async () => { + let savedTarget: Omit | undefined + const ssh = { + resolveConfigHost: vi.fn(), + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn().mockImplementation(async ({ target }) => { + savedTarget = target + return { target: { ...target, id: 'ssh-1', source: 'manual' }, repoReadoptions: [] } + }), + listConfigHosts: vi.fn(), + importConfig: vi.fn() + } + + const outcome = await saveNewSshHostFromForm({ + form: { ...EMPTY_FORM, host: '10.0.0.7' }, + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(outcome).toBe('saved') + expect(savedTarget?.label).toBe('10.0.0.7') + }) +}) + +describe('bulk add of ~/.ssh/config hosts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The button counts (and promises) only the new hosts the picker listed; re-adopting + // would resurrect hosts the user deleted, which those counts deliberately omit. + it('imports without re-adopting deleted aliases', async () => { + const importConfig = vi.fn().mockResolvedValue({ + targets: [{ id: 'ssh-1', label: 'prod', host: 'prod', port: 22, username: '' }], + repoReadoptions: [] + }) + const ssh = { + importConfig, + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn(), + listConfigHosts: vi.fn(), + resolveConfigHost: vi.fn() + } + + const result = await addAllSshConfigHostsToOrca({ + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(result).toEqual({ kind: 'added', count: 1 }) + expect(importConfig).toHaveBeenCalledTimes(1) + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) + + it('reports already-synced without clearing tombstones', async () => { + const importConfig = vi.fn().mockResolvedValue({ targets: [], repoReadoptions: [] }) + const result = await addAllSshConfigHostsToOrca({ + ssh: { + importConfig, + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn(), + listConfigHosts: vi.fn(), + resolveConfigHost: vi.fn() + }, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(result).toEqual({ kind: 'already-synced' }) + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) +}) + +describe('SSH config picker response admission', () => { + it('accepts the legacy preload array without exposing an unbounded row list', async () => { + const hosts = Array.from({ length: 150 }, (_, index) => ({ + alias: `host-${index}`, + hostname: `host-${index}`, + port: 22, + username: '', + alreadyInOrca: index === 0 + })) + const result = await loadSshConfigHostsForPicker({ + listConfigHosts: vi.fn().mockResolvedValue(hosts) + } as never) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.result).toMatchObject({ + totalHostCount: 150, + newHostCount: 149, + matchCount: 150, + hasMore: true + }) + expect(result.result.hosts).toHaveLength(100) + } + }) + + it('explains when a live renderer still has the older preload API', async () => { + await expect( + prefillFormFromSshConfigHost( + { + alias: 'prod', + hostname: 'prod', + port: 22, + username: '', + alreadyInOrca: false + }, + {} as never + ) + ).rejects.toThrow('Restart Orca') + }) +}) diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts new file mode 100644 index 00000000000..7ad8c6f5f86 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts @@ -0,0 +1,266 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { + getEditingTargetFromSshConfigHost, + getSshTargetDraftConnectionFields, + hasAdvancedConnectionValues, + isRelayGracePeriodValid, + parseRelayGracePeriodSeconds, + type EditingTarget +} from '../settings/ssh-target-draft' +import { + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_CONFIG_HOST_RESULT_LIMIT +} from '../../../../shared/ssh-types' +import type { + SshConfigHostSummary, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshRepoReadoption, + SshTarget, + SshTargetAddResult +} from '../../../../shared/ssh-types' +import { isDuplicateSshTargetAlias } from './ssh-target-duplicate' + +type SshApi = { + listTargets: () => Promise + addTarget: (args: { target: Omit }) => Promise + listConfigHosts: (args?: SshConfigHostListArgs) => Promise + resolveConfigHost: (args: { alias: string }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise<{ + targets: SshTarget[] + repoReadoptions: SshRepoReadoption[] + }> +} + +export async function saveNewSshHostFromForm({ + form, + ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction +}: { + form: EditingTarget + ssh: SshApi + recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void + setSshTargetsMetadata: (targets: SshTarget[]) => void + recordFeatureInteraction: (feature: 'ssh') => void +}): Promise<'saved' | 'validation-failed' | 'failed'> { + const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) + if (!host) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshHostRequired', + 'Host or SSH config alias is required.' + ) + ) + return 'validation-failed' + } + if (Number.isNaN(port) || port < 1 || port > 65535) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshPortInvalid', + 'Port must be between 1 and 65535.' + ) + ) + return 'validation-failed' + } + const graceSeconds = parseRelayGracePeriodSeconds(form) + if (!isRelayGracePeriodValid(form, graceSeconds)) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshRelayGraceInvalid', + 'Terminal timeout must be between 60 and {{value0}} seconds.', + { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } + ) + ) + return 'validation-failed' + } + + const identityFile = form.identityFile.trim() || undefined + const proxyCommand = form.proxyCommand.trim() || undefined + const jumpHost = form.jumpHost.trim() || undefined + const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false + const target = { + label: form.label.trim() || (username ? `${username}@${host}` : configHost || host), + configHost, + host, + port, + username, + ...(form.gssapiAuthentication ? { gssapiAuthentication: true } : {}), + relayGracePeriodSeconds: graceSeconds, + ...(identityFile ? { identityFile } : {}), + ...(proxyCommand ? { proxyCommand } : {}), + ...(jumpHost ? { jumpHost } : {}), + ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) + } + + try { + const existingTargets = await ssh.listTargets() + if ( + isDuplicateSshTargetAlias({ + existingTargets, + configHost: target.configHost, + label: target.label, + host: target.host + }) + ) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshAlreadyExists', + 'That SSH host is already in Orca.' + ) + ) + return 'validation-failed' + } + + const result = await ssh.addTarget({ target }) + recordSshRepoReadoptions(result.repoReadoptions) + setSshTargetsMetadata(await ssh.listTargets()) + recordFeatureInteraction('ssh') + toast.success( + translate('auto.components.sidebar.AddRemoteHostDialog.sshSaved', 'SSH host added.') + ) + return 'saved' + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshSaveFailed', + 'Failed to add SSH host.' + ) + ) + return 'failed' + } +} + +export async function prefillFormFromSshConfigHost( + host: SshConfigHostSummary, + ssh: Pick +): Promise<{ + form: EditingTarget + preferAdvancedOpen: boolean +} | null> { + if (typeof ssh.resolveConfigHost !== 'function') { + throw new Error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerRestartRequired', + 'Restart Orca to finish applying the SSH config picker update.' + ) + ) + } + const resolved = await ssh.resolveConfigHost({ alias: host.alias }) + if (!resolved) { + return null + } + const form = getEditingTargetFromSshConfigHost(resolved) + return { + form, + preferAdvancedOpen: hasAdvancedConnectionValues(form) + } +} + +/** Bulk-load ~/.ssh/config hosts into Orca’s host list (sidebar targets). */ +export async function addAllSshConfigHostsToOrca({ + ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction +}: { + ssh: SshApi + recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void + setSshTargetsMetadata: (targets: SshTarget[]) => void + recordFeatureInteraction: (feature: 'ssh') => void +}): Promise<{ kind: 'added'; count: number } | { kind: 'already-synced' } | { kind: 'failed' }> { + try { + // Why: no reAdopt — the button counts and promises only the *new* hosts the picker + // showed. Re-adopting would resurrect hosts the user deleted, which the count omits. + // Settings → Import stays the explicit re-adopt path. + const result = await ssh.importConfig() + recordSshRepoReadoptions(result.repoReadoptions) + setSshTargetsMetadata(await ssh.listTargets()) + recordFeatureInteraction('ssh') + if (result.targets.length === 0) { + toast( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportAlreadySynced', + '~/.ssh/config already in sync.' + ) + ) + return { kind: 'already-synced' } + } + toast.success( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced', + 'Added {{value0}} host{{value1}} to Orca.', + { value0: result.targets.length, value1: result.targets.length > 1 ? 's' : '' } + ) + ) + return { kind: 'added', count: result.targets.length } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportFailed', + 'Failed to import SSH config.' + ) + ) + return { kind: 'failed' } + } +} + +export async function loadSshConfigHostsForPicker( + ssh: SshApi, + args?: SshConfigHostListArgs +): Promise<{ ok: true; result: SshConfigHostListResult } | { ok: false; error: string }> { + try { + const listed: unknown = await ssh.listConfigHosts(args) + const result = normalizeSshConfigHostListResult(listed) + if (!result) { + throw new Error('Invalid SSH config host response') + } + return { ok: true, result } + } catch (error) { + return { + ok: false, + error: + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerLoadFailed', + 'Failed to read ~/.ssh/config.' + ) + } + } +} + +function normalizeSshConfigHostListResult(value: unknown): SshConfigHostListResult | null { + // Why: a renderer hot reload can briefly outlive the preload that returned the legacy array. + if (Array.isArray(value)) { + const hosts = value.slice(0, SSH_CONFIG_HOST_RESULT_LIMIT) as SshConfigHostSummary[] + return { + hosts, + totalHostCount: value.length, + newHostCount: value.filter( + (host): host is SshConfigHostSummary => + typeof host === 'object' && host !== null && host.alreadyInOrca === false + ).length, + matchCount: value.length, + hasMore: value.length > hosts.length + } + } + if (!value || typeof value !== 'object') { + return null + } + const result = value as Partial + return Array.isArray(result.hosts) && + typeof result.totalHostCount === 'number' && + typeof result.newHostCount === 'number' && + typeof result.matchCount === 'number' && + typeof result.hasMore === 'boolean' + ? (result as SshConfigHostListResult) + : null +} diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts index 6a42c461926..de9c6f430cf 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts @@ -34,11 +34,14 @@ function makeState(overrides: Partial): AddRepoSki filterRepoIds: [], showActiveOnly: false, hideDefaultBranchWorkspace: false, + showSleepingWorkspaces: true, + alwaysShowDefaultBranchWorkspace: true, worktreesByRepo: {}, setActiveRepo: vi.fn(), setFilterRepoIds: vi.fn(), setShowActiveOnly: vi.fn(), setHideDefaultBranchWorkspace: vi.fn(), + setAlwaysShowDefaultBranchWorkspace: vi.fn(), ...overrides } } @@ -83,6 +86,48 @@ describe('finalizeImportedRepoAfterSkip', () => { expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false) }) + it('re-enables the default-branch exemption when the import would land asleep and hidden', () => { + const state = makeState({ + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: false, + worktreesByRepo: { + 'repo-new': [ + makeWorktree({ + id: 'repo-new::/repo/main', + repoId: 'repo-new', + isMainWorktree: true, + branch: 'refs/heads/main' + }) + ] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setAlwaysShowDefaultBranchWorkspace).toHaveBeenCalledWith(true) + }) + + it('leaves the default-branch exemption alone when sleeping workspaces are shown', () => { + const state = makeState({ + showSleepingWorkspaces: true, + alwaysShowDefaultBranchWorkspace: false, + worktreesByRepo: { + 'repo-new': [ + makeWorktree({ + id: 'repo-new::/repo/main', + repoId: 'repo-new', + isMainWorktree: true, + branch: 'refs/heads/main' + }) + ] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setAlwaysShowDefaultBranchWorkspace).not.toHaveBeenCalled() + }) + it('still reveals the imported repo when it has no discovered worktrees yet', () => { const state = makeState({ activeRepoId: 'repo-old', diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 4c5e28d543b..4161f3bb6f9 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -6,11 +6,14 @@ export type AddRepoSkipFinalizationState = { filterRepoIds: string[] showActiveOnly: boolean hideDefaultBranchWorkspace: boolean + showSleepingWorkspaces: boolean + alwaysShowDefaultBranchWorkspace: boolean worktreesByRepo: Record setActiveRepo: (repoId: string | null) => void setFilterRepoIds: (repoIds: string[]) => void setShowActiveOnly: (value: boolean) => void setHideDefaultBranchWorkspace: (value: boolean) => void + setAlwaysShowDefaultBranchWorkspace: (value: boolean) => void } export function finalizeImportedRepoAfterSkip( @@ -37,4 +40,14 @@ export function finalizeImportedRepoAfterSkip( ) { state.setHideDefaultBranchWorkspace(false) } + // Why: with "Hide sleeping" on, a freshly imported project has no live PTY + // yet, so the opted-out exemption would leave it invisible on arrival. + if ( + importedWorktrees.length > 0 && + state.alwaysShowDefaultBranchWorkspace === false && + !state.showSleepingWorkspaces && + importedWorktrees.every((worktree) => worktree.isMainWorktree) + ) { + state.setAlwaysShowDefaultBranchWorkspace(true) + } } diff --git a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts new file mode 100644 index 00000000000..9187dd02e13 --- /dev/null +++ b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest' +import { + computeVisibleWorktreeIds, + isDefaultBranchWorkspace, + type SidebarFilterState +} from './visible-worktrees' +import type { Repo, Worktree } from '../../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' + +/** + * Repro for #8873 — "[Feature]: Display default branch". + * + * Reporter: turning "Hide default branch" OFF does not keep the repo's + * default-branch workspace in the sidebar; once it has no live PTY/browser/agent + * it counts as sleeping and "Hide sleeping" sweeps it away. There is no + * "Always display default branch" escape hatch. + */ + +function makeRepo(id: string): Repo { + return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 0 } +} + +function makeDefaultBranchWorktree(): Worktree { + return { + id: 'wt-main', + repoId: 'repo1', + path: '/tmp/repo1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +const repoMap = new Map([['repo1', makeRepo('repo1')]]) + +type VisibleOptions = Parameters[2] + +function visibleOptions(overrides: Partial = {}): VisibleOptions { + return { + filterRepoIds: [], + showSleepingWorkspaces: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + worktreeIdsWithLiveAgent: new Set(), + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + repoMap, + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {}, + ...overrides + } +} + +describe('#8873 default-branch workspace under "Hide sleeping"', () => { + it('is genuinely the default-branch row the "Hide default branch" toggle targets', () => { + expect(isDefaultBranchWorkspace(makeDefaultBranchWorktree())).toBe(true) + }) + + it('stays in the sidebar when it is sleeping and "Hide sleeping" is on', () => { + const worktree = makeDefaultBranchWorktree() + + const visible = computeVisibleWorktreeIds({ repo1: [worktree] }, [worktree.id], { + ...visibleOptions({ + // "Hide sleeping" ON, "Hide default branch" OFF — exactly the reporter's setup. + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: false + }) + }) + + expect(visible).toEqual([worktree.id]) + }) + + it('exposes an opt-in that keeps the default branch visible under "Hide sleeping"', () => { + // The issue asks for an "Always display default branch" flag. Prove the + // sidebar filter contract has no such knob today. + const filterKeys: readonly (keyof SidebarFilterState)[] = [ + 'showSleepingWorkspaces', + 'filterRepoIds', + 'hideDefaultBranchWorkspace', + 'hideAutomationGeneratedWorkspaces', + 'hideCliCreatedWorkspaces', + 'hideDetachedHeadWorkspaces', + 'alwaysShowDefaultBranchWorkspace', + 'visibleWorkspaceHostIds', + 'workspaceHostScope' + ] + const optionKeys = Object.keys(visibleOptions()) + + const alwaysShowKnob = [...filterKeys, ...optionKeys].find((key) => + /always.*default|default.*always|pinDefaultBranch/i.test(key) + ) + + expect(alwaysShowKnob).toBeDefined() + }) +}) + +describe('the "Hide sleeping" exemption for project entry-point rows', () => { + function visible(worktrees: Worktree[], overrides: Partial): string[] { + return computeVisibleWorktreeIds( + { repo1: worktrees }, + worktrees.map((w) => w.id), + visibleOptions({ showSleepingWorkspaces: false, ...overrides }) + ) + } + + it('re-hides the sleeping default branch when the user opts out', () => { + const worktree = makeDefaultBranchWorktree() + + expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: false })).toEqual([]) + }) + + it('keeps the sleeping default branch when the option is explicitly on', () => { + const worktree = makeDefaultBranchWorktree() + + expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: true })).toEqual([worktree.id]) + }) + + it('lets an explicit "Hide default branch" still win over the exemption', () => { + const worktree = makeDefaultBranchWorktree() + + expect( + visible([worktree], { + hideDefaultBranchWorkspace: true, + alwaysShowDefaultBranchWorkspace: true + }) + ).toEqual([]) + }) + + it('still sweeps sleeping non-main workspaces', () => { + const feature: Worktree = { + ...makeDefaultBranchWorktree(), + id: 'wt-feature', + branch: 'refs/heads/feature', + isMainWorktree: false + } + + expect(visible([feature], { alwaysShowDefaultBranchWorkspace: true })).toEqual([]) + }) + + it('keeps a sleeping folder workspace, which has no sibling row to fall back to', () => { + // Folder-mode projects are main worktrees with an empty branch/head, so the + // default-branch predicate rejects them; sweeping them drops the whole project. + const folder: Worktree = { + ...makeDefaultBranchWorktree(), + id: 'wt-folder', + branch: '', + head: '' + } + expect(isDefaultBranchWorkspace(folder)).toBe(false) + + expect(visible([folder], { alwaysShowDefaultBranchWorkspace: true })).toEqual([folder.id]) + }) + + it('keeps a sleeping detached-HEAD main worktree', () => { + const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' } + + expect(visible([detachedMain], { alwaysShowDefaultBranchWorkspace: true })).toEqual([ + detachedMain.id + ]) + }) + + it('lets "Hide detached HEAD" still win over the exemption', () => { + const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' } + + expect( + visible([detachedMain], { + hideDetachedHeadWorkspaces: true, + alwaysShowDefaultBranchWorkspace: true + }) + ).toEqual([]) + }) + + it('keeps every project’s entry point, not just the first', () => { + const mainA = makeDefaultBranchWorktree() + const mainB: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-main-b' } + + expect(visible([mainA, mainB], {})).toEqual([mainA.id, mainB.id]) + }) + + it('does not flicker out while an SSH host is offline', () => { + // A disconnected SSH provider re-synthesizes persisted worktrees with empty + // head/branch (orca-runtime.ts) while isMainWorktree stays a path compare — + // and the dead PTYs make the row read as sleeping at exactly that moment. + const awake = makeDefaultBranchWorktree() + const offline: Worktree = { ...awake, head: '', branch: '' } + + expect(visible([offline], {})).toEqual([offline.id]) + expect(visible([awake], {})).toEqual([awake.id]) + }) +}) diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index 16bf7c4b954..acaa7d8f16f 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' +import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree-removal' + +// Why: production never hands this function a literal reason — the store derives it from +// classifyWorktreeForceDeleteReason (store/slices/worktrees.ts). Passing one in would let a +// message the classifier rejects still render the force copy, testing a UI that never runs. +function toastCopyForRemovalError(worktreeName: string, error: string): unknown { + return getDeleteWorktreeToastCopy(worktreeName, classifyWorktreeForceDeleteReason(error), error) +} describe('getDeleteWorktreeToastCopy', () => { it('uses direct guidance when force delete is available', () => { @@ -14,11 +22,58 @@ describe('getDeleteWorktreeToastCopy', () => { // toast has to actually offer it — a reason of null hides the button entirely. it('uses terminal-teardown guidance when a PTY stop could not be proven', () => { expect( - getDeleteWorktreeToastCopy( + toastCopyForRemovalError( + 'feature/foo', + 'Failed to physically stop every PTY for worktree: repo-1::/w — could not verify these exited: term_a (the process list timed out)' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.', + isDestructive: false + }) + }) + + // Why: Force Delete proceeds on a proven-live PTY too, so the copy must not describe + // that as an unconfirmed exit — the user is killing a terminal Orca watched running. + it('names the running terminals when verification proved they are still live', () => { + expect( + toastCopyForRemovalError( 'feature/foo', - 'unstopped-pty', 'Failed to physically stop every PTY for worktree: repo-1::/w — still live: term_a' ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.', + isDestructive: false + }) + }) + + // Why: a sweep that never answered wedges removal the same way, and the waiver clears + // both — so it must reach the same force affordance instead of a dead end. + it('offers force delete when the teardown sweep itself timed out', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Timed out waiting for physical PTY teardown: repo-1::/w. Retry with force delete (--force) to remove it anyway.' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.', + isDestructive: false + }) + }) + + // Why: the same wedge worded by the provider instead of the deadline — a dropped SSH + // channel — must reach the same offer, or the escape hatch misses the case it was for. + it('offers force delete when the sweep failed rather than timed out', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Failed to physically stop every PTY for worktree: repo-1::/w — the terminal sweep failed: SSH channel closed while listing processes. Retry with force delete (--force) to remove it anyway.' + ) ).toEqual({ title: 'Failed to delete workspace feature/foo', description: diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index 22f7eb7f5ad..fac1c6ee06a 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -1,6 +1,7 @@ import { translate } from '@/i18n/i18n' import { isLockedWorktreeRemovalError, + isProvenLivePtyRemovalError, type WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' export type DeleteWorktreeToastCopy = { @@ -58,10 +59,18 @@ export function getDeleteWorktreeToastCopy( 'Failed to delete workspace {{value0}}', { value0: worktreeName } ), - description: translate( - 'auto.components.sidebar.delete.worktree.toast.unstoppedPty', - 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.' - ), + // Why: Force Delete proceeds either way, so the copy must say which case this is. + // Telling a user "could not confirm" about terminals Orca watched stay alive asks + // them to waive a doubt that does not exist, and any running agent's work dies with it. + description: isProvenLivePtyRemovalError(error) + ? translate( + 'auto.components.sidebar.delete.worktree.toast.unstoppedPtyLive', + 'This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.' + ) + : translate( + 'auto.components.sidebar.delete.worktree.toast.unstoppedPty', + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.' + ), isDestructive: false } } diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts index 0d27355c45a..317f0e6204e 100644 --- a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -38,6 +38,7 @@ function filterState(overrides: Partial = {}): FilterState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, workspaceHostScope: 'all', ...overrides } @@ -97,6 +98,17 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true) }) + it('counts an opted-out default-branch exemption as an active filter', () => { + expect(sidebarHasActiveFilters(filterState({ alwaysShowDefaultBranchWorkspace: false }))).toBe( + true + ) + }) + + it('treats a missing default-branch exemption as the default, not a filter', () => { + const { alwaysShowDefaultBranchWorkspace: _omitted, ...withoutFlag } = filterState() + expect(sidebarHasActiveFilters(withoutFlag)).toBe(false) + }) + it('returns true when only host visibility is narrowed', () => { expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true) }) @@ -111,6 +123,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -126,6 +139,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -140,6 +154,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -152,6 +167,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: true, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -164,6 +180,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: true, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -189,10 +206,26 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) }) + it('flags only the default-branch exemption for reset when it is the sole filter', () => { + expect( + computeClearFilterActions(filterState({ alwaysShowDefaultBranchWorkspace: false })) + ).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: false, + resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: false + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( @@ -211,6 +244,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) }) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 794d7f78117..d32192d36a5 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -60,7 +60,8 @@ function makeEntry(overrides: Partial & { paneKey: string }): tabId: overrides.tabId, terminalTitle: overrides.terminalTitle, stateHistory: overrides.stateHistory ?? [], - interrupted: overrides.interrupted + interrupted: overrides.interrupted, + restoredUnconfirmed: overrides.restoredUnconfirmed } } @@ -576,6 +577,82 @@ describe('buildAttentionByWorktree', () => { expect(map.get(w.id)).toEqual({ cls: 2, attentionTimestamp: NOW - 30_000 }) }) + it('keeps a restored row idle while allowing an independently live sibling title', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true + }) + }, + { [tab.id]: { 1: '⠋ Claude', 2: '✋ Gemini CLI' } }, + ptyMap([tab.id]), + NOW, + undefined, + splitLayout(tab.id) + ) + + expect(map.get(w.id)).toEqual({ + cls: 1, + attentionTimestamp: NOW, + cause: 'title-heuristic' + }) + }) + + it('does not revive a restored row from one title before layout hydration', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true + }) + }, + { [tab.id]: { 1: '⠋ Claude' } }, + ptyMap([tab.id]), + NOW + ) + + expect(map.get(w.id)).toEqual(IDLE) + }) + + it('still uses one unmapped title when the hook is only age-stale', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + { [tab.id]: { 1: '✋ Gemini CLI' } }, + ptyMap([tab.id]), + NOW + ) + + expect(map.get(w.id)).toEqual({ + cls: 1, + attentionTimestamp: NOW, + cause: 'title-heuristic' + }) + }) + it('per-pane authority across panes: pane A fresh hook=done, pane B no hook + permission title → Class 1', () => { const w = makeWorktree('wt-1') const tab = makeTab('tab-1', w.id) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 01a551428ca..6c102a41405 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -288,8 +288,11 @@ export function buildAttentionByWorktree( if (hookEntries) { for (const entry of hookEntries) { panes.push({ kind: 'hook', entry }) - // Why: only fresh hook entries suppress the title fallback; a stale one would hide the live title and drop to Class 4. - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + // Why: restored rows own their co-restored title without asserting live state. + if ( + !entry.restoredUnconfirmed && + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { continue } const leafId = leafIdFromPaneKey(entry.paneKey) @@ -308,9 +311,12 @@ export function buildAttentionByWorktree( if (paneTitles && Object.keys(paneTitles).length > 0) { // Why: split-pane tabs host multiple agents, one title each; mirrors getWorkingAgentsPerWorktree precedence. const tabLayout = terminalLayoutsByTabId?.[tab.id] - for (const [runtimePaneId, title] of Object.entries(paneTitles)) { + const paneTitleEntries = Object.entries(paneTitles) + for (const [runtimePaneId, title] of paneTitleEntries) { const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) - if (leafId !== null && hookLeafIds.has(leafId)) { + const hasSingleUnmappedHook = + leafId === null && hookLeafIds.size === 1 && paneTitleEntries.length === 1 + if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) { continue } panes.push({ diff --git a/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts b/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts new file mode 100644 index 00000000000..c2c4709101a --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { isDuplicateSshTargetAlias } from './ssh-target-duplicate' + +describe('isDuplicateSshTargetAlias', () => { + it('matches by configHost alias', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'staging', label: 'Staging', host: '10.0.0.1' }], + configHost: 'staging', + label: 'Staging box', + host: 'staging.internal' + }) + ).toBe(true) + }) + + it('matches by label when configHost is empty', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ label: 'prod-box', host: 'prod-box' }], + configHost: '', + label: 'prod-box', + host: 'prod-box' + }) + ).toBe(true) + }) + + it('matches case-only alias variants like the config picker does', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'Staging', label: 'Staging', host: '10.0.0.1' }], + configHost: 'staging', + label: 'staging', + host: 'staging.internal' + }) + ).toBe(true) + }) + + // Parity with the picker: it greys out an alias that matches either field, so saving + // that same alias must be blocked rather than creating a second target for one host. + it('matches an existing label even when that target has a different configHost', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'box1', label: 'prod', host: '10.0.0.1' }], + configHost: 'Prod', + label: 'Prod', + host: 'prod.internal' + }) + ).toBe(true) + }) + + it('returns false for a new alias', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'staging', label: 'staging', host: 's.example' }], + configHost: 'prod', + label: 'prod', + host: 'p.example' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/ssh-target-duplicate.ts b/src/renderer/src/components/sidebar/ssh-target-duplicate.ts new file mode 100644 index 00000000000..0062fa1df94 --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-target-duplicate.ts @@ -0,0 +1,33 @@ +import { normalizeSshConfigAlias } from '../../../../shared/ssh-config-alias' +import type { SshTarget } from '../../../../shared/ssh-types' + +/** True when an existing Orca host already owns this config alias / label. */ +export function isDuplicateSshTargetAlias({ + existingTargets, + configHost, + label, + host +}: { + existingTargets: readonly Pick[] + configHost: string + label: string + host: string +}): boolean { + // Why: the config picker's `alreadyInOrca` flag compares lowercased aliases; match it or the + // two checks disagree on case-only variants. + const alias = + normalizeSshConfigAlias(configHost) || + normalizeSshConfigAlias(label) || + normalizeSshConfigAlias(host) + if (!alias) { + return false + } + return existingTargets.some((target) => getOccupiedAliases(target).includes(alias)) +} + +/** Why: the picker treats configHost *and* label as owned, so the save check must too — + * otherwise an alias it greys out as "In Orca" is still savable as a second target. */ +function getOccupiedAliases(target: Pick): string[] { + const occupied = [target.configHost, target.label].map(normalizeSshConfigAlias).filter(Boolean) + return occupied.length > 0 ? occupied : [normalizeSshConfigAlias(target.host)].filter(Boolean) +} diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 15e3ec6de43..0e565018667 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -22,6 +22,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const settings = useAppStore((s) => s.settings) @@ -61,6 +62,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -79,6 +81,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 4d7ab9e84d6..913e197691a 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -267,6 +267,27 @@ describe('buildWorktreeAgentRows', () => { expect(done?.state).toBe('done') }) + it('decays a restored-unconfirmed working entry to idle even while recent', () => { + // Why: a hydrated nonterminal row may describe a turn that ended while no + // receiver was up; it must never render as confirmed working, however new. + const updatedAt = 2_000 + const now = updatedAt + 1 + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1'), makeTab('tab-2')], + entries: [ + makeEntry(PANE_KEY_1, updatedAt, { state: 'working', restoredUnconfirmed: true }), + makeEntry(PANE_KEY_2, updatedAt, { state: 'working' }) + ], + retained: [], + now + }) + + const unconfirmed = rows.find((r) => r.paneKey === PANE_KEY_1) + const confirmed = rows.find((r) => r.paneKey === PANE_KEY_2) + expect(unconfirmed?.state).toBe('idle') + expect(confirmed?.state).toBe('working') + }) + it('renders live worktree-attributed entries even when their tab is absent', () => { const rows = buildWorktreeAgentRows({ tabs: [], diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index f54dcef60e7..ee212e44ec9 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -553,6 +553,69 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) + it('gives a sleeping-exempt main its cached-sort slot, not a position at the end', () => { + // #8873: the exempted main enters `all` and is sorted with everyone else, + // so Cmd+1-9 numbers it where the sidebar actually renders it. + const awakeA = makeWorktree('awake-a') + const main = { ...makeWorktree('main'), isMainWorktree: true } + const awakeB = makeWorktree('awake-b') + + const options = { + showSleepingWorkspaces: false, + tabsByWorktree: { + [awakeA.id]: [makeTab('t-a', awakeA.id, 'p-a')], + [awakeB.id]: [makeTab('t-b', awakeB.id, 'p-b')] + }, + ptyIdsByTabId: { 't-a': ['p-a'], 't-b': ['p-b'] } + } + const byRepo = { repo1: [awakeA, main, awakeB] } + const sortedIds = [awakeA.id, main.id, awakeB.id] + + expect( + computeVisibleWorktreeIds( + byRepo, + sortedIds, + visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: true }) + ) + ).toEqual([awakeA.id, main.id, awakeB.id]) + + expect( + computeVisibleWorktreeIds( + byRepo, + sortedIds, + visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: false }) + ) + ).toEqual([awakeA.id, awakeB.id]) + }) + + it('leaves lineage ordering untouched when the exempted main is also a parent', () => { + // The parent used to arrive via addVisibleLineageAncestors and now arrives + // on its own; either way it must render immediately above its child. + const parent = { ...makeWorktree('parent'), isMainWorktree: true } + const child = makeWorktree('child') + const sibling = makeWorktree('sibling') + const lineage = makeWorktreeLineage(child, parent) + + const run = (alwaysShowDefaultBranchWorkspace: boolean): string[] => + computeVisibleWorktreeIds( + { repo1: [parent, child, sibling] }, + [sibling.id, child.id, parent.id], + visibleOptions({ + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace, + tabsByWorktree: { + [child.id]: [makeTab('t-child', child.id, 'p-child')], + [sibling.id]: [makeTab('t-sib', sibling.id, 'p-sib')] + }, + ptyIdsByTabId: { 't-child': ['p-child'], 't-sib': ['p-sib'] }, + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(run(true)).toEqual([sibling.id, parent.id, child.id]) + expect(run(false)).toEqual(run(true)) + }) + it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => { const parent = makeWorktree('parent') const child = makeWorktree('child') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 9c44d8587e3..10aa72bcf1f 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -35,6 +35,25 @@ export function isDefaultBranchWorkspace(worktree: Worktree): boolean { return worktree.isMainWorktree && worktree.branch.trim() !== '' } +/** + * Whether the "Hide sleeping" sweep must keep this row (#8873). + * + * Why isMainWorktree and not isDefaultBranchWorkspace: the project's primary + * checkout is the repo's only guaranteed entry point. Folder workspaces and + * detached-HEAD mains fail the default-branch predicate yet often have no + * sibling row at all, so sweeping them drops the entire project out of the + * sidebar, Cmd+J and the board with no way back except changing a filter. + * + * Why shared: the sidebar pipeline and the jump palette both apply this, and a + * second copy is how the two surfaces drift. + */ +export function isSleepingSweepExemptWorkspace( + worktree: Worktree, + alwaysShowDefaultBranchWorkspace: boolean | undefined +): boolean { + return alwaysShowDefaultBranchWorkspace !== false && worktree.isMainWorktree +} + export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean { return worktree.automationProvenance?.kind === 'created-by-automation' } @@ -63,6 +82,8 @@ export type SidebarFilterState = { hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + /** Keeps each project's main workspace out of the "Hide sleeping" sweep; absent means on. */ + alwaysShowDefaultBranchWorkspace?: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null workspaceHostScope?: ExecutionHostScope } @@ -84,6 +105,9 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.hideAutomationGeneratedWorkspaces || state.hideCliCreatedWorkspaces || state.hideDetachedHeadWorkspaces || + // Why: turning this off is the only way to narrow the list below the + // default, so Clear Filters must be able to undo it like any other filter. + state.alwaysShowDefaultBranchWorkspace === false || state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) @@ -98,6 +122,7 @@ export type ClearFilterActions = { resetHideAutomationGeneratedWorkspaces: boolean resetHideCliCreatedWorkspaces: boolean resetHideDetachedHeadWorkspaces: boolean + resetAlwaysShowDefaultBranchWorkspace: boolean resetVisibleWorkspaceHostIds: boolean } @@ -119,6 +144,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, resetHideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + resetAlwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace === false, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) @@ -155,6 +181,11 @@ export function computeVisibleWorktreeIds( hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + // Why optional here, against the "why required" rule above: omitting it + // must fail *open*. A caller that forgets the flag then shows an extra row + // instead of silently re-hiding the project's entry point, which is the + // exact regression #8873 reports. + alwaysShowDefaultBranchWorkspace?: boolean repoMap: Map workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -211,8 +242,11 @@ export function computeVisibleWorktreeIds( } if (!opts.showSleepingWorkspaces) { + // Why no !hideDefaultBranchWorkspace term: that filter already ran above, so + // an explicit hide still wins over the exemption. all = all.filter( (w) => + isSleepingSweepExemptWorkspace(w, opts.alwaysShowDefaultBranchWorkspace) || !isInactiveWorkspace( w.id, opts.tabsByWorktree, @@ -370,6 +404,7 @@ export function getVisibleWorktreeIds(): string[] { hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 26c5d2828df..bc1f06cc7b5 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -15,6 +15,7 @@ function makeAgentStatusEntry(args: { state: AgentStatusEntry['state'] worktreeId?: string parentPaneKey?: string + restoredUnconfirmed?: true }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -24,6 +25,7 @@ function makeAgentStatusEntry(args: { stateStartedAt: 1_000, stateHistory: [], worktreeId: args.worktreeId, + restoredUnconfirmed: args.restoredUnconfirmed, orchestration: args.parentPaneKey ? { taskId: 'task-1', @@ -165,6 +167,33 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('lets an unconfirmed restored row suppress only its pane title', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'working', + restoredUnconfirmed: true + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasLiveWorking: false, hasPermission: false }) + expect(summary.agentStatusPaneIdsByTabId['tab-1']).toEqual(new Set([LEAF_ID])) + }) + it('limits summary-reference churn to the transitioning worktree at scale', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const worktreeIds = Array.from({ length: 12 }, (_, index) => `repo::/wt-${index}`) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 0d3810fe53f..38e585d39ce 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -107,10 +107,17 @@ function getWorktreeAgentActivitySummaries( runtimeAgentOrchestrationByPaneKey?.[paneKey] ) const worktreeId = resolveAgentStatusWorktreeId(entry, tabIdToWorktreeId, orchestration) - if (!worktreeId || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!worktreeId) { continue } const summary = summaryForWorktree(worktreeId) + if (entry.restoredUnconfirmed) { + addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) + continue + } + if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 2f6cbcadcd8..8f1ea50121c 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -108,6 +108,39 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('does not revive an unconfirmed restored row from its preserved title', () => { + const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + agentStatusByPaneKey: { [restored.paneKey]: restored }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) + + it('keeps an independently live sibling title visible beside an unconfirmed row', () => { + const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [restored.paneKey]: restored }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } }, + ptyIdsByTabId: LIVE_PTY, + terminalLayout: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null + } + }) + ).toBe('working') + }) + it('de-spins a stale working tab on an epoch bump without a new map reference', () => { // Why: the freshness scheduler bumps agentStatusEpoch (not the map ref) at // the 30m stale boundary. The flag cache must invalidate on that bump, or an diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index b64ba41b0f5..611a6ca86ae 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -56,22 +56,21 @@ function getTerminalTabActivityFlags( const now = Date.now() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey ?? {})) { const identity = parseAgentStatusPaneKey(entry.paneKey || paneKey) + if (!identity) { + continue + } + if (entry.restoredUnconfirmed) { + const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) + flags.paneIds.add(identity.paneId) + continue + } // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane // must not keep a tab spinning. Same freshness gate as the sidebar. - if (!identity || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { continue } - let flags = flagsByTabId.get(identity.tabId) - if (!flags) { - flags = { - hasPermission: false, - hasLiveWorking: false, - hasLiveDone: false, - paneIds: new Set() - } - flagsByTabId.set(identity.tabId, flags) - } + const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) flags.paneIds.add(identity.paneId) if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true @@ -89,6 +88,23 @@ function getTerminalTabActivityFlags( return flagsByTabId } +function getOrCreateTerminalTabActivityFlags( + flagsByTabId: Map, + tabId: string +): TerminalTabActivityFlags { + let flags = flagsByTabId.get(tabId) + if (!flags) { + flags = { + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + paneIds: new Set() + } + flagsByTabId.set(tabId, flags) + } + return flags +} + // Why: mirror the sidebar summary's parse — live entries on restored/imported // sessions can still carry pre-UUID numeric pane keys. Keep the numeric pane id // so the title-heuristic dedup in resolveWorktreeStatus can still match them. diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 4bb234b9764..f493f2e5807 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -235,10 +235,12 @@ export function useTabGroupWorkspaceModel({ worktreeId ) if (item.contentType === 'terminal') { - closeTerminalTab(item.entityId) - if (!opts?.skipEmptyCheck) { - leaveWorktreeIfEmpty() - } + // Why: closeTerminalTab can defer behind a pin / running-process dialog, so the + // empty check has to run on the actual close — never on cancel. + closeTerminalTab( + item.entityId, + opts?.skipEmptyCheck ? undefined : { onClosed: leaveWorktreeIfEmpty } + ) return } if (item.contentType === 'browser') { @@ -296,7 +298,8 @@ export function useTabGroupWorkspaceModel({ ) if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: revoke local resume + hook authority before the host removes its canonical tab. - closeTerminalTab(item.entityId) + // No running-process prompt: a bulk close of N busy tabs would be a modal storm. + closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true }) continue } if (item.contentType === 'browser') { diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index 771d5e15e77..c4da5244457 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -17,17 +17,26 @@ export type CloseTerminalDialogCopyKind = 'command' | 'agent' export default function CloseTerminalDialog({ open, copyKind = 'command', + tabLabel, + subjectKey, onCancel, onConfirm }: { open: boolean copyKind?: CloseTerminalDialogCopyKind + /** Names the tab when the prompt can target a tab the user is not looking at + * (tab-strip X, middle-click). Omitted for the focused-pane keyboard path. */ + tabLabel?: string + /** Identifies what is being closed, for hosts that reuse one open dialog across a queue + * of confirmations. Changing it clears the previous subject's "don't ask again" tick. */ + subjectKey?: string onCancel: () => void onConfirm: (dontAskAgain: boolean) => void }): React.JSX.Element { const checkboxId = useId() const [dontAskAgain, setDontAskAgain] = useState(false) const [previousOpen, setPreviousOpen] = useState(open) + const [previousSubjectKey, setPreviousSubjectKey] = useState(subjectKey) // Why: each reopen represents a fresh confirmation, so clear the old choice // during render rather than briefly painting it while the dialog opens. @@ -38,7 +47,18 @@ export default function CloseTerminalDialog({ } } + // Why: a queued confirmation swaps the subject without ever closing the dialog, so the + // reopen reset above never fires. Ignore the swap to undefined as the dialog closes — + // clearing the tick mid-exit-animation would be visible for no reason. + if (subjectKey !== previousSubjectKey) { + setPreviousSubjectKey(subjectKey) + if (subjectKey !== undefined) { + setDontAskAgain(false) + } + } + const isAgent = copyKind === 'agent' + const trimmedTabLabel = tabLabel?.trim() return ( + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null}
& { onConfirm: () => void }, + updateSettings: AppState['updateSettings'] +): Promise { + useAppStore.setState({ updateSettings }) + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-1', + tabLabel: 'dev server', + copyKind: 'command', + ...request + }) + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await act(async () => { + root.render() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +function getCheckbox(): HTMLButtonElement { + const checkbox = document.body.querySelector('[role="checkbox"]') + if (!checkbox) { + throw new Error('Checkbox not found') + } + return checkbox +} + +describe('RunningTerminalCloseDialog', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + // Monotonic across tests: the store is a singleton, so winding the clock back would + // leave a previous test's guard deadline in the future and block every action. + clock += 10_000 + vi.spyOn(Date, 'now').mockImplementation(() => clock) + }) + + afterEach(async () => { + while (useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm !== null) { + advancePastGuard() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + } + vi.mocked(Date.now).mockRestore() + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + useAppStore.setState(initialState, true) + }) + + it('names the tab so a background close is not ambiguous', async () => { + const onConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm }, updateSettings) + + expect(document.body.textContent).toContain('Stop running command?') + expect(document.body.textContent).toContain('dev server') + + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).not.toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('uses the agent copy for an agent pane', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm: vi.fn(), copyKind: 'agent' }, updateSettings) + + expect(document.body.textContent).toContain('Stop this agent?') + expect(getButton('Stop Agent')).toBeTruthy() + }) + + it('persists the opt-out when "don\'t ask again" is checked', async () => { + const onConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm }, updateSettings) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + skipCloseTerminalWithRunningProcessConfirm: true + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + // Why: this queue opens after an async probe while the pinned queue opens synchronously, + // so both can be pending at once. Two modal overlays + focus traps is the bug. + it('waits for a visible pinned confirmation instead of stacking a second modal', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ + pinnedTabCloseConfirm: { tabLabel: 'pinned tab', onConfirm: vi.fn() } + }) + + await renderDialog({ onConfirm: vi.fn() }, updateSettings) + + expect(document.body.textContent).not.toContain('Stop running command?') + + await act(async () => { + useAppStore.setState({ pinnedTabCloseConfirm: null }) + }) + + expect(document.body.textContent).toContain('Stop running command?') + }) + + it('does not carry the opt-out tick over to the next queued tab', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const nextOnConfirm = vi.fn() + + await renderDialog({ onConfirm: vi.fn(), onCancel: vi.fn() }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Cancel').click() + }) + + expect(document.body.textContent).toContain('build watcher') + expect(getCheckbox().getAttribute('data-state')).toBe('unchecked') + + advancePastGuard() + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).not.toHaveBeenCalled() + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + }) + + it('drops a queued prompt once the user opts out of asking again', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const onConfirm = vi.fn() + const nextOnConfirm = vi.fn() + + await renderDialog({ onConfirm }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + skipCloseTerminalWithRunningProcessConfirm: true + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + expect(document.body.textContent).not.toContain('build watcher') + }) + + it('cancels without closing and shows the next queued tab', async () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const nextOnConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm, onCancel }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getButton('Cancel').click() + }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('build watcher') + + advancePastGuard() + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx b/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx new file mode 100644 index 00000000000..cf93fa77207 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx @@ -0,0 +1,45 @@ +import { useAppStore } from '@/store' +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import CloseTerminalDialog from './CloseTerminalDialog' + +/** Hosts the running-process close confirmation for tab-level closes (tab-strip X, + * middle-click, tab menu, tab groups, floating panel) so they share the prompt Cmd+W + * already raised. Store-driven, like PinnedTabCloseDialog, because those closes run + * outside any pane's React tree. */ +export default function RunningTerminalCloseDialog(): React.JSX.Element { + const request = useRunningTerminalCloseConfirmStore((state) => state.runningTerminalCloseConfirm) + const confirmClose = useRunningTerminalCloseConfirmStore( + (state) => state.confirmRunningTerminalClose + ) + const confirmAllCloses = useRunningTerminalCloseConfirmStore( + (state) => state.confirmAllRunningTerminalCloses + ) + const dismissClose = useRunningTerminalCloseConfirmStore( + (state) => state.dismissRunningTerminalClose + ) + const updateSettings = useAppStore((state) => state.updateSettings) + // Why: this queue is async (it opens after a probe) while the pinned queue is synchronous, + // so both can be pending at once. Wait rather than stack two modal overlays and focus traps. + const pinnedRequest = useAppStore((state) => state.pinnedTabCloseConfirm) + + return ( + { + if (dontAskAgain) { + void updateSettings({ skipCloseTerminalWithRunningProcessConfirm: true }) + // Why: the user just opted out of this prompt; a queued one must not still appear. + confirmAllCloses() + return + } + confirmClose() + }} + /> + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 9666ae98e53..c2b2149ba03 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -14,7 +14,6 @@ import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' import type { IDisposable } from '@xterm/xterm' import { useAppStore } from '../../store' -import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions' import { @@ -64,6 +63,8 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy' import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { useTerminalFontZoom } from './useTerminalFontZoom' import CloseTerminalDialog, { type CloseTerminalDialogCopyKind } from './CloseTerminalDialog' +import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind' +import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard' import CodexRestartChip from '../CodexRestartChip' import { MobileDriverOverlay } from './MobileDriverOverlay' import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast' @@ -1288,30 +1289,21 @@ function TerminalPane( ) // Cmd+W confirms before killing a shell with a running child (e.g. npm run dev); idle prompts close immediately, and Ctrl+D bypasses by design. + // Why: the agent-vs-command rule is shared with the tab-strip prompt so the two close paths cannot word the same close differently (#10142). const getCloseDialogCopyKind = useCallback( - (paneId: number): CloseTerminalDialogCopyKind => { - const leafId = managerRef.current?.getLeafId(paneId) - if (!leafId) { - return 'command' - } - const agentType = - useAppStore.getState().agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType - return agentType && agentType !== 'unknown' ? 'agent' : 'command' - }, + (paneId: number): CloseTerminalDialogCopyKind => + resolveLeafCloseCopyKind(tabId, managerRef.current?.getLeafId(paneId)), [tabId] ) const handleRequestClosePane = useCallback( (paneId: number) => { - // Why: closing the last pane of a pinned tab prefers the pin dialog over the running-process prompt; non-pinned tabs keep the process prompt. - const isLastPane = (managerRef.current?.getPanes().length ?? 0) <= 1 - if (isLastPane) { - const state = useAppStore.getState() - const confirmPinned = state.settings?.confirmClosePinnedTab ?? true - if (confirmPinned && isUnifiedTabPinned(state, worktreeId, tabId)) { - executeClosePane(paneId) - return - } + // Why: the last pane closes the whole tab, and closeTerminalTab owns both the pinned + // and running-process guards. Probing here too would double-prompt, and its nullable + // transport ptyId would silently skip the prompt the mouse paths now get (#10142). + if ((managerRef.current?.getPanes().length ?? 0) <= 1) { + executeClosePane(paneId) + return } const transport = paneTransportsRef.current.get(paneId) const ptyId = transport?.getPtyId() @@ -1320,18 +1312,40 @@ function TerminalPane( return } const settings = useAppStore.getState().settings + // Why: same bound as the whole-tab guard, so a wedged remote probe never leaves Cmd+W + // looking dead for the full 15s RPC timeout; unanswered means ask, not close (#10142). + let decided = false + const decide = (act: () => void): void => { + if (decided) { + return + } + decided = true + act() + } + const confirmClose = (): void => + setPendingCloseConfirmation({ paneId, copyKind: getCloseDialogCopyKind(paneId) }) + const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS) void inspectRuntimeTerminalProcess(settings, ptyId) .then((process) => { - if (!process.hasChildProcesses || settings?.skipCloseTerminalWithRunningProcessConfirm) { - executeClosePane(paneId) - } else { - setPendingCloseConfirmation({ paneId, copyKind: getCloseDialogCopyKind(paneId) }) - } + clearTimeout(probeTimeout) + decide(() => { + if ( + !process.hasChildProcesses || + settings?.skipCloseTerminalWithRunningProcessConfirm + ) { + executeClosePane(paneId) + } else { + confirmClose() + } + }) }) // Why: if the child-process probe rejects (wedged IPC, legacy provider), close anyway — Cmd+W doing nothing is worse than closing a pane with a child. - .catch(() => executeClosePane(paneId)) + .catch(() => { + clearTimeout(probeTimeout) + decide(() => executeClosePane(paneId)) + }) }, - [executeClosePane, tabId, worktreeId, getCloseDialogCopyKind] + [executeClosePane, getCloseDialogCopyKind] ) useImperativeHandle( diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 83252ccf93c..98cc941aadd 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -204,10 +204,10 @@ describe('agent interrupt inference', () => { now: () => 1_100 }) - tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape', entry, 1) expect(inferInterrupt).not.toHaveBeenCalled() - tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape', entry, 1) expect(inferInterrupt).toHaveBeenCalledWith({ paneKey: PANE_KEY, @@ -422,6 +422,76 @@ describe('agent interrupt inference', () => { expect(inferInterrupt).not.toHaveBeenCalled() }) + it('does not rearm after disposal', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry(), + inferInterrupt, + now: () => 1_100 + }) + + tracker.dispose() + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer against a status created after the input baseline', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry(), + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape', null) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + }) + + it.each([ + ['an older status', makeEntry()], + ['an absent status', null] + ] as const)( + 'does not let a delayed acknowledgment for %s cancel a newer turn after cleanup', + (_label, olderEntry) => { + vi.useFakeTimers() + const newerEntry = makeEntry({ + prompt: 'newer task', + updatedAt: 2_000, + stateStartedAt: 1_900 + }) + let currentEntry: AgentStatusEntry | undefined = newerEntry + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => currentEntry, + inferInterrupt, + now: () => 2_100 + }) + + tracker.observeInputIntent('plain-escape', newerEntry, 2) + currentEntry = undefined + tracker.observeInputIntent('plain-escape', olderEntry, 1) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 2_000, + baselineStateStartedAt: 1_900, + baselinePrompt: 'newer task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + } + ) + it('requires exact plain Escape and Ctrl+C key events', () => { expect(isPlainEscapeKeyEvent(keyEvent({ key: 'Escape' }))).toBe(true) expect(isCtrlCKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index d4ff64f6662..b847badcdfc 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -11,7 +11,11 @@ import { isAskUserQuestionTool } from '../../../../shared/agent-question-answere import { isExplicitAgentStatusFresh } from '@/lib/agent-status' export type AgentInterruptInference = { - observeInputIntent(intent: AgentInterruptInputIntent): void + observeInputIntent( + intent: AgentInterruptInputIntent, + entry?: AgentStatusEntry | null, + baselineSequence?: number + ): void flushPending(): boolean | Promise dispose(): void } @@ -112,10 +116,12 @@ export function createAgentInterruptInference({ setTimer = (callback, ms) => setTimeout(callback, ms), clearTimer = (timer) => clearTimeout(timer) }: AgentInterruptInferenceDeps): AgentInterruptInference { + let disposed = false let pendingTimer: ReturnType | null = null let pendingBaseline: CapturedInterruptBaseline | null = null let doubleEscapeBaseline: CapturedInterruptBaseline | null = null let doubleEscapeTimer: ReturnType | null = null + let latestBaselineSequence = 0 const clearPendingTimer = (): void => { if (pendingTimer !== null) { @@ -159,6 +165,9 @@ export function createAgentInterruptInference({ } const flushPending = (): boolean | Promise => { + if (disposed) { + return false + } const baseline = pendingBaseline pendingTimer = null pendingBaseline = null @@ -199,8 +208,22 @@ export function createAgentInterruptInference({ } return { - observeInputIntent(intent) { - const entry = getStatusEntry() + observeInputIntent(intent, capturedEntry, baselineSequence) { + if (disposed) { + return + } + if (baselineSequence !== undefined) { + if (baselineSequence < latestBaselineSequence) { + return + } + latestBaselineSequence = baselineSequence + } + const currentEntry = getStatusEntry() + // Why: an older acknowledged write must not replace a newer turn's pending inference. + if (capturedEntry !== undefined && currentEntry && currentEntry !== capturedEntry) { + return + } + const entry = capturedEntry === undefined ? currentEntry : capturedEntry if (!entry) { clearPending() return @@ -247,6 +270,7 @@ export function createAgentInterruptInference({ }, flushPending, dispose() { + disposed = true clearPending() } } diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts index f5c55cc72cd..d17155f9d45 100644 --- a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -10,7 +10,6 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, SNAPSHOT_REPLAY_PREAMBLE_ALT, SNAPSHOT_REPLAY_PREAMBLE_NORMAL, - bufferHasSerializeHostileWrappedRow, buildParityMainBufferSnapshot, createRendererParityTerminal, cursorPosition, @@ -86,7 +85,6 @@ type RevealResult = { /** True when the terminal ended on the alternate screen: alt has no scrollback, so the normal-buffer content comparison does not apply. */ alternate: boolean forcedFreshRestore: boolean - knownSerializeWrapBug: boolean } async function readScreen( @@ -100,8 +98,7 @@ async function readScreen( cursor: cursorPosition(term.terminal), scrolled: term.terminal.buffer.active.baseY > 0, alternate, - forcedFreshRestore: false, - knownSerializeWrapBug: false + forcedFreshRestore: false } } @@ -133,7 +130,6 @@ async function revealFromSnapshot( pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join('')) }) const alt = snapshot.alternateScreen - const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal) // The pending escape tail must be the FINAL replay write (mirrors applyMainBufferSnapshot); a later ESC would abort the dangling sequence. const preamble = alt && snapshot.scrollbackAnsi !== undefined @@ -168,7 +164,6 @@ async function revealFromSnapshot( } const result = await readScreen(restored) result.forcedFreshRestore = forcedFreshRestore - result.knownSerializeWrapBug = knownSerializeWrapBug return result } finally { source.terminal.dispose() @@ -388,7 +383,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { let statsCompared = 0 let statsSkippedScrolled = 0 let statsForcedFresh = 0 - let statsKnownWrapBug = 0 for (let i = 0; i < ITERATIONS; i++) { const seed = FIXED_SEED ?? 1 + i const scenario = buildScenario(seed) @@ -403,10 +397,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { statsForcedFresh += 1 continue } - if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) { - statsKnownWrapBug += 1 - continue - } if (reveal.alternate !== reference.alternate) { expect.fail( formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate) @@ -456,8 +446,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { if (FIXED_SEED === null) { expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2) } - expect( - statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug - ).toBeLessThanOrEqual(ITERATIONS) + expect(statsCompared + statsSkippedScrolled + statsForcedFresh).toBeLessThanOrEqual(ITERATIONS) }, 120_000) }) diff --git a/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts b/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts new file mode 100644 index 00000000000..d93f29ba796 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { PtyStartupIngress } from '../../../../shared/pty-startup-ingress' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { installTerminalCapabilityReplyHandlers } from './terminal-capability-replies' +import { createIpcPtyTransport } from './pty-transport' +import type { PtyTransport } from './pty-transport-types' + +// Regression for #12112: a NEW opencode agent tab on Linux prints the literal +// `10;rgb:ffff/ffff/ffff` / `11;rgb:2828/2c2c/3434` text and opencode never +// initializes, while a plain Ctrl+T tab running the same program is clean. +// +// The divergence is the main-side PtyStartupIngress: only agent panes arm it +// (terminal-startup-color-query-replies.ts), and on `posix-pty` it answers the +// query synchronously inside node-pty's onData — before the querying program +// has finished entering raw mode — with no echo suppression. The suppression +// exists but is gated on `ownerBackend === 'windows-conpty'` +// (pty-startup-ingress.ts:26-29, 247-252), so the cooked-mode echo of Orca's +// own reply is forwarded to the renderer verbatim and rendered as text. + +// opencode/OpenTUI's unconditional startup burst (BEL-terminated, not ST). +const OPENCODE_STARTUP_QUERY_BURST = '\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07' +// Orca's One Dark terminal theme — the values that appear in the leaked text. +const ORCA_TERMINAL_THEME = { foreground: '#ffffff', background: '#282c34' } +const OSC10_REPLY = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' +const OSC11_REPLY = '\x1b]11;rgb:2828/2c2c/3434\x1b\\' +const LEAKED_COLOR_REPLY_TEXT = /\d\d;rgb:[0-9a-f]{4}\// + +const PTY_ID = 'pty-12112' +let dispatchPtyData: (payload: { id: string; data: string }) => void = () => {} + +/** + * bash/readline echo projection: `\e]` is an unbound binding, so readline eats + * ESC + `]` and beeps, then self-inserts the rest; the ST is eaten the same way. + * Verified against a real `bash --norc -i` behind node-pty, and it reproduces + * the reported string exactly once rendered by xterm. + */ +function readlineEchoOf(reply: string): string { + return reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') +} + +type StartupTty = { + /** A writer (main ingress or renderer) pushes bytes at the PTY master. */ + writeToPty: (data: string) => void + /** Bytes the querying program actually read in raw mode. */ + programInput: () => string + onPtyOutput: (sink: (data: string) => void) => void + emitStartupBurst: () => void +} + +/** + * Models the single fact that decides this bug: the tty is still line + * disciplined (shell prompt / program mid-`tcsetattr`) during the turn that + * carries the startup burst, and raw immediately after. A writer answering + * synchronously inside that turn is echoed; one answering a turn later reaches + * the program. Both arms of this test share this exact model, so the divergence + * comes from Orca's code, not from the harness. + */ +function createStartupTty(): StartupTty { + let raw = false + let received = '' + let sink: (data: string) => void = () => {} + return { + writeToPty: (data) => { + if (raw) { + received += data + return + } + sink(readlineEchoOf(data)) + }, + programInput: () => received, + onPtyOutput: (next) => { + sink = next + }, + emitStartupBurst: () => { + sink(OPENCODE_STARTUP_QUERY_BURST) + // Why microtask: opencode finishes entering raw mode essentially at once, + // but not inside its writer's synchronous callback. + queueMicrotask(() => { + raw = true + }) + } + } +} + +function stubPtyApi(tty: StartupTty): void { + vi.stubGlobal('window', { + api: { + pty: { + spawn: vi.fn(async () => ({ id: PTY_ID })), + write: vi.fn((_id: string, data: string) => tty.writeToPty(data)), + resize: vi.fn(), + kill: vi.fn(async () => {}), + claimViewport: vi.fn(), + onData: (cb: (payload: { id: string; data: string }) => void) => { + dispatchPtyData = cb + return () => {} + }, + onReplay: () => () => {}, + onExit: () => () => {} + } + } + }) +} + +type RendererPane = { + transport: PtyTransport + deliver: (data: string) => void + renderedText: () => string + dispose: () => void +} + +/** Real xterm + real capability-reply handlers + the real local IPC transport. */ +async function createRendererPane(): Promise { + const terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + terminal.options.theme = { ...ORCA_TERMINAL_THEME } + + const transport = createIpcPtyTransport({ worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'pane:1' }) + await transport.connect({ + url: '', + cols: 80, + rows: 24, + callbacks: { onData: (data) => terminal.write(data) } + }) + + const handlers = installTerminalCapabilityReplyHandlers({ + terminal: terminal as never, + parser: terminal.parser, + // Matches pty-connection.ts: replies go out via sendInputImmediate. + sendInput: (data) => transport.sendInputImmediate(data), + isReplaying: () => false + }) + + return { + transport, + deliver: (data) => dispatchPtyData({ id: PTY_ID, data }), + renderedText: () => { + const lines: string[] = [] + for (let row = 0; row < terminal.rows; row += 1) { + lines.push(terminal.buffer.active.getLine(row)?.translateToString(true) ?? '') + } + return lines.join('\n').trim() + }, + dispose: () => { + handlers.dispose() + terminal.dispose() + } + } +} + +async function settleUntil(condition: () => boolean, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs + while (!condition() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } + expect(condition()).toBe(true) +} + +describe('#12112 opencode startup OSC 10/11 replies on the local path', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('plain terminal tab consumes the replies and renders nothing', async () => { + const tty = createStartupTty() + stubPtyApi(tty) + const pane = await createRendererPane() + // A Ctrl+T tab has no launch agent, so shouldReplyToStartupTerminalColorQueries + // (terminal-startup-color-query-replies.ts) is false, main arms no startup + // ingress, and the pane's own xterm is the sole responder. + expect(isTuiAgent(undefined)).toBe(false) + tty.onPtyOutput(pane.deliver) + + try { + tty.emitStartupBurst() + await settleUntil(() => tty.programInput().includes(OSC11_REPLY)) + + expect(pane.renderedText()).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + expect(tty.programInput()).toContain(OSC10_REPLY) + expect(tty.programInput()).toContain(OSC11_REPLY) + } finally { + pane.dispose() + } + }) + + it('agent pane consumes the replies and renders nothing', async () => { + const tty = createStartupTty() + stubPtyApi(tty) + const pane = await createRendererPane() + + // opencode is a TUI agent, so main arms spawnOptions.startupIngress for this + // pane and only this pane (pty.ts:4024, terminal-startup-color-query-replies.ts). + expect(isTuiAgent('opencode')).toBe(true) + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => tty.writeToPty(data), + onEmission: (emission) => pane.deliver(emission.data) + }) + tty.onPtyOutput((data) => ingress.accept(data)) + + try { + tty.emitStartupBurst() + await settleUntil(() => tty.programInput().includes(OSC11_REPLY)) + + expect(pane.renderedText()).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + expect(tty.programInput()).toContain(OSC10_REPLY) + expect(tty.programInput()).toContain(OSC11_REPLY) + } finally { + ingress.drainAndClose() + pane.dispose() + } + }) + + it('renders no reply text when the tty echo is coalesced with program output', () => { + // The reported topology: the agent is launched by writing `opencode\n` into an + // interactive shell, so bash's echo of Orca's reply shares a read with the shell's + // own echo and the agent's first frame. It is never at the head of a chunk, and a + // read carrying no echo at all comes first. + vi.useFakeTimers() + const echoLayouts = [ + (replies: readonly string[]) => + `opencode\r\n\x1b[2Jloading${replies.map(readlineEchoOf).join('')}\r\n$ `, + // Both slots answered, but the agent draws between the two echoes. + (replies: readonly string[]) => + replies.map((reply) => `${readlineEchoOf(reply)}FRAME\r\n`).join('') + ] + + for (const [index, layout] of echoLayouts.entries()) { + const emitted: string[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emitted.push(emission.data) + }) + ingress.accept(OPENCODE_STARTUP_QUERY_BURST) + vi.advanceTimersByTime(0) + expect(writes, `layout ${index}`).toEqual([OSC10_REPLY, OSC11_REPLY]) + + ingress.accept(layout(writes)) + ingress.drainAndClose() + + expect(emitted.join(''), `layout ${index}`).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + } + }) + + it('suppresses its own cooked echo on POSIX and ConPTY', () => { + // No xterm, no transport: the asymmetry alone, with each backend's observed + // cooked echo handed straight back the way its line discipline would. + vi.useFakeTimers() + const forward = (ownerBackend: 'posix-pty' | 'windows-conpty') => { + const echoOf = + ownerBackend === 'windows-conpty' + ? (reply: string): string => reply.replaceAll('\x1b', '') + : readlineEchoOf + const emitted: string[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend, + write: (data) => { + writes.push(data) + ingress.accept(echoOf(data)) + }, + onEmission: (emission) => emitted.push(emission.data) + }) + ingress.accept(OPENCODE_STARTUP_QUERY_BURST) + // Why: the posix write is deferred, so draining first would make this arm + // assert on a stream where no reply was ever sent. + vi.advanceTimersByTime(0) + ingress.drainAndClose() + return { visible: emitted.join(''), writes } + } + + for (const ownerBackend of ['windows-conpty', 'posix-pty'] as const) { + const { visible, writes } = forward(ownerBackend) + expect(writes, ownerBackend).toEqual([OSC10_REPLY, OSC11_REPLY]) + expect(visible, ownerBackend).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 7977196979e..626168ef265 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -33,13 +33,15 @@ beforeAll(() => { }) import { - buildFontFamily, buildPostReplayLiveAgentReattachReset, POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, replayPayloadEndsWithCursorHidden, RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../shared/terminal-mode-reset-profiles' +import { + buildFontFamily, restoreScrollbackBuffers, serializePaneTree, serializeTerminalLayout, diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 6fb2df02703..2f8d0fea2f9 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -4,6 +4,7 @@ import type { TerminalPaneSplitDirection } from '../../../../shared/types' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' @@ -27,40 +28,6 @@ export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { expandedLeafId: null } -// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. -export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' -export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' -// Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016. -export const RESET_MOUSE_REPORTING = - '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' - -export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l` - -// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on. -export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` - -// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. -export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` - -// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME). -export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` - -// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. -export function replayPayloadEndsWithCursorHidden(payload: string): boolean { - const hideIndex = payload.lastIndexOf('\x1b[?25l') - return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h') -} - -// Why: some agents hide the real cursor and draw their own, so preserve the payload's final visibility (pty-connection re-shows it if the agent was actually a dead TUI). -export function buildPostReplayLiveAgentReattachReset(payload: string): string { - return replayPayloadEndsWithCursorHidden(payload) - ? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` - : POST_REPLAY_LIVE_AGENT_REATTACH_RESET -} - -// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup. -export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE - // Cross-platform monospace chain: browsers skip fonts absent on the current OS, so listing all is safe. // Nerd Fonts come last to cover PUA glyphs (U+E000–U+F8FF) from OMP/Powerline that standard monospace fonts lack. const FALLBACK_FONTS = [ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 3044d90c519..8bb448ed11a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -10,7 +10,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { DEFAULT_DA1_RESPONSE } from './terminal-capability-replies' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' @@ -6536,7 +6536,74 @@ describe('connectPanePty', () => { expect(mockStoreState.recordTerminalInput).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) }) - it('does not consume startup draft delivery before deferred connect starts', async () => { + it('keeps startup draft ownership while deferred connect waits through setup', async () => { + const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = + await import('@/lib/agent-startup-delayed-delivery') + + const deferredFrames: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + deferredFrames.push(callback) + return 1 + }) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + }) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'wait-for-setup-then-codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-setup', + draftPrompt: 'Linked Linear issue: STA-905' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const sidecarClaimed = claimStartupDelivery({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-setup' + }) + expect(sidecarClaimed).toBe(false) + + let frameCount = 0 + while (deferredFrames.length > 0 && transport.connect.mock.calls.length === 0) { + if (frameCount >= 20) { + throw new Error('startup did not connect after the deferred setup handoff') + } + frameCount += 1 + deferredFrames.shift()?.(frameCount * 16) + } + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + capturedDataCallback.current?.('\x1b[?2004hWaiting for setup to finish...') + expect(transport.sendInputAccepted).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + '\x1b[200~Linked Linear issue: STA-905\x1b[201~' + ) + }) + + it('releases startup draft delivery when disposed before deferred connect starts', async () => { const { connectPanePty } = await import('./pty-connection') globalThis.requestAnimationFrame = vi.fn(() => 1) const transport = createMockTransport('pty-codex') @@ -7564,7 +7631,7 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') }) - it('flushes pending interrupt inference before dropping an exited foreground agent command', async () => { + it('pins interrupt inference before acknowledged input and command exit cleanup', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -7633,18 +7700,14 @@ describe('connectPanePty', () => { if (!onDataHandler) { throw new Error('expected onData handler to be registered') } - terminalTarget.dispatch( - keyEvent({ - key: 'c', - ctrlKey: true - }) - ) - ;(onDataHandler as unknown as (data: string) => void)('\x03') + terminalTarget.dispatch(keyEvent({ key: 'Escape' })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b') capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + delete mockStoreState.agentStatusByPaneKey[paneKey] writeAccepted.resolve(true) await flushAsyncTicks() @@ -7654,7 +7717,7 @@ describe('connectPanePty', () => { baselineStateStartedAt: 900, baselinePrompt: 'stop quickly', baselineAgentType: 'codex', - intent: 'ctrl-c' + intent: 'plain-escape' }) expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) }) @@ -8967,6 +9030,103 @@ describe('connectPanePty', () => { }) }) + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { + // Why: pane status and title are persisted, so after a cold restore they still + // describe the process that died and make the pane look agent-owned. Preserving + // "its" modes arms mouse/focus/paste reporting against the replacement shell, + // which then prints the reports as junk at the prompt (#12101). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + expect(writes).toContain(POST_REPLAY_MODE_RESET) + expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + }) + }) + + it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => { + // Why: main can answer a *spawn* with an adopted session, so the reattach handler + // is reachable by a second door that skips the restored-session path entirely. The + // cold-restore signal has to survive that door too, or #12101 returns on it. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + let activePtyId = 'tab-pty' + transport.getPtyId.mockImplementation(() => activePtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + throw new Error('restored session is gone') + } + // Main answered the spawn by adopting a durable session instead. + activePtyId = 'adopted-pty' + return { + id: 'adopted-pty', + isReattach: true, + // Keep this snapshot free of ?25l: the live agent reset is built from the + // payload and only equals the constant negated below when the cursor is left + // visible. Ending it hidden would quietly retire that assertion. + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(30) + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty') + expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + const output = writes.join('') + const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ') + const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET) + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(resetIndex).toBeGreaterThan(snapshotIndex) + expect(writes).toContain(POST_REPLAY_MODE_RESET) + expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + }) + }) + it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5edb8ac1538..fdace14f0d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route' import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' +import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' import { resolvePaneTitleDecision } from './terminal-title-evidence' import { blocksCodexPaneInput } from '../codex-restart-notice-state' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' @@ -122,7 +123,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' @@ -1267,6 +1268,8 @@ export function connectPanePty( }) startupDraftDeliveryClaimed = false } + // Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner. + const ownsStartupDraftPaste = claimStartupDraftPasteDelivery() if (paneStartup?.launchConfig) { useAppStore.getState().registerAgentLaunchConfig(cacheKey, paneStartup.launchConfig, { agentType: paneStartup.launchAgent ?? paneStartup.initialAgentStatus?.agent, @@ -1950,6 +1953,8 @@ export function connectPanePty( questionAnsweredInference.observeSentTerminalInput(data) } let pendingTerminalInputWrite: Promise | null = null + let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined + let interruptStatusBaselineSequence = 0 const setPendingTerminalInputWrite = (promise: Promise): void => { pendingTerminalInputWrite = promise void promise.finally(() => { @@ -3007,7 +3012,7 @@ export function connectPanePty( // rings the bell. This is specific to terminals with cross-restart // persistence (as we have); our fix is to reset 1004 and friends after // scrollback replay so the mode state matches the fresh shell - // underneath. See POST_REPLAY_MODE_RESET in layout-serialization.ts. + // underneath. See POST_REPLAY_MODE_RESET in shared/terminal-mode-reset-profiles.ts. const onBell = (): void => { // Why: restored Claude Code sessions have been observed to emit a real // standalone BEL some time after daemon snapshot reattach, even when Orca @@ -3684,9 +3689,14 @@ export function connectPanePty( // "input after done" forever. The core user-input signal fires only for real // input, so hibernation activity records from it; onData recording remains // solely as the fallback when the internal API is unavailable. + const recordRealUserTerminalInput = (): void => { + recordTerminalInputForHibernation() + // Takeover must never fire from the onData fallback below: it mixes in auto-replies. + reportWorkerTerminalUserInput(cacheKey, runtimeEnvironmentId) + } const userInputActivityDisposable = subscribeToTerminalUserInput( pane.terminal, - recordTerminalInputForHibernation + recordRealUserTerminalInput ) const recordTerminalInputForHibernationFallback = (): void => { if (userInputActivityDisposable === null) { @@ -4053,6 +4063,13 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + const interruptStatusBaseline = useAppStore.getState().agentStatusByPaneKey[cacheKey] ?? null + // Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races. + if (sequencedInterruptStatusBaseline !== interruptStatusBaseline) { + sequencedInterruptStatusBaseline = interruptStatusBaseline + interruptStatusBaselineSequence += 1 + } + const capturedBaselineSequence = interruptStatusBaselineSequence claimViewportForUserActivity() if (acknowledgedIntent === 'ctrl-c') { // Why: the accepted-write callback is async; let the next command be @@ -4068,7 +4085,11 @@ export function connectPanePty( markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) - interruptInference.observeInputIntent(acknowledgedIntent) + interruptInference.observeInputIntent( + acknowledgedIntent, + interruptStatusBaseline, + capturedBaselineSequence + ) observeTitleOnlyInterrupt() } else { // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; @@ -4700,7 +4721,6 @@ export function connectPanePty( } schedulePendingStartupCommandDelivery() } - const ownsStartupDraftPaste = claimStartupDraftPasteDelivery() const startupDraftReadyScanner = ownsStartupDraftPaste ? createDraftPasteReadyScanner( startupDraftAgentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' @@ -5472,7 +5492,15 @@ export function connectPanePty( }) } - const reattachReplayResetSequence = (payload: string): string => { + const reattachReplayResetSequence = (payload: string, ownerProcessEnded = false): string => { + // Why a cold restore overrides the agent signal: liveness is read from the + // pane's status and title, both of which are persisted, so after a cold + // restore they describe the process that died. Preserving "its" modes arms + // mouse, focus and paste reporting against the fresh shell that replaces it, + // which then prints the reports as junk at the prompt (#12101). + if (ownerProcessEnded) { + return POST_REPLAY_MODE_RESET + } return shouldPreserveAgentReattachModes() ? buildPostReplayLiveAgentReattachReset(payload) : POST_REPLAY_REATTACH_RESET @@ -7916,8 +7944,10 @@ export function connectPanePty( // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. kittyKeyboardModes.scanReplay(connectResult.snapshot) writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone. + writeReplayData( + reattachReplayResetSequence(connectResult.snapshot, Boolean(connectResult.coldRestore)) + ) if (connectResult.pendingEscapeTailAnsi) { // Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329). writeReplayData(connectResult.pendingEscapeTailAnsi) @@ -7970,7 +8000,9 @@ export function connectPanePty( for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) { writeReplayData(replayChunk) } - writeReplayData(reattachReplayResetSequence(modelData)) + writeReplayData( + reattachReplayResetSequence(modelData, Boolean(connectResult?.coldRestore)) + ) if (modelSnapshot.pendingEscapeTailAnsi) { // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329). writeReplayData(modelSnapshot.pendingEscapeTailAnsi) @@ -7989,7 +8021,9 @@ export function connectPanePty( // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack. kittyKeyboardModes.scanReplay(connectResult.replay) writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) + writeReplayData( + reattachReplayResetSequence(connectResult.replay, Boolean(connectResult.coldRestore)) + ) sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts new file mode 100644 index 00000000000..886516414ca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +describe('remote runtime pty reattach after the bounded recovery window', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribedTerminalHandles(): string[] { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + } + + function emitSnapshot(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + } + + function hostSnapshot( + terminal: string, + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude Code', + isActive: true, + status: 'ready', + terminal + } + ] + } + } + + async function attachStalePane() { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit, + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + + // The host keeps publishing the same live handle, so bounded replacement polling finds + // no replacement and stops quietly without retiring the pane. + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + return { ok: true, result: hostSnapshot('terminal-stale', hostListCalls + 1, 'epoch-1') } + }) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return { transport, onError, onPtyExit } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot('terminal-stale', 1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + return { + ok: true, + result: { + terminal: { + handle: 'terminal-stale', + tabId: params.paneKey.slice(0, separator), + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: { terminal: { handle: 'terminal-stale' } } } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('reattaches from a rotated host handle published after the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const { transport, onPtyExit } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + await vi.advanceTimersByTimeAsync(50_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + // The cutoff must not tear down the accepted-snapshot listener; it is the only path back. + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-after-timeout', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-after-timeout' }) + ) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout') + expect(onPtyExit).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches when the post-cutoff snapshot republishes the same live handle', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale', 'terminal-stale']) + // The snapshot is already-received host evidence; reattaching must cost no inventory RPC. + expect(hostListCalls).toBe(listCallsAtCutoff) + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores a same-handle snapshot while automatic recovery is still running', async () => { + vi.useFakeTimers() + try { + const { transport } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(1_000) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale']) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches from a same-handle snapshot delivered inside the Reconnect window', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + const listCallsAtCutoff = hostListCalls + + expect(transport.retryRecovery?.()).toBe(true) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + // Reconnect must not disarm the only path back: the click opens a window, it does not spend one. + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('revives a latched require-replacement pane when online or system resume fires', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('leaves Reconnect available when online fires on a pane latched during the attach wait', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method === 'session.tabs.activate') { + return { + ok: false, + error: { + code: 'remote_runtime_unavailable', + message: 'Remote Orca runtime connection closed' + } + } + } + return { ok: true, result: {} } + }) + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const callsBeforeRetry = runtimeCall.mock.calls.length + // The attach wait is single-shot and the cutoff already settled it; replaying it would spin the banner with no RPC in flight. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(runtimeCall.mock.calls.length).toBe(callsBeforeRetry) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts new file mode 100644 index 00000000000..5892ee2c19d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +// Why: the recovery cutoff no longer tears down the retry registry entry or the accepted-snapshot +// listener, so those two module-global collections are the only places a latched pane can accumulate. +describe('remote runtime pty latched-pane retention', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribeFrameCount(): number { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe).length + } + + // Why: every pane needs its own host surface, otherwise a later pane reuses the earlier pane's + // multiplexed stream and never enters recovery at all. + const paneIdentity = (pane: number) => ({ + hostTabId: `tab-${pane}`, + tabId: `web-terminal-tab-${pane}`, + leafId: `pane:${pane}`, + handle: `terminal-stale-${pane}` + }) + + const PANE_COUNT = 24 + + // Why: one payload carries every pane's surface, matching the real per-worktree session.tabs + // response that any polling pane receives. + function hostSnapshot( + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: `${paneIdentity(0).hostTabId}::${paneIdentity(0).leafId}`, + activeTabType: 'terminal' as const, + tabs: Array.from({ length: PANE_COUNT }, (_unused, pane) => { + const identity = paneIdentity(pane) + return { + type: 'terminal' as const, + id: `${identity.hostTabId}::${identity.leafId}`, + parentTabId: identity.hostTabId, + leafId: identity.leafId, + title: 'Claude Code', + isActive: pane === 0, + status: 'ready' as const, + terminal: identity.handle + } + }) + } + } + + async function attachStalePane(pane: number) { + const identity = paneIdentity(pane) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: identity.tabId, + leafId: identity.leafId, + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + const subscribesBefore = subscribeFrameCount() + transport.attach({ + existingPtyId: `remote:env-1@@${identity.handle}`, + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + await vi.waitFor(() => expect(subscribeFrameCount()).toBeGreaterThan(subscribesBefore)) + + // The host keeps publishing the same live handle, so bounded replacement polling finds no + // replacement and stops quietly: the exact shape that leaves the pane latched but revivable. + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return transport + } + + async function registries() { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const recoveryState = await import('./remote-runtime-pty-recovery-state') + return { + subscribers: handleEvents.getWebSessionTerminalHandleSubscriberCountForTests(), + scheduled: recoveryState.getScheduledRemoteRuntimePtyRecoveryCountForTests() + } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.list') { + hostListCalls += 1 + return { ok: true, result: hostSnapshot(hostListCalls + 1, 'epoch-1') } + } + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot(1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + const paneTabId = params.paneKey.slice(0, separator) + const pane = Number(paneTabId.slice(paneTabId.lastIndexOf('-') + 1)) + return { + ok: true, + result: { + terminal: { + handle: paneIdentity(pane).handle, + tabId: paneTabId, + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: {} } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('returns listener and retry-registry counts to baseline across destroy cycles', async () => { + vi.useFakeTimers() + try { + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + const latched: { subscribers: number; scheduled: number }[] = [] + const settled: { subscribers: number; scheduled: number }[] = [] + + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + latched.push(await registries()) + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + + // A latched pane deliberately holds exactly one listener and one registry entry... + expect(latched).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 1, scheduled: 1 }))) + // ...and destroy releases both, so twenty cycles do not accumulate anything. + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('returns to baseline when a latched pane is detached rather than destroyed', async () => { + vi.useFakeTimers() + try { + const settled: { subscribers: number; scheduled: number }[] = [] + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.detach?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('holds one listener and one registry entry per concurrently latched pane', async () => { + vi.useFakeTimers() + try { + const transports: Awaited>[] = [] + for (let pane = 0; pane < 8; pane += 1) { + transports.push(await attachStalePane(pane)) + await vi.advanceTimersByTimeAsync(66_000) + } + // Retention is per live pane, not per timeout: eight latched panes hold eight of each. + expect(await registries()).toEqual({ subscribers: 8, scheduled: 8 }) + + for (const transport of transports) { + transport.destroy?.() + } + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('leaves a latched pane fully quiescent — no timers, no RPCs, no growth', async () => { + vi.useFakeTimers() + try { + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const callsAtLatch = runtimeCall.mock.calls.length + const timersAtLatch = vi.getTimerCount() + // Nothing is armed once the window is spent: the pane costs one listener and one registry + // entry, and nothing else, no matter how long it stays latched. + expect(timersAtLatch).toBe(0) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + + expect(runtimeCall.mock.calls.length).toBe(callsAtLatch) + expect(vi.getTimerCount()).toBe(0) + expect(await registries()).toEqual(baseline) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack listeners, registry entries or timers across repeated revive cycles', async () => { + vi.useFakeTimers() + try { + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const timersAtFirstLatch = vi.getTimerCount() + const subscribesAtFirstLatch = subscribeFrameCount() + const observed: { + subscribers: number + scheduled: number + timers: number + revived: number + }[] = [] + + for (let cycle = 0; cycle < 25; cycle += 1) { + const revived = retryAllRemoteRuntimePtyRecoveriesNow() + // A second trigger in the same window must find nothing to advance, so an online/resume + // storm cannot stack fresh recovery epochs on one pane. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + observed.push({ ...(await registries()), timers: vi.getTimerCount(), revived }) + } + + expect(observed).toEqual( + Array.from({ length: 25 }, () => ({ + subscribers: baseline.subscribers, + scheduled: baseline.scheduled, + timers: timersAtFirstLatch, + revived: 1 + })) + ) + // Each revive re-derives the handle; it must not leave extra live stream subscriptions behind. + expect(subscribeFrameCount()).toBe(subscribesAtFirstLatch) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack anything when host snapshots arrive repeatedly at a latched pane', async () => { + vi.useFakeTimers() + try { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const baseline = await registries() + + const subscribesAtLatch = subscribeFrameCount() + + for (let cycle = 0; cycle < 50; cycle += 1) { + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot(10 + cycle, `epoch-${cycle}`), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(100) + } + + const after = await registries() + expect(after.subscribers).toBeLessThanOrEqual(baseline.subscribers) + expect(after.scheduled).toBeLessThanOrEqual(baseline.scheduled) + // At most one same-handle resubscribe per spent window: 50 snapshots inside one window + // must not become 50 subscribes. + expect(subscribeFrameCount() - subscribesAtLatch).toBeLessThanOrEqual(1) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts index f2556b737b0..07a9199d307 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts @@ -69,6 +69,7 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(state.isActive).toBe(false) expect(state.isCurrent(epoch)).toBe(false) expect(onChange).toHaveBeenCalled() + state.dispose() }) it('advances a pending backoff immediately via retryNow and the active registry', async () => { @@ -154,16 +155,56 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(retryNow).not.toHaveBeenCalled() }) - it('removes timed-out panes from the scheduled recovery registry', async () => { + it('keeps a timed-out pane revivable through the scheduled recovery registry', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + // Why: the backoff ladder outlasts the recovery window, so a retry is still armed when the cutoff lands. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS - 100) + state.schedule(firstEpoch, retry) + + await vi.advanceTimersByTimeAsync(100) + expect(state.currentPhase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(300_000) + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('revives a parked retry that never armed a backoff timer', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + expect(state.parkRetryForExternalTrigger(firstEpoch, retry)).toBe(true) + + // Why: parking must not fire on its own, so the pane still latches at the cutoff. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 300_000) + expect(state.currentPhase).toBe('disconnected') + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('refuses to park over an armed backoff or a stale epoch', () => { vi.useFakeTimers() const state = new RemoteRuntimePtyRecoveryState() const epoch = state.begin() state.schedule(epoch, vi.fn()) - const retryNow = vi.spyOn(state, 'retryNow') - await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) - retryAllRemoteRuntimePtyRecoveriesNow() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) - expect(retryNow).not.toHaveBeenCalled() + state.cancel() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + state.dispose() }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts index df6814a4028..0b609ff876c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts @@ -11,6 +11,10 @@ export type RemoteRuntimePtyRecoveryPhase = // Why: system resume / network online need to advance pending pane backoffs without a second coordinator. const scheduledRecoveries = new Set() +export function getScheduledRemoteRuntimePtyRecoveryCountForTests(): number { + return scheduledRecoveries.size +} + export function retryAllRemoteRuntimePtyRecoveriesNow(): number { let advanced = 0 // Why: a synchronous retry failure can schedule the same state again. @@ -101,14 +105,43 @@ export class RemoteRuntimePtyRecoveryState { return true } + // Why: a wait that ends with no liveness evidence arms no timer, so park a retry or online/resume/reconnect find nothing to revive. + parkRetryForExternalTrigger(epoch: number, retry: (epoch: number) => void): boolean { + if (!this.isCurrent(epoch) || this.pendingRetry !== null) { + return false + } + this.pendingRetry = retry + this.pendingEpoch = epoch + scheduledRecoveries.add(this) + return true + } + + // Why: a one-shot retry whose owner already resolved elsewhere would otherwise survive the cutoff as fake revivable work. + discardPendingRetry(retry: (epoch: number) => void): void { + if (this.pendingRetry !== retry) { + return + } + this.clearRetryTimer() + } + // Why: resume/online should fire an already-scheduled backoff immediately, not start a new epoch. retryNow(): boolean { - if (this.phase !== 'backoff' || this.pendingRetry === null || this.pendingEpoch === null) { + if (this.pendingRetry === null || this.pendingEpoch === null) { + return false + } + if (this.phase !== 'backoff' && this.phase !== 'disconnected') { return false } const retry = this.pendingRetry - const epoch = this.pendingEpoch + const latched = this.phase === 'disconnected' this.clearRetryTimer() + if (latched) { + // Why: the deadline only stops auto-retry; an explicit trigger opens a fresh recovery window. + this.epoch += 1 + this.attempt = 0 + this.armDeadline(this.epoch) + } + const epoch = this.epoch this.phase = 'recovering' this.onChange?.() retry(epoch) @@ -159,7 +192,8 @@ export class RemoteRuntimePtyRecoveryState { return } this.deadlineTimer = null - this.clearRetryTimer() + // Why: the cutoff stops self-initiated retries but must keep the pane revivable by online/resume/reconnect. + this.stopRetryTimer() this.phase = 'disconnected' this.onChange?.() }, REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) @@ -172,11 +206,15 @@ export class RemoteRuntimePtyRecoveryState { this.clearDeadlineTimer() } - private clearRetryTimer(): void { + private stopRetryTimer(): void { if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer = null } + } + + private clearRetryTimer(): void { + this.stopRetryTimer() this.pendingRetry = null this.pendingEpoch = null scheduledRecoveries.delete(this) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index eedd195c71e..c9e0ea4352f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -187,6 +187,9 @@ export function createRemoteRuntimePtyTransport( let recoveryReplacementPolicy: HostHandleReplacementPolicy = 'reuse' let recoveryReplacementPolicyHandle: string | null = null let stopWaitingForPublishedHandle: (() => void) | null = null + let publishedHandleWaitEpoch: number | null = null + // Why: a spent auto-recovery window is the evidence that licenses reattaching the fenced handle; explicit retries must not erase it. + let autoRecoveryWindowSpent = false let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let resubscribeInventoryEpoch: number | null = null let resubscribeInventoryWindows = 0 @@ -236,12 +239,18 @@ export function createRemoteRuntimePtyTransport( } const recovery = new RemoteRuntimePtyRecoveryState(() => { - if (recovery.currentPhase === 'disconnected') { + if (recovery.currentPhase === 'disposed') { clearPublishedHandleWait() + } + if (recovery.currentPhase === 'disconnected') { + autoRecoveryWindowSpent = true // Why: cached pixels may remain, but no stream from the exhausted epoch may keep delivering or accepting terminal traffic. subscriptionGeneration += 1 closeMultiplexedStream() } + if (recovery.currentPhase === 'idle') { + autoRecoveryWindowSpent = false + } if ( recovery.currentPhase === 'disconnected' || recovery.currentPhase === 'disposed' || @@ -562,11 +571,16 @@ export function createRemoteRuntimePtyTransport( if (settleHostSessionAttachRetry === settle) { settleHostSessionAttachRetry = null } + // Why: this wait is single-shot, so replaying it after the cutoff would strand the pane in 'recovering' with no RPC in flight. + recovery.discardPendingRetry(scheduledRetry) resolve(retry) } + const scheduledRetry = (): void => { + settle(true) + } settleHostSessionAttachRetry?.(false) settleHostSessionAttachRetry = settle - if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + if (!recovery.schedule(recoveryEpoch, scheduledRetry)) { settle(false) } }) @@ -1257,6 +1271,7 @@ export function createRemoteRuntimePtyTransport( function clearPublishedHandleWait(): void { stopWaitingForPublishedHandle?.() stopWaitingForPublishedHandle = null + publishedHandleWaitEpoch = null } function isCurrentRemoteTerminal(targetHandle: string, targetPtyId: string | null): boolean { @@ -1329,9 +1344,30 @@ export function createRemoteRuntimePtyTransport( retireRemoteTerminalId() return } - if (!update.terminalHandle || update.terminalHandle === previousHandle) { + if (!update.terminalHandle) { return } + if (update.terminalHandle === previousHandle) { + // Why: once the auto-recovery window is spent, a host still publishing this surface is evidence the fenced handle outlived the stale error. + if (!autoRecoveryWindowSpent || getCurrentMultiplexedStream(previousHandle)) { + return + } + // Why: one reattach per spent window, so a handle that really is dead is not retried on every host snapshot. + autoRecoveryWindowSpent = false + const reattachEpoch = recovery.begin() + clearPublishedHandleWait() + const reusedPtyId = remotePtyId + void subscribeToHandle(reattachEpoch, true).catch((error) => { + if (!recoverAfterSubscribeFailure(error, previousHandle, reusedPtyId)) { + handleRemoteTerminalError(error) + } + }) + return + } + if (recovery.currentPhase === 'disconnected') { + // Why: without a live epoch a failed resubscribe is swallowed as already-latched, leaving a pane with no handle and no way back. + recovery.begin() + } rebindRemoteTerminalHandle(update.terminalHandle) const reboundHandle = handle const reboundPtyId = remotePtyId @@ -1440,6 +1476,13 @@ export function createRemoteRuntimePtyTransport( code: 'remote_runtime_unavailable' }) } + // Why: liveness is unknown, so auto-retry stops here; keep an unarmed retry parked for online/resume/reconnect to fire. + recovery.parkRetryForExternalTrigger(recoveryEpoch, (nextEpoch) => { + scheduleResubscribeAfterTransportClose( + handle ? getRecoveryReplacementPolicy(handle) : 'reuse', + nextEpoch + ) + }) return } if (!nextHandle) { @@ -1507,8 +1550,12 @@ export function createRemoteRuntimePtyTransport( clearPendingViewportClaim() } strengthenRecoveryReplacementPolicy(handle, replacementPolicy) - if (replacementPolicy === 'require-replacement' && stopWaitingForPublishedHandle) { - // Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs. + if ( + replacementPolicy === 'require-replacement' && + stopWaitingForPublishedHandle && + // Why: only the epoch that handed recovery to accepted snapshots is blocked; a newer epoch is a fresh attempt, not a repeated stale send. + publishedHandleWaitEpoch === recoveryEpoch + ) { return } if (resubscribeEpoch === recoveryEpoch) { @@ -1529,6 +1576,7 @@ export function createRemoteRuntimePtyTransport( if (tabId && isWebTerminalSurfaceTabId(tabId)) { // Why: subscribe before polling so a fresh host snapshot can't land in the gap between the inventory loop and its event-driven fallback. waitForPublishedHostSessionHandle(toHostSessionTabId(tabId), resubscribeHandle) + publishedHandleWaitEpoch = recoveryEpoch } resubscribeEpoch = recoveryEpoch resubscribeRequestedHandle = null diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index 7d28a7d43f6..823ce3dd867 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -151,7 +151,7 @@ describe('canParkTerminalWorktreeRenderers', () => { ).toBe(false) }) - it('keeps a previously mounted v19 terminal eligible for ordinary parking', async () => { + it('defers preserved-daemon snapshot authority to the watcher coverage gate', async () => { const legacyPtyId = 'repo::/worktree@@session-1' clearTerminalProviderSnapshotCapabilities() await synchronizeTerminalProviderSnapshotCapabilities([legacyPtyId], async (ids) => diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index 11a3bb51656..f288521ae8a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, hasPendingRetentionSpawnWork, @@ -44,18 +48,36 @@ describe('hasPendingRetentionSpawnWork', () => { describe('isEvictionExemptTerminalPty', () => { const worktreeId = 'repo::/worktree' + const currentPtyId = `${worktreeId}@@session-1` + + beforeEach(async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([currentPtyId], async () => [ + { id: currentPtyId, authoritative: true } + ]) + }) + afterEach(() => clearTerminalProviderSnapshotCapabilities()) it('exempts only live local ptys a remount could not reattach', () => { expect(isEvictionExemptTerminalPty('pty-local-detached', worktreeId)).toBe(true) expect(isEvictionExemptTerminalPty('other::wt@@session-1', worktreeId)).toBe(true) }) - it('never exempts snapshot-backed, SSH, remote-runtime, or unbound ptys', () => { - expect(isEvictionExemptTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(false) + it('never exempts authoritative, SSH, remote-runtime, or unbound ptys', () => { + expect(isEvictionExemptTerminalPty(currentPtyId, worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty('ssh:conn-1@@pty-1', worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty('remote:env-1@@t-1', worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty(null, worktreeId)).toBe(false) }) + + it('exempts a preserved daemon without an authoritative snapshot', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([currentPtyId], async () => [ + { id: currentPtyId, authoritative: false } + ]) + + expect(isEvictionExemptTerminalPty(currentPtyId, worktreeId)).toBe(true) + }) }) describe('selectRetentionForceParkedTerminalWorktrees', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 9cfc4e3742f..161d3376043 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -1,5 +1,6 @@ import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, isSnapshotBackedTerminalPty, @@ -41,10 +42,9 @@ export function hasPendingRetentionSpawnWork( return Boolean(tab.pendingActivationSpawn && (!tab.ptyId || !isRemoteRuntimePtyId(tab.ptyId))) } -// Why: an eviction-exempt pty is a live local one a remount could not reattach -// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a -// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when -// the worktree force-parks (per-tab exclusion, mirroring Activity portals). +// Why: an eviction-exempt pty is a live local one a remount cannot restore +// faithfully (daemon-fail-open/foreign ids or a preserved legacy daemon). Its +// TAB keeps its mounted pane when the worktree force-parks. // Per-PTY, not per-tab: the coverage veto that makes a worktree a retention // candidate walks every split pane, so the exemption must too (see // isEvictionExemptTerminalTab). @@ -55,7 +55,10 @@ export function isEvictionExemptTerminalPty( if (!ptyId || isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { return false } - return !isSnapshotBackedTerminalPty(ptyId, worktreeId) + return ( + !isSnapshotBackedTerminalPty(ptyId, worktreeId) || + !terminalProviderHasAuthoritativeSnapshot(ptyId) + ) } export type TerminalWorktreeRetentionCandidate = { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts new file mode 100644 index 00000000000..7263caf780a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { fallbackParkedPaneCandidates } from './terminal-parked-tab-watchers' + +const TAB_ID = 'tab-1' +const PTY_ID = 'repo::/worktree@@session-1' +const SECOND_PTY_ID = 'repo::/worktree@@session-2' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + // Why: a tab that never mounted a pane persists a rootless layout. Walking + // only `root` yielded zero candidates, so watcher coverage refused it and a + // manual park could never succeed for a workspace the user had not visited. + it('resolves the single leaf of a rootless layout', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('returns nothing for a rootless layout with no resolvable leaf', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 21c292751ce..2e9b3a52ef2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -101,12 +101,15 @@ import { isEvictionExemptTerminalTab, selectEvictionExemptTerminalTabIds } from './terminal-eviction-exempt-tabs' +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' import { canWatcherCoverParkedTerminalTab, captureParkedTerminalPaneCandidates, disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, - fallbackParkedPaneCandidates, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, @@ -140,7 +143,7 @@ function syncParked(args?: { } describe('terminal-parked-tab-watchers', () => { - beforeEach(() => { + beforeEach(async () => { mockStoreState = { tabsByWorktree: {}, terminalLayoutsByTabId: {}, @@ -154,6 +157,10 @@ describe('terminal-parked-tab-watchers', () => { updateTabTitle: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID, SECOND_PTY_ID], async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) }) afterEach(() => { @@ -163,6 +170,7 @@ describe('terminal-parked-tab-watchers', () => { startedWatchers.length = 0 exitSubscriptions.length = 0 vi.clearAllMocks() + clearTerminalProviderSnapshotCapabilities() ;(globalThis as { window?: unknown }).window = originalWindow }) @@ -721,7 +729,7 @@ describe('terminal-parked-tab-watchers', () => { ) }) - it('lets cold activation add stricter eligibility without changing ordinary parking', () => { + it('lets cold activation add stricter eligibility for a provider-capable local PTY', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) const providerCanSnapshotWithoutRenderer = vi.fn(() => false) @@ -738,6 +746,18 @@ describe('terminal-parked-tab-watchers', () => { expect(providerCanSnapshotWithoutRenderer).toHaveBeenCalledWith(PTY_ID) }) + it('rejects ordinary parking for a preserved daemon with a lossy snapshot', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [ + { id: PTY_ID, authoritative: false } + ]) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + it('rejects a capture containing a legacy non-UUID leaf id', () => { capturePanes([ { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, @@ -860,6 +880,18 @@ describe('terminal-parked-tab-watchers', () => { expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(false) }) + it('exempts a preserved daemon whose snapshot is not authoritative', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [ + { id: PTY_ID, authoritative: false } + ]) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + + const tab = { id: TAB_ID, ptyId: PTY_ID } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, tab)).toBe(false) + expect(isEvictionExemptTerminalTab(tab, WORKTREE_ID)).toBe(true) + }) + it('exempts on tab.ptyId alone when no panes resolve', () => { expect( isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: 'pty-local-detached' }, WORKTREE_ID) @@ -886,78 +918,3 @@ describe('terminal-parked-tab-watchers', () => { }) }) }) - -describe('fallbackParkedPaneCandidates', () => { - it('returns nothing without a layout snapshot', () => { - expect( - fallbackParkedPaneCandidates( - { id: TAB_ID, ptyId: PTY_ID }, - { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } - ) - ).toEqual([]) - }) - - it('reuses the single runtime-title slot for a single-pane tab', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } - }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } - } as never) - ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) - }) - - // Why: a tab that never mounted a pane persists a rootless layout. Walking - // only `root` yielded zero candidates, so watcher coverage refused it and a - // manual park could never succeed for a workspace the user had not visited. - it('resolves the single leaf of a rootless layout', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { - root: null, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } - } - }, - runtimePaneTitlesByTabId: {} - } as never) - ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) - }) - - it('returns nothing for a rootless layout with no resolvable leaf', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } - }, - runtimePaneTitlesByTabId: {} - } as never) - ).toEqual([]) - }) - - it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { - root: { - type: 'split', - direction: 'row', - first: { type: 'leaf', leafId: LEAF_ID }, - second: { type: 'leaf', leafId: SECOND_LEAF_ID } - }, - activeLeafId: SECOND_LEAF_ID, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } - } - }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } - } as never) - ).toEqual([ - { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, - { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } - ]) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 59d1b90db3e..42de06cac37 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -7,7 +7,10 @@ */ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' import { isParkRestorableTerminalPty, selectPairedRuntimeParkingEnvironmentIds, @@ -46,7 +49,10 @@ export { export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean -const allowSnapshotBackedPty = (): boolean => true +const allowOrdinaryParkRestore = (ptyId: string): boolean => + isRemoteRuntimePtyId(ptyId) || + parseAppSshPtyId(ptyId) !== null || + terminalProviderHasAuthoritativeSnapshot(ptyId) // Why: fact-mode watchers work for any pty whose bytes transit local main — // SSH included — so watcher coverage follows the park-restore policy, not the @@ -74,8 +80,9 @@ function parkRestorePolicyFromState(state: { export function canWatcherCoverParkedTerminalTab( worktreeId: string, tab: ParkableTerminalTabModel, - // Why: cold activation needs stronger snapshot support (view never mounted); ordinary parking can reattach a mounted view. - isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty + // Why: paired and SSH restore through their own policy; local parking must not + // unmount a pane whose preserved daemon can only return a lossy snapshot. + isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore ): boolean { const state = useAppStore.getState() const panes = resolveParkedTerminalPaneCandidates(tab, state) diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 9e927552049..3072b98dad2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -7,7 +7,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 113fd52a527..bc230ecd419 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -65,10 +65,10 @@ import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, normalizeTerminalLayoutSnapshot, - RESET_KITTY_KEYBOARD_PROTOCOL, replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization' +import { RESET_KITTY_KEYBOARD_PROTOCOL } from '../../../../shared/terminal-mode-reset-profiles' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' @@ -1664,7 +1664,8 @@ export function useTerminalPaneLifecycle({ manager: mgr, getPtyIdForLeaf: (leafId) => useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId], - closeTab: () => closeTerminalTab(tabId), + // Why: CLI-driven pane close; its caller is answered immediately and cannot wait on a modal. + closeTab: () => closeTerminalTab(tabId, { skipRunningProcessConfirm: true }), closeTabPreservingPty: () => { const store = useAppStore.getState() if (detail.retireSurface && detail.leafId) { diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts new file mode 100644 index 00000000000..3dc808e6abc --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock, inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({ + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose, + RUNNING_CLOSE_PROBE_TIMEOUT_MS +} from './running-terminal-close-guard' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab-1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {}, + ...overrides + }) +} + +function guard(onClose = vi.fn(), onCancel?: () => void): void { + guardRunningTerminalClose({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + onClose, + ...(onCancel ? { onCancel } : {}) + }) +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('shouldConfirmRunningTerminalClose', () => { + it('confirms a plain interactive close', () => { + expect(shouldConfirmRunningTerminalClose(undefined)).toBe(true) + expect(shouldConfirmRunningTerminalClose({})).toBe(true) + expect(shouldConfirmRunningTerminalClose({ reason: 'user' })).toBe(true) + expect(shouldConfirmRunningTerminalClose({ hostCloseReason: 'user' })).toBe(true) + }) + + it.each([ + ['post-confirmation re-entry', { force: true }], + ['non-interactive CLI reject', { rejectPinned: true }], + ['bulk/CLI opt-out', { skipRunningProcessConfirm: true }], + ['pty lifecycle echo', { reason: 'pty-exit' as const }], + ['cleanup teardown', { reason: 'cleanup' as const }], + ['host-only pty lifecycle echo', { hostCloseReason: 'pty-exit' as const }], + ['pty-scoped lifecycle close', { lifecyclePtyId: 'pty-a' }] + ])('never prompts for a %s', (_label, options) => { + expect(shouldConfirmRunningTerminalClose(options)).toBe(false) + }) +}) + +describe('guardRunningTerminalClose', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes synchronously and never probes when the tab has no live pty', () => { + setState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + // Why: a mounting pane is bound into the layout before ptyIdsByTabId catches up. Reading + // only the liveness map would let a close slip through that window with no prompt. + it('prompts for a pane the layout has bound but the liveness map has not', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a') + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1' }) + }) + + it('probes each pty once when the map and the layout name the same one', async () => { + guard() + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(1) + }) + + it('closes without probing when the user turned the prompt off', () => { + setState({ + settings: { + activeRuntimeEnvironmentId: null, + skipCloseTerminalWithRunningProcessConfirm: true + } + }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(visibleRequest()).toBeNull() + }) + + it('closes an idle terminal without a prompt', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('defers a busy terminal behind a confirmation that carries the tab label', async () => { + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('runs onCancel and keeps the tab when the confirmation is dismissed', async () => { + const onClose = vi.fn() + const onCancel = vi.fn() + + guard(onClose, onCancel) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + }) + + it('fails open and closes when the probe rejects (wedged relay / legacy provider)', async () => { + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('rpc_timeout')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('fails open when a remote handle reports the inspection as unavailable', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('prompts once for a split tab where only the second pane is busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-b' ? 'claude' : 'zsh', + hasChildProcesses: ptyId === 'pty-b' + })) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(2) + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', copyKind: 'agent' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not use an idle sibling pane to pick the agent copy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-a' ? 'npm' : 'zsh', + hasChildProcesses: ptyId === 'pty-a' + })) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('prefers the agent copy regardless of pty spawn order when both panes are busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'codex' } } + }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('ignores a pane whose agent status is unknown', async () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: { agentType: 'unknown' } } }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('survives legacy layout leaf ids that are not stable pane uuids', async () => { + setState({ terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { leaf: 'pty-a' } } } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + // Why: makePaneKey throws on a tab id containing ':'; the dialog must never be the + // reason a close silently stops happening. + it('closes rather than wedging when the copy-kind lookup throws', async () => { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab:1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {} + }) + const onClose = vi.fn() + + guardRunningTerminalClose({ terminalTabId: 'tab:1', tabLabel: 'weird', onClose }) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab:1', copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes rather than wedging when raising the confirmation throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + // Why: a remote inspect can take its full 15s RPC timeout. Closing at 4s would kill a + // running remote command with no prompt — the exact failure this guard exists to stop. + it('prompts instead of closing when a wedged remote probe never settles', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', tabLabel: 'npm run dev' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('treats every pane as a candidate when picking the copy for a timed-out probe', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + + guard() + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('closes rather than wedging when the timed-out prompt throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('ignores a slow probe that resolves after the timeout already prompted', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + // Why: an SSH drop zeroes ptyIdsByTabId while the layout still names the pane. The stale + // binding is probed, that probe fails on the dead link, and the close falls open — so a + // reconnecting tab stays closable instead of being blocked behind a prompt for a pty + // nobody can reach. Documented so the behavior is a decision, not an accident. + it('closes a reconnecting ssh tab whose pty ids were already zeroed', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('ssh_disconnected')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.ts new file mode 100644 index 00000000000..73f63fc85f7 --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.ts @@ -0,0 +1,154 @@ +import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' +import type { AppState } from '@/store/types' +import { resolveBusyPtyCloseCopyKind } from './terminal-close-copy-kind' + +export type RunningTerminalCloseGuardOptions = { + force?: boolean + rejectPinned?: boolean + reason?: TerminalTabCloseReason + hostCloseReason?: TerminalTabCloseReason + lifecyclePtyId?: string + skipRunningProcessConfirm?: boolean +} + +/** Upper bound on how long a close may wait on the probe before it asks instead. A remote + * inspect RPC can hang for its full 15s timeout, and an X button that looks dead for 15s + * is the same class of bug as one that never asks — but an unanswered probe is not + * evidence of an idle shell, so the timeout raises the prompt rather than killing a + * possibly-running remote command (#10142). */ +export const RUNNING_CLOSE_PROBE_TIMEOUT_MS = 4_000 + +/** Whether this close is an interactive user action that should stop and ask before + * killing a live child process. Lifecycle echoes, bulk closes, CLI/RPC closes and the + * post-confirmation re-entry are all excluded. */ +export function shouldConfirmRunningTerminalClose( + options?: RunningTerminalCloseGuardOptions +): boolean { + if (options?.force === true || options?.rejectPinned === true) { + return false + } + if (options?.skipRunningProcessConfirm === true || options?.lifecyclePtyId !== undefined) { + return false + } + const isUserReason = (reason: TerminalTabCloseReason | undefined): boolean => + reason === undefined || reason === 'user' + return isUserReason(options?.reason) && isUserReason(options?.hostCloseReason) +} + +/** Every PTY the tab could still own. `ptyIdsByTabId` is the liveness map the rest of the + * app reads, but a mounting pane is bound into the layout before the map catches up, and + * the store's own teardown collector unions both for exactly that reason — reading only + * the map would let a close slip through the window with no prompt. A stale id costs + * nothing: its probe fails and the guard falls open. */ +function collectTabPtyIds( + state: Pick, + terminalTabId: string +): string[] { + const ptyIds = new Set() + for (const ptyId of state.ptyIdsByTabId?.[terminalTabId] ?? []) { + if (ptyId) { + ptyIds.add(ptyId) + } + } + const ptyIdsByLeafId = state.terminalLayoutsByTabId?.[terminalTabId]?.ptyIdsByLeafId ?? {} + for (const ptyId of Object.values(ptyIdsByLeafId)) { + if (typeof ptyId === 'string' && ptyId) { + ptyIds.add(ptyId) + } + } + return [...ptyIds] +} + +/** + * Routes an interactive terminal-tab close through the running-process confirmation. + * Closes immediately when nothing is running, so idle tabs keep today's behavior. + */ +export function guardRunningTerminalClose(params: { + terminalTabId: string + tabLabel: string + onClose: () => void + onCancel?: () => void +}): void { + const { terminalTabId, tabLabel, onClose, onCancel } = params + const state = useAppStore.getState() + const settings = state.settings + const ptyIds = collectTabPtyIds(state, terminalTabId) + // Why: no PTY at all means there is nothing to probe (parked/hibernated tab, or a + // teardown that already cleared both maps), and the opt-out setting means the answer is + // already known. Both keep the close fully synchronous. + if (ptyIds.length === 0 || settings?.skipCloseTerminalWithRunningProcessConfirm === true) { + onClose() + return + } + + // Why: the timeout, the probe result and the error path race to decide this close, so the + // first one to land owns it instead of trusting those races to stay mutually exclusive. + let decided = false + const closeNow = (): void => { + if (decided) { + return + } + decided = true + onClose() + } + const confirmClose = (busyPtyIds: readonly string[]): void => { + if (decided) { + return + } + const copyKind = resolveBusyPtyCloseCopyKind(terminalTabId, busyPtyIds) + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId, + tabLabel, + copyKind, + onConfirm: onClose, + ...(onCancel ? { onCancel } : {}) + }) + // Why: only once the prompt is actually up — if either call above throws, the close must + // still be free to fall through and happen. + decided = true + } + + const probeTimeout = setTimeout(() => { + try { + // Why: a probe that has not answered yet is unknown, not idle. Ask, treating every pty + // as a candidate, so a degraded relay costs a click instead of a killed remote command. + confirmClose(ptyIds) + } catch { + closeNow() + } + }, RUNNING_CLOSE_PROBE_TIMEOUT_MS) + + void Promise.allSettled(ptyIds.map((ptyId) => inspectRuntimeTerminalProcess(settings, ptyId))) + .then((results) => { + clearTimeout(probeTimeout) + if (decided) { + return + } + // Why: fail open on an *answered* probe, matching the Cmd+W pane path — a rejection + // (wedged relay, legacy provider) or a stale remote handle is not evidence of a live + // child, and a close button that silently does nothing is worse than closing a busy tab. + const busyPtyIds = ptyIds.filter((_, index) => { + const result = results[index] + return ( + result?.status === 'fulfilled' && + result.value.hasChildProcesses && + result.value.unavailable !== true + ) + }) + if (busyPtyIds.length === 0) { + closeNow() + return + } + confirmClose(busyPtyIds) + }) + // Why: allSettled never rejects, so this only fires when the decision above throws (a + // copy-kind lookup, a store subscriber). Without it the tab would silently never close + // and the user would get no feedback at all; the pane path it replaced had this catch. + .catch(() => { + clearTimeout(probeTimeout) + closeNow() + }) +} diff --git a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts new file mode 100644 index 00000000000..3ad4229e233 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts @@ -0,0 +1,131 @@ +/** + * Regression for #10142: the tab X button / middle-click used to bypass the + * running-process close confirmation that Cmd+W enforces. Assertions unchanged + * from the adjudicated repro. + * + * Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose + * -> Terminal.tsx handleCloseTab -> closeTerminalTab() -> running-process guard. + * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess + * (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + requestPinnedTabCloseConfirmMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + requestPinnedTabCloseConfirmMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +// A non-pinned terminal tab whose PTY has a live child process (e.g. `sleep 300`). +function stateWithBusyTerminalTab(closeTab: () => void): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { id: 'tab-busy', entityId: 'tab-busy', contentType: 'terminal', isPinned: false }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { leaf: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn() + } +} + +describe('#10142 close confirmation policy is the same for keyboard and mouse', () => { + const closeTab = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(stateWithBusyTerminalTab(closeTab)) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + // Control: the keyboard entry point does probe for running children. + it('keyboard Cmd+W path probes for running child processes before closing', () => { + const source = readFileSync(join(__dirname, '../terminal-pane/TerminalPane.tsx'), 'utf8') + const handler = source.slice(source.indexOf('const handleRequestClosePane')) + expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain( + 'inspectRuntimeTerminalProcess' + ) + }) + + // Control: the harness does observe a guard when one exists — pinning blocks the same mouse close. + it('mouse close routes a pinned tab through its confirmation guard', () => { + const state = stateWithBusyTerminalTab(closeTab) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) consults the running-process probe', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) does not drop a busy tab without confirmation', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts new file mode 100644 index 00000000000..960eaaa8c9c --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts @@ -0,0 +1,67 @@ +/** + * The keyboard (pane-scoped) and tab-strip (pty-scoped) close prompts must word the same + * close the same way, so both resolve their copy through this module (#10142). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock } = vi.hoisted(() => ({ getStateMock: vi.fn() })) + +vi.mock('@/store', () => ({ useAppStore: { getState: getStateMock } })) + +import { resolveBusyPtyCloseCopyKind, resolveLeafCloseCopyKind } from './terminal-close-copy-kind' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } }, + ...overrides + }) +} + +describe('terminal close copy kind', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + }) + + it('agrees between the pane lookup and the busy-pty lookup for the same pane', () => { + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('agent') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('agent') + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_A)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a'])).toBe('command') + }) + + it('prefers the agent copy when a split has both busy', () => { + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a', 'pty-b'])).toBe('agent') + }) + + it.each([ + ['a missing leaf id', undefined], + ['a null leaf id', null], + ['a legacy non-uuid leaf id', 'leaf'] + ])('falls back to the command copy for %s', (_label, leafId) => { + expect(resolveLeafCloseCopyKind('tab-1', leafId)).toBe('command') + }) + + it('never throws on a tab id that makePaneKey would reject', () => { + setState({ + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } } + }) + + expect(resolveLeafCloseCopyKind('tab:1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab:1', ['pty-b'])).toBe('command') + }) + + it('ignores an unknown agent type', () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'unknown' } } }) + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('command') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts new file mode 100644 index 00000000000..e012987ba4d --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import type { CloseTerminalDialogCopyKind } from '../terminal-pane/CloseTerminalDialog' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' + +/** + * Single source of truth for "is this pane an agent?" in the close confirmation, so the + * keyboard (pane-scoped) and tab-strip (pty-scoped) prompts cannot word the same close + * differently. Each caller resolves its own leaf id; only the policy is shared. + */ +export function resolveLeafCloseCopyKind( + tabId: string, + leafId: string | null | undefined +): CloseTerminalDialogCopyKind { + // Why: legacy layouts and mid-attach panes carry non-UUID or missing leaf ids, and + // makePaneKey throws on those — a dialog must never be the thing that breaks a close. + if (!leafId || !isTerminalLeafId(leafId) || !tabId || tabId.includes(':')) { + return 'command' + } + const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey ?? {} + const agentType = agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType + return agentType && agentType !== 'unknown' ? 'agent' : 'command' +} + +/** Copy for a whole-tab close, given the PTYs that reported a live child. Agent panes win + * in a mixed split: stopping an agent mid-task is the costlier surprise. */ +export function resolveBusyPtyCloseCopyKind( + tabId: string, + busyPtyIds: readonly string[] +): CloseTerminalDialogCopyKind { + const ptyIdsByLeafId = + useAppStore.getState().terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {} + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + if (busyPtyIds.includes(ptyId) && resolveLeafCloseCopyKind(tabId, leafId) === 'agent') { + return 'agent' + } + } + return 'command' +} diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts index 94cf42f1299..b1c727f9e4e 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearTerminalProviderSnapshotCapabilities, collectTerminalProviderSnapshotPtyIds, + refreshTerminalProviderSnapshotCapabilities, synchronizeTerminalProviderSnapshotCapabilities, terminalProviderHasAuthoritativeSnapshot } from './terminal-provider-snapshot-capability' @@ -44,6 +45,18 @@ describe('terminal provider snapshot capabilities', () => { expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false) }) + it('refreshes a pre-provider false after startup services become ready', async () => { + await synchronizeTerminalProviderSnapshotCapabilities(['restored-pty'], async () => [ + { id: 'restored-pty', authoritative: false } + ]) + const readyResolve = vi.fn(async () => [{ id: 'restored-pty', authoritative: true }]) + + await refreshTerminalProviderSnapshotCapabilities(['restored-pty'], readyResolve) + + expect(readyResolve).toHaveBeenCalledWith(['restored-pty']) + expect(terminalProviderHasAuthoritativeSnapshot('restored-pty')).toBe(true) + }) + it('caches resolved PTYs and prunes closed ones', async () => { const resolve = vi.fn(async (ids: string[]) => ids.map((id) => ({ id, authoritative: true as boolean | null })) diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts index b3fee0d3b96..3c4e0b7b61b 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts @@ -178,6 +178,20 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( return unknownCapabilityRetryDelayMs(observedAtMs === undefined ? Date.now() : nowMs) } +export async function refreshTerminalProviderSnapshotCapabilities( + livePtyIds: readonly string[], + resolveCapabilities?: SnapshotCapabilityResolver +): Promise { + lastSynchronizedLivePtyIds = null + for (const id of livePtyIds) { + authoritativeSnapshotByPtyId.delete(id) + unknownCapabilityRetryAtByPtyId.delete(id) + unknownCapabilityAttemptsByPtyId.delete(id) + } + refreshEarliestUnknownCapabilityRetry() + return synchronizeTerminalProviderSnapshotCapabilities(livePtyIds, resolveCapabilities) +} + export function startTerminalProviderSnapshotCapabilitySynchronization( livePtyIds: readonly string[] ): () => void { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 3dd2762f7f3..55934e7ae88 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -9,11 +9,20 @@ import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel, + shouldConfirmPinnedTabClose +} from '@/store/pinned-tab-close-guard' import type { TerminalTabCloseReason, TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose +} from './running-terminal-close-guard' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' import { getTerminalIncarnationHandle } from './terminal-close-incarnation' import { @@ -25,20 +34,6 @@ import { export type { PrecomputedTerminalCloseState } from './terminal-close-target' export { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' -type TerminalTabActionState = ReturnType - -function isPinnedVisibleTab( - state: TerminalTabActionState, - worktreeId: string, - visibleId: string -): boolean { - return ( - (state.unifiedTabsByWorktree?.[worktreeId] ?? []).some( - (tab) => (tab.id === visibleId || tab.entityId === visibleId) && tab.isPinned - ) ?? false - ) -} - export function closeTerminalTab( tabId: string, options?: { @@ -51,6 +46,9 @@ export function closeTerminalTab( hostCloseReason?: TerminalTabCloseReason /** PTY whose lifecycle event initiated the host close. */ lifecyclePtyId?: string + /** Set by callers that must never raise a modal (bulk closes, CLI/RPC, lifecycle + * fallbacks) and by the re-entry that runs once the user confirmed. */ + skipRunningProcessConfirm?: boolean captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -82,7 +80,7 @@ export function closeTerminalTab( if ( options?.reason !== 'pty-exit' && !options?.force && - isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) + isUnifiedTabPinned(state, owningWorktreeId, terminalTabId) ) { // Why: background lifecycle callers cannot safely wait on a modal whose // owner may be unattended; reject pinned tabs without bypassing the guard. @@ -90,10 +88,30 @@ export function closeTerminalTab( options.onCancel?.() return } - guardPinnedTabClose({ - isPinned: true, + // Why: the pin prompt supersedes the running-process one only when it actually + // appears. With `confirmClosePinnedTab` off it says nothing, so fall through and let + // a busy pinned tab still get asked — Cmd+W did exactly that before #10142. + if (shouldConfirmPinnedTabClose(state)) { + guardPinnedTabClose({ + isPinned: true, + tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), + onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + ...(options?.onCancel ? { onCancel: options.onCancel } : {}) + }) + return + } + } + + // Why: the X button, middle-click and the tab menu used to skip the running-process + // prompt that Cmd+W enforced (#10142). Guarding here — above the web-runtime branch so + // host-backed tabs are covered too — gives every close path one shared policy. + if (shouldConfirmRunningTerminalClose(options)) { + guardRunningTerminalClose({ + terminalTabId, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), - onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + // Why: re-enter instead of continuing inline so pinned/route/precomputed state is + // re-validated against fresh state after an arbitrarily long dialog. + onClose: () => closeTerminalTab(tabId, { ...options, skipRunningProcessConfirm: true }), ...(options?.onCancel ? { onCancel: options.onCancel } : {}) }) return diff --git a/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts new file mode 100644 index 00000000000..066c5795ef9 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts @@ -0,0 +1,263 @@ +/** + * Which `closeTerminalTab` callers reach the running-process confirmation (#10142). + * Kept out of terminal-tab-actions.test.ts, which is already at its max-lines budget. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + closeWebRuntimeSessionTabMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + requestPinnedTabCloseConfirmMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + closeWebRuntimeSessionTabMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + requestPinnedTabCloseConfirmMock: vi.fn(), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: closeWebRuntimeSessionTabMock, + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { closeTerminalTab } from './terminal-tab-actions' + +const LEAF = '33333333-3333-4333-8333-333333333333' + +const closeTab = vi.fn() + +function busyTabState(overrides: Record = {}): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'tab-busy', + entityId: 'tab-busy', + contentType: 'terminal', + isPinned: false, + label: 'dev server' + }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { [LEAF]: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn(), + ...overrides + } +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('closeTerminalTab running-process confirmation', () => { + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(busyTabState()) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'npm', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes a busy tab only after the confirmation is accepted', async () => { + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-busy', + tabLabel: 'dev server', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('keeps the tab and runs onCancel when the confirmation is dismissed', async () => { + const onCancel = vi.fn() + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onCancel, onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClosed).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('still reports the close through onClosed after the confirmation', async () => { + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(onClosed).toHaveBeenCalledTimes(1) + }) + + it('stays fully synchronous for a tab with no live pty', () => { + getStateMock.mockReturnValue(busyTabState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} })) + + closeTerminalTab('tab-busy') + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it.each([ + ['force (post-confirmation re-entry)', { force: true }], + ['rejectPinned (CLI tab close request)', { rejectPinned: true }], + ['skipRunningProcessConfirm (bulk / CLI)', { skipRunningProcessConfirm: true }], + ['reason pty-exit', { reason: 'pty-exit' as const }], + ['reason cleanup', { reason: 'cleanup' as const }], + ['hostCloseReason pty-exit', { hostCloseReason: 'pty-exit' as const }], + ['lifecyclePtyId', { lifecyclePtyId: 'pty-busy' }] + ])('never probes for a close carrying %s', (_label, options) => { + closeTerminalTab('tab-busy', options) + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + it('shows only the pinned dialog for a pinned busy tab, and its confirm does not probe', () => { + const state = busyTabState() + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + + const onConfirm = requestPinnedTabCloseConfirmMock.mock.calls[0]![0].onConfirm as () => void + onConfirm() + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + // Why: the pin prompt supersedes this one only when it actually appears. With the pin + // confirmation off it says nothing, and the running command must still be announced. + it('still asks about a running command on a pinned tab when pin confirmation is off', async () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(requestPinnedTabCloseConfirmMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-busy' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('rejects a pinned tab for a CLI close even when pin confirmation is off', () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + const onCancel = vi.fn() + + closeTerminalTab('tab-busy', { rejectPinned: true, onCancel }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('confirms before telling a paired host to close its busy tab', async () => { + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue( + busyTabState({ settings: { activeRuntimeEnvironmentId: 'web-runtime' } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(1) + }) + + it('picks the agent copy for a busy agent pane', async () => { + getStateMock.mockReturnValue( + busyTabState({ agentStatusByPaneKey: { [`tab-busy:${LEAF}`]: { agentType: 'claude' } } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) +}) diff --git a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts new file mode 100644 index 00000000000..05ac5130e36 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isSleepingSweepExemptWorkspace } from './sidebar/visible-worktrees' +import type { Worktree } from '../../../shared/types' + +const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8') + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-main', + repoId: 'repo1', + path: '/tmp/repo1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('Cmd+J empty-query "Hide sleeping" pass (#8873)', () => { + // Why source-level: the palette re-implements the sidebar's filter pass + // inline, so the only structural guarantee that the two agree is that both + // call the shared predicate. A behavioral copy here would not catch a + // hand-rolled duplicate creeping back in. + it('routes the sleeping sweep through the shared exemption predicate', () => { + const start = source.indexOf('const emptyQueryVisibleWorktrees = useMemo(') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('const { visibleWorktreesForState', start) + const filterPass = source.slice(start, end) + + expect(filterPass).toContain( + '!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace)' + ) + expect(filterPass).toContain('alwaysShowDefaultBranchWorkspace,') + }) + + it('reads the flag from the same store field the sidebar uses', () => { + expect(source).toContain( + 'const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)' + ) + }) + + it('exempts a project entry point by default and honours an explicit opt-out', () => { + const main = makeWorktree() + + expect(isSleepingSweepExemptWorkspace(main, undefined)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, true)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, false)).toBe(false) + }) + + it('never exempts a non-main workspace', () => { + const feature = makeWorktree({ id: 'wt-feature', isMainWorktree: false }) + + expect(isSleepingSweepExemptWorkspace(feature, true)).toBe(false) + expect(isSleepingSweepExemptWorkspace(feature, undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 81f9fbe23e9..7d5c09c8f22 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -127,6 +127,7 @@ import { } from '@/lib/project-host-setup-options' import { buildNewWorkspaceCreateTargetOptions, + findActionableFolderProjectGroup, getProjectGroupIdFromNewWorkspaceOptionId, type NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' @@ -613,6 +614,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos]) + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + hostSource: 'configured-only', + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const actionableHostIds = useMemo( + () => new Set(hostOptions.map((host) => host.id)), + [hostOptions] + ) // Why: a runtime-owned SSH repo (active right after creating a per-workspace-env) is ineligible; seed from its local same-project sibling, not another project. const seedActiveRepoId = useMemo( () => resolveComposerActiveRepoId(repos, eligibleRepos, activeRepoId), @@ -650,14 +676,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projectId: initialRunSeed.projectId, hostId: initialRunSeed.hostId, projectHostSetupId: initialRunSeed.projectHostSetupId, - focusedHostScope: workspaceHostScope + focusedHostScope: workspaceHostScope, + actionableHostIds }) const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId) const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId - const initialFolderProjectGroup = projectGroups.find( - (group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim()) - ) + const initialFolderProjectGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: initialFolderProjectGroupId, + actionableHostIds + }) const [selectedProjectGroupId, setSelectedProjectGroupId] = useState( initialFolderProjectGroup?.id ?? null ) @@ -666,12 +695,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const repoId = repoIdOverride ?? internalRepoId const selectedProjectGroup = useMemo( () => - selectedProjectGroupId - ? (projectGroups.find( - (group) => group.id === selectedProjectGroupId && Boolean(group.parentPath?.trim()) - ) ?? null) - : null, - [projectGroups, selectedProjectGroupId] + findActionableFolderProjectGroup({ + projectGroups, + groupId: selectedProjectGroupId, + actionableHostIds + }), + [actionableHostIds, projectGroups, selectedProjectGroupId] ) useEffect(() => { if (selectedProjectGroupId && !selectedProjectGroup) { @@ -686,14 +715,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) { return } - const nextGroup = projectGroups.find( - (group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim()) - ) + const nextGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: initialFolderProjectGroupId, + actionableHostIds + }) if (nextGroup) { initialProjectGroupAppliedRef.current = true setSelectedProjectGroupId(nextGroup.id) } - }, [initialFolderProjectGroupId, projectGroups, selectedProjectGroupId]) + }, [actionableHostIds, initialFolderProjectGroupId, projectGroups, selectedProjectGroupId]) const isProjectGroupTarget = selectedProjectGroup !== null const folderSourceRepos = useMemo( () => getFolderSourceRepos(repos, projectGroups, selectedProjectGroup), @@ -742,9 +773,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, draftRepoId: repoId, - focusedHostScope: workspaceHostScope + focusedHostScope: workspaceHostScope, + actionableHostIds }), - [eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] + [actionableHostIds, eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false @@ -784,26 +816,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS !selectedProjectGroup && selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectHostSetupId : null - const hostOptions = useMemo( - () => - buildExecutionHostRegistry({ - repos, - settings, - sshTargetLabels, - sshConnectionStates, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides: getHostDisplayLabelOverrides(settings) - }), - [ - repos, - settings, - sshConnectionStates, - sshTargetLabels, - runtimeEnvironments, - runtimeStatusByEnvironmentId - ] - ) const projectHostSetupOptions = useMemo( () => buildProjectHostSetupOptions({ @@ -2648,9 +2660,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialProjectGroupAppliedRef.current = true const projectGroupId = getProjectGroupIdFromNewWorkspaceOptionId(projectId) if (projectGroupId) { - const nextProjectGroup = projectGroups.find( - (group) => group.id === projectGroupId && Boolean(group.parentPath?.trim()) - ) + const nextProjectGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: projectGroupId, + actionableHostIds + }) if (!nextProjectGroup) { setSelectedProjectGroupId(null) setProjectError( @@ -2697,7 +2711,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, projectId, - focusedHostScope: preferredHostId ?? workspaceHostScope + focusedHostScope: preferredHostId ?? workspaceHostScope, + actionableHostIds }) if (!nextRepoId) { return @@ -2706,6 +2721,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [ eligibleRepos, + actionableHostIds, handleRepoChange, isProjectGroupTarget, linkedWorkItem, diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 1e5f5e45e35..b2367e33721 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -73,6 +73,8 @@ describe('getEditorExternalWatchTargets', () => { rightSidebarExplorerView: args.rightSidebarExplorerView ?? 'files', gitStatusHugeByWorktree: args.gitStatusHugeByWorktree ?? {}, sshConnectionStates: args.sshConnectionStates ?? new Map(), + folderWorkspaces: [], + projectGroups: [], settings: args.runtimeEnvironmentId === undefined ? null diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 6ac41b3b6be..1b4ff62a44d 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -32,6 +32,8 @@ import { import { isGitRepoKind } from '../../../shared/repo-kind' import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' // Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per (worktreeId+path). const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 @@ -95,6 +97,8 @@ export type EditorExternalWatchTargetState = Pick< | 'rightSidebarExplorerView' | 'gitStatusHugeByWorktree' | 'sshConnectionStates' + | 'folderWorkspaces' + | 'projectGroups' > let cachedOpenFiles: AppState['openFiles'] | null = null @@ -107,6 +111,8 @@ let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null +let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null +let cachedProjectGroups: AppState['projectGroups'] | null = null let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' } export function getWatchedTargetKey(target: WatchedTarget): string { @@ -132,7 +138,9 @@ export function getEditorExternalWatchTargets( cachedRightSidebarTab === state.rightSidebarTab && cachedRightSidebarExplorerView === state.rightSidebarExplorerView && cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree && - cachedSshConnectionStates === state.sshConnectionStates + cachedSshConnectionStates === state.sshConnectionStates && + cachedFolderWorkspaces === state.folderWorkspaces && + cachedProjectGroups === state.projectGroups ) { return cachedWatchedTargetsSnapshot } @@ -183,18 +191,31 @@ export function getEditorExternalWatchTargets( const sortedWorktreeIds = Array.from(targetOwnersByWorktreeId.keys()).sort() for (const id of sortedWorktreeIds) { const wt = findWorktreeById(state.worktreesByRepo, id) - if (!wt) { + const workspaceScope = parseWorkspaceKey(id) + const folderWorkspace = + workspaceScope?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + ) + : undefined + if (!wt && !folderWorkspace) { + continue + } + const repo = wt ? state.repos.find((r) => r.id === wt.repoId) : undefined + const connectionId = folderWorkspace + ? getFolderWorkspaceConnectionId(state, folderWorkspace.id) + : repo?.connectionId + if (connectionId === undefined && folderWorkspace) { continue } - const repo = state.repos.find((r) => r.id === wt.repoId) const owners = Array.from(targetOwnersByWorktreeId.get(id) ?? []).sort((a, b) => (a ?? '').localeCompare(b ?? '') ) for (const owner of owners) { const target = { worktreeId: id, - worktreePath: wt.path, - connectionId: repo?.connectionId ?? undefined, + worktreePath: wt?.path ?? folderWorkspace!.folderPath, + connectionId: connectionId ?? undefined, runtimeEnvironmentId: owner } nextTargets.push(target) @@ -213,6 +234,8 @@ export function getEditorExternalWatchTargets( cachedRightSidebarExplorerView = state.rightSidebarExplorerView cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree cachedSshConnectionStates = state.sshConnectionStates + cachedFolderWorkspaces = state.folderWorkspaces + cachedProjectGroups = state.projectGroups if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) { return cachedWatchedTargetsSnapshot diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3d897577e77..c13cf20057a 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -3382,7 +3382,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef.current?.({ tabId: 'terminal-1' }) - expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') + // The CLI/RPC caller is answered immediately, so this close must never raise a modal. + expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1', { + skipRunningProcessConfirm: true + }) }) it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { @@ -6160,6 +6163,202 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('applies a burst leading-edge first, then coalesces the rest into one deferred batch', async () => { + // Why: each live status event is its own IPC task, so N events used to pay + // N full render passes (STA-3328 mechanism 2). The leading event must stay + // synchronous (zero added latency); followers within the burst window must + // apply together on one later task, in arrival order. + vi.useFakeTimers() + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + const emit = (receivedAt: number, prompt: string): void => { + onSetListenerRef.current!({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt, + agentType: 'claude', + receivedAt, + stateStartedAt: receivedAt + }) + } + + emit(1_700_000_000_000, 'first') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + emit(1_700_000_000_001, 'second') + emit(1_700_000_000_002, 'third') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(3) + expect(setAgentStatus.mock.calls[1][1]).toEqual(expect.objectContaining({ prompt: 'second' })) + expect(setAgentStatus.mock.calls[2][1]).toEqual(expect.objectContaining({ prompt: 'third' })) + } finally { + vi.useRealTimers() + } + }) + + it('preserves queued set-clear order for working removal and done retention', async () => { + vi.useFakeTimers() + let storeState: StoreLike + const setAgentStatus = vi.fn( + ( + paneKey: string, + payload: { state: string }, + _title: unknown, + timing: { updatedAt: number } + ) => { + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [paneKey]: { ...payload, updatedAt: timing.updatedAt } + } + } + ) + const removeAgentStatus = vi.fn((paneKey: string) => { + const next = { ...(storeState.agentStatusByPaneKey as Record) } + delete next[paneKey] + storeState.agentStatusByPaneKey = next + }) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const onClearListenerRef: { current: ((data: { paneKey: string }) => void) | null } = { + current: null + } + storeState = buildStoreState({ + setAgentStatus, + removeAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + }, + onClear: (cb) => { + onClearListenerRef.current = cb as (data: { paneKey: string }) => void + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + if ( + typeof onSetListenerRef.current !== 'function' || + typeof onClearListenerRef.current !== 'function' + ) { + throw new Error('Expected agentStatus listeners to be registered') + } + const emit = ( + receivedAt: number, + prompt: string, + state: AgentStatusSetData['state'] = 'working' + ): void => { + onSetListenerRef.current!({ + paneKey: FUTURE_PANE_KEY, + state, + prompt, + agentType: 'claude', + receivedAt, + stateStartedAt: receivedAt + }) + } + + emit(1_700_000_000_000, 'leading') + emit(1_700_000_000_001, 'queued') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + expect(storeState.agentStatusByPaneKey).toEqual({}) + + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(storeState.agentStatusByPaneKey).toEqual({}) + + emit(1_700_000_000_002, 'task') + emit(1_700_000_000_003, 'task', 'done') + expect(setAgentStatus).toHaveBeenCalledTimes(3) + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + + expect(setAgentStatus).toHaveBeenCalledTimes(4) + expect(setAgentStatus.mock.calls[3][1]).toEqual(expect.objectContaining({ state: 'done' })) + expect(removeAgentStatus).toHaveBeenCalledTimes(1) + expect(storeState.agentStatusByPaneKey).toEqual({ + [FUTURE_PANE_KEY]: expect.objectContaining({ state: 'done' }) + }) + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(4) + } finally { + vi.useRealTimers() + } + }) + it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => { // Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded): // the store subscriber calls flushPendingAgentStatuses() on every update. diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3c8b19068c2..35c48344e63 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -288,6 +288,11 @@ export { resolveZoomTarget } from './resolve-zoom-target' const PENDING_AGENT_STATUS_RETRY_MS = 100 const PENDING_AGENT_STATUS_TTL_MS = 15_000 const MAX_PENDING_AGENT_STATUS_EVENTS = 100 +// Why: each live status event is its own IPC task, so a multi-agent burst pays +// one full render pass per event; same-task commits batch to ONE pass (React +// flushes external-store updates at the microtask boundary). One frame of +// buffering collapses a burst; the leading event still applies immediately. +const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 // Why: mobile driver hydration is async; cap replay so a stuck IPC snapshot can't retain an unbounded startup buffer. const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. @@ -758,6 +763,9 @@ export function useIpcEvents(): void { let pendingAgentStatusRetryTimer: ReturnType | null = null // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). let isFlushingAgentStatuses = false + const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] + let liveAgentStatusBurstTimer: ReturnType | null = null + let lastLiveAgentStatusApplyAt = 0 unsubs.push(attachMobileMarkdownBridge()) @@ -1957,7 +1965,8 @@ export function useIpcEvents(): void { const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) } else { - closeTerminalTab(tabId) + // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. + closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) } }) ) @@ -3203,12 +3212,18 @@ export function useIpcEvents(): void { const statusPayloadWithTurnBoundary = data.promptInteractionKey ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } : statusPayload + // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. + const statusPayloadWithProvenance = + data.restoredUnconfirmed === true + ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } + : statusPayloadWithTurnBoundary const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { agentType: existingStatus.agentType, state: existingStatus.state, - updatedAt: existingStatus.updatedAt + updatedAt: existingStatus.updatedAt, + restoredUnconfirmed: existingStatus.restoredUnconfirmed } : undefined, incoming: statusPayload.agentType, @@ -3241,7 +3256,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( paneKey, - statusPayloadWithTurnBoundary, + statusPayloadWithProvenance, terminalTitle, { updatedAt: data.receivedAt, @@ -3331,9 +3346,67 @@ export function useIpcEvents(): void { }) } + function flushLiveAgentStatusBurst(): void { + liveAgentStatusBurstTimer = null + lastLiveAgentStatusApplyAt = Date.now() + // Why: splice before applying — applyAgentStatus can synchronously + // re-enter the queue via subscribers, and it must not see this batch. + const batch = liveAgentStatusBurstQueue.splice(0) + let anyApplied = false + for (const data of batch) { + if (applyAgentStatus(data) === 'applied') { + anyApplied = true + } + } + if (!anyApplied) { + lastLiveAgentStatusApplyAt = 0 + } + } + + function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { + const queuedForPane: AgentStatusIpcPayload[] = [] + const remaining: AgentStatusIpcPayload[] = [] + for (const queued of liveAgentStatusBurstQueue) { + if (queued.paneKey === paneKey) { + queuedForPane.push(queued) + } else { + remaining.push(queued) + } + } + liveAgentStatusBurstQueue.length = 0 + liveAgentStatusBurstQueue.push(...remaining) + for (const queued of queuedForPane) { + applyAgentStatus(queued) + } + } + + function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { + const now = Date.now() + if ( + liveAgentStatusBurstTimer === null && + now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) { + lastLiveAgentStatusApplyAt = now + // Why: only an applied event commits state and costs a render pass — + // a dropped/pending leading edge must not make its successor pay + // burst latency (startup replay and unmounted panes stay immediate). + if (applyAgentStatus(data) !== 'applied') { + lastLiveAgentStatusApplyAt = 0 + } + return + } + liveAgentStatusBurstQueue.push(data) + if (liveAgentStatusBurstTimer === null) { + liveAgentStatusBurstTimer = globalThis.setTimeout( + flushLiveAgentStatusBurst, + LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) + } + } + unsubs.push( window.api.agentStatus.onSet((data) => { - applyAgentStatus(data) + enqueueLiveAgentStatus(data) }) ) const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( @@ -3362,12 +3435,30 @@ export function useIpcEvents(): void { pendingAgentStatusEvents.splice(index, 1) } } + for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { + const queued = liveAgentStatusBurstQueue[index] + if ( + queued.connectionId === data.connectionId && + queued.receivedAt <= effectiveWatermark + ) { + liveAgentStatusBurstQueue.splice(index, 1) + } + } useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) return } if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. + if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { + drainQueuedLiveAgentStatusesForPane(data.paneKey) + } + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } const store = useAppStore.getState() if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { return @@ -3559,6 +3650,11 @@ export function useIpcEvents(): void { globalThis.clearTimeout(pendingAgentStatusRetryTimer) } pendingAgentStatusEvents.length = 0 + if (liveAgentStatusBurstTimer !== null) { + globalThis.clearTimeout(liveAgentStatusBurstTimer) + liveAgentStatusBurstTimer = null + } + liveAgentStatusBurstQueue.length = 0 mobileStateHydrationDisposed = true pendingMobileStateEvents.length = 0 unsubscribeRuntimeEnvironmentStore() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4276272e72d..643a4f326c9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4309,7 +4309,9 @@ "ee240a39eb": "Edit filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -4374,7 +4376,9 @@ "82594419ba": "Filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "sidebarHostOptions": { "3e102f111c": "All hosts", @@ -4769,7 +4773,8 @@ "0899ebdb28": "Git already forgot this workspace, but its directory is still on disk. Use Force Delete to remove the orphaned directory.", "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", - "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway." + "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", + "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold." } } }, @@ -5083,15 +5088,40 @@ "pairingCommand": "orca serve --pairing-address ", "pairingHelpSuffix": "on the server and paste the printed pairing URL.", "sshImportAlreadySynced": "~/.ssh/config already in sync.", - "sshImportSynced": "Synced {{value0}} host{{value1}}.", + "sshImportSynced": "Added {{value0}} host{{value1}} to Orca.", "sshImportFailed": "Failed to import SSH config.", - "importing": "Importing...", - "importSshConfig": "Import ~/.ssh/config", + "sshAlreadyExists": "That SSH host is already in Orca.", "advanced": "Advanced", "linkDestination": "Link destination", "sshTunnel": "I am using an SSH tunnel", "sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.", - "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address." + "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.", + "sshConfigPickerLoadFailed": "Failed to read ~/.ssh/config.", + "sshConfigPickerResolveFailed": "Failed to resolve that SSH config host.", + "sshConfigPickerRestartRequired": "Restart Orca to finish applying the SSH config picker update.", + "sshConfigPickerFilled": "Filled from {{value0}}. Review and Save.", + "sshConfigPickerTitle": "Choose from ~/.ssh/config", + "sshConfigPickerDescription": "Pick a host to fill the form. Nothing is saved until you click Save.", + "sshConfigPickerFilter": "Filter hosts…", + "sshConfigPickerHostsLabel": "SSH config hosts", + "sshConfigPickerLoading": "Reading ~/.ssh/config…", + "sshConfigPickerEmpty": "No hosts in ~/.ssh/config", + "sshConfigPickerNoMatch": "No matching hosts", + "sshConfigPickerEmptyHint": "Add a Host entry there, or go back and type the details manually.", + "sshConfigPickerNoMatchHint": "Try another filter, or go back and type manually.", + "sshConfigPickerMoreResults": "Showing the first {{value0}} matches. Narrow your filter to find more.", + "sshConfigPickerInOrca": "In Orca", + "sshConfigPickerPreviouslyRemoved": "Removed from Orca", + "sshConfigPickerBulkHint": "Bulk sync stays in Settings → SSH", + "sshConfigPickerBack": "Back", + "fillFromSshConfig": "Fill from ~/.ssh/config…", + "sshConfigPickerAddingAll": "Adding hosts…", + "sshConfigPickerAddAll": "Add all {{value0}} to Orca", + "sshConfigPickerNoNewHosts": "No new hosts to add", + "sshConfigPickerAddAllEmpty": "Add all to Orca", + "identityFileFromConfigHint": "Left empty on purpose: Orca uses every key ~/.ssh/config resolves for {{value0}}. Type a path to use just that key.", + "sshConfigPickerRetry": "Try again", + "sshConfigPickerResolving": "Reading…" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Reconnection failed", @@ -6615,7 +6645,10 @@ "f79972271a": "No GitHub remote found for this repo.", "4d039317f4": "Website favicon", "acf31559a0": "Enter a valid website URL.", - "868c5c9b56": "Failed to import repo icon" + "868c5c9b56": "Failed to import repo icon", + "emojiTooLongForRepoIcon": "This emoji can't be used as a repo icon.", + "currentEmojiSelection": "Current: {{value0}}", + "searchEmojiPlaceholder": "Search emoji" }, "RepositoryPane": { "15a99d9b9f": "Relative paths resolve from this project root.", @@ -10216,10 +10249,10 @@ "653c105ecc": "More PR actions", "f316a8ca2b": "No unresolved comments selected.", "d00ebdc402": "Resolve {{value0}} Comments With AI", - "ed3f79c031": "Review the prompt before starting an agent. Selected threads are marked resolved after launch.", - "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", - "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", - "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", + "ed3f79c031": "Review the prompt before starting an agent. After the prompt is delivered, Orca replies to the selected comments and resolves host threads when possible.", + "f273f2271c": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.{{value4}}", + "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.", + "495b2f8c4b": "Started the agent, but could not resolve or reply on the selected comments.", "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", "review": { "auto_retry": "Orca will retry at {{time}}.", @@ -10229,7 +10262,8 @@ "sync": { "pending": "Syncing…", "branch": "Sync Branch" - } + }, + "7e4b2a19c0": "Could not resolve the GitHub PR to reply on." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -10778,7 +10812,6 @@ "a54ae21c6f": "Status:", "e4e3af15ee": "View full details", "b8c4e2a1f7": "View full logs", - "2524d1fb83": "Log tail available in full details.", "a2fb3f4408": "Showing first 100 jobs", "df137989b3": "Showing first 20 annotations", "1f2b980522": "Loading check details…", @@ -10830,7 +10863,8 @@ "5e6e5a13fa": "View", "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", "b3195cba33": "Unmark author as bot", - "f588b46a6c": "Mark author as bot" + "f588b46a6c": "Mark author as bot", + "checkDetailsLoadFailed": "Failed to load check details." }, "empty": { "state": { @@ -11474,6 +11508,14 @@ }, "activityBar": { "error": "Error" + }, + "AiVaultSessionLimitMenu": { + "historyDepth": "History depth: {{value0}}", + "performanceWarning": "Larger histories can slow the entire app, especially on remote hosts. Unlimited scans all available history.", + "recommended": "Recommended", + "mayBeSlower": "May be slower", + "slowest": "Slowest", + "unlimited": "Unlimited" } } }, @@ -14525,6 +14567,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "Failed to load the GitLab job log.", + "emptyTrace": "No log is available for this GitLab job.", + "timedOut": "Timed out loading the GitLab job log." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 01210fbd7d2..14dd169539e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4227,7 +4227,9 @@ "ee240a39eb": "Editar filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -4292,7 +4294,9 @@ "82594419ba": "Filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -5013,8 +5017,6 @@ "sshImportAlreadySynced": "~/.ssh/config ya está sincronizado.", "sshImportSynced": "Se sincronizó {{value0}} host{{value1}}.", "sshImportFailed": "No se pudo importar la configuración SSH.", - "importing": "Importando...", - "importSshConfig": "Importar ~/.ssh/config", "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.", "advanced": "Advanced", "linkDestination": "Destino del enlace", @@ -6481,7 +6483,10 @@ "f79972271a": "No se encontró ningún control remoto de GitHub para este repositorio.", "4d039317f4": "Favicon del sitio web", "acf31559a0": "Ingresa una URL de sitio web válida.", - "868c5c9b56": "No se pudo importar el icono del repositorio" + "868c5c9b56": "No se pudo importar el icono del repositorio", + "emojiTooLongForRepoIcon": "Este emoji no se puede usar como icono del repositorio.", + "currentEmojiSelection": "Actual: {{value0}}", + "searchEmojiPlaceholder": "Buscar emoji" }, "RepositoryPane": { "15a99d9b9f": "Las rutas relativas se resuelven desde la raíz de este proyecto.", @@ -10079,10 +10084,10 @@ "653c105ecc": "Más acciones de PR", "f316a8ca2b": "No hay comentarios sin resolver seleccionados.", "d00ebdc402": "Resolver comentarios de {{value0}} con IA", - "ed3f79c031": "Revisa el prompt antes de iniciar un agente. Los hilos seleccionados se marcarán como resueltos después del inicio.", - "f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", - "aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", - "495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.", + "ed3f79c031": "Revisa el prompt antes de iniciar un agente. Una vez entregado el prompt, Orca responde a los comentarios seleccionados y resuelve los hilos en el host cuando es posible.", + "f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.{{value4}}", + "aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.", + "495b2f8c4b": "Agente iniciado, pero no se pudieron resolver ni responder los comentarios seleccionados.", "3c3ad3a1d2": "Agente iniciado. No se puede marcar ningún comentario seleccionado como resuelto en el host.", "review": { "auto_retry": "Orca reintentará a las {{time}}.", @@ -10092,7 +10097,8 @@ "sync": { "pending": "Syncing…", "branch": "Sync Branch" - } + }, + "7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", @@ -10637,7 +10643,6 @@ "fd46a70f1a": "Iniciado", "a54ae21c6f": "Estado:", "e4e3af15ee": "Ver detalles completos", - "2524d1fb83": "Cola del log disponible en los detalles completos.", "a2fb3f4408": "Mostrando los primeros 100 jobs", "df137989b3": "Mostrando las primeras 20 anotaciones", "1f2b980522": "Cargando detalles del check…", @@ -10690,7 +10695,8 @@ "5e6e5a13fa": "Vista", "actionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que el merge quede desbloqueado.", "b3195cba33": "Desmarcar autor como bot", - "f588b46a6c": "Marcar autor como bot" + "f588b46a6c": "Marcar autor como bot", + "checkDetailsLoadFailed": "No se pudieron cargar los detalles del check." }, "empty": { "state": { @@ -11334,6 +11340,14 @@ }, "activityBar": { "error": "Error" + }, + "AiVaultSessionLimitMenu": { + "historyDepth": "Profundidad del historial: {{value0}}", + "performanceWarning": "Los historiales más extensos pueden ralentizar toda la aplicación, especialmente en hosts remotos. Sin límite escanea todo el historial disponible.", + "recommended": "Recomendado", + "mayBeSlower": "Puede ser más lento", + "slowest": "Más lento", + "unlimited": "Sin límite" } } }, @@ -14341,6 +14355,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "No se pudo cargar el registro del job de GitLab.", + "emptyTrace": "No hay ningún registro disponible para este job de GitLab.", + "timedOut": "Se agotó el tiempo de espera al cargar el registro del job de GitLab." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 46170810b7c..9ec2f14a80d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4208,7 +4208,9 @@ "ee240a39eb": "フィルターの編集", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -4273,7 +4275,9 @@ "82594419ba": "フィルター", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -5013,8 +5017,6 @@ "sshImportAlreadySynced": "~/.ssh/config はすでに同期されています。", "sshImportSynced": "{{value0}} ホスト{{value1}} を同期しました。", "sshImportFailed": "SSH 構成のインポートに失敗しました。", - "importing": "インポート中...", - "importSshConfig": "~/.ssh/config をインポートします", "sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。", "advanced": "詳細", "linkDestination": "リンク先", @@ -6503,7 +6505,10 @@ "f79972271a": "この repo の GitHub リモートが見つかりません。", "4d039317f4": "ウェブサイトのファビコン", "acf31559a0": "有効な Web サイトの URL を入力。", - "868c5c9b56": "repo のインポートに失敗しましたアイコン" + "868c5c9b56": "repo のインポートに失敗しましたアイコン", + "emojiTooLongForRepoIcon": "この絵文字は repo アイコンとして使用できません。", + "currentEmojiSelection": "現在: {{value0}}", + "searchEmojiPlaceholder": "絵文字を検索" }, "RepositoryPane": { "15a99d9b9f": "相対パスは、このプロジェクトのルートから解決されます。", @@ -10079,10 +10084,10 @@ "653c105ecc": "その他の PR 操作", "f316a8ca2b": "未解決のコメントが選択されていません。", "d00ebdc402": "{{value0}} のコメントを AI で解決", - "ed3f79c031": "agent を開始する前にプロンプトを確認してください。選択したスレッドは開始後に解決済みにマークされます。", - "f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", - "aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", - "495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。", + "ed3f79c031": "agent を開始する前にプロンプトを確認してください。プロンプトの送信後、Orca は選択したコメントに返信し、可能な場合はホスト上のスレッドを解決します。", + "f273f2271c": "agent を開始しました。{{value0}} 件を解決し、{{value1}} 件に返信し、{{value2}} 件をスキップし、{{value3}} 件の処理に失敗しました。{{value4}}", + "aa95b81a3a": "agent を開始しました。{{value0}} 件を解決し、{{value1}} 件に返信し、{{value2}} 件をスキップし、{{value3}} 件の処理に失敗しました。", + "495b2f8c4b": "agent を開始しましたが、選択したコメントの解決または返信ができませんでした。", "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。", "review": { "auto_retry": "Orca は {{time}} に再試行します。", @@ -10092,7 +10097,8 @@ "sync": { "pending": "Syncing…", "branch": "Sync Branch" - } + }, + "7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。" }, "CreatePullRequestDialog": { "2bc1b4345e": "キャンセル", @@ -10637,7 +10643,6 @@ "fd46a70f1a": "開始しました", "a54ae21c6f": "状態:", "e4e3af15ee": "詳細を表示", - "2524d1fb83": "ログテールで詳細を確認できます。", "a2fb3f4408": "最初の 100 件のジョブを表示しています", "df137989b3": "最初の 20 個の注釈を表示", "1f2b980522": "チェックの詳細を読み込んでいます…", @@ -10690,7 +10695,8 @@ "5e6e5a13fa": "表示", "actionRequiredHint": "マージが解除される前に、GitHubで手動アクションが必要です(例:ワークフロー実行の承認)。", "b3195cba33": "作成者のボット指定を解除", - "f588b46a6c": "作成者をボットとして指定" + "f588b46a6c": "作成者をボットとして指定", + "checkDetailsLoadFailed": "チェック詳細の読み込みに失敗しました。" }, "empty": { "state": { @@ -11334,6 +11340,14 @@ }, "activityBar": { "error": "エラー" + }, + "AiVaultSessionLimitMenu": { + "historyDepth": "履歴の件数: {{value0}}", + "performanceWarning": "履歴を増やすと、特にリモートホストではアプリ全体が遅くなる場合があります。無制限では利用可能な履歴をすべてスキャンします。", + "recommended": "推奨", + "mayBeSlower": "遅くなる可能性あり", + "slowest": "最も低速", + "unlimited": "無制限" } } }, @@ -14341,6 +14355,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab ジョブのログを読み込めませんでした。", + "emptyTrace": "この GitLab ジョブのログはありません。", + "timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 524efb4a66f..152e6a771dc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4208,7 +4208,9 @@ "ee240a39eb": "필터 편집", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -4273,7 +4275,9 @@ "82594419ba": "필터", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -5013,8 +5017,6 @@ "sshImportAlreadySynced": "~/.ssh/config가 이미 동기화되어 있습니다.", "sshImportSynced": "{{value0}} 호스트{{value1}}을(를) 동기화했습니다.", "sshImportFailed": "SSH 구성을 가져오지 못했습니다.", - "importing": "가져오는 중...", - "importSshConfig": "~/.ssh/config 가져오기", "sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.", "advanced": "고급", "linkDestination": "링크 대상", @@ -6466,7 +6468,10 @@ "f79972271a": "이 repo에 대한 GitHub 원격을 찾을 수 없습니다.", "4d039317f4": "웹사이트 파비콘", "acf31559a0": "유효한 웹사이트 URL을 입력하세요.", - "868c5c9b56": "repo 아이콘을 가져오지 못했습니다." + "868c5c9b56": "repo 아이콘을 가져오지 못했습니다.", + "emojiTooLongForRepoIcon": "이 이모티콘은 repo 아이콘으로 사용할 수 없습니다.", + "currentEmojiSelection": "현재: {{value0}}", + "searchEmojiPlaceholder": "이모티콘 검색" }, "RepositoryPane": { "15a99d9b9f": "상대 경로는 이 프로젝트 루트에서 확인됩니다.", @@ -10079,10 +10084,10 @@ "653c105ecc": "더 많은 PR 작업", "f316a8ca2b": "선택한 해결되지 않은 댓글이 없습니다.", "d00ebdc402": "AI로 {{value0}} 댓글 해결", - "ed3f79c031": "agent를 시작하기 전에 prompt를 검토하세요. 선택한 스레드는 시작 후 해결됨으로 표시됩니다.", - "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", - "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", - "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.", + "ed3f79c031": "에이전트를 시작하기 전에 프롬프트를 확인하세요. 프롬프트가 전달된 후 Orca가 선택한 댓글에 답글을 남기고 가능한 경우 호스트 스레드를 해결합니다.", + "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.{{value4}}", + "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.", + "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결하거나 답글할 수 없습니다.", "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.", "review": { "auto_retry": "Orca가 {{time}}에 다시 시도합니다.", @@ -10092,7 +10097,8 @@ "sync": { "pending": "Syncing…", "branch": "Sync Branch" - } + }, + "7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다." }, "CreatePullRequestDialog": { "2bc1b4345e": "취소", @@ -10637,7 +10643,6 @@ "fd46a70f1a": "시작됨", "a54ae21c6f": "상태:", "e4e3af15ee": "전체 세부정보 보기", - "2524d1fb83": "자세한 내용을 로그 테일에서 확인할 수 있습니다.", "a2fb3f4408": "처음 100개 작업 표시", "df137989b3": "처음 20개의 주석 표시 중", "1f2b980522": "검사 세부 정보 로드 중…", @@ -10690,7 +10695,8 @@ "5e6e5a13fa": "보기", "actionRequiredHint": "이 검사는 병합 차단이 해제되기 전에 GitHub에서 수동 작업(예: 워크플로 실행 승인)이 필요합니다.", "b3195cba33": "작성자의 봇 지정 해제", - "f588b46a6c": "작성자를 봇으로 지정" + "f588b46a6c": "작성자를 봇으로 지정", + "checkDetailsLoadFailed": "체크 세부 정보를 불러오지 못했습니다." }, "empty": { "state": { @@ -11334,6 +11340,14 @@ }, "activityBar": { "error": "오류" + }, + "AiVaultSessionLimitMenu": { + "historyDepth": "기록 개수: {{value0}}", + "performanceWarning": "더 많은 기록을 불러오면 특히 원격 호스트에서 앱 전체가 느려질 수 있습니다. 무제한은 사용 가능한 모든 기록을 스캔합니다.", + "recommended": "권장", + "mayBeSlower": "느려질 수 있음", + "slowest": "가장 느림", + "unlimited": "무제한" } } }, @@ -14341,6 +14355,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab 작업 로그를 불러오지 못했습니다.", + "emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.", + "timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b8a16a687e7..7f9907ab181 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4220,7 +4220,9 @@ "ee240a39eb": "编辑筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -4285,7 +4287,9 @@ "82594419ba": "筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", @@ -5025,8 +5029,6 @@ "sshImportAlreadySynced": "~/.ssh/config 已同步。", "sshImportSynced": "已同步 {{value0}} 个主机{{value1}}。", "sshImportFailed": "导入 SSH 配置失败。", - "importing": "正在导入...", - "importSshConfig": "导入 ~/.ssh/config", "sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。", "advanced": "Advanced", "linkDestination": "链接目标", @@ -6478,7 +6480,10 @@ "f79972271a": "找不到此仓库的 GitHub 远程。", "4d039317f4": "网站图标", "acf31559a0": "输入有效的网站 URL。", - "868c5c9b56": "无法导入仓库图标" + "868c5c9b56": "无法导入仓库图标", + "emojiTooLongForRepoIcon": "此表情符号无法用作仓库图标。", + "currentEmojiSelection": "当前:{{value0}}", + "searchEmojiPlaceholder": "搜索表情符号" }, "RepositoryPane": { "15a99d9b9f": "相对路径从该项目根解析。", @@ -10091,10 +10096,10 @@ "653c105ecc": "更多 PR 操作", "f316a8ca2b": "未选择未解决的评论。", "d00ebdc402": "使用 AI 解决 {{value0}} 评论", - "ed3f79c031": "启动智能体前请检查提示词。启动后,选中的线程会被标记为已解决。", - "f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", - "aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", - "495b2f8c4b": "已启动智能体,但无法将选中的评论标记为已解决。", + "ed3f79c031": "启动 agent 前请检查提示词。提示词送达后,Orca 会回复选中的评论,并在可能时解决主机上的讨论串。", + "f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。{{value4}}", + "aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。", + "495b2f8c4b": "已启动智能体,但无法解决或回复选中的评论。", "3c3ad3a1d2": "已启动智能体。选中的评论中没有可在托管平台上标记为已解决的评论。", "review": { "auto_retry": "Orca 将在 {{time}} 重试。", @@ -10104,7 +10109,8 @@ "sync": { "pending": "Syncing…", "branch": "Sync Branch" - } + }, + "7e4b2a19c0": "无法确定要回复的 GitHub PR。" }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", @@ -10649,7 +10655,6 @@ "fd46a70f1a": "开始", "a54ae21c6f": "状态:", "e4e3af15ee": "查看完整详细信息", - "2524d1fb83": "日志尾部提供完整详细信息。", "a2fb3f4408": "显示前 100 个作业", "df137989b3": "显示前 20 个注释", "1f2b980522": "正在加载检查详细信息...", @@ -10702,7 +10707,8 @@ "5e6e5a13fa": "查看", "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。", "b3195cba33": "取消将作者标记为机器人", - "f588b46a6c": "将作者标记为机器人" + "f588b46a6c": "将作者标记为机器人", + "checkDetailsLoadFailed": "加载检查详细信息失败。" }, "empty": { "state": { @@ -11346,6 +11352,14 @@ }, "activityBar": { "error": "错误" + }, + "AiVaultSessionLimitMenu": { + "historyDepth": "历史记录数量:{{value0}}", + "performanceWarning": "加载更多历史记录可能会拖慢整个应用,尤其是在远程主机上。无限制模式会扫描所有可用历史记录。", + "recommended": "推荐", + "mayBeSlower": "可能较慢", + "slowest": "最慢", + "unlimited": "无限制" } } }, @@ -14353,6 +14367,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "加载 GitLab 作业日志失败。", + "emptyTrace": "此 GitLab 作业没有可用的日志。", + "timedOut": "加载 GitLab 作业日志超时。" } } }, diff --git a/src/renderer/src/lib/agent-spinner-clock.test.ts b/src/renderer/src/lib/agent-spinner-clock.test.ts deleted file mode 100644 index cdf496995e9..00000000000 --- a/src/renderer/src/lib/agent-spinner-clock.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_SPINNER_TICK_MS, - registerAgentSpinnerElement, - resetAgentSpinnerClockForTesting -} from './agent-spinner-clock' -import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' - -type FakeElement = { style: { transform: string } } - -function makeElement(): HTMLElement { - return { style: { transform: '' } } as unknown as HTMLElement -} - -function stubDom(visibilityState: () => DocumentVisibilityState): { - fireVisibilityChange: () => void -} { - const documentListeners = new Map void>>() - const addListener = (event: string, listener: (event?: unknown) => void): void => { - if (!documentListeners.has(event)) { - documentListeners.set(event, new Set()) - } - documentListeners.get(event)!.add(listener) - } - vi.stubGlobal('document', { - get visibilityState() { - return visibilityState() - }, - addEventListener: vi.fn(addListener), - removeEventListener: vi.fn((event: string, listener: (event?: unknown) => void) => { - documentListeners.get(event)?.delete(listener) - }) - }) - vi.stubGlobal('window', { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - matchMedia: vi.fn(() => ({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - })) - }) - return { - fireVisibilityChange: () => { - for (const listener of documentListeners.get('visibilitychange') ?? []) { - listener() - } - } - } -} - -describe('agent-spinner-clock', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - resetAgentSpinnerClockForTesting() - resetStaleDocumentVisibilityForTesting() - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - it('rotates every registered element in phase from one shared timer', () => { - stubDom(() => 'visible') - const a = makeElement() - const unregisterA = registerAgentSpinnerElement(a) - - // Registration seeds the current dial position immediately. - expect((a as unknown as FakeElement).style.transform).toMatch(/^rotate\(\d+deg\)$/) - - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 3) - const b = makeElement() - const unregisterB = registerAgentSpinnerElement(b) - - // A late joiner starts at the shared dial position, not at zero. - expect((b as unknown as FakeElement).style.transform).toBe( - (a as unknown as FakeElement).style.transform - ) - - const before = (a as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS) - expect((a as unknown as FakeElement).style.transform).not.toBe(before) - expect((b as unknown as FakeElement).style.transform).toBe( - (a as unknown as FakeElement).style.transform - ) - - unregisterA() - unregisterB() - }) - - it('stops ticking while the document is hidden and resumes on visibility', () => { - let visibility: DocumentVisibilityState = 'visible' - const { fireVisibilityChange } = stubDom(() => visibility) - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - - visibility = 'hidden' - fireVisibilityChange() - const frozen = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 10) - expect((el as unknown as FakeElement).style.transform).toBe(frozen) - - visibility = 'visible' - fireVisibilityChange() - // Resume advances immediately so the restored window visibly spins again. - expect((el as unknown as FakeElement).style.transform).not.toBe(frozen) - - unregister() - }) - - it('clears its timer when the last element unregisters', () => { - stubDom(() => 'visible') - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - unregister() - - const parked = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 5) - expect((el as unknown as FakeElement).style.transform).toBe(parked) - expect(vi.getTimerCount()).toBe(0) - }) - - it('does not tick when prefers-reduced-motion is set', () => { - stubDom(() => 'visible') - const windowStub = globalThis.window as unknown as { matchMedia: ReturnType } - windowStub.matchMedia = vi.fn(() => ({ - matches: true, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - })) - - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - const initial = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 5) - expect((el as unknown as FakeElement).style.transform).toBe(initial) - - unregister() - }) -}) diff --git a/src/renderer/src/lib/agent-spinner-clock.ts b/src/renderer/src/lib/agent-spinner-clock.ts deleted file mode 100644 index 895d6a4f8c8..00000000000 --- a/src/renderer/src/lib/agent-spinner-clock.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Why: a per-element infinite CSS animation keeps Chromium's frame pipeline -// awake the whole time any agent spinner is visible (~23.5ms CPU/s measured -// live, nearly independent of cadence, scaling per element). Background -// throttling only helps hidden windows — a visible sidebar full of working -// agents pays continuously. One shared low-rate clock steps every registered -// spinner element's transform directly, so the renderer idles between ticks -// and costs the same whether one agent or fifty are working. -import { isWindowVisible } from './window-visibility-interval' -import { - isDocumentVisibilityProvenStale, - registerStaleDocumentVisibilityRecovery -} from '@/components/terminal-pane/stale-document-visibility' - -const SPIN_STEP_DEGREES = 30 -const SPIN_STEPS = 360 / SPIN_STEP_DEGREES -// Why: 12 steps/s at 30° matches the retired CSS animation exactly — slower -// cadences read as sluggish next to it. The win over CSS is structural (idle -// between ticks, one timer for N spinners), not the tick rate. -export const AGENT_SPINNER_TICK_MS = 1000 / 12 - -const elements = new Set() -let timer: ReturnType | null = null -let step = 0 -let teardownGlobalListeners: (() => void) | null = null - -function currentTransform(): string { - return `rotate(${step * SPIN_STEP_DEGREES}deg)` -} - -function tick(): void { - step = (step + 1) % SPIN_STEPS - const transform = currentTransform() - for (const el of elements) { - el.style.transform = transform - } -} - -function prefersReducedMotion(): boolean { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return false - } - return window.matchMedia('(prefers-reduced-motion: reduce)').matches -} - -function shouldRun(): boolean { - // Why: the stale-visibility latch proves user input arrived while the macOS - // occlusion tracker still claims hidden — keep spinning for the user we know - // is watching instead of freezing until the tracker recovers. - const visible = isWindowVisible() || isDocumentVisibilityProvenStale() - return elements.size > 0 && visible && !prefersReducedMotion() -} - -function reconcile(): void { - if (shouldRun()) { - if (timer === null) { - // Advance immediately so a just-restored window visibly resumes. - tick() - timer = setInterval(tick, AGENT_SPINNER_TICK_MS) - } - return - } - if (timer !== null) { - clearInterval(timer) - timer = null - } -} - -function installGlobalListeners(): void { - if ( - teardownGlobalListeners !== null || - typeof document === 'undefined' || - typeof document.addEventListener !== 'function' - ) { - return - } - const onSignal = (): void => { - reconcile() - } - document.addEventListener('visibilitychange', onSignal) - const reducedMotionQuery = - typeof window !== 'undefined' && typeof window.matchMedia === 'function' - ? window.matchMedia('(prefers-reduced-motion: reduce)') - : null - reducedMotionQuery?.addEventListener?.('change', onSignal) - const unregisterStaleRecovery = registerStaleDocumentVisibilityRecovery(onSignal) - teardownGlobalListeners = () => { - document.removeEventListener('visibilitychange', onSignal) - reducedMotionQuery?.removeEventListener?.('change', onSignal) - unregisterStaleRecovery() - } -} - -export function registerAgentSpinnerElement(el: HTMLElement): () => void { - elements.add(el) - // Join in phase with the shared dial so late-mounting spinners stay in sync. - el.style.transform = currentTransform() - installGlobalListeners() - reconcile() - return () => { - elements.delete(el) - reconcile() - if (elements.size === 0 && teardownGlobalListeners !== null) { - teardownGlobalListeners() - teardownGlobalListeners = null - } - } -} - -/** Stable React ref callback: attach to a spinner element to spin it. */ -export function agentSpinnerRef(el: HTMLElement | null): (() => void) | undefined { - if (el === null) { - return undefined - } - return registerAgentSpinnerElement(el) -} - -export function resetAgentSpinnerClockForTesting(): void { - if (timer !== null) { - clearInterval(timer) - timer = null - } - elements.clear() - if (teardownGlobalListeners !== null) { - teardownGlobalListeners() - teardownGlobalListeners = null - } - step = 0 -} diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts new file mode 100644 index 00000000000..9cdf4fa728d --- /dev/null +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import { requestLazyChunkRecoveryReload } from './lazy-chunk-recovery-reload' + +describe('requestLazyChunkRecoveryReload', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('refuses the reload when the staged checkpoint never reaches disk', async () => { + const reload = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + + await expect( + requestLazyChunkRecoveryReload(window, () => + Promise.reject(new Error('Failed to persist renderer state before unload.')) + ) + ).resolves.toBe('checkpoint-refused') + + expect(reload).not.toHaveBeenCalled() + }) + + it('navigates only after the checkpoint is durably written', async () => { + const order: string[] = [] + vi.spyOn(window.location, 'reload').mockImplementation(() => { + order.push('reload') + // A real landed reload destroys the document; veto so the wait settles. + window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) + + await expect( + requestLazyChunkRecoveryReload(window, async () => { + order.push('flushed') + }) + ).resolves.toBe('unload-vetoed') + + expect(order).toEqual(['flushed', 'reload']) + }) +}) diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts index 5bc259a7ae4..64e78fc283a 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts @@ -49,12 +49,16 @@ function waitForRefusedNavigation(win: Window): RefusedNavigationWait { /** Resolves only if the navigation is refused; a landed reload destroys this document. */ export async function requestLazyChunkRecoveryReload( - win: Window + win: Window, + // Hosts without a preload bridge stage durably in-process; nothing to join. + awaitCheckpoint: () => Promise = () => + window.api?.app?.awaitBeforeUnloadCheckpoint?.() ?? Promise.resolve() ): Promise { try { await prepareRendererForAppRestart(win, { startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint }) } catch { // Never reload over editor buffers that could not be backed up. diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index 67c25ab2598..f6130d8f861 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -3,6 +3,7 @@ import { NEW_WORKSPACE_PROJECT_OPTION_QUERY_MAX_BYTES, buildNewWorkspaceFolderSourceOptions, buildNewWorkspaceProjectOptions, + findActionableFolderProjectGroup, getRepoIdFromNewWorkspaceFolderSourceOptionId, isNewWorkspaceProjectOptionQueryTooLarge, searchNewWorkspaceProjectOptions, @@ -108,6 +109,35 @@ describe('buildNewWorkspaceProjectOptions', () => { expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) }) + it('excludes projects configured only on removed hosts', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'removed-setup', hostId: 'ssh:removed', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('ssh-repo', { connectionId: 'removed' })], + hosts: [{ id: 'local', label: 'Local Mac' }] + }) + + expect(options).toEqual([]) + }) + + it('keeps projects with an actionable sibling setup', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'local-setup', hostId: 'local', repoId: 'local-repo' }), + setup({ id: 'removed-setup', hostId: 'ssh:removed', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('local-repo'), repo('ssh-repo', { connectionId: 'removed' })], + hosts: [{ id: 'local', label: 'Local Mac' }] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'github:stablyai/orca', detail: 'stablyai/orca' }) + ]) + }) + it('shows configured directories when project names are duplicated', () => { const options = buildNewWorkspaceProjectOptions({ projects: [ @@ -432,9 +462,16 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { projects: [project()], projectHostSetups: [setup({ id: 'local-setup', repoId: 'local-repo' })], eligibleRepos: [repo('local-repo')], + hosts: [{ id: 'local', label: 'Local Mac' }], projectGroups: [ group({ id: 'folder-group', name: 'Platform', parentPath: '/tmp/platform' }), - group({ id: 'org-group', name: 'Org', parentPath: null }) + group({ id: 'org-group', name: 'Org', parentPath: null }), + group({ + id: 'removed-folder-group', + name: 'Removed Remote', + parentPath: '/srv/removed', + connectionId: 'removed' + }) ] }) @@ -450,3 +487,50 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { }) }) }) + +describe('findActionableFolderProjectGroup', () => { + const folderGroups = [ + group({ id: 'local-group' }), + group({ id: 'ssh-group', connectionId: 'box' }), + group({ id: 'repo-group', parentPath: null }) + ] + + it('finds a folder group whose host is actionable', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'ssh-group', + actionableHostIds: new Set(['ssh:box']) + }) + ).toBe(folderGroups[1]) + }) + + // Regression: the composer's initial-group restoration skipped the actionable-host + // check, so a removed host could still back a folder workspace. + it('rejects a folder group whose host is unavailable', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'ssh-group', + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + }) + + it('rejects repo groups and missing ids', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'repo-group', + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: null, + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index bfa47e2a63b..58cab355340 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -2,6 +2,12 @@ import { projectHostSetupProjectionFromRepos } from '../../../shared/project-hos import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { getDuplicateProjectDetailsById, type ProjectSetupDirectory @@ -92,11 +98,16 @@ export function buildNewWorkspaceProjectOptions( const { projects, projectHostSetups } = getProjectModel(input) const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) + const liveHostIds = input.hosts ? new Set(input.hosts.map((host) => host.id)) : null const readySetupCountsByProjectId = new Map() const setupDirectoriesByProjectId = new Map() for (const setup of projectHostSetups) { - if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { + if ( + setup.setupState !== 'ready' || + !eligibleRepoIds.has(setup.repoId) || + (liveHostIds && !liveHostIds.has(setup.hostId)) + ) { continue } readySetupCountsByProjectId.set( @@ -158,6 +169,41 @@ function getProjectGroupDetail(group: ProjectGroup): string { return group.parentPath?.trim() || 'Repo group' } +export function getNewWorkspaceProjectGroupHostId(group: ProjectGroup): ExecutionHostId { + const executionHost = parseExecutionHostId(group.executionHostId) + if (executionHost) { + return executionHost.id + } + const connectionId = group.connectionId?.trim() + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +/** + * A folder workspace can only be backed by a group whose host is still actionable — a removed + * or unreachable host must not be selectable, restorable from a draft, or kept once selected. + */ +export function findActionableFolderProjectGroup({ + projectGroups, + groupId, + actionableHostIds +}: { + projectGroups: readonly ProjectGroup[] + groupId: string | null | undefined + actionableHostIds: ReadonlySet +}): ProjectGroup | null { + if (!groupId) { + return null + } + return ( + projectGroups.find( + (group) => + group.id === groupId && + Boolean(group.parentPath?.trim()) && + actionableHostIds.has(getNewWorkspaceProjectGroupHostId(group)) + ) ?? null + ) +} + export function buildNewWorkspaceFolderSourceOptions( repos: readonly Repo[] ): NewWorkspaceProjectOption[] { @@ -178,8 +224,13 @@ export function buildNewWorkspaceCreateTargetOptions({ ...projectInput }: BuildNewWorkspaceCreateTargetOptionsInput): NewWorkspaceProjectOption[] { const projectOptions = buildNewWorkspaceProjectOptions(projectInput) + const liveHostIds = projectInput.hosts ? new Set(projectInput.hosts.map((host) => host.id)) : null const groupOptions = projectGroups - .filter((group) => Boolean(group.parentPath?.trim())) + .filter( + (group) => + Boolean(group.parentPath?.trim()) && + (!liveHostIds || liveHostIds.has(getNewWorkspaceProjectGroupHostId(group))) + ) .map((group) => ({ kind: 'project-group' as const, id: getProjectGroupOptionId(group.id), diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 4d67758951a..4c9c49ad9bb 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -15,11 +15,12 @@ import { // (Moved here from agent-status.ts so the evidence resolvers below and the // aggregate consumers share one gate without an import cycle.) export function isExplicitAgentStatusFresh( - entry: Pick, + entry: Pick, now: number, staleAfterMs: number ): boolean { - return now - entry.updatedAt <= staleAfterMs + // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= staleAfterMs } /** diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index ecd4d74495e..a5e5e2056d4 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -69,6 +69,7 @@ describe('buildProjectHostSetupOptions', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', eligibleRepos: [repo('local-repo'), repo('remote-repo')], + hosts: [host('local'), host('ssh:builder')], projectHostSetups: [ setup('remote', 'project-1', 'ssh:builder', 'remote-repo'), setup('local', 'project-1', 'local', 'local-repo') @@ -187,6 +188,7 @@ describe('buildProjectHostSetupOptions', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', eligibleRepos: [repo('ready-repo')], + hosts: [host('local')], projectHostSetups: [ setup('ready', 'project-1', 'local', 'ready-repo'), setup('setting-up', 'project-1', 'ssh:builder', 'missing-repo', { @@ -199,6 +201,23 @@ describe('buildProjectHostSetupOptions', () => { expect(options.map((option) => option.id)).toEqual(['ready']) }) + it('omits ready setups whose host is no longer actionable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo'), repo('removed-repo')], + hosts: [host('local')], + projectHostSetups: [ + setup('local', 'project-1', 'local', 'local-repo'), + setup('removed', 'project-1', 'ssh:removed', 'removed-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['local']) + expect(options).not.toContainEqual( + expect.objectContaining({ connectAction: { kind: 'ssh', targetId: 'removed' } }) + ) + }) + it('collapses duplicate ready setups on one host to the setup creation actually uses', () => { // Why: a linked worktree added as its own project projected a second ready `local` setup for the // same project, which rendered as repeated identical "Local Mac" rows separated only by path. @@ -249,7 +268,7 @@ describe('buildProjectHostSetupOptions', () => { id: 'needs-setup:ssh:builder', kind: 'needs-setup', label: 'Builder', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true }) ]) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 4323a486032..9a8039cf19f 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -60,14 +60,14 @@ type BuildProjectHostSetupOptionsInput = { projectId: string | null projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] - hosts?: readonly ExecutionHostRegistryEntry[] + hosts: readonly ExecutionHostRegistryEntry[] } export function buildProjectHostSetupOptions({ projectId, projectHostSetups, eligibleRepos, - hosts = [] + hosts }: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] { if (!projectId) { return [] @@ -122,6 +122,7 @@ function buildReadySetupOptions({ setup.projectId === projectId && setup.setupState === 'ready' && eligibleRepoIds.has(setup.repoId) && + Boolean(host) && !isEphemeralVmProjectHost(host) && !isRuntimeOwnedSshSetupHost(setup.hostId) ) @@ -181,7 +182,7 @@ function buildNeedsSetupOptions({ detail: availability.isAvailable ? pendingSetup ? getPendingSetupDetail(pendingSetup) - : 'Project not set up on this host' + : 'Project location not set' : availability.detail, isAvailable: availability.isAvailable, attention: host.health === 'error', diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index e0e3281a079..528773d8e40 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -200,6 +200,29 @@ describe('project-host workspace target resolution', () => { }) }) + it('does not fall back to another host when only a host is selected', () => { + const localRepo = makeRepo('orca-local') + const remoteRepo = makeRepo('orca-ssh', { connectionId: 'builder' }) + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-ssh'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [localRepo, remoteRepo], + projects, + projectHostSetups, + draftRepoId: 'orca-local', + hostId: 'ssh:builder', + actionableHostIds: new Set(['local', 'ssh:builder']) + }) + ).toEqual({ + status: 'unavailable', + reason: 'project-not-set-up-on-host' + }) + }) + it('reports setup-not-ready when the selected host has pending setup metadata', () => { const repo = makeRepo('orca') const projects = [makeProject('github:stablyai/orca', ['orca'])] @@ -246,6 +269,46 @@ describe('project-host workspace target resolution', () => { }) }) + it('does not resolve workspace creation through a removed host', () => { + const remoteRepo = makeRepo('remote-repo', { connectionId: 'removed' }) + const projects = [makeProject('repo:remote', ['remote-repo'])] + const projectHostSetups = [ + makeSetup('removed-setup', 'repo:remote', 'ssh:removed', 'remote-repo') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [remoteRepo], + projects, + projectHostSetups, + draftRepoId: remoteRepo.id, + projectHostSetupId: 'removed-setup', + actionableHostIds: new Set(['local']) + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) + + it('does not silently switch an explicit setup id to an actionable sibling host', () => { + const remoteRepo = makeRepo('remote-repo', { connectionId: 'removed' }) + const localRepo = makeRepo('local-repo') + const projects = [makeProject('repo:orca', ['remote-repo', 'local-repo'])] + const projectHostSetups = [ + makeSetup('removed-setup', 'repo:orca', 'ssh:removed', 'remote-repo'), + makeSetup('local-setup', 'repo:orca', 'local', 'local-repo') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [remoteRepo, localRepo], + projects, + projectHostSetups, + draftRepoId: remoteRepo.id, + projectHostSetupId: 'removed-setup', + actionableHostIds: new Set(['local']) + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) + // Regression: selecting a project in the new-workspace dropdown must not be // pinned to the host of the currently-active workspace. Each project below is // set up on exactly one (different) host; picking the other project while the diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index 2a7c776eec6..a4234f7686d 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -40,6 +40,7 @@ type ProjectHostWorkspaceTargetInput = { hostId?: ExecutionHostId | null projectHostSetupId?: string | null focusedHostScope?: ExecutionHostScope | null + actionableHostIds?: ReadonlySet } type ProjectSetupModel = { @@ -120,13 +121,23 @@ export function resolveWorkspaceCreationTarget( const model = getProjectSetupModel(input) const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo])) - const setups = model?.setups ?? [] + const actionableHostIds = input.actionableHostIds + const allSetups = model?.setups ?? [] + const setups = actionableHostIds + ? allSetups.filter((setup) => actionableHostIds.has(setup.hostId)) + : allSetups if (projectHostSetupId) { - const setup = setups.find((entry) => entry.id === projectHostSetupId) + const setup = allSetups.find((entry) => entry.id === projectHostSetupId) if (!setup) { return { status: 'unavailable', reason: 'setup-not-found' } } + if (actionableHostIds && !actionableHostIds.has(setup.hostId)) { + // Why: the caller named this exact setup. Silently creating the workspace on a + // sibling host would put files (and any agent run) somewhere the user never chose, + // so fail closed and let them re-pick a host. + return { status: 'unavailable', reason: 'setup-not-found' } + } if (!isReadySetup(setup)) { return { status: 'unavailable', reason: 'setup-not-ready' } } @@ -192,6 +203,9 @@ export function resolveWorkspaceCreationTarget( if (target) { return { status: 'ready', target } } + // Why: the caller named this host. Falling through to the legacy repo (or any other + // actionable host) would create the workspace somewhere the user never selected. + return { status: 'unavailable', reason: 'project-not-set-up-on-host' } } const repoId = resolveComposerRepoId(input) @@ -200,14 +214,22 @@ export function resolveWorkspaceCreationTarget( return { status: 'unavailable', reason: 'no-eligible-repo' } } + const projectedLegacySetup = projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] const legacySetup = setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ?? - projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] + (!actionableHostIds || actionableHostIds.has(projectedLegacySetup.hostId) + ? projectedLegacySetup + : null) const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null - if (!legacyTarget) { - return { status: 'unavailable', reason: 'setup-not-found' } + if (legacyTarget) { + return { status: 'ready', target: legacyTarget } } - return { status: 'ready', target: legacyTarget } + const fallbackTarget = actionableHostIds + ? findReadySetupTarget(setups, repoById, () => true) + : null + return fallbackTarget + ? { status: 'ready', target: fallbackTarget } + : { status: 'unavailable', reason: 'setup-not-found' } } export function resolveWorkspaceCreationRepoId(input: ProjectHostWorkspaceTargetInput): string { diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 847052d2e79..addf1741fa8 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -294,6 +294,36 @@ describe('running agent send targets', () => { expect(target).not.toHaveProperty('disabledReason') }) + it('does not promote an unconfirmed restored row from its preserved title', () => { + const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [paneKey]: { ...entry(paneKey, 'working'), restoredUnconfirmed: true } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + paneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + it('treats a missing tab title as absent live title evidence', () => { const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) const target = resolveRunningAgentSendTarget( diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index eedc06e36ba..e483b04b4a9 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -71,7 +71,9 @@ export function deriveRunningAgentSendTargets( const liveTitleStatus = ptyId ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) : null - if (decision.hookState === null) { + if (entry.restoredUnconfirmed) { + disabledReason = 'Agent status is stale' + } else if (decision.hookState === null) { if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === null) { diff --git a/src/renderer/src/lib/runtime-workspace-file-route.test.ts b/src/renderer/src/lib/runtime-workspace-file-route.test.ts new file mode 100644 index 00000000000..74f36c81cbb --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + findRuntimeWorkspaceFileRoute, + findWorkspaceFileRoute +} from './runtime-workspace-file-route' + +function state(): AppState { + return { + settings: { activeRuntimeEnvironmentId: 'runtime-a' }, + repos: [], + detectedWorktreesByRepo: {}, + worktreesByRepo: { + repoA: [ + { + id: 'repo-a::/srv/repo-a', + repoId: 'repo-a', + path: '/srv/repo-a', + runtimeOwnerEnvironmentId: 'runtime-a' + } + ], + repoB: [ + { + id: 'repo-b::/srv/repo-b', + repoId: 'repo-b', + path: '/srv/repo-b', + runtimeOwnerEnvironmentId: 'runtime-a' + }, + { + id: 'repo-b::/srv/repo-b/docs', + repoId: 'repo-b', + path: '/srv/repo-b/docs', + runtimeOwnerEnvironmentId: 'runtime-b' + } + ] + }, + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-a', + folderPath: '/srv/notes', + connectionId: null + } + ], + projectGroups: [ + { + id: 'group-a', + connectionId: null, + executionHostId: 'runtime:runtime-a' + } + ] + } as unknown as AppState +} + +describe('findRuntimeWorkspaceFileRoute', () => { + it('routes a file through its sibling worktree on the same runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/src/index.ts')).toEqual( + { + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'src/index.ts', + executionHostId: 'runtime:runtime-a' + } + ) + }) + + it('does not use a more specific workspace owned by another runtime', () => { + expect( + findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/docs/guide.md') + ).toEqual({ + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'docs/guide.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('includes folder workspaces owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes/todo.md')).toEqual({ + worktreeId: 'folder:notes', + relativePath: 'todo.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('rejects paths outside every workspace owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/etc/passwd')).toBeNull() + }) + + it('does not route workspace directories as editor files', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b')).toBeNull() + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes')).toBeNull() + }) + + it('routes direct SSH siblings only on the exact SSH host', () => { + const sshState = state() + sshState.repos = [ + { id: 'repo-a', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-b', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-c', connectionId: 'ssh-2', executionHostId: 'ssh:ssh-2' } + ] as never + sshState.worktreesByRepo = { + repoA: [{ id: 'a', repoId: 'repo-a', path: '/srv/a', hostId: 'ssh:ssh-1' }], + repoB: [{ id: 'b', repoId: 'repo-b', path: '/srv/b', hostId: 'ssh:ssh-1' }], + repoC: [{ id: 'c', repoId: 'repo-c', path: '/srv/b/nested', hostId: 'ssh:ssh-2' }] + } as never + sshState.folderWorkspaces = [] + + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-1', '/srv/b/nested/file.ts')).toEqual({ + worktreeId: 'b', + relativePath: 'nested/file.ts', + executionHostId: 'ssh:ssh-1' + }) + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-2', '/srv/a/file.ts')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/runtime-workspace-file-route.ts b/src/renderer/src/lib/runtime-workspace-file-route.ts new file mode 100644 index 00000000000..af30b70fefd --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.ts @@ -0,0 +1,76 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { findRuntimeWorkspaceFileOwner } from '../../../shared/runtime-workspace-file-owner' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '@/store/types' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' +import { + getExecutionHostIdForWorktree, + getExplicitRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' +import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-route' + +export type RuntimeWorkspaceFileRoute = { + worktreeId: string + relativePath: string + executionHostId: ExecutionHostId +} + +function workspaceMatchesExecutionHost( + state: AppState, + workspaceId: string, + executionHostId: ExecutionHostId +): boolean { + const parsedHost = parseExecutionHostId(executionHostId) + if (parsedHost?.kind === 'runtime') { + return ( + getExplicitRuntimeEnvironmentIdForWorktree(state, workspaceId) === parsedHost.environmentId + ) + } + const explicit = resolveExplicitWorktreeOperationRouteResult(state, workspaceId) + if (explicit.kind === 'resolved') { + return explicit.route.executionHostId === executionHostId + } + return ( + executionHostId === 'local' && getExecutionHostIdForWorktree(state, workspaceId) === 'local' + ) +} + +export function findWorkspaceFileRoute( + state: AppState, + executionHostId: ExecutionHostId, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const roots = getIndexedAllWorktrees(state.worktreesByRepo).flatMap((worktree) => + workspaceMatchesExecutionHost(state, worktree.id, executionHostId) + ? [{ workspaceId: worktree.id, rootPath: worktree.path, executionHostId }] + : [] + ) + for (const workspace of state.folderWorkspaces) { + const workspaceId = folderWorkspaceKey(workspace.id) + if (workspaceMatchesExecutionHost(state, workspaceId, executionHostId)) { + roots.push({ workspaceId, rootPath: workspace.folderPath, executionHostId }) + } + } + + const owner = findRuntimeWorkspaceFileOwner(roots, absolutePath, executionHostId) + return owner && owner.relativePath !== '' + ? { worktreeId: owner.workspaceId, relativePath: owner.relativePath, executionHostId } + : null +} + +export function findRuntimeWorkspaceFileRoute( + state: AppState, + runtimeEnvironmentId: string, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const ownerId = runtimeEnvironmentId.trim() + if (!ownerId) { + return null + } + const executionHostId = toRuntimeExecutionHostId(ownerId) + return findWorkspaceFileRoute(state, executionHostId, absolutePath) +} diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 6daa375c8ed..aeb14f9c73e 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -51,6 +51,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideDefaultBranchWorkspace: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/lib/worker-terminal-takeover-report.test.ts b/src/renderer/src/lib/worker-terminal-takeover-report.test.ts new file mode 100644 index 00000000000..94509465433 --- /dev/null +++ b/src/renderer/src/lib/worker-terminal-takeover-report.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { reportWorkerTerminalUserInput } from './worker-terminal-takeover-report' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) + +describe('reportWorkerTerminalUserInput', () => { + beforeEach(() => { + vi.mocked(callRuntimeRpc).mockReset().mockResolvedValue({ changed: 1 }) + }) + + it('throttles per runtime target so a rebound pane reports to its new owner', () => { + reportWorkerTerminalUserInput('pane-runtime-rebind', null) + reportWorkerTerminalUserInput('pane-runtime-rebind', null) + reportWorkerTerminalUserInput('pane-runtime-rebind', 'runtime-remote') + reportWorkerTerminalUserInput('pane-runtime-rebind', 'runtime-remote') + + expect(callRuntimeRpc).toHaveBeenCalledTimes(2) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'local' }, + 'orchestration.workerTerminalUserInput', + { paneKey: 'pane-runtime-rebind' }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'runtime-remote' }, + 'orchestration.workerTerminalUserInput', + { paneKey: 'pane-runtime-rebind' }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + }) + + it('retries one transient report failure without waiting for more user input', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValueOnce(new Error('runtime reconnecting')) + + reportWorkerTerminalUserInput('pane-transient-failure', 'runtime-reconnecting') + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2), { timeout: 1_000 }) + + expect(callRuntimeRpc).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/worker-terminal-takeover-report.ts b/src/renderer/src/lib/worker-terminal-takeover-report.ts new file mode 100644 index 00000000000..4a761046194 --- /dev/null +++ b/src/renderer/src/lib/worker-terminal-takeover-report.ts @@ -0,0 +1,61 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +// Why: one report per pane per window is enough to flip an owned orchestration resource to +// user_owned, but a pane can host a LATER dispatch, so re-report on continued typing instead +// of latching forever. +const REPORT_INTERVAL_MS = 30_000 +const REPORT_GATE_PRUNE_SIZE = 256 +const REPORT_RETRY_DELAY_MS = 250 + +const lastReportByPaneKey = new Map() + +// Fires only from the real-user-input signal (never xterm auto-replies, programmatic prompt +// delivery, resize, or output) and lands on the terminal-owning runtime, where the takeover is +// recorded durably in the orchestration DB. +export function reportWorkerTerminalUserInput( + paneKey: string, + runtimeEnvironmentId: string | null +): void { + const now = Date.now() + const gateKey = JSON.stringify([runtimeEnvironmentId, paneKey]) + const last = lastReportByPaneKey.get(gateKey) + if (last !== undefined && now - last < REPORT_INTERVAL_MS) { + return + } + if (lastReportByPaneKey.size >= REPORT_GATE_PRUNE_SIZE) { + for (const [key, reportedAt] of lastReportByPaneKey) { + if (now - reportedAt >= REPORT_INTERVAL_MS) { + lastReportByPaneKey.delete(key) + } + } + } + lastReportByPaneKey.set(gateKey, now) + void sendTakeoverReport(paneKey, runtimeEnvironmentId).catch(() => { + if (lastReportByPaneKey.get(gateKey) === now) { + lastReportByPaneKey.delete(gateKey) + } + }) +} + +async function sendTakeoverReport( + paneKey: string, + runtimeEnvironmentId: string | null +): Promise { + const target = + runtimeEnvironmentId !== null + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + const report = () => + callRuntimeRpc( + target, + 'orchestration.workerTerminalUserInput', + { paneKey }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + try { + await report() + } catch { + await new Promise((resolve) => setTimeout(resolve, REPORT_RETRY_DELAY_MS)) + await report() + } +} diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.test.ts b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts new file mode 100644 index 00000000000..10ecd88884c --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRCheckDetail } from '../../../shared/types' +import { loadGitLabJobLogDetails } from './gitlab-job-trace-client' + +const callRuntimeRpc = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) + +const jobTrace = vi.fn() + +const gitLabCheck: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 42 +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + jobTrace.mockReset() + ;(globalThis as { window?: unknown }).window = { api: { gl: { jobTrace } } } +}) + +describe('loadGitLabJobLogDetails', () => { + it('returns null for a check that is not a GitLab job, touching no transport', async () => { + const githubCheck: PRCheckDetail = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: 'https://github.com/acme/orca/runs/1', + checkRunId: 7 + } + + await expect( + loadGitLabJobLogDetails({ repoPath: '/repo', settings: null, check: githubCheck }) + ).resolves.toBeNull() + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('asks main for a bounded excerpt over local IPC', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'ERROR: Job failed: exit code 1' }) + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + + expect(jobTrace).toHaveBeenCalledWith({ + repoPath: '/repo', + repoId: 'repo-1', + jobId: 42, + projectRef: null, + logExcerpt: true + }) + expect(details?.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + }) + + it('targets the MR project so a fork pipeline trace is not looked up in the wrong project', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'boom' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck, + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + + expect(jobTrace).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + ) + }) + + it('does not request a trace for a manual job that cannot have one', async () => { + const manualCheck: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: manualCheck + }) + + // Why: GitLab 404s on a job with no trace, so skip the round trip entirely. + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + // #7732 round 1: a job canceled before it started is `completed`/`cancelled`, so it is + // fetched; main answers with an empty trace and the row must show that, not an error. + it('explains an empty trace for a job canceled before it produced output', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: '' }) + const canceledCheck: PRCheckDetail = { + name: 'unit', + status: 'completed', + conclusion: 'cancelled', + url: null, + gitlabJobId: 5 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: canceledCheck + }) + + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + it('routes to the owning runtime environment and still requests the excerpt', async () => { + callRuntimeRpc.mockResolvedValue({ ok: true, trace: 'remote failure' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: 'env-9' }, + check: gitLabCheck + }) + + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-9' }, + 'gitlab.jobTrace', + // Why: bounding in main keeps the response under the 1 MB transport frame cap. + { repo: 'repo-1', jobId: 42, projectRef: undefined, logExcerpt: true }, + { timeoutMs: 30_000 } + ) + }) + + it('surfaces the GitLab error verbatim so the panel does not claim "no details"', async () => { + jobTrace.mockResolvedValue({ ok: false, error: '403 Forbidden' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('403 Forbidden') + }) + + it('gives up on a local IPC call that never settles, matching the remote timeout', async () => { + vi.useFakeTimers() + try { + jobTrace.mockReturnValue(new Promise(() => {})) + + const pending = loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + const assertion = expect(pending).rejects.toThrow('Timed out loading the GitLab job log.') + await vi.advanceTimersByTimeAsync(30_000) + await assertion + } finally { + vi.useRealTimers() + } + }) + + it('falls back to a translated message when GitLab reports a blank error', async () => { + jobTrace.mockResolvedValue({ ok: false, error: ' ' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('Failed to load the GitLab job log.') + }) +}) diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.ts b/src/renderer/src/runtime/gitlab-job-trace-client.ts new file mode 100644 index 00000000000..929c8581c1f --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.ts @@ -0,0 +1,105 @@ +import { translate } from '@/i18n/i18n' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from '../../../shared/gitlab-job-trace-check-details' +import type { GitLabJobTraceResult, GitLabProjectRef } from '../../../shared/gitlab-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import { getActiveRuntimeTarget } from './runtime-client-target' +import { callRuntimeRpc } from './runtime-rpc-client' + +const JOB_TRACE_TIMEOUT_MS = 30_000 + +/** + * Load a GitLab pipeline job log as provider-neutral check details. + * + * Returns null for checks that are not GitLab jobs so callers can fall through to + * their existing provider path. Throws on a GitLab-reported failure so the caller's + * error handling surfaces the message instead of showing "no details available". + */ +export async function loadGitLabJobLogDetails(args: { + repoPath: string + repoId?: string + settings: Parameters[0] + check: PRCheckDetail + /** Fork/cross-project MRs run their pipeline outside the repo's own project. */ + projectRef?: GitLabProjectRef | null +}): Promise { + const jobId = args.check.gitlabJobId + if (!jobId) { + return null + } + if (!gitLabJobCanHaveTrace(args.check)) { + return gitLabJobTraceToCheckRunDetails(args.check, '', emptyTraceStrings()) + } + const target = getActiveRuntimeTarget(args.settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'gitlab.jobTrace', + { + repo: args.repoId ?? args.repoPath, + jobId, + projectRef: args.projectRef ?? undefined, + logExcerpt: true + }, + { timeoutMs: JOB_TRACE_TIMEOUT_MS } + ) + : await withJobTraceTimeout( + window.api.gl.jobTrace({ + repoPath: args.repoPath, + repoId: args.repoId, + jobId, + projectRef: args.projectRef ?? null, + logExcerpt: true + }) + ) + if (!result?.ok) { + throw new Error( + result?.error?.trim() || + translate( + 'auto.runtime.gitlabJobTraceClient.loadFailed', + 'Failed to load the GitLab job log.' + ) + ) + } + return gitLabJobTraceToCheckRunDetails(args.check, result.trace, emptyTraceStrings()) +} + +/** + * Bound the local IPC call the way `callRuntimeRpc` bounds the remote one. + * + * `glab` runs without a subprocess timeout in main, so an unreachable GitLab host + * would otherwise leave the expanded Checks row spinning forever. + */ +function withJobTraceTimeout(pending: Promise): Promise { + let timer: ReturnType | undefined + return Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + translate( + 'auto.runtime.gitlabJobTraceClient.timedOut', + 'Timed out loading the GitLab job log.' + ) + ) + ) + }, JOB_TRACE_TIMEOUT_MS) + }) + ]).finally(() => { + clearTimeout(timer) + }) +} + +// Called per-request, not at module scope, so the active locale applies. +function emptyTraceStrings(): { emptyTrace: string } { + return { + emptyTrace: translate( + 'auto.runtime.gitlabJobTraceClient.emptyTrace', + 'No log is available for this GitLab job.' + ) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts new file mode 100644 index 00000000000..aac5de9b2b7 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from './sync-runtime-graph' +import type { AppState } from '../store/types' +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types' + +function leafIdFor(index: number): string { + return `${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` +} + +/** Distinct ids per test: the publication memo is module state shared in this file. */ +function makeState(prefix: string, worktreeCount: number, titles: string[] = []): AppState { + const tabsByWorktree: Record = {} + const terminalLayoutsByTabId: Record = {} + for (let i = 0; i < worktreeCount; i++) { + const tabId = `${prefix}-term-${i}` + tabsByWorktree[`repo::/${prefix}-wt-${i}`] = [ + { id: tabId, title: titles[i] ?? `Agent ${i}`, customTitle: null, ptyId: null } + ] + terminalLayoutsByTabId[tabId] = { + root: { type: 'leaf', leafId: leafIdFor(i) }, + activeLeafId: leafIdFor(i), + expandedLeafId: null + } + } + return { + tabsByWorktree, + terminalLayoutsByTabId, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState +} + +function graphOf(call: unknown[] | undefined): RuntimeSyncWindowGraph { + return call?.[0] as RuntimeSyncWindowGraph +} + +function publishedWorktrees(call: unknown[] | undefined): string[] { + return (graphOf(call).mobileSessionTabs ?? []).map((snapshot) => snapshot.worktree).sort() +} + +function withheldWorktrees(call: unknown[] | undefined): string[] { + return [...(graphOf(call).unchangedMobileSessionWorktrees ?? [])].sort() +} + +async function flushSync(): Promise { + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function startSync( + state: AppState, + syncWindowGraph: ReturnType +): { setState: (next: AppState) => void } { + let current = state + vi.useFakeTimers() + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + setRuntimeGraphStoreStateGetter(() => current) + setRuntimeGraphSyncEnabled(true) + return { + setState: (next: AppState) => { + current = next + } + } +} + +describe('mobile session graph payload partition', () => { + it('withholds every unchanged worktree from the republished payload', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('a', 3), syncWindowGraph) + + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[0])).toEqual([]) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(2) + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + }) + + it('publishes only the worktree whose snapshot changed', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('b', 3), syncWindowGraph) + await flushSync() + + setState(makeState('b', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/b-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/b-wt-0', + 'repo::/b-wt-2' + ]) + }) + + it('republishes a changed worktree whose publication threw before it was acknowledged', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('c', 3), syncWindowGraph) + await flushSync() + + // The publication carrying c-wt-1's new snapshot fails in flight. + setState(makeState('c', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + syncWindowGraph.mockRejectedValueOnce(new Error('ipc closed')) + scheduleRuntimeGraphSync() + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/c-wt-1']) + + // Nothing changed since, but main never acknowledged it: resend, don't withhold. + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/c-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/c-wt-0', + 'repo::/c-wt-2' + ]) + }) + + it('resends everything when the first publication throws', async () => { + const syncWindowGraph = vi.fn().mockRejectedValueOnce(new Error('ipc closed')) + syncWindowGraph.mockResolvedValue({}) + startSync(makeState('d', 3), syncWindowGraph) + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toHaveLength(3) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/d-wt-0', + 'repo::/d-wt-1', + 'repo::/d-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + }) + + it('republishes worktrees main reports it dropped after acknowledging them', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('e', 3), syncWindowGraph) + await flushSync() + + syncWindowGraph.mockResolvedValueOnce({ mobileSessionResyncWorktrees: ['repo::/e-wt-2'] }) + scheduleRuntimeGraphSync() + await flushSync() + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toHaveLength(3) + + // The resync reply schedules its own republish; no store change is needed. + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/e-wt-2']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/e-wt-0', + 'repo::/e-wt-1' + ]) + }) + + it('drops a removed worktree from both the payload and the withheld list', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('f', 3), syncWindowGraph) + await flushSync() + + setState(makeState('f', 2)) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/f-wt-0', + 'repo::/f-wt-1' + ]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts index 0cd0793a266..3c285761cf5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' +import { buildMobileSessionTabSnapshots, registerRuntimeTerminalTab } from './sync-runtime-graph' import type { AppState } from '../store/types' // Why: getBrowserTabsByWorktree reads this slice once per worktree inside the build loop, @@ -207,3 +207,175 @@ describe('mobile session publication cost', () => { expect(rebuiltWorktrees).toEqual(['repo::/title-wt-7']) }) }) + +// Distinct ids per test: the snapshot memo is module state shared in this file. +const MOUNTED_WT = 'repo::/mounted-wt-0' +const CONTROL_WT = 'repo::/mounted-wt-1' +const MOUNTED_LEAF_ID = 'aaaaaaaa-1111-4111-8111-111111111111' +const SPLIT_LEAF_ID = 'bbbbbbbb-1111-4111-8111-111111111111' + +function makeMountedState(tabIdPrefix: string): { + state: AppState + mountedTabId: string + titleReads: () => number + resetTitleReads: () => void +} { + let titleReads = 0 + const mountedTabId = `${tabIdPrefix}-term-0` + const state = { + tabsByWorktree: { + [MOUNTED_WT]: [ + { + id: mountedTabId, + customTitle: null, + ptyId: null, + get title() { + titleReads++ + return 'Agent mounted' + } + } + ], + [CONTROL_WT]: [ + { id: `${tabIdPrefix}-term-1`, title: 'Agent control', customTitle: null, ptyId: null } + ] + }, + terminalLayoutsByTabId: { + [mountedTabId]: { + root: { type: 'leaf', leafId: MOUNTED_LEAF_ID }, + activeLeafId: MOUNTED_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [MOUNTED_LEAF_ID]: 'pty-saved-1' } + } + }, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState + return { + state, + mountedTabId, + titleReads: () => titleReads, + resetTitleReads: () => { + titleReads = 0 + } + } +} + +function makeLiveSurface(tabId: string): { + registration: Parameters[0] + setPtyId: (paneId: number, ptyId: string) => void + addPane: (paneId: number, leafId: string) => void +} { + const panes = [{ id: 1, leafId: MOUNTED_LEAF_ID }] + const ptyIdByPaneId = new Map([[1, 'pty-live-1']]) + const manager = { + getPanes: () => panes.map((pane) => ({ ...pane })), + getActivePane: () => panes[0] ?? null, + getLeafId: (paneId: number) => panes.find((pane) => pane.id === paneId)?.leafId ?? null, + getNumericIdForLeaf: (leafId: string) => + panes.find((pane) => pane.leafId === leafId)?.id ?? null + } + const registration = { + tabId, + worktreeId: MOUNTED_WT, + getManager: () => manager, + getContainer: () => null, + getPtyIdForPane: (paneId: number) => ptyIdByPaneId.get(paneId) ?? null + } as unknown as Parameters[0] + return { + registration, + setPtyId: (paneId, ptyId) => { + ptyIdByPaneId.set(paneId, ptyId) + }, + addPane: (paneId, leafId) => { + panes.push({ id: paneId, leafId }) + ptyIdByPaneId.set(paneId, `pty-live-${paneId}`) + } + } +} + +function snapshotsByWorktree( + state: AppState +): Map[number]> { + return new Map( + buildMobileSessionTabSnapshots(state).map((snapshot) => [snapshot.worktree, snapshot]) + ) +} + +describe('mounted terminal surface memoization', () => { + it('reuses a mounted worktree snapshot when its live pane state is unchanged', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-a') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + try { + const before = snapshotsByWorktree(state) + resetTitleReads() + + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBe(0) + expect(after.get(MOUNTED_WT)).toBe(before.get(MOUNTED_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pty rebinding on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-b') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + const before = snapshotsByWorktree(state) + + surface.setPtyId(1, 'pty-live-2') + const after = snapshotsByWorktree(state) + + expect(after.get(MOUNTED_WT)).not.toBe(before.get(MOUNTED_WT)) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([ + expect.objectContaining({ ptyId: 'pty-live-2' }) + ]) + expect(after.get(CONTROL_WT)).toBe(before.get(CONTROL_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pane split on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-c') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + snapshotsByWorktree(state) + + surface.addPane(2, SPLIT_LEAF_ID) + const after = snapshotsByWorktree(state) + + expect( + after.get(MOUNTED_WT)?.tabs.map((tab) => (tab.type === 'terminal' ? tab.leafId : null)) + ).toEqual([MOUNTED_LEAF_ID, SPLIT_LEAF_ID]) + } finally { + unregister() + } + }) + + it('falls back to the persisted pty binding after the surface unmounts', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-d') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + snapshotsByWorktree(state) + + unregister() + resetTitleReads() + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBeGreaterThan(0) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([expect.objectContaining({ ptyId: 'pty-saved-1' })]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 63f578c7dfb..1966bed1df1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -31,6 +31,7 @@ import type { TabGroup, TabGroupLayoutNode, TerminalLayoutSnapshot, + TerminalPaneLayoutNode, TerminalTab } from '../../../shared/types' import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' @@ -92,6 +93,22 @@ type MobileSessionPublicationInputs = { generatedTitlesEnabled: boolean terminalTheme: RuntimeMobileTerminalTheme | undefined } +/** + * Live PaneManager/DOM reads for one mounted terminal tab, captured once per + * publication. + * + * Why: builders read this instead of the registry, so live state the memo + * cannot witness is unrepresentable — mounted worktrees memoize like the rest + * instead of rebuilding on every publication. + */ +type MountedTerminalSurfaceCapture = { + paneLeafIds: readonly string[] + hasLiveActivePane: boolean + liveActiveLeafId: string | null + liveLayoutRoot: TerminalPaneLayoutNode | null + numericPaneIdByLeafId: ReadonlyMap + ptyIdByNumericPaneId: ReadonlyMap +} /** * One worktree's complete mobile-snapshot input set. * @@ -131,8 +148,7 @@ type MobileSessionWorktreeInputs = { activeBrowserWorkspaceId: string | null generatedTitlesEnabled: boolean terminalTheme: RuntimeMobileTerminalTheme | undefined - // Why: a mounted TerminalPane feeds live DOM/PaneManager state that no store reference can witness. - hasMountedTerminalSurface: boolean + mountedSurfaceCaptureByTabId: ReadonlyMap } const registeredTabs = new Map() @@ -217,6 +233,9 @@ const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [ const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` +// Why: the snapshot object main last acknowledged per worktree; anything still +// identical is withheld from the graph payload instead of re-cloned across IPC. +const publishedMobileSessionSnapshotByWorktree = new Map() export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { getStoreState = getter @@ -698,10 +717,13 @@ async function syncRuntimeGraph(): Promise { .map((tab) => [tab.id, tab]) ) const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true + const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark) + const publication = partitionMobileSessionPublication(mobileSessionTabs) const graph: RuntimeSyncWindowGraph = { tabs: [], leaves: [], - mobileSessionTabs: buildMobileSessionTabSnapshots(state, systemPrefersDark) + mobileSessionTabs: publication.changed, + unchangedMobileSessionWorktrees: publication.unchangedWorktrees } for (const [tabId, registeredTab] of registeredTabs) { @@ -811,6 +833,9 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) + // Why: only an acknowledged publication may be treated as delivered. A throw + // leaves the memo behind, so the retry resends every worktree in full. + commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) const currentState = getStoreState() currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { @@ -821,11 +846,53 @@ async function syncRuntimeGraph(): Promise { }) } } + if (result?.mobileSessionResyncWorktrees?.length) { + scheduleRuntimeGraphSync() + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } } +function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { + changed: RuntimeMobileSessionTabsSnapshot[] + unchangedWorktrees: string[] +} { + const changed: RuntimeMobileSessionTabsSnapshot[] = [] + const unchangedWorktrees: string[] = [] + for (const snapshot of snapshots) { + // Why: buildMobileSessionTabSnapshots returns the cached object for a worktree + // it did not rebuild, so identity — not a deep compare — settles this. + if (publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { + unchangedWorktrees.push(snapshot.worktree) + } else { + changed.push(snapshot) + } + } + return { changed, unchangedWorktrees } +} + +function commitMobileSessionPublication( + snapshots: RuntimeMobileSessionTabsSnapshot[], + resyncWorktrees: string[] | undefined +): void { + const published = new Set() + for (const snapshot of snapshots) { + published.add(snapshot.worktree) + publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) + } + for (const worktreeId of publishedMobileSessionSnapshotByWorktree.keys()) { + if (!published.has(worktreeId)) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } + } + // Why: main dropped these after acknowledging them, so forget the delivery and + // let the scheduled resync republish them in full. + for (const worktreeId of resyncWorktrees ?? []) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } +} + function narrowRecordByKeys( source: Record | undefined, keys: readonly string[] @@ -975,11 +1042,99 @@ function buildMobileSessionWorktreeInputs( activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, generatedTitlesEnabled: publication.generatedTitlesEnabled, terminalTheme: publication.terminalTheme, - hasMountedTerminalSurface: terminalTabs.some((tab) => registeredTabs.has(tab.id)) + mountedSurfaceCaptureByTabId: captureMountedTerminalSurfaces( + terminalTabs, + state.terminalLayoutsByTabId + ) } } -function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { +function captureMountedTerminalSurfaces( + terminalTabs: AppState['tabsByWorktree'][string], + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] +): ReadonlyMap { + let captures: Map | null = null + for (const tab of terminalTabs) { + const registered = registeredTabs.get(tab.id) + if (!registered) { + continue + } + captures ??= new Map() + captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) + } + return captures ?? EMPTY_NARROWED_BY_KEY +} + +function captureMountedTerminalSurface( + registered: RegisteredTerminalTab, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): MountedTerminalSurfaceCapture { + const manager = registered.getManager() + const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] + const activePane = manager?.getActivePane() ?? null + const firstChild = registered.getContainer()?.firstElementChild + // Why: mirrors getRuntimeLeafIdsForTerminal so every leaf the builder resolves has a captured pane id. + const effectiveLeafIds = + paneLeafIds.length > 0 + ? paneLeafIds + : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const numericPaneIdByLeafId = new Map() + const ptyIdByNumericPaneId = new Map() + for (const leafId of effectiveLeafIds) { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + numericPaneIdByLeafId.set(leafId, numericPaneId) + if (numericPaneId !== null) { + ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) + } + } + return { + paneLeafIds, + hasLiveActivePane: activePane !== null, + liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, + liveLayoutRoot: serializePaneTree( + typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null + ), + numericPaneIdByLeafId, + ptyIdByNumericPaneId + } +} + +function mountedTerminalSurfaceCaptureEquals( + a: MountedTerminalSurfaceCapture, + b: MountedTerminalSurfaceCapture +): boolean { + return ( + a.hasLiveActivePane === b.hasLiveActivePane && + a.liveActiveLeafId === b.liveActiveLeafId && + a.paneLeafIds.length === b.paneLeafIds.length && + a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && + narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && + narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && + // Why: serialization allocates a fresh tree per capture, so it compares by content. + jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) + ) +} + +function mountedSurfaceCapturesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [tabId, capture] of a) { + const other = b.get(tabId) + if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { + return false + } + } + return true +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { if (a === b) { return true } @@ -1005,9 +1160,6 @@ function canReuseMobileSessionSnapshot( next: MobileSessionWorktreeInputs ): boolean { return ( - // Why: live DOM/PaneManager state is invisible to store references, so a mounted surface always rebuilds. - !previous.hasMountedTerminalSurface && - !next.hasMountedTerminalSurface && previous.worktreeId === next.worktreeId && previous.terminalTabs === next.terminalTabs && previous.browserWorkspaces === next.browserWorkspaces && @@ -1033,6 +1185,12 @@ function canReuseMobileSessionSnapshot( narrowedEntriesEqual( previous.certificateFailureByBrowserPageId, next.certificateFailureByBrowserPageId + ) && + // Why: live DOM/PaneManager state is invisible to store references; the + // capture makes it comparable instead of forcing mounted worktrees to rebuild. + mountedSurfaceCapturesEqual( + previous.mountedSurfaceCaptureByTabId, + next.mountedSurfaceCaptureByTabId ) ) } @@ -1632,12 +1790,10 @@ function resolveMobileTerminalTheme( } function getRuntimeLeafIdsForTerminal( - tabId: string, + capture: MountedTerminalSurfaceCapture | undefined, savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): string[] { - const registered = registeredTabs.get(tabId) - const manager = registered?.getManager() - const liveLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] +): readonly string[] { + const liveLeafIds = capture?.paneLeafIds ?? [] if (liveLeafIds.length > 0) { return liveLeafIds } @@ -1656,18 +1812,15 @@ function buildMobileTerminalSurfaceTabs( terminal: NonNullable[number], unifiedTabId?: string ): RuntimeMobileSessionSnapshotTab[] { - const registered = registeredTabs.get(terminal.id) + const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) const isDesktopTabActive = unifiedTabId ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) : inputs.activeTerminalTabId === terminal.id - const manager = registered?.getManager() - const liveActivePaneId = manager?.getActivePane()?.id ?? null const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, savedLayout) - const activeLeafId = - liveActivePaneId !== null - ? (manager?.getLeafId(liveActivePaneId) ?? null) - : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const activeLeafId = capture?.hasLiveActivePane + ? capture.liveActiveLeafId + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} const generatedTitlesEnabled = inputs.generatedTitlesEnabled const sanitizedSavedLayout = savedLayout @@ -1685,11 +1838,7 @@ function buildMobileTerminalSurfaceTabs( ? seededLaunchDraft : null const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null - const container = registered?.getContainer() - const firstChild = container?.firstElementChild - const liveLayoutRoot = serializePaneTree( - typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null - ) + const liveLayoutRoot = capture?.liveLayoutRoot ?? null const parentLayout = normalizeTerminalLayoutSnapshot({ // Why: live DOM tree is authoritative when mounted, else the saved tree; synthesize only as a last resort, never re-guess. root: resolveTerminalLayoutRoot({ @@ -1709,11 +1858,11 @@ function buildMobileTerminalSurfaceTabs( : {}) } satisfies TerminalLayoutSnapshot).snapshot return leafIds.map((leafId) => { - const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null const ptyId = numericPaneId === null ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) - : (registered?.getPtyIdForPane(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) + : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null const paneTitle = numericPaneId !== null diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index e406d8644f0..01836b7955a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1896,6 +1896,61 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe(1) }) + it('applies a marker-only host restart degradation to mirrored agent status', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const snapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'codex [working]', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'fix web parity', + updatedAt: NOW - 100, + stateStartedAt: NOW - 1_000, + agentType: 'codex', + paneKey: hostPaneKey, + tabId: 'host-tab-1', + worktreeId: WT, + stateHistory: [] + } + } + ]) + const initial = applyWebSessionTabsSnapshot( + makeState(), + snapshot, + ENV, + NOW + ) as Partial + const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]! + const degraded = applyWebSessionTabsSnapshot( + makeState({ ...initial }), + { + ...snapshot, + snapshotVersion: 2, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.agentStatus + ? { + ...tab, + agentStatus: { ...tab.agentStatus, restoredUnconfirmed: true } + } + : tab + ) + }, + ENV, + NOW + ) as Partial + + expect(degraded.agentStatusByPaneKey?.[mirroredPaneKey]?.restoredUnconfirmed).toBe(true) + expect(degraded.agentStatusEpoch).toBe(2) + expect(degraded.sortEpoch).toBe(2) + }) + it('repairs mirrored same-state attribution and retains identity from an older snapshot', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const snapshot = makeSnapshot([ diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 0b8b3908675..d944272accb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -801,10 +801,13 @@ function buildMirroredAgentStatusPatch( changed = true const entryAttributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const entryFreshnessChanged = + !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) const entrySortRelevantChange = !existing || existing.state !== entry.state || !isAgentStatusFresh(existing, now) || + entryFreshnessChanged || entryAttributionChanged || isMirroredCommandCodeTurnBump(existing, entry) aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange @@ -1395,13 +1398,17 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && a.promptInteractionKey === b.promptInteractionKey && + a.restoredUnconfirmed === b.restoredUnconfirmed && agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } -function isAgentStatusFresh(entry: Pick, now: number): boolean { - return now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS +function isAgentStatusFresh( + entry: Pick, + now: number +): boolean { + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS } function isMirroredCommandCodeTurnBump( diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts index 9603abd60f1..75a7db4fcc8 100644 --- a/src/renderer/src/store/pinned-tab-close-guard.ts +++ b/src/renderer/src/store/pinned-tab-close-guard.ts @@ -23,6 +23,13 @@ export function isUnifiedTabPinned(state: AppState, worktreeId: string, tabId: s ) } +/** Whether a pinned close will actually raise the pin dialog. Callers that let the pin + * prompt supersede another confirmation must know this: with the setting off the pin + * has nothing to say, so it must not swallow the other prompt (#10142). */ +export function shouldConfirmPinnedTabClose(state: AppState): boolean { + return state.settings?.confirmClosePinnedTab ?? true +} + /** Routes a pinned-tab close attempt through the confirmation dialog when the * setting is on. Non-pinned tabs (and pinned tabs when the setting is off) * close immediately. Keeping every close path behind this single helper is why @@ -40,8 +47,7 @@ export function guardPinnedTabClose(params: { } const state = useAppStore.getState() - const shouldConfirm = state.settings?.confirmClosePinnedTab ?? true - if (!shouldConfirm) { + if (!shouldConfirmPinnedTabClose(state)) { onClose() return } diff --git a/src/renderer/src/store/running-terminal-close-confirm.test.ts b/src/renderer/src/store/running-terminal-close-confirm.test.ts new file mode 100644 index 00000000000..b597923a148 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRunningTerminalCloseConfirmStore } from './running-terminal-close-confirm' + +// The store rejects a second action within 350 ms of revealing a queued request, so these +// tests drive a clock rather than letting wall time decide whether an action lands. +let clock = 1_000 + +function advancePastGuard(): void { + clock += 400 +} + +function drainRequests(): void { + const store = useRunningTerminalCloseConfirmStore.getState() + while (useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm) { + advancePastGuard() + store.dismissRunningTerminalClose() + } +} + +function request(terminalTabId: string, onConfirm: () => void = vi.fn(), onCancel?: () => void) { + return { + terminalTabId, + tabLabel: terminalTabId, + copyKind: 'command' as const, + onConfirm, + ...(onCancel ? { onCancel } : {}) + } +} + +describe('running terminal close confirmation store', () => { + beforeEach(() => { + // Monotonic across tests: the store is a singleton, so winding the clock back would + // leave a previous test's guard deadline in the future and block every action. + clock += 10_000 + vi.spyOn(Date, 'now').mockImplementation(() => clock) + }) + + afterEach(() => { + drainRequests() + vi.mocked(Date.now).mockRestore() + }) + + it('shows the first request and queues the next one', () => { + const first = vi.fn() + const second = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-1') + + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + it('shows one prompt for a repeat request but still resolves both closes', () => { + const first = vi.fn() + const duplicate = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-1', duplicate)) + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('cancels both callers when a folded repeat request is dismissed', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const firstCancel = vi.fn() + const duplicateCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), firstCancel)) + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), duplicateCancel)) + store.dismissRunningTerminalClose() + + expect(firstCancel).toHaveBeenCalledTimes(1) + expect(duplicateCancel).toHaveBeenCalledTimes(1) + }) + + it('folds a repeat request into the one already waiting in the queue', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const queued = vi.fn() + const duplicate = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.requestRunningTerminalCloseConfirm(request('tab-2', duplicate)) + + store.confirmRunningTerminalClose() + advancePastGuard() + store.confirmRunningTerminalClose() + + expect(queued).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('confirms every pending request once the user opts out of the prompt', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const visible = vi.fn() + const queued = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', visible)) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.confirmAllRunningTerminalCloses() + + expect(visible).toHaveBeenCalledTimes(1) + expect(queued).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('runs onCancel on dismiss and never the close', () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', onConfirm, onCancel)) + store.dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('is inert when there is nothing to confirm', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + + expect(() => store.confirmRunningTerminalClose()).not.toThrow() + expect(() => store.dismissRunningTerminalClose()).not.toThrow() + }) + + it('lets a re-entrant close from onConfirm queue behind the next request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const reentrant = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm( + request('tab-1', () => { + // Why: the real onConfirm re-enters closeTerminalTab, which may request again. + store.requestRunningTerminalCloseConfirm(request('tab-3', reentrant)) + }) + ) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + store.confirmRunningTerminalClose() + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-3') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(reentrant).toHaveBeenCalledTimes(1) + }) + + // Why: a queued request replaces the visible one in place. Without this window, the second + // click of a double-click aimed at one tab lands on the next tab's prompt and kills a + // running process the user never saw asked about — the bug class this PR exists to close. + it('ignores a second action that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const first = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + store.confirmRunningTerminalClose() + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('also holds off a dismiss that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const secondCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', vi.fn(), secondCancel)) + + store.confirmRunningTerminalClose() + store.dismissRunningTerminalClose() + + expect(secondCancel).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + // Why: emptying the queue leaves nothing to mis-click, so the opt-out must not be delayed. + it('arms no guard when the last request leaves the queue empty', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const onConfirm = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.confirmRunningTerminalClose() + store.requestRunningTerminalCloseConfirm(request('tab-2', onConfirm)) + store.confirmRunningTerminalClose() + + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/running-terminal-close-confirm.ts b/src/renderer/src/store/running-terminal-close-confirm.ts new file mode 100644 index 00000000000..618d30c98c5 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.ts @@ -0,0 +1,132 @@ +import { create } from 'zustand' +import type { CloseTerminalDialogCopyKind } from '@/components/terminal-pane/CloseTerminalDialog' + +/** A pending confirmation for closing a terminal tab whose shell still has a + * running child process. `onConfirm` performs the original close. */ +export type RunningTerminalCloseConfirmRequest = { + terminalTabId: string + tabLabel: string + copyKind: CloseTerminalDialogCopyKind + onConfirm: () => void + onCancel?: () => void +} + +export type RunningTerminalCloseConfirmState = { + runningTerminalCloseConfirm: RunningTerminalCloseConfirmRequest | null + requestRunningTerminalCloseConfirm: (request: RunningTerminalCloseConfirmRequest) => void + confirmRunningTerminalClose: () => void + /** Accepts the visible request and every queued one. Used when the user ticks "don't ask + * again": a prompt they just opted out of must not still be waiting behind this one. */ + confirmAllRunningTerminalCloses: () => void + dismissRunningTerminalClose: () => void +} + +/** Folds a duplicate request for the same tab into the pending one instead of dropping it, + * so two surfaces closing the same tab both get their callback. */ +function mergeRequests( + pending: RunningTerminalCloseConfirmRequest, + duplicate: RunningTerminalCloseConfirmRequest +): RunningTerminalCloseConfirmRequest { + return { + ...pending, + onConfirm: () => { + pending.onConfirm() + duplicate.onConfirm() + }, + onCancel: () => { + pending.onCancel?.() + duplicate.onCancel?.() + } + } +} + +// Why a standalone store instead of an AppState slice (which is what the sibling +// pinned-tab confirmation uses): the request is raised from closeTerminalTab, a plain +// module whose unit fixtures build partial app-state objects, so dispatching through +// useAppStore.getState() would throw there. Nothing outside the dialog reads this state, +// so the AppState coupling would buy nothing. +export const useRunningTerminalCloseConfirmStore = create()(( + set, + get +) => { + const queuedRequests: RunningTerminalCloseConfirmRequest[] = [] + // Why: a queued request replaces the visible one in place, so a double-click or held + // Enter meant for the tab the user was looking at would land on the next tab's prompt and + // kill a second running process unseen. Matches the sibling pinned-tab confirmation. + const INTER_REQUEST_ACTION_GUARD_MS = 350 + let nextRequestActionAllowedAt = 0 + + /** Reveals the next queued request, and reports whether one took the visible slot. */ + const advanceRequest = (): boolean => { + const next = queuedRequests.shift() ?? null + set({ runningTerminalCloseConfirm: next }) + return next !== null + } + + const guardNextAction = (revealedNextRequest: boolean): void => { + if (revealedNextRequest) { + nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS + } + } + + return { + runningTerminalCloseConfirm: null, + + requestRunningTerminalCloseConfirm: (request) => { + const visible = get().runningTerminalCloseConfirm + // Why: the probe is async, so a second click on the same tab arrives before the + // dialog opens. One prompt, but both closes still resolve. + if (visible?.terminalTabId === request.terminalTabId) { + set({ runningTerminalCloseConfirm: mergeRequests(visible, request) }) + return + } + const queuedIndex = queuedRequests.findIndex( + (queued) => queued.terminalTabId === request.terminalTabId + ) + if (queuedIndex >= 0) { + queuedRequests[queuedIndex] = mergeRequests(queuedRequests[queuedIndex]!, request) + return + } + if (visible) { + // Why: closing two busy tabs in quick succession must not strand the second + // tab's close callback behind a replaced request. + queuedRequests.push(request) + return + } + set({ runningTerminalCloseConfirm: request }) + }, + + confirmRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + // Why: advance before running onConfirm so a re-entrant close queues behind the + // next real request instead of seeing the stale one. + guardNextAction(advanceRequest()) + request.onConfirm() + }, + + confirmAllRunningTerminalCloses: () => { + if (Date.now() < nextRequestActionAllowedAt) { + return + } + const pending = [get().runningTerminalCloseConfirm, ...queuedRequests.splice(0)] + set({ runningTerminalCloseConfirm: null }) + // No guard to arm: the queue is empty, so there is no next prompt to mis-click. + for (const request of pending) { + request?.onConfirm() + } + }, + + dismissRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + guardNextAction(advanceRequest()) + // Why: callers such as the tab-group model resume their own cleanup on cancel. + request.onCancel?.() + } + } +}) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 6954fcc7034..b6b2976e26b 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -265,6 +265,42 @@ describe('agent status runtime orchestration metadata', () => { }) }) + it('clears stale lineage when the authoritative runtime snapshot loses its Run binding', () => { + vi.useFakeTimers() + const store = createTestStore() + const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111' + + store.getState().setAgentStatus(childPaneKey, { + state: 'working', + prompt: 'child agent', + agentType: 'codex', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + parentTerminalHandle: 'term-old-coordinator', + parentPaneKey: 'tab-parent:22222222-2222-4222-8222-222222222222', + coordinatorHandle: 'term-old-coordinator', + orchestrationRunId: 'run-1' + } + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [childPaneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + } + }) + + expect(store.getState().agentStatusByPaneKey[childPaneKey].orchestration).toEqual({ + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + }) + }) + it('updates runtime status for the same dispatch', () => { vi.useFakeTimers() const store = createTestStore() @@ -603,6 +639,38 @@ describe('agent status tool + assistant fields', () => { expect(setGeneratedTabTitleFromAgentPrompt).toHaveBeenLastCalledWith('tab-1:1', 'parent codex') }) + it('does not let restored-unconfirmed identity suppress a live terminal status', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'stale codex turn', + agentType: 'codex', + restoredUnconfirmed: true + }, + 'codex', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'done', prompt: 'live claude turn', agentType: 'claude' }, + 'claude', + { updatedAt: 1_100, stateStartedAt: 1_100 } + ) + + expect(store.getState().agentStatusByPaneKey['tab-1:1']).toMatchObject({ + state: 'done', + prompt: 'live claude turn', + agentType: 'claude' + }) + expect(store.getState().agentStatusByPaneKey['tab-1:1'].restoredUnconfirmed).toBeUndefined() + }) + it('allows pane agentType to change after the prior turn is done', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index dc2fde9f603..921cc58d02a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -156,6 +156,7 @@ export type AgentStatusSlice = { payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string + restoredUnconfirmed?: boolean }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number }, @@ -1124,6 +1125,9 @@ function mergeCurrentOrchestrationContext( if (!sameDispatch) { return current } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } const merged = { ...existing, ...current } return orchestrationContextsEqual(existing, merged) ? existing : merged } @@ -1712,7 +1716,8 @@ export const createAgentStatusSlice: StateCreator ({ notifyHostOfMirroredEditorClose: (...args: unknown[]) => notifyHostOfMirroredEditorCloseMock(...args) })) +const loadGitLabJobLogDetailsMock = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/gitlab-job-trace-client', () => ({ + loadGitLabJobLogDetails: loadGitLabJobLogDetailsMock +})) function createEditorStore(): StoreApi { // Only the editor slice + activeWorktreeId are needed for these tests. @@ -1712,6 +1716,49 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles[0]?.id).toBe(firstId) expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) }) + + it('reactivates the live tab when a stale reopen id targets an already-open file', () => { + const store = createEditorTabsStore() + store.setState({ + worktreesByRepo: { + 'repo-1': [ + { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + { id: 'wt-2', repoId: 'repo-1', path: '/repo-2' } + ] + } + } as unknown as Partial) + const sharedPath = '/home/me/.zshrc' + const openShared = (worktreeId: string): string => + store.getState().openFile({ + filePath: sharedPath, + relativePath: '.zshrc', + worktreeId, + language: 'shell', + mode: 'edit' + }) + + // Bare path id: nothing else owns this path yet. + const staleWt1Id = openShared('wt-1') + expect(staleWt1Id).toBe(sharedPath) + store.getState().closeFile(staleWt1Id) + + // wt-2 claims the bare id, so wt-1's reopen gets a namespaced id instead. + const wt2Id = openShared('wt-2') + expect(wt2Id).toBe(sharedPath) + const liveWt1Id = openShared('wt-1') + expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null)) + store.getState().closeFile(wt2Id) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles.map((file) => file.id)).toEqual([liveWt1Id]) + expect(store.getState().activeFileId).toBe(liveWt1Id) + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(liveWt1Id) + expect( + (store.getState().unifiedTabsByWorktree['wt-1'] ?? []).map((tab) => tab.entityId) + ).toEqual([liveWt1Id]) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([liveWt1Id]) + }) }) describe('createEditorSlice markdown view state', () => { @@ -2998,6 +3045,151 @@ describe('createEditorSlice conflict status reconciliation', () => { ) }) + // Regression for #7732: refreshing a GitLab job tab through the GitHub check-runs + // API returns null and blanks the tab the user just asked to reload. + it('reloads an open GitLab job tab through the job trace client', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue({ + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + annotations: [], + jobs: [ + { + id: 42, + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + logTail: 'ERROR: Job failed: exit code 1', + steps: [] + } + ] + }) + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails, + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 42 + } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:42') + + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/repo', repoId: 'repo-1', check }) + ) + expect(store.getState().openFiles).toContainEqual( + expect.objectContaining({ + id: 'wt-1::check-details::gitlab-job:42', + checkRunDetails: expect.objectContaining({ + loading: false, + error: null, + details: expect.objectContaining({ + jobs: [expect.objectContaining({ logTail: 'ERROR: Job failed: exit code 1' })] + }) + }) + }) + ) + }) + + // Regression for #7732: a fork MR's job lives in the source project, so reloading + // without the stored project ref requests the trace from the wrong project. + it('reloads a fork MR job tab with the stored GitLab project ref', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails: vi.fn().mockResolvedValue(null), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:77') + + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ check, projectRef }) + ) + }) + + it('keeps a stored GitLab project ref when a patch omits it', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: true, + error: null, + gitlabProjectRef: null + }) + + expect( + store.getState().openFiles.find((file) => file.id === 'wt-1::check-details::gitlab-job:77') + ?.checkRunDetails?.gitlabProjectRef + ).toEqual(projectRef) + }) + it('patches an open check-details tab without changing the active file', () => { const store = createEditorTabsStore() const check = { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c3c2d630710..be768183cac 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -14,6 +14,8 @@ import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal import { buildCheckRunDetailsTabId, getCheckRunDetailsTabLabel, + isSameGitLabProjectRef, + type CheckRunDetailsTabPatch, type OpenCheckRunDetailsState } from '@/components/editor/check-run-details-tab' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' @@ -49,6 +51,7 @@ import { COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH } from '../../../../shared/combined-diff-file-tree-width' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache' import { @@ -72,8 +75,16 @@ import { } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + findWorktreeById, + getRepoIdFromWorktreeId, + type ActiveWorktreeStateTransition +} from './worktree-helpers' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getSettingsForWorktreeRuntimeOwner +} from '@/lib/worktree-runtime-owner' +import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -89,6 +100,9 @@ import { captureEditorFileOperationProvenance, getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { pruneTabGroupLayoutForGroups } from './tabs-hydration' +import { sanitizeRecentTabIds } from './tab-group-state' export type { ActiveRightSidebarTab, @@ -257,6 +271,8 @@ export type OpenFile = { pendingDiskBaselineVerification?: boolean /** Why: gates autosave during a live self-move echo's disk verification; separate flag from the restored scan's so the two can't clear each other's gate. Not persisted. */ pendingLiveDiskVerification?: boolean + /** Blocks saves while a restored tab's filesystem authority is being replaced. */ + pendingOwnerMigration?: boolean /** Why: routes an Orca-owned move's destination-watcher echo into content verification. On the tab so it survives the atomic rekey; operationId supersedes a stale verification on re-move. Not persisted. */ pendingSelfMoveEcho?: { operationId: string; targetPath: string } /** Why: diff bodies are cached in EditorPanel; bump this on re-select so the panel refetches instead of reusing a stale snapshot. */ @@ -521,6 +537,8 @@ export type EditorSlice = { /** When set, dirty autosave-capable destinations get move-echo provenance + a synchronous autosave gate so the watcher can content-verify the echo. */ moveOperationId?: string }) => RekeyOpenFilesResult + setRestoredEditorOwnerMigrationPending: (fileId: string, pending: boolean) => boolean + reparentRestoredEditorFileOwner: (args: RestoredEditorOwnerMigration) => RestoredEditorOwnerResult clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -577,13 +595,13 @@ export type EditorSlice = { worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void patchOpenCheckRunDetails: ( worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void reloadOpenCheckRunDetailsTab: (fileId: string) => Promise openBranchAllDiffs: ( @@ -1306,6 +1324,19 @@ export type OpenFilePathRekey = { export type RekeyOpenFilesResult = { ok: true } | { ok: false; reason: 'collision' | 'stale' } +export type RestoredEditorOwnerMigration = { + fileId: string + targetWorktreeId: string + targetRelativePath: string + targetExecutionHostId: ExecutionHostId + targetRuntimeEnvironmentId: string | null + targetOperationProvenance: EditorFileOperationProvenance +} + +export type RestoredEditorOwnerResult = + | { ok: true; fileId: string } + | { ok: false; reason: 'collision' | 'owner-changed' | 'stale' } + function rekeyFileIdRecord( record: Record, migrations: ReadonlyMap @@ -1324,6 +1355,41 @@ function rekeyFileIdRecord( return changed ? next : record } +function nextActiveIdAfterRemoval( + ids: readonly string[], + recentIds: readonly string[] | undefined, + removedIds: ReadonlySet +): string | null { + const recent = (recentIds ?? []).toReversed().find((id) => !removedIds.has(id)) + return recent ?? ids.find((id) => !removedIds.has(id)) ?? null +} + +function removeEmptyEditorGroups( + previousGroups: TabGroup[], + groups: TabGroup[], + movedTabIds: ReadonlySet, + layout: AppState['layoutByWorktree'][string] | undefined +): { groups: TabGroup[]; layout: AppState['layoutByWorktree'][string] | undefined } { + const emptiedGroupIds = new Set( + previousGroups + .filter( + (group) => + group.tabOrder.some((id) => movedTabIds.has(id)) && + groups.find((candidate) => candidate.id === group.id)?.tabOrder.length === 0 + ) + .map((group) => group.id) + ) + const remaining = groups.filter((group) => !emptiedGroupIds.has(group.id)) + if (remaining.length === 0) { + return { groups: [], layout: undefined } + } + const validIds = new Set(remaining.map((group) => group.id)) + return { + groups: remaining, + layout: layout ? (pruneTabGroupLayoutForGroups(layout, validIds) ?? undefined) : undefined + } +} + function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() @@ -1677,8 +1743,10 @@ export const createEditorSlice: StateCreator = (s matchesEditorMode(f, reusableOpenFileModes) && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) ) - const id = - options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) + // Why: a snapshot's reopenId can be a stale shape — the same path is bare in whichever worktree opened it first and namespaced elsewhere — so honoring it while this owner's tab is already open would strand activeFileId and the unified tab on an id no OpenFile has. + const id = existing + ? existing.id + : options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) ? options.reopenId : resolveEditorFileIdForOwner( s, @@ -2635,6 +2703,327 @@ export const createEditorSlice: StateCreator = (s } }), + setRestoredEditorOwnerMigrationPending: (fileId, pending) => { + let changed = false + set((s) => { + const file = s.openFiles.find((candidate) => candidate.id === fileId) + const next = pending || undefined + if (!file || file.pendingOwnerMigration === next) { + return s + } + changed = true + return { + openFiles: s.openFiles.map((candidate) => + candidate.id === fileId ? { ...candidate, pendingOwnerMigration: next } : candidate + ) + } + }) + return changed + }, + + reparentRestoredEditorFileOwner: (args) => { + let result: RestoredEditorOwnerResult = { ok: false, reason: 'stale' } + const projectReparent: ActiveWorktreeStateTransition = (s) => { + const source = s.openFiles.find((file) => file.id === args.fileId) + if (!source) { + return { patch: {}, activate: false } + } + + try { + const currentRoute = assertEditorFileOperationCurrent( + s, + args.targetWorktreeId, + args.targetOperationProvenance + ) + if ( + currentRoute.executionHostId !== args.targetExecutionHostId || + currentRoute.runtimeEnvironmentId !== args.targetRuntimeEnvironmentId + ) { + throw new Error('owner changed') + } + } catch { + result = { ok: false, reason: 'owner-changed' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + const operationProvenance = args.targetOperationProvenance + + const destinationCollision = s.openFiles.some( + (file) => + file.id !== source.id && + file.filePath === source.filePath && + file.worktreeId === args.targetWorktreeId && + (file.runtimeEnvironmentId?.trim() || null) === args.targetRuntimeEnvironmentId + ) + const newFileId = buildOwnedEditorFileId( + source.filePath, + args.targetWorktreeId, + args.targetRuntimeEnvironmentId + ) + const dependentPreviews = s.openFiles.filter( + (file) => file.markdownPreviewSourceFileId === source.id + ) + const previewIdMigrations = new Map( + dependentPreviews.map((preview) => [preview.id, `markdown-preview::${newFileId}`]) + ) + const destinationIds = new Set([newFileId, ...previewIdMigrations.values()]) + if ( + destinationCollision || + s.openFiles.some( + (file) => + destinationIds.has(file.id) && + file.id !== source.id && + !previewIdMigrations.has(file.id) + ) + ) { + result = { ok: false, reason: 'collision' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + + const parsedHost = parseExecutionHostId(args.targetExecutionHostId) + const externalSshTargetId = + parsedHost?.kind === 'ssh' && args.targetRuntimeEnvironmentId === null + ? parsedHost.targetId + : undefined + const migrations = new Map([[source.id, newFileId], ...previewIdMigrations]) + const movedFileIds = new Set(migrations.keys()) + const sourceWorktreeId = source.worktreeId + const targetWorktreeId = args.targetWorktreeId + const movedTabs = (s.unifiedTabsByWorktree[sourceWorktreeId] ?? []).filter((tab) => + movedFileIds.has(tab.entityId) + ) + const movedTabIds = new Set(movedTabs.map((tab) => tab.id)) + const tabIdMigration = new Map( + movedTabs.map((tab) => [tab.id, migrations.get(tab.id) ?? tab.id]) + ) + const mappedMovedTabIds = movedTabs.map((tab) => tabIdMigration.get(tab.id) ?? tab.id) + const mappedMovedTabBarIds = movedTabs.map( + (tab) => migrations.get(tab.entityId) ?? tab.entityId + ) + const targetGroups = s.groupsByWorktree[targetWorktreeId] ?? [] + const targetGroupId = + s.activeGroupIdByWorktree[targetWorktreeId] ?? targetGroups[0]?.id ?? createBrowserUuid() + const targetGroup = targetGroups.find((group) => group.id === targetGroupId) ?? { + id: targetGroupId, + worktreeId: targetWorktreeId, + activeTabId: null, + tabOrder: [] + } + + const previousSourceGroups = s.groupsByWorktree[sourceWorktreeId] ?? [] + const updatedSourceGroups = previousSourceGroups.map((group) => { + const tabOrder = group.tabOrder.filter((id) => !movedTabIds.has(id)) + const activeTabId = + group.activeTabId && movedTabIds.has(group.activeTabId) + ? nextActiveIdAfterRemoval(group.tabOrder, group.recentTabIds, movedTabIds) + : group.activeTabId + return { + ...group, + activeTabId, + tabOrder, + recentTabIds: sanitizeRecentTabIds( + (group.recentTabIds ?? []).filter((id) => !movedTabIds.has(id)), + tabOrder + ) + } + }) + const sourceGroupState = removeEmptyEditorGroups( + previousSourceGroups, + updatedSourceGroups, + movedTabIds, + s.layoutByWorktree[sourceWorktreeId] + ) + const destinationOrder = [ + ...targetGroup.tabOrder.filter((id) => !mappedMovedTabIds.includes(id)), + ...mappedMovedTabIds + ] + const updatedTargetGroup: TabGroup = { + ...targetGroup, + activeTabId: mappedMovedTabIds.at(-1) ?? targetGroup.activeTabId, + tabOrder: destinationOrder, + recentTabIds: sanitizeRecentTabIds( + [...(targetGroup.recentTabIds ?? []), ...mappedMovedTabIds], + destinationOrder + ) + } + const nextTargetGroups = targetGroups.some((group) => group.id === targetGroupId) + ? targetGroups.map((group) => (group.id === targetGroupId ? updatedTargetGroup : group)) + : [...targetGroups, updatedTargetGroup] + + const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } + nextUnifiedTabsByWorktree[sourceWorktreeId] = ( + nextUnifiedTabsByWorktree[sourceWorktreeId] ?? [] + ).filter((tab) => !movedTabIds.has(tab.id)) + nextUnifiedTabsByWorktree[targetWorktreeId] = [ + ...(nextUnifiedTabsByWorktree[targetWorktreeId] ?? []), + ...movedTabs.map((tab) => ({ + ...tab, + id: tabIdMigration.get(tab.id) ?? tab.id, + entityId: migrations.get(tab.entityId) ?? tab.entityId, + groupId: targetGroupId + })) + ] + + const nextGroupsByWorktree = { + ...s.groupsByWorktree, + [sourceWorktreeId]: sourceGroupState.groups, + [targetWorktreeId]: nextTargetGroups + } + const nextLayoutByWorktree = { ...s.layoutByWorktree } + if (sourceGroupState.layout) { + nextLayoutByWorktree[sourceWorktreeId] = sourceGroupState.layout + } else { + delete nextLayoutByWorktree[sourceWorktreeId] + } + if (targetGroups.length === 0 || !nextLayoutByWorktree[targetWorktreeId]) { + nextLayoutByWorktree[targetWorktreeId] = { type: 'leaf', groupId: targetGroupId } + } + + const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } + const sourceActiveFileId = nextActiveFileIdByWorktree[sourceWorktreeId] + if (sourceActiveFileId && movedFileIds.has(sourceActiveFileId)) { + nextActiveFileIdByWorktree[sourceWorktreeId] = + s.openFiles.find( + (file) => !movedFileIds.has(file.id) && file.worktreeId === sourceWorktreeId + )?.id ?? null + } + nextActiveFileIdByWorktree[targetWorktreeId] = newFileId + const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + nextTabBarOrderByWorktree[sourceWorktreeId] = ( + nextTabBarOrderByWorktree[sourceWorktreeId] ?? [] + ).filter((id) => !movedFileIds.has(id) && !movedTabIds.has(id)) + nextTabBarOrderByWorktree[targetWorktreeId] = [ + ...(nextTabBarOrderByWorktree[targetWorktreeId] ?? []).filter( + (id) => !mappedMovedTabBarIds.includes(id) + ), + ...mappedMovedTabBarIds + ] + const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } + if (sourceGroupState.groups.length > 0) { + const previousActiveGroupId = nextActiveGroupIdByWorktree[sourceWorktreeId] + nextActiveGroupIdByWorktree[sourceWorktreeId] = sourceGroupState.groups.some( + (group) => group.id === previousActiveGroupId + ) + ? previousActiveGroupId + : sourceGroupState.groups[0].id + } else { + delete nextActiveGroupIdByWorktree[sourceWorktreeId] + } + nextActiveGroupIdByWorktree[targetWorktreeId] = targetGroupId + + result = { ok: true, fileId: newFileId } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === source.id + ? { + ...file, + id: newFileId, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file.markdownPreviewSourceFileId === source.id + ? { + ...file, + id: previewIdMigrations.get(file.id) ?? file.id, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + markdownPreviewSourceFileId: newFileId, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file + ), + editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), + editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), + markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), + editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations), + markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations), + markdownTableOfContentsVisible: rekeyFileIdRecord( + s.markdownTableOfContentsVisible, + migrations + ), + activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null, + activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [targetWorktreeId]: 'editor' + }, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + ...(s.pendingEditorReveal?.fileId && migrations.has(s.pendingEditorReveal.fileId) + ? { + pendingEditorReveal: { + ...s.pendingEditorReveal, + fileId: migrations.get(s.pendingEditorReveal.fileId)!, + filePath: source.filePath + } + } + : {}), + ...(s.pendingEditorFocusRequest?.fileId && + migrations.has(s.pendingEditorFocusRequest.fileId) + ? { + pendingEditorFocusRequest: { + ...s.pendingEditorFocusRequest, + fileId: migrations.get(s.pendingEditorFocusRequest.fileId)!, + worktreeId: targetWorktreeId + } + } + : {}), + ...(s.pendingExplorerReveal?.filePath === source.filePath && + s.pendingExplorerReveal.worktreeId === sourceWorktreeId + ? { + pendingExplorerReveal: { + ...s.pendingExplorerReveal, + worktreeId: targetWorktreeId + } + } + : {}) + }, + activate: true, + preferredActiveUnifiedTabId: mappedMovedTabIds.at(-1) + } + } + const initialSource = get().openFiles.find((file) => file.id === args.fileId) + const activatesTargetWorkspace = Boolean( + initialSource && + get().activeWorktreeId === initialSource.worktreeId && + get().activeFileId === initialSource.id + ) + if (activatesTargetWorkspace) { + get().setActiveWorktree(args.targetWorktreeId, args.targetExecutionHostId, { + stateTransition: projectReparent + }) + } else { + set((state) => projectReparent(state).patch) + } + return result + }, + rekeyOpenFilesForPathChange: ({ rekeys, moveOperationId }) => { if (rekeys.length === 0) { return { ok: true } @@ -3409,7 +3798,8 @@ export const createEditorSlice: StateCreator = (s check, details: state.details, loading: state.loading, - error: state.error + error: state.error, + gitlabProjectRef: state.gitlabProjectRef ?? null } set((s) => { const existing = s.openFiles.find((f) => f.id === id) @@ -3458,26 +3848,31 @@ export const createEditorSlice: StateCreator = (s // Why: sidebar detail fetches can finish after the full-details tab is open; update the snapshot without stealing focus. patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => { const id = buildCheckRunDetailsTabId(worktreeId, check) - const nextCheckRunDetails: OpenCheckRunDetailsState = { - contextKey, - check, - details: state.details, - loading: state.loading, - error: state.error - } set((s) => { const existing = s.openFiles.find((f) => f.id === id) if (!existing?.checkRunDetails) { return s } const current = existing.checkRunDetails + // Why: the sidebar resolves the MR's project asynchronously, so an early patch + // must not blank a ref we already know. + const gitlabProjectRef = state.gitlabProjectRef ?? current.gitlabProjectRef ?? null + const nextCheckRunDetails: OpenCheckRunDetailsState = { + contextKey, + check, + details: state.details, + loading: state.loading, + error: state.error, + gitlabProjectRef + } if ( current.contextKey === nextCheckRunDetails.contextKey && current.check.status === nextCheckRunDetails.check.status && current.check.conclusion === nextCheckRunDetails.check.conclusion && current.loading === nextCheckRunDetails.loading && current.error === nextCheckRunDetails.error && - current.details === nextCheckRunDetails.details + current.details === nextCheckRunDetails.details && + isSameGitLabProjectRef(current.gitlabProjectRef ?? null, gitlabProjectRef) ) { return s } @@ -3503,22 +3898,33 @@ export const createEditorSlice: StateCreator = (s return } const { contextKey, check } = checkRunDetails - const patch = (next: Pick): void => { + const patch = (next: CheckRunDetailsTabPatch): void => { get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, next) } patch({ details: checkRunDetails.details, loading: true, error: null }) try { - const details = await get().fetchPRCheckDetails( - repo.path, - { - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo: null - }, - { repoId: repo.id } - ) + // Why: refreshing a GitLab job tab through the GitHub check-runs API returns + // null and would blank the tab the user just asked to reload. + const details = check.gitlabJobId + ? await loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings: getSettingsForWorktreeRuntimeOwner(state, file.worktreeId), + check, + // Why: a fork MR's job lives in the source project, not the repo's own. + projectRef: checkRunDetails.gitlabProjectRef ?? null + }) + : await get().fetchPRCheckDetails( + repo.path, + { + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: null + }, + { repoId: repo.id } + ) patch({ details, loading: false, diff --git a/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts new file mode 100644 index 00000000000..d3e8f37f15f --- /dev/null +++ b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts @@ -0,0 +1,683 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { useAppStore } from '@/store' +import { buildEditorSessionData } from '@/lib/workspace-session' +import { + createExternalWatchEventHandler, + getEditorExternalWatchTargets, + useEditorExternalWatch +} from '@/hooks/useEditorExternalWatch' +import { + captureEditorFileOperationProvenance, + getEditorFileOperationContext +} from '@/lib/editor-file-operation-owner' +import type { AppState } from '@/store/types' +import type { WorkspaceSessionState } from '../../../../shared/types' + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/docs/readme.md' + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +function installWorkspaceState(): void { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: false }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + folderWorkspaces: [], + projectGroups: [], + rightSidebarOpen: false, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {} + } as unknown as Partial) +} + +function openRestoredSource(): string { + return useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: FILE_PATH, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit', + pendingDiskBaselineVerification: true + }, + { suppressActiveRuntimeFallback: true } + ) +} + +function reparent(fileId: string) { + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(fileId, true) + return state.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(state, TARGET, null, true) + }) +} + +describe('restored editor owner reparent', () => { + beforeEach(() => { + vi.restoreAllMocks() + installWorkspaceState() + }) + + afterEach(() => vi.useRealTimers()) + + it('atomically migrates ownership, ids, placement, views, previews, and provenance', () => { + const oldId = openRestoredSource() + const state = useAppStore.getState() + state.setEditorDraft(oldId, 'edited draft') + state.markFileDirty(oldId, true) + state.setEditorCursorLine(oldId, 37) + state.setMarkdownViewMode(oldId, 'rich') + state.setEditorViewMode(oldId, 'changes') + state.setMarkdownFrontmatterVisible(oldId, false) + state.setMarkdownTableOfContentsVisible(oldId, true) + useAppStore.setState((current) => ({ + pendingEditorReveal: { + fileId: oldId, + filePath: FILE_PATH, + line: 4, + column: 2, + matchLength: 0 + }, + pendingEditorFocusRequest: { + fileId: oldId, + worktreeId: SOURCE, + viewStateId: 'view-1', + expiresAt: Date.now() + 1_000, + token: 1 + }, + pendingExplorerReveal: { worktreeId: SOURCE, filePath: FILE_PATH, requestId: 1 }, + openFiles: [ + ...current.openFiles, + { + ...current.openFiles[0], + id: 'preview-1', + mode: 'markdown-preview' as const, + markdownPreviewSourceFileId: oldId, + isDirty: false + } + ] + })) + + const result = reparent(oldId) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const next = useAppStore.getState() + const moved = next.openFiles.find((file) => file.id === result.fileId)! + expect(moved).toMatchObject({ + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + isDirty: true, + pendingDiskBaselineVerification: true + }) + expect(moved.pendingOwnerMigration).toBeUndefined() + expect(moved.operationProvenance?.generation.route).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + expect(getEditorFileOperationContext(next, moved, '/repo-b').worktreeId).toBe(TARGET) + expect(next.editorDrafts).toEqual({ [result.fileId]: 'edited draft' }) + expect(next.editorCursorLine[result.fileId]).toBe(37) + expect(next.markdownViewMode[result.fileId]).toBe('rich') + expect(next.editorViewMode[result.fileId]).toBe('changes') + expect(next.markdownFrontmatterVisible[result.fileId]).toBe(false) + expect(next.markdownTableOfContentsVisible[result.fileId]).toBe(true) + expect(next.openFiles.find((file) => file.mode === 'markdown-preview')).toMatchObject({ + id: `markdown-preview::${result.fileId}`, + markdownPreviewSourceFileId: result.fileId, + worktreeId: TARGET, + relativePath: 'docs/readme.md' + }) + expect(next.unifiedTabsByWorktree[SOURCE]).toEqual([]) + const movedTabId = next.unifiedTabsByWorktree[TARGET]?.[0]?.id + expect(next.unifiedTabsByWorktree[TARGET]?.[0]?.entityId).toBe(result.fileId) + expect(next.groupsByWorktree[TARGET]?.[0]).toMatchObject({ + activeTabId: movedTabId, + tabOrder: [movedTabId] + }) + expect(next.tabBarOrderByWorktree[TARGET]).toEqual([result.fileId]) + expect(next.activeFileIdByWorktree[SOURCE]).toBeNull() + expect(next.activeFileIdByWorktree[TARGET]).toBe(result.fileId) + expect(next.activeWorktreeId).toBe(TARGET) + expect(next.activeWorkspaceExecutionHostId).toBe('local') + expect(next.activeRepoId).toBe('repo-b') + expect(next.activeGroupIdByWorktree[SOURCE]).toBeUndefined() + expect(next.pendingEditorReveal?.fileId).toBe(result.fileId) + expect(next.pendingEditorFocusRequest).toMatchObject({ + fileId: result.fileId, + worktreeId: TARGET + }) + expect(next.pendingExplorerReveal?.worktreeId).toBe(TARGET) + }) + + it('does not retarget an explorer reveal owned by another workspace', () => { + const oldId = openRestoredSource() + useAppStore.setState({ + pendingExplorerReveal: { worktreeId: 'other-workspace', filePath: FILE_PATH, requestId: 1 } + }) + + expect(reparent(oldId).ok).toBe(true) + expect(useAppStore.getState().pendingExplorerReveal?.worktreeId).toBe('other-workspace') + }) + + it('commits active reparenting through the complete workspace activation projection', () => { + const oldId = openRestoredSource() + const refreshGitHubForWorktreeIfStale = vi.fn() + useAppStore.setState({ + activeTabId: 'source-terminal', + activeBrowserTabId: 'source-browser', + activePendingCreationId: 'source-creation', + rightSidebarExplorerView: 'files', + rightSidebarExplorerViewByWorktree: { [TARGET]: 'search' }, + tabsByWorktree: { + [SOURCE]: [ + { + id: 'source-terminal', + ptyId: null, + worktreeId: SOURCE, + title: 'Source terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 1 + } + ], + [TARGET]: [ + { + id: 'target-terminal', + ptyId: null, + worktreeId: TARGET, + title: 'Target terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 4 + } + ] + }, + activeTabIdByWorktree: { + [SOURCE]: 'source-terminal', + [TARGET]: 'target-terminal' + }, + unifiedTabsByWorktree: { + ...useAppStore.getState().unifiedTabsByWorktree, + [TARGET]: [ + { + id: 'target-terminal', + entityId: 'target-terminal', + groupId: 'target-group', + worktreeId: TARGET, + contentType: 'terminal', + label: 'Target terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + ...useAppStore.getState().groupsByWorktree, + [TARGET]: [ + { + id: 'target-group', + worktreeId: TARGET, + activeTabId: 'target-terminal', + tabOrder: ['target-terminal'] + } + ] + }, + layoutByWorktree: { + ...useAppStore.getState().layoutByWorktree, + [TARGET]: { type: 'leaf', groupId: 'target-group' } + }, + activeGroupIdByWorktree: { + ...useAppStore.getState().activeGroupIdByWorktree, + [TARGET]: 'target-group' + }, + browserTabsByWorktree: { + [SOURCE]: [{ id: 'source-browser' }], + [TARGET]: [{ id: 'target-browser' }] + }, + activeBrowserTabIdByWorktree: { + [SOURCE]: 'source-browser', + [TARGET]: 'target-browser' + }, + everActivatedWorktreeIds: new Set([SOURCE]), + refreshGitHubForWorktreeIfStale + } as unknown as Partial) + const targetSnapshots: AppState[] = [] + const unsubscribe = useAppStore.subscribe((state) => { + if (state.activeWorktreeId === TARGET) { + targetSnapshots.push(state) + } + }) + + const result = reparent(oldId) + unsubscribe() + + expect(result.ok).toBe(true) + expect(targetSnapshots).toHaveLength(1) + const activated = targetSnapshots[0] + expect(activated).toMatchObject({ + activeWorktreeId: TARGET, + activeRepoId: 'repo-b', + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'target-terminal', + activeBrowserTabId: 'target-browser', + activePendingCreationId: null, + rightSidebarExplorerView: 'search', + activeTabType: 'editor' + }) + expect(activated.activeFileId).toBe(result.ok ? result.fileId : null) + expect(activated.everActivatedWorktreeIds).toEqual(new Set([SOURCE, TARGET])) + expect(activated.tabsByWorktree[TARGET]?.[0]).toMatchObject({ + id: 'target-terminal', + generation: 5 + }) + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledOnce() + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledWith(TARGET) + }) + + it('persists and restores the destination owner and dirty hot-exit draft', () => { + const oldId = openRestoredSource() + useAppStore.getState().setEditorDraft(oldId, 'hot exit') + useAppStore.getState().markFileDirty(oldId, true) + const result = reparent(oldId) + expect(result.ok).toBe(true) + + const state = useAppStore.getState() + const session = buildEditorSessionData( + state.openFiles, + state.editorDrafts, + state.markdownFrontmatterVisible, + state.activeFileIdByWorktree, + state.activeTabTypeByWorktree + ) + expect(session.openFilesByWorktree?.[SOURCE]).toBeUndefined() + expect(session.openFilesByWorktree?.[TARGET]?.[0]).toMatchObject({ + relativePath: 'docs/readme.md', + dirtyDraftContent: 'hot exit' + }) + + installWorkspaceState() + useAppStore.getState().hydrateEditorSession(session as WorkspaceSessionState) + const restored = useAppStore.getState().openFiles[0] + expect(restored).toMatchObject({ + worktreeId: TARGET, + relativePath: 'docs/readme.md', + isDirty: true + }) + expect(useAppStore.getState().editorDrafts[restored.id]).toBe('hot exit') + }) + + it.each([false, true])('fails closed on a %s destination collision', (dirtyDestination) => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + if (dirtyDestination) { + useAppStore.getState().setEditorDraft(destinationId, 'destination draft') + useAppStore.getState().markFileDirty(destinationId, true) + } + const before = useAppStore.getState().openFiles + + expect(reparent(sourceId)).toEqual({ ok: false, reason: 'collision' }) + expect(useAppStore.getState().openFiles.map((file) => file.id)).toEqual( + before.map((file) => file.id) + ) + expect(useAppStore.getState().openFiles.find((file) => file.id === sourceId)?.worktreeId).toBe( + SOURCE + ) + }) + + it('leaves destination tabs and groups byte-identical when activation hits a collision', () => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const destinationTab = useAppStore + .getState() + .unifiedTabsByWorktree[TARGET]?.find((tab) => tab.entityId === destinationId) + expect(destinationTab).toBeDefined() + const staleTab = { + ...destinationTab!, + id: 'stale-target-tab', + entityId: 'stale-target-file' + } + useAppStore.setState((state) => ({ + activeWorktreeId: SOURCE, + activeFileId: sourceId, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [TARGET]: [...(state.unifiedTabsByWorktree[TARGET] ?? []), staleTab] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [TARGET]: (state.groupsByWorktree[TARGET] ?? []).map((group) => + group.id === staleTab.groupId + ? { + ...group, + activeTabId: staleTab.id, + tabOrder: [...group.tabOrder, staleTab.id], + recentTabIds: [...(group.recentTabIds ?? []), staleTab.id] + } + : group + ) + } + })) + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(sourceId, true) + const before = useAppStore.getState() + + const result = before.reparentRestoredEditorFileOwner({ + fileId: sourceId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(before, TARGET, null, true) + }) + + expect(result).toEqual({ ok: false, reason: 'collision' }) + const after = useAppStore.getState() + expect(after.unifiedTabsByWorktree).toBe(before.unifiedTabsByWorktree) + expect(after.groupsByWorktree).toBe(before.groupsByWorktree) + expect(after.layoutByWorktree).toBe(before.layoutByWorktree) + expect(after.activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree) + expect(after.activeWorktreeId).toBe(SOURCE) + expect(after.activeFileId).toBe(sourceId) + }) + + it('moves the watcher projection from source to destination without fanout', () => { + const oldId = openRestoredSource() + const before = getEditorExternalWatchTargets(useAppStore.getState()) + const result = reparent(oldId) + expect(result.ok).toBe(true) + const after = getEditorExternalWatchTargets(useAppStore.getState()) + + expect(before.targets.map((target) => target.worktreeId)).toEqual([SOURCE]) + expect(after.targets).toEqual([ + { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('unsubscribes the source watch once and subscribes the destination once', async () => { + const watchWorktree = vi.fn().mockResolvedValue(undefined) + const unwatchWorktree = vi.fn().mockResolvedValue(undefined) + const previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { + watchWorktree, + unwatchWorktree, + onFsChanged: vi.fn(() => vi.fn()) + } + } + const oldId = openRestoredSource() + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + await act(async () => root.render(createElement(WatchProbe))) + await vi.waitFor(() => expect(watchWorktree).toHaveBeenCalledTimes(1)) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + + await act(async () => { + expect(reparent(oldId).ok).toBe(true) + }) + await vi.waitFor(() => { + expect(unwatchWorktree).toHaveBeenCalledTimes(1) + expect(watchWorktree).toHaveBeenCalledTimes(2) + }) + expect(unwatchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-b', + connectionId: undefined + }) + + await act(async () => root.unmount()) + container.remove() + ;(window as unknown as { api?: unknown }).api = previousApi + }) + + it('reparents into a folder workspace with local save and watch authority', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-notes', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'group-notes', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const oldId = useAppStore.getState().openFile( + { + filePath: '/notes/todo.md', + relativePath: '/notes/todo.md', + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: 'folder:notes', + targetRelativePath: 'todo.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + 'folder:notes', + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const state = useAppStore.getState() + const moved = state.openFiles.find((file) => file.id === result.fileId)! + expect(getEditorFileOperationContext(state, moved, null)).toMatchObject({ + worktreeId: 'folder:notes', + worktreePath: '/notes', + expectedExecutionHostId: 'local' + }) + expect(getEditorExternalWatchTargets(state).targets).toEqual([ + { + worktreeId: 'folder:notes', + worktreePath: '/notes', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('captures exact direct-SSH host and connection-generation authority', () => { + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => ({ + ...repo, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + })), + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ ...worktree, hostId: 'ssh:ssh-1' as const })) + ]) + ), + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + connectionGeneration: 7 + } + ] + ]) + })) + const oldId = openRestoredSource() + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'ssh:ssh-1', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + TARGET, + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId) + ).toMatchObject({ + externalSshTargetId: 'ssh-1', + operationProvenance: { + expectedSshConnectionGeneration: 7, + generation: { + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + } + } + }) + }) + + it('correlates destination change, delete, and rename events after reparent', () => { + vi.useFakeTimers() + const oldId = openRestoredSource() + const result = reparent(oldId) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const target = { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + const externalChanges: unknown[] = [] + const listener = (event: Event): void => { + externalChanges.push((event as CustomEvent).detail) + } + window.addEventListener('orca:editor-external-file-change', listener) + const watcher = createExternalWatchEventHandler((path, owner) => + path === target.worktreePath && owner === null ? target : undefined + ) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'update', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect(externalChanges).toContainEqual({ + worktreeId: TARGET, + worktreePath: '/repo-b', + relativePath: 'docs/readme.md', + runtimeEnvironmentId: null + }) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'delete', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('deleted') + useAppStore.getState().setExternalMutation(result.fileId, null) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [ + { kind: 'delete', absolutePath: FILE_PATH }, + { kind: 'create', absolutePath: '/repo-b/archive/readme.md' } + ] + }) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('renamed') + + watcher.dispose() + window.removeEventListener('orca:editor-external-file-change', listener) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 72e22adc136..5a2456c24c0 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -616,6 +616,215 @@ function activeSurfacePatchMatchesState( ) } +export type WorktreeTabModelReconciliation = { + patch: Partial + renderableTabCount: number + activeRenderableTabId: string | null +} + +export function projectWorktreeTabModelReconciliation( + state: AppState, + worktreeId: string +): WorktreeTabModelReconciliation { + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTerminalEntityIds = new Set( + unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) + ) + const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { + if (unifiedTerminalEntityIds.has(tab.id)) { + return false + } + // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. + return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) + }) + const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) + const ensuredGroupState = + legacyRuntimeTerminalTabs.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null + const restoredLegacyTabs = + reconciliationGroup == null + ? [] + : legacyRuntimeTerminalTabs + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((tab) => ({ + id: tab.id, + entityId: tab.id, + groupId: reconciliationGroup.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + const reconciledUnifiedTabs = + restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) + const reconciledGroups = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { + ...reconciliationGroup, + // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, + tabOrder: dedupeTabOrder([ + ...reconciliationGroup.tabOrder, + ...restoredLegacyTabs.map((tab) => tab.id) + ]) + }) + : groups + const liveTerminalIds = new Set( + runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + if (tab.contentType === 'simulator') { + return true + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + const nextGroupsWithEmpty = reconciledGroups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + const recentUnchanged = + recentTabIds.length === (group.recentTabIds ?? []).length && + recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged + ? group + : { ...group, tabOrder, activeTabId, recentTabIds } + }) + const nextGroups = + validTabs.length > 0 + ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) + : nextGroupsWithEmpty + const currentActiveGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? + ensuredGroupState?.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + const groupsChanged = + nextGroups.length !== groups.length || + nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + const baseNextLayout = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) + : state.layoutByWorktree[worktreeId] + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const prunedNextLayout = + baseNextLayout && validGroupIds.size > 0 + ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) + : baseNextLayout + const nextLayout = + prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) + const currentLayout = state.layoutByWorktree[worktreeId] + const layoutChanged = nextLayout !== currentLayout + let patch: Partial = {} + + if ( + tabsChanged || + groupsChanged || + activeGroupChanged || + layoutChanged || + orphanTerminalIds.size > 0 + ) { + const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => + tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] + ) + let nextUnreadTerminalTabs = state.unreadTerminalTabs + if (droppedTerminalEntityIds.length > 0) { + const copy = { ...state.unreadTerminalTabs } + let changed = false + for (const entityId of droppedTerminalEntityIds) { + if (copy[entityId]) { + delete copy[entityId] + changed = true + } + } + if (changed) { + nextUnreadTerminalTabs = copy + } + } + patch = { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + }, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextLayout && layoutChanged + ? { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: restored runtime terminals need a concrete leaf before activation. + [worktreeId]: nextLayout + } + } + : {}), + ...(orphanTerminalIds.size > 0 + ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) + : {}) + } + } + + return { + patch, + renderableTabCount: validTabs.length, + activeRenderableTabId: + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + } +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, renamingTabId: null, @@ -1807,220 +2016,13 @@ export const createTabsSlice: StateCreator = (set, }, reconcileWorktreeTabModel: (worktreeId) => { - const state = get() - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groups = state.groupsByWorktree[worktreeId] ?? [] - const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const unifiedTerminalEntityIds = new Set( - unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) - ) - const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { - if (unifiedTerminalEntityIds.has(tab.id)) { - return false - } - // Why: migration filter — keep any tab still owning a live/reconnecting - // PTY (preserved sessionId or a reconnect-map session) so it re-enters the - // unified model for wake/reconnect reattach instead of vanishing (#9911). - return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) - }) - const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) - const ensuredGroupState = - legacyRuntimeTerminalTabs.length > 0 - ? ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - state.activeGroupIdByWorktree[worktreeId] - ) - : null - const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null - const restoredLegacyTabs = - reconciliationGroup == null - ? [] - : legacyRuntimeTerminalTabs - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab) => ({ - id: tab.id, - entityId: tab.id, - groupId: reconciliationGroup.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt - })) - const reconciledUnifiedTabs = - restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs - // Why: seed a freshly-ensured group's active tab from the remembered selection, else the worktree always reopens on Terminal 1. - const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] - const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) - const legacyFallbackActiveTabId = - rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) - ? rememberedLegacyActiveTabId - : (restoredLegacyTabs[0]?.id ?? null) - const reconciledGroups = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { - ...reconciliationGroup, - // Why: restore runtime-slice legacy tabs into the active/root group so live PTYs stay reachable instead of spawning a duplicate. - activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, - tabOrder: dedupeTabOrder([ - ...reconciliationGroup.tabOrder, - ...restoredLegacyTabs.map((tab) => tab.id) - ]) - }) - : groups - const liveTerminalIds = new Set( - runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) - const liveBrowserIds = new Set( - (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) - ) - - const isRenderableTab = (tab: Tab): boolean => { - if (tab.contentType === 'terminal') { - return liveTerminalIds.has(tab.entityId) - } - if (tab.contentType === 'browser') { - return liveBrowserIds.has(tab.entityId) - } - if (tab.contentType === 'simulator') { - return true - } - return liveEditorIds.has(tab.entityId) + const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) + if (Object.keys(reconciliation.patch).length > 0) { + set(reconciliation.patch) } - - const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) - const validTabIds = new Set(validTabs.map((tab) => tab.id)) - - const nextGroupsWithEmpty = reconciledGroups.map((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - const activeTabId = - group.activeTabId && validTabIds.has(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const tabOrderUnchanged = - tabOrder.length === group.tabOrder.length && - tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) - // Why: keep the MRU stack in sync with dropped tabs so the next close doesn't activate one the renderer no longer owns. - const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) - const recentUnchanged = - recentTabIds.length === (group.recentTabIds ?? []).length && - recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) - return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged - ? group - : { ...group, tabOrder, activeTabId, recentTabIds } - }) - const nextGroups = - validTabs.length > 0 - ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) - : nextGroupsWithEmpty - - const currentActiveGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? - ensuredGroupState?.activeGroupIdByWorktree[worktreeId] - const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) - const nextActiveGroupId = activeGroupStillExists - ? currentActiveGroupId - : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? - nextGroups[0]?.id ?? - currentActiveGroupId) - - const groupsChanged = - nextGroups.length !== groups.length || - nextGroups.some((group, index) => group !== groups[index]) - const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 - const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId - - const baseNextLayout = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) - : state.layoutByWorktree[worktreeId] - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const prunedNextLayout = - baseNextLayout && validGroupIds.size > 0 - ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) - : baseNextLayout - const nextLayout = - prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) - const currentLayout = state.layoutByWorktree[worktreeId] - const layoutChanged = nextLayout !== currentLayout - - if ( - tabsChanged || - groupsChanged || - activeGroupChanged || - layoutChanged || - orphanTerminalIds.size > 0 - ) { - // Why: drop unreadTerminalTabs entries (keyed by entityId) for terminal tabs reconcile removed, or the stale flag lingers forever. - const droppedTerminalEntityIds: string[] = [] - for (const tab of unifiedTabs) { - if (tab.contentType !== 'terminal') { - continue - } - if (!validTabIds.has(tab.id)) { - droppedTerminalEntityIds.push(tab.entityId) - } - } - set((current) => { - let nextUnreadTerminalTabs = current.unreadTerminalTabs - if (droppedTerminalEntityIds.length > 0) { - let changed = false - const copy = { ...current.unreadTerminalTabs } - for (const entityId of droppedTerminalEntityIds) { - if (copy[entityId]) { - delete copy[entityId] - changed = true - } - } - if (changed) { - nextUnreadTerminalTabs = copy - } - } - return { - unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...current.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, - ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextLayout && layoutChanged - ? { - layoutByWorktree: { - ...current.layoutByWorktree, - // Why: restored runtime terminal needs a concrete leaf before activation, else the fallback spawns a duplicate "Terminal 2". - [worktreeId]: nextLayout! - } - } - : {}), - ...(orphanTerminalIds.size > 0 - ? buildOrphanTerminalCleanupPatch(current, worktreeId, orphanTerminalIds) - : {}) - } - }) - } - - const activeRenderableTabId = - nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? - nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? - null - return { - renderableTabCount: validTabs.length, - activeRenderableTabId + renderableTabCount: reconciliation.renderableTabCount, + activeRenderableTabId: reconciliation.activeRenderableTabId } }, diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 2e0e48546e2..dbd81acee9f 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -721,6 +721,33 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showSleepingWorkspaces).toBe(true) }) + it('defaults the default-branch sleeping exemption to on', () => { + expect(getDefaultUIState().alwaysShowDefaultBranchWorkspace).toBe(true) + expect(createUIStore().getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('treats a legacy profile with no default-branch exemption key as opted in', () => { + // Why: profiles written before #8873 are exactly the ones showing the bug, + // so an absent key must hydrate to on rather than silently re-hiding main. + const store = createUIStore() + const legacy = makePersistedUI() + delete (legacy as Partial).alwaysShowDefaultBranchWorkspace + + store.getState().hydratePersistedUI(legacy, 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('preserves an explicit default-branch exemption opt-out on hydration', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ alwaysShowDefaultBranchWorkspace: false }), 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(false) + }) + it('defaults workspace host scope to all hosts', () => { expect(getDefaultUIState().workspaceHostScope).toBe('all') expect(createUIStore().getState().workspaceHostScope).toBe('all') diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 625a8e9aba0..d926a100f2f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -875,6 +875,8 @@ export type UISlice = { setHideCliCreatedWorkspaces: (v: boolean) => void hideDetachedHeadWorkspaces: boolean setHideDetachedHeadWorkspaces: (v: boolean) => void + alwaysShowDefaultBranchWorkspace: boolean + setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2053,6 +2055,8 @@ export const createUISlice: StateCreator = (set, get) setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), hideDetachedHeadWorkspaces: false, setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2459,6 +2463,9 @@ export const createUISlice: StateCreator = (set, get) hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + // Why !== false: profiles written before #8873 have no key, and they are + // precisely the ones showing the bug, so absence must mean "exempt". + alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. filterRepoIds: diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fd548c91e04..75ad5985153 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -35,6 +35,7 @@ import type { WorktreeCreationPhase } from '@/lib/pending-worktree-creation' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import type { AppState } from '../types' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { @@ -70,6 +71,12 @@ export type WorktreeRenameRequest = { rowKey?: string } +export type ActiveWorktreeStateTransition = (state: AppState) => { + patch: Partial + activate: boolean + preferredActiveUnifiedTabId?: string +} + export type WorktreeSlice = { worktreesByRepo: Record detectedWorktreesByRepo: Record @@ -280,7 +287,11 @@ export type WorktreeSlice = { * fresh visit. */ seedActiveWorktreeLastVisitedIfMissing: () => void - setActiveWorktree: (worktreeId: string | null, executionHostId?: ExecutionHostId) => void + setActiveWorktree: ( + worktreeId: string | null, + executionHostId?: ExecutionHostId, + options?: { stateTransition?: ActiveWorktreeStateTransition } + ) => boolean /** * Health-driven remount of one terminal tab: bumps the tab's generation so * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 66bf295a715..310e3e82d8d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -26,6 +26,7 @@ import { type WorktreeFetchOptions, type WorktreeSlice } from './worktree-helpers' +import { projectWorktreeTabModelReconciliation } from './tabs' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { areWorkspaceLinkedItemsEqual } from '../../../../shared/workspace-linked-item' import { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' @@ -5238,7 +5239,14 @@ export const createWorktreeSlice: StateCreator return remounted }, - setActiveWorktree: (worktreeId, executionHostId) => { + setActiveWorktree: (worktreeId, executionHostId, options) => { + const stateTransition = options?.stateTransition?.(get()) + if (stateTransition && !stateTransition.activate) { + if (Object.keys(stateTransition.patch).length > 0) { + set(stateTransition.patch) + } + return false + } const workspaceScope = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && shouldDeferActivationTerminalPrep()) { markInputQuietSchedulerInput() @@ -5247,15 +5255,27 @@ export const createWorktreeSlice: StateCreator if (get().activeWorktreeId !== worktreeId) { moveFocusToRendererBeforeFocusedWebviewHidden() } - const reconciledActiveTabId = worktreeId - ? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId - : null let shouldClearUnread = false let shouldPrepareTerminalTabs = false let shouldTagTerminalTabs = false - set((s) => { + set((current) => { + const transitioned = stateTransition + ? ({ ...current, ...stateTransition.patch } as AppState) + : current + const reconciliation = worktreeId + ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) + : null + const reconciliationChanged = Boolean( + reconciliation && Object.keys(reconciliation.patch).length > 0 + ) + const s = + reconciliation && reconciliationChanged + ? ({ ...transitioned, ...reconciliation.patch } as AppState) + : transitioned + const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null if (!worktreeId) { return { + ...stateTransition?.patch, activeWorktreeId: null, activeWorkspaceKey: null, activeWorkspaceExecutionHostId: null, @@ -5279,7 +5299,11 @@ export const createWorktreeSlice: StateCreator ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? null) : null - const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null + const activeUnifiedTabId = + stateTransition?.preferredActiveUnifiedTabId ?? + reconciledActiveTabId ?? + activeGroup?.activeTabId ?? + null const activeUnifiedTab = activeUnifiedTabId != null ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( @@ -5408,7 +5432,12 @@ export const createWorktreeSlice: StateCreator : workspace ) : s.folderWorkspaces - const nextActiveRepoId = workspaceScope?.type === 'folder' ? null : s.activeRepoId + const nextActiveRepoId = + workspaceScope?.type === 'folder' + ? null + : stateTransition + ? (worktree?.repoId ?? s.activeRepoId) + : s.activeRepoId const tabsByWorktreeUpdate = allDead && worktreeId != null ? { @@ -5444,13 +5473,17 @@ export const createWorktreeSlice: StateCreator nextWorktrees !== s.worktreesByRepo || nextDetectedWorktrees !== s.detectedWorktreesByRepo || nextFolderWorkspaces !== s.folderWorkspaces || - nextActiveRepoId !== s.activeRepoId + nextActiveRepoId !== s.activeRepoId || + reconciliationChanged || + stateTransition !== undefined if (!hasStateChange) { // Why: preserve the root Zustand reference on a no-op re-activation so session persistence/runtime sync don't fan out. return s } return { + ...stateTransition?.patch, + ...reconciliation?.patch, activeRepoId: nextActiveRepoId, activeWorktreeId: worktreeId, activeWorkspaceKey: isWorkspaceKey(worktreeId) @@ -5531,13 +5564,13 @@ export const createWorktreeSlice: StateCreator } if (!worktreeId || !get().getKnownWorktreeById(worktreeId, executionHostId)) { - return + return true } if (shouldClearUnread) { if (workspaceScope?.type === 'folder') { void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) - return + return true } persistPassiveWorktreeMetaForOwner( get, @@ -5546,6 +5579,7 @@ export const createWorktreeSlice: StateCreator 'persist worktree activation state' ) } + return true }, setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index fe27d358d0d..6199c35b831 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -556,6 +556,8 @@ function createWebPreloadApi(): Partial { } writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) }, + // Staging already wrote through to browser storage, so there is nothing left to join. + awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), @@ -1530,6 +1532,10 @@ function createAiVaultApi(): NonNullable['aiVault']> { executionHostId }) }, + // Why: the runtime RPC transport has no cancel verb, so the in-flight scan + // settles on its own timeout. The renderer's refreshId guard already drops + // the late result; this only means web pays for a scan nobody reads. + cancelListSessions: () => Promise.resolve(), prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => callRuntimeResult('aiVault.prepareSessionResume', args), // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. @@ -3246,6 +3252,15 @@ function createSshApi(): NonNullable['ssh']> { Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), + listConfigHosts: () => + Promise.resolve({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + resolveConfigHost: () => Promise.resolve(null), connect: async (args) => { const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( 'ssh.connect', diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts index dc4f3cf55f8..f3d4b65a86b 100644 --- a/src/shared/agent-status-identity.ts +++ b/src/shared/agent-status-identity.ts @@ -9,6 +9,7 @@ type ExistingAgentIdentity = { agentType?: AgentType state: AgentStatusState updatedAt: number + restoredUnconfirmed?: boolean } type AgentIdentityResolution = { diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 6bface4ec92..6059950b708 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { agentSubagentsEqual, + isFreshNonDoneAgentStatus, parseAgentStatusPayload, normalizeAgentStatusPayload, AGENT_STATUS_JSON_STRUCTURE_LIMITS, @@ -18,6 +19,26 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('isFreshNonDoneAgentStatus', () => { + it('treats a within-TTL working entry as fresh', () => { + expect(isFreshNonDoneAgentStatus({ state: 'working', updatedAt: 1_000 }, 2_000)).toBe(true) + }) + + it('never treats a restored-unconfirmed entry as fresh, regardless of age', () => { + expect( + isFreshNonDoneAgentStatus( + { state: 'working', updatedAt: 1_999, restoredUnconfirmed: true }, + 2_000 + ) + ).toBe(false) + }) + + it('stays false for done and for stale entries', () => { + expect(isFreshNonDoneAgentStatus({ state: 'done', updatedAt: 2_000 }, 2_000)).toBe(false) + expect(isFreshNonDoneAgentStatus({ state: 'working', updatedAt: 0 }, 10_000, 5_000)).toBe(false) + }) +}) + describe('parseAgentStatusPayload', () => { it('parses a valid working payload', () => { const result = parseAgentStatusPayload( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 157fc95a050..1f2c3680048 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -137,6 +137,10 @@ export type AgentStatusEntry = { providerSession?: AgentProviderSessionMetadata /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** True for a nonterminal state hydrated from last-status.json with no live hook since: + * the transition may have been missed while no receiver was up, so freshness gates + * treat the row as stale immediately. Cleared by any accepted live event. */ + restoredUnconfirmed?: boolean } export type MigrationUnsupportedPtyEntry = { @@ -200,6 +204,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSessionOnly?: boolean /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ + restoredUnconfirmed?: boolean } /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ @@ -228,11 +234,17 @@ export const AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH = 16000 export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 export function isFreshNonDoneAgentStatus( - entry: Pick | undefined, + entry: Pick | undefined, now = Date.now(), staleAfterMs = AGENT_STATUS_STALE_AFTER_MS ): boolean { - return Boolean(entry && entry.state !== 'done' && now - entry.updatedAt <= staleAfterMs) + // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. + return Boolean( + entry && + entry.state !== 'done' && + entry.restoredUnconfirmed !== true && + now - entry.updatedAt <= staleAfterMs + ) } // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. diff --git a/src/shared/ai-vault-session-depth.test.ts b/src/shared/ai-vault-session-depth.test.ts new file mode 100644 index 00000000000..86ceace2215 --- /dev/null +++ b/src/shared/ai-vault-session-depth.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from './ai-vault-types' +import { + aiVaultScanLimit, + aiVaultSessionDepthCovers, + requestedAiVaultSessionDepth, + truncateAiVaultListResult +} from './ai-vault-session-depth' + +function session(id: string, cwd: string, index: number): AiVaultSession { + const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString() + return { + id, + executionHostId: 'local', + agent: 'codex', + sessionId: id, + title: id, + cwd, + branch: null, + model: null, + filePath: `/sessions/${id}.jsonl`, + codexHome: null, + createdAt: timestamp, + updatedAt: timestamp, + modifiedAt: timestamp, + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: id, + subagent: null + } +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: '2026-08-02T00:00:00.000Z' } +} + +describe('Agent Session History depth', () => { + it('recognizes which loaded depths cover a request', () => { + expect(aiVaultSessionDepthCovers(250, 250)).toBe(true) + expect(aiVaultSessionDepthCovers(1000, 250)).toBe(true) + expect(aiVaultSessionDepthCovers(250, 500)).toBe(false) + expect(aiVaultSessionDepthCovers(1000, 'unlimited')).toBe(false) + expect(aiVaultSessionDepthCovers('unlimited', 1000)).toBe(true) + expect(aiVaultSessionDepthCovers('unlimited', 'unlimited')).toBe(true) + }) + + it('normalizes default, finite, and unlimited requests', () => { + expect(requestedAiVaultSessionDepth()).toBe(1000) + expect(requestedAiVaultSessionDepth({ limit: 500 })).toBe(500) + expect(requestedAiVaultSessionDepth({ limit: 500.75 })).toBe(500) + expect(requestedAiVaultSessionDepth({ limit: 0 })).toBe(1000) + expect(requestedAiVaultSessionDepth({ limit: -1 })).toBe(1000) + expect(requestedAiVaultSessionDepth({ limit: Number.NaN })).toBe(1000) + expect(requestedAiVaultSessionDepth({ limit: Number.POSITIVE_INFINITY })).toBe(1000) + expect(requestedAiVaultSessionDepth({ limit: 500, unlimited: true })).toBe('unlimited') + }) + + it('resolves scan limits to a numeric bound', () => { + expect(aiVaultScanLimit({ limit: 500 })).toBe(500) + expect(aiVaultScanLimit({ limit: Number.POSITIVE_INFINITY })).toBe(1000) + expect(aiVaultScanLimit({ unlimited: true })).toBe(Number.POSITIVE_INFINITY) + }) + + it('keeps the newest global and scoped sessions when truncating', () => { + const loaded = result([ + session('global-1', '/other', 6), + session('global-2', '/other', 5), + session('global-3', '/other', 4), + session('scoped-1', '/repo/app', 3), + session('scoped-2', '/repo/lib', 2), + session('scoped-3', '/repo/old', 1) + ]) + + expect(truncateAiVaultListResult(loaded, 2, ['/repo']).sessions.map(({ id }) => id)).toEqual([ + 'global-1', + 'global-2', + 'scoped-1', + 'scoped-2' + ]) + expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded) + }) +}) diff --git a/src/shared/ai-vault-session-depth.ts b/src/shared/ai-vault-session-depth.ts new file mode 100644 index 00000000000..6e12574c545 --- /dev/null +++ b/src/shared/ai-vault-session-depth.ts @@ -0,0 +1,55 @@ +import { isPathInsideOrEqual } from './cross-platform-path' +import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types' + +export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000 + +export type AiVaultSessionDepth = number | 'unlimited' + +export function requestedAiVaultSessionDepth( + args?: Pick +): AiVaultSessionDepth { + if (args?.unlimited === true) { + return 'unlimited' + } + return args?.limit && Number.isFinite(args.limit) && args.limit > 0 + ? Math.floor(args.limit) + : DEFAULT_AI_VAULT_SCAN_LIMIT +} + +// Scanners bound with slice/comparisons, so 'unlimited' resolves to no bound. +// Single owner of the normalization every scan limit goes through. +export function aiVaultScanLimit(args?: Pick): number { + const depth = requestedAiVaultSessionDepth(args) + return depth === 'unlimited' ? Number.POSITIVE_INFINITY : depth +} + +export function aiVaultSessionDepthCovers( + cached: AiVaultSessionDepth, + requested: AiVaultSessionDepth +): boolean { + return cached === 'unlimited' || (requested !== 'unlimited' && cached >= requested) +} + +export function truncateAiVaultListResult( + result: AiVaultListResult, + depth: AiVaultSessionDepth, + scopePaths: readonly string[] = [] +): AiVaultListResult { + if (depth === 'unlimited') { + return result + } + const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id)) + if (scopePaths.length > 0) { + let scopedCount = 0 + for (const session of result.sessions) { + const cwd = session.cwd + if (cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) { + selectedIds.add(session.id) + if (++scopedCount >= depth) { + break + } + } + } + } + return { ...result, sessions: result.sessions.filter((session) => selectedIds.has(session.id)) } +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 307352c5ddd..6e132fce990 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -25,6 +25,17 @@ export const AI_VAULT_AGENTS = [ // value are optional belt-and-braces, not required for the request to succeed. export const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64 +// Why: IPC rejection drops `Error.name`, so the renderer can only recognise a +// cancelled scan by its message. Both sides share this literal. +export const AI_VAULT_SCAN_CANCELLED_MESSAGE = 'Agent Session History scan was cancelled' + +export function isAiVaultScanCancelledError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || error.message.includes(AI_VAULT_SCAN_CANCELLED_MESSAGE)) + ) +} + export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' @@ -172,23 +183,29 @@ export function isAiVaultSessionRecoverableEmpty( export type AiVaultScanIssue = { executionHostId?: ExecutionHostId agent: AiVaultAgent + // 'notice' rows are scanner commentary (issue-list overflow), never a failure. + kind?: 'host' | 'scope' | 'notice' path: string message: string } export type AiVaultListArgs = { limit?: number + unlimited?: boolean force?: boolean // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] executionHostScope?: ExecutionHostScope + requestToken?: string } export type AiVaultListResult = { sessions: AiVaultSession[] issues: AiVaultScanIssue[] scannedAt: string + /** Set only by the desktop IPC boundary: this scan was superseded, so its empty body means "nothing to apply". */ + cancelled?: true } export function aiVaultAgentLabel(agent: AiVaultAgent): string { diff --git a/src/shared/ansi-escape-sequences.ts b/src/shared/ansi-escape-sequences.ts new file mode 100644 index 00000000000..1d914900c24 --- /dev/null +++ b/src/shared/ansi-escape-sequences.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-control-regex -- these patterns exist to match terminal control bytes. */ + +/** OSC title/progress frames, 7-bit (ESC ]) and 8-bit (U+009D) introducers. */ +export const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g +/** DCS/APC/PM/SOS string sequences terminated by ST. */ +export const STRING_SEQUENCE_PATTERN = + /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g +/** CSI sequences (colors, cursor moves, `\u001b[0K` erase-to-EOL). */ +export const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g +/** Remaining two/three-byte escape sequences (charset selection, RIS, ...). */ +export const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g +/** C0/C1 control bytes except tab, line feed and carriage return. */ +export const TERMINAL_CONTROL_CHARACTER_PATTERN = + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g + +/** + * Remove ANSI escape sequences while preserving text, tabs and line breaks. + * Ordering matters: OSC/string sequences must be consumed before the generic + * escape pattern, which would otherwise eat only their introducer. + */ +export function stripAnsiEscapeSequences(value: string): string { + return value + .replace(OSC_SEQUENCE_PATTERN, '') + .replace(STRING_SEQUENCE_PATTERN, '') + .replace(CSI_SEQUENCE_PATTERN, '') + .replace(ESCAPE_SEQUENCE_PATTERN, '') +} diff --git a/src/main/github/check-job-log-tail-slice.test.ts b/src/shared/check-job-log-tail-slice.test.ts similarity index 100% rename from src/main/github/check-job-log-tail-slice.test.ts rename to src/shared/check-job-log-tail-slice.test.ts diff --git a/src/main/github/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts similarity index 82% rename from src/main/github/check-job-log-tail-slice.ts rename to src/shared/check-job-log-tail-slice.ts index 694a5dfec99..a5a9f483b1f 100644 --- a/src/main/github/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -10,8 +10,19 @@ export const PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR = '… earlier errors …' const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i +// Why: this slicer runs in main (GitHub/GitLab log fetches) and in the renderer +// (defensive re-slice of traces from older remote runtimes), so no Buffer. +function utf8ByteLength(text: string): number { + let bytes = 0 + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 0 + bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4 + } + return bytes +} + function applyLogTailByteCap(text: string): string { - if (Buffer.byteLength(text, 'utf8') <= PR_CHECK_LOG_TAIL_BYTES) { + if (utf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { return text } return sliceTrailingTextByUtf8Bytes(text, PR_CHECK_LOG_TAIL_BYTES) @@ -21,7 +32,7 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { let byteLength = 0 const characters = Array.from(text) for (let index = characters.length - 1; index >= 0; index -= 1) { - const characterByteLength = Buffer.byteLength(characters[index] ?? '', 'utf8') + const characterByteLength = utf8ByteLength(characters[index] ?? '') if (byteLength + characterByteLength > byteLimit) { return characters.slice(index + 1).join('') } @@ -32,14 +43,13 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = Buffer.byteLength(prefix, 'utf8') + const prefixByteLength = utf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { return sliceTrailingTextByUtf8Bytes(prefix, PR_CHECK_LOG_TAIL_BYTES) } const separator = prefix.length > 0 && recentLines.length > 0 ? '\n' : '' - const recentBudget = - PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - Buffer.byteLength(separator, 'utf8') + const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - utf8ByteLength(separator) const recentTail = sliceTrailingTextByUtf8Bytes(recentLines.join('\n'), recentBudget) return `${prefix}${separator}${recentTail}` } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9645d3ba3b6..80fca73c828 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -479,6 +479,7 @@ export function getDefaultUIState(): PersistedUIState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts index 4e89af300eb..4a5d28ca740 100644 --- a/src/shared/daemon-audit-eligibility.ts +++ b/src/shared/daemon-audit-eligibility.ts @@ -1,5 +1,7 @@ export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const +export const DAEMON_AUDIT_GENERATION_ROLE_VALUES = ['current', 'legacy'] as const + export const DAEMON_AUDIT_TRIGGER_VALUES = [ 'inventory_answered', 'inventory_failed', diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index bd0bcfae227..c522d2e7b02 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -57,6 +57,41 @@ describe('execution host registry', () => { ]) }) + it('excludes repository references from configured-only registries', () => { + const hosts = buildExecutionHostRegistry({ + repos: [ + { connectionId: 'removed-ssh' }, + { connectionId: null, executionHostId: 'runtime:removed-runtime' } + ], + settings: { activeRuntimeEnvironmentId: 'removed-focused-runtime' }, + hostSource: 'configured-only', + sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]), + runtimeEnvironments: [{ id: 'saved-runtime', name: 'Saved Runtime' }] + }) + + expect(hosts.map((host) => host.id)).toEqual([ + 'local', + 'runtime:saved-runtime', + 'ssh:saved-ssh' + ]) + }) + + it('keeps repository references in the default reference-inclusive registry', () => { + const hosts = buildExecutionHostRegistry({ + repos: [ + { connectionId: 'removed-ssh' }, + { connectionId: null, executionHostId: 'runtime:removed-runtime' } + ], + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.map((host) => host.id)).toEqual([ + 'local', + 'runtime:removed-runtime', + 'ssh:removed-ssh' + ]) + }) + it('hides runtime-owned (ephemeral VM) SSH targets from repo-derived hosts', () => { const hosts = buildExecutionHostRegistry({ // A VM-backed repo carries the hidden runtime-owned target on both fields. diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index 6e966329228..bceddd86a4b 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -54,6 +54,8 @@ type RuntimeHostStatus = { type RuntimeStatusByEnvironmentId = ReadonlyMap +export type ExecutionHostSource = 'configured-only' | 'include-references' + function normalizeHostPart(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null @@ -177,6 +179,7 @@ function addRuntimeHost( export function buildExecutionHostRegistry(args: { repos: readonly Pick[] settings: Pick | null | undefined + hostSource?: ExecutionHostSource sshTargetLabels?: ReadonlyMap sshConnectionStates?: ReadonlyMap runtimeEnvironments?: readonly RuntimeEnvironmentSummary[] @@ -219,7 +222,7 @@ export function buildExecutionHostRegistry(args: { const focusedHost = getSettingsFocusedExecutionHostId(args.settings) const parsedFocusedHost = parseExecutionHostId(focusedHost) - if (parsedFocusedHost?.kind === 'runtime') { + if (parsedFocusedHost?.kind === 'runtime' && args.hostSource !== 'configured-only') { addRuntimeHost( hosts, parsedFocusedHost.environmentId, @@ -230,21 +233,23 @@ export function buildExecutionHostRegistry(args: { } const sshTargetIds = new Set() - for (const repo of args.repos) { - const parsedHost = parseExecutionHostId(repo.executionHostId) - if (parsedHost?.kind === 'runtime') { - addRuntimeHost( - hosts, - parsedHost.environmentId, - parsedHost.environmentId, - undefined, - args.runtimeStatusByEnvironmentId - ) - } - // Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-`. Runtime-owned - // targets are hidden, so they must not become visible SSH run-target hosts here. - if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) { - sshTargetIds.add(parsedHost.targetId) + if (args.hostSource !== 'configured-only') { + for (const repo of args.repos) { + const parsedHost = parseExecutionHostId(repo.executionHostId) + if (parsedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedHost.environmentId, + parsedHost.environmentId, + undefined, + args.runtimeStatusByEnvironmentId + ) + } + // Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-`. Runtime-owned + // targets are hidden, so they must not become visible SSH run-target hosts here. + if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) { + sshTargetIds.add(parsedHost.targetId) + } } } for (const targetId of args.sshTargetLabels?.keys() ?? []) { @@ -253,10 +258,12 @@ export function buildExecutionHostRegistry(args: { sshTargetIds.add(normalized) } } - for (const repo of args.repos) { - const targetId = normalizeHostPart(repo.connectionId) - if (targetId && !isRuntimeOwnedSshTargetId(targetId)) { - sshTargetIds.add(targetId) + if (args.hostSource !== 'configured-only') { + for (const repo of args.repos) { + const targetId = normalizeHostPart(repo.connectionId) + if (targetId && !isRuntimeOwnedSshTargetId(targetId)) { + sshTargetIds.add(targetId) + } } } diff --git a/src/shared/gitlab-job-log-excerpt.test.ts b/src/shared/gitlab-job-log-excerpt.test.ts new file mode 100644 index 00000000000..d477f9f094b --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { gitLabJobTraceToLogExcerpt, toGitLabJobLogExcerptResult } from './gitlab-job-log-excerpt' +import { PR_CHECK_LOG_TAIL_BYTES } from './check-job-log-tail-slice' + +const CSI_ERASE = '\u001b[0K' +const RESET = '\u001b[0;m' +const CR = '\r' + +describe('gitLabJobTraceToLogExcerpt', () => { + it('keeps the visible header of a section marker and drops the marker itself', () => { + const trace = [ + `section_start:1699000000:build_script${CR}${CSI_ERASE}$ pnpm build`, + 'built in 3s', + `section_end:1699000030:build_script${CR}${CSI_ERASE}` + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('$ pnpm build') + expect(excerpt).toContain('built in 3s') + expect(excerpt).not.toContain('section_start') + expect(excerpt).not.toContain('section_end') + }) + + it('strips ANSI colour sequences', () => { + const excerpt = gitLabJobTraceToLogExcerpt(`${RESET}ERROR: Job failed${RESET}`) + + expect(excerpt).toBe('ERROR: Job failed') + expect(excerpt).not.toContain('[0;m') + }) + + it('splits carriage-return progress output so the line tail still applies', () => { + const progress = Array.from({ length: 5_000 }, (_, index) => `downloading ${index}%`).join('\r') + + const excerpt = gitLabJobTraceToLogExcerpt(`${progress}\nERROR: Job failed: exit code 1`) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).not.toContain('downloading 0%') + expect(excerpt.length).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + }) + + it('bounds a multi-megabyte trace below the 1 MB runtime transport frame cap', () => { + const huge = Array.from( + { length: 60_000 }, + (_, index) => `line ${index} ${'x'.repeat(60)}` + ).join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(huge) + + expect(huge.length).toBeGreaterThan(1024 * 1024) + expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + expect(excerpt).toContain('line 59999') + }) + + it('preserves earlier error context buried under trailing noise', () => { + const trace = [ + ...Array.from({ length: 150 }, (_, index) => `Installing package ${index}`), + 'ERROR: Job failed: exit code 1', + ...Array.from({ length: 90 }, (_, index) => `Uploading artifact ${index}`) + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).toContain('Uploading artifact 89') + }) + + it('bounds the raw trace before stripping so a huge log is not scanned in full', () => { + const filler = `${'x'.repeat(200)}\n`.repeat(6_000) // ~1.2 MB, above the raw cap + const excerpt = gitLabJobTraceToLogExcerpt(`head marker\n${filler}ERROR: Job failed`) + + expect(excerpt).toContain('ERROR: Job failed') + expect(excerpt).not.toContain('head marker') + }) + + it('returns an empty excerpt for a job that never produced a log', () => { + expect(gitLabJobTraceToLogExcerpt('')).toBe('') + expect(gitLabJobTraceToLogExcerpt(` \n${CSI_ERASE}\n `)).toBe('') + }) +}) + +describe('toGitLabJobLogExcerptResult', () => { + it('bounds a successful trace and passes errors through untouched', () => { + const bounded = toGitLabJobLogExcerptResult({ ok: true, trace: `${RESET}done` }) + expect(bounded).toEqual({ ok: true, trace: 'done' }) + + const failure = { ok: false, error: '403 Forbidden' } as const + expect(toGitLabJobLogExcerptResult(failure)).toBe(failure) + }) +}) diff --git a/src/shared/gitlab-job-log-excerpt.ts b/src/shared/gitlab-job-log-excerpt.ts new file mode 100644 index 00000000000..66754edb751 --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.ts @@ -0,0 +1,52 @@ +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' +import { sliceCheckLogTail } from './check-job-log-tail-slice' +import type { GitLabJobTraceResult } from './gitlab-types' + +// GitLab wraps collapsible sections in `section_start::\r0K`; +// strip only through the CR so the human-readable header on the same line survives. +const SECTION_MARKER_PREFIX_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*?\r/gm +// Sections without a visible header leave a marker-only line; drop it whole. +const SECTION_MARKER_LINE_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*\n?/gm + +// Why: stripping makes several full passes over the input, so a multi-megabyte CI log +// would stall the main-process event loop before the tail is trimmed to 16 KB anyway. +// 512 K chars is far more than the excerpt can keep, even with markup removed. +const MAX_RAW_TRACE_CHARS = 512 * 1024 + +function rawTraceTail(trace: string): string { + if (trace.length <= MAX_RAW_TRACE_CHARS) { + return trace + } + const tail = trace.slice(trace.length - MAX_RAW_TRACE_CHARS) + // Drop the partial first line so a marker or escape cut in half cannot survive stripping. + const firstLineBreak = tail.indexOf('\n') + return firstLineBreak === -1 ? tail : tail.slice(firstLineBreak + 1) +} + +/** + * Turn a raw `glab api .../jobs/:id/trace` body into a bounded, readable excerpt. + * + * Bounding is not cosmetic: the runtime RPC transports drop frames over 1 MB + * (`MAX_WS_MESSAGE_BYTES` / `MAX_RELAY_MESSAGE_BYTES`), so a multi-megabyte CI + * log must be cut before it crosses the wire, not after. + */ +export function gitLabJobTraceToLogExcerpt(trace: string): string { + if (!trace) { + return '' + } + // Order matters: ANSI first so the erase-to-EOL sequence between the marker's CR + // and its header text is gone, CR normalisation last so the marker patterns can + // still see the CR that separates a marker from its visible header. + const readable = stripAnsiEscapeSequences(rawTraceTail(trace)) + .replace(SECTION_MARKER_PREFIX_PATTERN, '') + .replace(SECTION_MARKER_LINE_PATTERN, '') + // Why: progress output redraws with bare CR, which would otherwise make the + // whole job log a single line and defeat the line-based tail. + .replace(/\r\n?/g, '\n') + return sliceCheckLogTail(readable).trim() +} + +/** Applied in main so only the excerpt crosses IPC / the runtime RPC socket. */ +export function toGitLabJobLogExcerptResult(result: GitLabJobTraceResult): GitLabJobTraceResult { + return result.ok ? { ok: true, trace: gitLabJobTraceToLogExcerpt(result.trace) } : result +} diff --git a/src/shared/gitlab-job-trace-check-details.test.ts b/src/shared/gitlab-job-trace-check-details.test.ts new file mode 100644 index 00000000000..59cf24cbf79 --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from './gitlab-job-trace-check-details' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './gitlab-types' +import type { PRCheckDetail } from './types' + +const STRINGS = { emptyTrace: 'No log is available for this GitLab job.' } + +const failedJob: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: 'https://gitlab.example.test/acme/orca/-/jobs/42', + gitlabJobId: 42 +} + +describe('gitLabJobTraceToCheckRunDetails', () => { + it('renders the trace as a single job log tail', () => { + const details = gitLabJobTraceToCheckRunDetails( + failedJob, + 'FAIL src/purchase/refund.spec.ts\nERROR: Job failed: exit code 1', + STRINGS + ) + + expect(details.jobs).toHaveLength(1) + expect(details.jobs[0]?.id).toBe(42) + expect(details.jobs[0]?.conclusion).toBe('failure') + expect(details.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + expect(details.summary).toBeNull() + }) + + it('copies status and conclusion from the row so the poll cache is not evicted', () => { + const details = gitLabJobTraceToCheckRunDetails(failedJob, 'boom', STRINGS) + + expect(details.status).toBe(failedJob.status) + expect(details.conclusion).toBe(failedJob.conclusion) + }) + + it('explains an empty trace instead of falling back to "no inline details"', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = gitLabJobTraceToCheckRunDetails(manualJob, '', STRINGS) + + expect(details.jobs).toEqual([]) + expect(details.summary).toBe(STRINGS.emptyTrace) + }) + + it('re-slices a raw trace from an older remote runtime that ignored logExcerpt', () => { + const raw = Array.from({ length: 5_000 }, (_, index) => `line ${index}`).join('\n') + + const details = gitLabJobTraceToCheckRunDetails(failedJob, raw, STRINGS) + + expect(details.jobs[0]?.logTail).toContain('line 4999') + expect(details.jobs[0]?.logTail).not.toContain('line 0\n') + }) +}) + +describe('gitLabJobCanHaveTrace', () => { + // Why: GitLab answers 404 for a job that never ran, so skip the round trip. Jobs that + // may have run are still asked; main turns a missing log into an empty trace. + const cases: { status: string; expected: boolean }[] = [ + { status: 'failed', expected: true }, + { status: 'success', expected: true }, + { status: 'running', expected: true }, + { status: 'canceled', expected: true }, + { status: 'created', expected: false }, + { status: 'pending', expected: false }, + { status: 'scheduled', expected: false }, + { status: 'manual', expected: false }, + { status: 'skipped', expected: false } + ] + + for (const { status, expected } of cases) { + it(`${expected ? 'requests' : 'skips'} a trace for a ${status} job`, () => { + const job = { + id: 5, + name: 'unit', + stage: 'test', + status, + webUrl: '', + duration: null + } as unknown as GitLabPipelineJob + const [check] = gitLabPipelineJobsToPRChecks([job]) + + expect(gitLabJobCanHaveTrace(check)).toBe(expected) + }) + } +}) diff --git a/src/shared/gitlab-job-trace-check-details.ts b/src/shared/gitlab-job-trace-check-details.ts new file mode 100644 index 00000000000..fd81789bc0a --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.ts @@ -0,0 +1,70 @@ +import { gitLabJobTraceToLogExcerpt } from './gitlab-job-log-excerpt' +import type { PRCheckDetail, PRCheckRunDetails } from './types' + +export type GitLabJobTraceCheckDetailsStrings = { + /** Shown for a job with no log: never ran, waiting on a human, or log erased/expired. */ + emptyTrace: string +} + +/** + * Whether GitLab can have a trace for this job yet. + * + * Jobs that never started (`created`/`pending` -> queued) and jobs waiting on a human + * (`manual` -> neutral) or bypassed (`skipped`) have no trace, so skip the round trip. + * Other jobs may still lack a log (canceled before start, erased); main maps that 404 + * to an empty trace rather than an error. + */ +export function gitLabJobCanHaveTrace(check: PRCheckDetail): boolean { + if (check.status === 'queued') { + return false + } + return check.conclusion !== 'neutral' && check.conclusion !== 'skipped' +} + +/** + * Adapt a GitLab job trace to the provider-neutral check-details shape the Checks + * panel and the full-details tab already render. GitLab exposes one flat trace per + * job rather than GitHub's step/annotation breakdown, so it lands in a single job's + * `logTail`. + */ +export function gitLabJobTraceToCheckRunDetails( + check: PRCheckDetail, + trace: string, + strings: GitLabJobTraceCheckDetailsStrings +): PRCheckRunDetails { + // Re-slice defensively: an older remote runtime returns the raw trace because it + // does not understand the `logExcerpt` request flag. + const logTail = gitLabJobTraceToLogExcerpt(trace) + return { + name: check.name, + // Why: copying the row's own state keeps the panel's status/conclusion cache + // invalidation from evicting this entry on every poll tick. + status: check.status, + conclusion: check.conclusion, + url: check.url, + detailsUrl: check.url, + startedAt: null, + completedAt: null, + title: null, + // Why: a job with no log still needs a distinct explanation, otherwise it falls + // back to the generic "no inline details" text this feature exists to remove. + summary: logTail ? null : strings.emptyTrace, + text: null, + annotations: [], + jobs: logTail + ? [ + { + id: check.gitlabJobId ?? null, + name: check.name, + status: check.status, + conclusion: check.conclusion, + startedAt: null, + completedAt: null, + url: check.url, + logTail, + steps: [] + } + ] + : [] + } +} diff --git a/src/shared/gitlab-pipeline-checks.test.ts b/src/shared/gitlab-pipeline-checks.test.ts index 673fcf3cd16..71f2be73d8d 100644 --- a/src/shared/gitlab-pipeline-checks.test.ts +++ b/src/shared/gitlab-pipeline-checks.test.ts @@ -44,26 +44,42 @@ describe('gitLabPipelineJobsToPRChecks', () => { name: 'test: unit', status: 'completed', conclusion: 'failure', - url: 'https://gitlab.com/acme/orca/-/jobs/1' + url: 'https://gitlab.com/acme/orca/-/jobs/1', + gitlabJobId: 1 }, { name: 'deploy: deploy', status: 'completed', conclusion: 'neutral', - url: null + url: null, + gitlabJobId: 2 }, { name: 'deploy: delayed deploy', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/3' + url: 'https://gitlab.com/acme/orca/-/jobs/3', + gitlabJobId: 3 }, { name: 'integration: external callback', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/4' + url: 'https://gitlab.com/acme/orca/-/jobs/4', + gitlabJobId: 4 } ]) }) + + it('omits the job id when GitLab does not report a usable one', () => { + const jobs = [ + { id: 0, name: 'zero', stage: '', status: 'failed', webUrl: '', duration: null }, + { name: 'missing', stage: '', status: 'failed', webUrl: '', duration: null } + ] as unknown as GitLabPipelineJob[] + + // Why: the runtime RPC schema requires a positive int, so a falsy id must not be forwarded. + for (const check of gitLabPipelineJobsToPRChecks(jobs)) { + expect(check).not.toHaveProperty('gitlabJobId') + } + }) }) diff --git a/src/shared/gitlab-pipeline-checks.ts b/src/shared/gitlab-pipeline-checks.ts index f993be157a3..9cb9a2b1ebd 100644 --- a/src/shared/gitlab-pipeline-checks.ts +++ b/src/shared/gitlab-pipeline-checks.ts @@ -62,6 +62,8 @@ export function gitLabPipelineJobsToPRChecks(jobs: GitLabPipelineJob[]): PRCheck name: job.stage ? `${job.stage}: ${job.name}` : job.name, status: mapGitLabPipelineJobStatusToCheckStatus(job.status), conclusion: mapGitLabPipelineJobStatusToConclusion(job.status), - url: job.webUrl || null + url: job.webUrl || null, + // Truthiness, not != null: the runtime RPC schema requires a positive int. + ...(job.id ? { gitlabJobId: job.id } : {}) })) } diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 714d962a1ab..d7b46db0959 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 31, + "daemonProtocolVersion": 32, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31 + 27, 28, 29, 30, 31, 32 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index f65912cc450..07bbacd3d3a 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 31, + daemonProtocolVersion: 32, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31 + 27, 28, 29, 30, 31, 32 ] } as const diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index de85ef8d8bd..f95fe2f45c4 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -26,6 +26,8 @@ const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.workerStart', 'orchestration.workerStop', 'orchestration.workerAbandon', + 'orchestration.workerRelease', + 'orchestration.workerRetain', 'orchestration.ask', 'orchestration.gateCreate', 'orchestration.gateResolve', diff --git a/src/shared/orchestration-worker-output.ts b/src/shared/orchestration-worker-output.ts index 250e8ccded9..8e61d1d3b97 100644 --- a/src/shared/orchestration-worker-output.ts +++ b/src/shared/orchestration-worker-output.ts @@ -44,6 +44,8 @@ export type OrchestrationWorkerReadTranscriptResult = { } fallbackReason: null warnings: string[] + // The live PTY was released; output comes from the frozen archive source. + archived?: boolean } export type OrchestrationWorkerReadTerminalResult = { @@ -58,6 +60,8 @@ export type OrchestrationWorkerReadTerminalResult = { } fallbackReason: OrchestrationWorkerReadFallbackReason | null warnings: string[] + // The live PTY was released; output comes from the frozen archive source. + archived?: boolean } export type OrchestrationWorkerReadResult = diff --git a/src/shared/pr-checks-fix-prompt.ts b/src/shared/pr-checks-fix-prompt.ts index e31a9fbbf80..ff50a410942 100644 --- a/src/shared/pr-checks-fix-prompt.ts +++ b/src/shared/pr-checks-fix-prompt.ts @@ -82,6 +82,11 @@ export function getCheckDetailsPromptKey(check: PRCheckDetail, index: number): s if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}:${check.name}` } + // Keep in step with getCheckIdentityKey / getCheckRunTabIdentity: a GitLab job + // without a web_url would otherwise key on its index and miss its loaded log. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}:${check.name}` + } if (check.url) { return `url:${check.url}:${check.name}` } diff --git a/src/shared/pty-slave-line-discipline-echo.test.ts b/src/shared/pty-slave-line-discipline-echo.test.ts new file mode 100644 index 00000000000..a1889957114 --- /dev/null +++ b/src/shared/pty-slave-line-discipline-echo.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { createPtySlaveEchoProbe, readPtySlavePath } from './pty-slave-line-discipline-echo' + +/** Replies to the next stty call with the given output, or an error when `output` is null. */ +function answerStty(output: string | null): void { + execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { + cb(output === null ? new Error('stty: no such file') : null, output ?? '', '') + }) +} + +const COOKED = 'speed 38400 baud;\nlflags: icanon isig iexten echo echoe echok echoctl\n' +const RAW = 'speed 38400 baud;\nlflags: -icanon -isig -iexten -echo -echoe -echok -echoctl\n' + +beforeEach(() => { + execFileMock.mockReset() +}) + +describe('readPtySlavePath', () => { + it('reads node-pty ptsName and rejects every shape that is not a usable path', () => { + expect(readPtySlavePath({ ptsName: '/dev/ttys048' })).toBe('/dev/ttys048') + // A ConPTY terminal has no ptsName at all, and an empty one names no device. + expect(readPtySlavePath({})).toBeUndefined() + expect(readPtySlavePath({ ptsName: '' })).toBeUndefined() + expect(readPtySlavePath({ ptsName: 12 })).toBeUndefined() + expect(readPtySlavePath(undefined)).toBeUndefined() + expect(readPtySlavePath(null)).toBeUndefined() + }) +}) + +describe('createPtySlaveEchoProbe', () => { + it('has no probe to offer when there is no POSIX slave to read', () => { + expect(createPtySlaveEchoProbe('/dev/ttys048', 'win32')).toBeUndefined() + expect(createPtySlaveEchoProbe(undefined, 'darwin')).toBeUndefined() + }) + + it('reads the ECHO bit off the slave', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(COOKED) + await expect(probe?.()).resolves.toBe('echoing') + answerStty(RAW) + await expect(probe?.()).resolves.toBe('quiet') + }) + + it('does not read `echoctl` or `echoe` as the ECHO bit', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + // Why: a substring match on "echo" reports echoing for a raw tty that merely keeps + // echoctl set, which is the exact tty the write must not be held back for. + answerStty('lflags: -icanon -echo echoe echok echoctl echoke\n') + await expect(probe?.()).resolves.toBe('quiet') + }) + + it('reports unknown rather than quiet when the slave cannot be read', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(null) + await expect(probe?.()).resolves.toBe('unknown') + }) + + it('reports unknown when the output carries no echo flag at all', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty('speed 38400 baud;\n') + await expect(probe?.()).resolves.toBe('unknown') + }) + + it('stops spawning stty once it has failed, but keeps re-reading a live slave', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(null) + await probe?.() + await probe?.() + await probe?.() + expect(execFileMock).toHaveBeenCalledTimes(1) + + // The bit itself is what changes, so a working probe is never cached. + const live = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(COOKED) + await expect(live?.()).resolves.toBe('echoing') + answerStty(RAW) + await expect(live?.()).resolves.toBe('quiet') + expect(execFileMock).toHaveBeenCalledTimes(3) + }) + + it('keeps probing after a transient failure and only latches a permanent one', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + // Why: a multi-pane restore forks these in a burst, so EAGAIN and the timeout kill + // are contention — condemning the pty to guessing for its whole life on one of + // those is the failure mode, not the protection. + for (const transient of [ + Object.assign(new Error('spawn EAGAIN'), { code: 'EAGAIN' }), + Object.assign(new Error('killed'), { killed: true }), + Object.assign(new Error('too many files'), { code: 'EMFILE' }) + ]) { + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => cb(transient, '', '')) + await expect(probe?.()).resolves.toBe('unknown') + } + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => cb(null, RAW, '')) + await expect(probe?.()).resolves.toBe('quiet') + expect(execFileMock).toHaveBeenCalledTimes(4) + + // A non-zero exit means the device is gone or was never a tty: permanent. + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => + cb(Object.assign(new Error('not a tty'), { code: 1 }), '', '') + ) + await expect(probe?.()).resolves.toBe('unknown') + await expect(probe?.()).resolves.toBe('unknown') + expect(execFileMock).toHaveBeenCalledTimes(5) + }) + + it('passes the device with the flag its own platform understands', async () => { + answerStty(RAW) + await createPtySlaveEchoProbe('/dev/ttys048', 'darwin')?.() + expect(execFileMock.mock.calls[0]?.[1]).toEqual(['-a', '-f', '/dev/ttys048']) + answerStty(RAW) + await createPtySlaveEchoProbe('/dev/pts/3', 'linux')?.() + expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3']) + }) +}) diff --git a/src/shared/pty-slave-line-discipline-echo.ts b/src/shared/pty-slave-line-discipline-echo.ts new file mode 100644 index 00000000000..c54cd61e974 --- /dev/null +++ b/src/shared/pty-slave-line-discipline-echo.ts @@ -0,0 +1,102 @@ +import { execFile, type ExecFileException } from 'node:child_process' + +// Why this exists: a startup color reply is written to the PTY master, and a POSIX +// line discipline in ECHO copies it straight back out as visible junk (#12112). +// Whether that will happen is readable state on the slave, not something that has to +// be inferred from the bytes that come back — so Orca asks instead of guessing. + +/** `unknown` means "could not be determined", never "assume quiet". */ +export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown' + +export type PtySlaveEchoProbe = () => Promise + +const STTY_TIMEOUT_MS = 2_000 +// `stty -a` prints the lflags as a space-separated list where a disabled flag is +// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter. +const ECHO_FLAG = /(?:^|\s)(-?)echo(?:\s|$)/ + +function sttyArgs(ptsName: string, platform: NodeJS.Platform): readonly string[] { + // BSD/macOS take `-f`; Linux (GNU coreutils) takes `-F`. + return platform === 'darwin' || platform.includes('bsd') + ? ['-a', '-f', ptsName] + : ['-a', '-F', ptsName] +} + +function parseEchoFlag(sttyOutput: string): PtySlaveLineDisciplineEcho { + const match = ECHO_FLAG.exec(sttyOutput) + if (!match) { + return 'unknown' + } + return match[1] === '-' ? 'quiet' : 'echoing' +} + +type SttyProbeResult = { state: PtySlaveLineDisciplineEcho; permanent: boolean } + +/** + * A spawn that never ran (`stty` absent) or a device that answered non-zero (reaped, + * not a tty) will answer the same way forever. A kill by the timeout, or a fork that + * failed for want of a resource, is contention — the very thing a multi-pane restore + * produces — and must not condemn the pty to guessing for the rest of its life. + */ +function isPermanentSttyFailure(error: ExecFileException): boolean { + if (error.killed || error.signal) { + return false + } + return error.code !== 'EAGAIN' && error.code !== 'EMFILE' && error.code !== 'ENFILE' +} + +function runStty(ptsName: string, platform: NodeJS.Platform): Promise { + return new Promise((resolve) => { + execFile( + 'stty', + sttyArgs(ptsName, platform), + { timeout: STTY_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + resolve( + error + ? { state: 'unknown', permanent: isPermanentSttyFailure(error) } + : { state: parseEchoFlag(stdout), permanent: false } + ) + } + ) + }) +} + +/** + * node-pty's UnixTerminal carries the slave device path, but its public typings do not + * declare it and the Windows terminal has no such field — so read it defensively. + */ +export function readPtySlavePath(pty: unknown): string | undefined { + const candidate = (pty as { ptsName?: unknown } | null | undefined)?.ptsName + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined +} + +/** + * Probe for whether the slave would echo a write to the master right now. + * + * Returns undefined when the platform has no line discipline to read: ConPTY and + * wsl.exe do not echo a master write at all, so a caller with no probe is correct to + * write immediately rather than degraded. A probe that exists but answers `unknown` + * is the degraded case, and callers must not read that as `quiet`. + */ +export function createPtySlaveEchoProbe( + ptsName: string | undefined, + platform: NodeJS.Platform = process.platform +): PtySlaveEchoProbe | undefined { + if (platform === 'win32' || !ptsName) { + return undefined + } + // Why latch: `stty` missing or the slave already reaped is a permanent condition for + // this pty, and the caller polls — without this a dead probe respawns a process per + // attempt. A successful probe is never cached, because the bit is what changes, and a + // transient failure is not latched at all (see isPermanentSttyFailure). + let unavailable = false + return async () => { + if (unavailable) { + return 'unknown' + } + const result = await runStty(ptsName, platform) + unavailable = result.permanent + return result.state + } +} diff --git a/src/shared/pty-startup-ingress-contract.ts b/src/shared/pty-startup-ingress-contract.ts index c8e865faa99..461c6d6c836 100644 --- a/src/shared/pty-startup-ingress-contract.ts +++ b/src/shared/pty-startup-ingress-contract.ts @@ -1,5 +1,6 @@ import type { PtyOwnerBackend } from './pty-owner-backend' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' +import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo' export type PtyIngressEmission = { data: string @@ -13,6 +14,12 @@ export type PtyStartupIngressOptions = { ownerBackend?: PtyOwnerBackend write: (data: string) => void onEmission: (emission: PtyIngressEmission) => void + /** + * Reports whether the slave would echo a reply written to the master. When present, + * the reply waits for `quiet` instead of relying on echo-shape recognition. Absent + * on backends with no line discipline to read (ConPTY, wsl.exe). + */ + echoProbe?: PtySlaveEchoProbe } export type PtyIngressSourceSpan = { @@ -27,6 +34,7 @@ export type PtyStartupIngressOperation = | { kind: 'snapshot' } | { kind: 'teardown' } | { kind: 'expire' } + | { kind: 'release-echo' } export function slicePtyIngressSourceSpan( span: PtyIngressSourceSpan, diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts index a6ec33a371c..c45d546d59c 100644 --- a/src/shared/pty-startup-ingress.test.ts +++ b/src/shared/pty-startup-ingress.test.ts @@ -4,10 +4,28 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from './pty-startup-ingress' +import type { + PtySlaveEchoProbe, + PtySlaveLineDisciplineEcho +} from './pty-slave-line-discipline-echo' const COLORS = { foreground: '#2e3434', background: '#ffffff' } +const FOREGROUND_REPLY = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' +const BACKGROUND_REPLY = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' +// The two echo shapes a cooked POSIX tty produces for a written reply: ECHOCTL +// caret forms, and readline eating `ESC ]` / ST while self-inserting the rest. +const POSIX_COOKED_ECHOES = [ + (reply: string): string => reply.replaceAll('\x1b', '^['), + (reply: string): string => reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') +] -function createHarness(options: { projection?: boolean; nested?: (data: string) => void } = {}) { +function createHarness( + options: { + projection?: boolean + nested?: (data: string) => void + echoProbe?: PtySlaveEchoProbe + } = {} +) { const emissions: PtyIngressEmission[] = [] let ingress!: PtyStartupIngress const writes: string[] = [] @@ -17,6 +35,7 @@ function createHarness(options: { projection?: boolean; nested?: (data: string) deadlineMs: 5_000 }, ...(options.projection ? { ownerBackend: 'windows-conpty' as const } : {}), + ...(options.echoProbe ? { echoProbe: options.echoProbe } : {}), write: (data) => { writes.push(data) options.nested?.(data) @@ -26,6 +45,19 @@ function createHarness(options: { projection?: boolean; nested?: (data: string) return { ingress, writes, emissions } } +/** Probe that answers from a script, repeating its last answer once exhausted. */ +function scriptedEchoProbe(...states: PtySlaveLineDisciplineEcho[]) { + let index = 0 + const probe: PtySlaveEchoProbe & { calls: number } = Object.assign( + async () => { + probe.calls += 1 + return states[Math.min(index++, states.length - 1)] ?? 'unknown' + }, + { calls: 0 } + ) + return probe +} + function visible(emissions: readonly PtyIngressEmission[]): string { return emissions.map((emission) => emission.data).join('') } @@ -42,12 +74,17 @@ describe('PtyStartupIngress', () => { expect(parsePtyStartupIngressIntent({ ...intent, deadlineMs: 30_001 })).toBeUndefined() }) - it('recognizes BEL/ST queries at every split and emits canonical replies', () => { + it('recognizes BEL/ST queries at every split and defers canonical replies', () => { + vi.useFakeTimers() const query = '\x1b]10;?\x07\x1b]11;?\x1b\\' for (let split = 0; split <= query.length; split += 1) { const { ingress, writes, emissions } = createHarness() ingress.accept(query.slice(0, split)) ingress.accept(query.slice(split)) + // Why: answering inside the query's own turn beats the querying program's + // tcsetattr, so a cooked tty echoes the reply as text instead (#12112). + expect(writes, `split ${split}`).toEqual([]) + vi.advanceTimersByTime(0) ingress.drainAndClose() expect(visible(emissions), `split ${split}`).toBe('') expect(writes, `split ${split}`).toEqual([ @@ -116,6 +153,7 @@ describe('PtyStartupIngress', () => { }) it('serializes a synchronous nested provider callback after the consumed query span', () => { + vi.useFakeTimers() const emissions: PtyIngressEmission[] = [] let ingress!: PtyStartupIngress ingress = new PtyStartupIngress({ @@ -124,6 +162,7 @@ describe('PtyStartupIngress', () => { onEmission: (emission) => emissions.push(emission) }) ingress.accept('before\x1b]10;?\x07after') + vi.advanceTimersByTime(0) ingress.drainAndClose() expect(emissions.map(({ data, transformed }) => ({ data, transformed }))).toEqual([ { data: 'before', transformed: false }, @@ -260,6 +299,515 @@ describe('PtyStartupIngress', () => { expect(visible(emissions)).toBe(input) }) + it('swallows a cooked POSIX echo of its own reply without re-sending it', () => { + // Why never re-send: POSIX ECHO copies the reply to the master but leaves it in + // the slave input queue, so the program still reads it; a second write would + // arrive on its stdin as unsolicited input once it is raw. + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + let ingress!: PtyStartupIngress + ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => { + writes.push(data) + ingress.accept(echoOf(data)) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(visible(emissions)).toBe('') + + vi.advanceTimersByTime(5_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(visible(emissions)).toBe('') + ingress.drainAndClose() + } + }) + + it('swallows a cooked POSIX echo coalesced behind earlier program output', () => { + // Why this shape: an agent pane is launched by writing a command into an interactive + // shell, so the tty echo of Orca's reply never arrives at the head of a read (#12112). + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const replies: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => replies.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + vi.advanceTimersByTime(0) + expect(replies).toHaveLength(2) + + // A read with no echo in it must not retire the projections either. + ingress.accept('booting...\r\n') + ingress.accept(`\x1b[2JFRAME${replies.map((reply) => echoOf(reply)).join('')}`) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('booting...\r\n\x1b[2JFRAME') + } + }) + + it('answers both slots when the deferred write lands between two reads of the burst', () => { + // Why between: a pty read boundary is a macrotask, so the deferred reply is written + // while the rest of the burst is still unread. A `\x07` head-of-echo guess taken then + // steals the OSC 11 terminator, leaving the slot unanswered and its bytes emitted + // after the BEL — which parks xterm in an OSC that never terminates. + vi.useFakeTimers() + const burst = '\x1b]10;?\x07\x1b]11;?\x07' + for (let split = 0; split <= burst.length; split += 1) { + const { ingress, writes, emissions } = createHarness() + ingress.accept(burst.slice(0, split)) + vi.advanceTimersByTime(0) + ingress.accept(burst.slice(split)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + expect(writes, `split ${split}`).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + expect(visible(emissions), `split ${split}`).toBe('') + } + }) + + it('keeps raw ranges disjoint when an echo lands on a retained torn query', () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07\x1b]11;?') + vi.advanceTimersByTime(0) + ingress.accept(`${writes[0]?.replaceAll('\x1b', '^[')}tail`) + const accepted = ingress.drainAndClose() + + expect(visible(emissions)).toBe('\x1b]11;?tail') + // Why exact ranges: a candidate carried across the suppressed echo re-emits its own + // bytes on a span whose end no longer matches its data, so ranges start to overlap. + expect(emissions.map((item) => [item.rawStartSeq, item.rawEndSeq])).toEqual([ + [0, 7], + [7, 13], + [13, 40], + [40, accepted] + ]) + }) + + it('releases a partial echo hold long before the startup deadline', () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // Why a range and not the exact hold: what matters is that the guess outlasts + // relay jitter yet still resolves without the deadline's help. Pinning the exact + // value would fail on any honest retune while teaching the retuner nothing. + const RELAY_JITTER_MS = 400 + const WELL_BELOW_DEADLINE_MS = 1_500 + + // A lone BEL is the head of the readline echo projection, so it is held. + ingress.accept('\x07') + expect(visible(emissions)).toBe('') + vi.advanceTimersByTime(RELAY_JITTER_MS) + expect(visible(emissions)).toBe('') + vi.advanceTimersByTime(WELL_BELOW_DEADLINE_MS - RELAY_JITTER_MS) + + expect(visible(emissions)).toBe('\x07') + ingress.drainAndClose() + }) + + it('still swallows the echo of a reply the startup deadline raced', () => { + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + // The deferred write flushes at 4_999, then the deadline expires at 5_000. + vi.advanceTimersByTime(2) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(FOREGROUND_REPLY.replaceAll('\x1b', '^[')) + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('writes a reply the startup deadline raced instead of dropping it', () => { + // Why: the query span was already consumed, so nobody downstream can answer it. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + expect(writes).toEqual([]) + vi.advanceTimersByTime(1) + + expect(visible(emissions)).toBe('') + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('keeps the synchronous write for ConPTY-hosted wsl.exe panes', () => { + // Why: a Windows-hosted pty must be answered before conhost's own responder. + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-wsl', + write: (data) => writes.push(data), + onEmission: () => {} + }) + + ingress.accept('\x1b]10;?\x07') + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('swallows a coalesced POSIX echo torn at every byte boundary', () => { + // Why a prefix matters: recognition used to hold a split echo only when it began + // at offset 0, so a single byte of program output ahead of it made every torn + // boundary leak the reply verbatim — the exact #12112 symptom the fix targets. + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const echo = echoOf(FOREGROUND_REPLY) + for (let split = 1; split < echo.length; split += 1) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(`FRAME${echo.slice(0, split)}`) + ingress.accept(echo.slice(split)) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('FRAME') + } + } + }) + + it('still recognizes an echo that arrives behind an enormous splash frame', () => { + // Why: the search budget must not be spent by one large frame, retiring the + // projection while the echo is still in flight behind it. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // One enormous splash frame must not retire the projection. + ingress.accept('x'.repeat(64_000)) + ingress.accept(FOREGROUND_REPLY.replaceAll('\x1b', '^[')) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('x'.repeat(64_000)) + }) + + it('stops shadowing the stream once the projection outlives its search budget', () => { + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + const printed = 'tick\r\n'.repeat(50_000) + ingress.accept(printed) + + // The echo never came, so a later exact collision is ordinary output again. + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('bounds echo suppression to a few hundred bytes past the startup deadline', () => { + // Why: reset() keeps a raced reply recognizable, but an unbounded projection would + // keep deleting matching spans out of ordinary output for the rest of the session. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(2) + expect(writes).toEqual([FOREGROUND_REPLY]) + + const printed = 'a\r\n'.repeat(200) + ingress.accept(printed) + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('drops its answered claim when a deferred write fails so a retry falls through', () => { + // Why: the deferred write already reported success, so the first query was consumed + // on its behalf. Without the rollback the slot stays claimed forever and no + // downstream color authority ever sees the query either. + vi.useFakeTimers() + let failWrites = true + const emissions: PtyIngressEmission[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => { + if (failWrites) { + throw new Error('EIO') + } + writes.push(data) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([]) + + failWrites = false + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + ingress.drainAndClose() + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + + it('ages a projection out even when every read ends mid-candidate', () => { + // Why: the read budget is charged once per read at the entry point, so a stream + // whose every read ends on a candidate byte still retires a projection that never + // lands. Charging only on reads that fall through left it alive forever. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // A trailing `^` is a strict prefix of the caret projection, so every one of these + // reads returns holding a candidate. + let printed = '' + for (let read = 0; read < 8; read += 1) { + const chunk = `${'line of output\r\n'.repeat(4_000)}^` + printed += chunk + ingress.accept(chunk) + } + + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('swallows an echo no matter how finely the tty chunks it', () => { + // Why: an SSH relay or a slow drain delivers the echo a few bytes at a time. A + // per-read budget was spent inside the echo itself, so the leak came back for any + // chunking finer than the budget. + vi.useFakeTimers() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + for (const chunkSize of [1, 2, 3, 5, 13]) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + for (let at = 0; at < echo.length; at += chunkSize) { + ingress.accept(echo.slice(at, at + chunkSize)) + } + ingress.drainAndClose() + + expect({ chunkSize, visible: visible(emissions) }).toEqual({ chunkSize, visible: '' }) + } + }) + + it('lets every downstream barrier cut a partial echo hold short', () => { + // Why pinned: the hold window is only affordable because it is not what bounds + // the wait — these are. If one stopped releasing, the window would become a + // real stall rather than a bet on the next read. + vi.useFakeTimers() + const cutShort: Record void> = { + snapshotBarrier: (ingress) => ingress.snapshotBarrier(), + drainAndClose: (ingress) => ingress.drainAndClose(), + startupDeadline: () => vi.advanceTimersByTime(5_000) + } + for (const [name, cut] of Object.entries(cutShort)) { + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // A lone BEL heads the readline projection, so it is held rather than shown. + ingress.accept('\x07') + expect({ name, held: visible(emissions) }).toEqual({ name, held: '' }) + cut(ingress) + + expect({ name, released: visible(emissions) }).toEqual({ name, released: '\x07' }) + } + }) + + it('keeps swallowing an echo split across the query-authority handoff', () => { + // Why the asymmetry with snapshotBarrier is deliberate: closing query authority + // hands off who may answer, but the reply is already on the wire and its echo is + // still Orca's to swallow. Cutting the hold here would show its first half. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept(echo.slice(0, 10)) + ingress.closeQueryAuthority() + ingress.accept(echo.slice(10)) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('') + }) + + it('swallows an echo whose halves straddle a relay-sized stall', () => { + // Why: an expired hold releases raw, so a hold shorter than real inter-chunk + // jitter reinstates the leak on exactly the links Orca has to work over. + vi.useFakeTimers() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + for (const gapMs of [50, 200, 400]) { + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(echo.slice(0, 10)) + vi.advanceTimersByTime(gapMs) + ingress.accept(echo.slice(10)) + ingress.drainAndClose() + + expect({ gapMs, visible: visible(emissions) }).toEqual({ gapMs, visible: '' }) + } + }) + + it('swallows an echo that arrives behind a query torn on an earlier read', () => { + // Why: the tty can tear the program's second query and start echoing the first + // reply in the same read. Refusing to hold while a query is pending leaked the + // whole echo, because the prefix that would have completed that query was never + // emitted first. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept('\x1b]11;') + ingress.accept(`?\x07${echo.slice(0, 8)}`) + ingress.accept(echo.slice(8)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('') + // The torn query is still answered: it is the prefix that completes it. + expect(writes).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + }) + + it('drops a torn query the next read disproves instead of the echo behind it', () => { + // Why: with the echo starting at offset 0 there is no prefix to complete the torn + // candidate, so preferring it fed the echo to the raw path and printed both. The + // candidate is not a color query at all once the echo's first byte lands. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + ingress.accept('\x1b]11;') + ingress.accept(echo.slice(0, 8)) + ingress.accept(echo.slice(8)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + // Only the program's own bytes survive; the echo is gone rather than trailing them. + expect(visible(emissions)).toBe('\x1b]11;') + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + + it('keeps a landed reply claimed when the sibling query write fails', () => { + // Why: ConPTY writes inside the query's own turn, so one span can land slot 10 and + // lose slot 11. Forgetting every claim would answer 10 a second time, and a + // duplicate reply corrupts a parser already mid-read. + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-conpty', + write: (data) => { + if (data === BACKGROUND_REPLY) { + throw new Error('EIO') + } + writes.push(data) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + ingress.drainAndClose() + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + it('ignores callbacks after teardown without recreating the raw sequence domain', () => { const { ingress, emissions } = createHarness({ projection: true }) ingress.accept('\x1b]10;?\x07') @@ -269,4 +817,140 @@ describe('PtyStartupIngress', () => { expect(ingress.acceptedRawSequence).toBe(closedAt) expect(visible(emissions)).toBe(']10;rgb:2e2e/') }) + + it('withholds the reply while the slave would echo it, then writes once it is quiet', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing', 'echoing', 'quiet') + const { ingress, writes } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + // Nothing may go out while the line discipline is still cooked: that write is the + // one that comes straight back as visible junk (#12112). + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(echoProbe.calls).toBe(3) + ingress.drainAndClose() + }) + + it('retires only the kernel caret projection once the probe proves ECHO is clear', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('quiet') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // A cleared ECHO bit proves the kernel cannot produce the caret form, so output + // that merely resembles it is ordinary program output and must survive. + const caret = POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '' + ingress.accept(caret) + ingress.drainAndClose() + expect(visible(emissions)).toBe(caret) + }) + + it('still suppresses the readline echo on a slave the probe called quiet', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('quiet') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // Why: readline echoes a master write in software with the tty already raw and + // ECHO off, so `quiet` is no evidence at all about this shape. Verified on a live + // pty: at a bash prompt the probe reports quiet and readline still emits it. + ingress.accept(POSIX_COOKED_ECHOES[1]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('falls back to recognizing echo shapes when the probe cannot answer', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('unknown') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // `unknown` is not evidence of quiet, so the guess stays armed and swallows the echo. + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('falls back immediately when the echo probe rejects', async () => { + vi.useFakeTimers() + const echoProbe: PtySlaveEchoProbe = async () => { + throw new Error('probe failed') + } + const { ingress, writes, emissions } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + + await vi.advanceTimersByTimeAsync(0) + + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('stops polling a tty that never leaves cooked mode and answers it anyway', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing') + const { ingress, writes, emissions } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + // Waiting past this point only delays a reply that will echo whenever it is sent, + // so the reply goes out with the shape guess armed rather than being dropped. + expect(writes).toEqual([FOREGROUND_REPLY]) + // Bounded in wall-clock, not in probes: under fork contention each probe takes + // longer and the budget buys fewer of them, instead of the wait growing. + expect(echoProbe.calls).toBeLessThanOrEqual(11) + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('gives a later query its own probe budget, not the first query remainder', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing') + const { ingress, writes } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + const spentOnFirst = echoProbe.calls + // Why: OSC 10 and OSC 11 routinely arrive more than a budget apart over SSH. A + // counter carried across them would send the second reply out entirely unprobed. + ingress.accept('\x1b]11;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + expect(echoProbe.calls).toBeGreaterThan(spentOnFirst) + ingress.drainAndClose() + }) + + it('answers a still-pending reply when the startup deadline expires mid-poll', async () => { + vi.useFakeTimers() + const { ingress, writes } = createHarness({ echoProbe: scriptedEchoProbe('echoing') }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(60) + expect(writes).toEqual([]) + // The deadline is the outer bound: a reply held by a cooked tty still gets sent + // rather than dropped, because the querying program is blocked on it. + await vi.advanceTimersByTimeAsync(5_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('drops a held reply on teardown instead of writing to a dead pty', async () => { + vi.useFakeTimers() + const { ingress, writes } = createHarness({ echoProbe: scriptedEchoProbe('echoing') }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(20) + ingress.drainAndClose() + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([]) + }) }) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index 0675eb92a4b..edddd73dd43 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -5,6 +5,7 @@ import { } from './terminal-osc-color-reply' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' import type { PtyOwnerBackend } from './pty-owner-backend' +import { PtyStartupReplyDelivery } from './pty-startup-reply-delivery' import { combinePtyIngressSourceSpans, slicePtyIngressSourceSpan, @@ -22,11 +23,13 @@ export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' export type { PtyIngressEmission, PtyStartupIngressOptions } from './pty-startup-ingress-contract' const MAX_QUERY_CANDIDATE_CHARS = 64 - -function projectedWindowsConptyReply(reply: string): string { - // Why: the native provider harness observes ConPTY's cooked echo with ESC removed. - return reply.replaceAll('\x1b', '') -} +// Why this long: a torn echo whose halves straddle this window is released raw, so +// anything under relay jitter reinstates the leak (#12112). Almost nothing is risked +// by waiting, because the timer is rarely what ends a hold — the next read is, and +// the startup deadline and snapshot barrier both cap the wait independently. The +// exposure is at most one projection's worth of echo-shaped bytes on an already idle +// pane, which is why the guess is allowed to be slow rather than tight. +const ECHO_CONTINUATION_HOLD_MS = 500 /** * Serialized source-side startup classifier. Its raw sequence begins after @@ -35,23 +38,23 @@ function projectedWindowsConptyReply(reply: string): string { export class PtyStartupIngress { private readonly intent: PtyStartupIngressIntent | undefined private readonly ownerBackend: PtyOwnerBackend - private readonly writeProvider: (data: string) => void + private readonly delivery: PtyStartupReplyDelivery private readonly onEmission: (emission: PtyIngressEmission) => void private readonly operations: PtyStartupIngressOperation[] = [] private readonly answeredSlots = new Set() - private readonly expectedEchoes: string[] = [] private processing = false private closed = false private queryOpen: boolean private rawHighWater = 0 private queryPending: PtyIngressSourceSpan | null = null private echoPending: PtyIngressSourceSpan | null = null + private echoHoldTimer: ReturnType | null = null private deadlineTimer: ReturnType | null = null constructor(options: PtyStartupIngressOptions) { this.intent = options.intent this.ownerBackend = options.ownerBackend ?? 'posix-pty' - this.writeProvider = options.write + this.delivery = new PtyStartupReplyDelivery(this.ownerBackend, options.write, options.echoProbe) this.onEmission = options.onEmission this.queryOpen = options.intent !== undefined if (options.intent) { @@ -121,61 +124,110 @@ export class PtyStartupIngress { case 'close-query': if (this.ownerBackend !== 'windows-conpty') { this.queryOpen = false + // Why the echo hold deliberately survives this, unlike `snapshot`: the + // handoff ends query *authority*, but a reply already on the wire is still + // Orca's to swallow. Releasing here would show the first half of an echo + // split across the boundary and orphan the second. this.releaseQueryPending() } // Why: ConPTY cannot safely transfer color-query authority to a downstream view. return case 'expire': this.queryOpen = false - this.releaseEchoPending() - if (this.ownerBackend !== 'windows-conpty') { - this.releaseQueryPending() - } - this.expectedEchoes.length = 0 + this.releasePendingInSourceOrder(false) + this.delivery.reset() this.clearDeadline() return case 'snapshot': - this.releaseSnapshotPending() + case 'release-echo': + this.releasePendingInSourceOrder(false) return case 'teardown': this.queryOpen = false - this.releaseAllPending() - this.expectedEchoes.length = 0 + this.releasePendingInSourceOrder(true) + this.delivery.close() this.clearDeadline() this.closed = true } } + /** + * One PTY read. The charge is in `finally` because every path below can return + * early: charging after the match gives a real echo the whole read it arrives in, + * and charging unconditionally means a projection that never lands still ages out + * on the reads that end mid-candidate rather than shadowing the rest of the session. + * It charges the read, never the held-bytes-plus-read span, so a tail that waits + * across several reads is not billed again on each one. + */ private processEchoSpan(span: PtyIngressSourceSpan): void { - let input = combinePtyIngressSourceSpans(this.echoPending, span) - this.echoPending = null + try { + this.classifyRead(span) + } finally { + this.delivery.chargeEchoSearch(span.data.length) + } + } - while (this.expectedEchoes.length > 0) { - const expected = this.expectedEchoes[0] - const compared = Math.min(input.data.length, expected.length) - let matching = 0 - while (matching < compared && input.data[matching] === expected[matching]) { - matching += 1 - } - if (matching < compared) { - this.expectedEchoes.shift() - this.processQuerySpan(input) - return - } - if (input.data.length < expected.length) { - this.echoPending = input - return - } + private classifyRead(span: PtyIngressSourceSpan): void { + let input = combinePtyIngressSourceSpans(this.takeEchoPending(), span) - this.expectedEchoes.shift() - this.emit(slicePtyIngressSourceSpan(input, 0, expected.length), true, '') - input = slicePtyIngressSourceSpan(input, expected.length) - if (input.data.length === 0) { - return + while (this.delivery.hasExpectedEcho && input.data.length > 0) { + const match = this.delivery.matchEcho(input.data) + if (match.kind !== 'complete') { + // Why hold from the match rather than only at offset 0: the tty coalesces its + // echo with whatever the shell printed around it, so a split echo almost + // always arrives behind other bytes. Those bytes are emitted now and only the + // candidate tail waits, so recognition survives a split at any boundary + // without stalling real output. + if (match.kind === 'partial') { + const tail = slicePtyIngressSourceSpan(input, match.offset) + if (match.offset > 0) { + this.processQuerySpan(slicePtyIngressSourceSpan(input, 0, match.offset)) + } + // A still-torn query outranks the echo only while it can still become one: + // the tail may open with the BEL that terminates it, since the readline + // projection starts with one. Re-parsing it against the tail is what tells + // the two apart — a candidate the tail *disproves* is ordinary output that + // would otherwise absorb the echo behind it and dump both raw (#12112). + // + // `partial` counts as viable, not just `match`: the terminator can arrive a + // read later, and demoting it would emit a bare ESC and leave a real query + // unanswered until the program's own timeout. On ConPTY that costs an echo, + // because the ESC-stripped projection shares the `]10;` prefix with a real + // query and so keeps re-parsing as `partial` — a hang is the worse of the two. + if (this.queryPending) { + const resolved = combinePtyIngressSourceSpans(this.queryPending, tail) + if (parseTerminalOscColorQuery(resolved.data, 0).kind !== 'none') { + this.processQuerySpan(tail) + return + } + // Unconditional, unlike `releasePendingInSourceOrder`, which withholds a + // ConPTY candidate: that one releases candidates still *undetermined*, + // and on ConPTY an undetermined candidate may be a query it is meant to + // suppress. Here the candidate and the tail together parse as `none`, so + // whatever the candidate is, the bytes behind it are not its body — which + // is what makes it safe to stop holding the echo hostage to it. + this.releaseQueryPending() + } + this.echoPending = tail + this.armEchoHold() + return + } + break } + if (match.offset > 0) { + this.processQuerySpan(slicePtyIngressSourceSpan(input, 0, match.offset)) + } + // Why release first: a retained torn candidate cannot straddle the suppressed + // range without desynchronizing its raw sequence arithmetic. + this.releaseQueryPending() + const echoEnd = match.offset + match.length + this.emit(slicePtyIngressSourceSpan(input, match.offset, echoEnd), true, '') + input = slicePtyIngressSourceSpan(input, echoEnd) } - this.processQuerySpan(input) + if (input.data.length > 0) { + this.processQuerySpan(input) + } } private processQuerySpan(span: PtyIngressSourceSpan): void { @@ -244,22 +296,15 @@ export class PtyStartupIngress { return wroteAny } this.answeredSlots.add(slot) - const projected = - this.ownerBackend === 'windows-conpty' ? projectedWindowsConptyReply(reply) : null - if (projected) { - // Why: register before write because node-pty can synchronously re-enter onData. - this.expectedEchoes.push(projected) - } - try { - this.writeProvider(reply) - wroteAny = true - } catch { + // Why per slot: the replies to one query are written independently, so a + // deferred write that fails after reporting success invalidates only its own + // claim. Dropping every claim would let a slot that did land be answered a + // second time, and a duplicate reply corrupts a parser already mid-read. + if (!this.delivery.answer(reply, () => this.answeredSlots.delete(slot))) { this.answeredSlots.delete(slot) - if (projected) { - this.expectedEchoes.pop() - } return wroteAny } + wroteAny = true } if (this.answeredSlots.has(10) && this.answeredSlots.has(11)) { @@ -277,28 +322,41 @@ export class PtyStartupIngress { this.emit(pending, false) } - private releaseAllPending(): void { - this.releaseEchoPending() - this.releaseQueryPending() - } - - private releaseEchoPending(): void { - if (!this.echoPending) { - return - } - const pending = this.echoPending - this.echoPending = null - this.emit(pending, false) - } - - private releaseSnapshotPending(): void { - if (this.echoPending) { - this.expectedEchoes.shift() - this.releaseEchoPending() - } - if (this.ownerBackend !== 'windows-conpty') { + /** + * Why this order: were both ever live, queryPending would hold the earlier source + * bytes. `classifyRead` only ever arms one — it either keeps a viable query and + * returns, or releases a disproven one before holding the echo — so this is defense + * against a future second arming site, not a live inversion. + */ + private releasePendingInSourceOrder(includeConptyQuery: boolean): void { + if (includeConptyQuery || this.ownerBackend !== 'windows-conpty') { this.releaseQueryPending() } + const pending = this.takeEchoPending() + if (pending) { + this.emit(pending, false) + } + } + + private takeEchoPending(): PtyIngressSourceSpan | null { + const pending = this.echoPending + this.echoPending = null + if (this.echoHoldTimer) { + clearTimeout(this.echoHoldTimer) + this.echoHoldTimer = null + } + return pending + } + + private armEchoHold(): void { + if (this.echoHoldTimer) { + return + } + this.echoHoldTimer = setTimeout( + () => this.enqueue({ kind: 'release-echo' }), + ECHO_CONTINUATION_HOLD_MS + ) + this.echoHoldTimer.unref?.() } private emit(span: PtyIngressSourceSpan, transformed: boolean, data = span.data): void { diff --git a/src/shared/pty-startup-reply-delivery.ts b/src/shared/pty-startup-reply-delivery.ts new file mode 100644 index 00000000000..876fc26a000 --- /dev/null +++ b/src/shared/pty-startup-reply-delivery.ts @@ -0,0 +1,349 @@ +import type { PtyOwnerBackend } from './pty-owner-backend' +import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo' + +// Why this module exists: a startup color reply is written to the PTY master, so +// whatever line discipline sits between Orca and the querying program can echo it +// straight back out as ordinary output (#12112). ConPTY echoes it with the ESC +// bytes stripped; a POSIX tty echoes it while the querying program is still cooked. +// A program that queries before clearing ECHO loses that race if Orca answers +// inside the query's own turn, so on POSIX the write waits until the slave's ECHO +// bit is observably clear, and recognized echo shapes cover what remains. +// +// Deliberately NO re-send on a matched echo: ECHO copies bytes to the master +// without consuming them from the slave's input queue, so a program that arms raw +// mode with TCSANOW/TCSADRAIN (libuv's setRawMode, hence Node-based agents) still +// receives the reply, and re-writing would duplicate it in stdin. A TCSAFLUSH +// switcher does discard it; that case is left to the query timeout, because a +// duplicate reply corrupts a parser that is already mid-read. +// +// Why not PostReadyFlushGate's settle-and-fallback shape, which solves this same +// "don't write while ECHO is on" race for shell startup input: that gate defers +// bytes nothing is waiting on, so it can wait for the stream to go observably +// quiet. A color reply is different — the querying program is blocked on it and +// times out — so the wait here is bounded by a budget and always ends in a write. +// +// There are TWO echo sources and they are independent, which is the thing to hold onto +// when reading the rest of this file: +// +// 1. The kernel line discipline, when ECHO is set. Readable state — the probe asks +// the slave directly, and waiting for it to clear removes this echo outright. +// 2. The foreground line editor, in software. readline echoes a master write as if +// it were typed *while the tty is raw with ECHO off*, so the probe's verdict says +// nothing about it. Verified on a live pty: at a bash prompt the probe reports +// `quiet` and readline still emits `BEL 10;rgb:2e2e/3434/3434`. +// +// So `quiet` is proof about (1) only. It gates the withholding and retires the caret +// projection, and must never be read as "no suppression needed" — that reintroduces +// #12112 at a shell prompt, which is the foreground for most of an agent pane's +// startup window. The projections below stay armed for (2) on every path. + +export type PtyStartupReplyEchoMatch = + | { kind: 'complete'; offset: number; length: number } + | { kind: 'partial'; offset: number } + | { kind: 'none' } + +// Why bytes and not reads: the echo is a fixed ~30 bytes, but nothing bounds how the +// tty chunks them — an SSH relay or a slow drain delivers a few bytes at a time, and a +// per-read budget is then spent inside the echo itself. What actually bounds a live +// projection is the startup deadline; this is only a backstop against a pathological +// pre-deadline stream, so it is set well above any splash an echo could arrive behind. +const ECHO_SEARCH_BUDGET_BYTES = 256 * 1024 +// Why far tighter past the deadline: a reply still on the wire at expiry deserves the +// read or two its echo takes, but nothing beyond it — see reset(). +const ECHO_POST_DEADLINE_BUDGET_BYTES = 512 +// Why this tight: the querying program is blocked on the reply, so every interval is +// latency it pays. A raw-mode switch lands within a turn or two of the query, and the +// probe is a subprocess — this is the smallest interval that does not spin on it. +const ECHO_POLL_INTERVAL_MS = 20 +// Why a budget at all: the startup deadline runs to 30s, which at this interval is a +// four-figure count of probe subprocesses. It is also the wrong bound — a tty still +// cooked this long after its own query never leaves cooked mode, and waiting on it only +// delays a reply that will echo whenever it is sent. +// +// Why wall-clock rather than a probe count: each probe is a subprocess, so a multi-pane +// restore serializes them on fork — a count-based cap measured ~26ms per probe across +// 30 panes, turning a nominal 200ms into ~8s of withholding and blowing past every +// query timeout at once. A deadline spends fewer probes under load instead of taking +// longer, which is the direction that fails safe: measured flat at ~210ms of withholding +// from 1 to 100 concurrent panes, with probe spawns plateauing rather than scaling. +// +// This bounds when a probe is STARTED, not one already in flight, so the hard bound is +// this plus STTY_TIMEOUT_MS — still inside the startup deadline that reset() enforces. +const ECHO_POLL_BUDGET_MS = 200 + +type ExpectedEcho = { projections: readonly string[]; remainingBytes: number } +type PendingWrite = { reply: string; onFailed: (() => void) | undefined } + +/** Only a POSIX tty both echoes the reply and still delivers a deferred write. */ +function defersWrite(ownerBackend: PtyOwnerBackend): boolean { + return ownerBackend === 'posix-pty' +} + +function replyEchoProjections( + reply: string, + ownerBackend: PtyOwnerBackend, + kernelEchoImpossible: boolean +): readonly string[] { + if (ownerBackend === 'windows-conpty') { + // Why: ConPTY's projection is the documented, deterministic ESC-stripped form. + return [reply.replaceAll('\x1b', '')] + } + if (!defersWrite(ownerBackend)) { + // wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing. + return [] + } + // What makes both shapes below safe to match on is that neither starts with ESC, so + // no query can share a prefix with them. The verbatim echo of a `stty -echoctl` tty + // is deliberately NOT projected for exactly that reason: it is byte-identical to the + // reply, so a bare trailing ESC — how any read can end — is a strict prefix of it. + // That read would be held as an echo candidate, and an expired hold releases its + // bytes raw, past the query parser, so a query torn at its own ESC is never answered. + return [ + // ECHOCTL (default cooked tty) renders each control byte as its caret form. This is + // the ONE projection the probe can retire, because it is the kernel's echo and a + // cleared ECHO bit is proof it cannot happen. + ...(kernelEchoImpossible ? [] : [reply.replaceAll('\x1b', '^[')]), + // readline: `ESC ]` is an unbound binding, so it is eaten (with a bell) and the + // remainder self-inserts; the ST is eaten the same way. Software echo — survives + // `quiet`, because readline does this with the tty already raw and ECHO off. + // + // This buys display cleanliness ONLY. The bytes self-inserted into readline's edit + // buffer are still there, so a user who then presses Enter runs them: `bash: 10: + // command not found`, with nothing on screen to explain it. Not fixable by + // suppressing harder — undoing it means writing a kill-line into someone's prompt. + reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') + ] +} + +/** Earliest offset whose suffix of `data` is a strict prefix of `projection`, else -1. */ +function suffixPrefixOffset(projection: string, data: string): number { + for ( + let offset = Math.max(0, data.length - projection.length + 1); + offset < data.length; + offset += 1 + ) { + if (projection.startsWith(data.slice(offset))) { + return offset + } + } + return -1 +} + +// Why search the whole span: the tty coalesces its echo with whatever the shell and the +// program wrote around it, so anchoring at offset 0 recognizes almost no real echo. +function locateEcho(projections: readonly string[], data: string): PtyStartupReplyEchoMatch { + let complete: { offset: number; length: number } | null = null + let partialOffset = -1 + for (const projection of projections) { + const at = data.indexOf(projection) + if (at !== -1) { + if (!complete || at < complete.offset) { + complete = { offset: at, length: projection.length } + } + continue + } + const suffix = suffixPrefixOffset(projection, data) + if (suffix !== -1 && (partialOffset === -1 || suffix < partialOffset)) { + partialOffset = suffix + } + } + if (complete) { + return { kind: 'complete', ...complete } + } + return partialOffset === -1 ? { kind: 'none' } : { kind: 'partial', offset: partialOffset } +} + +function isBetterEchoMatch( + candidate: PtyStartupReplyEchoMatch, + best: PtyStartupReplyEchoMatch +): boolean { + if (candidate.kind === 'none') { + return false + } + if (best.kind === 'none') { + return true + } + if (candidate.kind !== best.kind) { + return candidate.kind === 'complete' + } + return candidate.offset < best.offset +} + +/** Owns when a startup color reply is written and how its own echo is recognized. */ +export class PtyStartupReplyDelivery { + private readonly expectedEchoes: ExpectedEcho[] = [] + private readonly pendingWrites: PendingWrite[] = [] + private writeTimer: ReturnType | null = null + private echoPollDeadline = 0 + private closed = false + + constructor( + private readonly ownerBackend: PtyOwnerBackend, + private readonly writeProvider: (data: string) => void, + private readonly echoProbe?: PtySlaveEchoProbe + ) {} + + get hasExpectedEcho(): boolean { + return this.expectedEchoes.length > 0 + } + + /** + * True once the reply has been written or accepted for a later write. + * + * `onFailed` fires only for the second case: a deferred write reports success + * before it happens, so the caller's bookkeeping for THIS reply is a lie if the + * write later throws. Scoped per reply because the replies to one query are + * written independently — one failing says nothing about the ones that landed. + */ + answer(reply: string, onFailed?: () => void): boolean { + if (this.closed) { + return false + } + if (!defersWrite(this.ownerBackend)) { + // Why: ConPTY answers the query itself unless Orca beats it in this turn. + return this.writeReply(reply) + } + // A fresh queue starts a fresh budget, so a second query arriving after the first + // one exhausted its own still gets probed rather than going straight to guessing. + if (this.pendingWrites.length === 0) { + this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS + } + this.pendingWrites.push({ reply, onFailed }) + this.armWriteTimer() + return true + } + + /** Recognizes any written reply's echo anywhere in the span, earliest match first. */ + matchEcho(data: string): PtyStartupReplyEchoMatch { + let best: PtyStartupReplyEchoMatch = { kind: 'none' } + let bestIndex = -1 + for (const [index, expected] of this.expectedEchoes.entries()) { + const match = locateEcho(expected.projections, data) + if (isBetterEchoMatch(match, best)) { + best = match + bestIndex = index + } + } + if (best.kind === 'complete') { + this.expectedEchoes.splice(bestIndex, 1) + return best + } + return best + } + + /** + * Bytes that went by without completing an echo. Charged by the caller once per PTY + * read rather than per `matchEcho` call, which runs several times over one span. + */ + chargeEchoSearch(byteCount: number): void { + for (let index = this.expectedEchoes.length - 1; index >= 0; index -= 1) { + const expected = this.expectedEchoes[index] + if (!expected) { + continue + } + expected.remainingBytes -= byteCount + if (expected.remainingBytes <= 0) { + this.expectedEchoes.splice(index, 1) + } + } + } + + /** + * Startup window closed. Replies already on the wire stay recognizable, but only + * across the next few hundred bytes: an unbounded projection would keep deleting + * matching spans out of ordinary output for the rest of the session. + */ + reset(): void { + this.flushPendingWrites() + for (const expected of this.expectedEchoes) { + expected.remainingBytes = Math.min(expected.remainingBytes, ECHO_POST_DEADLINE_BUDGET_BYTES) + } + } + + /** Teardown: the pty is gone, so an unwritten reply has nowhere left to go. */ + close(): void { + this.closed = true + this.clearWriteTimer() + this.pendingWrites.length = 0 + this.expectedEchoes.length = 0 + } + + private armWriteTimer(delayMs = 0): void { + if (this.writeTimer) { + return + } + this.writeTimer = setTimeout(() => this.attemptPendingWrites(), delayMs) + this.writeTimer.unref?.() + } + + /** + * Why poll rather than write on the first turn: one deferred turn cannot prove the + * querying program left cooked mode, and the leak happens precisely because Orca + * answered before it got there. Waiting costs the program nothing it is not already + * spending — it is blocked on this reply either way. + */ + private attemptPendingWrites(): void { + this.clearWriteTimer() + if (this.closed || this.pendingWrites.length === 0) { + return + } + if (!this.echoProbe || Date.now() >= this.echoPollDeadline) { + this.flushPendingWrites() + return + } + void this.echoProbe() + .catch(() => 'unknown' as const) + .then((state) => { + if (this.closed || this.pendingWrites.length === 0) { + return + } + if (state === 'echoing') { + this.armWriteTimer(ECHO_POLL_INTERVAL_MS) + return + } + // `quiet` retires the kernel caret projection; `unknown` keeps both shapes. + this.flushPendingWrites(state === 'quiet') + }) + } + + private flushPendingWrites(kernelEchoImpossible = false): void { + this.clearWriteTimer() + for (const pending of this.pendingWrites.splice(0)) { + this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible) + } + } + + private clearWriteTimer(): void { + if (!this.writeTimer) { + return + } + clearTimeout(this.writeTimer) + this.writeTimer = null + } + + private writeReply(reply: string, onFailed?: () => void, kernelEchoImpossible = false): boolean { + if (this.closed) { + return false + } + const projections = replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + // Why: register before write because node-pty can synchronously re-enter onData. + const expected: ExpectedEcho | null = + projections.length > 0 ? { projections, remainingBytes: ECHO_SEARCH_BUDGET_BYTES } : null + if (expected) { + this.expectedEchoes.push(expected) + } + try { + this.writeProvider(reply) + return true + } catch { + // Why splice by identity, not pop: the write above can re-enter onData and + // retire a different projection, so the last slot is not necessarily ours. + const index = expected ? this.expectedEchoes.indexOf(expected) : -1 + if (index !== -1) { + this.expectedEchoes.splice(index, 1) + } + onFailed?.() + return false + } + } +} diff --git a/src/shared/renderer-restart-preparation.test.ts b/src/shared/renderer-restart-preparation.test.ts index 02665442694..925aedbf845 100644 --- a/src/shared/renderer-restart-preparation.test.ts +++ b/src/shared/renderer-restart-preparation.test.ts @@ -18,7 +18,8 @@ describe('prepareRendererForAppRestart', () => { await expect( prepareRendererForAppRestart(eventTarget, { startedEventName: 'restart-started', - abortedEventName: 'restart-aborted' + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => Promise.resolve() }) ).rejects.toThrow('Renderer shutdown checkpoint was not completed.') @@ -26,6 +27,53 @@ describe('prepareRendererForAppRestart', () => { expect(checkpoint).toHaveBeenCalledTimes(1) expect(aborted).toHaveBeenCalledTimes(1) }) + + it('waits for the durable checkpoint write before the restart proceeds', async () => { + const eventTarget = new EventTarget() + const order: string[] = [] + let releaseCheckpoint!: () => void + eventTarget.addEventListener('beforeunload', () => order.push('staged')) + + const prepared = prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => + new Promise((resolve) => { + order.push('awaiting-flush') + releaseCheckpoint = () => { + order.push('flushed') + resolve() + } + }) + }) + let settled = false + void prepared.then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + + releaseCheckpoint() + await prepared + expect(order).toEqual(['staged', 'awaiting-flush', 'flushed']) + }) + + it('aborts the restart when the staged state cannot be persisted', async () => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + eventTarget.addEventListener('restart-aborted', aborted) + + await expect( + prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => Promise.reject(new Error('Failed to persist renderer state.')) + }) + ).rejects.toThrow('Failed to persist renderer state.') + + expect(aborted).toHaveBeenCalledTimes(1) + }) }) describe('createUpdaterQuitAbortRelay', () => { diff --git a/src/shared/renderer-restart-preparation.ts b/src/shared/renderer-restart-preparation.ts index 78560dd761e..19aeb20ff44 100644 --- a/src/shared/renderer-restart-preparation.ts +++ b/src/shared/renderer-restart-preparation.ts @@ -7,6 +7,8 @@ import type { UpdateStatus } from './types' export type AppRestartPrepOptions = { startedEventName: string abortedEventName: string + /** Joins the durable write of the state the checkpoint staged; rejects if it failed. */ + awaitCheckpoint: () => Promise } function requestEditorHotExitBackup(eventTarget: EventTarget): Promise { @@ -36,7 +38,7 @@ function requestEditorHotExitBackup(eventTarget: EventTarget): Promise { export async function prepareRendererForAppRestart( eventTarget: EventTarget, - { startedEventName, abortedEventName }: AppRestartPrepOptions + { startedEventName, abortedEventName, awaitCheckpoint }: AppRestartPrepOptions ): Promise { eventTarget.dispatchEvent(new Event(startedEventName)) @@ -48,6 +50,9 @@ export async function prepareRendererForAppRestart( if (!accepted) { throw new Error('Renderer shutdown checkpoint was not completed.') } + // Why: the checkpoint only stages synchronously. Navigating before that + // write lands loses the session snapshot to a crash or power loss. + await awaitCheckpoint() } catch (error) { eventTarget.dispatchEvent(new Event(abortedEventName)) throw error diff --git a/src/shared/repro-7732-gitlab-job-id-dropped.test.ts b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts new file mode 100644 index 00000000000..4f1130f26f2 --- /dev/null +++ b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './types' + +// Repro for #7732: the Checks side panel can only ask for a GitLab job trace if the mapped +// check row still carries the numeric GitLab job id (gitlab:jobTrace takes { jobId }). +function numericHandles(value: object): number[] { + return Object.values(value).filter((v): v is number => typeof v === 'number') +} + +describe('#7732 GitLab pipeline job -> check row mapping', () => { + const failedJob: GitLabPipelineJob = { + id: 42, + name: 'Purchase API Component Tests', + stage: 'Component Tests', + status: 'failed', + webUrl: 'https://gitlab.com/acme/orca/-/jobs/42', + duration: 31 + } + + it('keeps the GitLab job id so the panel can fetch the job trace', () => { + const [check] = gitLabPipelineJobsToPRChecks([failedJob]) + + expect(check.name).toBe('Component Tests: Purchase API Component Tests') + expect(check.conclusion).toBe('failure') + // The id is the only handle gitlab:jobTrace accepts; without it the expand path has nothing to send. + expect(numericHandles(check)).toContain(42) + }) +}) diff --git a/src/shared/runtime-pairing-reach.ts b/src/shared/runtime-pairing-reach.ts new file mode 100644 index 00000000000..0f7f0315416 --- /dev/null +++ b/src/shared/runtime-pairing-reach.ts @@ -0,0 +1,5 @@ +// Why: STA-2370 — widening the runtime listener to every interface is one-way for the life of the process +// and persists across launches, so it must follow the reach the user picked, not the shape of the address +// they typed: a Custom `127.0.0.1:8443` is usually an SSH tunnel or reverse proxy that still needs off-host +// reach, while "This computer only" must never publish the runtime beyond loopback. +export type RuntimePairingReach = 'this-computer' | 'network' diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c8291e35fb1..6e012e60b42 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -143,7 +143,11 @@ export type RuntimeSyncedLeaf = { export type RuntimeSyncWindowGraph = { tabs: RuntimeSyncedTab[] leaves: RuntimeSyncedLeaf[] + /** Only worktrees whose snapshot changed since the last acknowledged publication. */ mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] + /** Worktrees the renderer is still publishing unchanged; main must keep their + * stored snapshots alive instead of pruning them as removed. */ + unchangedMobileSessionWorktrees?: string[] } export type RuntimeNativeChatLaunchDraftResolution = { @@ -157,6 +161,9 @@ export type RuntimeSyncWindowGraphResult = RuntimeStatus & { * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] + /** Worktrees the renderer withheld as unchanged that main holds no snapshot + * for — it dropped them independently, so the renderer must republish them. */ + mobileSessionResyncWorktrees?: string[] } export type RuntimeMobileSessionTerminalTab = { @@ -402,8 +409,8 @@ export type RuntimeTerminalPathOpenTarget = } /** Result of resolving a file path tapped in the mobile terminal against the - * worktree root (+ optional cwd). relativePath is null when the path resolves - * outside the worktree (not openable via the worktree-scoped file RPCs). */ + * selected or sibling workspace root (+ optional cwd). relativePath is null + * when no workspace on the same execution host owns the path. */ export type RuntimeTerminalPathResolution = { worktree: string relativePath: string | null @@ -740,6 +747,8 @@ export type RuntimeWorktreeAgentRow = { /** When the current `state` was first reported (ms). Drives "Xm ago". */ stateStartedAt: number updatedAt: number + /** See AgentStatusEntry.restoredUnconfirmed — set for hydrated nonterminal rows so clients don't render them as confirmed activity. */ + restoredUnconfirmed?: boolean } export type RuntimeWorktreePsSummary = { diff --git a/src/shared/runtime-workspace-file-owner.test.ts b/src/shared/runtime-workspace-file-owner.test.ts new file mode 100644 index 00000000000..c6d003b8ea6 --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { findRuntimeWorkspaceFileOwner } from './runtime-workspace-file-owner' + +describe('findRuntimeWorkspaceFileOwner', () => { + const roots = [ + { workspaceId: 'repo-a', rootPath: '/srv/repo-a', executionHostId: 'runtime:host-a' as const }, + { workspaceId: 'repo-b', rootPath: '/srv/repo-b', executionHostId: 'runtime:host-a' as const }, + { + workspaceId: 'repo-b-docs', + rootPath: '/srv/repo-b/docs', + executionHostId: 'runtime:host-a' as const + }, + { + workspaceId: 'other-host', + rootPath: '/srv/repo-b', + executionHostId: 'runtime:host-b' as const + } + ] + + it('finds a sibling workspace on the same execution host', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b', + relativePath: 'src/index.ts' + }) + }) + + it('uses the most specific nested workspace root', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs/guide.md', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: 'guide.md' + }) + }) + + it('uses an exact nested workspace root instead of its parent', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: '' + }) + }) + + it('does not cross execution-host boundaries', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-b') + ).toMatchObject({ + workspaceId: 'other-host' + }) + expect(findRuntimeWorkspaceFileOwner(roots, '/srv/repo-a/file.ts', 'runtime:host-b')).toBeNull() + }) + + it('matches Windows roots without case-sensitive drive assumptions', () => { + expect( + findRuntimeWorkspaceFileOwner( + [ + { + workspaceId: 'windows-repo', + rootPath: 'C:\\Work\\Repo', + executionHostId: 'runtime:windows' + } + ], + 'c:\\work\\repo\\src\\index.ts', + 'runtime:windows' + ) + ).toMatchObject({ + workspaceId: 'windows-repo', + relativePath: 'src/index.ts' + }) + }) +}) diff --git a/src/shared/runtime-workspace-file-owner.ts b/src/shared/runtime-workspace-file-owner.ts new file mode 100644 index 00000000000..1edacb07bbc --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.ts @@ -0,0 +1,43 @@ +import type { ExecutionHostId } from './execution-host' +import { normalizeRuntimePathForComparison, relativePathInsideRoot } from './cross-platform-path' + +export type RuntimeWorkspaceFileRoot = { + workspaceId: string + rootPath: string + executionHostId: ExecutionHostId +} + +export type RuntimeWorkspaceFileOwner = RuntimeWorkspaceFileRoot & { + relativePath: string +} + +export function findRuntimeWorkspaceFileOwner( + roots: readonly RuntimeWorkspaceFileRoot[], + absolutePath: string, + executionHostId: ExecutionHostId +): RuntimeWorkspaceFileOwner | null { + let best: RuntimeWorkspaceFileOwner | null = null + let bestRootLength = -1 + + for (const root of roots) { + if (root.executionHostId !== executionHostId) { + continue + } + const relativePath = relativePathInsideRoot(root.rootPath, absolutePath) + if (relativePath === null) { + continue + } + const rootLength = normalizeRuntimePathForComparison(root.rootPath).length + if ( + rootLength > bestRootLength || + (rootLength === bestRootLength && + best !== null && + root.workspaceId.localeCompare(best.workspaceId) < 0) + ) { + best = { ...root, relativePath } + bestRootLength = rootLength + } + } + + return best +} diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index bffeb0de351..61dc001e627 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -218,6 +218,40 @@ describe('createSequencedSetupAgentCommands', () => { }) }) + it('launches a batch runner through the cmd launcher inside a Git Bash gate', () => { + // Regression (#6896): a Git Bash terminal with a batch setup script still gets a .cmd + // runner, and the gate must not hand that runner to bash. The gate itself stays POSIX + // because the Git Bash pane types it and quoted the startup command for bash. + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + startupCommand: "claude 'fix the user'\\''s login'", + platform: 'windows', + shell: { family: 'posix' }, + nonce: 'nonce-gitbash-cmd' + }) + + expect(result.setupCommand).toContain( + 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand' + ) + expect(result.setupCommand).not.toMatch(/bash\s+\S*setup-runner/) + expect(decodePowerShellScript(result.setupCommand)).toContain( + "$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'" + ) + // Why: PowerShell's `Invoke-Expression` cannot parse the POSIX `'\''` escaping a Git Bash + // pane produces, so the gate that evaluates the startup command must be bash. + expect(result.setupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).not.toContain('Invoke-Expression') + expect(result.startupCommand).toContain('eval "$ORCA_SEQUENCED_STARTUP_COMMAND"') + // Why: bash writes and reads the marker here, so it needs the /c/... form of the path. + expect(result.setupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + expect(result.startupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + }) + it.skipIf(process.platform !== 'win32')( 'executes the native Windows setup-to-agent sequence through cmd.exe', async () => { diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 92376f10886..1ec888e3aa6 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,5 +1,6 @@ import { encodePowerShellCommand } from './powershell-command-encoding' import { + nativeWindowsPathToPosixShellPath, resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, type SetupRunnerCommandShell, @@ -41,12 +42,20 @@ export function createSequencedSetupAgentCommands(args: { }): SequencedSetupAgentCommands { const nonce = args.nonce ?? createSetupAgentSequenceNonce() const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell) + // Why: the gate is typed into the terminal pane and `startupCommand` is already quoted for that + // pane, so a batch runner launched from a Git Bash pane still needs the bash gate — PowerShell's + // `Invoke-Expression` cannot parse the POSIX `'\''` escaping the pane's quoting produces. The + // runner itself still launches through `resolution.command`, never through bash. + const posixGateForWindowsRunner = resolution.shell === 'windows' && args.shell?.family === 'posix' + const markerBasePath = posixGateForWindowsRunner + ? nativeWindowsPathToPosixShellPath(resolution.runnerScriptPathForShell) + : resolution.runnerScriptPathForShell // Why: overlapping gated launches of the same setup runner must not race on // a shared completion marker. - const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` + const markerPath = `${markerBasePath}.${nonce}.done` const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS - if (resolution.shell === 'windows') { + if (resolution.shell === 'windows' && !posixGateForWindowsRunner) { return { setupCommand: buildWindowsSetupCommand( resolution.runnerScriptPathForShell, diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 99ba2e029c4..069130b1313 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -61,6 +61,51 @@ describe('buildSetupRunnerCommand', () => { 'bash /c/repo/.git/orca/setup-runner.sh' ) }) + + it('never hands a batch runner to bash, even from a Git Bash pane', () => { + // Regression: a Git Bash terminal with a batch-syntax setup script gets a .cmd runner, + // so the launch shell being POSIX must not be read as "the runner is a shell script". + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('bash ') + expect(command).not.toContain('/c/repo') + }) + + it('avoids the bare /c switch when a POSIX pane launches a batch runner', () => { + // Regression (#6896): MSYS rewrites `cmd.exe /c` into a drive path inside Git Bash, so cmd + // opens interactively and the runner payload never executes. + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('cmd.exe /c') + expect(command).toMatch( + /^powershell\.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand [A-Za-z0-9+/=]+$/ + ) + }) + + it('keeps the batch runner path in native form for a POSIX pane launch', () => { + // Why: the PowerShell launcher hands the path to cmd, which cannot read /c/... MSYS paths; + // marker and completion paths derive from this value too. + expect( + resolveSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + ).toMatchObject({ + runnerScriptPathForShell: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + shell: 'windows' + }) + }) + + it('still uses bash for a POSIX runner launched from a POSIX pane', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix' + }) + ).toBe('bash /c/repo/.git/orca/setup-runner.sh') + }) }) describe('buildSetupRunnerCommand cmd metacharacter guard', () => { diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index fe9cebed50b..7b1dee560fd 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -60,7 +60,10 @@ export function resolveSetupRunnerCommand( shell: 'posix' } } - if (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath)) { + // Why: `shell` is the shell that types the command; the runner file's own extension decides + // what can execute it. A batch runner never goes to bash even from a Git Bash pane. + const cmdRunnerFile = isWindowsCmdRunnerPath(runnerScriptPath) + if (!cmdRunnerFile && (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath))) { // Why: WSL shells need /mnt/... paths, while Git Bash expects /c/... when replaying deferred setup scripts. if (isWslExecutable(shell?.executable)) { const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath) @@ -79,11 +82,15 @@ export function resolveSetupRunnerCommand( } } return { - // Why: some path characters survive no amount of quoting on a cmd command line, so those - // paths take a delayed-expansion launcher instead. Every other path keeps the plain form. - command: windowsRunnerPathNeedsCmdGuard(runnerScriptPath) - ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) - : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + // Why: some path characters survive no amount of quoting on a cmd command line, and a Git + // Bash pane rewrites the bare `/c` switch itself into a drive path (issue #6896) so cmd + // opens interactively and the runner never starts. Both take the delayed-expansion + // launcher, which passes the switch through a PowerShell ProcessStartInfo instead. Every + // other case keeps the plain form. + command: + shell?.family === 'posix' || windowsRunnerPathNeedsCmdGuard(runnerScriptPath) + ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) + : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'windows' } @@ -96,6 +103,11 @@ export function resolveSetupRunnerCommand( } } +/** True when the runner file is a batch script, which only cmd can execute. */ +export function isWindowsCmdRunnerPath(runnerScriptPath: string): boolean { + return /\.(cmd|bat)$/i.test(runnerScriptPath) +} + export function isWslUncPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return /^\/\/(wsl\.localhost|wsl\$)\//i.test(normalized) diff --git a/src/shared/setup-script-shebang.test.ts b/src/shared/setup-script-shebang.test.ts new file mode 100644 index 00000000000..05dca1204da --- /dev/null +++ b/src/shared/setup-script-shebang.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { + isShebangLine, + parseSetupScriptShebang, + scriptDeclaresPosixShell, + stripLeadingShebangLine +} from './setup-script-shebang' + +describe('scriptDeclaresPosixShell', () => { + it('accepts the common env and absolute-path forms', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/bin/sh -e\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/usr/bin/env -S bash -euo pipefail\npnpm install')).toBe( + true + ) + expect(scriptDeclaresPosixShell('#!/bin/zsh')).toBe(true) + }) + + it('rejects scripts with no interpreter line', () => { + // Regression: batch-syntax setup scripts must stay on the cmd runner. + expect(scriptDeclaresPosixShell('copy .env.example .env\nxcopy /E assets dist')).toBe(false) + expect(scriptDeclaresPosixShell('')).toBe(false) + expect(scriptDeclaresPosixShell('pnpm install\n#!/usr/bin/env bash')).toBe(false) + expect(scriptDeclaresPosixShell('# !/usr/bin/env bash\npnpm install')).toBe(false) + }) + + it('rejects interpreters that are not POSIX shells', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env node\nconsole.log(1)')).toBe(false) + expect(scriptDeclaresPosixShell('#!/usr/bin/env python3\nprint(1)')).toBe(false) + }) + + it('tolerates CRLF and Windows-style interpreter paths', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\r\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!C:\\tools\\git\\bin\\bash.exe\r\npnpm install')).toBe(true) + }) +}) + +describe('parseSetupScriptShebang', () => { + it('keeps the interpreter flags a script declares', () => { + // Regression: the runner is launched as `bash `, so flags survive only if they are + // parsed out here and replayed with `set` — otherwise `pipefail` is silently lost. + expect(parseSetupScriptShebang('#!/usr/bin/env -S bash -euo pipefail\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-euo', 'pipefail'] + }) + expect(parseSetupScriptShebang('#!/bin/bash -e -x\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-e', '-x'] + }) + expect(parseSetupScriptShebang('#!/bin/sh\nmake')).toEqual({ + interpreter: 'sh', + shellOptions: [] + }) + }) + + it('ignores interpreter arguments that `set` cannot apply', () => { + // Regression: `set` rejects invocation-only flags with exit 2, and the runner's `set -e` + // turns that into an aborted setup before its first line runs. + expect(parseSetupScriptShebang('#!/bin/bash --norc\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -l\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -s\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -i\nmake')?.shellOptions).toEqual([]) + // Why: `-r` is accepted by `set` on some shells but silently restricts the rest of setup. + expect(parseSetupScriptShebang('#!/bin/bash -r\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -ex -l\nmake')?.shellOptions).toEqual(['-ex']) + // Why: a bare `-o` would print the whole shell-option table into the setup terminal. + expect(parseSetupScriptShebang('#!/bin/bash -o\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -euo\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash +o posix\nmake')?.shellOptions).toEqual([ + '+o', + 'posix' + ]) + }) + + it('returns null without an interpreter line', () => { + expect(parseSetupScriptShebang('pnpm install')).toBeNull() + expect(parseSetupScriptShebang('#!/usr/bin/env')).toBeNull() + }) +}) + +describe('stripLeadingShebangLine', () => { + it('removes only a leading interpreter line', () => { + expect(stripLeadingShebangLine('#!/usr/bin/env bash\npnpm install\n')).toBe('pnpm install\n') + expect(stripLeadingShebangLine('#!/usr/bin/env bash\r\npnpm install')).toBe('pnpm install') + expect(stripLeadingShebangLine('pnpm install\n#!/usr/bin/env bash')).toBe( + 'pnpm install\n#!/usr/bin/env bash' + ) + expect(stripLeadingShebangLine('#!/usr/bin/env bash')).toBe('') + }) +}) + +describe('isShebangLine', () => { + it('detects interpreter lines regardless of leading whitespace', () => { + expect(isShebangLine('#!/usr/bin/env bash')).toBe(true) + expect(isShebangLine(' #!/bin/sh')).toBe(true) + expect(isShebangLine('#comment')).toBe(false) + expect(isShebangLine('pnpm install')).toBe(false) + }) +}) diff --git a/src/shared/setup-script-shebang.ts b/src/shared/setup-script-shebang.ts new file mode 100644 index 00000000000..f30bbab0959 --- /dev/null +++ b/src/shared/setup-script-shebang.ts @@ -0,0 +1,100 @@ +// Why: the interpreter a setup/issue-command script is written for is a property of the script, +// not of the user's terminal preference, so a `#!` line is how a project declares it. + +const POSIX_SHELL_BASENAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']) +// Why: only the letters `set` itself accepts (`set [--abefhkmnptuvxBCHP] [-o option]`). An +// invocation-only flag such as `-l` or `-r` makes `set` exit 2, which under the runner's `set -e` +// aborts setup before its first line runs. +const SET_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]+$/ +// Why: `-o`/`-euo` name their option in the next token; a trailing `-o` with no name would make +// `set` dump the whole shell-option table into the setup terminal instead. +const SET_LONG_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]*o$/ +const SHELL_OPTION_NAME_PATTERN = /^[a-z_]+$/ + +export type SetupScriptShebang = { + /** Lowercased interpreter basename, e.g. `bash` for `#!/usr/bin/env -S bash -e`. */ + interpreter: string + /** Interpreter flags the generated runner replays through `set`, e.g. `['-euo', 'pipefail']`. */ + shellOptions: string[] +} + +/** True when `line` is a `#!` interpreter line (leading whitespace tolerated). */ +export function isShebangLine(line: string): boolean { + return line.trimStart().startsWith('#!') +} + +/** Parses the script's leading `#!` line, or null when it has none. */ +export function parseSetupScriptShebang(script: string): SetupScriptShebang | null { + const firstLine = script.split('\n', 1)[0] ?? '' + if (!isShebangLine(firstLine)) { + return null + } + + const tokens = firstLine.trim().slice(2).trim().split(/\s+/).filter(Boolean) + const interpreterIndex = findInterpreterIndex(tokens) + if (interpreterIndex === -1) { + return null + } + + return { + interpreter: executableBasename(tokens[interpreterIndex]), + shellOptions: parseShellOptions(tokens.slice(interpreterIndex + 1)) + } +} + +/** True when the script's first line is a `#!` line naming a POSIX shell. */ +export function scriptDeclaresPosixShell(script: string): boolean { + const shebang = parseSetupScriptShebang(script) + return shebang !== null && POSIX_SHELL_BASENAMES.has(shebang.interpreter) +} + +/** Drops a leading `#!` line; the generated runner carries its own interpreter line. */ +export function stripLeadingShebangLine(script: string): string { + if (!isShebangLine(script.split('\n', 1)[0] ?? '')) { + return script + } + const lineEnd = script.indexOf('\n') + return lineEnd === -1 ? '' : script.slice(lineEnd + 1) +} + +function findInterpreterIndex(tokens: string[]): number { + for (let index = 0; index < tokens.length; index++) { + const basename = executableBasename(tokens[index]) + // Why: `env` (and its `-S` split-string form) only forwards to the real interpreter. + if (basename === '' || basename === 'env' || basename.startsWith('-')) { + continue + } + return index + } + return -1 +} + +function parseShellOptions(tokens: string[]): string[] { + const options: string[] = [] + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] + if (SET_LONG_OPTION_FLAG_PATTERN.test(token)) { + const optionName = tokens[index + 1] + if (optionName && SHELL_OPTION_NAME_PATTERN.test(optionName)) { + options.push(token, optionName) + index++ + } + continue + } + if (SET_OPTION_FLAG_PATTERN.test(token)) { + options.push(token) + } + } + return options +} + +function executableBasename(token: string): string { + return ( + token + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') ?? '' + ) +} diff --git a/src/shared/ssh-ai-vault-relay.ts b/src/shared/ssh-ai-vault-relay.ts new file mode 100644 index 00000000000..ac84b7d0989 --- /dev/null +++ b/src/shared/ssh-ai-vault-relay.ts @@ -0,0 +1,12 @@ +export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const +export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000 +export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000 +export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 + +export type SshAiVaultRelayListParams = { + limit?: number + unlimited?: boolean + force?: boolean + scopePaths?: string[] + scopePathsTruncated?: boolean +} diff --git a/src/shared/ssh-config-alias.ts b/src/shared/ssh-config-alias.ts new file mode 100644 index 00000000000..ea93dd75de4 --- /dev/null +++ b/src/shared/ssh-config-alias.ts @@ -0,0 +1,8 @@ +/** + * Canonical form for an SSH config alias. OpenSSH matches Host patterns + * case-insensitively, so the picker, import reconciliation, delete tombstones + * and the save-time duplicate check must all compare aliases through this. + */ +export function normalizeSshConfigAlias(alias: string | null | undefined): string { + return alias ? alias.trim().toLowerCase() : '' +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 230e53c6a5b..fd6a9a3bcb0 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -92,6 +92,54 @@ export type SshConfigImportResult = { repoReadoptions: SshRepoReadoption[] } +/** Concrete Host entry from ~/.ssh/config, for pickers that prefill the add-host form. */ +export type SshConfigHostSummary = { + alias: string + hostname: string + port: number + username: string + identityFile?: string + proxyCommand?: string + jumpHost?: string + /** True when an Orca SSH target already uses this config alias. */ + alreadyInOrca: boolean + /** + * True when the user deleted this alias from Orca (tombstone). Still listed so they + * can re-pick it; passive import and "Add all" keep it out until re-adopt / save. + */ + previouslyRemoved?: boolean +} + +/** Max hosts one picker query returns; shared so the renderer's copy cannot drift. */ +export const SSH_CONFIG_HOST_RESULT_LIMIT = 100 + +export type SshConfigHostListResult = { + hosts: SshConfigHostSummary[] + totalHostCount: number + newHostCount: number + matchCount: number + hasMore: boolean +} + +/** `refresh` re-reads ~/.ssh/config; filter keystrokes reuse the cached parse. */ +export type SshConfigHostListArgs = { query?: string; refresh?: boolean } + +/** Effective OpenSSH values used to prefill one manually managed target. */ +export type SshConfigHostResolution = { + alias: string + hostname: string + port: number + username: string + identityFiles: string[] + identityAgent?: string + identitiesOnly: boolean + forwardAgent: boolean + gssapiAuthentication?: boolean + proxyCommand?: string + proxyUseFdpass: boolean + jumpHost?: string +} + export type SavedPortForward = { localPort: number remoteHost: string diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 18f7cd8986f..490bb162af6 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -28,6 +28,7 @@ import { DAEMON_RETIRE_REASONS } from './daemon-lifecycle-telemetry' import { + DAEMON_AUDIT_GENERATION_ROLE_VALUES, DAEMON_AUDIT_PROCESS_REASON_VALUES, DAEMON_AUDIT_REASON_VALUES, DAEMON_AUDIT_STATE_VALUES, @@ -419,28 +420,38 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [ .strict() ]) -const daemonAuditEligibilitySchema = z - .object({ - state: z.enum(DAEMON_AUDIT_STATE_VALUES), - reason: z.enum(DAEMON_AUDIT_REASON_VALUES), - trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), - evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), - protocol_generation: z.number().int().positive().max(1_000), - provider: z.literal('local-daemon'), - endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), - profile_scope: z.enum(['configured', 'unspecified']), - exact_incarnation: z.enum([ - 'endpoint-identity', - 'endpoint-identity-linux-ticks', - 'unavailable' - ]), - reachability: z.enum(['authenticated', 'disconnected', 'unknown']), - inventory_authority: z.enum(['authoritative', 'unavailable']), - process_liveness: z.enum(['present', 'gone', 'unknown']), - process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), - endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) - }) - .strict() +const daemonAuditEligibilityBaseSchema = z.object({ + state: z.enum(DAEMON_AUDIT_STATE_VALUES), + reason: z.enum(DAEMON_AUDIT_REASON_VALUES), + trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), + evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), + protocol_generation: z.number().int().positive().max(1_000), + generation_role: z.enum(DAEMON_AUDIT_GENERATION_ROLE_VALUES), + provider: z.literal('local-daemon'), + endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), + profile_scope: z.enum(['configured', 'unspecified']), + reachability: z.enum(['authenticated', 'disconnected', 'unknown']), + inventory_authority: z.enum(['authoritative', 'unavailable']), + process_liveness: z.enum(['present', 'gone', 'unknown']), + process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), + endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) +}) + +const daemonAuditEligibilitySchema = z.discriminatedUnion('exact_incarnation', [ + daemonAuditEligibilityBaseSchema + .extend({ + exact_incarnation: z.literal('endpoint-identity'), + exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/) + }) + .strict(), + daemonAuditEligibilityBaseSchema + .extend({ + exact_incarnation: z.literal('endpoint-identity-linux-ticks'), + exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/) + }) + .strict(), + daemonAuditEligibilityBaseSchema.extend({ exact_incarnation: z.literal('unavailable') }).strict() +]) // Rollout signal for granting Codex hook trust via codex app-server RPCs // instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts new file mode 100644 index 00000000000..bddcd3dbe6b --- /dev/null +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + COLD_RESTORE_SEED_MODE_RESET, + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_MOUSE_REPORTING, + buildPostReplayLiveAgentReattachReset, + replayPayloadEndsWithCursorHidden +} from './terminal-mode-reset-profiles' + +// Why literal expectations: consumers import these constants, so only a byte-level +// assertion here can catch a profile silently losing a mode it is meant to clear. +describe('terminal mode reset profiles', () => { + it('clears every mouse protocol and encoding a snapshot can re-arm', () => { + expect(RESET_MOUSE_REPORTING).toBe( + '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' + ) + }) + + it('pins the fresh-shell profile', () => { + expect(POST_REPLAY_MODE_RESET).toBe( + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' + ) + }) + + it('pins the daemon-reattach profile, which keeps bracketed paste', () => { + expect(POST_REPLAY_REATTACH_RESET).toBe( + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l' + ) + expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l') + }) + + // Why: #12101 — a cold-restored seed re-arms mouse reporting for a dead TUI. + it('disarms mouse reporting on the cold-restore seed', () => { + expect(COLD_RESTORE_SEED_MODE_RESET).toBe(RESET_MOUSE_REPORTING) + }) + + // Why: the seed also feeds the daemon emulator and is re-serialized from it, so + // re-entering alt screen or resetting the cursor there would fight the renderer. + it('keeps the cold-restore seed free of cursor, kitty and alt-screen bytes', () => { + for (const forbidden of ['\x1b[0 q', '\x1b[<99u', '\x1b[?25h', '\x1b[?1049']) { + expect(COLD_RESTORE_SEED_MODE_RESET).not.toContain(forbidden) + } + }) + + // Why byte equality and not just `not.toContain`: a profile that lost every mode + // would satisfy an absence assertion perfectly, so these two — whose only other + // coverage asserts they were passed through unchanged — need a literal here. + it('pins the live-snapshot and live-agent profiles', () => { + expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l') + expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h') + expect(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET).toBe('\x1b[0 q') + }) + + it('leaves a live agent its focus reporting and bracketed paste', () => { + for (const profile of [ + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET + ]) { + expect(profile).not.toContain('\x1b[?1000l') + expect(profile).not.toContain('\x1b[?2004l') + } + expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).not.toContain('\x1b[?1004l') + }) + + describe('live-agent cursor preservation', () => { + it('detects a payload that ends cursor-hidden', () => { + expect(replayPayloadEndsWithCursorHidden('a\x1b[?25hb\x1b[?25lc')).toBe(true) + expect(replayPayloadEndsWithCursorHidden('a\x1b[?25lb\x1b[?25hc')).toBe(false) + expect(replayPayloadEndsWithCursorHidden('no modes here')).toBe(false) + }) + + it('omits the cursor-show when the agent left its cursor hidden', () => { + expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25l')).not.toContain('\x1b[?25h') + expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25h')).toContain('\x1b[?25h') + }) + }) +}) diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts new file mode 100644 index 00000000000..70a01fcd4fd --- /dev/null +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -0,0 +1,41 @@ +// Why this module is shared: these profiles describe a terminal-protocol +// contract, not a renderer concern. Both the renderer (replaying a snapshot +// into an xterm) and the daemon (seeding a cold-restored session) must clear +// the same mode bits, and duplicating the literals drifted them apart (#12101). + +// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. +export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' +export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' +// Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016. +export const RESET_MOUSE_REPORTING = + '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' + +export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l` + +// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` + +// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. +export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` + +// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME). +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + +// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup. +export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE + +/** Dead-TUI bytes feed a fresh shell; clear mouse modes here and renderer-owned modes later. */ +export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING + +// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. +export function replayPayloadEndsWithCursorHidden(payload: string): boolean { + const hideIndex = payload.lastIndexOf('\x1b[?25l') + return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h') +} + +// Why: some agents hide the real cursor and draw their own, so preserve the payload's final visibility (pty-connection re-shows it if the agent was actually a dead TUI). +export function buildPostReplayLiveAgentReattachReset(payload: string): string { + return replayPayloadEndsWithCursorHidden(payload) + ? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + : POST_REPLAY_LIVE_AGENT_REATTACH_RESET +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 5444b701ec2..b05b450b90a 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -60,6 +60,13 @@ export function visibleRows(terminal: Terminal): string[] { return rows } +export function visibleRowWraps(terminal: Terminal): boolean[] { + const buffer = terminal.buffer.active + return Array.from({ length: terminal.rows }, (_, y) => + Boolean(buffer.getLine(buffer.baseY + y)?.isWrapped) + ) +} + // xterm attribute color modes (Attributes CM_* in xterm's buffer model). const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -75,105 +82,69 @@ function canonicalColorMode(mode: number, color: number): number { /** Per-cell descriptor rows so SGR runs that shift cells are caught even * when the text matches. Encodes only VISUALLY EFFECTIVE state: * - glyph cells: char, width, fg, bg, all attribute flags; - * - blank cells (null cells and spaces render identically): width, bg, - * underline/strikethrough (drawn across blanks), and fg only when inverse - * swaps it into the cell background. SerializeAddon legitimately skips - * null cells with cursor motion, dropping their invisible fg/bold/italic - * state, and may materialize a skipped run as plain spaces — neither can - * be seen, so neither may fail the garble gate. + * - blank cells: width, bg, and fg only when inverse swaps it into the cell + * background. Literal spaces retain underline/strikethrough/overline, + * while Orca's WebGL glyph renderer skips decorations on null cells. + * SerializeAddon may materialize a skipped null run as plain spaces, so + * invisible fg/bold/italic state cannot fail the garble gate. * Trailing default blanks are trimmed: the serializer does not re-emit * pristine cells past the last written column. */ -export function visibleRowStyles(terminal: Terminal): string[] { - const buffer = terminal.buffer.active - const out: string[] = [] - for (let y = 0; y < terminal.rows; y++) { - const line = buffer.getLine(buffer.baseY + y) - const cells: string[] = [] - for (let x = 0; line && x < line.length; x++) { - const cell = line.getCell(x) - if (!cell) { - continue - } - const chars = cell.getChars() - const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) - const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) - if (chars === '' || chars === ' ') { - const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] - .map((flag) => (flag ? '1' : '0')) - .join('') - const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' - cells.push( - `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` - ) - continue - } - const flags = [ - cell.isBold(), - cell.isDim(), - cell.isItalic(), - cell.isUnderline(), - cell.isInverse(), - cell.isStrikethrough() +type TerminalBufferLine = ReturnType + +function effectiveRowStyles(line: TerminalBufferLine): string { + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [ + chars === ' ' && cell.isUnderline(), + chars === ' ' && cell.isStrikethrough(), + chars === ' ' && cell.isOverline() ] .map((flag) => (flag ? '1' : '0')) .join('') - cells.push( - `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` - ) + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push(`▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}`) + continue } - const defaultBlank = `▯·w1·b0:-1·00` - while (cells.length > 0 && cells.at(-1) === defaultBlank) { - cells.pop() - } - out.push(cells.join('|')) + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) } - return out + const defaultBlank = `▯·w1·b0:-1·000` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + return cells.join('|') +} + +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + return Array.from({ length: terminal.rows }, (_, y) => + effectiveRowStyles(buffer.getLine(buffer.baseY + y)) + ) } export function cursorPosition(terminal: Terminal): { x: number; y: number } { return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } } -/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): - * null cells touching a soft-wrap boundary do not round-trip. Two confirmed - * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): - * - V1 (cell loss): a wrapped continuation row starting with a NULL cell - * (only erasure creates those — typed spaces have chars ' ') passes the - * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as - * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — - * which clamps at the right margin instead of crossing the wrap boundary, - * overwriting the previous row's last cell and shifting the tail left. - * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null - * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI - * param 0 means 1, so the erase lands one cell right and the first filler - * '-' stays visible on the restored screen. - * The fuzz suites use this predicate to tolerate (and count) exactly these - * divergences without masking unknown ones. */ -export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { - const buffer = terminal.buffer.active - for (let y = 1; y < buffer.length; y++) { - const line = buffer.getLine(y) - if (!line?.isWrapped) { - continue - } - if (line.getCell(0)?.getChars() === '') { - return true - } - const previous = buffer.getLine(y - 1) - let previousIsAllNull = previous !== undefined - for (let x = 0; previous && x < previous.length; x++) { - if (previous.getCell(x)?.getChars() !== '') { - previousIsAllNull = false - break - } - } - if (previousIsAllNull) { - return true - } - } - return false -} - /** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon * restores content rows; both sides may differ only in trailing blanks). */ export function normalBufferRowsTrimmed(terminal: Terminal): string[] { @@ -188,15 +159,24 @@ export function normalBufferRowsTrimmed(terminal: Terminal): string[] { return rows } +export function normalBufferStylesTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows = Array.from({ length: buffer.length }, (_, y) => + effectiveRowStyles(buffer.getLine(y)) + ) + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + // Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): // normal-buffer restores wipe screen+scrollback+home; alt-screen restores // clear only the alt screen so the normal buffer's scrollback survives. export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H' -// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the -// renderer suite pins equality against the real constant so drift fails fast. -export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' +export { POST_REPLAY_LIVE_SNAPSHOT_RESET as POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY } from './terminal-mode-reset-profiles' export type ParityMainSnapshot = { data: string diff --git a/src/shared/types.ts b/src/shared/types.ts index bc88d4d6d6c..1ba5aeb7919 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1408,6 +1408,9 @@ export type PRCheckDetail = { url: string | null checkRunId?: number workflowRunId?: number + // Why: the GitLab job trace API is addressed by numeric job id only, so the + // Checks panel cannot load a job log without carrying it on the row. + gitlabJobId?: number } export type PRCheckAnnotation = { @@ -3393,6 +3396,8 @@ export type PersistedUIState = { hideCliCreatedWorkspaces?: boolean /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ hideDetachedHeadWorkspaces?: boolean + /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ + alwaysShowDefaultBranchWorkspace?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/src/shared/worktree-removal-force-classification.test.ts b/src/shared/worktree-removal-force-classification.test.ts index 1d995d412f4..b4d95a5727d 100644 --- a/src/shared/worktree-removal-force-classification.test.ts +++ b/src/shared/worktree-removal-force-classification.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest' -import { classifyWorktreeForceDeleteReason, WORKTREE_TEARDOWN_FORCE_HINT } from './worktree-removal' +import { + classifyWorktreeForceDeleteReason, + isProvenLivePtyRemovalError, + UNSTOPPED_PTY_REMOVAL_PREFIX, + WORKTREE_TEARDOWN_FORCE_HINT, + WORKTREE_TEARDOWN_TIMEOUT_PREFIX +} from './worktree-removal' + +const liveError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` +const unverifiableError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` // Why (#11960): the desktop Force Delete button renders only when this classifier // returns a reason. The PTY-teardown error tells the user to force-delete, so an // unclassified message leaves them reading advice they cannot act on. describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { - const liveError = `Failed to physically stop every PTY for worktree: repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` - const unverifiableError = `Failed to physically stop every PTY for worktree: repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` - it('offers force for a PTY that is still live', () => { expect(classifyWorktreeForceDeleteReason(liveError)).toBe('unstopped-pty') }) @@ -29,7 +35,35 @@ describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { expect(classifyWorktreeForceDeleteReason(liveError, false, true)).toBeNull() }) + // Why: a wedged provider rejects the sweep before any per-PTY verdict exists, so the + // failure arrives worded as a teardown timeout. The waiver clears it exactly like an + // unproven stop, so leaving it unclassified hid the button for the wedge #11960 targeted. + it('offers force when the teardown sweep itself timed out', () => { + expect( + classifyWorktreeForceDeleteReason( + `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} repo-1::/w. ${WORKTREE_TEARDOWN_FORCE_HINT}` + ) + ).toBe('unstopped-pty') + }) + it('leaves unrelated failures unclassified', () => { expect(classifyWorktreeForceDeleteReason('some other failure')).toBeNull() }) }) + +// The live verdict escalates the delete toast to "Force Delete will kill them", so it must +// come from Orca's own detail — not from the worktree id, which is a user-chosen path. +describe('isProvenLivePtyRemovalError', () => { + it('reads the verdict Orca actually recorded', () => { + expect(isProvenLivePtyRemovalError(liveError)).toBe(true) + expect(isProvenLivePtyRemovalError(unverifiableError)).toBe(false) + }) + + it('does not let a worktree path spell out a live verdict', () => { + expect( + isProvenLivePtyRemovalError( + `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/Users/dev/still live: notes — could not verify these exited: term_a (daemon socket closed)` + ) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree-removal.ts b/src/shared/worktree-removal.ts index 17e1b719cae..8788171ebe0 100644 --- a/src/shared/worktree-removal.ts +++ b/src/shared/worktree-removal.ts @@ -15,8 +15,34 @@ export type WorktreeForceDeleteReason = | 'missing-registration' | 'unstopped-pty' +// Why: everything before this separator is the worktree id — a user-chosen filesystem path. +// Only the detail after it is Orca's own wording, so verdict matchers anchor on the boundary +// rather than scanning the whole message and letting a path spell out a verdict. +export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — ' + +// Why: verification distinguishes a PTY it watched stay alive from one it could not reach, +// and the delete toast must not flatten the two — a user waiving "we could not confirm" is +// making a different decision than one killing a terminal Orca just saw running. The marker +// and its matcher stay together for the same reason the force hint does. +export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:' + +// Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not +// be proven, and the waiver clears both — but this error carries different words, so without +// its own matcher the force affordance stayed hidden for the very case it was added for. +export const WORKTREE_TEARDOWN_TIMEOUT_PREFIX = 'Timed out waiting for physical PTY teardown:' + export function isUnstoppedPtyRemovalError(error: string): boolean { - return error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) + return ( + error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) || error.includes(WORKTREE_TEARDOWN_TIMEOUT_PREFIX) + ) +} + +/** True only when verification positively observed the PTYs still running. */ +export function isProvenLivePtyRemovalError(error: string): boolean { + return ( + isUnstoppedPtyRemovalError(error) && + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`) + ) } export function createLockedWorktreeRemovalError(lockReason?: string): Error { diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts new file mode 100644 index 00000000000..166dc419431 --- /dev/null +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -0,0 +1,165 @@ +/** + * Regression #8873: with "Hide sleeping" on, the project's main workspace must + * stay in the sidebar — it is the only guaranteed way back into the project — + * while a sleeping feature workspace is still swept. + */ + +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { worktreeRow } from './worktree-row-locators' + +type SidebarVisibilityScenario = { + defaultBranchId: string + featureId: string +} + +async function seedSidebarVisibilityScenario(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Sidebar visibility E2E needs a seeded repo') + } + + const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0] + if (!currentWorktree) { + throw new Error('Sidebar visibility E2E needs a seeded worktree') + } + + const defaultBranchId = 'e2e-default-branch-visibility-main' + const featureId = 'e2e-default-branch-visibility-feature' + const currentId = currentWorktree.id + + store.setState((current) => ({ + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: [ + { + ...currentWorktree, + id: currentId, + displayName: 'Current workspace', + isMainWorktree: false, + branch: 'refs/heads/current', + lastActivityAt: 3 + }, + { + ...currentWorktree, + id: defaultBranchId, + displayName: 'Default branch workspace', + isMainWorktree: true, + branch: 'refs/heads/main', + lastActivityAt: 2 + }, + { + ...currentWorktree, + id: featureId, + displayName: 'Feature workspace', + isMainWorktree: false, + branch: 'refs/heads/feature', + lastActivityAt: 1 + } + ] + }, + tabsByWorktree: { + ...current.tabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + }, + browserTabsByWorktree: { + ...current.browserTabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + } + })) + + const nextState = store.getState() + nextState.setActiveView('terminal') + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setFilterRepoIds([]) + + return { defaultBranchId, featureId } + }) +} + +test.describe('Default branch visibility', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('keeps the default branch visible when sleeping workspaces are hidden', async ({ + orcaPage + }) => { + const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage) + const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId) + const featureRow = worktreeRow(orcaPage, featureId) + + // Poll rather than set once: hydration can land after the seed and reset the filters. + await expect + .poll(() => + orcaPage.evaluate( + ({ defaultBranchId, featureId }) => { + const state = window.__store?.getState() + state?.setShowSleepingWorkspaces(false) + state?.setHideDefaultBranchWorkspace(false) + state?.setAlwaysShowDefaultBranchWorkspace(true) + const featureTabs = state?.tabsByWorktree[featureId] ?? [] + return { + alwaysShowDefaultBranchWorkspace: state?.alwaysShowDefaultBranchWorkspace ?? null, + defaultBranchTabs: state?.tabsByWorktree[defaultBranchId]?.length ?? 0, + featureBrowserTabs: state?.browserTabsByWorktree[featureId]?.length ?? 0, + featureHasLivePty: featureTabs.some( + (tab) => (state?.ptyIdsByTabId[tab.id] ?? []).length > 0 + ), + featureTabs: featureTabs.length, + hideDefaultBranchWorkspace: state?.hideDefaultBranchWorkspace ?? null, + showSleepingWorkspaces: state?.showSleepingWorkspaces ?? null + } + }, + { defaultBranchId, featureId } + ) + ) + .toEqual({ + alwaysShowDefaultBranchWorkspace: true, + defaultBranchTabs: 0, + featureBrowserTabs: 0, + featureHasLivePty: false, + featureTabs: 0, + hideDefaultBranchWorkspace: false, + showSleepingWorkspaces: false + }) + + await expect(defaultBranchRow).toBeVisible() + await expect(defaultBranchRow).toContainText('Default branch workspace') + await expect(featureRow).toHaveCount(0) + + // Opting out of the exemption is the only way back to the pre-#8873 sweep. + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(false) + }) + + await expect(defaultBranchRow).toHaveCount(0) + + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toBeVisible() + + // The explicit hide filter still outranks the exemption. + await orcaPage.evaluate(() => { + window.__store?.getState().setHideDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toHaveCount(0) + }) +}) diff --git a/tests/e2e/helpers/ssh-config-host-picker.ts b/tests/e2e/helpers/ssh-config-host-picker.ts new file mode 100644 index 00000000000..482eb9ed84e --- /dev/null +++ b/tests/e2e/helpers/ssh-config-host-picker.ts @@ -0,0 +1,282 @@ +/** + * Shared helpers for SSH config host picker / import E2E specs. + * Prefer role/label locators and user-visible copy over ids / data-*. + */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +export function makeSshConfigHostPrefix(): string { + return `e2e-ssh-cfg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +export type SeededSshConfigHost = { + alias: string + hostname: string + user: string + port: number +} + +export function hostEndpointSummary( + host: Pick +): string { + return `${host.user}@${host.hostname}:${host.port}` +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function buildSshConfigBody(hosts: SeededSshConfigHost[]): string { + return hosts + .map( + (host) => + `Host ${host.alias}\n HostName ${host.hostname}\n User ${host.user}\n Port ${host.port}\n` + ) + .join('\n') +} + +/** Write ~/.ssh/config into the Electron-isolated HOME (same path os.homedir() uses). */ +export async function seedIsolatedSshConfig( + electronApp: ElectronApplication, + configBody: string +): Promise { + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + const sshDir = path.join(home, '.ssh') + mkdirSync(sshDir, { recursive: true, mode: 0o700 }) + writeFileSync(path.join(sshDir, 'config'), configBody, { mode: 0o600 }) + return home +} + +export async function dismissTransientAnnouncement(page: Page): Promise { + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + const visible = await expect(maybeLaterButton) + .toBeVisible({ timeout: 1_000 }) + .then(() => true) + .catch(() => false) + if (visible) { + await maybeLaterButton.click() + } +} + +export async function closeSettingsPage(page: Page): Promise { + await page.evaluate(() => { + window.__store?.getState().closeSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')) + .toBeHidden({ timeout: 5_000 }) + .catch(() => undefined) +} + +export async function closeOpenDialogs(page: Page): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const dialogCount = await page.getByRole('dialog').count() + if (dialogCount === 0) { + return + } + const dialog = page.getByRole('dialog').last() + const cancelOrBack = dialog.getByRole('button', { name: /^(Cancel|Back)$/ }) + await ((await cancelOrBack + .first() + .isVisible() + .catch(() => false)) + ? cancelOrBack.first().click() + : page.keyboard.press('Escape')) + await expect + .poll(async () => page.getByRole('dialog').count(), { timeout: 3_000 }) + .toBeLessThan(dialogCount) + .catch(() => undefined) + } +} + +/** Leave settings / overlays so the main shell (Add Project) is reachable. */ +export async function returnToAppShell(page: Page): Promise { + await closeOpenDialogs(page) + await closeSettingsPage(page) + await closeOpenDialogs(page) +} + +/** Add Project → Host → Add remote host → Add SSH host → form dialog. */ +export async function openAddSshHostDialog(page: Page): Promise { + await returnToAppShell(page) + await page + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i }) + await expect(addProjectDialog).toBeVisible({ timeout: 10_000 }) + + const hostCombobox = addProjectDialog.getByRole('combobox') + await expect(hostCombobox).toBeVisible() + await hostCombobox.click() + + // cmdk exposes options; accessible name includes the detail line. + const addRemoteHostItem = page.getByRole('option', { name: /Add remote host/i }) + await expect(addRemoteHostItem).toBeVisible({ timeout: 5_000 }) + await addRemoteHostItem.click() + + // Nested popover is portaled; name includes the “existing machine over SSH” detail. + const addSshHostAction = page.getByRole('button', { + name: /Add SSH host.*existing machine over SSH/i + }) + await expect(addSshHostAction).toBeVisible({ timeout: 5_000 }) + await addSshHostAction.click() + + const sshDialog = page.getByRole('dialog', { name: 'Add SSH host' }) + await expect(sshDialog).toBeVisible({ timeout: 10_000 }) + await expect(sshDialog.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + return sshDialog +} + +export async function openSshConfigHostPicker(page: Page): Promise { + const sshDialog = await openAddSshHostDialog(page) + await sshDialog.getByRole('button', { name: /Fill from ~\/\.ssh\/config/i }).click() + const pickerDialog = page.getByRole('dialog', { name: 'Choose from ~/.ssh/config' }) + await expect(pickerDialog).toBeVisible({ timeout: 10_000 }) + await expect( + pickerDialog.getByRole('heading', { name: 'Choose from ~/.ssh/config' }) + ).toBeVisible() + // Wait past the loading empty-state before asserting host rows. + await expect(pickerDialog.getByText('Reading ~/.ssh/config…')).toBeHidden({ timeout: 10_000 }) + return pickerDialog +} + +/** Settings → SSH pane: section the user sees under the “SSH Hosts” heading. */ +export async function openSshHostSettings(page: Page): Promise { + await closeOpenDialogs(page) + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + state.openSettingsTarget({ pane: 'ssh', repoId: null }) + state.openSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + await dismissTransientAnnouncement(page) + + const sshSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'SSH Hosts' }) }) + await expect(sshSection).toBeVisible({ timeout: 10_000 }) + await expect(sshSection.getByRole('button', { name: 'Import' })).toBeVisible() + await expect(sshSection.getByRole('button', { name: 'Add Target' })).toBeVisible() + return sshSection +} + +/** Form fields on Add SSH host — labels the user reads. */ +export function addSshHostFormFields(dialog: Locator): { + label: Locator + host: Locator + username: Locator + port: Locator + identityFile: Locator +} { + return { + label: dialog.getByLabel('Label', { exact: true }), + host: dialog.getByLabel('Host or alias'), + username: dialog.getByLabel('Username'), + port: dialog.getByLabel('Port'), + identityFile: dialog.getByLabel('Identity file') + } +} + +/** + * Picker row for one config Host: the list button whose accessible name includes + * the alias and the user@host:port line the user sees. + */ +export function configHostRow( + pickerDialog: Locator, + host: Pick +): Locator { + const alias = escapeRegExp(host.alias) + const endpoint = escapeRegExp(hostEndpointSummary(host)) + return pickerDialog + .getByRole('list', { name: 'SSH config hosts' }) + .getByRole('button', { name: new RegExp(`${alias}[\\s\\S]*${endpoint}`) }) +} + +/** + * Assert a saved host appears in Settings. Card subtitle is + * `user@host:port • terminal timeout: …`, so match the endpoint as a prefix. + */ +export async function expectSshHostListedInSettings( + sshSection: Locator, + host: SeededSshConfigHost +): Promise { + await expect(sshSection.getByText(host.alias, { exact: true })).toBeVisible({ timeout: 10_000 }) + await expect( + sshSection.getByText(new RegExp(`^${escapeRegExp(hostEndpointSummary(host))}\\b`)) + ).toBeVisible() +} + +export async function expectSshHostAbsentFromSettings( + sshSection: Locator, + host: SeededSshConfigHost +): Promise { + await expect(sshSection.getByText(host.alias, { exact: true })).toHaveCount(0) + await expect( + sshSection.getByText(new RegExp(escapeRegExp(hostEndpointSummary(host)))) + ).toHaveCount(0) +} +export async function seedOrcaSshTargetMatchingAlias( + page: Page, + args: { alias: string; hostname: string; username?: string; port?: number } +): Promise { + return page.evaluate(async ({ alias, hostname, username, port }) => { + const result = await window.api.ssh.addTarget({ + target: { + label: alias, + configHost: alias, + host: hostname, + port: port ?? 22, + username: username ?? 'deploy', + relayGracePeriodSeconds: 60 + } + }) + window.__store?.getState().recordSshRepoReadoptions(result.repoReadoptions) + return result.target.id + }, args) +} + +export async function removeSshTargetsByPrefix(page: Page, prefix: string): Promise { + await page.evaluate(async (labelPrefix) => { + const targets = (await window.api.ssh.listTargets()) as { + id: string + label: string + configHost?: string + }[] + for (const target of targets) { + const matches = + target.label.startsWith(labelPrefix) || + (target.configHost != null && target.configHost.startsWith(labelPrefix)) + if (!matches) { + continue + } + try { + await window.api.ssh.removeTarget({ id: target.id }) + } catch { + // Best-effort cleanup. + } + } + }, prefix) +} + +export async function removeSshTargetByAlias(page: Page, alias: string): Promise { + await page.evaluate(async (hostAlias) => { + const targets = (await window.api.ssh.listTargets()) as { + id: string + label: string + configHost?: string + }[] + const match = targets.find( + (target) => target.configHost === hostAlias || target.label === hostAlias + ) + if (!match) { + throw new Error(`No SSH target for alias ${hostAlias}`) + } + await window.api.ssh.removeTarget({ id: match.id }) + }, alias) +} diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts new file mode 100644 index 00000000000..acabed0650e --- /dev/null +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -0,0 +1,484 @@ +/** + * Paired remote server: a revealed remote terminal must stay interactive + * without a tab flip. + * + * Topology: headed Orca desktop host (remote server) + a separate paired Orca + * desktop client — the "connect to Windows 2, open an old workspace" shape. + * + * Oracle (the reported symptom verbatim): type into the revealed pane and see + * the echo paint live. "Paints only after switching to another terminal and + * back" is the failure, so each scenario records both. + * + * The decoy tabs are load-bearing, not scenery. Pre-fix, handleClose never runs, + * so closeIfIdle is the only remaining release path and it needs zero streams — + * a sibling stream is what keeps the wedged multiplexer alive and the pre-fix + * state red. Each scenario asserts its flip decoy is still mounted at the reveal + * so that invariant cannot quietly lapse and turn this green. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { focusActiveTerminalInput } from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 2_000 +const LIVE_PAINT_BUDGET_MS = 12_000 +const REVEAL_BUDGET_MS = 20_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-reveal-')) +const fixturePath = path.join(scratch, 'parked-reveal-terminal.mjs') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + 'const size = () => `${process.stdout.columns}x${process.stdout.rows}`', + 'const record = (line) => appendFileSync(sink, `${line}\\n`)', + 'record(`READY:${size()}`)', + 'process.stdout.write(`READY:${size()}\\r\\n`)', + "process.stdout.on('resize', () => {", + ' record(`SIZE:${size()}`)', + ' process.stdout.write(`SIZE:${size()}\\r\\n`)', + '})', + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' record(`LINE:${line}`)', + ' process.stdout.write(`LINE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function readSink(sinkPath: string): string { + try { + return readFileSync(sinkPath, 'utf8') + } catch { + return '' + } +} + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +type HostTerminal = { + hostTabId: string + sinkPath: string + terminal: string + webTabId: string +} + +async function createHostTerminal( + page: Page, + environmentId: string, + worktreeId: string +): Promise { + const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`) + const result = await callEnvironment<{ tab: { id: string; terminal: string | null } }>( + page, + environmentId, + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: false, + select: false, + navigation: 'caller' + } + ) + if (!result.tab.terminal) { + throw new Error('host session terminal was not created') + } + // Why: the host answers with a `tabId::leafId` surface id; client tabs mirror the parent tab. + const hostTabId = result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + return { + hostTabId, + sinkPath, + terminal: result.tab.terminal, + webTabId: toWebTerminalSurfaceTabId(hostTabId) + } +} + +async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id), + worktreeId + ), + { timeout: 60_000, message: `client never mirrored host tab ${webTabId}` } + ) + .toContain(webTabId) + await page.evaluate( + ({ webTabId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { webTabId, worktreeId } + ) + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), { + timeout: 60_000, + message: `client pane for ${webTabId} did not mount` + }) + .toBe(true) +} + +async function readActivePaneGrid( + page: Page, + webTabId: string +): Promise<{ cols: number; rows: number } | null> { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane ? { cols: pane.terminal.cols, rows: pane.terminal.rows } : null + }, webTabId) +} + +/** Reads the target tab's own buffer. `getTerminalContent` resolves whatever + * tab the store thinks is active, which hides per-tab reveal failures. */ +async function readPaneContent(page: Page, webTabId: string): Promise { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, webTabId) +} + +async function readPaneDiagnostics( + page: Page, + worktreeId: string, + webTabId: string +): Promise { + return page.evaluate( + ({ webTabId, worktreeId }) => { + const manager = window.__paneManagers?.get(webTabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === webTabId) + return { + mounted: Boolean(manager), + ptyId: pane?.container?.dataset?.ptyId ?? null, + recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null, + cols: pane?.terminal?.cols ?? null, + rows: pane?.terminal?.rows ?? null, + bufferLength: pane?.serializeAddon?.serialize?.()?.length ?? null, + paneLeafIds: manager?.getPanes?.().map((entry) => entry.leafId ?? null) ?? null, + storeTabPtyId: tab?.ptyId ?? null, + storeTabLayout: tab?.paneLayout ? JSON.stringify(tab.paneLayout) : null, + storePtyIdsByTab: state?.ptyIdsByTabId?.[webTabId] ?? null + } + }, + { webTabId, worktreeId } + ) +} + +async function waitForPaneMarker( + page: Page, + webTabId: string, + marker: string, + budgetMs: number +): Promise { + const deadline = Date.now() + budgetMs + while (Date.now() < deadline) { + if ((await readPaneContent(page, webTabId)).includes(marker)) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false +} + +function readPtyGridFromContent(content: string): { cols: number; rows: number } | null { + const sizes = [...content.matchAll(/(?:READY|SIZE):(\d+)x(\d+)/g)] + const last = sizes.at(-1) + return last ? { cols: Number(last[1]), rows: Number(last[2]) } : null +} + +type ScenarioResult = { + name: string + restoredBuffer: boolean + hostReceivedInput: boolean + paintedLive: boolean + paintedAfterFlip: boolean + paneGrid: { cols: number; rows: number } | null + ptyGrid: { cols: number; rows: number } | null + diagnostics: unknown +} + +/** Types a unique marker into the revealed pane and records whether the host + * received it, whether it painted live, and (if not) whether the reported + * tab-flip workaround reveals it. */ +async function probeInteractivity( + page: Page, + worktreeId: string, + target: HostTerminal, + flipTo: HostTerminal, + name: string +): Promise { + const token = `probe-${name}` + // Why: a human types once the pane looks restored; typing earlier would race the reattach. + const restoredBuffer = await waitForPaneMarker(page, target.webTabId, 'READY:', REVEAL_BUDGET_MS) + await focusActiveTerminalInput(page) + await page.keyboard.type(token) + await page.keyboard.press('Enter') + const paintedLive = await waitForPaneMarker( + page, + target.webTabId, + `LINE:${token}`, + LIVE_PAINT_BUDGET_MS + ) + const paneGrid = await readActivePaneGrid(page, target.webTabId) + const diagnostics = await readPaneDiagnostics(page, worktreeId, target.webTabId) + let paintedAfterFlip = paintedLive + if (!paintedLive) { + await openClientTab(page, worktreeId, flipTo.webTabId) + await openClientTab(page, worktreeId, target.webTabId) + paintedAfterFlip = await waitForPaneMarker( + page, + target.webTabId, + `LINE:${token}`, + LIVE_PAINT_BUDGET_MS + ) + } + const sink = readSink(target.sinkPath) + return { + name, + restoredBuffer, + hostReceivedInput: sink.includes(`LINE:${token}`), + paintedLive, + paintedAfterFlip, + paneGrid, + ptyGrid: readPtyGridFromContent(sink), + diagnostics + } +} + +/** The wedged-multiplexer repro needs a sibling stream alive at reveal time + * (see the header), and S1 additionally needs its target never to have parked. */ +async function expectStillMounted(page: Page, webTabId: string, label: string): Promise { + expect( + await page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), + `${label} parked before the reveal it is supposed to survive` + ).toBe(true) +} + +/** Logged unconditionally: on failure this line is the whole diagnosis. */ +function logResult(result: ScenarioResult): ScenarioResult { + console.log(`[paired-reveal] ${JSON.stringify(result)}`) + return result +} + +async function seedScenario( + client: PairedElectronClient, + worktreeId: string +): Promise<{ target: HostTerminal; decoys: HostTerminal[] }> { + const target = await createHostTerminal(client.page, client.environmentId, worktreeId) + const decoys = [ + await createHostTerminal(client.page, client.environmentId, worktreeId), + await createHostTerminal(client.page, client.environmentId, worktreeId) + ] + await openClientTab(client.page, worktreeId, target.webTabId) + await expect + .poll(() => readPaneContent(client.page, target.webTabId), { + timeout: 60_000, + message: 'target terminal never painted its READY marker' + }) + .toContain('READY:') + return { target, decoys } +} + +test('paired client keeps revealed remote terminals interactive', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(600_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + // Why: the paired client inherits this from the launching process; a reused + // Playwright worker would otherwise leak the shortened delay into later specs. + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const client = await launchPairedElectronClient(offer, testInfo, 'parked-reveal') + const createdTerminals: string[] = [] + const results: ScenarioResult[] = [] + try { + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('headed host has no active worktree') + } + return id + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + + // S1 — hidden tab that stays mounted, then revealed. + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + // Pins the scenario label: this reveal must not have gone through a park. + await expectStillMounted(client.page, target.webTabId, 'hidden-mounted target') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'hidden-mounted') + ) + ) + } + + // S2 — cold-parked tab (renderer unmounted), then revealed. + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + await openClientTab(client.page, worktreeId, decoys[1].webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + await expectStillMounted(client.page, decoys[1].webTabId, 'cold-parked flip decoy') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'cold-parked') + ) + ) + } + + // S3 — cold-parked tab whose runtime connection dropped and came back + // while parked (the "returned after a while" report). + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + await openClientTab(client.page, worktreeId, decoys[1].webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + await expect + .poll( + async () => + client.page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + await expectStillMounted(client.page, decoys[1].webTabId, 'reconnect-parked flip decoy') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'reconnect-parked') + ) + ) + } + + for (const result of results) { + expect( + { + scenario: result.name, + restoredBuffer: result.restoredBuffer, + hostReceivedInput: result.hostReceivedInput, + paintedLive: result.paintedLive + }, + `${result.name}: revealed pane was not interactive (painted after tab flip: ${result.paintedAfterFlip})` + ).toEqual({ + scenario: result.name, + restoredBuffer: true, + hostReceivedInput: true, + paintedLive: true + }) + expect( + { scenario: result.name, ptyGrid: result.ptyGrid }, + `${result.name}: host PTY geometry never converged on the revealed pane grid` + ).toEqual({ scenario: result.name, ptyGrid: result.paneGrid }) + } + } finally { + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + for (const terminal of createdTerminals) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + await client.dispose() + } +}) diff --git a/tests/e2e/repo-icon-emoji-picker.spec.ts b/tests/e2e/repo-icon-emoji-picker.spec.ts new file mode 100644 index 00000000000..2698615b881 --- /dev/null +++ b/tests/e2e/repo-icon-emoji-picker.spec.ts @@ -0,0 +1,80 @@ +/** + * Coverage for the full native emoji picker that replaced the hardcoded + * 12-emoji grid in the repo icon settings (RepositoryIconTabs "Emoji" tab). + * Verifies search + selection persist through the existing RepoIcon + * contract, and captures the new picker for the PR screenshot record. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' +import type { Repo } from '../../src/shared/types' + +/** Opens the repo settings panel and pins the UI language to English. */ +async function openRepoSettings(page: Page, repoId: string): Promise { + // Why: the host OS locale (e.g. ko-KR) drives Orca's default UI language. + // Pin English so this spec's locators are stable across dev machines and CI. + await page.evaluate(() => window.__store!.getState().updateSettings({ uiLanguage: 'en' })) + await page.evaluate((repoId) => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'repo', repoId }) + state.openSettingsPage() + }, repoId) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + if (await maybeLaterButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await maybeLaterButton.click() + } +} + +/** Captures a full-page screenshot and attaches it to the test report. */ +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshotPath = testInfo.outputPath(`${name}.png`) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Repository icon emoji picker', () => { + test('search selects a native emoji and persists it as the repo icon', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + + const repos = await getStoreState(orcaPage, 'repos') + expect(repos.length).toBeGreaterThan(0) + const repo = repos[0] + + await openRepoSettings(orcaPage, repo.id) + + const repoSection = orcaPage.locator(`[data-settings-section="repo-${repo.id}"]`) + await repoSection.getByRole('tab', { name: 'Emoji' }).click() + + const picker = repoSection.locator('.repo-icon-emoji-picker') + await expect(picker).toBeVisible({ timeout: 10_000 }) + + // Full catalog with search — the removed grid only ever offered 12 fixed emoji. + const searchInput = picker.getByPlaceholder('Search emoji') + await expect(searchInput).toBeVisible() + await searchInput.fill('rocket') + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-search') + + const rocketResult = picker.getByRole('button', { name: /rocket/i }).first() + await expect(rocketResult).toBeVisible({ timeout: 10_000 }) + await rocketResult.click() + + await expect + .poll( + async () => { + const current = await getStoreState(orcaPage, 'repos') + return current.find((entry) => entry.id === repo.id)?.repoIcon + }, + { timeout: 5_000, message: 'repo icon did not persist the picked emoji' } + ) + .toEqual({ type: 'emoji', emoji: '🚀' }) + + // The store round-trip alone would pass even if the panel rendered nothing. + await expect(repoSection.getByText('Current: 🚀')).toBeVisible() + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-selected') + }) +}) diff --git a/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts new file mode 100644 index 00000000000..c7f87dee9e3 --- /dev/null +++ b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts @@ -0,0 +1,160 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { openChecks } from './helpers/source-control-ai-generation' + +// Repro for #7732: expanding a GitLab pipeline job in the Checks panel must show its log, +// not "No inline details are available for this check." All fixture data is synthetic. +const FIXTURE = { + mrNumber: 4242, + jobId: 987654, + jobName: 'Purchase API Component Tests', + stage: 'Component Tests', + webUrl: 'https://gitlab.example.test/acme/orca/-/jobs/987654', + mrUrl: 'https://gitlab.example.test/acme/orca/-/merge_requests/4242', + headSha: 'e2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2e', + trace: [ + '$ pnpm test:component --project purchase-api', + 'FAIL src/purchase/refund.spec.ts', + ' AssertionError: expected refunded amount 4200 to equal 4250', + 'ERROR: Job failed: exit code 1' + ].join('\n') +} as const + +const SCREENSHOT_DIR = + process.env.ORCA_GITLAB_CHECKS_JOB_DETAILS_SCREENSHOT_DIR ?? + path.join(process.cwd(), 'test-results', 'gitlab-checks-job-details') + +// contextIsolation makes window.api non-writable, so stub at the IPC boundary in main. +async function installGitLabChecksBackend(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ ipcMain }, fx) => { + ipcMain.removeHandler('hostedReview:forBranch') + ipcMain.handle('hostedReview:forBranch', async () => ({ + provider: 'gitlab', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'open', + url: fx.mrUrl, + status: 'failure', + updatedAt: '2026-07-07T12:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: fx.headSha + })) + + ipcMain.removeHandler('gitlab:workItemDetails') + ipcMain.handle('gitlab:workItemDetails', async () => ({ + item: { + id: `gitlab-mr-${fx.mrNumber}`, + type: 'mr', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'opened', + url: fx.mrUrl, + labels: [], + updatedAt: '2026-07-07T12:00:00.000Z', + author: 'e2e-bot' + }, + body: 'Synthetic MR used for #7732 repro evidence.', + comments: [], + headSha: fx.headSha, + pipelineJobs: [ + { + id: fx.jobId, + pipelineId: 55, + name: fx.jobName, + stage: fx.stage, + status: 'failed', + webUrl: fx.webUrl, + duration: 87 + } + ], + reviewers: [] + })) + + ipcMain.removeHandler('gitlab:jobTrace') + ;(globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls = 0 + ipcMain.handle('gitlab:jobTrace', async () => { + const g = globalThis as { __repro7732JobTraceCalls?: number } + g.__repro7732JobTraceCalls = (g.__repro7732JobTraceCalls ?? 0) + 1 + return { ok: true, trace: fx.trace } + }) + }, FIXTURE) +} + +async function readJobTraceCallCount(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => (globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls ?? 0 + ) +} + +async function linkGitLabMRToWorktree(page: Page, worktreeId: string): Promise { + await page.evaluate( + ({ worktreeId, mrNumber }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => + worktree.id === worktreeId ? { ...worktree, linkedGitLabMR: mrNumber } : worktree + ) + ]) + ), + gitStatusByWorktree: { ...current.gitStatusByWorktree, [worktreeId]: [] } + })) + }, + { worktreeId, mrNumber: FIXTURE.mrNumber } + ) +} + +test.describe('#7732 GitLab Checks panel job details', () => { + test('expanding a failed pipeline job shows its log, not "No inline details"', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installGitLabChecksBackend(electronApp) + + const worktreeId = await orcaPage.evaluate( + () => window.__store?.getState().activeWorktreeId ?? null + ) + if (!worktreeId) { + throw new Error('E2E fixture did not expose an active worktree') + } + // Late startup UI hydration resets the active workspace + sidebar route; let it settle first. + await orcaPage.waitForTimeout(8_000) + + const jobRow = orcaPage.getByText(`${FIXTURE.stage}: ${FIXTURE.jobName}`, { exact: true }) + for (let attempt = 0; attempt < 40 && (await jobRow.count()) === 0; attempt++) { + await linkGitLabMRToWorktree(orcaPage, worktreeId) + await openChecks(orcaPage, worktreeId) + await orcaPage.waitForTimeout(500) + } + await expect(jobRow).toBeVisible({ timeout: 15_000 }) + + mkdirSync(SCREENSHOT_DIR, { recursive: true }) + await jobRow.click() + const noDetails = orcaPage.getByText('No inline details are available for this check.') + const viewFullLogs = orcaPage.getByRole('button', { name: 'View full logs' }) + for (let attempt = 0; attempt < 20; attempt++) { + if ((await noDetails.count()) > 0 || (await viewFullLogs.count()) > 0) { + break + } + await orcaPage.waitForTimeout(500) + } + await orcaPage.screenshot({ + path: path.join(SCREENSHOT_DIR, 'gitlab-checks-job-expanded.png') + }) + + // Correct behavior: the stubbed gitlab:jobTrace log renders instead of the "no details" fallback. + expect(await readJobTraceCallCount(electronApp)).toBeGreaterThan(0) + await expect(noDetails).toHaveCount(0) + await expect(viewFullLogs).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts index 87f1f120d30..8a6f0029ff2 100644 --- a/tests/e2e/ssh-codex-reconnect-replay-driver.ts +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -29,7 +29,7 @@ export async function connectDockerRemote( page: Page, target: DockerSshRelayTarget ): Promise { - return await page.evaluate( + const remote = await page.evaluate( async ({ target, remotePath }) => { const store = window.__store if (!store) { @@ -70,22 +70,44 @@ export async function connectDockerRemote( } await store.getState().fetchRepos() await store.getState().fetchWorktrees(result.repo.id) - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] - if (!worktree) { - throw new Error(`No remote worktree found for ${result.repo.path}`) - } - store.getState().setActiveWorktree(worktree.id) - if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal') - return { targetId: createdTarget.id, worktreeId: worktree.id } + return { targetId: createdTarget.id, repoId: result.repo.id, repoPath: result.repo.path } } finally { credentialUnsub() } }, { target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } ) + + await expect + .poll( + () => + page.evaluate(async (repoId) => { + const store = window.__store + if (!store) { + return 0 + } + await store.getState().fetchWorktrees(repoId) + return store.getState().worktreesByRepo[repoId]?.length ?? 0 + }, remote.repoId), + { timeout: 30_000, message: `No remote worktree found for ${remote.repoPath}` } + ) + .toBeGreaterThan(0) + + const worktreeId = await page.evaluate((repoId) => { + const store = window.__store + const worktree = store?.getState().worktreesByRepo[repoId]?.[0] + if (!store || !worktree) { + throw new Error(`Remote worktree disappeared for repo ${repoId}`) + } + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return worktree.id + }, remote.repoId) + + return { targetId: remote.targetId, worktreeId } } export async function switchToNonRemoteWorktree( diff --git a/tests/e2e/ssh-config-host-import.spec.ts b/tests/e2e/ssh-config-host-import.spec.ts new file mode 100644 index 00000000000..959d180ddde --- /dev/null +++ b/tests/e2e/ssh-config-host-import.spec.ts @@ -0,0 +1,202 @@ +/** + * E2E: SSH config bulk import (picker "Add all") vs Settings Import re-adopt. + * Covers plan cases P5, P6, P7, P9. Picker list/filter/select live in + * ssh-config-host-picker.spec.ts. + */ + +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { + buildSshConfigBody, + configHostRow, + expectSshHostAbsentFromSettings, + expectSshHostListedInSettings, + makeSshConfigHostPrefix, + openSshConfigHostPicker, + openSshHostSettings, + removeSshTargetByAlias, + removeSshTargetsByPrefix, + returnToAppShell, + seedIsolatedSshConfig, + seedOrcaSshTargetMatchingAlias, + type SeededSshConfigHost +} from './helpers/ssh-config-host-picker' + +// Why: afterEach deletes every target carrying this prefix; workers loading the +// module in the same millisecond must not collide on a shared Date.now(). +const HOST_PREFIX = makeSshConfigHostPrefix() + +function pairHosts(prefix: string): { alpha: SeededSshConfigHost; bravo: SeededSshConfigHost } { + return { + alpha: { + alias: `${prefix}-alpha`, + hostname: `${prefix}-alpha.example.test`, + user: 'deploy', + port: 22 + }, + bravo: { + alias: `${prefix}-bravo`, + hostname: `${prefix}-bravo.example.test`, + user: 'ops', + port: 2222 + } + } +} + +async function seedPairConfig( + electronApp: ElectronApplication, + prefix: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = pairHosts(prefix) + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([hosts.alpha, hosts.bravo])) + return hosts +} + +/** Import both config hosts via picker, then suppress `alias` (removeTarget tombstone). */ +async function importPairThenDeleteAlias( + page: Page, + electronApp: ElectronApplication, + prefix: string, + aliasToDelete: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = await seedPairConfig(electronApp, prefix) + const picker = await openSshConfigHostPicker(page) + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + await picker.getByRole('button', { name: 'Add all 2 to Orca' }).click() + await expect(page.getByText('Added 2 hosts to Orca.')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('dialog', { name: 'Choose from ~/.ssh/config' })).toBeHidden({ + timeout: 10_000 + }) + await expect(page.getByRole('dialog', { name: 'Add SSH host' })).toBeHidden({ + timeout: 10_000 + }) + await removeSshTargetByAlias(page, aliasToDelete) + return hosts +} + +test.describe('SSH config host import (bulk + settings re-adopt)', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test.afterEach(async ({ orcaPage }) => { + await removeSshTargetsByPrefix(orcaPage, HOST_PREFIX).catch(() => undefined) + }) + + // ── P5 ───────────────────────────────────────────────────────────── + test('P5: already-in-Orca badge, disabled row, and Add all counts only new hosts', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + await seedOrcaSshTargetMatchingAlias(orcaPage, { + alias: hosts.alpha.alias, + hostname: hosts.alpha.hostname, + username: hosts.alpha.user, + port: hosts.alpha.port + }) + + const picker = await openSshConfigHostPicker(orcaPage) + const alphaRow = configHostRow(picker, hosts.alpha) + const bravoRow = configHostRow(picker, hosts.bravo) + + await expect(alphaRow).toBeVisible() + await expect(alphaRow).toBeDisabled() + await expect(alphaRow.getByText('In Orca', { exact: true })).toBeVisible() + + await expect(bravoRow).toBeVisible() + await expect(bravoRow).toBeEnabled() + await expect(bravoRow.getByText('In Orca', { exact: true })).toHaveCount(0) + + await expect(picker.getByRole('button', { name: 'Add all 1 to Orca' })).toBeEnabled() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toHaveCount(0) + }) + + // ── P6 ───────────────────────────────────────────────────────────── + test('P6: Add all N to Orca imports new hosts; re-open shows all in Orca', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + + await picker.getByRole('button', { name: 'Add all 2 to Orca' }).click() + await expect(orcaPage.getByText('Added 2 hosts to Orca.')).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('dialog', { name: 'Choose from ~/.ssh/config' })).toBeHidden({ + timeout: 10_000 + }) + await expect(orcaPage.getByRole('dialog', { name: 'Add SSH host' })).toBeHidden({ + timeout: 10_000 + }) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, hosts.alpha) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + + await returnToAppShell(orcaPage) + const reopened = await openSshConfigHostPicker(orcaPage) + await expect(configHostRow(reopened, hosts.alpha)).toBeDisabled() + await expect( + configHostRow(reopened, hosts.alpha).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(configHostRow(reopened, hosts.bravo)).toBeDisabled() + await expect( + configHostRow(reopened, hosts.bravo).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(reopened.getByRole('button', { name: 'All hosts already in Orca' })).toBeDisabled() + }) + + // ── P7 ───────────────────────────────────────────────────────────── + test('P7: Add all does not re-adopt deleted config hosts (suppress tombstones)', async ({ + electronApp, + orcaPage + }) => { + const hosts = await importPairThenDeleteAlias( + orcaPage, + electronApp, + HOST_PREFIX, + `${HOST_PREFIX}-alpha` + ) + + const picker = await openSshConfigHostPicker(orcaPage) + // Suppressed aliases are omitted from the picker entirely. + await expect(configHostRow(picker, hosts.alpha)).toHaveCount(0) + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect( + configHostRow(picker, hosts.bravo).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'All hosts already in Orca' })).toBeDisabled() + await expect(picker.getByRole('button', { name: /Add all \d+ to Orca/ })).toHaveCount(0) + + await returnToAppShell(orcaPage) + const sshSection = await openSshHostSettings(orcaPage) + // Pane auto-syncs without reAdopt — deleted alpha must stay gone. + await expectSshHostListedInSettings(sshSection, hosts.bravo) + await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) + }) + + // ── P9 ───────────────────────────────────────────────────────────── + test('P9: Settings Import re-adopts deleted config hosts', async ({ electronApp, orcaPage }) => { + const hosts = await importPairThenDeleteAlias( + orcaPage, + electronApp, + HOST_PREFIX, + `${HOST_PREFIX}-alpha` + ) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) + + await sshSection.getByRole('button', { name: 'Import' }).click() + await expect(orcaPage.getByText(/Synced \d+ servers?/i)).toBeVisible({ timeout: 15_000 }) + + await expectSshHostListedInSettings(sshSection, hosts.alpha) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + }) +}) diff --git a/tests/e2e/ssh-config-host-picker.PLAN.md b/tests/e2e/ssh-config-host-picker.PLAN.md new file mode 100644 index 00000000000..ebb7519616e --- /dev/null +++ b/tests/e2e/ssh-config-host-picker.PLAN.md @@ -0,0 +1,188 @@ +# E2E Test Plan: SSH config host picker (`import-ssh-config-does-nothing`) + +## Branch summary + +Users can open **Fill from ~/.ssh/config…** on the add-SSH-host dialog, pick a +Host alias, and get the form prefilled from `ssh -G` resolution. Bulk sync is a +secondary **Add all N to Orca** action (no re-adopt). Settings → SSH → **Import** +remains the deliberate re-adopt path. + +Commits under test (vs main): + +- `bd0b594dee` feat(ssh): add SSH config host picker for add-host form +- `dc8339369a` fix(ssh): import filter preservation and label fallback +- `5982c8108f` fix(ssh): harden config picker import, alias folding, host targeting +- `5cfb6369e7` refactor(ssh): centralize host result limit / folder group helper + +## Already covered (do **not** re-test in E2E) + +| Area | Where | +|------|--------| +| `listConfigHosts` / `resolveConfigHost` IPC registration | `src/main/ipc/ssh.test.ts` | +| Search, result limit, suppressed aliases, alreadyInOrca | `ssh-config-host-picker.test.ts` | +| Generation guard, freeze-while-resolving, late resolve | `AddRemoteHostDialog.config-picker.test.tsx` | +| Bulk `importConfig()` without `reAdopt` | `add-remote-host-ssh-actions.test.ts` | +| Alias folding / duplicate save check | `ssh-target-duplicate.test.ts` | +| `configured-only` host registry / setup fail-closed | unit tests in shared + project-host-workspace-target | +| Settings modal viewport stability | `ssh-host-form-modal.spec.ts` | + +E2E is reserved for real Electron HOME isolation, real `~/.ssh/config` parse, +real `ssh -G` resolve, and user-visible DOM outcomes. + +## Harness requirements + +1. **Isolated HOME** — E2E already sets `HOME` to `{userDataDir}/home`. Seed config with: + ```ts + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + mkdirSync(path.join(home, '.ssh'), { recursive: true, mode: 0o700 }) + writeFileSync(path.join(home, '.ssh/config'), configBody, { mode: 0o600 }) + ``` +2. **Unique aliases** — prefix Host entries and Orca labels with + `e2e-ssh-cfg-${Date.now().toString(36)}-…` so workers never collide; clean up + via `window.api.ssh.removeTarget` in `afterEach` by label/configHost prefix. +3. **Open the picker dialog** (not Settings `SshTargetForm` — that form has no + config picker). Path: + - Open **Add Project** (sidebar / landing control) + - Host combobox → **Add remote host** → **Add SSH host** + - Dialog title **Add SSH host** with link **Fill from ~/.ssh/config…** +4. **Assertions** — DOM only (`getByRole`, `toHaveValue`, visible badges/toasts). + Store/API only for setup/cleanup/seeding existing targets. +5. **Prereq** — OpenSSH client on PATH (`ssh -G`). macOS/Linux CI has it; skip + or soft-fail only if `ssh -G` is unavailable (document in test comment). + +## Spec file + +`tests/e2e/ssh-config-host-picker.spec.ts` +Reuse patterns from `ssh-host-form-modal.spec.ts` (session ready, target cleanup, +announcement dismiss). Prefer small local helpers over new shared modules unless +helpers would be reused elsewhere. + +Optional second file if the Settings Import case grows: +`tests/e2e/ssh-config-import-settings.spec.ts` — otherwise keep Import in the same file. + +--- + +## Cases (must ship) + +### P1 — Empty config empty state + +| | | +|--|--| +| **Setup** | Do not create `~/.ssh/config` (or write empty file). | +| **Steps** | Open Add SSH host → Fill from ~/.ssh/config… | +| **Expect** | Dialog title **Choose from ~/.ssh/config**; body **No hosts in ~/.ssh/config**; **Add all to Orca** disabled; **Back** returns to form. | + +### P2 — Seeded hosts listed with summary lines + +| | | +|--|--| +| **Setup** | Write config with ≥2 concrete Hosts, e.g. `e2e-alpha` / `e2e-bravo` with HostName, User, Port. | +| **Steps** | Open picker. | +| **Expect** | Host list `SSH config hosts` shows both aliases; subtitle `user@hostname:port`; button **Add all 2 to Orca** enabled. | + +### P3 — Select host prefills form (and Save persists) + +| | | +|--|--| +| **Setup** | Config Host `e2e-prod` → HostName `prod.example.test`, User `deploy`, Port `2222`. | +| **Steps** | Pick `e2e-prod` → wait for form → click **Save**. | +| **Expect** | After pick: Host/alias field = `prod.example.test`, Username `deploy`, Port `2222`, Label `e2e-prod` (or alias); optional toast *Filled from e2e-prod*; Identity file may stay empty with config hint. After Save: dialog closes; target appears in Settings → SSH (or listTargets shows matching host). | + +### P4 — Filter narrows list + +| | | +|--|--| +| **Setup** | Hosts `e2e-alpha`, `e2e-bravo`. | +| **Steps** | Open picker; filter `bravo`. | +| **Expect** | Only bravo row; alpha gone; **No matching hosts** if filter is nonsense. | + +### P5 — Already-in-Orca badge + disabled row + +| | | +|--|--| +| **Setup** | Config hosts alpha + bravo. Seed Orca target with `configHost`/`label` matching alpha (via `ssh.addTarget`). | +| **Steps** | Open picker. | +| **Expect** | Alpha shows **In Orca** badge and is not clickable; bravo still selectable; **Add all 1 to Orca** (not 2). | + +### P6 — Add all N to Orca imports new hosts only + +| | | +|--|--| +| **Setup** | Config with 2 new hosts; no Orca targets for them. | +| **Steps** | **Add all 2 to Orca** → wait for success toast / return to form or list refresh. | +| **Expect** | Both targets exist (DOM in Settings SSH and/or listTargets); re-open picker shows **All hosts already in Orca** / both **In Orca**. | + +### P7 — Add all does **not** re-adopt deleted hosts + +| | | +|--|--| +| **Setup** | Config with alpha + bravo; Add all → remove alpha via API (creates suppress tombstone). | +| **Steps** | Re-open picker; note count; optionally click Add all again. | +| **Expect** | Alpha absent from picker (suppressed) or not re-created; only new hosts counted; `listTargets` still lacks deleted alpha after second Add all. | + +### P8 — Back discards pending pick path + +| | | +|--|--| +| **Setup** | Seeded config. | +| **Steps** | Open picker → **Back** without selecting. | +| **Expect** | Form fields still empty (Host blank); no filled toast. | + +### P9 — Settings Import re-adopts (contrast with P7) + +| | | +|--|--| +| **Setup** | Same as P7 after delete. | +| **Steps** | Settings → SSH → **Import** (explicit reAdopt path). | +| **Expect** | Deleted config host reappears as an Orca target; toast sync count ≥ 1. | + +--- + +## Nice-to-have (only if cheap after P1–P9) + +- **N1** ProxyCommand / JumpHost: pick host with ProxyJump → Advanced opens and jump field filled (proves advanced prefill + `preferAdvancedOpen`). +- **N2** Case-insensitive alias: config `Prod`, existing label `prod` → **In Orca**. +- **N3** Empty Identity file hint visible after config fill. + +Skip: 100-host truncation, resolve races, GSSAPI system-default, composer host-availability fail-closed (unit-covered). + +## Out of scope + +- Real SSH connect / relay / PTY +- Docker SSH fixtures +- Web client stub paths (`listConfigHosts` returns empty) +- i18n non-English + +## Implementation status (done) + +| Case | Spec | +|------|------| +| P1 empty state | `ssh-config-host-picker.spec.ts` | +| P2 list + Add all enabled | `ssh-config-host-picker.spec.ts` | +| P3 select + Save (+ N3 identity hint) | `ssh-config-host-picker.spec.ts` | +| P4 filter | `ssh-config-host-picker.spec.ts` | +| P5 In Orca badge / count | `ssh-config-host-import.spec.ts` | +| P6 Add all imports | `ssh-config-host-import.spec.ts` | +| P7 no re-adopt after delete | `ssh-config-host-import.spec.ts` | +| P8 Back without select | `ssh-config-host-picker.spec.ts` | +| P9 Settings Import re-adopts | `ssh-config-host-import.spec.ts` | + +Shared helpers: `tests/e2e/helpers/ssh-config-host-picker.ts` + +### Product fix required for E2E (and real HOME isolation) + +OpenSSH resolves the default user config via **getpwuid**, not `$HOME`. E2E +sets an isolated `HOME`, so `loadUserSshConfig` (Node `os.homedir()`) and +`ssh -G` could disagree. `src/main/ssh/ssh-g-config-resolution.ts` now passes +`-F /.ssh/config` when the HOME config path exists and differs from the +passwd home. Normal installs (HOME = passwd home) are unchanged. + +## Suggested run command + +```bash +pnpm exec electron-vite build --mode e2e +SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-config-host-picker.spec.ts \ + tests/e2e/ssh-config-host-import.spec.ts \ + --config tests/playwright.config.ts --project=electron-headless --workers=1 +``` diff --git a/tests/e2e/ssh-config-host-picker.spec.ts b/tests/e2e/ssh-config-host-picker.spec.ts new file mode 100644 index 00000000000..fe717762d7b --- /dev/null +++ b/tests/e2e/ssh-config-host-picker.spec.ts @@ -0,0 +1,187 @@ +/** + * E2E: Add SSH host → Fill from ~/.ssh/config picker (isolated HOME + real ssh -G). + * Plan cases: P1, P2, P3, P4, P8 (+ N3 with P3). Bulk/import: ssh-config-host-import.spec.ts. + */ + +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { + addSshHostFormFields, + buildSshConfigBody, + closeOpenDialogs, + configHostRow, + expectSshHostListedInSettings, + hostEndpointSummary, + makeSshConfigHostPrefix, + openSshConfigHostPicker, + openSshHostSettings, + removeSshTargetsByPrefix, + seedIsolatedSshConfig, + type SeededSshConfigHost +} from './helpers/ssh-config-host-picker' + +// Why: afterEach deletes every target carrying this prefix; workers loading the +// module in the same millisecond must not collide on a shared Date.now(). +const HOST_PREFIX = makeSshConfigHostPrefix() + +function pairHosts(prefix: string): { alpha: SeededSshConfigHost; bravo: SeededSshConfigHost } { + return { + alpha: { + alias: `${prefix}-alpha`, + hostname: `${prefix}-alpha.example.test`, + user: 'deploy', + port: 22 + }, + bravo: { + alias: `${prefix}-bravo`, + hostname: `${prefix}-bravo.example.test`, + user: 'alice', + port: 2222 + } + } +} + +async function seedPairConfig( + electronApp: ElectronApplication, + prefix: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = pairHosts(prefix) + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([hosts.alpha, hosts.bravo])) + return hosts +} + +test.describe('SSH config host picker', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test.afterEach(async ({ orcaPage }) => { + await closeOpenDialogs(orcaPage).catch(() => undefined) + await removeSshTargetsByPrefix(orcaPage, HOST_PREFIX).catch(() => undefined) + }) + + // ── P1 ───────────────────────────────────────────────────────────── + test('P1: empty config shows empty state; Back returns to blank form', async ({ orcaPage }) => { + // Isolated HOME has no ~/.ssh/config by default. + const picker = await openSshConfigHostPicker(orcaPage) + await expect(picker.getByRole('heading', { name: 'Choose from ~/.ssh/config' })).toBeVisible() + await expect(picker.getByText('No hosts in ~/.ssh/config')).toBeVisible() + await expect( + picker.getByText('Add a Host entry there, or go back and type the details manually.') + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all to Orca' })).toBeDisabled() + + await picker.getByRole('button', { name: 'Back' }).click() + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue('') + await expect(fields.username).toHaveValue('') + await expect(fields.label).toHaveValue('') + }) + + // ── P2 ───────────────────────────────────────────────────────────── + test('P2: seeded hosts list with summary lines and Add all enabled', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + const hostList = picker.getByRole('list', { name: 'SSH config hosts' }) + await expect(hostList).toBeVisible() + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect( + hostList.getByText(hostEndpointSummary(hosts.alpha), { exact: true }) + ).toBeVisible() + await expect( + hostList.getByText(hostEndpointSummary(hosts.bravo), { exact: true }) + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + }) + + // ── P3 + N3 ──────────────────────────────────────────────────────── + test('P3: select host prefills form; Save persists; N3 identity hint', async ({ + electronApp, + orcaPage + }) => { + const prod: SeededSshConfigHost = { + alias: `${HOST_PREFIX}-prod`, + hostname: 'prod.example.test', + user: 'deploy', + port: 2222 + } + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([prod])) + + const picker = await openSshConfigHostPicker(orcaPage) + await configHostRow(picker, prod).click() + + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible({ + timeout: 10_000 + }) + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue(prod.hostname, { timeout: 15_000 }) + await expect(fields.username).toHaveValue(prod.user) + await expect(fields.port).toHaveValue(String(prod.port)) + await expect(fields.label).toHaveValue(prod.alias) + await expect(fields.identityFile).toHaveValue('') + // N3: empty Identity file explains multi-key resolve from config. + await expect( + form.getByText(new RegExp(`Left empty on purpose:.*${escapeRegExp(prod.alias)}`, 'i')) + ).toBeVisible() + await expect( + orcaPage.getByText(new RegExp(`Filled from ${escapeRegExp(prod.alias)}`, 'i')) + ).toBeVisible({ timeout: 5_000 }) + + await form.getByRole('button', { name: 'Save' }).click() + await expect(form).toBeHidden({ timeout: 10_000 }) + await expect(orcaPage.getByText('SSH host added.')).toBeVisible({ timeout: 5_000 }) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, prod) + }) + + // ── P4 ───────────────────────────────────────────────────────────── + test('P4: filter narrows host list', async ({ electronApp, orcaPage }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + + const filter = picker.getByRole('textbox', { name: 'Filter hosts…' }) + await filter.fill('bravo') + // Why: picker debounces filter IPC ~200ms. + await expect(configHostRow(picker, hosts.bravo)).toBeVisible({ timeout: 5_000 }) + await expect(configHostRow(picker, hosts.alpha)).toHaveCount(0) + + await filter.fill('no-such-host-zzzz') + await expect(picker.getByText('No matching hosts')).toBeVisible({ timeout: 5_000 }) + await expect( + picker.getByText('Try another filter, or go back and type manually.') + ).toBeVisible() + }) + + // ── P8 ───────────────────────────────────────────────────────────── + test('P8: Back without select leaves form empty', async ({ electronApp, orcaPage }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + + await picker.getByRole('button', { name: 'Back' }).click() + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue('') + await expect(fields.username).toHaveValue('') + await expect(fields.label).toHaveValue('') + await expect(orcaPage.getByText(/Filled from /i)).toHaveCount(0) + }) +}) + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/tests/e2e/terminal-attention.spec.ts b/tests/e2e/terminal-attention.spec.ts index 43767d8483b..22e334f53c8 100644 --- a/tests/e2e/terminal-attention.spec.ts +++ b/tests/e2e/terminal-attention.spec.ts @@ -12,7 +12,7 @@ import { waitForSessionReady } from './helpers/store' import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log' -import { POST_REPLAY_MODE_RESET } from '../../src/renderer/src/components/terminal-pane/layout-serialization' +import { POST_REPLAY_MODE_RESET } from '../../src/shared/terminal-mode-reset-profiles' import { waitForPtyShellEcho } from './terminal-pty-readiness' test.describe.configure({ mode: 'serial' }) @@ -353,7 +353,7 @@ test.describe('Terminal attention', () => { // even though the underlying shell is fresh. Pane clicks then emit // `\e[I` / `\e[O` into zsh, which rings the bell as unbound-key input. // - // POST_REPLAY_MODE_RESET (in layout-serialization.ts) clears these mode + // POST_REPLAY_MODE_RESET (in shared/terminal-mode-reset-profiles.ts) clears these mode // bits after every scrollback replay so the mode state matches the fresh // shell. This test pins that fix: after writing a DECSET 1004 byte into // the terminal, focus events should NOT be emitted back to the PTY. diff --git a/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts new file mode 100644 index 00000000000..c854c8ba798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts @@ -0,0 +1,101 @@ +/** + * #10142 follow-ups to the X-button regression: middle-click prompts too, confirming + * actually closes, and Cmd+W raises exactly one dialog (the pane path delegates the + * last-pane close to closeTerminalTab instead of probing a second time). + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +async function startBusyTerminal(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(page, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal(page, ptyId, 'echo close-confirm-ready') + await waitForTerminalOutput(page, 'close-confirm-ready', 20_000) + await execInTerminal(page, ptyId, 'sleep 300') + // Why: `hasChildProcesses` is already true while macOS's `login` wrapper starts the + // shell, so wait for `sleep` itself or the close legitimately sees an idle terminal. + await expect + .poll( + async () => + (await page.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)).foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + return (await getActiveTabId(page))! +} + +test.describe.configure({ mode: 'serial' }) + +test('middle-clicking a busy tab prompts, and cancelling keeps the tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + const tabsBefore = await orcaPage.locator(SORTABLE_TAB).count() + + await busyTab.click({ button: 'middle' }) + + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + expect(await orcaPage.locator(SORTABLE_TAB).count()).toBe(tabsBefore) +}) + +test('confirming the X-button prompt closes the busy tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) + +test('Cmd+W on a busy single-pane tab raises exactly one dialog', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + // Why: the pane used to probe and prompt on its own before delegating to + // closeTerminalTab, which now prompts too — a second dialog would mean a double prompt. + await orcaPage.waitForTimeout(1_500) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) diff --git a/tests/e2e/terminal-tab-close-running-confirm.spec.ts b/tests/e2e/terminal-tab-close-running-confirm.spec.ts new file mode 100644 index 00000000000..68182eb4798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm.spec.ts @@ -0,0 +1,90 @@ +/** + * Regression for #10142: keyboard and mouse enforce the same running-process close + * confirmation. Both halves run against one tab with a live `sleep 300` child: + * 1. Cmd/Ctrl+W -> "Stop running command?" dialog (cancelled, tab survives). + * 2. X click -> the same dialog, and the tab is still there behind it. + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function countRenderedTabs(page: Page): Promise { + return page.locator(SORTABLE_TAB).count() +} + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +test.describe.configure({ mode: 'serial' }) + +test('the tab X button applies the same running-process confirmation as Cmd+W', async ({ + orcaPage +}) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(orcaPage, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo repro-10142-ready') + await waitForTerminalOutput(orcaPage, 'repro-10142-ready', 20_000) + await execInTerminal(orcaPage, ptyId, 'sleep 300') + // Only press close once `sleep` is the foreground process; otherwise the probe + // legitimately sees an idle shell and closing is correct. `hasChildProcesses` alone is + // not enough: macOS spawns the shell under `login`, so a still-initialising terminal + // reports a child before `sleep 300` has run. + await expect + .poll( + async () => + (await orcaPage.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)) + .foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + + const busyTabId = (await getActiveTabId(orcaPage))! + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + // 1. Keyboard close prompts. + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + const tabsBefore = await countRenderedTabs(orcaPage) + + // 2. Same tab, same running child, mouse close. + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await orcaPage.waitForTimeout(1_500) + + expect( + { + confirmDialogVisible: await closeDialogTitle(orcaPage).isVisible(), + tabStillPresent: (await countRenderedTabs(orcaPage)) === tabsBefore + }, + 'X-button close must apply the same running-process confirmation as Cmd+W' + ).toEqual({ confirmDialogVisible: true, tabStillPresent: true }) +}) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 44042ce16f0..a6c847a5504 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -307,9 +307,11 @@ test.describe('Workspace board lane virtualization', () => { }) test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { + test.skip(true, 'Quarantined by https://github.com/stablyai/orca/issues/12415') const statusId = 'virtual-marquee' + const emptyStatusId = 'virtual-marquee-start' await orcaPage.evaluate( - ({ count, status }) => { + ({ count, emptyStatus, status }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -353,10 +355,13 @@ test.describe('Workspace board lane virtualization', () => { }) state.setWorkspaceStatuses([ { id: status, label: 'Virtual marquee' }, - ...state.workspaceStatuses.filter((entry) => entry.id !== status) + { id: emptyStatus, label: 'Marquee start' }, + ...state.workspaceStatuses.filter( + (entry) => entry.id !== status && entry.id !== emptyStatus + ) ]) }, - { count: MARQUEE_WORKSPACE_COUNT, status: statusId } + { count: MARQUEE_WORKSPACE_COUNT, emptyStatus: emptyStatusId, status: statusId } ) await orcaPage.getByRole('button', { name: 'Workspace board' }).click() @@ -366,35 +371,95 @@ test.describe('Workspace board lane virtualization', () => { const laneCards = lane.locator('[data-workspace-board-card-id]') await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') - const selectionSurface = orcaPage.locator('[data-workspace-board-selection-surface]') + const emptyLaneScroll = orcaPage.locator( + `[data-workspace-status="${emptyStatusId}"] [data-workspace-board-lane-scroll]` + ) const box = await laneScroll.boundingBox() - const selectionBox = await selectionSurface.boundingBox() - if (!box || !selectionBox) { - throw new Error('Expected the marquee lane and selection surface to have bounding boxes') + if (!box) { + throw new Error('Expected the marquee lane to have a bounding box') } - await orcaPage.mouse.move(selectionBox.x + 4, box.y + 12) + // Why: CI can overlay individual lane pixels, so choose a live board-owned point. + const startPoint = await emptyLaneScroll.evaluate((element) => { + const ignored = [ + '[data-workspace-board-card-id]', + 'a', + 'button', + 'input', + 'select', + 'textarea', + '[role="button"]', + '[role="menu"]', + '[role="menuitem"]' + ].join(',') + const rect = element.getBoundingClientRect() + for (let y = Math.ceil(rect.top) + 6; y <= Math.floor(rect.top) + 40; y += 6) { + for (let x = Math.ceil(rect.left) + 8; x <= Math.floor(rect.right) - 8; x += 8) { + const target = document.elementFromPoint(x, y) + if ( + target?.closest('[data-workspace-board-selection-surface]') && + !target.closest(ignored) + ) { + return { x, y } + } + } + } + return null + }) + expect(startPoint, 'the empty start lane must expose board-owned space').not.toBeNull() + if (!startPoint) { + throw new Error('Expected empty board space for the marquee start') + } + + await orcaPage.mouse.move(startPoint.x, startPoint.y) await orcaPage.mouse.down() await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80, { steps: 4 }) + + // Why: proves the board accepted the gesture. Without it a rejected + // pointerdown only shows up 15s later as "0 cards previewed", which reads as + // a virtualization bug rather than a marquee that never started. + await expect(orcaPage.locator('[data-workspace-board-selection-rect]')).toBeVisible() await expect .poll(() => lane.locator('[data-workspace-board-card-area-selected="true"]').count(), { timeout: 15_000 }) .toBeGreaterThan(0) - await laneScroll.evaluate(async (element) => { - for (let pass = 0; pass < 4; pass++) { + // Why: a measured card is much taller than the lane's row estimate, so each + // jump to the bottom re-measures the window and grows the spacer past the + // scrollTop the jump just landed on. A fixed pass budget commits the marquee + // mid-growth on a loaded machine — short of the last cards — so jump until + // the virtualizer stops moving the bottom instead. + const laneScrollSettle = await laneScroll.evaluate(async (element) => { + let settledPasses = 0 + let passes = 0 + while (settledPasses < 2 && passes < 40) { + passes += 1 element.scrollTop = element.scrollHeight element.dispatchEvent(new Event('scroll', { bubbles: true })) await new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())) }) + const maxScrollTop = element.scrollHeight - element.clientHeight + settledPasses = element.scrollTop >= maxScrollTop - 1 ? settledPasses + 1 : 0 + } + return { + passes, + scrollTop: element.scrollTop, + maxScrollTop: element.scrollHeight - element.clientHeight } }) + expect( + laneScrollSettle.scrollTop, + `lane scroll never settled at its bottom: ${JSON.stringify(laneScrollSettle)}` + ).toBeGreaterThanOrEqual(laneScrollSettle.maxScrollTop - 1) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12) await orcaPage.mouse.up() - await expect( - orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true }) - ).toBeVisible() + // Why: assert the badge's text, not its presence — a marquee that stopped + // short reports the count it did commit instead of a bare locator timeout. + await expect(orcaPage.getByText(/^\d+ selected$/)).toHaveText( + `${MARQUEE_WORKSPACE_COUNT} selected` + ) }) })