mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/bug-macos-access-other-apps-data-prompt-keeps-re
This commit is contained in:
Generated
+50
-83
@@ -1494,6 +1494,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1':
|
||||
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1':
|
||||
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -1942,56 +1948,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -2064,56 +2062,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2575,42 +2565,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -3167,11 +3151,11 @@ packages:
|
||||
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
|
||||
engines: {node: '>= 5.10.0'}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -4175,8 +4159,8 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
flatted@3.4.3:
|
||||
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
|
||||
|
||||
flow-enums-runtime@0.0.6:
|
||||
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
|
||||
@@ -4910,28 +4894,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -5910,8 +5890,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.4:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
shell-quote@1.10.0:
|
||||
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
@@ -6585,6 +6565,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@7.5.13:
|
||||
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -8098,6 +8090,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
@@ -9674,7 +9671,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
@@ -9694,7 +9691,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4
|
||||
@@ -9711,15 +9708,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
debug: 4.4.3
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9729,10 +9717,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9762,21 +9746,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
@@ -10236,12 +10205,12 @@ snapshots:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
brace-expansion@1.1.16:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
brace-expansion@5.0.7:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -10839,11 +10808,11 @@ snapshots:
|
||||
|
||||
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-config-prettier: 9.1.2(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
eslint-plugin-node: 11.1.0(eslint@9.39.4)
|
||||
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
|
||||
@@ -10867,7 +10836,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
@@ -10890,7 +10859,7 @@ snapshots:
|
||||
eslint-utils: 2.1.0
|
||||
regexpp: 3.2.0
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -10901,7 +10870,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
hasown: 2.0.3
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
@@ -10998,7 +10967,7 @@ snapshots:
|
||||
|
||||
eslint@9.39.4:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
@@ -11519,10 +11488,10 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
flatted: 3.4.3
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.4.2: {}
|
||||
flatted@3.4.3: {}
|
||||
|
||||
flow-enums-runtime@0.0.6: {}
|
||||
|
||||
@@ -12933,7 +12902,7 @@ snapshots:
|
||||
serialize-error: 2.1.0
|
||||
source-map: 0.5.7
|
||||
throat: 5.0.0
|
||||
ws: 7.5.11
|
||||
ws: 7.5.13
|
||||
yargs: 17.7.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -12967,11 +12936,11 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
brace-expansion: 5.0.7
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.15
|
||||
brace-expansion: 1.1.16
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -13342,7 +13311,7 @@ snapshots:
|
||||
|
||||
react-devtools-core@6.1.5:
|
||||
dependencies:
|
||||
shell-quote: 1.8.4
|
||||
shell-quote: 1.10.0
|
||||
ws: 7.5.11
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -13769,7 +13738,7 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.4: {}
|
||||
shell-quote@1.10.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
@@ -14071,10 +14040,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
ts-declaration-location@1.0.7(typescript@5.9.3):
|
||||
dependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -14405,6 +14370,8 @@ snapshots:
|
||||
|
||||
ws@7.5.11: {}
|
||||
|
||||
ws@7.5.13: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
xcode@3.0.1:
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.151-rc.0",
|
||||
"version": "1.4.151-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -114,7 +114,7 @@
|
||||
"@xterm/addon-serialize": "0.15.0-beta.287",
|
||||
"@xterm/headless": "6.1.0-beta.287",
|
||||
"agent-browser": "~0.27.0",
|
||||
"electron-updater": "^6.8.3",
|
||||
"electron-updater": "^6.8.9",
|
||||
"i18next": "^26.3.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
@@ -176,9 +176,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.12",
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-builder-squirrel-windows": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
@@ -213,7 +214,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"shadcn": "^4.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
@@ -251,7 +252,8 @@
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"monaco-editor>dompurify": "3.4.11"
|
||||
"@modelcontextprotocol/sdk>@hono/node-server": "2.0.10",
|
||||
"monaco-editor>dompurify": "3.4.12"
|
||||
},
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
|
||||
Generated
+460
-855
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import { types } from 'node:util'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildGuestOverlayScript } from './grab-guest-script'
|
||||
import { clampGrabPayload } from './browser-grab-payload'
|
||||
|
||||
describe('buildGuestOverlayScript', () => {
|
||||
it('returns a non-empty string for arm action', () => {
|
||||
@@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => {
|
||||
expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)')
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for issue #9947: Zone.js swaps the global Promise for a
|
||||
// non-native thenable, which executeJavaScript won't unwrap — so a page-global
|
||||
// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }.
|
||||
describe('awaitClick under a Zone.js-patched global Promise', () => {
|
||||
type Executor<T> = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void
|
||||
|
||||
// Native promises are kept off the instance so its own enumerable keys match
|
||||
// Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value'].
|
||||
const nativeOf = new WeakMap<object, Promise<unknown>>()
|
||||
|
||||
/** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */
|
||||
class ZoneAwarePromiseLike<T = unknown> {
|
||||
__zone_symbol__state: unknown = null
|
||||
__zone_symbol__value: unknown = undefined
|
||||
|
||||
constructor(executor: Executor<T>) {
|
||||
nativeOf.set(
|
||||
this,
|
||||
new Promise<T>((res, rej) => {
|
||||
executor(
|
||||
(value) => {
|
||||
this.__zone_symbol__state = true
|
||||
this.__zone_symbol__value = value
|
||||
res(value)
|
||||
},
|
||||
(reason) => {
|
||||
this.__zone_symbol__state = false
|
||||
this.__zone_symbol__value = reason
|
||||
rej(reason)
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`,
|
||||
// so an Object.prototype.toString check is fooled into seeing a promise. The
|
||||
// boundary below deliberately uses the brand-based V8 check instead, and the
|
||||
// control test asserts this tag does NOT let the wrapper masquerade as native.
|
||||
get [Symbol.toStringTag](): string {
|
||||
return 'Promise'
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise
|
||||
then(
|
||||
onFulfilled?: ((value: unknown) => unknown) | null,
|
||||
onRejected?: ((reason: unknown) => unknown) | null
|
||||
): Promise<unknown> {
|
||||
const native = nativeOf.get(this) as Promise<unknown>
|
||||
return native.then(onFulfilled ?? undefined, onRejected ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models Electron's boundary: it awaits only genuine V8 promises and
|
||||
* serializes anything else — including non-native thenables — by value.
|
||||
* `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses):
|
||||
* realm-independent and unforgeable, unlike an Object.prototype.toString /
|
||||
* Symbol.toStringTag check that a ZoneAwarePromise would defeat.
|
||||
*/
|
||||
async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise<unknown> {
|
||||
if (types.isPromise(completionValue)) {
|
||||
return await (completionValue as Promise<unknown>)
|
||||
}
|
||||
return { ...(completionValue as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
const validPayload = (): Record<string, unknown> => ({
|
||||
// A minimal but structurally valid payload — page + target are what
|
||||
// clampGrabPayload requires and what the bug stripped away.
|
||||
page: { title: 'Angular App' },
|
||||
target: { tagName: 'button' },
|
||||
nearbyText: [],
|
||||
ancestorPath: [],
|
||||
screenshot: null
|
||||
})
|
||||
|
||||
function armGrabHarness(options?: {
|
||||
extractPayload?: () => unknown
|
||||
getCurrentElement?: () => unknown
|
||||
}): {
|
||||
window: { __orcaGrab: Record<string, unknown> }
|
||||
click: () => void
|
||||
contextmenu: () => void
|
||||
cancel: () => void
|
||||
} {
|
||||
const handlers: Record<string, (event: unknown) => void> = {}
|
||||
const noopEvent = {
|
||||
preventDefault(): void {},
|
||||
stopPropagation(): void {},
|
||||
stopImmediatePropagation(): void {}
|
||||
}
|
||||
const grab: Record<string, unknown> = {
|
||||
host: {
|
||||
addEventListener(type: string, fn: (event: unknown) => void): void {
|
||||
handlers[type] = fn
|
||||
},
|
||||
removeEventListener(): void {}
|
||||
},
|
||||
extractPayload: options?.extractPayload ?? validPayload,
|
||||
getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})),
|
||||
freezeHighlight(): void {},
|
||||
cleanup(): void {}
|
||||
}
|
||||
const window = { __orcaGrab: grab }
|
||||
return {
|
||||
window,
|
||||
click: () => handlers.click?.(noopEvent),
|
||||
contextmenu: () => handlers.contextmenu?.(noopEvent),
|
||||
// cancelAwait is installed on __orcaGrab by the script itself at runtime.
|
||||
cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.()
|
||||
}
|
||||
}
|
||||
|
||||
const runAwaitClick = (harness: ReturnType<typeof armGrabHarness>): unknown =>
|
||||
runInNewContext(buildGuestOverlayScript('awaitClick'), {
|
||||
window: harness.window,
|
||||
Promise: ZoneAwarePromiseLike,
|
||||
Error
|
||||
})
|
||||
|
||||
it('returns the payload through a native async promise, not the page-global Promise', () => {
|
||||
const script = buildGuestOverlayScript('awaitClick')
|
||||
expect(script).toContain('(async function()')
|
||||
expect(script).toContain('return await new Promise(')
|
||||
})
|
||||
|
||||
it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
// The async IIFE hands Electron an intrinsic promise even though the global
|
||||
// Promise is a non-native thenable — so the boundary unwraps it.
|
||||
expect(types.isPromise(completion)).toBe(true)
|
||||
|
||||
harness.click()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toHaveProperty('page')
|
||||
expect(received).toHaveProperty('target')
|
||||
expect(received).not.toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.contextmenu()
|
||||
const received = (await crossExecuteJavaScriptBoundary(completion)) as Record<string, unknown>
|
||||
|
||||
expect(received).toHaveProperty('__orcaContextMenu', true)
|
||||
expect(received.payload).toHaveProperty('page')
|
||||
expect(clampGrabPayload(received.payload)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.cancel()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toEqual({ __orcaCancelled: true })
|
||||
})
|
||||
|
||||
it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => {
|
||||
// getCurrentElement -> null drives onClick's reject(new Error('cancelled')),
|
||||
// which must surface as a rejected intrinsic promise (not a serialized value)
|
||||
// so the controller classifies it as a cancellation rather than a payload.
|
||||
const harness = armGrabHarness({ getCurrentElement: () => null })
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.click()
|
||||
await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled')
|
||||
})
|
||||
|
||||
it('control: a bare page-global new Promise would cross as the raw wrapper', async () => {
|
||||
// Proves the harness detects the regression: without the async wrapper the
|
||||
// completion value is the ZoneAwarePromise itself, which the boundary
|
||||
// serializes to __zone_symbol__* fields with no page/target.
|
||||
const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', {
|
||||
Promise: ZoneAwarePromiseLike
|
||||
})
|
||||
// Symbol.toStringTag='Promise' fools a toString check — exactly why the
|
||||
// boundary must use the brand-based IsPromise, which still rejects it.
|
||||
expect(Object.prototype.toString.call(bare)).toBe('[object Promise]')
|
||||
expect(types.isPromise(bare)).toBe(false)
|
||||
|
||||
const received = await crossExecuteJavaScriptBoundary(bare)
|
||||
expect(received).not.toHaveProperty('page')
|
||||
expect(received).toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() {
|
||||
})()`
|
||||
|
||||
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
|
||||
const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
const AWAIT_CLICK_SCRIPT = `(async function() {
|
||||
// Why: hand the click result to executeJavaScript through a native (intrinsic)
|
||||
// Promise. On pages that replace the global Promise with a non-native thenable
|
||||
// — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not
|
||||
// recognized as a promise by Electron, so its raw wrapper object (exposing
|
||||
// __zone_symbol__state/__value instead of { page, target }) crosses the boundary
|
||||
// and main rejects it as an invalid payload structure. An async function's
|
||||
// promise comes from the engine intrinsic that page code cannot reassign, so
|
||||
// Electron always unwraps it to the resolved payload.
|
||||
return await new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
})`
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
});
|
||||
})()`
|
||||
|
||||
const FINALIZE_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
@@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) return true;
|
||||
// If there's an active awaitClick Promise, cancel it so the
|
||||
// executeJavaScript call in main rejects and settles the grab op.
|
||||
// If there's an active awaitClick Promise, cancel it: cancelAwait resolves
|
||||
// it with the __orcaCancelled marker so the executeJavaScript call in main
|
||||
// settles the grab op as a cancellation.
|
||||
if (grab.cancelAwait) {
|
||||
grab.cancelAwait();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
|
||||
function unusedSubprocess(): SubprocessHandle {
|
||||
throw new Error('Test must not create a PTY')
|
||||
}
|
||||
|
||||
describe('daemon authenticated client activity', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-'))
|
||||
socketPath = getDaemonSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function connectHello(role: 'control' | 'stream', clientId: string): Promise<Socket> {
|
||||
const socket = connect(socketPath)
|
||||
await new Promise<void>((resolve) => socket.once('connect', resolve))
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
token: readFileSync(tokenPath, 'utf8').trim(),
|
||||
clientId,
|
||||
role
|
||||
})
|
||||
)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('data', (data) => {
|
||||
const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string }
|
||||
if (response.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(response.error ?? 'hello rejected'))
|
||||
}
|
||||
})
|
||||
socket.once('error', reject)
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
it('excludes control-only health probes and reports one complete app pair', async () => {
|
||||
const onAuthenticatedClientPair = vi.fn()
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: unusedSubprocess,
|
||||
onAuthenticatedClientPair
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await connectHello('control', 'resolver-health-check')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
|
||||
await connectHello('control', 'app-client')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
await connectHello('stream', 'app-client')
|
||||
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => {
|
||||
it('still requires --socket and --token when --log-file is given', () => {
|
||||
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('parses the GUI-only --login-session-watch flag and omits it when absent', () => {
|
||||
expect(
|
||||
parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch'])
|
||||
).toEqual({
|
||||
socketPath: '/tmp/t.sock',
|
||||
tokenPath: '/tmp/t.token',
|
||||
loginSessionWatch: true
|
||||
})
|
||||
expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty(
|
||||
'loginSessionWatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
* Signals readiness to parent via IPC: { type: 'ready' }
|
||||
* Shuts down cleanly on SIGTERM.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
|
||||
import {
|
||||
prepareMacosTccLoginShell,
|
||||
probeMacosLoginSessionAlive
|
||||
} from '../providers/macos-tcc-login-shell'
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
/** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */
|
||||
loginSessionWatch?: boolean
|
||||
/** Optional — absent for adopted old daemons and tests, which log nothing. */
|
||||
logFilePath?: string
|
||||
}
|
||||
@@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
let logFilePath = ''
|
||||
let pidPath = ''
|
||||
let launchNonce = ''
|
||||
let loginSessionWatch = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--socket' && argv[i + 1]) {
|
||||
@@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
|
||||
launchNonce = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--login-session-watch') {
|
||||
loginSessionWatch = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(pidPath ? { pidPath, launchNonce } : {}),
|
||||
...(loginSessionWatch ? { loginSessionWatch } : {}),
|
||||
...(logFilePath ? { logFilePath } : {})
|
||||
}
|
||||
}
|
||||
@@ -73,7 +85,7 @@ async function main(): Promise<void> {
|
||||
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
|
||||
process.stderr.on('error', () => {})
|
||||
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs(
|
||||
process.argv.slice(2)
|
||||
)
|
||||
const startedAtMs = Date.now() - process.uptime() * 1000
|
||||
@@ -123,6 +135,7 @@ async function main(): Promise<void> {
|
||||
})
|
||||
|
||||
let daemon: DaemonHandle | null = null
|
||||
let deathWatch: MacosLoginSessionDeathWatch | null = null
|
||||
let shuttingDown = false
|
||||
// Bound the wait so a wedged native shutdown can't leave the daemon running
|
||||
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
|
||||
@@ -134,6 +147,7 @@ async function main(): Promise<void> {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
deathWatch?.stop()
|
||||
daemonLog.log('shutdown', { reason })
|
||||
try {
|
||||
if (daemon) {
|
||||
@@ -157,6 +171,63 @@ async function main(): Promise<void> {
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
// Why: a dead macOS login session cannot be fabricated without root (PAM owns
|
||||
// audit-session teardown), so e2e drives the oracles from a verdict file:
|
||||
// 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' →
|
||||
// timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive.
|
||||
const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE
|
||||
const readE2eVerdict = (): string => {
|
||||
try {
|
||||
return readFileSync(e2eProbeFile as string, 'utf8').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
deathWatch =
|
||||
loginSessionWatch && process.platform === 'darwin'
|
||||
? new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
if (verdict === 'alive') {
|
||||
return { ok: true, conclusive: true, reason: 'accepted' }
|
||||
}
|
||||
if (verdict === 'dead') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
: probeMacosLoginSessionAlive,
|
||||
readResolverHealth: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy'
|
||||
}
|
||||
: readCurrentProcessMacSystemResolverHealth,
|
||||
...(e2eProbeFile
|
||||
? {
|
||||
timing: {
|
||||
periodicProbeMs: 2_000,
|
||||
rejectionRecheckMs: 500,
|
||||
ptyExitDebounceMs: 200,
|
||||
clientActivityMinGapMs: 1_000,
|
||||
minProbeGapMs: 100
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
log: daemonLog,
|
||||
onRetire: (details) => {
|
||||
shuttingDown = true
|
||||
daemonLog.log('login-session-dead-retire', details)
|
||||
daemonLog.close()
|
||||
// Why: crash-style exit (no PTY teardown) keeps session meta unclean so the
|
||||
// replacement daemon cold-restores scrollback; stale socket/pid files ride
|
||||
// the existing dead-endpoint recovery.
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
daemon = await startDaemon({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
@@ -165,14 +236,22 @@ async function main(): Promise<void> {
|
||||
...(pidPath ? { startedAtMs } : {}),
|
||||
log: daemonLog,
|
||||
preparePtySpawn: runMacosLoginPreflight,
|
||||
...(deathWatch
|
||||
? {
|
||||
onPtySessionExit: () => deathWatch.notifyPtyExit(),
|
||||
onAuthenticatedClientPair: () => deathWatch.notifyClientActivity()
|
||||
}
|
||||
: {}),
|
||||
spawnSubprocess: (opts) => createPtySubprocess(opts),
|
||||
onIdleShutdown: () => {
|
||||
deathWatch?.stop()
|
||||
shuttingDown = true
|
||||
daemonLog.log('shutdown', { reason: 'idle' })
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
deathWatch?.start()
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
|
||||
@@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
@@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
'/fake/socket',
|
||||
'--token',
|
||||
'/fake/token',
|
||||
'--login-session-watch',
|
||||
'--log-file',
|
||||
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
||||
]),
|
||||
@@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[]
|
||||
expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch')
|
||||
|
||||
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
|
||||
|
||||
@@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions(
|
||||
return true
|
||||
}
|
||||
|
||||
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
function createOutOfProcessLauncher(
|
||||
runtimeDir: string,
|
||||
macosLoginSessionWatch = false
|
||||
): DaemonLauncher {
|
||||
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
|
||||
const entryPath = getDaemonEntryPath()
|
||||
const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
|
||||
@@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
|
||||
...daemonLogArgs()
|
||||
],
|
||||
{
|
||||
@@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
|
||||
export async function initDaemonPtyProvider(
|
||||
signal?: AbortSignal,
|
||||
options: { macosLoginSessionWatch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
logDaemonMilestone('daemon-init-start')
|
||||
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
|
||||
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
|
||||
@@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
runtimeDir,
|
||||
launcher: createOutOfProcessLauncher(runtimeDir)
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
|
||||
})
|
||||
|
||||
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
|
||||
|
||||
@@ -11,6 +11,8 @@ export type DaemonStartOptions = {
|
||||
protocolVersion?: number
|
||||
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
|
||||
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
|
||||
onPtySessionExit?: DaemonServerOptions['onPtySessionExit']
|
||||
onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair']
|
||||
log?: DaemonFileLog
|
||||
onIdleShutdown?: () => void
|
||||
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
|
||||
@@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
|
||||
...(opts.onPtySessionExit ? { onPtySessionExit: opts.onPtySessionExit } : {}),
|
||||
...(opts.onAuthenticatedClientPair
|
||||
? { onAuthenticatedClientPair: opts.onAuthenticatedClientPair }
|
||||
: {}),
|
||||
...(opts.log ? { log: opts.log } : {}),
|
||||
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
|
||||
...(opts.initialAdoptionTestConfig
|
||||
|
||||
@@ -57,6 +57,9 @@ export type DaemonServerOptions = {
|
||||
}
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
preparePtySpawn?: () => Promise<void>
|
||||
// Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections.
|
||||
onPtySessionExit?: (sessionId: string) => void
|
||||
onAuthenticatedClientPair?: () => void
|
||||
log?: DaemonFileLog
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
@@ -101,6 +104,7 @@ export class DaemonServer {
|
||||
private startedAtMs: number | null
|
||||
private protocolVersion: number
|
||||
private onIdleShutdown: () => void
|
||||
private onAuthenticatedClientPair: () => void
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
private preparePtySpawn: () => Promise<void>
|
||||
private log: DaemonFileLog
|
||||
@@ -180,7 +184,11 @@ export class DaemonServer {
|
||||
now: () => Date.now()
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
|
||||
this.host = new TerminalHost({
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
|
||||
})
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
|
||||
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
|
||||
@@ -481,6 +489,8 @@ export class DaemonServer {
|
||||
}
|
||||
this.setupStreamSocket(socket, client)
|
||||
client.authenticatedPairEstablished = true
|
||||
// Why: one-shot health probes authenticate only a control socket; they are not fresh app activity.
|
||||
this.onAuthenticatedClientPair()
|
||||
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = false
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
|
||||
import { createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import {
|
||||
MacosLoginSessionDeathWatch,
|
||||
type MacosLoginSessionDeathWatchOptions
|
||||
} from './macos-login-session-death-watch'
|
||||
import type { SystemResolverHealth } from './types'
|
||||
|
||||
const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' }
|
||||
const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' }
|
||||
const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' }
|
||||
|
||||
type FakeTimer = { at: number; callback: () => void; cleared: boolean }
|
||||
|
||||
class FakeClock {
|
||||
private timers: FakeTimer[] = []
|
||||
private nowMs = 0
|
||||
|
||||
setTimeout = (callback: () => void, delayMs: number): unknown => {
|
||||
const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false }
|
||||
this.timers.push(timer)
|
||||
return timer
|
||||
}
|
||||
|
||||
clearTimeout = (handle: unknown): void => {
|
||||
;(handle as FakeTimer).cleared = true
|
||||
}
|
||||
|
||||
now = (): number => this.nowMs
|
||||
|
||||
async advance(ms: number): Promise<void> {
|
||||
const target = this.nowMs + ms
|
||||
for (;;) {
|
||||
const due = this.timers
|
||||
.filter((t) => !t.cleared && t.at <= target)
|
||||
.sort((a, b) => a.at - b.at)[0]
|
||||
if (!due) {
|
||||
break
|
||||
}
|
||||
this.nowMs = due.at
|
||||
due.cleared = true
|
||||
due.callback()
|
||||
// Why: probes are async; let their promise chains settle before firing the next timer.
|
||||
await drainMicrotasks()
|
||||
}
|
||||
this.nowMs = target
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.timers.filter((t) => !t.cleared).length
|
||||
}
|
||||
}
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createWatch(
|
||||
overrides: Partial<MacosLoginSessionDeathWatchOptions> & {
|
||||
outcomes?: (LoginPreflightOutcome | null)[]
|
||||
} = {}
|
||||
): {
|
||||
watch: MacosLoginSessionDeathWatch
|
||||
clock: FakeClock
|
||||
onRetire: ReturnType<typeof vi.fn>
|
||||
probe: ReturnType<typeof vi.fn>
|
||||
setResolverHealth: (health: SystemResolverHealth) => void
|
||||
} {
|
||||
const clock = new FakeClock()
|
||||
const onRetire = vi.fn()
|
||||
const outcomes = overrides.outcomes ?? []
|
||||
// Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch.
|
||||
const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED))
|
||||
let resolverHealth: SystemResolverHealth = 'unhealthy'
|
||||
const watch = new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: overrides.probeLoginSession ?? probe,
|
||||
readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth),
|
||||
onRetire: overrides.onRetire ?? onRetire,
|
||||
log: overrides.log ?? createNoopDaemonFileLog(),
|
||||
clock,
|
||||
timing: {
|
||||
periodicProbeMs: 120_000,
|
||||
rejectionRecheckMs: 10_000,
|
||||
ptyExitDebounceMs: 2_000,
|
||||
clientActivityMinGapMs: 30_000,
|
||||
minProbeGapMs: 5_000,
|
||||
...overrides.timing
|
||||
}
|
||||
})
|
||||
return {
|
||||
watch,
|
||||
clock,
|
||||
onRetire,
|
||||
probe,
|
||||
setResolverHealth: (health) => {
|
||||
resolverHealth = health
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MacosLoginSessionDeathWatch', () => {
|
||||
it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
|
||||
await clock.advance(120_000) // periodic → rejection 1
|
||||
await clock.advance(10_000) // recheck → rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await clock.advance(10_000) // recheck → rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledWith({
|
||||
cause: 'pam-rejections',
|
||||
rejections: 3,
|
||||
resolverHealth: 'unhealthy'
|
||||
})
|
||||
})
|
||||
|
||||
it('never retires when the session was never conclusively accepted', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the rejection streak on a conclusive acceptance', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
await clock.advance(10_000) // acceptance → reset
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the rejection streak across interleaved inconclusive probes', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // inconclusive timeout — rejection streak holds
|
||||
await clock.advance(120_000) // rejection 2
|
||||
await clock.advance(10_000) // inconclusive
|
||||
await clock.advance(120_000) // rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
expect(onRetire.mock.calls[0][0].rejections).toBe(3)
|
||||
expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections')
|
||||
})
|
||||
|
||||
it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => {
|
||||
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
|
||||
const { watch, clock, onRetire, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)],
|
||||
readResolverHealth
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(probe).toHaveBeenCalledTimes(11)
|
||||
expect(readResolverHealth).not.toHaveBeenCalled()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['healthy', 'unknown'] as const)(
|
||||
'suppresses retirement while resolver health is %s, then retires on explicit degradation',
|
||||
async (initialResolverHealth) => {
|
||||
const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
setResolverHealth(initialResolverHealth)
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // threshold reached but resolver did not corroborate death
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
const probesAtSuppression = probe.mock.calls.length
|
||||
setResolverHealth('unhealthy')
|
||||
await clock.advance(10_000)
|
||||
expect(probe).toHaveBeenCalledTimes(probesAtSuppression)
|
||||
await clock.advance(110_000) // suppressed states return to the bounded periodic cadence
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('debounces a sustained PTY-exit burst into one trailing probe', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(60_000)
|
||||
const before = probe.mock.calls.length
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_999)
|
||||
expect(probe).toHaveBeenCalledTimes(before)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('probes on client activity only after the min gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ACCEPTED, ACCEPTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
const after = probe.mock.calls.length
|
||||
watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe
|
||||
await clock.advance(29_999)
|
||||
expect(probe.mock.calls.length).toBe(after)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(after + 1)
|
||||
})
|
||||
|
||||
it('defers a PTY-exit trigger that lands inside the global probe gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(4_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains one follow-up when a logout signal arrives during a probe', async () => {
|
||||
let resolveStartup!: (outcome: LoginPreflightOutcome) => void
|
||||
const startup = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockReturnValueOnce(startup)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(2_000)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
resolveStartup(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(2_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains client activity that arrives during an armed periodic probe', async () => {
|
||||
let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void
|
||||
const periodic = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolvePeriodic = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockResolvedValueOnce(ACCEPTED)
|
||||
.mockReturnValueOnce(periodic)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
|
||||
watch.notifyClientActivity()
|
||||
resolvePeriodic(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(29_999)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('disables itself when the wrapper machinery does not apply', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [null] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await clock.advance(600_000)
|
||||
watch.notifyPtyExit()
|
||||
watch.notifyClientActivity()
|
||||
await clock.advance(600_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop() cancels all pending timers', async () => {
|
||||
const { watch, clock } = createWatch({ outcomes: [ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
watch.notifyPtyExit()
|
||||
watch.stop()
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stop() aborts an in-flight subprocess probe', async () => {
|
||||
let probeSignal: AbortSignal | undefined
|
||||
const probe = vi.fn((signal?: AbortSignal) => {
|
||||
probeSignal = signal
|
||||
return new Promise<LoginPreflightOutcome>(() => {})
|
||||
})
|
||||
const { watch } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
expect(probeSignal?.aborted).toBe(false)
|
||||
|
||||
watch.stop()
|
||||
|
||||
expect(probeSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('stop() prevents an in-flight resolver check from retiring the daemon', async () => {
|
||||
let resolveHealth!: (health: SystemResolverHealth) => void
|
||||
let resolverSignal: AbortSignal | undefined
|
||||
const resolverHealth = new Promise<SystemResolverHealth>((resolve) => {
|
||||
resolveHealth = resolve
|
||||
})
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED],
|
||||
readResolverHealth: (signal) => {
|
||||
resolverSignal = signal
|
||||
return resolverHealth
|
||||
}
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // retirement is now waiting on resolver health
|
||||
|
||||
watch.stop()
|
||||
expect(resolverSignal?.aborted).toBe(true)
|
||||
resolveHealth('unhealthy')
|
||||
await drainMicrotasks()
|
||||
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => {
|
||||
const outcomes: (() => Promise<LoginPreflightOutcome>)[] = [
|
||||
async () => ACCEPTED,
|
||||
async () => {
|
||||
throw new Error('launchctl exploded')
|
||||
},
|
||||
async () => ACCEPTED
|
||||
]
|
||||
const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))())
|
||||
const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // throwing probe
|
||||
await clock.advance(120_000) // rescheduled probe still runs
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { SystemResolverHealth } from './types'
|
||||
|
||||
// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient
|
||||
// rejection storm (PAM db reload, OS update) from retiring a daemon whose login
|
||||
// session is still alive; a real logout rejects conclusively on every probe.
|
||||
const REQUIRED_CONSECUTIVE_REJECTIONS = 3
|
||||
const PERIODIC_PROBE_MS = 120_000
|
||||
const REJECTION_RECHECK_MS = 10_000
|
||||
const PTY_EXIT_DEBOUNCE_MS = 2_000
|
||||
// Why: a client hello right after login is the fastest death signal for a stale
|
||||
// daemon, but steady reconnects must not turn hellos into a PAM probe storm.
|
||||
const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000
|
||||
const MIN_PROBE_GAP_MS = 5_000
|
||||
|
||||
type WatchClock = {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
now(): number
|
||||
}
|
||||
|
||||
export type MacosLoginSessionDeathWatchOptions = {
|
||||
/** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */
|
||||
probeLoginSession: (signal?: AbortSignal) => Promise<LoginPreflightOutcome | null>
|
||||
readResolverHealth: (signal?: AbortSignal) => Promise<SystemResolverHealth>
|
||||
onRetire: (details: {
|
||||
cause: 'pam-rejections'
|
||||
rejections: number
|
||||
resolverHealth: SystemResolverHealth
|
||||
}) => void
|
||||
log: DaemonFileLog
|
||||
/** Direct-construction seam for deterministic tests; production uses real timers. */
|
||||
clock?: WatchClock
|
||||
timing?: Partial<{
|
||||
periodicProbeMs: number
|
||||
rejectionRecheckMs: number
|
||||
ptyExitDebounceMs: number
|
||||
clientActivityMinGapMs: number
|
||||
minProbeGapMs: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects that the macOS GUI login session this daemon was born into has died
|
||||
* (full logout / WindowServer session teardown) and retires the daemon so the
|
||||
* next app start cold-starts a replacement inside the live session (#7936).
|
||||
*
|
||||
* A daemon in a dead login session is unsalvageable: its PAM context can no
|
||||
* longer host `login(1)` spawns ("Login incorrect" zombies) and its Mach
|
||||
* bootstrap namespace has lost the system DNS resolver, so every terminal it
|
||||
* hosts has no egress. Retirement is the only converging heal.
|
||||
*
|
||||
* The oracle is the existing TCC login-shell PAM probe: it conclusively accepts
|
||||
* while the session is valid — including fast-user-switched-away sessions — and
|
||||
* conclusively rejects once the session is destroyed. Retirement additionally
|
||||
* requires the in-process system resolver to be explicitly degraded, so an
|
||||
* inconclusive probe or PAM anomaly alone can never kill a healthy daemon.
|
||||
*/
|
||||
export class MacosLoginSessionDeathWatch {
|
||||
private readonly probeLoginSession: MacosLoginSessionDeathWatchOptions['probeLoginSession']
|
||||
private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth']
|
||||
private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire']
|
||||
private readonly log: DaemonFileLog
|
||||
private readonly clock: WatchClock
|
||||
private readonly periodicProbeMs: number
|
||||
private readonly rejectionRecheckMs: number
|
||||
private readonly ptyExitDebounceMs: number
|
||||
private readonly clientActivityMinGapMs: number
|
||||
private readonly minProbeGapMs: number
|
||||
|
||||
// Why: retire only a daemon that once proved its session could host login(1);
|
||||
// a host where the wrapper never worked has no death signal to trust.
|
||||
private armed = false
|
||||
private consecutiveRejections = 0
|
||||
private lastProbeStartedAtMs: number | null = null
|
||||
private probeInFlight = false
|
||||
private stopped = false
|
||||
private retired = false
|
||||
private scheduledProbeTimer: unknown | null = null
|
||||
private scheduledProbeAtMs: number | null = null
|
||||
private ptyExitDebounceTimer: unknown | null = null
|
||||
private pendingProbeTrigger: string | null = null
|
||||
private probeAbortController: AbortController | null = null
|
||||
|
||||
constructor(opts: MacosLoginSessionDeathWatchOptions) {
|
||||
this.probeLoginSession = opts.probeLoginSession
|
||||
this.readResolverHealth = opts.readResolverHealth
|
||||
this.onRetire = opts.onRetire
|
||||
this.log = opts.log
|
||||
this.clock = opts.clock ?? {
|
||||
setTimeout: (callback, delayMs) => {
|
||||
const timer = setTimeout(callback, delayMs)
|
||||
timer.unref()
|
||||
return timer
|
||||
},
|
||||
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
now: () => Date.now()
|
||||
}
|
||||
this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS
|
||||
this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS
|
||||
this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS
|
||||
this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS
|
||||
this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
void this.runProbe('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.probeAbortController?.abort()
|
||||
this.probeAbortController = null
|
||||
if (this.scheduledProbeTimer !== null) {
|
||||
this.clock.clearTimeout(this.scheduledProbeTimer)
|
||||
this.scheduledProbeTimer = null
|
||||
this.scheduledProbeAtMs = null
|
||||
}
|
||||
if (this.ptyExitDebounceTimer !== null) {
|
||||
this.clock.clearTimeout(this.ptyExitDebounceTimer)
|
||||
this.ptyExitDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** A mass PTY-exit burst is what a logout's SIGHUP sweep looks like from inside the daemon. */
|
||||
notifyPtyExit(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.ptyExitDebounceTimer !== null) {
|
||||
this.clock.clearTimeout(this.ptyExitDebounceTimer)
|
||||
}
|
||||
this.ptyExitDebounceTimer = this.clock.setTimeout(() => {
|
||||
this.ptyExitDebounceTimer = null
|
||||
void this.runProbe('pty-exit')
|
||||
}, this.ptyExitDebounceMs)
|
||||
}
|
||||
|
||||
notifyClientActivity(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.probeInFlight) {
|
||||
if (this.armed) {
|
||||
this.retainPendingProbe('client-hello')
|
||||
}
|
||||
return
|
||||
}
|
||||
const elapsedSinceProbe =
|
||||
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
|
||||
if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) {
|
||||
// Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop.
|
||||
this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello')
|
||||
return
|
||||
}
|
||||
void this.runProbe('client-hello')
|
||||
}
|
||||
|
||||
private scheduleProbe(delayMs: number, trigger: string): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.scheduledProbeTimer !== null) {
|
||||
this.clock.clearTimeout(this.scheduledProbeTimer)
|
||||
}
|
||||
this.scheduledProbeAtMs = this.clock.now() + delayMs
|
||||
this.scheduledProbeTimer = this.clock.setTimeout(() => {
|
||||
this.scheduledProbeTimer = null
|
||||
this.scheduledProbeAtMs = null
|
||||
void this.runProbe(trigger)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private scheduleNextProbe(delayMs: number): void {
|
||||
this.scheduleProbe(delayMs, 'periodic')
|
||||
}
|
||||
|
||||
private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void {
|
||||
const requestedAtMs = this.clock.now() + delayMs
|
||||
if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) {
|
||||
return
|
||||
}
|
||||
this.scheduleProbe(delayMs, trigger)
|
||||
}
|
||||
|
||||
private probeGapMs(trigger: string): number {
|
||||
return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs
|
||||
}
|
||||
|
||||
private retainPendingProbe(trigger: string): void {
|
||||
if (
|
||||
this.pendingProbeTrigger === null ||
|
||||
this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger)
|
||||
) {
|
||||
this.pendingProbeTrigger = trigger
|
||||
}
|
||||
}
|
||||
|
||||
private async runProbe(trigger: string): Promise<void> {
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (this.probeInFlight) {
|
||||
// Why: the current probe may describe the pre-logout state; retain one follow-up without polling.
|
||||
this.retainPendingProbe(trigger)
|
||||
return
|
||||
}
|
||||
const elapsedSinceProbe =
|
||||
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
|
||||
if (
|
||||
trigger !== 'startup' &&
|
||||
elapsedSinceProbe !== null &&
|
||||
elapsedSinceProbe < this.minProbeGapMs
|
||||
) {
|
||||
this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger)
|
||||
return
|
||||
}
|
||||
this.probeInFlight = true
|
||||
this.lastProbeStartedAtMs = this.clock.now()
|
||||
const abortController = new AbortController()
|
||||
this.probeAbortController = abortController
|
||||
try {
|
||||
const outcome = await this.probeLoginSession(abortController.signal)
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (outcome === null) {
|
||||
// Why: no wrapper machinery means no PAM oracle — watching would only ever misfire.
|
||||
this.log.log('login-session-watch-disabled', { trigger })
|
||||
this.stop()
|
||||
return
|
||||
}
|
||||
if (!outcome.conclusive) {
|
||||
// Why: repeated timeouts are still ambiguous on slow/offline PAM hosts;
|
||||
// never turn an inconclusive probe into authority to orphan live PTYs.
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
if (outcome.ok) {
|
||||
if (!this.armed) {
|
||||
this.log.log('login-session-watch-armed', { trigger })
|
||||
}
|
||||
this.armed = true
|
||||
this.consecutiveRejections = 0
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
if (!this.armed) {
|
||||
// Session never hosted login(1) here; the preflight already degraded spawns.
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
this.consecutiveRejections++
|
||||
this.log.log('login-session-probe-rejected', {
|
||||
trigger,
|
||||
rejections: this.consecutiveRejections
|
||||
})
|
||||
if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) {
|
||||
this.scheduleNextProbe(this.rejectionRecheckMs)
|
||||
return
|
||||
}
|
||||
await this.retireIfResolverDegraded(() => {
|
||||
this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1
|
||||
}, abortController.signal)
|
||||
} catch (error) {
|
||||
// Why: a probe failure is diagnostic only; an escaped rejection would trip the
|
||||
// daemon's fatal unhandled-error path and kill live terminals.
|
||||
this.log.log('login-session-probe-error', { message: (error as Error)?.message })
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
} finally {
|
||||
this.probeInFlight = false
|
||||
if (this.probeAbortController === abortController) {
|
||||
this.probeAbortController = null
|
||||
}
|
||||
const pendingTrigger = this.pendingProbeTrigger
|
||||
this.pendingProbeTrigger = null
|
||||
if (!this.stopped && !this.retired && pendingTrigger !== null) {
|
||||
const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now())
|
||||
this.scheduleProbeNoLaterThan(
|
||||
Math.max(0, this.probeGapMs(pendingTrigger) - elapsed),
|
||||
pendingTrigger
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async retireIfResolverDegraded(
|
||||
holdAtThreshold: () => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const resolverHealth = await this.readResolverHealth(signal)
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (resolverHealth !== 'unhealthy') {
|
||||
// Why: only explicit resolver degradation corroborates session death; unknown
|
||||
// probe failures must preserve terminals and avoid a permanent fast retry loop.
|
||||
this.log.log('login-session-retire-suppressed', {
|
||||
cause: 'pam-rejections',
|
||||
resolverHealth
|
||||
})
|
||||
holdAtThreshold()
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
this.retired = true
|
||||
this.onRetire({
|
||||
cause: 'pam-rejections',
|
||||
rejections: this.consecutiveRejections,
|
||||
resolverHealth
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type TerminalHostOptions = {
|
||||
terminalWindowsWslDistro?: string | null
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
}) => SubprocessHandle
|
||||
// Why: login-session death detection (#7936) needs subprocess exits even when no client is attached.
|
||||
onSessionReaped?: (sessionId: string) => void
|
||||
// Why: graceful shutdown checkpoints must finish in-process before teardown.
|
||||
onFinalCheckpoint?: (
|
||||
sessionId: string,
|
||||
|
||||
@@ -883,6 +883,8 @@ describe('TerminalHost', () => {
|
||||
})
|
||||
|
||||
it('does not list exited sessions', async () => {
|
||||
const onSessionReaped = vi.fn()
|
||||
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped })
|
||||
await host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
@@ -892,6 +894,7 @@ describe('TerminalHost', () => {
|
||||
|
||||
lastSubprocess._onExitCb?.(0)
|
||||
expect(host.listSessions()).toEqual([])
|
||||
expect(onSessionReaped).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class TerminalHost {
|
||||
private sessionTeardown = new TerminalSessionTeardown(this.sessions)
|
||||
private killedTombstones: TerminalHostTombstones
|
||||
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
|
||||
private onSessionReaped: TerminalHostOptions['onSessionReaped']
|
||||
private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
|
||||
private maxTombstones: number
|
||||
private creationFenced = false
|
||||
@@ -36,6 +37,7 @@ export class TerminalHost {
|
||||
|
||||
constructor(opts: TerminalHostOptions) {
|
||||
this.spawnSubprocess = opts.spawnSubprocess
|
||||
this.onSessionReaped = opts.onSessionReaped
|
||||
this.onFinalCheckpoint = opts.onFinalCheckpoint
|
||||
this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
|
||||
this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
|
||||
@@ -119,6 +121,7 @@ export class TerminalHost {
|
||||
}
|
||||
session.dispose()
|
||||
this.sessions.delete(sessionId)
|
||||
this.onSessionReaped?.(sessionId)
|
||||
}
|
||||
|
||||
signal(sessionId: string, sig: string): void {
|
||||
|
||||
+5
-1
@@ -674,7 +674,11 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
// Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer.
|
||||
startDaemonPtyProvider: async (signal) => {
|
||||
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
|
||||
await initDaemonPtyProvider(signal)
|
||||
// Why: only GUI-spawned macOS daemons watch for login-session death; a headless
|
||||
// serve daemon must survive its spawning session ending (SSH disconnect).
|
||||
await initDaemonPtyProvider(signal, {
|
||||
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
|
||||
})
|
||||
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
|
||||
},
|
||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
|
||||
|
||||
@@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => {
|
||||
const parentPath = '/workspace/assigned-issues'
|
||||
const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing'
|
||||
const parentId = `repo-1::${parentPath}`
|
||||
const childId = `repo-1::${childPath}`
|
||||
const metaById: Record<string, { instanceId: string }> = {
|
||||
[parentId]: { instanceId: 'parent-instance' },
|
||||
[childId]: { instanceId: 'child-instance' }
|
||||
}
|
||||
store.getWorktreeMeta.mockImplementation((id: string) => metaById[id])
|
||||
store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({
|
||||
...metaById[id],
|
||||
...updates
|
||||
}))
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
})
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'child-head',
|
||||
branch: 'refs/heads/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'parent-head',
|
||||
branch: 'refs/heads/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
|
||||
const folderRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const parentId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const childId = `${parentId}::workspace:child-instance`
|
||||
const metaById: Record<string, Record<string, unknown>> = {
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
}),
|
||||
[childId]: makeWorktreeMeta({
|
||||
instanceId: 'child-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([folderRepo])
|
||||
store.getRepo.mockReturnValue(folderRepo)
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
})
|
||||
|
||||
const result = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: folderRepo.id
|
||||
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hides agent scratch created inside a linked checkout from desktop listings', async () => {
|
||||
const linkedCheckoutPath = '/workspace/feature-x'
|
||||
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../shared/workspace-scope'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import type {
|
||||
AutomationWorkspaceProvenance,
|
||||
@@ -738,7 +739,7 @@ function buildDetectedGitWorktrees(
|
||||
repo.path,
|
||||
...liveWorktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
return liveWorktrees.map((gitWorktree) => {
|
||||
const detected = liveWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
let meta = store.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
@@ -766,6 +767,7 @@ function buildDetectedGitWorktrees(
|
||||
agentScratchWorktreePathMatcher
|
||||
})
|
||||
})
|
||||
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
|
||||
function stampAndMergeVisibleDetectedWorktree(
|
||||
@@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees(
|
||||
repo.path,
|
||||
...worktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
return worktrees.map((worktree) => {
|
||||
const detected = worktrees.map((worktree) => {
|
||||
const meta = store.getWorktreeMeta(worktree.id)
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
@@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees(
|
||||
})
|
||||
return applyMetadataFallbackVisibility(detected)
|
||||
})
|
||||
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
@@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers(
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: buildFolderDetectedWorktrees(store, repo)
|
||||
worktrees: projectResolvedWorktreeLineage(
|
||||
buildFolderDetectedWorktrees(store, repo),
|
||||
store.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
}
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
|
||||
@@ -132,6 +132,23 @@ resolver #1
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('kills scutil and removes listeners when its owner stops', async () => {
|
||||
mockPlatform('darwin')
|
||||
const child = createMockScutilProcess()
|
||||
vi.mocked(spawn).mockReturnValue(child)
|
||||
const abortController = new AbortController()
|
||||
|
||||
const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal)
|
||||
abortController.abort()
|
||||
|
||||
await expect(healthPromise).resolves.toBe('unknown')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('removes scutil listeners when the child closes normally', async () => {
|
||||
mockPlatform('darwin')
|
||||
const child = createMockScutilProcess()
|
||||
|
||||
@@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function readCurrentProcessMacSystemResolverHealth(): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin') {
|
||||
export async function readCurrentProcessMacSystemResolverHealth(
|
||||
signal?: AbortSignal
|
||||
): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin' || signal?.aborted) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
const onStderrData = (chunk: string): void => {
|
||||
stderr += chunk
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
child.kill('SIGKILL')
|
||||
finish()
|
||||
}
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
@@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.off('data', onStderrData)
|
||||
child.off('error', finish)
|
||||
child.off('close', finish)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`))
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
@@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.on('data', onStderrData)
|
||||
child.on('error', finish)
|
||||
child.on('close', finish)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, existsSyncMock, stdinEndMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
stdinEndMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
|
||||
|
||||
import { runMacosLoginSessionPtyProbe } from './macos-login-session-pty-probe'
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
|
||||
|
||||
describe('runMacosLoginSessionPtyProbe', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
execFileMock.mockReset()
|
||||
stdinEndMock.mockReset()
|
||||
})
|
||||
|
||||
it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => {
|
||||
const abortController = new AbortController()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal)
|
||||
).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'/usr/bin/expect',
|
||||
[
|
||||
'-c',
|
||||
expect.stringContaining(
|
||||
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME)'
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: '/Users/ada',
|
||||
env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }),
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 1_024,
|
||||
signal: abortController.signal,
|
||||
timeout: 4_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(stdinEndMock).toHaveBeenCalledOnce()
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof')
|
||||
})
|
||||
|
||||
it('treats a natural exit without the marker as a conclusive rejection', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, 'Login incorrect\r\nlogin: ', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: true,
|
||||
reason: 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a timeout or output overflow inconclusive', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'timeout'
|
||||
})
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(
|
||||
Object.assign(new Error('stdout maxBuffer length exceeded'), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
}),
|
||||
'',
|
||||
''
|
||||
)
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake an expect wrapper failure for a PAM rejection', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('expect Tcl error'), { code: 1 }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails safe without spawning when expect is unavailable', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { execFile, type ExecFileException } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
const MACOS_EXPECT_PATH = '/usr/bin/expect'
|
||||
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
|
||||
const LOGIN_PROBE_USERNAME_ENV = 'ORCA_LOGIN_PROBE_USERNAME'
|
||||
// Why: expect owns the PTY without adding a long-lived native handle to the daemon.
|
||||
const EXPECT_LOGIN_PROBE_SCRIPT =
|
||||
'log_user 1; ' +
|
||||
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME) /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK; ' +
|
||||
'send "\\004"; expect eof; wait; exit 0'
|
||||
|
||||
export type LoginPreflightOutcome = {
|
||||
ok: boolean
|
||||
conclusive: boolean
|
||||
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
|
||||
}
|
||||
|
||||
export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome {
|
||||
// Why: a probe killed by our bound proves nothing about PAM and must not stick.
|
||||
if (error.killed || error.code === 'ETIMEDOUT') {
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
// Why: a natural nonzero exit is login(1)'s conclusive rejection verdict.
|
||||
if (typeof error.code === 'number') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
return { ok: false, conclusive: false, reason: 'error' }
|
||||
}
|
||||
|
||||
/** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */
|
||||
export function runMacosLoginSessionPtyProbe(
|
||||
username: string,
|
||||
accountHome: string,
|
||||
timeoutMs: number,
|
||||
maxOutputBytes: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome> {
|
||||
if (!existsSync(MACOS_EXPECT_PATH)) {
|
||||
return Promise.resolve({ ok: false, conclusive: false, reason: 'error' })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const child = execFile(
|
||||
MACOS_EXPECT_PATH,
|
||||
['-c', EXPECT_LOGIN_PROBE_SCRIPT],
|
||||
{
|
||||
cwd: accountHome,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username },
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: maxOutputBytes,
|
||||
signal,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error !== null) {
|
||||
// Why: a nonzero expect exit can be its own Tcl/PTY failure, not PAM authority.
|
||||
resolve(
|
||||
error.killed || error.code === 'ETIMEDOUT'
|
||||
? { ok: false, conclusive: false, reason: 'timeout' }
|
||||
: { ok: false, conclusive: false, reason: 'error' }
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(
|
||||
stdout.includes(LOGIN_PREFLIGHT_MARKER)
|
||||
? { ok: true, conclusive: true, reason: 'accepted' }
|
||||
: { ok: false, conclusive: true, reason: 'rejected' }
|
||||
)
|
||||
}
|
||||
)
|
||||
child.stdin?.end()
|
||||
} catch {
|
||||
resolve({ ok: false, conclusive: false, reason: 'error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
|
||||
|
||||
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
userInfoMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
stdinEndMock: vi.fn()
|
||||
}))
|
||||
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted(
|
||||
() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
userInfoMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
stdinEndMock: vi.fn(),
|
||||
ptyProbeMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
|
||||
vi.mock('node:os', () => ({ userInfo: userInfoMock }))
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LoginSessionPtyProbe>()),
|
||||
runMacosLoginSessionPtyProbe: ptyProbeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
prepareMacosTccLoginShell,
|
||||
probeMacosLoginSessionAlive,
|
||||
resetMacosLoginShellPreflightForTests,
|
||||
wrapShellSpawnForMacosTccAttribution
|
||||
} from './macos-tcc-login-shell'
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
|
||||
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
|
||||
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
|
||||
|
||||
describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
@@ -39,6 +50,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
resetMacosLoginShellPreflightForTests()
|
||||
})
|
||||
|
||||
@@ -387,3 +399,200 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeMacosLoginSessionAlive', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
let origDisable: string | undefined
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
resetMacosLoginShellPreflightForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
if (origDisable === undefined) {
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
} else {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('re-probes even after a cached acceptance', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
|
||||
setPlatform('darwin')
|
||||
let finishPreflight!: ExecFileCallback
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
finishPreflight = callback
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
const warmup = prepareMacosTccLoginShell()
|
||||
const freshProbe = probeMacosLoginSessionAlive()
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
|
||||
finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
|
||||
ACCEPTED_OUTCOME,
|
||||
ACCEPTED_OUTCOME
|
||||
])
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
const callbacks: ExecFileCallback[] = []
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callbacks.push(callback)
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
const freshProbe = probeMacosLoginSessionAlive()
|
||||
const spawnProbe = prepareMacosTccLoginShell()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
|
||||
await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME)
|
||||
callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME)
|
||||
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
// The dead-session daemon must stop minting login(1) prompt zombies (#7936).
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('does not overwrite the cached verdict on an inconclusive probe', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('returns null off macOS and when disabled', async () => {
|
||||
setPlatform('linux')
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
setPlatform('darwin')
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('stays inconclusive when both pipe and PTY probes time out', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
// Inconclusive must not disturb the cached acceptance.
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('does not start a PTY fallback after the watch cancels its pipe probe', async () => {
|
||||
setPlatform('darwin')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
expect(ptyProbeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { execFile, type ExecFileException } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import {
|
||||
classifyLoginPreflightError,
|
||||
runMacosLoginSessionPtyProbe,
|
||||
type LoginPreflightOutcome
|
||||
} from './macos-login-session-pty-probe'
|
||||
|
||||
export type { LoginPreflightOutcome } from './macos-login-session-pty-probe'
|
||||
|
||||
const MACOS_LOGIN_PATH = '/usr/bin/login'
|
||||
const MACOS_ENV_PATH = '/usr/bin/env'
|
||||
const MACOS_PRINTF_PATH = '/usr/bin/printf'
|
||||
const LOGIN_PREFLIGHT_TIMEOUT_MS = 500
|
||||
// Why: the death-watch probe runs off the spawn path, so it can afford a bound
|
||||
// that outlasts a PAM stack answering slowly rather than misreading it as a hang.
|
||||
const LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS = 4_000
|
||||
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
|
||||
const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024
|
||||
const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000
|
||||
@@ -28,16 +38,12 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL'
|
||||
* reject) that may be cached; an inconclusive probe (our own timeout/SIGKILL,
|
||||
* maxBuffer, or spawn error) proves nothing about PAM and must not stick.
|
||||
*/
|
||||
export type LoginPreflightOutcome = {
|
||||
ok: boolean
|
||||
conclusive: boolean
|
||||
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
|
||||
}
|
||||
|
||||
let cachedLoginPreflightResult: boolean | null = null
|
||||
let cachedRejectionAtMs: number | null = null
|
||||
let loginPreflightInFlight: Promise<LoginPreflightOutcome> | null = null
|
||||
let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null
|
||||
let loginPreflightCacheEpoch = 0
|
||||
let loginSessionProbeInFlight = false
|
||||
|
||||
function isDisabledByEnv(): boolean {
|
||||
const value = process.env[DISABLE_ENV_VAR]
|
||||
@@ -51,25 +57,15 @@ function loginPreflightRetryDelayMs(failureCount: number): number {
|
||||
)
|
||||
}
|
||||
|
||||
function classifyPreflightError(error: ExecFileException): LoginPreflightOutcome {
|
||||
// Why: our SIGKILL timeout cap (and maxBuffer, which also kills) is an
|
||||
// environmental slow-path, not a PAM verdict — retry, don't cache (F1).
|
||||
if (error.killed || error.code === 'ETIMEDOUT') {
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
// A numeric exit code means login(1) ran to completion and rejected the user
|
||||
// (it exits immediately on EOF-driven rejection); that verdict is cacheable.
|
||||
if (typeof error.code === 'number') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
// Spawn/EOF/other failure: inconclusive, fail open for this spawn but retry.
|
||||
return { ok: false, conclusive: false, reason: 'error' }
|
||||
}
|
||||
|
||||
// Fidelity limit: the probe runs over pipes while production shells run under a
|
||||
// real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe
|
||||
// pass with a prod failure only degrades to today's direct spawn (no wrapper).
|
||||
function runLoginPreflight(username: string, accountHome: string): Promise<LoginPreflightOutcome> {
|
||||
function runLoginPreflight(
|
||||
username: string,
|
||||
accountHome: string,
|
||||
timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const child = execFile(
|
||||
@@ -84,7 +80,8 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
|
||||
// captured diagnostics without blocking the PTY host's event loop.
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
|
||||
timeout: LOGIN_PREFLIGHT_TIMEOUT_MS
|
||||
signal,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error === null) {
|
||||
@@ -97,7 +94,7 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(classifyPreflightError(error))
|
||||
resolve(classifyLoginPreflightError(error))
|
||||
}
|
||||
)
|
||||
// Why: login(1) must see immediate EOF, not an interactive pipe, so a PAM
|
||||
@@ -138,16 +135,18 @@ function loginPreflightSucceeds(
|
||||
return Promise.resolve(cached)
|
||||
}
|
||||
if (!loginPreflightInFlight) {
|
||||
const cacheEpoch = loginPreflightCacheEpoch
|
||||
// Why: simultaneous pane restores share one PAM child instead of multiplying
|
||||
// subprocesses at exactly the point terminal startup is already busiest.
|
||||
loginPreflightInFlight = runLoginPreflight(username, accountHome).then((outcome) => {
|
||||
// Why: cache only a conclusive PAM verdict; a killed/timed-out probe is
|
||||
// environmental and must be retried next spawn, not stuck forever (F1).
|
||||
if (outcome.conclusive) {
|
||||
const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch
|
||||
if (outcome.conclusive && mayUpdateCache) {
|
||||
cachedLoginPreflightResult = outcome.ok
|
||||
cachedRejectionAtMs = outcome.ok ? null : Date.now()
|
||||
transientLoginPreflightFailure = null
|
||||
} else {
|
||||
} else if (!outcome.conclusive && mayUpdateCache) {
|
||||
const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1
|
||||
transientLoginPreflightFailure = {
|
||||
failureCount,
|
||||
@@ -214,6 +213,64 @@ export function resetMacosLoginShellPreflightForTests(): void {
|
||||
cachedRejectionAtMs = null
|
||||
loginPreflightInFlight = null
|
||||
transientLoginPreflightFailure = null
|
||||
loginPreflightCacheEpoch = 0
|
||||
loginSessionProbeInFlight = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh PAM probe for login-session death detection (#7936): bypasses the
|
||||
* cached verdict and the transient backoff, and writes any conclusive verdict
|
||||
* back into the cache — so a daemon whose login session died stops wrapping
|
||||
* spawns in `login(1)` (which would only mint "Login incorrect" zombies) even
|
||||
* before retirement completes. Escalates to a PTY-hosted probe when the pipe
|
||||
* probe is inconclusive, since a dead session's PAM stack may only misbehave
|
||||
* under a real tty. Returns null when the wrapper doesn't apply.
|
||||
*/
|
||||
export async function probeMacosLoginSessionAlive(
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome | null> {
|
||||
if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) {
|
||||
return null
|
||||
}
|
||||
let username: string
|
||||
let accountHome: string
|
||||
try {
|
||||
const account = userInfo()
|
||||
username = account.username
|
||||
accountHome = account.homedir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!username || !accountHome) {
|
||||
return null
|
||||
}
|
||||
// Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict.
|
||||
const existingPreflight = loginPreflightInFlight
|
||||
loginSessionProbeInFlight = true
|
||||
loginPreflightCacheEpoch++
|
||||
let outcome: LoginPreflightOutcome
|
||||
try {
|
||||
outcome = await (existingPreflight ??
|
||||
runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal))
|
||||
if (!outcome.conclusive && !signal?.aborted) {
|
||||
outcome = await runMacosLoginSessionPtyProbe(
|
||||
username,
|
||||
accountHome,
|
||||
LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS,
|
||||
LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
|
||||
signal
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict.
|
||||
loginPreflightCacheEpoch++
|
||||
loginSessionProbeInFlight = false
|
||||
}
|
||||
if (outcome.conclusive) {
|
||||
cachedLoginPreflightResult = outcome.ok
|
||||
transientLoginPreflightFailure = null
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { basename, join, win32 } from 'node:path'
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
OrcaRuntimeService,
|
||||
recentTerminalPathCandidatesIncludePath,
|
||||
recentTerminalOutputIncludesPath,
|
||||
resolveWorktreeScanCacheTtlMs,
|
||||
type RuntimeTerminalAgentStatusEvent
|
||||
} from './orca-runtime'
|
||||
import { RecentPtyOutputBuffer } from './recent-pty-output-buffer'
|
||||
@@ -24853,19 +24854,34 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('emits only instance-validated lineage parents in mobile summaries', async () => {
|
||||
it('emits only instance- and boundary-validated lineage parents in mobile summaries', async () => {
|
||||
// Regression: shipped mobile clients trust parentWorktreeId blindly, so worktree.ps must not emit stale same-path lineage.
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const validChildPath = '/tmp/worktree-child-valid'
|
||||
const staleChildPath = '/tmp/worktree-child-stale'
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const validChildPath = join(tmpdir(), 'worktree-child-valid')
|
||||
const staleChildPath = join(tmpdir(), 'worktree-child-stale')
|
||||
const crossHostChildPath = join(tmpdir(), 'worktree-child-cross-host')
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const validChildId = `${TEST_REPO_ID}::${validChildPath}`
|
||||
const staleChildId = `${TEST_REPO_ID}::${staleChildPath}`
|
||||
const crossHostChildId = `${TEST_REPO_ID}::${crossHostChildPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }),
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'project-a'
|
||||
}),
|
||||
[validChildId]: makeWorktreeMeta({
|
||||
instanceId: 'child-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'project-a'
|
||||
}),
|
||||
// The stale child path was reused by a replacement checkout.
|
||||
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' })
|
||||
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }),
|
||||
[crossHostChildId]: makeWorktreeMeta({
|
||||
instanceId: 'cross-host-child-instance',
|
||||
hostId: 'runtime:other-host',
|
||||
projectId: 'project-a'
|
||||
})
|
||||
}
|
||||
const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({
|
||||
worktreeId: childId,
|
||||
@@ -24878,7 +24894,8 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[validChildId]: makeLineage(validChildId, 'child-instance'),
|
||||
[staleChildId]: makeLineage(staleChildId, 'old-child-instance')
|
||||
[staleChildId]: makeLineage(staleChildId, 'old-child-instance'),
|
||||
[crossHostChildId]: makeLineage(crossHostChildId, 'cross-host-child-instance')
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -24892,10 +24909,10 @@ describe('OrcaRuntimeService', () => {
|
||||
getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId]
|
||||
}
|
||||
vi.mocked(listWorktrees).mockResolvedValue(
|
||||
[parentPath, validChildPath, staleChildPath].map((path) => ({
|
||||
[parentPath, validChildPath, staleChildPath, crossHostChildPath].map((path) => ({
|
||||
path,
|
||||
head: 'abc',
|
||||
branch: `feature/${path.split('/').pop()}`,
|
||||
branch: `feature/${basename(path)}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}))
|
||||
@@ -24917,6 +24934,13 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined()
|
||||
expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined()
|
||||
const crossHostSummary = worktrees.find((worktree) => worktree.worktreeId === crossHostChildId)
|
||||
expect(crossHostSummary).toMatchObject({
|
||||
parentWorktreeId: null,
|
||||
worktreeInstanceId: 'cross-host-child-instance'
|
||||
})
|
||||
expect(crossHostSummary?.lineageWorktreeInstanceId).toBeUndefined()
|
||||
expect(crossHostSummary?.parentWorktreeInstanceId).toBeUndefined()
|
||||
expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({
|
||||
childWorktreeIds: [validChildId]
|
||||
})
|
||||
@@ -28050,6 +28074,80 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
boundary: 'repository',
|
||||
childRepoId: 'repo-child',
|
||||
parentRepoId: 'repo-parent',
|
||||
childMeta: {},
|
||||
parentMeta: {}
|
||||
},
|
||||
{
|
||||
boundary: 'known host',
|
||||
childRepoId: TEST_REPO_ID,
|
||||
parentRepoId: TEST_REPO_ID,
|
||||
childMeta: { hostId: 'runtime:child-host' as const },
|
||||
parentMeta: { hostId: 'runtime:parent-host' as const }
|
||||
},
|
||||
{
|
||||
boundary: 'known project',
|
||||
childRepoId: TEST_REPO_ID,
|
||||
parentRepoId: TEST_REPO_ID,
|
||||
childMeta: { projectId: 'project-child' },
|
||||
parentMeta: { projectId: 'project-parent' }
|
||||
}
|
||||
])('rejects manual lineage writes across a $boundary boundary', async (scenario) => {
|
||||
const repos = [...new Set([scenario.childRepoId, scenario.parentRepoId])].map((id) => ({
|
||||
id,
|
||||
path: join(tmpdir(), id),
|
||||
displayName: id,
|
||||
badgeColor: 'blue' as const,
|
||||
addedAt: 1
|
||||
}))
|
||||
const childRepoPath = repos.find((repo) => repo.id === scenario.childRepoId)!.path
|
||||
const parentRepoPath = repos.find((repo) => repo.id === scenario.parentRepoId)!.path
|
||||
const childPath = join(childRepoPath, 'child')
|
||||
const parentPath = join(parentRepoPath, 'parent')
|
||||
const childId = `${scenario.childRepoId}::${childPath}`
|
||||
const parentId = `${scenario.parentRepoId}::${parentPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }),
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta })
|
||||
}
|
||||
const setWorktreeLineage = vi.fn()
|
||||
const setWorkspaceLineage = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => repos,
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage,
|
||||
setWorkspaceLineage
|
||||
}
|
||||
vi.mocked(listWorktrees).mockImplementation(async (repoPath) => [
|
||||
...(repoPath === childRepoPath ? [makeWorktreeInfo(childPath)] : []),
|
||||
...(repoPath === parentRepoPath ? [makeWorktreeInfo(parentPath)] : [])
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Parent worktree must belong to the same repository, execution host, and project.'
|
||||
)
|
||||
|
||||
expect(setWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(setWorkspaceLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears workspace lineage when manually removing a parent', async () => {
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
@@ -28376,6 +28474,110 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const childPath = join(tmpdir(), 'worktree-child')
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
getAllWorktreeLineage: () => lineageById
|
||||
} as never)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
makeWorktreeInfo(childPath),
|
||||
makeWorktreeInfo(parentPath)
|
||||
])
|
||||
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
|
||||
const folderRepo = {
|
||||
id: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: 'blue' as const,
|
||||
addedAt: 1,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const parentId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const childId = `${parentId}::workspace:child-instance`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [folderRepo],
|
||||
getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getAllWorktreeLineage: () => lineageById
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${folderRepo.id}`)
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hides agent scratch created inside a linked checkout from runtime listings', async () => {
|
||||
const linkedCheckoutPath = '/tmp/worktree-a'
|
||||
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
|
||||
@@ -34979,3 +35181,83 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeScanCacheTtlMs', () => {
|
||||
const BASE_TTL_MS = 30_000
|
||||
const SCRATCH_TTL_MS = 5 * 60_000
|
||||
|
||||
it('keeps the base TTL for ordinary local repos', () => {
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({ path: '/Users/dev/projects/app', connectionId: '' })
|
||||
).toBe(BASE_TTL_MS)
|
||||
})
|
||||
|
||||
it('extends the TTL for agent-scratch repo roots', () => {
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6',
|
||||
connectionId: ''
|
||||
})
|
||||
).toBe(SCRATCH_TTL_MS)
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/Users/dev/.claude/skills/obsidian-second-brain',
|
||||
connectionId: ''
|
||||
})
|
||||
).toBe(SCRATCH_TTL_MS)
|
||||
})
|
||||
|
||||
it('never extends the TTL for SSH repos', () => {
|
||||
// Why: scratch classification reads local path conventions; a remote path
|
||||
// that merely looks similar must keep normal freshness.
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/home/dev/.codex-tmp/capsule',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).toBe(BASE_TTL_MS)
|
||||
})
|
||||
|
||||
it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => {
|
||||
// Why: the whole fix lives in the cache-stamp call site; pin the wiring so
|
||||
// a revert to the flat TTL fails CI, not just the pure-function tests.
|
||||
vi.useFakeTimers()
|
||||
// Why: the shared listWorktrees stub keeps call history across this file's
|
||||
// tests; absolute counts need a clean baseline.
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
try {
|
||||
const scratchPath = '/tmp/.codex-tmp/capsule-a'
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: '/tmp/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 },
|
||||
{
|
||||
id: 'repo-scratch',
|
||||
path: scratchPath,
|
||||
displayName: 'capsule',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
]
|
||||
} as never)
|
||||
const internals = runtime as unknown as { listResolvedWorktrees: () => Promise<unknown> }
|
||||
const scanCallsFor = (path: string): number =>
|
||||
vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length
|
||||
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor('/tmp/repo')).toBe(1)
|
||||
expect(scanCallsFor(scratchPath)).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(BASE_TTL_MS + 1_000)
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor('/tmp/repo')).toBe(2)
|
||||
expect(scanCallsFor(scratchPath)).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(SCRATCH_TTL_MS)
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor(scratchPath)).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,6 +301,10 @@ import {
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../shared/workspace-scope'
|
||||
import {
|
||||
projectResolvedWorktreeLineage,
|
||||
sharesResolvedWorktreeLineageBoundary
|
||||
} from '../../shared/resolved-worktree-lineage'
|
||||
import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
@@ -314,6 +318,7 @@ import {
|
||||
} from '../../shared/worktree-ownership'
|
||||
import {
|
||||
createAgentScratchWorktreePathMatcher,
|
||||
isAgentScratchRepoRootPath,
|
||||
type AgentScratchWorktreePathMatcher
|
||||
} from '../../shared/agent-scratch-worktrees'
|
||||
import {
|
||||
@@ -16554,13 +16559,15 @@ export class OrcaRuntimeService {
|
||||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const store = this.requireStore()
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||||
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
}
|
||||
let scan: RuntimeWorktreeScanResult
|
||||
@@ -16578,7 +16585,7 @@ export class OrcaRuntimeService {
|
||||
])
|
||||
const detected = scan.worktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const detectedWorktree = this.toRuntimeDetectedWorktree(
|
||||
repo,
|
||||
@@ -16594,7 +16601,7 @@ export class OrcaRuntimeService {
|
||||
repoId: repo.id,
|
||||
authoritative: scan.ok,
|
||||
source: scan.ok ? 'git' : 'metadata-fallback',
|
||||
worktrees: detected
|
||||
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23151,6 +23158,12 @@ export class OrcaRuntimeService {
|
||||
if (childWorktreeId === parentWorktreeId) {
|
||||
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.')
|
||||
}
|
||||
if (!sharesResolvedWorktreeLineageBoundary(child, parent)) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||||
'Parent worktree must belong to the same repository, execution host, and project.'
|
||||
)
|
||||
}
|
||||
const instanceByWorktreeId = new Map(
|
||||
this.resolvedWorktreeCache?.worktrees.map((worktree) => [
|
||||
worktree.id,
|
||||
@@ -23721,7 +23734,10 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
})
|
||||
)
|
||||
const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat())
|
||||
const worktrees = projectResolvedWorktreeLineage(
|
||||
perRepoWorktrees.flat(),
|
||||
this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
|
||||
if (generation === this.resolvedWorktreeGeneration) {
|
||||
this.resolvedWorktreeCache = {
|
||||
@@ -23733,41 +23749,6 @@ export class OrcaRuntimeService {
|
||||
return { worktrees, platformByRepoId }
|
||||
}
|
||||
|
||||
private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] {
|
||||
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const validLineageByChildId = new Map<string, WorktreeLineage>()
|
||||
const childIdsByParentId = new Map<string, string[]>()
|
||||
|
||||
for (const [childId, lineage] of Object.entries(lineageById)) {
|
||||
const child = worktreeById.get(childId)
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
!child ||
|
||||
!parent ||
|
||||
child.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
// Why: worktree IDs are path-derived, so instance checks keep replacement checkouts off stale same-path lineage.
|
||||
continue
|
||||
}
|
||||
validLineageByChildId.set(childId, lineage)
|
||||
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
|
||||
children.push(childId)
|
||||
childIdsByParentId.set(lineage.parentWorktreeId, children)
|
||||
}
|
||||
|
||||
return worktrees.map((worktree) => {
|
||||
const lineage = validLineageByChildId.get(worktree.id) ?? null
|
||||
return {
|
||||
...worktree,
|
||||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||||
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
|
||||
lineage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void {
|
||||
const store = this.store
|
||||
if (
|
||||
@@ -23853,7 +23834,7 @@ export class OrcaRuntimeService {
|
||||
generation,
|
||||
runtimeKey,
|
||||
result,
|
||||
expiresAt: Date.now() + WORKTREE_SCAN_CACHE_TTL_MS
|
||||
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo)
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -29253,7 +29234,17 @@ const DEFAULT_WORKTREE_PS_LIMIT = 200
|
||||
const DISCONNECTED_PTY_RECORD_MAX = 128
|
||||
const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000
|
||||
const WORKTREE_SCAN_CACHE_TTL_MS = 30_000
|
||||
// Why: agent-scratch repos don't need 30s freshness — the steady-state scan
|
||||
// fan-out was measured at ~128 git execs/min on real installs, mostly against
|
||||
// these (crash-cluster diagnostics, 2026-07).
|
||||
const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000
|
||||
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||||
|
||||
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
|
||||
return !repo.connectionId && isAgentScratchRepoRootPath(repo.path)
|
||||
? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS
|
||||
: WORKTREE_SCAN_CACHE_TTL_MS
|
||||
}
|
||||
const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000
|
||||
// Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out.
|
||||
const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: MarkdownPreview keeps rendering, link interception, search, and viewport state together so preview behavior stays coherent. */
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: search match state is synchronized with DOM highlights inserted into the rendered markdown body. */
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
Plus,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type { Components } from 'react-markdown'
|
||||
import type { Components, Options as ReactMarkdownOptions } from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -328,6 +329,53 @@ const markdownPreviewSanitizeSchema = {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: react-markdown's <Markdown> has no internal memoization — it rebuilds the whole
|
||||
// unified remark→rehype pipeline and re-parses the document on EVERY render. These plugin
|
||||
// lists are fully static, so hoist them to module scope; a fresh array identity per render
|
||||
// would otherwise defeat the memoized body below.
|
||||
type MarkdownPluginList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
|
||||
const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
remarkFrontmatter,
|
||||
remarkMath,
|
||||
remarkMarkdownDocLinks
|
||||
]
|
||||
// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema.
|
||||
const MARKDOWN_REHYPE_PLUGINS: MarkdownPluginList = [
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, markdownPreviewSanitizeSchema],
|
||||
rehypeSlug,
|
||||
rehypeHighlight,
|
||||
rehypeKatex
|
||||
]
|
||||
|
||||
// Why: render the body through a memoized wrapper so the expensive pipeline only re-runs when
|
||||
// the rendered content or the components map changes. Find state (query/match index) and
|
||||
// body-unrelated toolbar re-renders touch neither prop, so keystrokes in Find no longer
|
||||
// re-parse+re-highlight the whole doc. (Inline-annotation/review state — attentionReviewCommentId,
|
||||
// copiedReviewNoteId, activeAnnotationBlockKey — deliberately rebuilds `components`, so those
|
||||
// re-renders still re-run the pipeline; that's required to update the live annotation markup.)
|
||||
const MarkdownBody = memo(function MarkdownBody({
|
||||
content,
|
||||
components
|
||||
}: {
|
||||
content: string
|
||||
components: Components
|
||||
}) {
|
||||
return (
|
||||
<Markdown
|
||||
components={components}
|
||||
// Why: react-markdown filters file:// after sanitize; click handlers need the target to authorize and open it.
|
||||
urlTransform={markdownPreviewUrlTransform}
|
||||
remarkPlugins={MARKDOWN_REMARK_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
})
|
||||
|
||||
function parseLineTarget(hash: string): { line: number; column?: number } | null {
|
||||
if (!hash) {
|
||||
return null
|
||||
@@ -1947,28 +1995,7 @@ export default function MarkdownPreview({
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
<Markdown
|
||||
components={components}
|
||||
// Why: react-markdown filters file:// after sanitize; click handlers need the target to authorize and open it.
|
||||
urlTransform={markdownPreviewUrlTransform}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
remarkFrontmatter,
|
||||
remarkMath,
|
||||
remarkMarkdownDocLinks
|
||||
]}
|
||||
// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema.
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, markdownPreviewSanitizeSchema],
|
||||
rehypeSlug,
|
||||
rehypeHighlight,
|
||||
rehypeKatex
|
||||
]}
|
||||
>
|
||||
{renderedContent}
|
||||
</Markdown>
|
||||
<MarkdownBody content={renderedContent} components={components} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EmulatorDeviceFrame } from './emulator-device-frame'
|
||||
import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility'
|
||||
import { EMULATOR_STREAM_PARK_DELAY_MS } from './use-emulator-stream-window-visibility'
|
||||
|
||||
// Why: a backgrounded but still-attached emulator must stop streaming frames.
|
||||
// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG
|
||||
@@ -56,9 +58,20 @@ afterEach(() => {
|
||||
delete (URL as Partial<typeof URL>).createObjectURL
|
||||
delete (URL as Partial<typeof URL>).revokeObjectURL
|
||||
delete (window as { api?: unknown }).api
|
||||
setDocumentVisibility('visible')
|
||||
resetStaleDocumentVisibilityForTesting()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state
|
||||
})
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
async function renderFrame(isActive: boolean): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -102,3 +115,71 @@ describe('EmulatorDeviceFrame visibility gating', () => {
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmulatorDeviceFrame window-visibility gating', () => {
|
||||
it('parks the stream after the window stays hidden past the park delay, and resumes when visible', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Hiding the window does not tear down immediately — a short grace covers a
|
||||
// quick Cmd+Tab round-trip.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
|
||||
// Once the grace elapses, the stream parks at the source.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
|
||||
})
|
||||
expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' })
|
||||
|
||||
// Returning to the window resumes immediately; the session was never detached.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not tear down on a quick hide/show within the park delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100)
|
||||
})
|
||||
await act(async () => {
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
// The park timer was cancelled by the return-to-visible, so the stream never
|
||||
// stopped and no reconnect was needed.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Window reports hidden, but real user input proves the occlusion tracker is
|
||||
// wedged; the stream must keep running instead of freezing on a black frame.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import { EmulatorScreenSurface } from './emulator-screen-surface'
|
||||
import { useEmulatorControlStream } from './use-emulator-control-stream'
|
||||
import { useEmulatorPaneSize } from './use-emulator-pane-size'
|
||||
import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard'
|
||||
import { useEmulatorStreamWindowVisible } from './use-emulator-stream-window-visibility'
|
||||
|
||||
type EmulatorDeviceFrameProps = {
|
||||
previewUrl?: string
|
||||
@@ -335,9 +336,13 @@ export function EmulatorDeviceFrame({
|
||||
setStreamError(true)
|
||||
}, [])
|
||||
|
||||
// Why: hidden panes still receive emulator frames, including over SSH, so
|
||||
// parking the stream avoids background decode/IPC churn while staying attached.
|
||||
const showStream = isActive && isLive && Boolean(previewUrl)
|
||||
// Why: a hidden/occluded window (or a background tab) still receives emulator
|
||||
// frames, including over SSH; parking the stream avoids background decode/IPC
|
||||
// churn while staying attached. isActive covers the background-tab case;
|
||||
// windowVisibleForStream additionally parks when the whole window is hidden
|
||||
// (minimize / occlusion / display sleep), which no tab gate catches.
|
||||
const windowVisibleForStream = useEmulatorStreamWindowVisible()
|
||||
const showStream = isActive && isLive && windowVisibleForStream && Boolean(previewUrl)
|
||||
const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
|
||||
// Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the
|
||||
// physical frame still follows the last successful rotate request.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { isWindowVisible } from '@/lib/window-visibility-interval'
|
||||
import {
|
||||
isDocumentVisibilityProvenStale,
|
||||
registerStaleDocumentVisibilityRecovery
|
||||
} from '../terminal-pane/stale-document-visibility'
|
||||
|
||||
// Why: after display sleep macOS can wedge document.visibilityState at 'hidden'
|
||||
// with no further visibilitychange event; honor the terminal occlusion-staleness
|
||||
// latch so a window the user is actually looking at is never treated as hidden —
|
||||
// otherwise the emulator freezes on a black frame with no recovery (same bug class
|
||||
// as the 78MB terminal drop that motivated the latch).
|
||||
function getWindowVisibleSnapshot(): boolean {
|
||||
return isWindowVisible() || isDocumentVisibilityProvenStale()
|
||||
}
|
||||
|
||||
function subscribeWindowVisible(onChange: () => void): () => void {
|
||||
const handler = (): void => onChange()
|
||||
document.addEventListener('visibilitychange', handler)
|
||||
// Why: the stale latch flips visibility to proven-visible without emitting a
|
||||
// visibilitychange, so recompute when it fires too.
|
||||
const unregister = registerStaleDocumentVisibilityRecovery(handler)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handler)
|
||||
unregister()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: parking is delayed so a quick Cmd+Tab / app-switch round-trip does not tear
|
||||
// down and renegotiate the device stream (MJPEG reconnect or scrcpy H.264 keyframe),
|
||||
// which is heavier than a terminal resync and flashes the "Connecting…" UI. Re-showing
|
||||
// restores immediately.
|
||||
export const EMULATOR_STREAM_PARK_DELAY_MS = 500
|
||||
|
||||
/**
|
||||
* Reactive "is this window visible enough to keep the emulator device stream
|
||||
* running" signal. Returns true while the window is visible (or occlusion state is
|
||||
* proven stale) and defers the visible→hidden transition by `parkDelayMs`.
|
||||
*/
|
||||
export function useEmulatorStreamWindowVisible(
|
||||
parkDelayMs = EMULATOR_STREAM_PARK_DELAY_MS
|
||||
): boolean {
|
||||
const rawVisible = useSyncExternalStore(
|
||||
subscribeWindowVisible,
|
||||
getWindowVisibleSnapshot,
|
||||
getWindowVisibleSnapshot
|
||||
)
|
||||
const [effectiveVisible, setEffectiveVisible] = useState(rawVisible)
|
||||
useEffect(() => {
|
||||
if (rawVisible) {
|
||||
// Restore immediately so returning to the window resumes without delay.
|
||||
setEffectiveVisible(true)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => setEffectiveVisible(false), parkDelayMs)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [rawVisible, parkDelayMs])
|
||||
return effectiveVisible
|
||||
}
|
||||
+107
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceLineage
|
||||
} from '../../../../shared/types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees'
|
||||
|
||||
function makeFolder(id = 'folder-1'): FolderWorkspace {
|
||||
@@ -166,4 +167,110 @@ describe('getAttachedWorktreesForFolderWorkspace', () => {
|
||||
[nested.id]
|
||||
)
|
||||
})
|
||||
|
||||
it('includes an exact inline-only legacy descendant under an attached root', () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo-1::/parent',
|
||||
instanceId: 'parent'
|
||||
})
|
||||
const nested = makeWorktree({
|
||||
id: 'repo-1::/nested',
|
||||
instanceId: 'nested'
|
||||
})
|
||||
const inlineNested = {
|
||||
...nested,
|
||||
lineage: makeWorktreeLineage(nested, parent)
|
||||
} as Worktree
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {},
|
||||
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.get(parent.id)?.map((worktree) => worktree.id)).toEqual(
|
||||
[nested.id]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a stale side-map entry authoritative over valid inline lineage', () => {
|
||||
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
|
||||
const inlineNested = {
|
||||
...nested,
|
||||
lineage: makeWorktreeLineage(nested, parent)
|
||||
} as Worktree
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[nested.id]: {
|
||||
...makeWorktreeLineage(nested, parent),
|
||||
parentWorktreeInstanceId: 'stale-parent'
|
||||
}
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects nested descendants across host or project boundaries', () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo-1::/parent',
|
||||
instanceId: 'parent',
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
projectId: 'project-1'
|
||||
})
|
||||
const hostChild = makeWorktree({
|
||||
id: 'repo-1::/host-child',
|
||||
instanceId: 'host-child',
|
||||
hostId: toSshExecutionHostId('other')
|
||||
})
|
||||
const projectChild = makeWorktree({
|
||||
id: 'repo-1::/project-child',
|
||||
instanceId: 'project-child',
|
||||
projectId: 'project-2'
|
||||
})
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[hostChild.id]: makeWorktreeLineage(hostChild, parent),
|
||||
[projectChild.id]: makeWorktreeLineage(projectChild, parent)
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, hostChild, projectChild] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not attach cyclic legacy descendants', () => {
|
||||
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[parent.id]: makeWorktreeLineage(parent, nested),
|
||||
[nested.id]: makeWorktreeLineage(nested, parent)
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, nested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
expect(result.rootChildWorktrees.map((worktree) => worktree.id)).toEqual([parent.id])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceLineage
|
||||
} from '../../../../shared/types'
|
||||
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
|
||||
import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection'
|
||||
|
||||
export type AttachedWorktreeResolution = {
|
||||
folderWorkspace: FolderWorkspace | null
|
||||
@@ -90,39 +91,30 @@ export function getLineageChildrenByParentId(
|
||||
worktreeById: Map<string, Worktree>,
|
||||
rootWorktreeIds: ReadonlySet<string>
|
||||
): Map<string, Worktree[]> {
|
||||
const descendantsByParentId = new Map<string, Worktree[]>()
|
||||
const projectedChildrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
|
||||
lineageById,
|
||||
worktreeById
|
||||
)
|
||||
const includedIds = new Set(rootWorktreeIds)
|
||||
let added = true
|
||||
|
||||
while (added) {
|
||||
added = false
|
||||
for (const lineage of Object.values(lineageById)) {
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
const child = worktreeById.get(lineage.worktreeId)
|
||||
if (!isValidLineageChild(parent, child, lineage, includedIds)) {
|
||||
const queue = [...rootWorktreeIds]
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
for (const child of projectedChildrenByParentId.get(queue[index]) ?? []) {
|
||||
if (child.isArchived || includedIds.has(child.id)) {
|
||||
continue
|
||||
}
|
||||
includedIds.add(child.id)
|
||||
added = true
|
||||
queue.push(child.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const worktreeId of includedIds) {
|
||||
const child = worktreeById.get(worktreeId)
|
||||
if (!child) {
|
||||
continue
|
||||
const descendantsByParentId = new Map<string, Worktree[]>()
|
||||
for (const parentId of includedIds) {
|
||||
const children = (projectedChildrenByParentId.get(parentId) ?? []).filter(
|
||||
(child) => includedIds.has(child.id) && !child.isArchived
|
||||
)
|
||||
if (children.length > 0) {
|
||||
descendantsByParentId.set(parentId, children)
|
||||
}
|
||||
const lineage = lineageById[child.id]
|
||||
if (!lineage || !includedIds.has(lineage.parentWorktreeId)) {
|
||||
continue
|
||||
}
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
if (!isCurrentLineagePair(parent, child, lineage)) {
|
||||
continue
|
||||
}
|
||||
const children = descendantsByParentId.get(parent.id) ?? []
|
||||
children.push(child)
|
||||
descendantsByParentId.set(parent.id, children)
|
||||
}
|
||||
|
||||
for (const children of descendantsByParentId.values()) {
|
||||
@@ -160,39 +152,6 @@ function getLineageChildWorktree(
|
||||
return worktree
|
||||
}
|
||||
|
||||
function isValidLineageChild(
|
||||
parent: Worktree | undefined,
|
||||
child: Worktree | undefined,
|
||||
lineage: WorktreeLineage,
|
||||
includedIds: ReadonlySet<string>
|
||||
): child is Worktree {
|
||||
if (
|
||||
!parent ||
|
||||
!child ||
|
||||
parent.isArchived ||
|
||||
child.isArchived ||
|
||||
!includedIds.has(parent.id) ||
|
||||
includedIds.has(child.id)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isCurrentLineagePair(parent, child, lineage)
|
||||
}
|
||||
|
||||
function isCurrentLineagePair(
|
||||
parent: Worktree | undefined,
|
||||
child: Worktree,
|
||||
lineage: WorktreeLineage
|
||||
): parent is Worktree {
|
||||
return Boolean(
|
||||
parent &&
|
||||
!parent.isArchived &&
|
||||
!child.isArchived &&
|
||||
child.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
)
|
||||
}
|
||||
|
||||
function sortWorktreesByRecentActivity(left: Worktree, right: Worktree): number {
|
||||
return (
|
||||
getWorktreeActivityTime(right) - getWorktreeActivityTime(left) ||
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
shouldContinueDeleteSiblingPositionRestore,
|
||||
getWorktreeParentPickerAnchor,
|
||||
getWorktreeParentPickerLabel,
|
||||
hasWorktreeParentLink,
|
||||
isWorktreeParentPickerDisabled,
|
||||
selectMenuScopedMap
|
||||
} from './WorktreeContextMenu'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
|
||||
describe('selectMenuScopedMap (delete-teardown re-render guard)', () => {
|
||||
// Why: the closed menu wrapper must stay inert to delete teardown's high-churn
|
||||
@@ -139,6 +141,26 @@ describe('shouldContinueDeleteSiblingPositionRestore', () => {
|
||||
})
|
||||
|
||||
describe('parent picker context menu affordance', () => {
|
||||
it('offers unlink for valid inline-only legacy lineage after stable-update hydration', () => {
|
||||
const parent = { id: 'repo::parent', instanceId: 'parent-instance' }
|
||||
const lineage: WorktreeLineage = {
|
||||
worktreeId: 'repo::child',
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
const child = {
|
||||
id: lineage.worktreeId,
|
||||
instanceId: lineage.worktreeInstanceId,
|
||||
lineage
|
||||
} as Worktree & { lineage: WorktreeLineage }
|
||||
|
||||
expect(hasWorktreeParentLink(child, {}, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('uses set/change labels based on valid parent presence', () => {
|
||||
expect(getWorktreeParentPickerLabel(null)).toBe('Set Parent Worktree...')
|
||||
expect(getWorktreeParentPickerLabel('parent-1')).toBe('Change Parent Worktree...')
|
||||
|
||||
@@ -41,7 +41,11 @@ import { runSleepWorktrees } from './sleep-worktree-flow'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { getLineageRenderInfo } from './worktree-list-groups'
|
||||
import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getLineageRenderInfo,
|
||||
getProjectedWorktreeLineage
|
||||
} from './worktree-lineage-projection'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
@@ -82,6 +86,7 @@ const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
|
||||
const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {}
|
||||
const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {}
|
||||
const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {}
|
||||
const EMPTY_CYCLIC_LINEAGE_IDS: ReadonlySet<string> = new Set()
|
||||
|
||||
// Why: the gating decision for the menu-only store subscriptions. When the menu is
|
||||
// closed we MUST return the same `empty` reference every render so Zustand's Object.is
|
||||
@@ -92,6 +97,17 @@ export function selectMenuScopedMap<T>(menuOpen: boolean, live: T, empty: T): T
|
||||
return menuOpen ? live : empty
|
||||
}
|
||||
|
||||
export function hasWorktreeParentLink(
|
||||
worktree: Worktree,
|
||||
lineageById: AppState['worktreeLineageById'],
|
||||
workspaceLineageByChildKey: AppState['workspaceLineageByChildKey']
|
||||
): boolean {
|
||||
return Boolean(
|
||||
getProjectedWorktreeLineage(worktree, lineageById) ||
|
||||
workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)]
|
||||
)
|
||||
}
|
||||
|
||||
function shouldUseNativeContextMenu(target: EventTarget | null): boolean {
|
||||
const maybeElement = target as {
|
||||
closest?: (selector: string) => Element | null
|
||||
@@ -376,29 +392,41 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
isMultiContext && batchDeleteWorktrees.length > 0
|
||||
? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}`
|
||||
: 'Delete Selected'
|
||||
const lineage = worktreeLineageById[worktree.id]
|
||||
const workspaceLineage = workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)]
|
||||
const hasParentLink = hasWorktreeParentLink(
|
||||
worktree,
|
||||
worktreeLineageById,
|
||||
workspaceLineageByChildKey
|
||||
)
|
||||
const cyclicLineageIds = useMemo(
|
||||
() =>
|
||||
menuOpen
|
||||
? getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap)
|
||||
: EMPTY_CYCLIC_LINEAGE_IDS,
|
||||
[menuOpen, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
// Why: path-derived worktree IDs can be reused. The menu must honor the same
|
||||
// instance check as grouped rows before offering navigation to a parent.
|
||||
const lineageInfo = useMemo(
|
||||
() => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap),
|
||||
[worktree, worktreeLineageById, worktreeMap]
|
||||
() => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap, cyclicLineageIds),
|
||||
[cyclicLineageIds, worktree, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null
|
||||
const hasAnyContextLineage = activeContextWorktrees.some(
|
||||
(item) =>
|
||||
worktreeLineageById[item.id] || workspaceLineageByChildKey[worktreeWorkspaceKey(item.id)]
|
||||
const hasAnyContextLineage = activeContextWorktrees.some((item) =>
|
||||
hasWorktreeParentLink(item, worktreeLineageById, workspaceLineageByChildKey)
|
||||
)
|
||||
const eligibleParentCount = useMemo(
|
||||
() =>
|
||||
getEligibleWorktreeParents({
|
||||
child: worktree,
|
||||
worktrees: allWorktrees,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
repoMap
|
||||
}).length,
|
||||
[allWorktrees, repoMap, worktree, worktreeLineageById, worktreeMap]
|
||||
menuOpen
|
||||
? getEligibleWorktreeParents({
|
||||
child: worktree,
|
||||
worktrees: allWorktrees,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
repoMap,
|
||||
cyclicLineageIds
|
||||
}).length
|
||||
: 0,
|
||||
[allWorktrees, cyclicLineageIds, menuOpen, repoMap, worktree, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
|
||||
const setMenuOpenState = useCallback(
|
||||
@@ -814,7 +842,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
<FolderTree className="size-3.5" />
|
||||
{getWorktreeParentPickerLabel(validParentWorktreeId)}
|
||||
</DropdownMenuItem>
|
||||
{(validParentWorktreeId || lineage || workspaceLineage) && (
|
||||
{(validParentWorktreeId || hasParentLink) && (
|
||||
<>
|
||||
{validParentWorktreeId && (
|
||||
<DropdownMenuItem onSelect={handleOpenParent} disabled={isDeleting}>
|
||||
@@ -825,7 +853,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(lineage || workspaceLineage) && (
|
||||
{hasParentLink && (
|
||||
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={isDeleting}>
|
||||
<Unlink className="size-3.5" />
|
||||
{translate(
|
||||
|
||||
@@ -122,6 +122,10 @@ import {
|
||||
setVisibleWorktreeIds,
|
||||
sidebarHasActiveFilters
|
||||
} from './visible-worktrees'
|
||||
import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getWorktreeLineageAncestors
|
||||
} from './worktree-lineage-projection'
|
||||
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
|
||||
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
|
||||
import {
|
||||
@@ -249,7 +253,7 @@ import {
|
||||
suppressNewExternalWorktreeInbox,
|
||||
type NewExternalWorktreesInboxActionState
|
||||
} from './new-external-worktrees-inbox-actions'
|
||||
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
|
||||
import { isEligibleWorktreeParent } from './worktree-parent-candidates'
|
||||
import {
|
||||
buildImportedWorktreesCardCandidates,
|
||||
getHiddenImportedWorktrees
|
||||
@@ -1395,6 +1399,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
|
||||
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
|
||||
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
|
||||
const cyclicLineageIds = useMemo(
|
||||
() => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap),
|
||||
[worktreeLineageById, worktreeMap]
|
||||
)
|
||||
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
|
||||
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
|
||||
const worktreePointerAutoscrollFrameIdRef = useRef<number | null>(null)
|
||||
@@ -2085,25 +2093,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
toggleGroup(hostGroupKey)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = targetWorktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = worktreeLineageById[current.id]
|
||||
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
!lineage ||
|
||||
!parent ||
|
||||
current.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
for (const parent of getWorktreeLineageAncestors(
|
||||
targetWorktree,
|
||||
worktreeLineageById,
|
||||
worktreeMap
|
||||
)) {
|
||||
const lineageGroupKey = getLineageGroupKey(parent.id)
|
||||
if (collapsedGroups.has(lineageGroupKey)) {
|
||||
toggleGroup(lineageGroupKey)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
const groupKeys =
|
||||
@@ -2695,17 +2693,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
if (!child) {
|
||||
return false
|
||||
}
|
||||
return getEligibleWorktreeParents({
|
||||
child,
|
||||
worktrees,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
repoMap
|
||||
}).some((candidate) => candidate.id === parentId)
|
||||
const candidateParent = worktreeMap.get(parentId)
|
||||
return Boolean(
|
||||
candidateParent &&
|
||||
isEligibleWorktreeParent({
|
||||
child,
|
||||
candidateParent,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
repoMap,
|
||||
cyclicLineageIds
|
||||
})
|
||||
)
|
||||
})
|
||||
return canAssignAll ? target : { ...target, lineageParentId: null }
|
||||
},
|
||||
[repoMap, worktreeLineageById, worktreeMap, worktrees]
|
||||
[cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
|
||||
const commitWorktreeLineageParentDrop = useCallback(
|
||||
@@ -2742,7 +2745,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const ids = getReorderedWorktreeIdsToUnnest({
|
||||
draggedIds: args.draggedIds,
|
||||
sourceGroupIds: sourceGroup.worktreeIds,
|
||||
lineageById: worktreeLineageById
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
})
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
@@ -2760,7 +2765,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
)
|
||||
},
|
||||
[updateWorktreeLineage, worktreeDragGroups, worktreeLineageById]
|
||||
[cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
|
||||
const flushWorktreePointerDrag = useCallback(() => {
|
||||
@@ -5496,16 +5501,9 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
workspaceHostScope,
|
||||
visibleWorkspaceHostIds,
|
||||
defaultHostId: getSettingsFocusedExecutionHostId(settings),
|
||||
worktreeLineageById
|
||||
worktreeLineageById,
|
||||
forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined
|
||||
})
|
||||
if (
|
||||
agentSendTargetWorktreeId &&
|
||||
!ids.includes(agentSendTargetWorktreeId) &&
|
||||
worktreeMap.has(agentSendTargetWorktreeId)
|
||||
) {
|
||||
// Why: send-target mode is a temporary picker; surface the target card without rewriting the user's filters.
|
||||
ids.push(agentSendTargetWorktreeId)
|
||||
}
|
||||
return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null)
|
||||
}, [
|
||||
agentSendTargetWorktreeId,
|
||||
@@ -5573,22 +5571,12 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = targetWorktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = worktreeLineageById[current.id]
|
||||
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
!lineage ||
|
||||
!parent ||
|
||||
current.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
for (const parent of getWorktreeLineageAncestors(
|
||||
targetWorktree,
|
||||
worktreeLineageById,
|
||||
worktreeMap
|
||||
)) {
|
||||
next.delete(getLineageGroupKey(parent.id))
|
||||
current = parent
|
||||
}
|
||||
return next
|
||||
}, [
|
||||
|
||||
@@ -171,6 +171,109 @@ describe('getFolderWorkspaceCardPrDisplay', () => {
|
||||
expect(display).toMatchObject({ number: 4, status: 'success' })
|
||||
})
|
||||
|
||||
it('includes a nested PR from exact inline-only legacy lineage', () => {
|
||||
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
|
||||
const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree
|
||||
|
||||
const display = getFolderWorkspaceCardPrDisplay({
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {},
|
||||
worktreeMap: new Map([
|
||||
[parent.id, parent],
|
||||
[inlineNested.id, inlineNested]
|
||||
]),
|
||||
repoMap: new Map([[repo.id, repo]]),
|
||||
hostedReviewCache: null,
|
||||
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
|
||||
})
|
||||
|
||||
expect(display).toMatchObject({ number: 4, status: 'success' })
|
||||
})
|
||||
|
||||
it('keeps a stale side-map entry authoritative over valid inline lineage', () => {
|
||||
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
|
||||
const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree
|
||||
|
||||
const display = getFolderWorkspaceCardPrDisplay({
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[nested.id]: {
|
||||
...makeWorktreeLineage(nested, parent),
|
||||
parentWorktreeInstanceId: 'stale-parent'
|
||||
}
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[parent.id, parent],
|
||||
[inlineNested.id, inlineNested]
|
||||
]),
|
||||
repoMap: new Map([[repo.id, repo]]),
|
||||
hostedReviewCache: null,
|
||||
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
|
||||
})
|
||||
|
||||
expect(display).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['repository', { repoId: 'repo-2' }, {}],
|
||||
['known host', { hostId: 'ssh:remote' as const }, { hostId: 'local' as const }],
|
||||
['known project', { projectId: 'project-b' }, { projectId: 'project-a' }]
|
||||
])('excludes nested PRs across a %s boundary', (_boundary, childOverrides, parentOverrides) => {
|
||||
const parent = makeWorktree({ id: 'parent', instanceId: 'parent', ...parentOverrides })
|
||||
const nested = makeWorktree({
|
||||
id: 'nested',
|
||||
instanceId: 'nested',
|
||||
linkedPR: 4,
|
||||
...childOverrides
|
||||
})
|
||||
const nestedRepo = { ...repo, id: nested.repoId }
|
||||
|
||||
const display = getFolderWorkspaceCardPrDisplay({
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) },
|
||||
worktreeMap: new Map([
|
||||
[parent.id, parent],
|
||||
[nested.id, nested]
|
||||
]),
|
||||
repoMap: new Map([
|
||||
[repo.id, repo],
|
||||
[nestedRepo.id, nestedRepo]
|
||||
]),
|
||||
hostedReviewCache: null,
|
||||
prCache: { [`${nested.repoId}::nested`]: makePrEntry(4, 'success') }
|
||||
})
|
||||
|
||||
expect(display).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes nested PRs from cyclic projected lineage', () => {
|
||||
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
|
||||
|
||||
const display = getFolderWorkspaceCardPrDisplay({
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[parent.id]: makeWorktreeLineage(parent, nested),
|
||||
[nested.id]: makeWorktreeLineage(nested, parent)
|
||||
},
|
||||
worktreeMap: new Map([
|
||||
[parent.id, parent],
|
||||
[nested.id, nested]
|
||||
]),
|
||||
repoMap: new Map([[repo.id, repo]]),
|
||||
hostedReviewCache: null,
|
||||
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
|
||||
})
|
||||
|
||||
expect(display).toBeNull()
|
||||
})
|
||||
|
||||
it('uses branch-discovered PR cache for unlinked attached worktrees', () => {
|
||||
const worktree = makeWorktree({ id: 'branch-discovered', linkedPR: null })
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ParentPrChecksRow
|
||||
} from '@/components/right-sidebar/parent-pr-checks-rows'
|
||||
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
|
||||
import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection'
|
||||
|
||||
type FolderWorkspaceCardPrDisplayArgs = {
|
||||
folderWorkspaceId: string
|
||||
@@ -77,21 +78,18 @@ function getAttachedWorktreesForFolderWorkspaceCard({
|
||||
.filter((worktree): worktree is Worktree => worktree !== null)
|
||||
|
||||
const included = new Map(directChildren.map((worktree) => [worktree.id, worktree]))
|
||||
let added = true
|
||||
|
||||
while (added) {
|
||||
added = false
|
||||
for (const lineage of Object.values(worktreeLineageById ?? {})) {
|
||||
if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) {
|
||||
continue
|
||||
}
|
||||
const parent = worktreeMap.get(lineage.parentWorktreeId)
|
||||
const child = worktreeMap.get(lineage.worktreeId)
|
||||
if (!isCurrentLineagePair(parent, child, lineage)) {
|
||||
const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
|
||||
worktreeLineageById ?? {},
|
||||
worktreeMap
|
||||
)
|
||||
const queue = [...directChildren]
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
for (const child of childrenByParentId.get(queue[index].id) ?? []) {
|
||||
if (child.isArchived || included.has(child.id)) {
|
||||
continue
|
||||
}
|
||||
included.set(child.id, child)
|
||||
added = true
|
||||
queue.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,21 +128,6 @@ function getWorkspaceLineageChild(
|
||||
return worktree
|
||||
}
|
||||
|
||||
function isCurrentLineagePair(
|
||||
parent: Worktree | undefined,
|
||||
child: Worktree | undefined,
|
||||
lineage: WorktreeLineage
|
||||
): child is Worktree {
|
||||
return Boolean(
|
||||
parent &&
|
||||
child &&
|
||||
!parent.isArchived &&
|
||||
!child.isArchived &&
|
||||
child.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
)
|
||||
}
|
||||
|
||||
function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number {
|
||||
return getReviewDisplayPriority(left) - getReviewDisplayPriority(right)
|
||||
}
|
||||
|
||||
@@ -61,9 +61,10 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
|
||||
workspaceHostScope,
|
||||
visibleWorkspaceHostIds,
|
||||
defaultHostId: getSettingsFocusedExecutionHostId(settings),
|
||||
worktreeLineageById: {},
|
||||
// Why: the board has no nested lineage presentation. Ancestor injection
|
||||
// would make filtered-out parents appear as ordinary cards.
|
||||
worktreeLineageById: {}
|
||||
injectLineageAncestors: false
|
||||
})
|
||||
)
|
||||
}, [
|
||||
|
||||
@@ -478,6 +478,109 @@ describe('computeVisibleWorktreeIds', () => {
|
||||
expect(result).toEqual([parent.id, child.id])
|
||||
})
|
||||
|
||||
it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const lineage = makeWorktreeLineage(child, parent)
|
||||
const resolvedChild = { ...child, lineage }
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [parent, resolvedChild] },
|
||||
[child.id, parent.id],
|
||||
visibleOptions({
|
||||
showSleepingWorkspaces: false,
|
||||
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
|
||||
ptyIdsByTabId: { 't-child': ['p-child'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([parent.id, child.id])
|
||||
})
|
||||
|
||||
it('keeps inline parents out of non-nested board results across parent filters', () => {
|
||||
const child = makeWorktree('child')
|
||||
const run = (
|
||||
parent: ReturnType<typeof makeWorktree>,
|
||||
options: Partial<VisibleOptions>
|
||||
): string[] => {
|
||||
const resolvedChild = { ...child, lineage: makeWorktreeLineage(child, parent) }
|
||||
return computeVisibleWorktreeIds(
|
||||
{ repo1: [parent, resolvedChild] },
|
||||
[parent.id, child.id],
|
||||
visibleOptions({ ...options, injectLineageAncestors: false })
|
||||
)
|
||||
}
|
||||
|
||||
const sleepingParent = makeWorktree('sleeping-parent')
|
||||
expect(
|
||||
run(sleepingParent, {
|
||||
showSleepingWorkspaces: false,
|
||||
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
|
||||
ptyIdsByTabId: { 't-child': ['p-child'] }
|
||||
})
|
||||
).toEqual([child.id])
|
||||
|
||||
const defaultBranchParent = makeWorktree('default-parent')
|
||||
defaultBranchParent.isMainWorktree = true
|
||||
expect(run(defaultBranchParent, { hideDefaultBranchWorkspace: true })).toEqual([child.id])
|
||||
|
||||
const automationParent = makeWorktree('automation-parent')
|
||||
automationParent.automationProvenance = {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'automation-1',
|
||||
automationNameSnapshot: 'Review',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Review run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'repo1',
|
||||
repoId: 'repo1',
|
||||
hostId: 'local'
|
||||
}
|
||||
expect(run(automationParent, { hideAutomationGeneratedWorkspaces: true })).toEqual([child.id])
|
||||
})
|
||||
|
||||
it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const lineage = makeWorktreeLineage(child, parent)
|
||||
const resolvedChild = { ...child, lineage }
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [parent, resolvedChild] },
|
||||
[parent.id, child.id],
|
||||
visibleOptions({
|
||||
showSleepingWorkspaces: false,
|
||||
forcedVisibleWorktreeIds: [child.id]
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([parent.id, child.id])
|
||||
})
|
||||
|
||||
it('keeps the hydrated side-map authoritative over disagreeing inline lineage', () => {
|
||||
const inlineParent = makeWorktree('inline-parent')
|
||||
const hydratedParent = makeWorktree('hydrated-parent')
|
||||
const child = makeWorktree('child')
|
||||
const inlineLineage = makeWorktreeLineage(child, inlineParent)
|
||||
const hydratedLineage = makeWorktreeLineage(child, hydratedParent)
|
||||
const resolvedChild = { ...child, lineage: inlineLineage }
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [inlineParent, hydratedParent, resolvedChild] },
|
||||
[child.id, inlineParent.id, hydratedParent.id],
|
||||
visibleOptions({
|
||||
showSleepingWorkspaces: false,
|
||||
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
|
||||
ptyIdsByTabId: { 't-child': ['p-child'] },
|
||||
worktreeLineageById: { [child.id]: hydratedLineage }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([hydratedParent.id, child.id])
|
||||
})
|
||||
|
||||
it('does not resurrect stale lineage parents', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
@@ -537,7 +640,7 @@ describe('computeVisibleWorktreeIds', () => {
|
||||
expect(result).toEqual([parent.id, child.id])
|
||||
})
|
||||
|
||||
it('includes cross-repo parents when repo filtering leaves their valid child visible', () => {
|
||||
it('does not include a cross-repo parent when repo filtering leaves the child visible', () => {
|
||||
const parent = makeWorktree('parent', 'repo1')
|
||||
const child = makeWorktree('child', 'repo2')
|
||||
const lineage = makeWorktreeLineage(child, parent)
|
||||
@@ -551,7 +654,44 @@ describe('computeVisibleWorktreeIds', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([parent.id, child.id])
|
||||
expect(result).toEqual([child.id])
|
||||
})
|
||||
|
||||
it('does not include a known cross-host parent after host filtering', () => {
|
||||
const parent = Object.assign(makeWorktree('parent'), { hostId: 'ssh:remote' as const })
|
||||
const child = Object.assign(makeWorktree('child'), { hostId: 'local' as const })
|
||||
const lineage = makeWorktreeLineage(child, parent)
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [parent, child] },
|
||||
[child.id, parent.id],
|
||||
visibleOptions({
|
||||
visibleWorkspaceHostIds: ['local'],
|
||||
worktreeLineageById: { [child.id]: lineage }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([child.id])
|
||||
})
|
||||
|
||||
it('does not include a known cross-project parent hidden by another filter', () => {
|
||||
const parent = Object.assign(makeWorktree('parent'), {
|
||||
projectId: 'project-b',
|
||||
isMainWorktree: true
|
||||
})
|
||||
const child = Object.assign(makeWorktree('child'), { projectId: 'project-a' })
|
||||
const lineage = makeWorktreeLineage(child, parent)
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [parent, child] },
|
||||
[child.id, parent.id],
|
||||
visibleOptions({
|
||||
hideDefaultBranchWorkspace: true,
|
||||
worktreeLineageById: { [child.id]: lineage }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([child.id])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
type ExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getLineageRenderInfo
|
||||
} from './worktree-lineage-projection'
|
||||
|
||||
/**
|
||||
* Whether a worktree represents the repo's default-branch row that the
|
||||
@@ -124,6 +128,8 @@ export function computeVisibleWorktreeIds(
|
||||
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
|
||||
defaultHostId: ExecutionHostId
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
injectLineageAncestors?: boolean
|
||||
forcedVisibleWorktreeIds?: readonly string[]
|
||||
}
|
||||
): string[] {
|
||||
let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo })
|
||||
@@ -177,6 +183,17 @@ export function computeVisibleWorktreeIds(
|
||||
)
|
||||
}
|
||||
|
||||
if (opts.forcedVisibleWorktreeIds && opts.forcedVisibleWorktreeIds.length > 0) {
|
||||
const includedIds = new Set(all.map((worktree) => worktree.id))
|
||||
for (const worktreeId of opts.forcedVisibleWorktreeIds) {
|
||||
const worktree = lineageAncestorById.get(worktreeId)
|
||||
if (worktree && !includedIds.has(worktreeId)) {
|
||||
includedIds.add(worktreeId)
|
||||
all.push(worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cached sort order. Items not yet in the cache (e.g. brand-new
|
||||
// worktrees before the next sortEpoch bump) are appended at the end.
|
||||
const orderIndex = new Map(sortedIds.map((id, i) => [id, i]))
|
||||
@@ -186,11 +203,10 @@ export function computeVisibleWorktreeIds(
|
||||
return ai - bi
|
||||
})
|
||||
|
||||
return addVisibleLineageAncestors(
|
||||
all.map((w) => w.id),
|
||||
lineageAncestorById,
|
||||
opts.worktreeLineageById
|
||||
)
|
||||
const visibleIds = all.map((w) => w.id)
|
||||
return opts.injectLineageAncestors === false
|
||||
? visibleIds
|
||||
: addVisibleLineageAncestors(visibleIds, lineageAncestorById, opts.worktreeLineageById)
|
||||
}
|
||||
|
||||
function addVisibleLineageAncestors(
|
||||
@@ -201,6 +217,7 @@ function addVisibleLineageAncestors(
|
||||
const result: string[] = []
|
||||
const included = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeById)
|
||||
|
||||
const addWithAncestors = (id: string): void => {
|
||||
if (included.has(id) || visiting.has(id)) {
|
||||
@@ -211,16 +228,11 @@ function addVisibleLineageAncestors(
|
||||
return
|
||||
}
|
||||
visiting.add(id)
|
||||
const lineage = lineageById[id]
|
||||
const parent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
parent &&
|
||||
worktree.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeById, cyclicLineageIds)
|
||||
if (lineage.state === 'valid') {
|
||||
// Why: sidebar lineage is structural. If a filtered child is visible,
|
||||
// its valid parent must be rendered too so the hierarchy remains legible.
|
||||
addWithAncestors(parent.id)
|
||||
addWithAncestors(lineage.parent.id)
|
||||
}
|
||||
visiting.delete(id)
|
||||
if (!included.has(id)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
|
||||
@@ -70,4 +71,68 @@ describe('getWorkspaceDeleteLineage', () => {
|
||||
expect(lineage.descendants).toEqual([])
|
||||
expect(lineage.deleteAllTargets).toEqual([parent])
|
||||
})
|
||||
|
||||
it('orders an exact inline-only legacy descendant before its parent', () => {
|
||||
const parent = makeWorktree('parent', '/workspaces/parent')
|
||||
const child = makeWorktree('child', '/workspaces/parent/child')
|
||||
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
|
||||
|
||||
const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {})
|
||||
|
||||
expect(lineage.descendants.map((worktree) => worktree.id)).toEqual([child.id])
|
||||
expect(lineage.deleteAllTargets.map((worktree) => worktree.id)).toEqual([child.id, parent.id])
|
||||
})
|
||||
|
||||
it('keeps a stale side-map child authoritative over valid inline lineage', () => {
|
||||
const parent = makeWorktree('parent', '/workspaces/parent')
|
||||
const child = makeWorktree('child', '/workspaces/parent/child')
|
||||
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
|
||||
|
||||
const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {
|
||||
[child.id]: {
|
||||
...makeLineage(child, parent),
|
||||
parentWorktreeInstanceId: 'stale-parent-instance'
|
||||
}
|
||||
})
|
||||
|
||||
expect(lineage.descendants).toEqual([])
|
||||
expect(lineage.deleteAllTargets).toEqual([parent])
|
||||
})
|
||||
|
||||
it('rejects cross-repo, cross-host, and cross-project descendants', () => {
|
||||
const parent: Worktree = {
|
||||
...makeWorktree('parent', '/workspaces/parent'),
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
projectId: 'project-1'
|
||||
}
|
||||
const children: Worktree[] = [
|
||||
{ ...makeWorktree('repo-child', '/workspaces/repo-child'), repoId: 'repo-2' },
|
||||
{
|
||||
...makeWorktree('host-child', '/workspaces/host-child'),
|
||||
hostId: toSshExecutionHostId('other')
|
||||
},
|
||||
{ ...makeWorktree('project-child', '/workspaces/project-child'), projectId: 'project-2' }
|
||||
]
|
||||
const lineageById = Object.fromEntries(
|
||||
children.map((child) => [child.id, makeLineage(child, parent)])
|
||||
)
|
||||
|
||||
const lineage = getWorkspaceDeleteLineage(parent, [parent, ...children], lineageById)
|
||||
|
||||
expect(lineage.descendants).toEqual([])
|
||||
expect(lineage.deleteAllTargets).toEqual([parent])
|
||||
})
|
||||
|
||||
it('does not traverse cyclic projected lineage', () => {
|
||||
const parent = makeWorktree('parent', '/workspaces/parent')
|
||||
const child = makeWorktree('child', '/workspaces/parent/child')
|
||||
|
||||
const lineage = getWorkspaceDeleteLineage(parent, [parent, child], {
|
||||
[parent.id]: makeLineage(parent, child),
|
||||
[child.id]: makeLineage(child, parent)
|
||||
})
|
||||
|
||||
expect(lineage.descendants).toEqual([])
|
||||
expect(lineage.deleteAllTargets).toEqual([parent])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection'
|
||||
|
||||
type WorkspaceDeleteLineage = {
|
||||
descendants: Worktree[]
|
||||
deleteAllTargets: Worktree[]
|
||||
}
|
||||
|
||||
function isValidLineageLink(
|
||||
child: Worktree,
|
||||
parent: Worktree | undefined,
|
||||
lineage: WorktreeLineage | undefined
|
||||
): parent is Worktree {
|
||||
return Boolean(
|
||||
lineage &&
|
||||
parent &&
|
||||
child.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkspaceDeleteLineage(
|
||||
parent: Worktree,
|
||||
worktrees: readonly Worktree[],
|
||||
lineageById: Record<string, WorktreeLineage>
|
||||
): WorkspaceDeleteLineage {
|
||||
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
const lineage = lineageById[worktree.id]
|
||||
const lineageParent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined
|
||||
if (!isValidLineageLink(worktree, lineageParent, lineage)) {
|
||||
continue
|
||||
}
|
||||
const children = childrenByParentId.get(lineageParent.id) ?? []
|
||||
children.push(worktree)
|
||||
childrenByParentId.set(lineageParent.id, children)
|
||||
}
|
||||
const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
|
||||
lineageById,
|
||||
worktreeById
|
||||
)
|
||||
|
||||
const descendants: Worktree[] = []
|
||||
const childFirstTargets: Worktree[] = []
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
|
||||
import {
|
||||
getReorderedWorktreeIdsToUnnest,
|
||||
getWorktreeLineageDropTargetId,
|
||||
@@ -45,32 +47,121 @@ describe('getWorktreeLineageDropTargetId', () => {
|
||||
|
||||
describe('getReorderedWorktreeIdsToUnnest', () => {
|
||||
it('clears parents only for directly dragged nested cards', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const root = makeWorktree('root')
|
||||
const grandchild = makeWorktree('grandchild')
|
||||
const lineageById = {
|
||||
[child.id]: makeLineage(child, parent),
|
||||
[grandchild.id]: makeLineage(grandchild, child)
|
||||
}
|
||||
const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item]))
|
||||
|
||||
expect(
|
||||
getReorderedWorktreeIdsToUnnest({
|
||||
draggedIds: ['child', 'child', 'root', 'grandchild'],
|
||||
sourceGroupIds: ['child', 'root', 'grandchild'],
|
||||
lineageById: {
|
||||
child: true,
|
||||
grandchild: true
|
||||
}
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
})
|
||||
).toEqual(['child', 'grandchild'])
|
||||
})
|
||||
|
||||
it('does not clear selected nested cards outside the reordered source group', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const sourceChild = makeWorktree('source-child')
|
||||
const otherChild = makeWorktree('other-child')
|
||||
const lineageById = {
|
||||
[sourceChild.id]: makeLineage(sourceChild, parent),
|
||||
[otherChild.id]: makeLineage(otherChild, parent)
|
||||
}
|
||||
const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item]))
|
||||
|
||||
expect(
|
||||
getReorderedWorktreeIdsToUnnest({
|
||||
draggedIds: ['source-child', 'other-child'],
|
||||
sourceGroupIds: ['source-child'],
|
||||
lineageById: {
|
||||
'source-child': true,
|
||||
'other-child': true
|
||||
}
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
})
|
||||
).toEqual(['source-child'])
|
||||
})
|
||||
|
||||
it('clears an exact inline-only legacy parent', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
|
||||
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
|
||||
|
||||
expect(
|
||||
getReorderedWorktreeIdsToUnnest({
|
||||
draggedIds: [child.id],
|
||||
sourceGroupIds: [child.id],
|
||||
lineageById: {},
|
||||
worktreeMap,
|
||||
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap)
|
||||
})
|
||||
).toEqual([child.id])
|
||||
})
|
||||
|
||||
it('does not fall back to inline lineage when the side-map has a stale child entry', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
|
||||
const lineageById = {
|
||||
[child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' }
|
||||
}
|
||||
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
|
||||
|
||||
expect(
|
||||
getReorderedWorktreeIdsToUnnest({
|
||||
draggedIds: [child.id],
|
||||
sourceGroupIds: [child.id],
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return {
|
||||
id,
|
||||
instanceId: `${id}-instance`,
|
||||
repoId: 'repo-1',
|
||||
path: `/worktrees/${id}`,
|
||||
head: 'abc123',
|
||||
branch: id,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage {
|
||||
return {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId ?? '',
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId ?? '',
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeTarget(args: {
|
||||
worktreeId: string
|
||||
top: number
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getLineageRenderInfo } from './worktree-lineage-projection'
|
||||
|
||||
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]'
|
||||
const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]'
|
||||
|
||||
@@ -52,13 +55,22 @@ export function getWorktreeLineageDropTargetId(args: {
|
||||
export function getReorderedWorktreeIdsToUnnest(args: {
|
||||
draggedIds: readonly string[]
|
||||
sourceGroupIds: readonly string[]
|
||||
lineageById: Readonly<Record<string, unknown>>
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}): string[] {
|
||||
const ids: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const sourceGroupIdSet = new Set(args.sourceGroupIds)
|
||||
for (const id of args.draggedIds) {
|
||||
if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) {
|
||||
const worktree = args.worktreeMap.get(id)
|
||||
if (
|
||||
seen.has(id) ||
|
||||
!sourceGroupIdSet.has(id) ||
|
||||
!worktree ||
|
||||
getLineageRenderInfo(worktree, args.lineageById, args.worktreeMap, args.cyclicLineageIds)
|
||||
.state !== 'valid'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
seen.add(id)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
getCyclicWorktreeLineageChildIds,
|
||||
isValidResolvedWorktreeLineageEdge
|
||||
} from '../../../../shared/resolved-worktree-lineage'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
|
||||
export type LineageRenderInfo =
|
||||
| { state: 'none' }
|
||||
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
|
||||
| { state: 'missing'; lineage: WorktreeLineage }
|
||||
|
||||
type WorktreeWithResolvedLineage = Worktree & { lineage?: WorktreeLineage | null }
|
||||
|
||||
export function getProjectedWorktreeLineage(
|
||||
worktree: Worktree,
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
): WorktreeLineage | null | undefined {
|
||||
if (Object.prototype.hasOwnProperty.call(lineageById, worktree.id)) {
|
||||
return lineageById[worktree.id]
|
||||
}
|
||||
return (worktree as WorktreeWithResolvedLineage).lineage
|
||||
}
|
||||
|
||||
export function getCyclicProjectedWorktreeLineageIds(
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>,
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
): Set<string> {
|
||||
const validLineageByChildId = new Map<string, WorktreeLineage>()
|
||||
for (const worktree of worktreeMap.values()) {
|
||||
const lineage = getProjectedWorktreeLineage(worktree, lineageById)
|
||||
if (!lineage) {
|
||||
continue
|
||||
}
|
||||
const parent = worktreeMap.get(lineage.parentWorktreeId)
|
||||
if (parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)) {
|
||||
validLineageByChildId.set(worktree.id, lineage)
|
||||
}
|
||||
}
|
||||
return getCyclicWorktreeLineageChildIds(validLineageByChildId)
|
||||
}
|
||||
|
||||
export function getLineageRenderInfo(
|
||||
worktree: Worktree,
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>,
|
||||
worktreeMap: ReadonlyMap<string, Worktree>,
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
): LineageRenderInfo {
|
||||
const lineage = getProjectedWorktreeLineage(worktree, lineageById)
|
||||
if (!lineage) {
|
||||
return { state: 'none' }
|
||||
}
|
||||
const parent = worktreeMap.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
cyclicLineageIds.has(worktree.id) ||
|
||||
!parent ||
|
||||
!isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)
|
||||
) {
|
||||
return { state: 'missing', lineage }
|
||||
}
|
||||
return { state: 'valid', lineage, parent }
|
||||
}
|
||||
|
||||
export function getProjectedWorktreeLineageChildrenByParentId(
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>,
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
): Map<string, Worktree[]> {
|
||||
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
for (const worktree of worktreeMap.values()) {
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
|
||||
if (lineage.state !== 'valid') {
|
||||
continue
|
||||
}
|
||||
const children = childrenByParentId.get(lineage.parent.id) ?? []
|
||||
children.push(worktree)
|
||||
childrenByParentId.set(lineage.parent.id, children)
|
||||
}
|
||||
return childrenByParentId
|
||||
}
|
||||
|
||||
export function getWorktreeLineageAncestors(
|
||||
worktree: Worktree,
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>,
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
): Worktree[] {
|
||||
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
const ancestors: Worktree[] = []
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = worktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds)
|
||||
if (lineage.state !== 'valid') {
|
||||
break
|
||||
}
|
||||
ancestors.push(lineage.parent)
|
||||
current = lineage.parent
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
REPO_HEADER_ACTION_BUTTON_CLASS,
|
||||
REPO_HEADER_ACTION_REVEAL_CLASS
|
||||
} from './repo-header-action-button-class'
|
||||
import { getWorktreeLineageAncestors } from './worktree-lineage-projection'
|
||||
import type {
|
||||
DetectedWorktree,
|
||||
Project,
|
||||
@@ -3309,6 +3310,12 @@ describe('project groups', () => {
|
||||
})
|
||||
|
||||
describe('buildRows workspace lineage nesting', () => {
|
||||
type ResolvedLineageWorktree = Worktree & {
|
||||
lineage: WorktreeLineage | null
|
||||
workspaceLineage?: null
|
||||
parentWorktreeId?: string | null
|
||||
}
|
||||
|
||||
const parent: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-parent',
|
||||
@@ -3399,6 +3406,255 @@ describe('buildRows workspace lineage nesting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('nests stable-update resolved legacy lineage when generalized lineage is absent', () => {
|
||||
const parentId =
|
||||
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/assigned-issues'
|
||||
const childId =
|
||||
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9276-nested-ssh-runtime-routing'
|
||||
const secondChildId =
|
||||
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9744-terminal-close-lifecycle'
|
||||
const resolvedParent: ResolvedLineageWorktree = {
|
||||
...parent,
|
||||
id: parentId,
|
||||
instanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c',
|
||||
lineage: null,
|
||||
workspaceLineage: null
|
||||
}
|
||||
const resolvedLineage: WorktreeLineage = {
|
||||
...lineage,
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
|
||||
}
|
||||
const resolvedChild: ResolvedLineageWorktree = {
|
||||
...child,
|
||||
id: childId,
|
||||
instanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b',
|
||||
lineage: resolvedLineage,
|
||||
workspaceLineage: null
|
||||
}
|
||||
const secondResolvedLineage: WorktreeLineage = {
|
||||
...resolvedLineage,
|
||||
worktreeId: secondChildId,
|
||||
worktreeInstanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417'
|
||||
}
|
||||
const secondResolvedChild: ResolvedLineageWorktree = {
|
||||
...child,
|
||||
id: secondChildId,
|
||||
instanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417',
|
||||
lineage: secondResolvedLineage,
|
||||
workspaceLineage: null
|
||||
}
|
||||
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[secondResolvedChild, resolvedChild, resolvedParent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map([
|
||||
[resolvedParent.id, resolvedParent],
|
||||
[resolvedChild.id, resolvedChild],
|
||||
[secondResolvedChild.id, secondResolvedChild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
const items = rows.filter((row) => row.type === 'item')
|
||||
expect(items.map((row) => [row.worktree.id, row.depth])).toEqual([
|
||||
[parentId, 0],
|
||||
[secondChildId, 1],
|
||||
[childId, 1]
|
||||
])
|
||||
expect(items[0]).toMatchObject({ lineageChildCount: 2, lineageCollapsed: false })
|
||||
})
|
||||
|
||||
it('rejects stale resolved lineage after a parent instance is replaced', () => {
|
||||
const resolvedChild: ResolvedLineageWorktree = {
|
||||
...child,
|
||||
lineage: { ...lineage, parentWorktreeInstanceId: 'replaced-parent-instance' }
|
||||
}
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[resolvedChild, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[resolvedChild.id, resolvedChild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('keeps mixed cyclic lineage participants visible as roots', () => {
|
||||
const parentLineage: WorktreeLineage = {
|
||||
...lineage,
|
||||
worktreeId: parent.id,
|
||||
worktreeInstanceId: parent.instanceId!,
|
||||
parentWorktreeId: child.id,
|
||||
parentWorktreeInstanceId: child.instanceId!
|
||||
}
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[grandchild, child, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ [child.id]: lineage, [parent.id]: parentLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
[grandchild.id, grandchild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth])
|
||||
).toEqual([
|
||||
[grandchild.id, 0],
|
||||
[child.id, 0],
|
||||
[parent.id, 0]
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves inline-only ancestor chains for reveal and temporary picker expansion', () => {
|
||||
const resolvedChild: ResolvedLineageWorktree = { ...child, lineage }
|
||||
const resolvedGrandchild: ResolvedLineageWorktree = {
|
||||
...grandchild,
|
||||
lineage: grandchildLineage
|
||||
}
|
||||
const worktreeMap = new Map<string, Worktree>([
|
||||
[parent.id, parent],
|
||||
[resolvedChild.id, resolvedChild],
|
||||
[resolvedGrandchild.id, resolvedGrandchild]
|
||||
])
|
||||
|
||||
expect(
|
||||
getWorktreeLineageAncestors(resolvedGrandchild, {}, worktreeMap).map(
|
||||
(worktree) => worktree.id
|
||||
)
|
||||
).toEqual([child.id, parent.id])
|
||||
})
|
||||
|
||||
it('keeps a resolved child at the root when its parent is missing', () => {
|
||||
const resolvedChild: ResolvedLineageWorktree = { ...child, lineage }
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[resolvedChild],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map([[child.id, resolvedChild]]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows.find((row) => row.type === 'item')).toMatchObject({ depth: 0 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['repo', { repoId: 'other-repo' }],
|
||||
['host', { hostId: 'ssh:other-host' as const }],
|
||||
['project', { projectId: 'github:other/project' }]
|
||||
])('does not nest resolved lineage across a known %s boundary', (_label, boundary) => {
|
||||
const boundedParent = {
|
||||
...parent,
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local' as const,
|
||||
projectId: 'github:stablyai/orca',
|
||||
...boundary
|
||||
}
|
||||
const boundedChild: ResolvedLineageWorktree = {
|
||||
...child,
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local' as const,
|
||||
projectId: 'github:stablyai/orca',
|
||||
lineage
|
||||
}
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[boundedChild, boundedParent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map<string, Worktree>([
|
||||
[boundedParent.id, boundedParent],
|
||||
[boundedChild.id, boundedChild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('keeps the hydrated lineage side-map authoritative when inline metadata disagrees', () => {
|
||||
const otherParent = {
|
||||
...parent,
|
||||
id: 'wt-other-parent',
|
||||
instanceId: 'other-parent-instance'
|
||||
}
|
||||
const hydratedLineage = {
|
||||
...lineage,
|
||||
parentWorktreeId: otherParent.id,
|
||||
parentWorktreeInstanceId: otherParent.instanceId!
|
||||
}
|
||||
const resolvedChild: ResolvedLineageWorktree = {
|
||||
...child,
|
||||
parentWorktreeId: parent.id,
|
||||
lineage
|
||||
}
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[resolvedChild, parent, otherParent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ [child.id]: hydratedLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[otherParent.id, otherParent],
|
||||
[child.id, resolvedChild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth])
|
||||
).toEqual([
|
||||
[parent.id, 0],
|
||||
[otherParent.id, 0],
|
||||
[child.id, 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('supports nested lineage chains beyond one level', () => {
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
@@ -3504,7 +3760,8 @@ describe('buildRows workspace lineage nesting', () => {
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child]
|
||||
])
|
||||
]),
|
||||
new Set()
|
||||
)
|
||||
|
||||
expect(info).toMatchObject({ state: 'missing' })
|
||||
|
||||
@@ -43,6 +43,12 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import { parseWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getLineageRenderInfo
|
||||
} from './worktree-lineage-projection'
|
||||
|
||||
export { getLineageRenderInfo } from './worktree-lineage-projection'
|
||||
|
||||
export { branchName }
|
||||
|
||||
@@ -373,30 +379,6 @@ export function getLineageGroupKey(worktreeId: string): string {
|
||||
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
|
||||
}
|
||||
|
||||
export type LineageRenderInfo =
|
||||
| { state: 'none' }
|
||||
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
|
||||
| { state: 'missing'; lineage: WorktreeLineage }
|
||||
|
||||
export function getLineageRenderInfo(
|
||||
worktree: Worktree,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>
|
||||
): LineageRenderInfo {
|
||||
const lineage = lineageById[worktree.id]
|
||||
if (!lineage) {
|
||||
return { state: 'none' }
|
||||
}
|
||||
const parent = worktreeMap.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
!parent ||
|
||||
worktree.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
return { state: 'missing', lineage }
|
||||
}
|
||||
return { state: 'valid', lineage, parent }
|
||||
}
|
||||
export function getPRGroupKey(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
@@ -603,9 +585,17 @@ function appendWorktreeRows(
|
||||
groupDepth: number
|
||||
sectionKey: string
|
||||
hostContextLabelByRepoId?: ReadonlyMap<string, string>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}
|
||||
): void {
|
||||
const { nestLineage, collapsedGroups, groupDepth, sectionKey, hostContextLabelByRepoId } = options
|
||||
const {
|
||||
nestLineage,
|
||||
collapsedGroups,
|
||||
groupDepth,
|
||||
sectionKey,
|
||||
hostContextLabelByRepoId,
|
||||
cyclicLineageIds
|
||||
} = options
|
||||
if (!nestLineage) {
|
||||
for (const worktree of worktrees) {
|
||||
result.push(
|
||||
@@ -629,7 +619,7 @@ function appendWorktreeRows(
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
const childIds = new Set<string>()
|
||||
for (const worktree of worktrees) {
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap)
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
|
||||
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
|
||||
continue
|
||||
}
|
||||
@@ -999,6 +989,9 @@ export function buildRows(
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
const projectIndex = buildProjectGroupingIndex(projectGrouping)
|
||||
const cyclicLineageIds = nestLineage
|
||||
? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
: new Set<string>()
|
||||
|
||||
const pendingByRepo = new Map<string, PendingCreationRef[]>()
|
||||
for (const creation of pendingCreations) {
|
||||
@@ -1058,7 +1051,8 @@ export function buildRows(
|
||||
nestLineage,
|
||||
collapsedGroups,
|
||||
groupDepth: 0,
|
||||
sectionKey: ALL_GROUP_KEY
|
||||
sectionKey: ALL_GROUP_KEY,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1302,7 +1296,8 @@ export function buildRows(
|
||||
collapsedGroups,
|
||||
groupDepth: projectGroupDepth,
|
||||
sectionKey: key,
|
||||
hostContextLabelByRepoId
|
||||
hostContextLabelByRepoId,
|
||||
cyclicLineageIds
|
||||
})
|
||||
} else {
|
||||
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
|
||||
@@ -1310,7 +1305,8 @@ export function buildRows(
|
||||
collapsedGroups,
|
||||
groupDepth: projectGroupDepth,
|
||||
sectionKey: key,
|
||||
hostContextLabelByRepoId
|
||||
hostContextLabelByRepoId,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { canAssignWorktreeParent } from './worktree-parent-eligibility'
|
||||
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
|
||||
|
||||
type ParentCandidateArgs = {
|
||||
child: Worktree
|
||||
@@ -8,6 +9,7 @@ type ParentCandidateArgs = {
|
||||
lineageById: Record<string, WorktreeLineage>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
repoMap: Map<string, Pick<Repo, 'connectionId' | 'executionHostId'>>
|
||||
cyclicLineageIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function getWorktreeOwnerHostId(
|
||||
@@ -23,20 +25,51 @@ export function getEligibleWorktreeParents({
|
||||
worktrees,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
repoMap
|
||||
repoMap,
|
||||
cyclicLineageIds: precomputedCyclicLineageIds
|
||||
}: ParentCandidateArgs): Worktree[] {
|
||||
const childHostId = getWorktreeOwnerHostId(child, repoMap)
|
||||
return worktrees.filter(
|
||||
(candidate) =>
|
||||
candidate.repoId === child.repoId &&
|
||||
childHostId !== null &&
|
||||
getWorktreeOwnerHostId(candidate, repoMap) === childHostId &&
|
||||
!candidate.isArchived &&
|
||||
canAssignWorktreeParent({
|
||||
child,
|
||||
candidateParent: candidate,
|
||||
lineageById,
|
||||
worktreeMap
|
||||
})
|
||||
const cyclicLineageIds =
|
||||
precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
return worktrees.filter((candidate) =>
|
||||
isEligibleWorktreeParent({
|
||||
child,
|
||||
candidateParent: candidate,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
repoMap,
|
||||
cyclicLineageIds,
|
||||
childHostId
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function isEligibleWorktreeParent({
|
||||
child,
|
||||
candidateParent,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
repoMap,
|
||||
cyclicLineageIds,
|
||||
childHostId = getWorktreeOwnerHostId(child, repoMap)
|
||||
}: Omit<ParentCandidateArgs, 'worktrees'> & {
|
||||
candidateParent: Worktree
|
||||
childHostId?: string | null
|
||||
}): boolean {
|
||||
return (
|
||||
candidateParent.repoId === child.repoId &&
|
||||
childHostId !== null &&
|
||||
getWorktreeOwnerHostId(candidateParent, repoMap) === childHostId &&
|
||||
(child.projectId === undefined ||
|
||||
candidateParent.projectId === undefined ||
|
||||
child.projectId === candidateParent.projectId) &&
|
||||
!candidateParent.isArchived &&
|
||||
canAssignWorktreeParent({
|
||||
child,
|
||||
candidateParent,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { canAssignWorktreeParent } from './worktree-parent-eligibility'
|
||||
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
|
||||
import { getEligibleWorktreeParents, isEligibleWorktreeParent } from './worktree-parent-candidates'
|
||||
|
||||
function makeWorktree(id: string, repoId = 'repo'): Worktree {
|
||||
return {
|
||||
@@ -209,6 +209,34 @@ describe('canAssignWorktreeParent', () => {
|
||||
).toEqual([sameHost.id])
|
||||
})
|
||||
|
||||
it('excludes a candidate across a known project boundary for picker and direct drop checks', () => {
|
||||
const child = { ...makeWorktree('child'), projectId: 'project-a' }
|
||||
const sameProject = { ...makeWorktree('same-project'), projectId: 'project-a' }
|
||||
const otherProject = { ...makeWorktree('other-project'), projectId: 'project-b' }
|
||||
const worktrees = [child, sameProject, otherProject]
|
||||
const worktreeMap = makeMap(worktrees)
|
||||
const repoMap = makeRepoMap()
|
||||
|
||||
expect(
|
||||
getEligibleWorktreeParents({
|
||||
child,
|
||||
worktrees,
|
||||
lineageById: {},
|
||||
worktreeMap,
|
||||
repoMap
|
||||
}).map((worktree) => worktree.id)
|
||||
).toEqual([sameProject.id])
|
||||
expect(
|
||||
isEligibleWorktreeParent({
|
||||
child,
|
||||
candidateParent: otherProject,
|
||||
lineageById: {},
|
||||
worktreeMap,
|
||||
repoMap
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes archived worktrees from picker candidates', () => {
|
||||
const child = makeWorktree('child')
|
||||
const archived = makeWorktree('archived')
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getLineageRenderInfo } from './worktree-list-groups'
|
||||
import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getLineageRenderInfo
|
||||
} from './worktree-lineage-projection'
|
||||
|
||||
type ParentEligibilityArgs = {
|
||||
child: Worktree
|
||||
candidateParent: Worktree
|
||||
lineageById: Record<string, WorktreeLineage>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
cyclicLineageIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export function canAssignWorktreeParent({
|
||||
child,
|
||||
candidateParent,
|
||||
lineageById,
|
||||
worktreeMap
|
||||
worktreeMap,
|
||||
cyclicLineageIds: precomputedCyclicLineageIds
|
||||
}: ParentEligibilityArgs): boolean {
|
||||
if (child.id === candidateParent.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap)
|
||||
const cyclicLineageIds =
|
||||
precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap, cyclicLineageIds)
|
||||
if (childLineage.state === 'valid' && childLineage.parent.id === candidateParent.id) {
|
||||
return false
|
||||
}
|
||||
@@ -26,14 +33,14 @@ export function canAssignWorktreeParent({
|
||||
let current: Worktree | undefined = candidateParent
|
||||
const visited = new Set<string>()
|
||||
while (current) {
|
||||
if (visited.has(current.id)) {
|
||||
if (visited.has(current.id) || cyclicLineageIds.has(current.id)) {
|
||||
return false
|
||||
}
|
||||
visited.add(current.id)
|
||||
if (current.id === child.id) {
|
||||
return false
|
||||
}
|
||||
const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap)
|
||||
const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds)
|
||||
// Why: stale instance links are broken edges for renderer filtering; the
|
||||
// backend remains the authoritative final cycle guard.
|
||||
current = lineageInfo.state === 'valid' ? lineageInfo.parent : undefined
|
||||
|
||||
@@ -13463,6 +13463,8 @@ describe('connectPanePty', () => {
|
||||
|
||||
it('holds newer live bytes until a later replay frame has fully parsed', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { deliverTerminalDataWithDeferredCredit } =
|
||||
await import('@/lib/pane-manager/terminal-delivery-credit')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const transport = createMockTransport('remote:env-1@@terminal-live-order')
|
||||
const callbacksRef: {
|
||||
@@ -13484,8 +13486,12 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(8)
|
||||
expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H'])
|
||||
|
||||
callbacksRef.data?.('NEWER-LIVE\r\n')
|
||||
const acknowledgeLiveFrame = vi.fn()
|
||||
deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => {
|
||||
callbacksRef.data?.('NEWER-LIVE\r\n')
|
||||
})
|
||||
expect(writes).not.toContain('NEWER-LIVE\r\n')
|
||||
expect(acknowledgeLiveFrame).not.toHaveBeenCalled()
|
||||
for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(4)
|
||||
@@ -13498,7 +13504,17 @@ describe('connectPanePty', () => {
|
||||
expect(replayIndex).toBeGreaterThan(0)
|
||||
expect(resetIndex).toBeGreaterThan(replayIndex)
|
||||
expect(liveIndex).toBeGreaterThan(resetIndex)
|
||||
expect(acknowledgeLiveFrame).toHaveBeenCalledOnce()
|
||||
|
||||
callbacksRef.replay?.('stalled replay')
|
||||
await flushAsyncTicks(8)
|
||||
const acknowledgeDisposedFrame = vi.fn()
|
||||
deliverTerminalDataWithDeferredCredit(acknowledgeDisposedFrame, () => {
|
||||
callbacksRef.data?.('LIVE-BEHIND-STALLED-REPLAY')
|
||||
})
|
||||
expect(acknowledgeDisposedFrame).not.toHaveBeenCalled()
|
||||
binding.dispose()
|
||||
expect(acknowledgeDisposedFrame).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => {
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
isStatefulRendererReplyCsiQuery,
|
||||
isStatelessRendererReplyCsiQuery
|
||||
} from '../../../../shared/terminal-reply-query-extraction'
|
||||
import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate'
|
||||
import {
|
||||
deliverTerminalDataWithDeferredCredit,
|
||||
takeCurrentTerminalDeliveryCredit
|
||||
} from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
|
||||
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
|
||||
@@ -3312,7 +3315,13 @@ export function connectPanePty(
|
||||
let lastTerminalInputAt = Number.NEGATIVE_INFINITY
|
||||
let hasReceivedPtyOutput = false
|
||||
let deferredReattachLiveData:
|
||||
| { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[]
|
||||
| {
|
||||
data: string
|
||||
ptyId: string | null
|
||||
streamGeneration: number
|
||||
meta?: PtyDataMeta
|
||||
ackCredit?: () => void
|
||||
}[]
|
||||
| null = null
|
||||
let deferredReattachLiveDataChars = 0
|
||||
let reattachLiveDataDeferralDepth = 0
|
||||
@@ -5787,7 +5796,7 @@ export function connectPanePty(
|
||||
foreground: foregroundOutput,
|
||||
beforeWrite: beforeTerminalOutputWrite,
|
||||
// Why: claim the delivery's parse-deferred ACK credit (null outside a delivery); the FIRST scheduler write carries it all and fires when bytes are consumed.
|
||||
ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined,
|
||||
ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined,
|
||||
onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded,
|
||||
latencySensitive:
|
||||
!foreground || parseHiddenStartupOutput
|
||||
@@ -6881,20 +6890,26 @@ export function connectPanePty(
|
||||
}
|
||||
if (deferredReattachLiveData !== null) {
|
||||
// Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded.
|
||||
deferredReattachLiveData = deferredReattachLiveData.filter(
|
||||
(chunk) => chunk.streamGeneration === streamGeneration
|
||||
)
|
||||
deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => {
|
||||
const keep = chunk.streamGeneration === streamGeneration
|
||||
if (!keep) {
|
||||
chunk.ackCredit?.()
|
||||
}
|
||||
return keep
|
||||
})
|
||||
deferredReattachLiveDataChars = deferredReattachLiveData.reduce(
|
||||
(total, chunk) => total + chunk.data.length,
|
||||
0
|
||||
)
|
||||
const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS
|
||||
const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data
|
||||
const ackCredit = takeCurrentTerminalDeliveryCredit()
|
||||
deferredReattachLiveData.push({
|
||||
data: deferredData,
|
||||
ptyId: transport.getPtyId(),
|
||||
streamGeneration,
|
||||
...(meta ? { meta } : {})
|
||||
...(meta ? { meta } : {}),
|
||||
...(ackCredit ? { ackCredit } : {})
|
||||
})
|
||||
deferredReattachLiveDataChars += deferredData.length
|
||||
// Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame.
|
||||
@@ -6906,6 +6921,7 @@ export function connectPanePty(
|
||||
) {
|
||||
const removed = deferredReattachLiveData.shift()
|
||||
deferredReattachLiveDataChars -= removed?.data.length ?? 0
|
||||
removed?.ackCredit?.()
|
||||
dropped = true
|
||||
}
|
||||
if (dropped && deferredReattachLiveData[0]) {
|
||||
@@ -7111,6 +7127,9 @@ export function connectPanePty(
|
||||
const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration)
|
||||
deferredReattachLiveDataOwners = new Map()
|
||||
if (disposed || !chunks) {
|
||||
for (const chunk of chunks ?? []) {
|
||||
chunk.ackCredit?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output.
|
||||
@@ -7121,9 +7140,16 @@ export function connectPanePty(
|
||||
chunk.streamGeneration !== currentGeneration ||
|
||||
currentOwner?.failed === true
|
||||
) {
|
||||
chunk.ackCredit?.()
|
||||
continue
|
||||
}
|
||||
dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
|
||||
if (chunk.ackCredit) {
|
||||
deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => {
|
||||
dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
|
||||
})
|
||||
} else {
|
||||
dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
|
||||
}
|
||||
deliveredDeferredChunks += 1
|
||||
}
|
||||
if (deliveredDeferredChunks > 0) {
|
||||
@@ -8157,6 +8183,14 @@ export function connectPanePty(
|
||||
reconcileIfSessionMissing,
|
||||
dispose() {
|
||||
disposed = true
|
||||
// Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream.
|
||||
for (const chunk of deferredReattachLiveData ?? []) {
|
||||
chunk.ackCredit?.()
|
||||
}
|
||||
deferredReattachLiveData = null
|
||||
deferredReattachLiveDataChars = 0
|
||||
reattachLiveDataDeferralDepth = 0
|
||||
deferredReattachLiveDataOwners = new Map()
|
||||
cancelPendingSafeFitContinuations(pane)
|
||||
pendingHiddenSnapshotFit = null
|
||||
pendingReattachFit = null
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
deliverTerminalDataWithDeferredCredit,
|
||||
takeCurrentTerminalDeliveryCredit
|
||||
} from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
|
||||
type E2eTerminalPtyAckGateSnapshot = {
|
||||
gatedPtyCount: number
|
||||
@@ -89,25 +93,6 @@ export function ackPtyData(ptyId: string, chars: number): void {
|
||||
// true parse backpressure and main's producer flow control pauses the shell
|
||||
// instead of dropping.
|
||||
|
||||
type DeferredPtyAckCredit = {
|
||||
ptyId: string
|
||||
chars: number
|
||||
claimed: boolean
|
||||
credited: boolean
|
||||
}
|
||||
|
||||
let currentDeliveryCredit: DeferredPtyAckCredit | null = null
|
||||
|
||||
function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void {
|
||||
// Why fire-once: split queue chunks and discard paths may both touch the
|
||||
// same delivery; the invariant is exactly one credit per delivered chunk.
|
||||
if (credit.credited) {
|
||||
return
|
||||
}
|
||||
credit.credited = true
|
||||
ackPtyData(credit.ptyId, credit.chars)
|
||||
}
|
||||
|
||||
/** Runs one pty:data delivery with a parse-deferred ACK credit. If the
|
||||
* handler hands bytes to the output scheduler, the claimed credit fires when
|
||||
* the scheduler consumes (writes or discards) them; any credit left
|
||||
@@ -118,28 +103,14 @@ export function deliverPtyDataWithDeferredAck(
|
||||
chars: number,
|
||||
deliver: () => void
|
||||
): void {
|
||||
const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false }
|
||||
currentDeliveryCredit = credit
|
||||
try {
|
||||
deliver()
|
||||
} finally {
|
||||
currentDeliveryCredit = null
|
||||
if (!credit.claimed) {
|
||||
creditDeferredPtyAck(credit)
|
||||
}
|
||||
}
|
||||
deliverTerminalDataWithDeferredCredit(() => ackPtyData(ptyId, chars), deliver)
|
||||
}
|
||||
|
||||
/** Claims the in-progress delivery's credit for the output scheduler. Returns
|
||||
* a fire-once callback, or null when outside a delivery or already claimed
|
||||
* (only the FIRST scheduler write of a delivery carries the credit). */
|
||||
export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null {
|
||||
const credit = currentDeliveryCredit
|
||||
if (!credit || credit.claimed) {
|
||||
return null
|
||||
}
|
||||
credit.claimed = true
|
||||
return () => creditDeferredPtyAck(credit)
|
||||
return takeCurrentTerminalDeliveryCredit()
|
||||
}
|
||||
|
||||
export function getProcessedPtyCharTotals(): Record<string, number> {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('terminal delivery credit', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('restores an outer delivery after a nested delivery returns', async () => {
|
||||
const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } =
|
||||
await import('./terminal-delivery-credit')
|
||||
const completeOuter = vi.fn()
|
||||
const completeInner = vi.fn()
|
||||
let outerCredit: (() => void) | null = null
|
||||
let innerCredit: (() => void) | null = null
|
||||
|
||||
deliverTerminalDataWithDeferredCredit(completeOuter, () => {
|
||||
deliverTerminalDataWithDeferredCredit(completeInner, () => {
|
||||
innerCredit = takeCurrentTerminalDeliveryCredit()
|
||||
})
|
||||
outerCredit = takeCurrentTerminalDeliveryCredit()
|
||||
})
|
||||
|
||||
expect(completeOuter).not.toHaveBeenCalled()
|
||||
expect(completeInner).not.toHaveBeenCalled()
|
||||
innerCredit!()
|
||||
outerCredit!()
|
||||
expect(completeInner).toHaveBeenCalledOnce()
|
||||
expect(completeOuter).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('auto-settles before a deferred consumer can claim the delivery', async () => {
|
||||
const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } =
|
||||
await import('./terminal-delivery-credit')
|
||||
const complete = vi.fn()
|
||||
let claimLater: (() => (() => void) | null) | null = null
|
||||
|
||||
deliverTerminalDataWithDeferredCredit(complete, () => {
|
||||
claimLater = takeCurrentTerminalDeliveryCredit
|
||||
})
|
||||
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(claimLater!()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
type TerminalDeliveryCredit = {
|
||||
complete: () => void
|
||||
claimed: boolean
|
||||
credited: boolean
|
||||
}
|
||||
|
||||
// Why: consumers must claim during deliver(); after it returns this synchronous slot is restored and unclaimed credit settles.
|
||||
let currentDeliveryCredit: TerminalDeliveryCredit | null = null
|
||||
|
||||
function completeTerminalDeliveryCredit(credit: TerminalDeliveryCredit): void {
|
||||
// Why: queue splitting and discard paths can both settle one delivery.
|
||||
if (credit.credited) {
|
||||
return
|
||||
}
|
||||
credit.credited = true
|
||||
credit.complete()
|
||||
}
|
||||
|
||||
/** Defers producer credit until the output scheduler consumes or discards the delivery. */
|
||||
export function deliverTerminalDataWithDeferredCredit(
|
||||
complete: () => void,
|
||||
deliver: () => void
|
||||
): void {
|
||||
const credit: TerminalDeliveryCredit = {
|
||||
complete,
|
||||
claimed: false,
|
||||
credited: false
|
||||
}
|
||||
const previousCredit = currentDeliveryCredit
|
||||
currentDeliveryCredit = credit
|
||||
try {
|
||||
deliver()
|
||||
} finally {
|
||||
currentDeliveryCredit = previousCredit
|
||||
if (!credit.claimed) {
|
||||
completeTerminalDeliveryCredit(credit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Claims the current delivery for parse-deferred settlement by the output scheduler. */
|
||||
export function takeCurrentTerminalDeliveryCredit(): (() => void) | null {
|
||||
const credit = currentDeliveryCredit
|
||||
if (!credit || credit.claimed) {
|
||||
return null
|
||||
}
|
||||
credit.claimed = true
|
||||
return () => completeTerminalDeliveryCredit(credit)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
|
||||
|
||||
type RuntimeEnvironmentSubscriptionHandle = {
|
||||
@@ -472,7 +473,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
? (span!.data as string)
|
||||
: ''
|
||||
: decodeTerminalStreamText(frame.payload)
|
||||
try {
|
||||
const deliverOutput = (): void => {
|
||||
if (!validSpan) {
|
||||
// Why: rendering malformed span JSON would expose protocol framing
|
||||
// as terminal text and lose its raw sequence accounting.
|
||||
@@ -501,15 +502,18 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
rawLength,
|
||||
...(frame.opcode === TerminalStreamOpcode.OutputSpan ? { transformed: true } : {})
|
||||
})
|
||||
} finally {
|
||||
if (stream.acknowledgeOutput) {
|
||||
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
|
||||
stream.heldAckBytes += frame.payload.byteLength
|
||||
} else {
|
||||
this.acknowledgeOutput(stream, frame.payload.byteLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stream.acknowledgeOutput) {
|
||||
deliverOutput()
|
||||
return
|
||||
}
|
||||
deliverTerminalDataWithDeferredCredit(() => {
|
||||
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
|
||||
stream.heldAckBytes += frame.payload.byteLength
|
||||
} else {
|
||||
this.acknowledgeOutput(stream, frame.payload.byteLength)
|
||||
}
|
||||
}, deliverOutput)
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
|
||||
describe('remote terminal renderer backpressure', () => {
|
||||
const sendBinary = vi.fn()
|
||||
let callbacks: {
|
||||
onResponse: (response: unknown) => void
|
||||
onBinary: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
} | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
sendBinary.mockReset()
|
||||
callbacks = null
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
subscribe: vi.fn(async (_args, nextCallbacks) => {
|
||||
callbacks = nextCallbacks
|
||||
queueMicrotask(() => {
|
||||
callbacks?.onResponse({ ok: true, result: { type: 'ready' } })
|
||||
})
|
||||
return { unsubscribe: vi.fn(), sendBinary }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('withholds server credit until xterm consumes the output frame', async () => {
|
||||
const { getRemoteRuntimeTerminalMultiplexer } =
|
||||
await import('./remote-runtime-terminal-multiplexer')
|
||||
const { takeCurrentTerminalDeliveryCredit } =
|
||||
await import('../lib/pane-manager/terminal-delivery-credit')
|
||||
const { writeTerminalOutput } =
|
||||
await import('../lib/pane-manager/pane-terminal-output-scheduler')
|
||||
const parsedCallbacks: (() => void)[] = []
|
||||
const terminal = {
|
||||
write: vi.fn((_data: string, parsed?: () => void) => {
|
||||
if (parsed) {
|
||||
parsedCallbacks.push(parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({
|
||||
terminal: 'term-codex',
|
||||
client: { id: 'mac-viewer', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: (data) => {
|
||||
writeTerminalOutput(terminal, data, {
|
||||
foreground: true,
|
||||
ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined
|
||||
})
|
||||
},
|
||||
onSnapshot: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
callbacks?.onBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotStart,
|
||||
streamId: stream.streamId,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({ kind: 'scrollback', seq: 0 })
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotEnd,
|
||||
streamId: stream.streamId,
|
||||
seq: 2,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
sendBinary.mockClear()
|
||||
|
||||
const text = '\x1b[?1049h\x1b[?2026h\x1b[2J\x1b[H\x1b[31m-red 🙂 界\x1b[0m\x1b[?2026l'
|
||||
const output = encodeTerminalStreamText(text)
|
||||
callbacks?.onBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId: stream.streamId,
|
||||
seq: text.length,
|
||||
payload: output
|
||||
})
|
||||
)
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledWith(text, expect.any(Function))
|
||||
expect(parsedCallbacks).toHaveLength(1)
|
||||
expect(sentAckBytes()).toEqual([])
|
||||
|
||||
parsedCallbacks.shift()?.()
|
||||
expect(sentAckBytes()).toEqual([output.byteLength])
|
||||
stream.close()
|
||||
})
|
||||
|
||||
function sentAckBytes(): number[] {
|
||||
return sendBinary.mock.calls.flatMap(([bytes]) => {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (frame?.opcode !== TerminalStreamOpcode.Ack) {
|
||||
return []
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ bytes?: number }>(frame.payload)
|
||||
return typeof payload?.bytes === 'number' ? [payload.bytes] : []
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1668,6 +1668,42 @@ describe('worktree lineage state', () => {
|
||||
expect(store.getState().sortEpoch).toBe(4)
|
||||
})
|
||||
|
||||
it('clears inline local lineage immediately when an inline-only child is unnested', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage()
|
||||
const parent = {
|
||||
...makeWorktree({
|
||||
id: lineage.parentWorktreeId,
|
||||
instanceId: lineage.parentWorktreeInstanceId,
|
||||
repoId: 'repo1'
|
||||
}),
|
||||
childWorktreeIds: [lineage.worktreeId],
|
||||
lineage: null
|
||||
}
|
||||
const child = {
|
||||
...makeWorktree({
|
||||
id: lineage.worktreeId,
|
||||
instanceId: lineage.worktreeInstanceId,
|
||||
repoId: 'repo1'
|
||||
}),
|
||||
parentWorktreeId: lineage.parentWorktreeId,
|
||||
childWorktreeIds: [],
|
||||
lineage
|
||||
}
|
||||
mockApi.worktrees.updateLineage.mockResolvedValue(null)
|
||||
store.setState({
|
||||
worktreesByRepo: { repo1: [parent, child] },
|
||||
worktreeLineageById: {}
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().updateWorktreeLineage(child.id, { noParent: true })
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1).toMatchObject([
|
||||
{ id: parent.id, childWorktreeIds: [] },
|
||||
{ id: child.id, parentWorktreeId: null, lineage: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('syncs workspace lineage when a child is manually reparented', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage({
|
||||
@@ -5680,6 +5716,64 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => {
|
||||
const store = createTestStore()
|
||||
const parent = makeWorktree({
|
||||
id: 'repoA::/a/parent',
|
||||
instanceId: 'parent-instance',
|
||||
repoId: 'repoA',
|
||||
path: '/a/parent'
|
||||
})
|
||||
const child = makeWorktree({
|
||||
id: 'repoA::/a/child',
|
||||
instanceId: 'child-instance',
|
||||
repoId: 'repoA',
|
||||
path: '/a/child'
|
||||
})
|
||||
const lineage = makeLineage({
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId!,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId!
|
||||
})
|
||||
const resolvedParent = {
|
||||
...parent,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [child.id],
|
||||
lineage: null,
|
||||
workspaceLineage: null
|
||||
}
|
||||
const resolvedChild = {
|
||||
...child,
|
||||
parentWorktreeId: parent.id,
|
||||
childWorktreeIds: [],
|
||||
lineage,
|
||||
workspaceLineage: null
|
||||
}
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(
|
||||
makeDetectedResult('repoA', [resolvedParent, resolvedChild])
|
||||
)
|
||||
store.setState({
|
||||
repos: [repoA],
|
||||
hasHydratedWorktreePurge: true,
|
||||
worktreeLineageById: {}
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().worktreeLineageById).toEqual({})
|
||||
expect(store.getState().worktreesByRepo.repoA).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: child.id,
|
||||
parentWorktreeId: parent.id,
|
||||
lineage,
|
||||
workspaceLineage: null
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('defers the purge when a sibling repo fetch fails (F1 regression)', async () => {
|
||||
const store = createTestStore()
|
||||
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
|
||||
|
||||
@@ -1062,6 +1062,51 @@ async function setWorktreeLineageForRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
function projectLocalWorktreeLineageUpdate(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
worktreeId: string,
|
||||
lineage: WorktreeLineage | null
|
||||
): Record<string, Worktree[]> {
|
||||
let nextByRepo = worktreesByRepo
|
||||
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
|
||||
let repoChanged = false
|
||||
const projected = worktrees.map((worktree) => {
|
||||
const current = worktree as WorktreeWithLineage
|
||||
const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false
|
||||
const isParent =
|
||||
lineage?.parentWorktreeId === worktree.id &&
|
||||
lineage.parentWorktreeInstanceId === worktree.instanceId
|
||||
let childWorktreeIds = current.childWorktreeIds
|
||||
if (hadChild) {
|
||||
childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId)
|
||||
}
|
||||
if (isParent && !childWorktreeIds?.includes(worktreeId)) {
|
||||
childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId]
|
||||
}
|
||||
if (worktree.id === worktreeId) {
|
||||
repoChanged = true
|
||||
return {
|
||||
...worktree,
|
||||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||||
lineage
|
||||
}
|
||||
}
|
||||
if (hadChild || isParent) {
|
||||
repoChanged = true
|
||||
return { ...worktree, childWorktreeIds }
|
||||
}
|
||||
return worktree
|
||||
})
|
||||
if (repoChanged) {
|
||||
if (nextByRepo === worktreesByRepo) {
|
||||
nextByRepo = { ...worktreesByRepo }
|
||||
}
|
||||
nextByRepo[repoId] = projected
|
||||
}
|
||||
}
|
||||
return nextByRepo
|
||||
}
|
||||
|
||||
function applyWorktreeLineageUpdate(
|
||||
set: Parameters<StateCreator<AppState>>[0],
|
||||
worktreeId: string,
|
||||
@@ -1074,6 +1119,18 @@ function applyWorktreeLineageUpdate(
|
||||
} else {
|
||||
delete next[worktreeId]
|
||||
}
|
||||
const worktreesByRepo =
|
||||
result.target.kind === 'local'
|
||||
? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage)
|
||||
: result.updatedRemoteWorktree
|
||||
? replaceWorktreeInRepoLists(
|
||||
s.worktreesByRepo,
|
||||
withRepoHostOwnership(
|
||||
result.updatedRemoteWorktree,
|
||||
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
|
||||
)
|
||||
)
|
||||
: s.worktreesByRepo
|
||||
return {
|
||||
worktreeLineageById: next,
|
||||
workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage(
|
||||
@@ -1081,16 +1138,7 @@ function applyWorktreeLineageUpdate(
|
||||
result.lineage,
|
||||
s.workspaceLineageByChildKey
|
||||
),
|
||||
worktreesByRepo:
|
||||
result.target.kind === 'local' || !result.updatedRemoteWorktree
|
||||
? s.worktreesByRepo
|
||||
: replaceWorktreeInRepoLists(
|
||||
s.worktreesByRepo,
|
||||
withRepoHostOwnership(
|
||||
result.updatedRemoteWorktree,
|
||||
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
|
||||
)
|
||||
),
|
||||
worktreesByRepo,
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createAgentScratchWorktreePathMatcher,
|
||||
isAgentScratchRepoRootPath,
|
||||
isAgentScratchWorktreePath
|
||||
} from './agent-scratch-worktrees'
|
||||
|
||||
@@ -101,3 +102,44 @@ describe('isAgentScratchWorktreePath', () => {
|
||||
expect(isAgentScratchWorktreePath('/Users/dev/app', '/orca/workspaces/app/feature')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentScratchRepoRootPath', () => {
|
||||
it('matches codex scratch capsule repos', () => {
|
||||
expect(
|
||||
isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6')
|
||||
).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/rc-fwd-qEXuEq')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches codex vendor imports and claude skills containers', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports/skills')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.claude/skills/obsidian-second-brain')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a repo registered at the scratch container itself', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches scratch worktree containers used as repo roots', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/app/.claude/worktrees/agent-a04ccaaa')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/app/.gsd-workspaces/phase-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Windows separators and casing', () => {
|
||||
expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.codex-tmp\\Capsule-X')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.Claude\\Skills\\foo')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match ordinary user repos', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/projects/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/codex-tmp/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/checkouts/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/skills/.claude-app')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match partial multi-segment markers', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.claude/config')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/vendor_imports/app')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,3 +37,27 @@ export function createAgentScratchWorktreePathMatcher(
|
||||
export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean {
|
||||
return createAgentScratchWorktreePathMatcher([repoPath])(worktreePath)
|
||||
}
|
||||
|
||||
/** Why: agent CLIs also mint whole scratch *repos* under these containers; a
|
||||
* repo registered at such a root is agent-internal, not a user project (#9388). */
|
||||
const AGENT_SCRATCH_REPO_ROOT_SEGMENTS: readonly (readonly string[])[] = [
|
||||
['.codex-tmp'],
|
||||
['.codex', 'vendor_imports'],
|
||||
['.claude', 'skills'],
|
||||
...AGENT_SCRATCH_PATH_PREFIXES
|
||||
]
|
||||
|
||||
export function isAgentScratchRepoRootPath(repoPath: string): boolean {
|
||||
const segments = normalizeRuntimePathForComparison(repoPath).split('/')
|
||||
for (const marker of AGENT_SCRATCH_REPO_ROOT_SEGMENTS) {
|
||||
// Why: match the marker anywhere above the repo root (the repo lives at or
|
||||
// under the scratch container), unlike worktree matching which anchors to a
|
||||
// registered checkout path.
|
||||
for (let index = 0; index + marker.length <= segments.length; index += 1) {
|
||||
if (marker.every((segment, offset) => segments[index + offset] === segment)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import type { Worktree, WorktreeLineage } from './types'
|
||||
import { projectResolvedWorktreeLineage } from './resolved-worktree-lineage'
|
||||
|
||||
function worktree(id: string, instanceId: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id,
|
||||
instanceId,
|
||||
repoId: 'repo',
|
||||
path: join('workspace', id),
|
||||
head: 'abc123',
|
||||
branch: `refs/heads/${id}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function lineage(overrides: Partial<WorktreeLineage> = {}): WorktreeLineage {
|
||||
return {
|
||||
worktreeId: 'child',
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: 'parent',
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('projectResolvedWorktreeLineage', () => {
|
||||
const parent = worktree('parent', 'parent-instance')
|
||||
const child = worktree('child', 'child-instance')
|
||||
|
||||
it('projects exact instance-aware parent and child metadata', () => {
|
||||
const projected = projectResolvedWorktreeLineage([child, parent], { child: lineage() })
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [], lineage: lineage() },
|
||||
{ id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'], lineage: null }
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['stale child instance', lineage({ worktreeInstanceId: 'old-child' })],
|
||||
['stale parent instance', lineage({ parentWorktreeInstanceId: 'old-parent' })],
|
||||
['mismatched child record', lineage({ worktreeId: 'other-child' })]
|
||||
])('rejects %s', (_label, candidate) => {
|
||||
const projected = projectResolvedWorktreeLineage([child, parent], { child: candidate })
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: null, lineage: null },
|
||||
{ id: 'parent', childWorktreeIds: [] }
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['repo', { repoId: 'other-repo' }, {}],
|
||||
['known host', { hostId: 'local' as const }, { hostId: 'ssh:remote' as const }],
|
||||
['known project', { projectId: 'github:stablyai/orca' }, { projectId: 'github:other/project' }]
|
||||
])('rejects a %s boundary mismatch', (_label, childOverrides, parentOverrides) => {
|
||||
const boundedChild = worktree('child', 'child-instance', childOverrides)
|
||||
const boundedParent = worktree('parent', 'parent-instance', parentOverrides)
|
||||
|
||||
const projected = projectResolvedWorktreeLineage([boundedChild, boundedParent], {
|
||||
child: lineage()
|
||||
})
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: null, lineage: null },
|
||||
{ id: 'parent', childWorktreeIds: [] }
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts legacy records when only one side has host or project identity', () => {
|
||||
const legacyChild = worktree('child', 'child-instance', {
|
||||
hostId: 'local',
|
||||
projectId: 'github:stablyai/orca'
|
||||
})
|
||||
|
||||
const projected = projectResolvedWorktreeLineage([legacyChild, parent], {
|
||||
child: lineage()
|
||||
})
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: 'parent', lineage: lineage() },
|
||||
{ id: 'parent', childWorktreeIds: ['child'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects self-parent lineage', () => {
|
||||
const projected = projectResolvedWorktreeLineage([child], {
|
||||
child: lineage({
|
||||
parentWorktreeId: child.id,
|
||||
parentWorktreeInstanceId: child.instanceId!
|
||||
})
|
||||
})
|
||||
|
||||
expect(projected[0]).toMatchObject({
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects every edge in a multi-node cycle without hiding valid descendants', () => {
|
||||
const grandchild = worktree('grandchild', 'grandchild-instance')
|
||||
const parentToChild = lineage({
|
||||
worktreeId: parent.id,
|
||||
worktreeInstanceId: parent.instanceId!,
|
||||
parentWorktreeId: child.id,
|
||||
parentWorktreeInstanceId: child.instanceId!
|
||||
})
|
||||
const grandchildToParent = lineage({
|
||||
worktreeId: grandchild.id,
|
||||
worktreeInstanceId: grandchild.instanceId!
|
||||
})
|
||||
|
||||
const projected = projectResolvedWorktreeLineage([child, parent, grandchild], {
|
||||
child: lineage(),
|
||||
parent: parentToChild,
|
||||
grandchild: grandchildToParent
|
||||
})
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: null, childWorktreeIds: [], lineage: null },
|
||||
{
|
||||
id: 'parent',
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: ['grandchild'],
|
||||
lineage: null
|
||||
},
|
||||
{ id: 'grandchild', parentWorktreeId: 'parent', lineage: grandchildToParent }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a missing parent without mutating the raw lineage record', () => {
|
||||
const rawLineage = lineage()
|
||||
const projected = projectResolvedWorktreeLineage([child], { child: rawLineage })
|
||||
|
||||
expect(projected[0]).toMatchObject({ parentWorktreeId: null, lineage: null })
|
||||
expect(rawLineage.parentWorktreeId).toBe('parent')
|
||||
})
|
||||
|
||||
it('replaces disagreeing parent and child projections from the validated lineage record', () => {
|
||||
const projected = projectResolvedWorktreeLineage(
|
||||
[
|
||||
{ ...child, parentWorktreeId: 'stale-parent', childWorktreeIds: ['stale-child'] },
|
||||
{ ...parent, parentWorktreeId: 'stale-parent', childWorktreeIds: [] }
|
||||
] as (Worktree & { parentWorktreeId: string; childWorktreeIds: string[] })[],
|
||||
{ child: lineage() }
|
||||
)
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{ id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [] },
|
||||
{ id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Worktree, WorktreeLineage } from './types'
|
||||
|
||||
export type WorktreeWithResolvedLineage<T extends Worktree = Worktree> = T & {
|
||||
parentWorktreeId: string | null
|
||||
childWorktreeIds: string[]
|
||||
lineage: WorktreeLineage | null
|
||||
}
|
||||
|
||||
export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean {
|
||||
return (
|
||||
child.repoId === parent.repoId &&
|
||||
(child.hostId === undefined || parent.hostId === undefined || child.hostId === parent.hostId) &&
|
||||
(child.projectId === undefined ||
|
||||
parent.projectId === undefined ||
|
||||
child.projectId === parent.projectId)
|
||||
)
|
||||
}
|
||||
|
||||
export function isValidResolvedWorktreeLineageEdge(
|
||||
child: Worktree,
|
||||
parent: Worktree,
|
||||
lineage: WorktreeLineage
|
||||
): boolean {
|
||||
return (
|
||||
child.id !== parent.id &&
|
||||
lineage.worktreeId === child.id &&
|
||||
lineage.parentWorktreeId === parent.id &&
|
||||
sharesResolvedWorktreeLineageBoundary(child, parent) &&
|
||||
child.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
)
|
||||
}
|
||||
|
||||
export function getCyclicWorktreeLineageChildIds(
|
||||
lineageByChildId: ReadonlyMap<string, WorktreeLineage>
|
||||
): Set<string> {
|
||||
const processed = new Set<string>()
|
||||
const cyclic = new Set<string>()
|
||||
|
||||
for (const childId of lineageByChildId.keys()) {
|
||||
if (processed.has(childId)) {
|
||||
continue
|
||||
}
|
||||
const path: string[] = []
|
||||
const pathIndexById = new Map<string, number>()
|
||||
let currentId: string | undefined = childId
|
||||
while (currentId && lineageByChildId.has(currentId) && !processed.has(currentId)) {
|
||||
const cycleStart = pathIndexById.get(currentId)
|
||||
if (cycleStart !== undefined) {
|
||||
for (let index = cycleStart; index < path.length; index += 1) {
|
||||
cyclic.add(path[index])
|
||||
}
|
||||
break
|
||||
}
|
||||
pathIndexById.set(currentId, path.length)
|
||||
path.push(currentId)
|
||||
currentId = lineageByChildId.get(currentId)?.parentWorktreeId
|
||||
}
|
||||
for (const id of path) {
|
||||
processed.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return cyclic
|
||||
}
|
||||
|
||||
export function projectResolvedWorktreeLineage<T extends Worktree>(
|
||||
worktrees: readonly T[],
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
): WorktreeWithResolvedLineage<T>[] {
|
||||
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const validLineageByChildId = new Map<string, WorktreeLineage>()
|
||||
const childIdsByParentId = new Map<string, string[]>()
|
||||
|
||||
for (const child of worktrees) {
|
||||
const childId = child.id
|
||||
const lineage = lineageById[childId]
|
||||
if (!lineage) {
|
||||
continue
|
||||
}
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
if (!parent || !isValidResolvedWorktreeLineageEdge(child, parent, lineage)) {
|
||||
continue
|
||||
}
|
||||
validLineageByChildId.set(childId, lineage)
|
||||
}
|
||||
|
||||
const cyclicChildIds = getCyclicWorktreeLineageChildIds(validLineageByChildId)
|
||||
for (const childId of cyclicChildIds) {
|
||||
validLineageByChildId.delete(childId)
|
||||
}
|
||||
|
||||
for (const [childId, lineage] of validLineageByChildId) {
|
||||
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
|
||||
children.push(childId)
|
||||
childIdsByParentId.set(lineage.parentWorktreeId, children)
|
||||
}
|
||||
|
||||
return worktrees.map((worktree) => {
|
||||
const lineage = validLineageByChildId.get(worktree.id) ?? null
|
||||
return {
|
||||
...worktree,
|
||||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||||
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
|
||||
lineage
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test as base, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { getTerminalContent } from './helpers/terminal'
|
||||
|
||||
const ISSUE_NUMBER = 6613
|
||||
const ISSUE_TITLE = 'Start a newly created issue without losing its context'
|
||||
const ISSUE_URL = `https://github.com/acme/repo/issues/${ISSUE_NUMBER}`
|
||||
const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-created-issue-prefill-'))
|
||||
|
||||
const fakeGhSource = `
|
||||
const args = process.argv.slice(2)
|
||||
const joined = args.join(' ')
|
||||
const issue = {
|
||||
number: ${ISSUE_NUMBER},
|
||||
title: ${JSON.stringify(ISSUE_TITLE)},
|
||||
state: 'open',
|
||||
html_url: ${JSON.stringify(ISSUE_URL)},
|
||||
labels: [],
|
||||
assignees: [],
|
||||
user: { login: 'e2e' },
|
||||
updated_at: '2026-07-22T12:00:00.000Z'
|
||||
}
|
||||
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
console.error('github.com\\n ✓ Logged in to github.com account e2e (GITHUB_TOKEN)')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args[1] === 'user') {
|
||||
console.log(JSON.stringify({ login: 'e2e' }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args.includes('rate_limit')) {
|
||||
console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args.includes('-X') && args.includes('POST') && joined.includes('repos/acme/repo/issues')) {
|
||||
console.log(JSON.stringify(issue))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && joined.includes('/labels')) {
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && joined.includes('/assignees')) {
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && joined.includes('repos/acme/repo/issues/${ISSUE_NUMBER}')) {
|
||||
console.log(JSON.stringify(issue))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && joined.includes('search/issues')) {
|
||||
console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'issue' && args[1] === 'list') {
|
||||
console.log('[]')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'pr' && args[1] === 'list') {
|
||||
console.log('[]')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args[1] === 'graphql') {
|
||||
console.log(JSON.stringify({ data: { search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } } }))
|
||||
process.exit(0)
|
||||
}
|
||||
console.error('fake gh: unhandled ' + joined)
|
||||
process.exit(1)
|
||||
`
|
||||
|
||||
const fakeClaudeSource = `
|
||||
const args = process.argv.slice(2)
|
||||
process.stdout.write('E2E_CLAUDE_ARGV ' + JSON.stringify(args) + '\\n')
|
||||
setInterval(() => {}, 60_000)
|
||||
`
|
||||
|
||||
function installFakeCli(name: 'gh' | 'claude', source: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(path.join(fakeCliDir, `fake-${name}.js`), source)
|
||||
writeFileSync(
|
||||
path.join(fakeCliDir, `${name}.cmd`),
|
||||
`@echo off\r\nnode "%~dp0\\fake-${name}.js" %*\r\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
const executable = path.join(fakeCliDir, name)
|
||||
writeFileSync(executable, `#!/usr/bin/env node\n${source}`)
|
||||
chmodSync(executable, 0o755)
|
||||
}
|
||||
|
||||
installFakeCli('gh', fakeGhSource)
|
||||
installFakeCli('claude', fakeClaudeSource)
|
||||
|
||||
const test = base.extend({
|
||||
launchEnv: [
|
||||
{
|
||||
PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`
|
||||
},
|
||||
{ option: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.afterAll(() => {
|
||||
rmSync(fakeCliDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function configureGitHubRemote(repoPath: string): void {
|
||||
try {
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], { cwd: repoPath, stdio: 'ignore' })
|
||||
} catch {
|
||||
// The disposable E2E repo does not have an origin on its first run.
|
||||
}
|
||||
execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/repo.git'], {
|
||||
cwd: repoPath,
|
||||
stdio: 'pipe'
|
||||
})
|
||||
}
|
||||
|
||||
test('starting a just-created GitHub issue launches Claude with its URL prefilled', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
configureGitHubRemote(testRepoPath)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
|
||||
await orcaPage.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const preparedWorkspace = state
|
||||
.allWorktrees()
|
||||
.find((worktree) => worktree.branch?.endsWith('e2e-secondary'))
|
||||
if (!preparedWorkspace) {
|
||||
throw new Error('Seeded secondary E2E worktree is not available')
|
||||
}
|
||||
// Why: this regression owns renderer-to-PTY command propagation, while the shared fixture already covers Git worktree creation.
|
||||
store.setState({
|
||||
createWorktree: async () => ({ worktree: preparedWorkspace })
|
||||
})
|
||||
await state.updateSettings({
|
||||
defaultTuiAgent: 'claude',
|
||||
disabledTuiAgents: [],
|
||||
...(navigator.userAgent.includes('Windows') ? { terminalWindowsShell: 'git-bash' } : {})
|
||||
})
|
||||
store.getState().openTaskPage({ taskSource: 'github' })
|
||||
})
|
||||
|
||||
const newIssueButton = orcaPage.getByRole('button', { name: 'New GitHub issue' })
|
||||
await expect(newIssueButton).toBeEnabled({ timeout: 15_000 })
|
||||
await newIssueButton.click()
|
||||
|
||||
const createDialog = orcaPage.getByRole('dialog', { name: 'New GitHub issue' })
|
||||
await expect(createDialog).toBeVisible()
|
||||
await createDialog.getByPlaceholder('Short summary').fill(ISSUE_TITLE)
|
||||
await createDialog.getByRole('button', { name: 'Create issue' }).click()
|
||||
|
||||
await expect(createDialog).toBeHidden({ timeout: 10_000 })
|
||||
await expect(orcaPage.getByRole('heading', { name: ISSUE_TITLE })).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Start workspace from issue' }).click()
|
||||
|
||||
let terminalText = ''
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
terminalText = await getTerminalContent(orcaPage, 12_000)
|
||||
return terminalText
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: 'Claude prefill command did not reach the active terminal buffer'
|
||||
}
|
||||
)
|
||||
.toContain('--prefill')
|
||||
expect(terminalText).toContain('--dangerously-skip-permissions')
|
||||
expect(terminalText).toContain('--prefill')
|
||||
expect(terminalText).toContain(ISSUE_URL)
|
||||
expect(terminalText).not.toMatch(/(?:^|\n)claude '--dangerously-skip-permissions'(?:\r?\n|$)/)
|
||||
})
|
||||
@@ -5,8 +5,11 @@ export type LineageScenario = {
|
||||
childId: string
|
||||
}
|
||||
|
||||
export async function seedLineageScenario(page: Page): Promise<LineageScenario> {
|
||||
return page.evaluate(() => {
|
||||
export async function seedLineageScenario(
|
||||
page: Page,
|
||||
options: { inlineOnly?: boolean } = {}
|
||||
): Promise<LineageScenario> {
|
||||
return page.evaluate(({ inlineOnly }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
@@ -35,38 +38,50 @@ export async function seedLineageScenario(page: Page): Promise<LineageScenario>
|
||||
if (!parent.instanceId || !child.instanceId) {
|
||||
throw new Error('Worktree lineage E2E needs instance-stamped worktrees')
|
||||
}
|
||||
const lineage = {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'manual' as const,
|
||||
capture: { source: 'manual-action' as const, confidence: 'explicit' as const },
|
||||
createdAt: Date.now()
|
||||
}
|
||||
store.setState((current) => ({
|
||||
worktreesByRepo: Object.fromEntries(
|
||||
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
|
||||
repoId,
|
||||
repoWorktrees.map((worktree) => {
|
||||
if (worktree.id === parent.id) {
|
||||
return { ...worktree, displayName: 'E2E lineage parent', sortOrder: 0 }
|
||||
return {
|
||||
...worktree,
|
||||
displayName: 'E2E lineage parent',
|
||||
sortOrder: 0,
|
||||
...(inlineOnly
|
||||
? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (worktree.id === child.id) {
|
||||
return { ...worktree, displayName: 'E2E lineage child', sortOrder: 1 }
|
||||
return {
|
||||
...worktree,
|
||||
displayName: 'E2E lineage child',
|
||||
sortOrder: 1,
|
||||
...(inlineOnly
|
||||
? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return worktree
|
||||
})
|
||||
])
|
||||
),
|
||||
worktreeLineageById: {
|
||||
...current.worktreeLineageById,
|
||||
[child.id]: {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: Date.now()
|
||||
}
|
||||
}
|
||||
worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage }
|
||||
}))
|
||||
|
||||
store.getState().setActiveWorktree(parent.id)
|
||||
return { parentId: parent.id, childId: child.id }
|
||||
})
|
||||
}, options)
|
||||
}
|
||||
|
||||
export async function seedWorkspaceAgentStatus(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
@@ -13,6 +15,20 @@ function worktreeOption(page: Page, worktreeId: string) {
|
||||
return worktreeRow(page, worktreeId)
|
||||
}
|
||||
|
||||
async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
|
||||
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
|
||||
return
|
||||
}
|
||||
const outputDir = resolve(process.cwd(), 'pr-evidence')
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
await page
|
||||
.locator('[data-worktree-sidebar]')
|
||||
.first()
|
||||
.screenshot({
|
||||
path: resolve(outputDir, name)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Worktree Lineage', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -87,6 +103,27 @@ test.describe('Worktree Lineage', () => {
|
||||
await expect(childRow).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders legacy-only inline lineage when side-map hydration is absent', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true })
|
||||
const parentRow = worktreeOption(orcaPage, parentId)
|
||||
const childRow = worktreeOption(orcaPage, childId)
|
||||
|
||||
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
|
||||
await expect(childRow).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [parentBox, childBox] = await Promise.all([
|
||||
parentRow.boundingBox(),
|
||||
childRow.boundingBox()
|
||||
])
|
||||
return parentBox && childBox ? childBox.y > parentBox.y : false
|
||||
})
|
||||
.toBe(true)
|
||||
await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png')
|
||||
})
|
||||
|
||||
test('injects filtered parents structurally without showing a parent badge', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user