fix(runtime): route HUB-owned SSH worktrees through owning runtime (#9994)
This commit is contained in:
parent
3b86405201
commit
41751dd90d
|
|
@ -0,0 +1,32 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
|
||||
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
|
|
@ -90,6 +90,7 @@
|
|||
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
|
||||
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
|
||||
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"releaseRevision": 6,
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
|
||||
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
|
||||
"files": [
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"releaseRevision": 8,
|
||||
"releaseRevision": 10,
|
||||
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
|
||||
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
|
||||
"files": [
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"releaseRevision": 5,
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
|
||||
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
|
||||
"files": [
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"releaseRevision": 3,
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
|
||||
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
|
||||
"files": [
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"releaseRevision": 6,
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
|
||||
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
|
||||
"files": [
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"releaseRevision": 3,
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
|
||||
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
|
||||
"files": [
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 26,
|
||||
"releaseRevision": 28,
|
||||
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
|
||||
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -587,6 +587,32 @@
|
|||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.151-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 6,
|
||||
"linear-tickets": 8,
|
||||
"orca-cli": 35,
|
||||
"orca-emulator": 5,
|
||||
"orca-emulator-android": 3,
|
||||
"orca-linear": 6,
|
||||
"orca-per-workspace-env": 3,
|
||||
"orchestration": 26
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.151",
|
||||
"skills": {
|
||||
"computer-use": 7,
|
||||
"linear-tickets": 9,
|
||||
"orca-cli": 35,
|
||||
"orca-emulator": 6,
|
||||
"orca-emulator-android": 4,
|
||||
"orca-linear": 7,
|
||||
"orca-per-workspace-env": 4,
|
||||
"orchestration": 27
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -979,6 +979,38 @@
|
|||
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 27,
|
||||
"packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908",
|
||||
"gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22676,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 28,
|
||||
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
|
||||
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4220,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
|
||||
"textNormalizedSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
|
||||
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mobile-fit-debug": [
|
||||
|
|
@ -1095,6 +1127,38 @@
|
|||
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11241,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
|
||||
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3667,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
|
||||
"textNormalizedSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
|
||||
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-emulator": [
|
||||
|
|
@ -1177,6 +1241,38 @@
|
|||
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11527,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
|
||||
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3724,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
|
||||
"textNormalizedSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
|
||||
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"linear-tickets": [
|
||||
|
|
@ -1307,6 +1403,38 @@
|
|||
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 12466,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"textNormalizedSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"identitySha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 10,
|
||||
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
|
||||
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4148,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
|
||||
"textNormalizedSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
|
||||
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-linear": [
|
||||
|
|
@ -1405,6 +1533,38 @@
|
|||
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 12190,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"textNormalizedSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"identitySha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
|
||||
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3902,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
|
||||
"textNormalizedSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
|
||||
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-emulator-android": [
|
||||
|
|
@ -1455,6 +1615,38 @@
|
|||
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 8886,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
|
||||
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3529,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
|
||||
"textNormalizedSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
|
||||
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-per-workspace-env": [
|
||||
|
|
@ -1505,6 +1697,38 @@
|
|||
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 43769,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
|
||||
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4222,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
|
||||
"textNormalizedSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
|
||||
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2261,6 +2261,11 @@ app.whenReady().then(async () => {
|
|||
})
|
||||
// Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port.
|
||||
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
|
||||
const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT
|
||||
const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort)
|
||||
if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) {
|
||||
throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`)
|
||||
}
|
||||
// Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511).
|
||||
const devWsPort = is.dev && !isE2E ? 6769 : undefined
|
||||
let serveOptions: ServeOptions | null = null
|
||||
|
|
@ -2278,7 +2283,7 @@ app.whenReady().then(async () => {
|
|||
// Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts.
|
||||
userDataPath: getCanonicalUserDataPath(),
|
||||
enableWebSocket: true,
|
||||
...(isE2E ? { wsPort: 0 } : {}),
|
||||
...(isE2E ? { wsPort: e2eWsPort } : {}),
|
||||
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
|
||||
...(serveOptions?.wsPort !== undefined
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
removeRuntimeOwnedSshTarget
|
||||
} from '../ephemeral-vm-runtime-ssh'
|
||||
import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
|
||||
import { invalidateRuntimeEnvironmentTransport } from './runtime-environments'
|
||||
|
||||
export type EphemeralVmCleanupCommandResult = {
|
||||
runtimeId: string
|
||||
|
|
@ -192,6 +193,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
|||
updateEnvironmentFromPairingCode(userDataPath, runtime.runtimeEnvironmentId, {
|
||||
pairingCode
|
||||
})
|
||||
invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId)
|
||||
}
|
||||
const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult)
|
||||
if (!result.skipped && connection.type === 'ssh') {
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@ const {
|
|||
getPathMock,
|
||||
connectRuntimeOwnedSshTargetMock,
|
||||
disconnectRuntimeOwnedSshTargetMock,
|
||||
removeRuntimeOwnedSshTargetMock
|
||||
removeRuntimeOwnedSshTargetMock,
|
||||
invalidateRuntimeEnvironmentTransportMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
connectRuntimeOwnedSshTargetMock: vi.fn(),
|
||||
disconnectRuntimeOwnedSshTargetMock: vi.fn(),
|
||||
removeRuntimeOwnedSshTargetMock: vi.fn()
|
||||
removeRuntimeOwnedSshTargetMock: vi.fn(),
|
||||
invalidateRuntimeEnvironmentTransportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
|
|
@ -38,6 +40,10 @@ vi.mock('../ephemeral-vm-runtime-ssh', () => ({
|
|||
removeRuntimeOwnedSshTarget: removeRuntimeOwnedSshTargetMock
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environments', () => ({
|
||||
invalidateRuntimeEnvironmentTransport: invalidateRuntimeEnvironmentTransportMock
|
||||
}))
|
||||
|
||||
import { registerEphemeralVmHandlers } from './ephemeral-vm'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
|
@ -98,6 +104,7 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
connectRuntimeOwnedSshTargetMock.mockReset()
|
||||
disconnectRuntimeOwnedSshTargetMock.mockReset()
|
||||
removeRuntimeOwnedSshTargetMock.mockReset()
|
||||
invalidateRuntimeEnvironmentTransportMock.mockReset()
|
||||
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
|
||||
targetId: 'runtime-ssh-orca-instance-1',
|
||||
target: {
|
||||
|
|
@ -493,6 +500,10 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
expect(suspended).toEqual(expect.objectContaining({ status: 'suspended' }))
|
||||
expect(readFileSync(join(repoPath, 'suspend-mode.txt'), 'utf8')).toBe('suspend')
|
||||
|
||||
invalidateRuntimeEnvironmentTransportMock.mockImplementationOnce((environmentId: string) => {
|
||||
const environment = listEnvironments(userDataPath).find((entry) => entry.id === environmentId)
|
||||
expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com')
|
||||
})
|
||||
const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, {
|
||||
workspaceId: 'workspace-1'
|
||||
} as never)
|
||||
|
|
@ -507,6 +518,9 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
(entry) => entry.id === provisioned.environment.id
|
||||
)
|
||||
expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com')
|
||||
expect(invalidateRuntimeEnvironmentTransportMock).toHaveBeenCalledWith(
|
||||
provisioned.environment.id
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a copyable cleanup command for a persisted runtime', async () => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export async function uploadSshImportDirectory(
|
|||
localDir: string,
|
||||
remoteDir: string,
|
||||
rootRealPath: string,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
remotePathFlavor: RemotePathFlavor,
|
||||
assertCurrent?: () => void
|
||||
): Promise<void> {
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
|
||||
const entries = await readdir(localDir, { withFileTypes: true })
|
||||
|
|
@ -63,6 +64,7 @@ export async function uploadSshImportDirectory(
|
|||
}
|
||||
|
||||
if (statResult.isDirectory()) {
|
||||
assertCurrent?.()
|
||||
await provider.createDirNoClobber(remotePath)
|
||||
await uploadSshImportDirectory(
|
||||
provider,
|
||||
|
|
@ -70,10 +72,12 @@ export async function uploadSshImportDirectory(
|
|||
localPath,
|
||||
remotePath,
|
||||
rootRealPath,
|
||||
remotePathFlavor
|
||||
remotePathFlavor,
|
||||
assertCurrent
|
||||
)
|
||||
continue
|
||||
}
|
||||
assertCurrent?.()
|
||||
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ vi.mock('fs/promises', () => ({
|
|||
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
advanceSshConnectionGeneration,
|
||||
resetSshConnectionGenerations
|
||||
} from '../ssh/ssh-connection-generation'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
|
|
@ -112,7 +116,15 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
})
|
||||
}
|
||||
const invoke = (args: Record<string, unknown>) =>
|
||||
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
|
||||
handlers.get('fs:importExternalPaths')!(null, {
|
||||
...args,
|
||||
...(typeof args.connectionId === 'string'
|
||||
? {
|
||||
expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId,
|
||||
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0
|
||||
}
|
||||
: {})
|
||||
}) as Promise<{
|
||||
results: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
|
|
@ -145,6 +157,42 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
|
||||
afterEach(() => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
resetSshConnectionGenerations()
|
||||
})
|
||||
|
||||
it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => {
|
||||
resetSshConnectionGenerations(71)
|
||||
const stagedGeneration = advanceSshConnectionGeneration(connId)
|
||||
const sourcePath = path.resolve('/tmp/dropped/restart.txt')
|
||||
lstatMock.mockImplementation(async (candidate: string) => {
|
||||
if (candidate !== sourcePath) {
|
||||
throw enoent()
|
||||
}
|
||||
resetSshConnectionGenerations(72)
|
||||
advanceSshConnectionGeneration(connId)
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
})
|
||||
|
||||
const { results } = await invoke({
|
||||
sourcePaths: [sourcePath],
|
||||
destDir,
|
||||
connectionId: connId,
|
||||
expectedSshTargetId: connId,
|
||||
expectedSshConnectionGeneration: stagedGeneration
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: 'SSH connection changed; refresh and try again'
|
||||
})
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deconflicts file names via provider stat', async () => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
|
||||
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
|
|
@ -113,12 +114,22 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
})
|
||||
}
|
||||
const invoke = (args: Record<string, unknown>) =>
|
||||
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
|
||||
handlers.get('fs:importExternalPaths')!(
|
||||
null,
|
||||
typeof args.connectionId === 'string'
|
||||
? {
|
||||
...args,
|
||||
expectedSshTargetId: args.connectionId,
|
||||
expectedSshConnectionGeneration: 0
|
||||
}
|
||||
: args
|
||||
) as Promise<{
|
||||
results: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
resetSshConnectionGenerations()
|
||||
;[
|
||||
handleMock,
|
||||
lstatMock,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export async function importExternalPathsSsh(
|
|||
sourcePaths: string[],
|
||||
destDir: string,
|
||||
connectionId: string,
|
||||
options?: { ensureDir?: boolean }
|
||||
options?: { ensureDir?: boolean; assertCurrent?: () => void }
|
||||
): Promise<{ results: ImportItemResult[] }> {
|
||||
if (sourcePaths.length === 0) {
|
||||
return { results: [] }
|
||||
|
|
@ -45,7 +45,7 @@ export async function importExternalPathsSsh(
|
|||
// Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist
|
||||
// before the first upload. .orca/ is reserved as Orca-owned remote state;
|
||||
// see docs/terminal-drop-ssh.md.
|
||||
await ensureDropStagingDir(provider, destDir)
|
||||
await ensureDropStagingDir(provider, destDir, options.assertCurrent)
|
||||
}
|
||||
|
||||
const results: ImportItemResult[] = []
|
||||
|
|
@ -53,6 +53,7 @@ export async function importExternalPathsSsh(
|
|||
if (!provider.openFileUploadSession) {
|
||||
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
options?.assertCurrent?.()
|
||||
const uploadSession = await provider.openFileUploadSession()
|
||||
// Why: filename legality follows the remote filesystem, not the client's OS.
|
||||
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
|
||||
|
|
@ -66,7 +67,8 @@ export async function importExternalPathsSsh(
|
|||
sourcePath,
|
||||
destDir,
|
||||
reservedNames,
|
||||
remotePathFlavor
|
||||
remotePathFlavor,
|
||||
options?.assertCurrent
|
||||
)
|
||||
results.push(result)
|
||||
if (result.status === 'imported') {
|
||||
|
|
@ -89,7 +91,8 @@ async function importOneSourceSsh(
|
|||
sourcePath: string,
|
||||
destDir: string,
|
||||
reservedNames: Set<string>,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
remotePathFlavor: RemotePathFlavor,
|
||||
assertCurrent?: () => void
|
||||
): Promise<ImportItemResult> {
|
||||
const resolvedSource = resolve(sourcePath)
|
||||
|
||||
|
|
@ -145,11 +148,20 @@ async function importOneSourceSsh(
|
|||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
|
||||
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
|
||||
// Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write.
|
||||
assertCurrent?.()
|
||||
const finalName = await deconflictName(
|
||||
provider,
|
||||
destDir,
|
||||
originalName,
|
||||
reservedNames,
|
||||
assertCurrent
|
||||
)
|
||||
const destPath = `${destDir}/${finalName}`
|
||||
const renamed = finalName !== originalName
|
||||
|
||||
if (isDir) {
|
||||
assertCurrent?.()
|
||||
await provider.createDirNoClobber(destPath)
|
||||
createdDestDir = destPath
|
||||
await uploadSshImportDirectory(
|
||||
|
|
@ -158,9 +170,11 @@ async function importOneSourceSsh(
|
|||
resolvedSource,
|
||||
destPath,
|
||||
rootRealPath!,
|
||||
remotePathFlavor
|
||||
remotePathFlavor,
|
||||
assertCurrent
|
||||
)
|
||||
} else {
|
||||
assertCurrent?.()
|
||||
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +189,12 @@ async function importOneSourceSsh(
|
|||
if (createdDestDir) {
|
||||
// Why: local directory imports roll back partial output; SSH imports
|
||||
// should not leave the no-clobber root after a nested upload failure.
|
||||
await provider.deletePath(createdDestDir, true).catch(() => {})
|
||||
try {
|
||||
assertCurrent?.()
|
||||
await provider.deletePath(createdDestDir, true)
|
||||
} catch {
|
||||
// Best effort; a replacement session must never inherit cleanup from the retired owner.
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourcePath,
|
||||
|
|
@ -189,8 +208,10 @@ async function deconflictName(
|
|||
provider: IFilesystemProvider,
|
||||
destDir: string,
|
||||
originalName: string,
|
||||
reservedNames: Set<string>
|
||||
reservedNames: Set<string>,
|
||||
assertCurrent?: () => void
|
||||
): Promise<string> {
|
||||
assertCurrent?.()
|
||||
if (
|
||||
!(await remotePathExists(provider, `${destDir}/${originalName}`)) &&
|
||||
!reservedNames.has(originalName)
|
||||
|
|
@ -204,6 +225,7 @@ async function deconflictName(
|
|||
const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : ''
|
||||
|
||||
let candidate = `${stem} copy${ext}`
|
||||
assertCurrent?.()
|
||||
if (
|
||||
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
|
||||
!reservedNames.has(candidate)
|
||||
|
|
@ -214,6 +236,7 @@ async function deconflictName(
|
|||
let counter = 2
|
||||
while (counter < 10000) {
|
||||
candidate = `${stem} copy ${counter}${ext}`
|
||||
assertCurrent?.()
|
||||
if (
|
||||
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
|
||||
!reservedNames.has(candidate)
|
||||
|
|
@ -228,13 +251,21 @@ async function deconflictName(
|
|||
)
|
||||
}
|
||||
|
||||
async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise<void> {
|
||||
async function ensureDropStagingDir(
|
||||
provider: IFilesystemProvider,
|
||||
destDir: string,
|
||||
assertCurrent?: () => void
|
||||
): Promise<void> {
|
||||
const parent = posix.dirname(destDir)
|
||||
assertCurrent?.()
|
||||
await provider.createDir(parent)
|
||||
const gitignorePath = `${parent}/.gitignore`
|
||||
assertCurrent?.()
|
||||
if (!(await remotePathExists(provider, gitignorePath))) {
|
||||
assertCurrent?.()
|
||||
await provider.writeFile(gitignorePath, '*\n!.gitignore\n')
|
||||
}
|
||||
assertCurrent?.()
|
||||
await provider.createDir(destDir)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ import {
|
|||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resetSshConnectionGenerations,
|
||||
setSshConnectionGeneration
|
||||
} from '../ssh/ssh-connection-generation'
|
||||
|
||||
// Why: paths are resolved via path.resolve() in production code, so test
|
||||
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
|
||||
|
|
@ -72,6 +76,7 @@ describe('registerFilesystemMutationHandlers', () => {
|
|||
renameMock.mockReset()
|
||||
writeFileMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
resetSshConnectionGenerations()
|
||||
|
||||
handleMock.mockImplementation((channel: string, handler: never) => {
|
||||
handlers.set(channel, handler)
|
||||
|
|
@ -281,7 +286,9 @@ describe('registerFilesystemMutationHandlers', () => {
|
|||
await handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
|
|
@ -300,7 +307,9 @@ describe('registerFilesystemMutationHandlers', () => {
|
|||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('destination exists')
|
||||
} finally {
|
||||
|
|
@ -310,6 +319,120 @@ describe('registerFilesystemMutationHandlers', () => {
|
|||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects direct SSH rename without target-bound generation provenance', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects equal-generation provenance for another direct SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-b',
|
||||
expectedSshTargetId: 'ssh-a',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-b')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stale generation provenance for a direct SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
setSshConnectionGeneration('ssh-1', 8)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 7
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stale SSH provenance when a direct mutation resolves local', async () => {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: path.resolve('/workspace/repo/old.ts'),
|
||||
newPath: path.resolve('/workspace/repo/new.ts'),
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fs:createFile', { filePath: path.resolve('/workspace/repo/new.ts') }],
|
||||
['fs:createDir', { dirPath: path.resolve('/workspace/repo/new-dir') }],
|
||||
[
|
||||
'fs:rename',
|
||||
{
|
||||
oldPath: path.resolve('/workspace/repo/old.ts'),
|
||||
newPath: path.resolve('/workspace/repo/new.ts')
|
||||
}
|
||||
],
|
||||
[
|
||||
'fs:copy',
|
||||
{
|
||||
sourcePath: path.resolve('/workspace/repo/source.ts'),
|
||||
destinationPath: path.resolve('/workspace/repo/copy.ts')
|
||||
}
|
||||
],
|
||||
[
|
||||
'fs:importExternalPaths',
|
||||
{ sourcePaths: [path.resolve('/tmp/source.ts')], destDir: path.resolve('/workspace/repo') }
|
||||
],
|
||||
[
|
||||
'fs:resolveDroppedPathsForAgent',
|
||||
{ paths: [path.resolve('/tmp/source.ts')], worktreePath: path.resolve('/workspace/repo') }
|
||||
]
|
||||
])(
|
||||
'rejects %s before local fallback when the expected execution host is SSH',
|
||||
async (channel, args) => {
|
||||
await expect(
|
||||
handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' })
|
||||
).rejects.toThrow('Workspace host changed; refresh and try again')
|
||||
|
||||
expect(writeFileMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
// ── fs:copy ────────────────────────────────────────────────────
|
||||
|
||||
it('copies a file without overwriting an existing destination', async () => {
|
||||
|
|
@ -330,7 +453,9 @@ describe('registerFilesystemMutationHandlers', () => {
|
|||
await handlers.get('fs:copy')!(null, {
|
||||
sourcePath: '/home/me/repo/source.ts',
|
||||
destinationPath: '/home/me/repo/source copy.ts',
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat
|
|||
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
|
||||
/**
|
||||
* Re-throw filesystem errors with user-friendly messages.
|
||||
|
|
@ -70,7 +72,16 @@ async function assertNotExists(targetPath: string): Promise<void> {
|
|||
export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
ipcMain.handle(
|
||||
'fs:createFile',
|
||||
async (_event, args: { filePath: string; connectionId?: string }): Promise<void> => {
|
||||
async (
|
||||
_event,
|
||||
args: { filePath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.createFile(args.filePath)
|
||||
|
|
@ -88,7 +99,16 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
|
||||
ipcMain.handle(
|
||||
'fs:createDir',
|
||||
async (_event, args: { dirPath: string; connectionId?: string }): Promise<void> => {
|
||||
async (
|
||||
_event,
|
||||
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.createDir(args.dirPath)
|
||||
|
|
@ -106,8 +126,14 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
'fs:rename',
|
||||
async (
|
||||
_event,
|
||||
args: { oldPath: string; newPath: string; connectionId?: string }
|
||||
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.renameNoClobber(args.oldPath, args.newPath)
|
||||
|
|
@ -129,8 +155,18 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
'fs:copy',
|
||||
async (
|
||||
_event,
|
||||
args: { sourcePath: string; destinationPath: string; connectionId?: string }
|
||||
args: {
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.copy(args.sourcePath, args.destinationPath)
|
||||
|
|
@ -152,11 +188,29 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
'fs:importExternalPaths',
|
||||
async (
|
||||
_event,
|
||||
args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean }
|
||||
args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
} & SshMutationExpectation
|
||||
): Promise<{ results: ImportItemResult[] }> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
|
||||
ensureDir: args.ensureDir
|
||||
ensureDir: args.ensureDir,
|
||||
assertCurrent: () =>
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -205,8 +259,18 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
'fs:resolveDroppedPathsForAgent',
|
||||
async (
|
||||
_event,
|
||||
args: { paths: string[]; worktreePath: string; connectionId?: string }
|
||||
args: {
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<ResolveDroppedPathsResult> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
// Why: `== null` (not `!args.connectionId`) so an empty string is
|
||||
// treated as a renderer error, not silently routed to the local branch.
|
||||
if (args.connectionId == null) {
|
||||
|
|
@ -219,7 +283,14 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
|||
const worktreePath = args.worktreePath.replace(/\/+$/, '')
|
||||
const destDir = `${worktreePath}/.orca/drops`
|
||||
const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, {
|
||||
ensureDir: true
|
||||
ensureDir: true,
|
||||
assertCurrent: () =>
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
})
|
||||
const resolvedPaths: string[] = []
|
||||
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
|
||||
|
|
|
|||
|
|
@ -1129,6 +1129,24 @@ describe('registerFilesystemHandlers', () => {
|
|||
expect(writeFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fs:writeFile', { filePath: path.resolve('/workspace/repo/file.txt'), content: 'data' }],
|
||||
['fs:deletePath', { targetPath: path.resolve('/workspace/repo/file.txt') }]
|
||||
])(
|
||||
'rejects %s before local mutation when the expected execution host is SSH',
|
||||
async (channel, args) => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' })
|
||||
).rejects.toThrow('Workspace host changed; refresh and try again')
|
||||
|
||||
expect(writeFileMock).not.toHaveBeenCalled()
|
||||
expect(trashItemMock).not.toHaveBeenCalled()
|
||||
expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] },
|
||||
{ ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import type {
|
|||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import {
|
||||
buildRgArgs,
|
||||
createAccumulator,
|
||||
|
|
@ -807,8 +809,14 @@ export function registerFilesystemHandlers(
|
|||
'fs:writeFile',
|
||||
async (
|
||||
_event,
|
||||
args: { filePath: string; content: string; connectionId?: string }
|
||||
args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.writeFile(args.filePath, args.content)
|
||||
|
|
@ -834,8 +842,18 @@ export function registerFilesystemHandlers(
|
|||
'fs:deletePath',
|
||||
async (
|
||||
_event,
|
||||
args: { targetPath: string; connectionId?: string; recursive?: boolean }
|
||||
args: {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
} & SshMutationExpectation
|
||||
): Promise<void> => {
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.deletePath(args.targetPath, args.recursive)
|
||||
|
|
|
|||
|
|
@ -694,10 +694,14 @@ describe('registerPtyHandlers', () => {
|
|||
|
||||
function registerAgentClaimController(): {
|
||||
spawn: (args: Record<string, unknown>) => Promise<unknown>
|
||||
write: (ptyId: string, data: string) => boolean
|
||||
resize: (ptyId: string, cols: number, rows: number) => boolean
|
||||
} {
|
||||
let controller:
|
||||
| {
|
||||
spawn: (args: Record<string, unknown>) => Promise<unknown>
|
||||
write: (ptyId: string, data: string) => boolean
|
||||
resize: (ptyId: string, cols: number, rows: number) => boolean
|
||||
}
|
||||
| undefined
|
||||
const runtime = {
|
||||
|
|
@ -714,6 +718,34 @@ describe('registerPtyHandlers', () => {
|
|||
return controller
|
||||
}
|
||||
|
||||
it('fails closed instead of routing encoded SSH PTY writes locally after disconnect', () => {
|
||||
const connectionId = 'ssh-1'
|
||||
const ptyId = `ssh:${connectionId}@@remote-pty`
|
||||
const localProvider = createAgentClaimProvider({})
|
||||
const sshProvider = createAgentClaimProvider({})
|
||||
setLocalPtyProvider(localProvider as never)
|
||||
registerSshPtyProvider(connectionId, sshProvider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
const controller = registerAgentClaimController()
|
||||
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
clearPtyOwnershipForConnection(connectionId)
|
||||
|
||||
expect(controller.write(ptyId, 'input')).toBe(false)
|
||||
expect(controller.resize(ptyId, 100, 40)).toBe(false)
|
||||
expect(localProvider.write).not.toHaveBeenCalled()
|
||||
expect(localProvider.resize).not.toHaveBeenCalled()
|
||||
|
||||
registerSshPtyProvider(connectionId, sshProvider as never)
|
||||
expect(controller.write(ptyId, 'reconnected')).toBe(true)
|
||||
expect(controller.resize(ptyId, 120, 50)).toBe(true)
|
||||
expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected')
|
||||
expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50)
|
||||
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
clearProviderPtyState(ptyId)
|
||||
})
|
||||
|
||||
it('does not dispatch a runtime PTY spawn after its client disconnects', async () => {
|
||||
const provider = createAgentClaimProvider({})
|
||||
setLocalPtyProvider(provider as never)
|
||||
|
|
@ -3647,12 +3679,15 @@ describe('registerPtyHandlers', () => {
|
|||
state: 'attached'
|
||||
})
|
||||
)
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId,
|
||||
ptyId: 'ssh-pty'
|
||||
})
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId,
|
||||
ptyId: 'ssh-pty'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
store.upsertSshRemotePtyLease.mockClear()
|
||||
store.persistPtyBinding.mockClear()
|
||||
|
|
@ -7103,12 +7138,15 @@ describe('registerPtyHandlers', () => {
|
|||
state: 'attached'
|
||||
})
|
||||
)
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-remote',
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-1@@relay-pty'
|
||||
})
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreeId: 'wt-remote',
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-1@@relay-pty'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan(
|
||||
store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]!
|
||||
)
|
||||
|
|
@ -7252,12 +7290,15 @@ describe('registerPtyHandlers', () => {
|
|||
persistHostSessionBinding: true
|
||||
})
|
||||
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-remote',
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
|
||||
})
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreeId: 'wt-remote',
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
|
||||
},
|
||||
'ssh:ssh-reattach-ok'
|
||||
)
|
||||
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'ssh-reattach-ok',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea
|
|||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings, TuiAgent } from '../../shared/types'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
|
||||
import type {
|
||||
|
|
@ -462,6 +463,11 @@ function getProvider(connectionId: string | null | undefined): IPtyProvider {
|
|||
function getProviderForPty(ptyId: string): IPtyProvider {
|
||||
const connectionId = ptyOwnership.get(ptyId)
|
||||
if (connectionId === undefined) {
|
||||
const parsedSshId = parseAppSshPtyId(ptyId)
|
||||
if (parsedSshId) {
|
||||
// Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider.
|
||||
return getProvider(parsedSshId.connectionId)
|
||||
}
|
||||
return localProvider
|
||||
}
|
||||
return getProvider(connectionId)
|
||||
|
|
@ -3441,14 +3447,22 @@ export function registerPtyHandlers(
|
|||
}
|
||||
if (hostSessionBinding) {
|
||||
try {
|
||||
hostSessionBinding.store.persistPtyBinding({
|
||||
const binding = {
|
||||
worktreeId: hostSessionBinding.worktreeId,
|
||||
tabId: hostSessionBinding.tabId,
|
||||
leafId: hostSessionBinding.leafId,
|
||||
ptyId: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(cwd ? { startupCwd: cwd } : {})
|
||||
})
|
||||
}
|
||||
if (args.connectionId) {
|
||||
hostSessionBinding.store.persistPtyBinding(
|
||||
binding,
|
||||
toSshExecutionHostId(args.connectionId)
|
||||
)
|
||||
} else {
|
||||
hostSessionBinding.store.persistPtyBinding(binding)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
|
||||
deletePtyOwnership(result.id)
|
||||
|
|
@ -3562,9 +3576,8 @@ export function registerPtyHandlers(
|
|||
}
|
||||
},
|
||||
write: (ptyId, data) => {
|
||||
const provider = getProviderForPty(ptyId)
|
||||
try {
|
||||
provider.write(ptyId, data)
|
||||
getProviderForPty(ptyId).write(ptyId, data)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -4507,14 +4520,19 @@ export function registerPtyHandlers(
|
|||
validatedLeafId !== null
|
||||
) {
|
||||
try {
|
||||
store.persistPtyBinding({
|
||||
const binding = {
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: args.tabId,
|
||||
leafId: validatedLeafId,
|
||||
ptyId: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(cwd ? { startupCwd: cwd } : {})
|
||||
})
|
||||
}
|
||||
if (args.connectionId) {
|
||||
store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
|
||||
} else {
|
||||
store.persistPtyBinding(binding)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[pty] failed to persist PTY binding after spawn:', err)
|
||||
if (!result.isReattach) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ export function getRemoteRuntimeSharedControlDiagnostics(
|
|||
return sharedControlConnections.get(environmentId)?.connection.getDiagnostics() ?? null
|
||||
}
|
||||
|
||||
export function reconnectRemoteRuntimeSharedControlConnection(environmentId: string): void {
|
||||
sharedControlConnections.get(environmentId)?.connection.reconnectNow()
|
||||
}
|
||||
|
||||
function getSharedControlConnection(
|
||||
environmentId: string,
|
||||
pairing: PairingOffer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
|
||||
|
||||
const environment = {
|
||||
id: 'hub-a',
|
||||
runtimeId: 'runtime-b',
|
||||
createdAt: 1,
|
||||
pairingRevision: 20
|
||||
} as KnownRuntimeEnvironment
|
||||
|
||||
describe('runtimeEnvironmentRevisionFailure', () => {
|
||||
it('fails a queued call when the saved pairing changed under the same environment id', () => {
|
||||
expect(runtimeEnvironmentRevisionFailure(environment, 10, 'worktree.rm')).toEqual({
|
||||
id: 'worktree.rm',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_environment_changed',
|
||||
message: 'Runtime environment pairing changed; refresh and try again'
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-b' }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves mixed-version calls that provide no revision', () => {
|
||||
expect(runtimeEnvironmentRevisionFailure(environment, undefined, 'repo.list')).toBeNull()
|
||||
expect(runtimeEnvironmentRevisionFailure(environment, 20, 'repo.list')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export function runtimeEnvironmentRevisionFailure(
|
||||
environment: KnownRuntimeEnvironment,
|
||||
expectedPairingRevision: number | undefined,
|
||||
method: string
|
||||
): RuntimeRpcResponse<never> | null {
|
||||
if (
|
||||
expectedPairingRevision === undefined ||
|
||||
(environment.pairingRevision ?? environment.createdAt) === expectedPairingRevision
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: method,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_environment_changed',
|
||||
message: 'Runtime environment pairing changed; refresh and try again'
|
||||
},
|
||||
_meta: { runtimeId: environment.runtimeId }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
|
||||
|
||||
export function withTailscaleHintForResponse<TResult>(
|
||||
response: RuntimeRpcResponse<TResult>,
|
||||
endpoint: string
|
||||
): RuntimeRpcResponse<TResult> {
|
||||
if (response.ok === true) {
|
||||
return response
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
const generationByEnvironment = new Map<string, number>()
|
||||
|
||||
export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number {
|
||||
return generationByEnvironment.get(environmentId) ?? 0
|
||||
}
|
||||
|
||||
export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void {
|
||||
generationByEnvironment.set(
|
||||
environmentId,
|
||||
getRuntimeEnvironmentTransportGeneration(environmentId) + 1
|
||||
)
|
||||
}
|
||||
|
|
@ -14,40 +14,22 @@ import {
|
|||
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
|
||||
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
|
||||
import {
|
||||
reconnectRemoteRuntimeSharedControlConnection,
|
||||
sendRemoteRuntimeConnectionRequest,
|
||||
sendRemoteRuntimeSharedControlRequest,
|
||||
subscribeRemoteRuntimeSharedControlRequest
|
||||
} from './runtime-environment-request-connections'
|
||||
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
|
||||
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
|
||||
import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response'
|
||||
|
||||
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
|
||||
const sharedControlSupport = new Map<string, { cacheKey: string; check: Promise<boolean> }>()
|
||||
|
||||
export function resetSharedControlSupport(): void {
|
||||
sharedControlSupport.clear()
|
||||
}
|
||||
export const resetSharedControlSupport = (): void => sharedControlSupport.clear()
|
||||
|
||||
export function clearSharedControlSupport(environmentId: string): void {
|
||||
sharedControlSupport.delete(environmentId)
|
||||
}
|
||||
|
||||
// Why: when a remote host is unreachable, point the user at Tailscale as the
|
||||
// connectivity remedy; the helper no-ops on non-connectivity errors.
|
||||
function withTailscaleHintForResponse<TResult>(
|
||||
response: RuntimeRpcResponse<TResult>,
|
||||
endpoint: string
|
||||
): RuntimeRpcResponse<TResult> {
|
||||
if (response.ok === true) {
|
||||
return response
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
export const clearSharedControlSupport = (environmentId: string): void =>
|
||||
void sharedControlSupport.delete(environmentId)
|
||||
|
||||
export async function getRuntimeEnvironmentStatus(
|
||||
userDataPath: string,
|
||||
|
|
@ -85,6 +67,7 @@ export async function getRuntimeEnvironmentStatus(
|
|||
}
|
||||
if (response.ok === true) {
|
||||
markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId })
|
||||
reconnectRemoteRuntimeSharedControlConnection(environment.id)
|
||||
}
|
||||
return attachRemoteControlDiagnostics(
|
||||
withTailscaleHintForResponse(response, pairing.endpoint),
|
||||
|
|
@ -97,7 +80,8 @@ export async function callRuntimeEnvironment(
|
|||
selector: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs?: number
|
||||
timeoutMs?: number,
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
// Why: connection failures reject (they don't resolve as ok:false), so the
|
||||
|
|
@ -109,6 +93,14 @@ export async function callRuntimeEnvironment(
|
|||
try {
|
||||
return await enqueueRuntimeCall(environment.id, method, async () => {
|
||||
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
|
||||
const revisionFailure = runtimeEnvironmentRevisionFailure(
|
||||
currentEnvironment,
|
||||
expectedEnvironmentPairingRevision,
|
||||
method
|
||||
)
|
||||
if (revisionFailure) {
|
||||
return revisionFailure
|
||||
}
|
||||
const pairing = getPreferredPairingOffer(currentEnvironment)
|
||||
endpoint = pairing.endpoint
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
|
|
@ -125,6 +117,7 @@ export async function callRuntimeEnvironment(
|
|||
}
|
||||
if (
|
||||
method !== 'status.get' &&
|
||||
!shouldUseOneShotRequest(method) &&
|
||||
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
|
||||
) {
|
||||
const response = await sendRemoteRuntimeSharedControlRequest(
|
||||
|
|
@ -245,6 +238,11 @@ function shouldUseCachedRequestConnection(method: string): boolean {
|
|||
return method === 'terminal.send' || method === 'terminal.updateViewport'
|
||||
}
|
||||
|
||||
function shouldUseOneShotRequest(method: string): boolean {
|
||||
// Why: snapshot recovery must remain available while a retained shared-control stream is reconnecting after a HUB restart.
|
||||
return method === 'session.tabs.list' || method === 'session.tabs.listAll'
|
||||
}
|
||||
|
||||
function shouldKeepDedicatedSubscriptionSocket(method: string): boolean {
|
||||
return method === 'browser.screencast' || method === 'terminal.multiplex'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
sendRemoteRuntimeSharedControlRequestMock,
|
||||
subscribeRemoteRuntimeSharedControlRequestMock,
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock,
|
||||
reconnectRemoteRuntimeSharedControlConnectionMock,
|
||||
closeRemoteRuntimeRequestConnectionMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
|
|
@ -33,6 +34,7 @@ const {
|
|||
sendRemoteRuntimeSharedControlRequestMock: vi.fn(),
|
||||
subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(),
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(),
|
||||
reconnectRemoteRuntimeSharedControlConnectionMock: vi.fn(),
|
||||
closeRemoteRuntimeRequestConnectionMock: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -56,10 +58,14 @@ vi.mock('./runtime-environment-request-connections', () => ({
|
|||
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
|
||||
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
|
||||
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
|
||||
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
|
||||
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
|
||||
}))
|
||||
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
import {
|
||||
invalidateRuntimeEnvironmentTransport,
|
||||
registerRuntimeEnvironmentHandlers
|
||||
} from './runtime-environments'
|
||||
|
||||
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
return encodePairingOffer({
|
||||
|
|
@ -108,6 +114,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
subscribeRemoteRuntimeSharedControlRequestMock.mockReset()
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReset()
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null)
|
||||
reconnectRemoteRuntimeSharedControlConnectionMock.mockReset()
|
||||
closeRemoteRuntimeRequestConnectionMock.mockReset()
|
||||
})
|
||||
|
||||
|
|
@ -267,6 +274,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
undefined,
|
||||
50
|
||||
)
|
||||
expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith(
|
||||
added.environment.id
|
||||
)
|
||||
|
||||
const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>(
|
||||
'runtimeEnvironments:resolve'
|
||||
|
|
@ -708,6 +718,40 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('keeps session snapshot recovery on one-shot transport while shared control reconnects', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => ({
|
||||
id: method,
|
||||
ok: true,
|
||||
result:
|
||||
method === 'status.get'
|
||||
? {
|
||||
runtimeId: 'runtime-remote',
|
||||
capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY]
|
||||
}
|
||||
: { snapshots: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}))
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
const call = handler<
|
||||
{ selector: string; method: string; params?: unknown },
|
||||
{ ok: true; result: unknown }
|
||||
>('runtimeEnvironments:call')
|
||||
|
||||
await expect(
|
||||
call(null, { selector: 'desk', method: 'session.tabs.listAll' })
|
||||
).resolves.toMatchObject({ ok: true, result: { snapshots: [] } })
|
||||
expect(sendRemoteRuntimeRequestMock.mock.calls.map((entry) => entry[1])).toEqual([
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
|
|
@ -1536,6 +1580,103 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ method: 'terminal.multiplex', includeExpectedRevision: true },
|
||||
{ method: 'browser.screencast', includeExpectedRevision: true },
|
||||
{ method: 'terminal.multiplex', includeExpectedRevision: false },
|
||||
{ method: 'browser.screencast', includeExpectedRevision: false }
|
||||
])(
|
||||
'closes a pending $method subscription after same-id re-pair (expected revision: $includeExpectedRevision)',
|
||||
async ({ method, includeExpectedRevision }) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn(() => true)
|
||||
let emitRemoteBinary: (bytes: Uint8Array<ArrayBufferLike>) => void = () => {}
|
||||
let resolveSubscribe: (value: {
|
||||
requestId: string
|
||||
close: () => void
|
||||
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => boolean
|
||||
}) => void = () => {}
|
||||
subscribeRemoteRuntimeRequestMock.mockImplementation(
|
||||
(_pairing, _method, _params, _timeoutMs, callbacks) => {
|
||||
emitRemoteBinary = callbacks.onBinary
|
||||
return new Promise((resolve) => {
|
||||
resolveSubscribe = resolve
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
const savedEnvironment = environmentStore.resolveEnvironment(
|
||||
userDataPath,
|
||||
added.environment.id
|
||||
)
|
||||
const pairingRevision = savedEnvironment.pairingRevision ?? savedEnvironment.createdAt
|
||||
const senderSend = vi.fn()
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
const resultPromise = subscribe(
|
||||
{
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: senderSend,
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: added.environment.id,
|
||||
method,
|
||||
params: {},
|
||||
subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`,
|
||||
...(includeExpectedRevision
|
||||
? { expectedEnvironmentPairingRevision: pairingRevision }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledTimes(1))
|
||||
environmentStore.updateEnvironmentFromPairingCode(userDataPath, added.environment.id, {
|
||||
pairingCode: pairingCode('ws://127.0.0.1:7678')
|
||||
})
|
||||
invalidateRuntimeEnvironmentTransport(added.environment.id)
|
||||
|
||||
emitRemoteBinary(new Uint8Array([1, 2, 3]))
|
||||
expect(senderSend).not.toHaveBeenCalled()
|
||||
resolveSubscribe({ requestId: 'retired-stream', close, sendBinary })
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
'Runtime environment pairing changed; refresh and try again'
|
||||
)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
|
||||
const binaryListener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
|
||||
)?.[1] as (_event: unknown, args: unknown) => void
|
||||
binaryListener(
|
||||
{ sender: { id: 1 } },
|
||||
{
|
||||
subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`,
|
||||
bytes: new Uint8Array([4, 5, 6])
|
||||
}
|
||||
)
|
||||
expect(sendBinary).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-clie
|
|||
import type { Store } from '../persistence'
|
||||
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import {
|
||||
advanceRuntimeEnvironmentTransportGeneration,
|
||||
getRuntimeEnvironmentTransportGeneration
|
||||
} from './runtime-environment-transport-generation'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
clearSharedControlSupport,
|
||||
|
|
@ -42,14 +46,10 @@ type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
|
|||
removeDestroyedListener: () => void
|
||||
}
|
||||
const remoteRuntimeSubscriptions = new Map<string, RetainedRemoteRuntimeSubscription>()
|
||||
|
||||
function getUserDataPath(): string {
|
||||
return app.getPath('userData')
|
||||
}
|
||||
const getUserDataPath = (): string => app.getPath('userData')
|
||||
|
||||
function closeSubscriptionsForEnvironment(environmentId: string): void {
|
||||
// Why: removing a saved runtime invalidates its streaming WebSockets too;
|
||||
// otherwise terminal/browser subscriptions stay alive until renderer teardown.
|
||||
// Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown.
|
||||
for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) {
|
||||
if (subscription.environmentId !== environmentId) {
|
||||
continue
|
||||
|
|
@ -58,10 +58,16 @@ function closeSubscriptionsForEnvironment(environmentId: string): void {
|
|||
subscription.close()
|
||||
}
|
||||
}
|
||||
export function invalidateRuntimeEnvironmentTransport(environmentId: string): void {
|
||||
// Why: a same-id re-pair must retire every transport that still authenticates as the old peer.
|
||||
advanceRuntimeEnvironmentTransportGeneration(environmentId)
|
||||
closeRemoteRuntimeRequestConnection(environmentId)
|
||||
clearSharedControlSupport(environmentId)
|
||||
closeSubscriptionsForEnvironment(environmentId)
|
||||
}
|
||||
|
||||
function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
|
||||
// Why: `source` is persisted on the env record, so read it directly instead of
|
||||
// joining the VM store — a corrupt VM store must not break listing all envs.
|
||||
// Why: a corrupt VM store must not break persisted environment listing.
|
||||
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
|
||||
}
|
||||
|
||||
|
|
@ -95,14 +101,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
'runtimeEnvironments:remove',
|
||||
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
closeRemoteRuntimeRequestConnection(removed.id)
|
||||
clearSharedControlSupport(removed.id)
|
||||
invalidateRuntimeEnvironmentTransport(removed.id)
|
||||
if (args.selector !== removed.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
clearSharedControlSupport(args.selector)
|
||||
}
|
||||
clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id)
|
||||
closeSubscriptionsForEnvironment(removed.id)
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
|
|
@ -112,13 +116,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
// Why: disconnect is intentionally non-destructive; it drops live
|
||||
// transport state while keeping the paired server available for later.
|
||||
closeRemoteRuntimeRequestConnection(environment.id)
|
||||
clearSharedControlSupport(environment.id)
|
||||
invalidateRuntimeEnvironmentTransport(environment.id)
|
||||
if (args.selector !== environment.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
clearSharedControlSupport(args.selector)
|
||||
}
|
||||
closeSubscriptionsForEnvironment(environment.id)
|
||||
return { disconnected: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
|
|
@ -135,14 +137,21 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
'runtimeEnvironments:call',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
|
||||
args: {
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
return callRuntimeEnvironment(
|
||||
getUserDataPath(),
|
||||
args.selector,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -156,6 +165,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
params?: unknown
|
||||
timeoutMs?: number
|
||||
subscriptionId?: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}
|
||||
): Promise<{ subscriptionId: string; requestId: string }> => {
|
||||
const subscriptionId =
|
||||
|
|
@ -166,6 +176,16 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
throw new Error('Runtime environment subscription id already exists')
|
||||
}
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
const pairingRevision = environment.pairingRevision ?? environment.createdAt
|
||||
if (
|
||||
args.expectedEnvironmentPairingRevision !== undefined &&
|
||||
pairingRevision !== args.expectedEnvironmentPairingRevision
|
||||
) {
|
||||
throw new Error('Runtime environment pairing changed; refresh and try again')
|
||||
}
|
||||
const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id)
|
||||
const transportIsCurrent = (): boolean =>
|
||||
getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration
|
||||
const sender = event.sender
|
||||
const ownerWebContentsId = sender.id
|
||||
let senderDestroyed = sender.isDestroyed()
|
||||
|
|
@ -200,7 +220,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
args.timeoutMs,
|
||||
{
|
||||
onEvent: (payload) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
if (transportIsCurrent() && !sender.isDestroyed()) {
|
||||
sender.send('runtimeEnvironments:subscriptionEvent', {
|
||||
subscriptionId,
|
||||
...payload
|
||||
|
|
@ -218,6 +238,19 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
|||
removeDestroyedListener()
|
||||
throw error
|
||||
}
|
||||
let pairingIsCurrent = false
|
||||
try {
|
||||
const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id)
|
||||
pairingIsCurrent =
|
||||
(currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision
|
||||
} catch {
|
||||
pairingIsCurrent = false
|
||||
}
|
||||
if (!transportIsCurrent() || !pairingIsCurrent) {
|
||||
removeDestroyedListener()
|
||||
subscription.close()
|
||||
throw new Error('Runtime environment pairing changed; refresh and try again')
|
||||
}
|
||||
if (senderDestroyed || sender.isDestroyed()) {
|
||||
removeDestroyedListener()
|
||||
subscription.close()
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ import {
|
|||
getSshPtyProvider,
|
||||
getPtyIdsForConnection
|
||||
} from './pty'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
|
||||
describe('SSH IPC handlers', () => {
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
|
||||
|
|
@ -537,6 +538,7 @@ describe('SSH IPC handlers', () => {
|
|||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1,
|
||||
remotePlatform: 'win32'
|
||||
})
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', {
|
||||
|
|
@ -546,6 +548,7 @@ describe('SSH IPC handlers', () => {
|
|||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1,
|
||||
supportsFolderDownload: true,
|
||||
remotePlatform: 'win32'
|
||||
}
|
||||
|
|
@ -586,14 +589,16 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: 'Relay channel lost. Reconnecting...',
|
||||
reconnectAttempt: 1
|
||||
reconnectAttempt: 1,
|
||||
connectionGeneration: 1
|
||||
}
|
||||
})
|
||||
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
|
||||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: 'Relay channel lost. Reconnecting...',
|
||||
reconnectAttempt: 1
|
||||
reconnectAttempt: 1,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
|
@ -605,6 +610,7 @@ describe('SSH IPC handlers', () => {
|
|||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1,
|
||||
supportsFolderDownload: true
|
||||
}
|
||||
})
|
||||
|
|
@ -612,13 +618,72 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).not.toThrow()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a staged mutation after the underlying SSH transport reconnects', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
const stagedGeneration = 1
|
||||
const callbacks = mockConnectionManager.callbacksRef.current as {
|
||||
onStateChange: (targetId: string, state: SshConnectionState) => void
|
||||
}
|
||||
|
||||
callbacks.onStateChange('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: null,
|
||||
reconnectAttempt: 1
|
||||
})
|
||||
callbacks.onStateChange('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
callbacks.onStateChange('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
|
||||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: 'Relay channel reconnecting...',
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow(
|
||||
'SSH connection changed; refresh and try again'
|
||||
)
|
||||
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rebuilds instead of reusing a ready session while relay loss is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const target: SshTarget = {
|
||||
|
|
@ -651,7 +716,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'reconnecting',
|
||||
error: 'Relay channel lost. Reconnecting...',
|
||||
reconnectAttempt: 1
|
||||
reconnectAttempt: 1,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
|
||||
mockDeployAndLaunchRelay.mockClear()
|
||||
|
|
@ -661,7 +727,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
|
||||
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
||||
|
|
@ -670,7 +737,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
|
@ -709,7 +777,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -719,7 +788,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.',
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
|
@ -756,7 +826,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1)
|
||||
|
|
@ -767,7 +838,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
|
||||
expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled()
|
||||
|
|
@ -823,7 +895,8 @@ describe('SSH IPC handlers', () => {
|
|||
const runtime = {
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
notifySshStateChanged: vi.fn()
|
||||
notifySshStateChanged: vi.fn(),
|
||||
notifySshRelayReady: vi.fn()
|
||||
}
|
||||
registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never)
|
||||
const target: SshTarget = {
|
||||
|
|
@ -850,6 +923,7 @@ describe('SSH IPC handlers', () => {
|
|||
'ssh-1',
|
||||
expect.objectContaining({ targetId: 'ssh-1', status: 'connected' })
|
||||
)
|
||||
expect(runtime.notifySshRelayReady).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('keeps runtime-owned SSH state off the renderer while invalidating runtime scans', async () => {
|
||||
|
|
@ -970,9 +1044,10 @@ describe('SSH IPC handlers', () => {
|
|||
mockDeployAndLaunchRelay.mockClear()
|
||||
mockPortForwardManager.removeAllForwards.mockClear()
|
||||
|
||||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual(
|
||||
connectedState
|
||||
)
|
||||
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({
|
||||
...connectedState,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled()
|
||||
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
|
||||
expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([
|
||||
|
|
@ -1171,7 +1246,8 @@ describe('SSH IPC handlers', () => {
|
|||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'network down',
|
||||
reconnectAttempt: 0
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
}
|
||||
})
|
||||
expect(secondWindow.webContents.send).toHaveBeenCalledWith(
|
||||
|
|
@ -1569,6 +1645,23 @@ describe('SSH IPC handlers', () => {
|
|||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockConnectionManager.reconnect.mockImplementation(async (targetId: string) => {
|
||||
const callbacks = mockConnectionManager.callbacksRef.current as {
|
||||
onStateChange: (id: string, state: SshConnectionState) => void
|
||||
}
|
||||
callbacks.onStateChange(targetId, {
|
||||
targetId,
|
||||
status: 'reconnecting',
|
||||
error: null,
|
||||
reconnectAttempt: 1
|
||||
})
|
||||
callbacks.onStateChange(targetId, {
|
||||
targetId,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(false)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
|
@ -1581,6 +1674,9 @@ describe('SSH IPC handlers', () => {
|
|||
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
|
||||
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
|
||||
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
|
||||
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({
|
||||
connectionGeneration: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('skips reconnect on system resume when the relay link is still alive', async () => {
|
||||
|
|
@ -1729,6 +1825,6 @@ describe('SSH IPC handlers', () => {
|
|||
mockConnectionManager.getState.mockReturnValue(state)
|
||||
|
||||
const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })
|
||||
expect(result).toEqual(state)
|
||||
expect(result).toEqual({ ...state, connectionGeneration: 0 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward broadcasting, and session lifecycle to keep the data flow obvious. */
|
||||
import { ipcMain, powerMonitor, type BrowserWindow } from 'electron'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import type { Store } from '../persistence'
|
||||
import { SshConnectionStore } from '../ssh/ssh-connection-store'
|
||||
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
|
||||
|
|
@ -37,6 +38,12 @@ import {
|
|||
getSshPtyProvider
|
||||
} from './pty'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
advanceSshConnectionGeneration,
|
||||
getSshConnectionGeneration,
|
||||
initializeSshConnectionGenerationSession,
|
||||
resetSshConnectionGenerations
|
||||
} from '../ssh/ssh-connection-generation'
|
||||
|
||||
let sshStore: SshConnectionStore | null = null
|
||||
let connectionManager: SshConnectionManager | null = null
|
||||
|
|
@ -182,14 +189,14 @@ type ConnectAttempt = {
|
|||
}
|
||||
|
||||
const connectInFlight = new Map<string, ConnectAttempt>()
|
||||
const connectGenerationByTarget = new Map<string, number>()
|
||||
|
||||
const pendingTransportReconnects = new Set<string>()
|
||||
function currentConnectGeneration(targetId: string): number {
|
||||
return connectGenerationByTarget.get(targetId) ?? 0
|
||||
return getSshConnectionGeneration(targetId)
|
||||
}
|
||||
|
||||
function invalidateConnectAttempt(targetId: string): void {
|
||||
connectGenerationByTarget.set(targetId, currentConnectGeneration(targetId) + 1)
|
||||
advanceSshConnectionGeneration(targetId)
|
||||
pendingTransportReconnects.delete(targetId)
|
||||
connectInFlight.delete(targetId)
|
||||
credentialRequestedForTarget.delete(targetId)
|
||||
}
|
||||
|
|
@ -254,7 +261,11 @@ function broadcastSshState(
|
|||
|
||||
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
|
||||
const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os
|
||||
return remotePlatform ? { ...state, remotePlatform } : state
|
||||
return {
|
||||
...state,
|
||||
connectionGeneration: currentConnectGeneration(targetId),
|
||||
...(remotePlatform ? { remotePlatform } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function publishRelayOverride(
|
||||
|
|
@ -505,11 +516,35 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
|
|||
// Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'.
|
||||
const session = activeSessions.get(targetId)
|
||||
const sessionState = session?.getState()
|
||||
if (
|
||||
state.status === 'reconnecting' &&
|
||||
(sessionState === 'ready' || sessionState === 'reconnecting')
|
||||
) {
|
||||
pendingTransportReconnects.add(targetId)
|
||||
} else if (
|
||||
state.status === 'disconnected' ||
|
||||
state.status === 'auth-failed' ||
|
||||
state.status === 'reconnection-failed' ||
|
||||
state.status === 'error'
|
||||
) {
|
||||
pendingTransportReconnects.delete(targetId)
|
||||
}
|
||||
const completedTransportReconnect =
|
||||
state.status === 'connected' && pendingTransportReconnects.delete(targetId)
|
||||
if (completedTransportReconnect) {
|
||||
// Why: staged mutations from the replaced SSH transport must fail even if its relay session disappeared before recovery completed.
|
||||
advanceSshConnectionGeneration(targetId)
|
||||
}
|
||||
const shouldReconnectRelay =
|
||||
session !== undefined &&
|
||||
state.status === 'connected' &&
|
||||
completedTransportReconnect &&
|
||||
state.reconnectAttempt === 0 &&
|
||||
(sessionState === 'ready' || sessionState === 'reconnecting')
|
||||
const relayReconnectAlreadyInFlight =
|
||||
!completedTransportReconnect &&
|
||||
state.status === 'connected' &&
|
||||
sessionState === 'reconnecting' &&
|
||||
relayStateOverrides.has(targetId)
|
||||
|
||||
if (shouldReconnectRelay) {
|
||||
// Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again.
|
||||
|
|
@ -520,6 +555,9 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
|
|||
'Relay channel reconnecting...',
|
||||
state.reconnectAttempt
|
||||
)
|
||||
} else if (relayReconnectAlreadyInFlight) {
|
||||
// Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes.
|
||||
return
|
||||
} else {
|
||||
clearRelayStateOverride(targetId)
|
||||
broadcastSshState(getCurrentMainWindow, targetId, state)
|
||||
|
|
@ -646,6 +684,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
|
|||
supportsFolderDownload: connectionSupportsFolderDownload(tid)
|
||||
})
|
||||
}
|
||||
currentRuntime?.notifySshRelayReady?.(tid)
|
||||
void restorePortForwards(tid, getCurrentMainWindow)
|
||||
})
|
||||
}
|
||||
|
|
@ -671,6 +710,7 @@ export function registerSshHandlers(
|
|||
getMainWindow: () => BrowserWindow | null,
|
||||
runtime?: OrcaRuntimeService
|
||||
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
|
||||
initializeSshConnectionGenerationSession()
|
||||
// Why: macOS re-activation re-calls this with a new BrowserWindow; ipcMain.handle() throws on a duplicate channel, so remove prior handlers first.
|
||||
for (const ch of SSH_IPC_CHANNELS) {
|
||||
ipcMain.removeHandler(ch)
|
||||
|
|
@ -759,6 +799,11 @@ export function registerSshHandlers(
|
|||
// ── Connection lifecycle ───────────────────────────────────────────
|
||||
|
||||
async function connectTarget(targetId: string): Promise<SshConnectionState> {
|
||||
const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE
|
||||
if (e2eProbePath) {
|
||||
appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`)
|
||||
throw new Error('e2e_forbidden_local_ssh_connect')
|
||||
}
|
||||
const observedGeneration = currentConnectGeneration(targetId)
|
||||
const reset = resetRelayInFlight.get(targetId)
|
||||
if (reset) {
|
||||
|
|
@ -774,10 +819,9 @@ export function registerSshHandlers(
|
|||
throw connectCancelledError()
|
||||
}
|
||||
|
||||
const generation = observedGeneration + 1
|
||||
connectGenerationByTarget.set(targetId, generation)
|
||||
const promise = doConnect(targetId, generation)
|
||||
const attempt = { generation, promise }
|
||||
pendingTransportReconnects.delete(targetId)
|
||||
const promise = doConnect(targetId)
|
||||
const attempt = { generation: currentConnectGeneration(targetId), promise }
|
||||
connectInFlight.set(targetId, attempt)
|
||||
try {
|
||||
return await promise
|
||||
|
|
@ -795,7 +839,7 @@ export function registerSshHandlers(
|
|||
return connectTarget(args.targetId)
|
||||
})
|
||||
|
||||
async function doConnect(targetId: string, generation: number): Promise<SshConnectionState> {
|
||||
async function doConnect(targetId: string): Promise<SshConnectionState> {
|
||||
const target = sshStore!.getTarget(targetId)
|
||||
if (!target) {
|
||||
throw new Error(`SSH target "${targetId}" not found`)
|
||||
|
|
@ -815,9 +859,10 @@ export function registerSshHandlers(
|
|||
) {
|
||||
// Why: BrowserWindow reactivation re-fires ssh:connect for already-live targets; treat as a refresh instead of tearing down the relay and its forwards.
|
||||
broadcastSshState(getCurrentMainWindow, targetId, existingState)
|
||||
return existingState
|
||||
return getPublicSshState(targetId)!
|
||||
}
|
||||
|
||||
const generation = advanceSshConnectionGeneration(targetId)
|
||||
clearRelayStateOverride(targetId)
|
||||
let conn
|
||||
// Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error).
|
||||
|
|
@ -1231,7 +1276,8 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
|
|||
}
|
||||
relayStateOverrides.clear()
|
||||
connectInFlight.clear()
|
||||
connectGenerationByTarget.clear()
|
||||
pendingTransportReconnects.clear()
|
||||
resetSshConnectionGenerations()
|
||||
resetRelayInFlight.clear()
|
||||
testingTargets.clear()
|
||||
credentialRequestedForTarget.clear()
|
||||
|
|
|
|||
|
|
@ -10511,6 +10511,35 @@ describe('Store host-partitioned workspace sessions', () => {
|
|||
activeRepoId
|
||||
})
|
||||
|
||||
const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'repo-1::/worktree',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {
|
||||
'repo-1::/worktree': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('migrates a legacy workspaceSession blob into the local partition', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
|
@ -10652,6 +10681,81 @@ describe('Store host-partitioned workspace sessions', () => {
|
|||
).toBe(7)
|
||||
})
|
||||
|
||||
it('persists an SSH PTY binding only in the SSH host partition', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
|
||||
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
|
||||
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
tabId: 'tab-1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'ssh:ssh-1@@remote-pty'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(
|
||||
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
||||
).toBe('ssh:ssh-1@@remote-pty')
|
||||
expect(
|
||||
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rolls back a failed SSH PTY binding flush in the SSH host partition', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
|
||||
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
|
||||
const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
|
||||
throw new Error('disk unavailable')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
tabId: 'tab-1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'ssh:ssh-1@@remote-pty'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).toThrow('disk unavailable')
|
||||
flush.mockRestore()
|
||||
|
||||
expect(
|
||||
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
||||
).toBeNull()
|
||||
expect(
|
||||
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('clears expired SSH PTY bindings from the SSH partition and legacy local copy', async () => {
|
||||
const store = await createStore()
|
||||
const ptyId = 'ssh:ssh-1@@remote-pty'
|
||||
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local')
|
||||
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1')
|
||||
store.upsertSshRemotePtyLease({
|
||||
targetId: 'ssh-1',
|
||||
ptyId: 'remote-pty',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
tabId: 'tab-1',
|
||||
leafId: TEST_LEAF_1,
|
||||
state: 'attached'
|
||||
})
|
||||
|
||||
store.markSshRemotePtyLease('ssh-1', ptyId, 'expired')
|
||||
|
||||
for (const hostId of ['local', 'ssh:ssh-1']) {
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
expect(session.tabsByWorktree['repo-1::/worktree'][0]?.ptyId).toBeNull()
|
||||
expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults an omitted hostId to the local partition', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
||||
|
|
|
|||
|
|
@ -5910,17 +5910,24 @@ export class Store {
|
|||
}
|
||||
|
||||
// Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217).
|
||||
persistPtyBinding(args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
startupCwd?: string
|
||||
}): void {
|
||||
const session = this.state.workspaceSession
|
||||
if (!session) {
|
||||
return
|
||||
persistPtyBinding(
|
||||
args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
startupCwd?: string
|
||||
},
|
||||
hostId?: string | null
|
||||
): void {
|
||||
const resolvedHostId = this.resolveHostId(hostId)
|
||||
const session = this.getWorkspaceSession(resolvedHostId)
|
||||
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
this.state.workspaceSessionsByHostId = {
|
||||
...this.state.workspaceSessionsByHostId,
|
||||
[resolvedHostId]: session
|
||||
}
|
||||
}
|
||||
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
|
||||
const paneKey = `${args.tabId}:${args.leafId}`
|
||||
|
|
@ -5937,6 +5944,16 @@ export class Store {
|
|||
[repoId]: currentRevision + 1
|
||||
}
|
||||
}
|
||||
const restoreSession = (): void => {
|
||||
if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
this.state.workspaceSession = sessionBeforeBinding
|
||||
} else {
|
||||
this.state.workspaceSessionsByHostId = {
|
||||
...this.state.workspaceSessionsByHostId,
|
||||
[resolvedHostId]: sessionBeforeBinding
|
||||
}
|
||||
}
|
||||
}
|
||||
if (args.incarnationId) {
|
||||
session.terminalPtyIncarnationsByPaneKey = {
|
||||
...session.terminalPtyIncarnationsByPaneKey,
|
||||
|
|
@ -5980,7 +5997,7 @@ export class Store {
|
|||
try {
|
||||
this.flushOrThrow()
|
||||
} catch (err) {
|
||||
this.state.workspaceSession = sessionBeforeBinding
|
||||
restoreSession()
|
||||
throw err
|
||||
}
|
||||
return
|
||||
|
|
@ -6028,7 +6045,7 @@ export class Store {
|
|||
try {
|
||||
this.flushOrThrow()
|
||||
} catch (err) {
|
||||
this.state.workspaceSession = sessionBeforeBinding
|
||||
restoreSession()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
@ -6393,54 +6410,61 @@ export class Store {
|
|||
targetId: string,
|
||||
leases: SshRemotePtyLease[]
|
||||
): boolean {
|
||||
const session = this.state.workspaceSession
|
||||
if (!leases?.length || !session) {
|
||||
if (!leases?.length) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
tab.ptyId &&
|
||||
leases.some((lease) =>
|
||||
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
|
||||
ptyId: tab.ptyId!,
|
||||
worktreeId,
|
||||
targetId,
|
||||
tabId: tab.id
|
||||
})
|
||||
)
|
||||
) {
|
||||
tab.ptyId = null
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
|
||||
const bindings = layout.ptyIdsByLeafId
|
||||
if (!bindings) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === tabId)
|
||||
)?.[0]
|
||||
const nextBindings = Object.fromEntries(
|
||||
Object.entries(bindings).filter(
|
||||
([leafId, ptyId]) =>
|
||||
!leases.some((lease) =>
|
||||
const sessions = new Set(
|
||||
[
|
||||
this.state.workspaceSession,
|
||||
this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)]
|
||||
].filter((session): session is WorkspaceSessionState => Boolean(session))
|
||||
)
|
||||
for (const session of sessions) {
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
tab.ptyId &&
|
||||
leases.some((lease) =>
|
||||
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
|
||||
ptyId,
|
||||
targetId,
|
||||
ptyId: tab.ptyId!,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId
|
||||
targetId,
|
||||
tabId: tab.id
|
||||
})
|
||||
)
|
||||
) {
|
||||
tab.ptyId = null
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
|
||||
const bindings = layout.ptyIdsByLeafId
|
||||
if (!bindings) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === tabId)
|
||||
)?.[0]
|
||||
const nextBindings = Object.fromEntries(
|
||||
Object.entries(bindings).filter(
|
||||
([leafId, ptyId]) =>
|
||||
!leases.some((lease) =>
|
||||
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
|
||||
ptyId,
|
||||
targetId,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
|
||||
layout.ptyIdsByLeafId = nextBindings
|
||||
changed = true
|
||||
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
|
||||
layout.ptyIdsByLeafId = nextBindings
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type {
|
||||
SshPtyDataCallback,
|
||||
SshPtyExitCallback,
|
||||
SshPtyReplayCallback
|
||||
} from './ssh-pty-provider-contract'
|
||||
|
||||
export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback }
|
||||
|
||||
export function subscribeSshPtyNotifications(args: {
|
||||
mux: SshChannelMultiplexer
|
||||
toAppPtyId: (id: string) => string
|
||||
dataListeners: Set<SshPtyDataCallback>
|
||||
replayListeners: Set<SshPtyReplayCallback>
|
||||
exitListeners: Set<SshPtyExitCallback>
|
||||
livePtyIds: Set<string>
|
||||
recordExit: (relayPtyId: string, incarnationId: unknown) => void
|
||||
}): () => void {
|
||||
return args.mux.onNotification((method, params) => {
|
||||
const id = args.toAppPtyId(params.id as string)
|
||||
if (method === 'pty.exit') {
|
||||
args.recordExit(params.id as string, params.incarnationId)
|
||||
args.livePtyIds.delete(id)
|
||||
for (const listener of args.exitListeners) {
|
||||
listener({
|
||||
id,
|
||||
code: params.code as number,
|
||||
...(isPtyIncarnationId(params.incarnationId)
|
||||
? { incarnationId: params.incarnationId }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method !== 'pty.data' && method !== 'pty.replay') {
|
||||
return
|
||||
}
|
||||
args.livePtyIds.add(id)
|
||||
if (method === 'pty.replay') {
|
||||
for (const listener of args.replayListeners) {
|
||||
listener({ id, data: params.data as string })
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const listener of args.dataListeners) {
|
||||
listener({
|
||||
id,
|
||||
data: params.data as string,
|
||||
...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}),
|
||||
...(params.transformed === true ? { transformed: true } : {}),
|
||||
...(typeof params.seq === 'number' ? { seq: params.seq } : {})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -268,6 +268,16 @@ describe('SshPtyProvider', () => {
|
|||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
|
||||
})
|
||||
expect(result).toEqual({ id: scopedPty1 })
|
||||
expect(provider.hasPty(scopedPty1)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a spawned PTY live across an overlapping stale process list', async () => {
|
||||
mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([])
|
||||
|
||||
const result = await provider.spawn({ cols: 80, rows: 24 })
|
||||
await provider.listProcesses()
|
||||
|
||||
expect(provider.hasPty(result.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('gates fresh startup intent with the relay ingress capability version', async () => {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import type {
|
|||
SshPtyExitCallback,
|
||||
SshPtyReplayCallback
|
||||
} from './ssh-pty-provider-contract'
|
||||
import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing'
|
||||
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
|
||||
import {
|
||||
assertSshAgentSessionCreateResult,
|
||||
requestSshAgentSessionCreate
|
||||
} from './ssh-agent-session-create-operation'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { mapSshPtyProcessList } from './ssh-agent-session-process-list'
|
||||
import {
|
||||
parseSshPtyAttachResult,
|
||||
|
|
@ -36,6 +36,7 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
private dataListeners = new Set<SshPtyDataCallback>()
|
||||
private replayListeners = new Set<SshPtyReplayCallback>()
|
||||
private exitListeners = new Set<SshPtyExitCallback>()
|
||||
private livePtyIds = new Set<string>()
|
||||
// Why: stale notification callbacks must not outlive a disconnected provider.
|
||||
private unsubscribeNotifications: (() => void) | null = null
|
||||
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
|
||||
|
|
@ -52,41 +53,15 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
|
||||
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
|
||||
|
||||
this.unsubscribeNotifications = mux.onNotification((method, params) => {
|
||||
switch (method) {
|
||||
case 'pty.data':
|
||||
for (const cb of this.dataListeners) {
|
||||
cb({
|
||||
id: this.toAppPtyId(params.id as string),
|
||||
data: params.data as string,
|
||||
...(typeof params.rawLength === 'number'
|
||||
? { sequenceChars: params.rawLength as number }
|
||||
: {}),
|
||||
...(params.transformed === true ? { transformed: true } : {}),
|
||||
...(typeof params.seq === 'number' ? { seq: params.seq as number } : {})
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'pty.replay':
|
||||
for (const cb of this.replayListeners) {
|
||||
cb({ id: this.toAppPtyId(params.id as string), data: params.data as string })
|
||||
}
|
||||
break
|
||||
|
||||
case 'pty.exit':
|
||||
this.spawnExitRaces.recordExit(params.id as string, params.incarnationId)
|
||||
for (const cb of this.exitListeners) {
|
||||
cb({
|
||||
id: this.toAppPtyId(params.id as string),
|
||||
code: params.code as number,
|
||||
...(isPtyIncarnationId(params.incarnationId)
|
||||
? { incarnationId: params.incarnationId }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
this.unsubscribeNotifications = subscribeSshPtyNotifications({
|
||||
mux,
|
||||
toAppPtyId: (id) => this.toAppPtyId(id),
|
||||
dataListeners: this.dataListeners,
|
||||
replayListeners: this.replayListeners,
|
||||
exitListeners: this.exitListeners,
|
||||
livePtyIds: this.livePtyIds,
|
||||
recordExit: (relayPtyId, incarnationId) =>
|
||||
this.spawnExitRaces.recordExit(relayPtyId, incarnationId)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +73,7 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
this.dataListeners.clear()
|
||||
this.replayListeners.clear()
|
||||
this.exitListeners.clear()
|
||||
this.livePtyIds.clear()
|
||||
}
|
||||
|
||||
getConnectionId = (): string => this.connectionId
|
||||
|
|
@ -124,13 +100,15 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
}
|
||||
}
|
||||
if (opts.sessionId) {
|
||||
return await reattachSshPtySessionWithExitFence({
|
||||
const result = await reattachSshPtySessionWithExitFence({
|
||||
mux: this.mux,
|
||||
connectionId: this.connectionId,
|
||||
sessionId: opts.sessionId,
|
||||
options: opts,
|
||||
exitRaceTracker: this.spawnExitRaces
|
||||
})
|
||||
this.livePtyIds.add(result.id)
|
||||
return result
|
||||
}
|
||||
|
||||
const supportsCreateOperation = opts.agentSessionCreateOperationId
|
||||
|
|
@ -181,9 +159,11 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
throw new Error(validation.error)
|
||||
}
|
||||
}
|
||||
const id = this.toAppPtyId(spawnResult.id)
|
||||
this.livePtyIds.add(id)
|
||||
return {
|
||||
...spawnResult,
|
||||
id: this.toAppPtyId(spawnResult.id),
|
||||
id,
|
||||
...(claimed
|
||||
? {
|
||||
agentSessionEnsure: {
|
||||
|
|
@ -259,6 +239,7 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
},
|
||||
relayTimeoutOptions(opts.deadlineMs)
|
||||
)
|
||||
this.livePtyIds.delete(id)
|
||||
}
|
||||
|
||||
async sendSignal(id: string, signal: string): Promise<void> {
|
||||
|
|
@ -317,7 +298,15 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
undefined,
|
||||
relayTimeoutOptions(opts?.deadlineMs)
|
||||
)
|
||||
return mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
|
||||
const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
|
||||
for (const process of processes) {
|
||||
this.livePtyIds.add(process.id)
|
||||
}
|
||||
return processes
|
||||
}
|
||||
|
||||
hasPty(id: string): boolean {
|
||||
return this.livePtyIds.has(id)
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -686,8 +686,10 @@ describe('graph-sync mobile snapshot gating', () => {
|
|||
).toBe(true)
|
||||
|
||||
// The persisted SSH binding is removed with no renderer-visible change, so
|
||||
// the renderer resends the unchanged version 1 — the tab must still drop.
|
||||
// the renderer resends the unchanged version 1 after the bounded HUB-restart
|
||||
// recovery grace — the tab must still drop.
|
||||
setSession(makeSession())
|
||||
vi.advanceTimersByTime(30_001)
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(
|
||||
|
|
@ -762,9 +764,10 @@ describe('graph-sync mobile snapshot gating', () => {
|
|||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(true)
|
||||
|
||||
// Once the SSH binding disappears from persistence (and no live PTY backs
|
||||
// it), the next renderer revision must stop preserving it.
|
||||
// Once the recovery grace expires and the SSH binding disappears from
|
||||
// persistence (with no live PTY), the next revision must stop preserving it.
|
||||
setSession(makeSession())
|
||||
vi.advanceTimersByTime(30_001)
|
||||
sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -190,6 +190,10 @@ function activeTabId(response: Record<string, unknown>): string | null {
|
|||
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null
|
||||
}
|
||||
|
||||
function snapshotVersion(response: Record<string, unknown>): number {
|
||||
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.snapshotVersion ?? -1
|
||||
}
|
||||
|
||||
function seedSessionTabs(runtime: OrcaRuntimeService): void {
|
||||
const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({
|
||||
type: 'terminal' as const,
|
||||
|
|
@ -410,11 +414,17 @@ describe('paired runtime navigation isolation', () => {
|
|||
notifyClients: false
|
||||
}
|
||||
})
|
||||
expect(activeTabId(await harness.readerA.next('select-a2'))).toBe('client-a2-tab')
|
||||
const selectA2 = await harness.readerA.next('select-a2')
|
||||
expect(activeTabId(selectA2)).toBe('client-a2-tab')
|
||||
|
||||
harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID)
|
||||
const [updateA, updateB] = await Promise.all([
|
||||
harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'),
|
||||
harness.readerA.next(
|
||||
'tabs-a',
|
||||
(response) =>
|
||||
resultType(response) === 'updated' &&
|
||||
snapshotVersion(response) >= snapshotVersion(selectA2)
|
||||
),
|
||||
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
|
||||
])
|
||||
expect(activeTabId(updateA)).toBe('client-a2-tab')
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
|||
|
||||
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resetSshConnectionGenerations,
|
||||
setSshConnectionGeneration
|
||||
} from '../ssh/ssh-connection-generation'
|
||||
import { SEARCH_TIMEOUT_MS } from '../../shared/text-search'
|
||||
|
||||
type MockRuntimeSearchChild = EventEmitter & {
|
||||
|
|
@ -207,6 +211,7 @@ describe('RuntimeFileCommands', () => {
|
|||
watchMock.mockReset()
|
||||
checkRgAvailableMock.mockReset()
|
||||
vi.mocked(getSshFilesystemProvider).mockReset()
|
||||
resetSshConnectionGenerations()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReset()
|
||||
wslAwareSpawnMock.mockReset()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
|
||||
|
|
@ -367,11 +372,64 @@ describe('RuntimeFileCommands', () => {
|
|||
const { commands } = createRuntimeFileCommands()
|
||||
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
|
||||
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
|
||||
await commands.renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'old.ts',
|
||||
'new.ts',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
|
||||
expect(renameMock).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
|
||||
})
|
||||
|
||||
it('rejects legacy paired local mutations before selecting a filesystem provider', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
|
||||
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
|
||||
'newer Orca client'
|
||||
)
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects legacy paired SSH mutations before selecting a filesystem provider', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1')
|
||||
).rejects.toThrow('newer Orca client')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a local expectation when the worktree moved to SSH', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an SSH expectation when the worktree moved to HUB-local', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows runtime-local case-only rename with IPC parity guard behavior', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
mockLocalPathStats({
|
||||
|
|
@ -379,7 +437,14 @@ describe('RuntimeFileCommands', () => {
|
|||
'/repo/readme.md': [10, 100]
|
||||
})
|
||||
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md')
|
||||
await commands.renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'README.md',
|
||||
'readme.md',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
|
||||
expect(renameMock).toHaveBeenCalledWith('/repo/README.md', '/repo/readme.md')
|
||||
})
|
||||
|
|
@ -391,9 +456,9 @@ describe('RuntimeFileCommands', () => {
|
|||
'/repo/new.ts': [11, 111]
|
||||
})
|
||||
|
||||
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
|
||||
"A file or folder named 'new.ts' already exists in this location"
|
||||
)
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local')
|
||||
).rejects.toThrow("A file or folder named 'new.ts' already exists in this location")
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -406,7 +471,14 @@ describe('RuntimeFileCommands', () => {
|
|||
})
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'README.md', 'README-hardlink.md')
|
||||
commands.renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'README.md',
|
||||
'README-hardlink.md',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
).rejects.toThrow("A file or folder named 'README-hardlink.md' already exists in this location")
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
|
|
@ -420,7 +492,14 @@ describe('RuntimeFileCommands', () => {
|
|||
})
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'src/README.md', 'docs/readme.md')
|
||||
commands.renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'src/README.md',
|
||||
'docs/readme.md',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
).rejects.toThrow("A file or folder named 'readme.md' already exists in this location")
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
|
|
@ -432,9 +511,64 @@ describe('RuntimeFileCommands', () => {
|
|||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
|
||||
expect(store.getRepo).toHaveBeenCalledTimes(1)
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a mutation captured for an obsolete SSH connection generation', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
setSshConnectionGeneration('ssh-1', 8)
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 7, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects nested SSH mutations from clients without generation support', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'old.ts',
|
||||
'new.ts',
|
||||
undefined,
|
||||
'ssh-1',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an equal-generation mutation captured for another SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-b' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-a', 'ssh:ssh-a')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a stale SSH expectation after the worktree becomes HUB-local', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -444,9 +578,9 @@ describe('RuntimeFileCommands', () => {
|
|||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
|
||||
'destination exists'
|
||||
)
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('destination exists')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ import {
|
|||
RuntimeMobileFilePathSearchCache
|
||||
} from './runtime-mobile-file-path-search'
|
||||
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const MOBILE_FILE_LIST_LIMIT = 5000
|
||||
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
|
||||
|
|
@ -97,8 +99,27 @@ const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
|
|||
export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000
|
||||
const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000
|
||||
const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0
|
||||
const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED =
|
||||
'Remote file changes require a newer Orca client. Update the paired client and try again.'
|
||||
|
||||
function assertRuntimeFileMutationExpectation(
|
||||
connectionId: string | undefined,
|
||||
expectedExecutionHostId: string | undefined,
|
||||
expectedSshTargetId: string | undefined,
|
||||
expectedSshConnectionGeneration: number | undefined
|
||||
): void {
|
||||
if (!expectedExecutionHostId) {
|
||||
throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED)
|
||||
}
|
||||
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
|
||||
if (expectedExecutionHostId !== actualExecutionHostId) {
|
||||
throw new Error('Workspace host changed; refresh and try again')
|
||||
}
|
||||
assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration)
|
||||
}
|
||||
// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them.
|
||||
const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
|
||||
|
||||
type RuntimeFileWatcherLease = {
|
||||
suspend(): Promise<void>
|
||||
resume(): Promise<void>
|
||||
|
|
@ -1339,9 +1360,18 @@ export class RuntimeFileCommands {
|
|||
async writeFileExplorerFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
content: string
|
||||
content: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -1369,9 +1399,18 @@ export class RuntimeFileCommands {
|
|||
async writeFileExplorerFileBase64(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
contentBase64: string
|
||||
contentBase64: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
|
|
@ -1392,9 +1431,18 @@ export class RuntimeFileCommands {
|
|||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
contentBase64: string,
|
||||
append: boolean
|
||||
append: boolean,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
|
|
@ -1413,9 +1461,18 @@ export class RuntimeFileCommands {
|
|||
|
||||
async createFileExplorerFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
relativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -1437,9 +1494,18 @@ export class RuntimeFileCommands {
|
|||
|
||||
async createFileExplorerDir(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
relativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -1457,9 +1523,18 @@ export class RuntimeFileCommands {
|
|||
|
||||
async createFileExplorerDirNoClobber(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
relativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -1477,10 +1552,21 @@ export class RuntimeFileCommands {
|
|||
async commitFileExplorerUpload(
|
||||
worktreeSelector: string,
|
||||
tempRelativePath: string,
|
||||
finalRelativePath: string
|
||||
finalRelativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath)
|
||||
const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath)
|
||||
const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
|
||||
tempRelativePath,
|
||||
finalRelativePath
|
||||
])
|
||||
assertRuntimeFileMutationExpectation(
|
||||
tempTarget.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = tempTarget.connectionId
|
||||
? getSshFilesystemProvider(tempTarget.connectionId)
|
||||
: null
|
||||
|
|
@ -1505,10 +1591,21 @@ export class RuntimeFileCommands {
|
|||
async renameFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
oldRelativePath: string,
|
||||
newRelativePath: string
|
||||
newRelativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath)
|
||||
const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath)
|
||||
const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
|
||||
oldRelativePath,
|
||||
newRelativePath
|
||||
])
|
||||
assertRuntimeFileMutationExpectation(
|
||||
oldTarget.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = oldTarget.connectionId
|
||||
? getSshFilesystemProvider(oldTarget.connectionId)
|
||||
: null
|
||||
|
|
@ -1531,12 +1628,20 @@ export class RuntimeFileCommands {
|
|||
async copyFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
sourceRelativePath: string,
|
||||
destinationRelativePath: string
|
||||
destinationRelativePath: string,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath)
|
||||
const destinationTarget = await this.resolveFileExplorerPath(
|
||||
const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths(
|
||||
worktreeSelector,
|
||||
destinationRelativePath
|
||||
[sourceRelativePath, destinationRelativePath]
|
||||
)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
sourceTarget.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = sourceTarget.connectionId
|
||||
? getSshFilesystemProvider(sourceTarget.connectionId)
|
||||
|
|
@ -1565,9 +1670,18 @@ export class RuntimeFileCommands {
|
|||
async deleteFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
recursive?: boolean
|
||||
recursive?: boolean,
|
||||
expectedSshConnectionGeneration?: number,
|
||||
expectedSshTargetId?: string,
|
||||
expectedExecutionHostId?: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
assertRuntimeFileMutationExpectation(
|
||||
target.connectionId,
|
||||
expectedExecutionHostId,
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
|
|
@ -1768,13 +1882,23 @@ export class RuntimeFileCommands {
|
|||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
|
||||
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
|
||||
return target
|
||||
}
|
||||
|
||||
private async resolveFileExplorerPaths(
|
||||
worktreeSelector: string,
|
||||
relativePaths: readonly string[]
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
|
||||
return {
|
||||
return relativePaths.map((relativePath) => ({
|
||||
worktree: target.worktree,
|
||||
path: joinWorktreeRelativePath(target.worktree.path, normalizedRelativePath),
|
||||
path: joinWorktreeRelativePath(
|
||||
target.worktree.path,
|
||||
normalizeRuntimeRelativePath(relativePath)
|
||||
),
|
||||
connectionId: target.connectionId
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private async listRemoteMobileFiles(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,9 @@
|
|||
import type { SshConnectionState } from '../../shared/ssh-types'
|
||||
|
||||
export function getPublicSshError(status: SshConnectionState['status']): string {
|
||||
return status === 'auth-failed' ? 'SSH authentication failed' : 'SSH connection unavailable'
|
||||
}
|
||||
|
||||
export function getPublicSshState(state: SshConnectionState | null): SshConnectionState | null {
|
||||
return state ? { ...state, error: state.error ? getPublicSshError(state.status) : null } : null
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh'
|
||||
import { getPublicSshState } from '../../public-ssh-state'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
let clientEventSubscriptionSeq = 0
|
||||
|
|
@ -36,7 +38,12 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
|
|||
for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) {
|
||||
emit(event)
|
||||
}
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
const sshStates = listRegisteredSshTargets().flatMap((target) => {
|
||||
const state = getPublicSshState(getRegisteredSshState(target.id) ?? null)
|
||||
return state ? [{ targetId: target.id, state }] : []
|
||||
})
|
||||
// Why: attaching the listener before snapshotting closes the reload gap without exposing HUB-private target configuration.
|
||||
emit({ type: 'ready', subscriptionId, snapshot: { sshStates } })
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ describe('file RPC methods', () => {
|
|||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
|
|
@ -412,7 +412,6 @@ describe('file RPC methods', () => {
|
|||
'{}',
|
||||
undefined
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('reads a preview file for a selected worktree', async () => {
|
||||
|
|
@ -775,7 +774,7 @@ describe('file RPC methods', () => {
|
|||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('files.delete', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'src',
|
||||
|
|
@ -784,6 +783,34 @@ describe('file RPC methods', () => {
|
|||
)
|
||||
|
||||
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true)
|
||||
})
|
||||
|
||||
it('forwards the captured SSH target and generation for destructive mutations', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.delete', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'src',
|
||||
recursive: true,
|
||||
expectedExecutionHostId: 'ssh:ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 7
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'src',
|
||||
true,
|
||||
7,
|
||||
'ssh-1',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,29 @@ function isValidRuntimeFileBase64(value: unknown): value is string {
|
|||
)
|
||||
}
|
||||
|
||||
type SshMutationParams = {
|
||||
expectedExecutionHostId?: string
|
||||
expectedSshTargetId?: string
|
||||
expectedSshConnectionGeneration?: number
|
||||
}
|
||||
|
||||
function sshMutationArguments(
|
||||
params: SshMutationParams
|
||||
): [expectedGeneration?: number, expectedTargetId?: string, expectedExecutionHostId?: string] {
|
||||
if (
|
||||
params.expectedExecutionHostId === undefined &&
|
||||
params.expectedSshTargetId === undefined &&
|
||||
params.expectedSshConnectionGeneration === undefined
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
params.expectedSshConnectionGeneration,
|
||||
params.expectedSshTargetId,
|
||||
params.expectedExecutionHostId
|
||||
]
|
||||
}
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
|
|
@ -31,6 +54,12 @@ const FileOpen = WorktreeSelector.extend({
|
|||
.pipe(z.string().min(1, 'Missing relative path'))
|
||||
})
|
||||
|
||||
const FileMutationOpen = FileOpen.extend({
|
||||
expectedExecutionHostId: z.string().min(1).optional(),
|
||||
expectedSshTargetId: z.string().min(1).optional(),
|
||||
expectedSshConnectionGeneration: z.number().int().nonnegative().optional()
|
||||
})
|
||||
|
||||
const ResolveTerminalPath = WorktreeSelector.extend({
|
||||
pathText: z
|
||||
.unknown()
|
||||
|
|
@ -86,13 +115,13 @@ const ServerDirectoryBrowse = z.object({
|
|||
// Why: write content must be a real string. Coercing a missing/non-string value
|
||||
// to '' silently truncated the target file to empty instead of erroring. An
|
||||
// explicit '' is still accepted (writing an empty file is legitimate).
|
||||
const FileWrite = FileOpen.extend({
|
||||
const FileWrite = FileMutationOpen.extend({
|
||||
content: z
|
||||
.unknown()
|
||||
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
|
||||
})
|
||||
|
||||
const FileWriteBase64 = FileOpen.extend({
|
||||
const FileWriteBase64 = FileMutationOpen.extend({
|
||||
contentBase64: z
|
||||
.unknown()
|
||||
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
|
||||
|
|
@ -115,6 +144,9 @@ const FileReadChunk = FileOpen.extend({
|
|||
})
|
||||
|
||||
const FileRename = WorktreeSelector.extend({
|
||||
expectedExecutionHostId: z.string().min(1).optional(),
|
||||
expectedSshTargetId: z.string().min(1).optional(),
|
||||
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
|
||||
oldRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
|
|
@ -126,6 +158,9 @@ const FileRename = WorktreeSelector.extend({
|
|||
})
|
||||
|
||||
const FileCopy = WorktreeSelector.extend({
|
||||
expectedExecutionHostId: z.string().min(1).optional(),
|
||||
expectedSshTargetId: z.string().min(1).optional(),
|
||||
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
|
||||
sourceRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
|
|
@ -137,6 +172,9 @@ const FileCopy = WorktreeSelector.extend({
|
|||
})
|
||||
|
||||
const FileCommitUpload = WorktreeSelector.extend({
|
||||
expectedExecutionHostId: z.string().min(1).optional(),
|
||||
expectedSshTargetId: z.string().min(1).optional(),
|
||||
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
|
||||
tempRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
|
|
@ -147,7 +185,7 @@ const FileCommitUpload = WorktreeSelector.extend({
|
|||
.pipe(z.string().min(1, 'Missing final path'))
|
||||
})
|
||||
|
||||
const FileDelete = FileOpen.extend({
|
||||
const FileDelete = FileMutationOpen.extend({
|
||||
recursive: z.boolean().optional()
|
||||
})
|
||||
|
||||
|
|
@ -283,7 +321,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
name: 'files.write',
|
||||
params: FileWrite,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content)
|
||||
runtime.writeFileExplorerFile(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.content,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.writeBase64',
|
||||
|
|
@ -292,7 +335,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
runtime.writeFileExplorerFileBase64(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.contentBase64
|
||||
params.contentBase64,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
@ -303,26 +347,39 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
params.worktree,
|
||||
params.relativePath,
|
||||
params.contentBase64,
|
||||
params.append === true
|
||||
params.append === true,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createFile',
|
||||
params: FileOpen,
|
||||
params: FileMutationOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerFile(params.worktree, params.relativePath)
|
||||
runtime.createFileExplorerFile(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createDir',
|
||||
params: FileOpen,
|
||||
params: FileMutationOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerDir(params.worktree, params.relativePath)
|
||||
runtime.createFileExplorerDir(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createDirNoClobber',
|
||||
params: FileOpen,
|
||||
params: FileMutationOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath)
|
||||
runtime.createFileExplorerDirNoClobber(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.commitUpload',
|
||||
|
|
@ -331,7 +388,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
runtime.commitFileExplorerUpload(
|
||||
params.worktree,
|
||||
params.tempRelativePath,
|
||||
params.finalRelativePath
|
||||
params.finalRelativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
@ -341,7 +399,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
runtime.renameFileExplorerPath(
|
||||
params.worktree,
|
||||
params.oldRelativePath,
|
||||
params.newRelativePath
|
||||
params.newRelativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
@ -351,14 +410,20 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
runtime.copyFileExplorerPath(
|
||||
params.worktree,
|
||||
params.sourceRelativePath,
|
||||
params.destinationRelativePath
|
||||
params.destinationRelativePath,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.delete',
|
||||
params: FileDelete,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive)
|
||||
runtime.deleteFileExplorerPath(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.recursive,
|
||||
...sshMutationArguments(params)
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.search',
|
||||
|
|
|
|||
|
|
@ -72,15 +72,89 @@ describe('ssh RPC methods', () => {
|
|||
expect(response).toMatchObject({ ok: true, result: { state: null } })
|
||||
})
|
||||
|
||||
it('lists the registered SSH targets for paired clients', async () => {
|
||||
const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }]
|
||||
it('redacts HUB-private diagnostics from state and connect failures', async () => {
|
||||
const privateMessage = 'identity /Users/hub/.ssh/private via bastion.internal failed'
|
||||
getRegisteredSshStateMock.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'auth-failed',
|
||||
error: privateMessage,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
connectRegisteredSshTargetMock.mockRejectedValueOnce(new Error(privateMessage))
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const stateResponse = await dispatcher.dispatch(
|
||||
makeRequest('ssh.getState', { targetId: 'ssh-1' })
|
||||
)
|
||||
const connectResponse = await dispatcher.dispatch(
|
||||
makeRequest('ssh.connect', { targetId: 'ssh-1' })
|
||||
)
|
||||
|
||||
expect(stateResponse).toMatchObject({
|
||||
ok: true,
|
||||
result: { state: { error: 'SSH authentication failed' } }
|
||||
})
|
||||
expect(connectResponse).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: 'SSH authentication failed' }
|
||||
})
|
||||
expect(JSON.stringify([stateResponse, connectResponse])).not.toContain(privateMessage)
|
||||
})
|
||||
|
||||
it('lists redacted SSH target summaries for paired clients', async () => {
|
||||
const targets = [
|
||||
{
|
||||
id: 'ssh-1',
|
||||
label: 'Dev box',
|
||||
host: 'dev.internal',
|
||||
port: 22,
|
||||
username: 'me',
|
||||
identityFile: '/secret/key',
|
||||
jumpHost: 'bastion',
|
||||
proxyCommand: 'private proxy'
|
||||
}
|
||||
]
|
||||
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries'))
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain('dev.internal')
|
||||
expect(JSON.stringify(response)).not.toContain('/secret/key')
|
||||
expect(JSON.stringify(response)).not.toContain('bastion')
|
||||
})
|
||||
|
||||
it('redacts the legacy target response for older clients', async () => {
|
||||
const targets = [
|
||||
{
|
||||
id: 'ssh-1',
|
||||
label: 'Dev box',
|
||||
host: 'dev.internal',
|
||||
port: 22,
|
||||
username: 'me',
|
||||
identityFile: '/secret/key',
|
||||
jumpHost: 'bastion'
|
||||
}
|
||||
]
|
||||
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listTargets'))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { targets } })
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain('dev.internal')
|
||||
expect(JSON.stringify(response)).not.toContain('/secret/key')
|
||||
expect(JSON.stringify(response)).not.toContain('bastion')
|
||||
})
|
||||
|
||||
it('lists removed-target labels for ghost-host display on paired clients', async () => {
|
||||
|
|
|
|||
|
|
@ -6,26 +6,47 @@ import {
|
|||
listRegisteredSshTargets
|
||||
} from '../../../ipc/ssh'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
|
||||
|
||||
const SshTarget = z.object({
|
||||
targetId: z.string().min(1)
|
||||
})
|
||||
|
||||
function listRegisteredSshTargetSummaries(): { id: string; label: string }[] {
|
||||
return listRegisteredSshTargets().map(({ id, label }) => ({ id, label }))
|
||||
}
|
||||
|
||||
export const SSH_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'ssh.getState',
|
||||
params: SshTarget,
|
||||
handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null })
|
||||
handler: (params) => ({
|
||||
state: getPublicSshState(getRegisteredSshState(params.targetId) ?? null)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.connect',
|
||||
params: SshTarget,
|
||||
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
|
||||
handler: async (params) => {
|
||||
try {
|
||||
return { state: getPublicSshState(await connectRegisteredSshTarget(params.targetId)) }
|
||||
} catch {
|
||||
const state = getRegisteredSshState(params.targetId)
|
||||
throw new Error(getPublicSshError(state?.status ?? 'error'))
|
||||
}
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.listTargets',
|
||||
params: null,
|
||||
handler: () => ({ targets: listRegisteredSshTargets() })
|
||||
// Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary.
|
||||
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.listTargetSummaries',
|
||||
params: null,
|
||||
// Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private.
|
||||
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ssh.listRemovedTargetLabels',
|
||||
|
|
|
|||
|
|
@ -839,7 +839,14 @@ const TerminalResolveActive = z.object({
|
|||
})
|
||||
|
||||
const TerminalResolvePane = z.object({
|
||||
paneKey: requiredString('Missing pane key')
|
||||
paneKey: requiredString('Missing pane key'),
|
||||
worktreeId: OptionalString
|
||||
})
|
||||
|
||||
const TerminalRecoverPane = z.object({
|
||||
paneKey: requiredString('Missing pane key'),
|
||||
worktreeId: requiredString('Missing worktree ID'),
|
||||
expectedTerminal: requiredString('Missing expected terminal handle').optional()
|
||||
})
|
||||
|
||||
const TerminalRead = TerminalHandle.extend({
|
||||
|
|
@ -1110,7 +1117,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
name: 'terminal.resolvePane',
|
||||
params: TerminalResolvePane,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: runtime.resolveTerminalPane(params.paneKey)
|
||||
terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.recoverPane',
|
||||
params: TerminalRecoverPane,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: await runtime.recoverTerminalPane(
|
||||
params.paneKey,
|
||||
params.worktreeId,
|
||||
params.expectedTerminal
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
|
|||
|
|
@ -3092,7 +3092,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
|||
id: 'req_resolve_pane',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.resolvePane',
|
||||
params: { paneKey: `tab-right:${bottomLeaf}` }
|
||||
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId }
|
||||
})
|
||||
expect(resolvePaneResponse).toMatchObject({
|
||||
id: 'req_resolve_pane',
|
||||
|
|
@ -3102,10 +3102,23 @@ describe('OrcaRuntimeRpcServer', () => {
|
|||
handle: handleByLeaf.get(bottomLeaf),
|
||||
tabId: 'tab-right',
|
||||
leafId: bottomLeaf,
|
||||
ptyId: 'pty-bottom'
|
||||
ptyId: 'pty-bottom',
|
||||
worktreeId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrongOwnerResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_resolve_pane_wrong_owner',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.resolvePane',
|
||||
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId: 'other-worktree' }
|
||||
})
|
||||
expect(wrongOwnerResponse).toMatchObject({
|
||||
id: 'req_resolve_pane_wrong_owner',
|
||||
ok: false,
|
||||
error: { message: 'terminal_not_found' }
|
||||
})
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
|||
'ssh.getState',
|
||||
'ssh.listRemovedTargetLabels',
|
||||
'ssh.listTargets',
|
||||
'ssh.listTargetSummaries',
|
||||
'speech.dictation.cancel',
|
||||
'speech.dictation.chunk',
|
||||
'speech.dictation.finish',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
advanceSshConnectionGeneration,
|
||||
assertSshMutationExpectation,
|
||||
getSshConnectionGeneration,
|
||||
resetSshConnectionGenerations,
|
||||
setSshConnectionGeneration
|
||||
} from './ssh-connection-generation'
|
||||
|
||||
const SESSION_COUNTER_STRIDE = 2 ** 13
|
||||
const MAX_SESSION_SCOPE = 2 ** 40 - 1
|
||||
|
||||
describe('SSH connection generation session scope', () => {
|
||||
afterEach(() => resetSshConnectionGenerations())
|
||||
|
||||
it('does not reuse a target token when a restarted HUB reaches the same counter', () => {
|
||||
resetSshConnectionGenerations(41)
|
||||
const beforeRestart = advanceSshConnectionGeneration('ssh-a')
|
||||
|
||||
resetSshConnectionGenerations(42)
|
||||
const afterRestart = advanceSshConnectionGeneration('ssh-a')
|
||||
|
||||
expect(afterRestart).not.toBe(beforeRestart)
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', beforeRestart)).toThrow(
|
||||
'SSH connection changed; refresh and try again'
|
||||
)
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', afterRestart)).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps target counters independent within one HUB session', () => {
|
||||
resetSshConnectionGenerations(7)
|
||||
|
||||
expect(advanceSshConnectionGeneration('ssh-a')).toBe(advanceSshConnectionGeneration('ssh-b'))
|
||||
expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b'))
|
||||
})
|
||||
|
||||
it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => {
|
||||
expect(() =>
|
||||
assertSshMutationExpectation(undefined, undefined, undefined, 'ssh:ssh-a')
|
||||
).toThrow('Workspace host changed; refresh and try again')
|
||||
})
|
||||
|
||||
it('rejects a local execution-host expectation when direct IPC resolves through SSH', () => {
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', 0, 'local')).toThrow(
|
||||
'Workspace host changed; refresh and try again'
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls the session scope after counter exhaustion and keeps rotating', () => {
|
||||
resetSshConnectionGenerations(7)
|
||||
const exhaustedGeneration = 8 * SESSION_COUNTER_STRIDE - 1
|
||||
setSshConnectionGeneration('ssh-a', exhaustedGeneration)
|
||||
|
||||
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
|
||||
|
||||
expect(rolledGeneration).toBe(8 * SESSION_COUNTER_STRIDE + 1)
|
||||
expect(advanceSshConnectionGeneration('ssh-a')).toBe(rolledGeneration + 1)
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', exhaustedGeneration)).toThrow(
|
||||
'SSH connection changed; refresh and try again'
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidates other targets when exhaustion rolls the session scope', () => {
|
||||
resetSshConnectionGenerations(11)
|
||||
const otherTargetGeneration = advanceSshConnectionGeneration('ssh-b')
|
||||
setSshConnectionGeneration('ssh-a', 12 * SESSION_COUNTER_STRIDE - 1)
|
||||
|
||||
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
|
||||
|
||||
expect(getSshConnectionGeneration('ssh-b')).toBe(12 * SESSION_COUNTER_STRIDE)
|
||||
expect(rolledGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1)
|
||||
expect(() => assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration)).toThrow(
|
||||
'SSH connection changed; refresh and try again'
|
||||
)
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration)).not.toThrow()
|
||||
})
|
||||
|
||||
it('wraps the maximum safe numeric scope without reusing it', () => {
|
||||
resetSshConnectionGenerations(MAX_SESSION_SCOPE)
|
||||
setSshConnectionGeneration('ssh-a', Number.MAX_SAFE_INTEGER)
|
||||
|
||||
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
|
||||
|
||||
expect(rolledGeneration).toBe(1)
|
||||
expect(Number.isSafeInteger(rolledGeneration)).toBe(true)
|
||||
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', Number.MAX_SAFE_INTEGER)).toThrow(
|
||||
'SSH connection changed; refresh and try again'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { randomBytes } from 'node:crypto'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const SESSION_COUNTER_BITS = 13
|
||||
const SESSION_COUNTER_STRIDE = 2 ** SESSION_COUNTER_BITS
|
||||
const MAX_SESSION_SCOPE = 2 ** (53 - SESSION_COUNTER_BITS) - 1
|
||||
|
||||
function createSessionScope(): number {
|
||||
return randomBytes(5).readUIntBE(0, 5)
|
||||
}
|
||||
|
||||
let sessionGenerationBase = 0
|
||||
let sessionInitialized = false
|
||||
const connectionGenerationByTarget = new Map<string, number>()
|
||||
const usedSessionScopes = new Set<number>()
|
||||
|
||||
function assertGenerationInCurrentSession(generation: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(generation) ||
|
||||
generation < sessionGenerationBase ||
|
||||
generation - sessionGenerationBase >= SESSION_COUNTER_STRIDE
|
||||
) {
|
||||
throw new Error('SSH connection generation exhausted for this runtime session')
|
||||
}
|
||||
}
|
||||
|
||||
export function getSshConnectionGeneration(targetId: string): number {
|
||||
return connectionGenerationByTarget.get(targetId) ?? sessionGenerationBase
|
||||
}
|
||||
|
||||
export function initializeSshConnectionGenerationSession(): void {
|
||||
if (sessionInitialized) {
|
||||
return
|
||||
}
|
||||
const sessionScope = createSessionScope()
|
||||
// Why: randomize the process scope so a replacement HUB does not predictably reuse the prior target/counter token.
|
||||
sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE
|
||||
usedSessionScopes.add(sessionScope)
|
||||
sessionInitialized = true
|
||||
}
|
||||
|
||||
export function advanceSshConnectionGeneration(targetId: string): number {
|
||||
let next = getSshConnectionGeneration(targetId) + 1
|
||||
if (next - sessionGenerationBase >= SESSION_COUNTER_STRIDE) {
|
||||
let nextSessionScope =
|
||||
(sessionGenerationBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1)
|
||||
while (usedSessionScopes.has(nextSessionScope)) {
|
||||
nextSessionScope = (nextSessionScope + 1) % (MAX_SESSION_SCOPE + 1)
|
||||
}
|
||||
usedSessionScopes.add(nextSessionScope)
|
||||
sessionGenerationBase = nextSessionScope * SESSION_COUNTER_STRIDE
|
||||
// Why: changing the scope must revoke tokens for every target, not only the target that exhausted its counter.
|
||||
connectionGenerationByTarget.clear()
|
||||
next = sessionGenerationBase + 1
|
||||
}
|
||||
assertGenerationInCurrentSession(next)
|
||||
connectionGenerationByTarget.set(targetId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function setSshConnectionGeneration(targetId: string, generation: number): void {
|
||||
assertGenerationInCurrentSession(generation)
|
||||
connectionGenerationByTarget.set(targetId, generation)
|
||||
}
|
||||
|
||||
export function resetSshConnectionGenerations(sessionScope = 0): void {
|
||||
if (!Number.isSafeInteger(sessionScope) || sessionScope < 0 || sessionScope > MAX_SESSION_SCOPE) {
|
||||
throw new Error('Invalid SSH connection generation session scope')
|
||||
}
|
||||
sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE
|
||||
sessionInitialized = true
|
||||
connectionGenerationByTarget.clear()
|
||||
usedSessionScopes.clear()
|
||||
usedSessionScopes.add(sessionScope)
|
||||
}
|
||||
|
||||
export function assertSshMutationExpectation(
|
||||
connectionId: string | undefined,
|
||||
expectedTargetId: string | undefined,
|
||||
expectedGeneration: number | undefined,
|
||||
expectedExecutionHostId?: string
|
||||
): void {
|
||||
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
|
||||
if (expectedExecutionHostId !== undefined && expectedExecutionHostId !== actualExecutionHostId) {
|
||||
throw new Error('Workspace host changed; refresh and try again')
|
||||
}
|
||||
const hasExpectation = expectedTargetId !== undefined || expectedGeneration !== undefined
|
||||
if (!connectionId) {
|
||||
if (hasExpectation) {
|
||||
throw new Error('SSH connection changed; refresh and try again')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
expectedTargetId !== connectionId ||
|
||||
expectedGeneration === undefined ||
|
||||
expectedGeneration !== getSshConnectionGeneration(connectionId)
|
||||
) {
|
||||
throw new Error('SSH connection changed; refresh and try again')
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
} from '../shared/terminal-render-desync-evidence'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { SshMutationExpectation } from '../shared/ssh-types'
|
||||
import type {
|
||||
CreateLocalOrcaProfileArgs,
|
||||
CreateLocalOrcaProfileResult,
|
||||
|
|
@ -2503,20 +2504,32 @@ export type PreloadApi = {
|
|||
rootPath: string
|
||||
connectionId?: string
|
||||
}) => Promise<MarkdownDocument[]>
|
||||
writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise<void>
|
||||
createFile: (args: { filePath: string; connectionId?: string }) => Promise<void>
|
||||
createDir: (args: { dirPath: string; connectionId?: string }) => Promise<void>
|
||||
rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise<void>
|
||||
copy: (args: {
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
connectionId?: string
|
||||
}) => Promise<void>
|
||||
deletePath: (args: {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
}) => Promise<void>
|
||||
writeFile: (
|
||||
args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
createFile: (
|
||||
args: { filePath: string; connectionId?: string } & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
createDir: (
|
||||
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
rename: (
|
||||
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
copy: (
|
||||
args: {
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
deletePath: (
|
||||
args: {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
} & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
|
||||
stat: (args: {
|
||||
filePath: string
|
||||
|
|
@ -2531,12 +2544,14 @@ export type PreloadApi = {
|
|||
}) => Promise<string[]>
|
||||
cancelListFiles: (args: { requestToken: string }) => Promise<void>
|
||||
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
|
||||
importExternalPaths: (args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
}) => Promise<{
|
||||
importExternalPaths: (
|
||||
args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
} & SshMutationExpectation
|
||||
) => Promise<{
|
||||
results: (
|
||||
| {
|
||||
sourcePath: string
|
||||
|
|
@ -2581,11 +2596,13 @@ export type PreloadApi = {
|
|||
}
|
||||
)[]
|
||||
}>
|
||||
resolveDroppedPathsForAgent: (args: {
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}) => Promise<{
|
||||
resolveDroppedPathsForAgent: (
|
||||
args: {
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
) => Promise<{
|
||||
resolvedPaths: string[]
|
||||
skipped: {
|
||||
sourcePath: string
|
||||
|
|
@ -3077,6 +3094,7 @@ export type PreloadApi = {
|
|||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}) => Promise<RuntimeRpcResponse<unknown>>
|
||||
subscribe: (
|
||||
args: {
|
||||
|
|
@ -3084,6 +3102,7 @@ export type PreloadApi = {
|
|||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
},
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
} from '../shared/agent-session-resume'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { SshMutationExpectation } from '../shared/ssh-types'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
BaseRefDefaultResult,
|
||||
|
|
@ -2862,27 +2863,36 @@ const api = {
|
|||
connectionId?: string
|
||||
}): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> =>
|
||||
ipcRenderer.invoke('fs:listMarkdownDocuments', args),
|
||||
writeFile: (args: {
|
||||
filePath: string
|
||||
content: string
|
||||
connectionId?: string
|
||||
}): Promise<void> => ipcRenderer.invoke('fs:writeFile', args),
|
||||
createFile: (args: { filePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:createFile', args),
|
||||
createDir: (args: { dirPath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:createDir', args),
|
||||
rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:rename', args),
|
||||
copy: (args: {
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
connectionId?: string
|
||||
}): Promise<void> => ipcRenderer.invoke('fs:copy', args),
|
||||
deletePath: (args: {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
}): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
|
||||
writeFile: (
|
||||
args: {
|
||||
filePath: string
|
||||
content: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:writeFile', args),
|
||||
createFile: (
|
||||
args: { filePath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:createFile', args),
|
||||
createDir: (
|
||||
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:createDir', args),
|
||||
rename: (
|
||||
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:rename', args),
|
||||
copy: (
|
||||
args: {
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:copy', args),
|
||||
deletePath: (
|
||||
args: {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
} & SshMutationExpectation
|
||||
): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
|
||||
authorizeExternalPath: (args: { targetPath: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:authorizeExternalPath', args),
|
||||
stat: (args: {
|
||||
|
|
@ -2911,12 +2921,14 @@ const api = {
|
|||
maxResults?: number
|
||||
connectionId?: string
|
||||
}): Promise<SearchResult> => ipcRenderer.invoke('fs:search', args),
|
||||
importExternalPaths: (args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
}): Promise<{
|
||||
importExternalPaths: (
|
||||
args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
} & SshMutationExpectation
|
||||
): Promise<{
|
||||
results: (
|
||||
| {
|
||||
sourcePath: string
|
||||
|
|
@ -2963,11 +2975,13 @@ const api = {
|
|||
}
|
||||
)[]
|
||||
}> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args),
|
||||
resolveDroppedPathsForAgent: (args: {
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
}): Promise<{
|
||||
resolveDroppedPathsForAgent: (
|
||||
args: {
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<{
|
||||
resolvedPaths: string[]
|
||||
skipped: {
|
||||
sourcePath: string
|
||||
|
|
@ -4068,6 +4082,7 @@ const api = {
|
|||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}): Promise<RuntimeRpcResponse<unknown>> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:call', args),
|
||||
subscribe: async (
|
||||
|
|
@ -4076,6 +4091,7 @@ const api = {
|
|||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
},
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks
|
|||
import { joinPath } from '@/lib/path'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { setWithLRU } from '@/lib/scroll-cache'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector'
|
||||
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
|
||||
import { formatDiffComments } from '@/lib/diff-comments-format'
|
||||
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
||||
import {
|
||||
|
|
@ -1185,18 +1185,20 @@ export default function CombinedDiffViewer({
|
|||
const content = modifiedEditor?.getValue() ?? section.modifiedContent
|
||||
const absolutePath = joinPath(file.filePath, section.path)
|
||||
try {
|
||||
const connectionId = getConnectionIdForFile(file.worktreeId, absolutePath) ?? undefined
|
||||
const state = useAppStore.getState()
|
||||
const worktree = file.worktreeId
|
||||
? findWorktreeById(state.worktreesByRepo, file.worktreeId)
|
||||
: null
|
||||
await writeRuntimeFile(
|
||||
{
|
||||
settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId),
|
||||
worktreeId: file.worktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId
|
||||
},
|
||||
getEditorFileOperationContext(
|
||||
state,
|
||||
{
|
||||
worktreeId: file.worktreeId,
|
||||
runtimeEnvironmentId: file.runtimeEnvironmentId,
|
||||
operationProvenance: file.operationProvenance
|
||||
},
|
||||
worktree?.path ?? null
|
||||
),
|
||||
absolutePath,
|
||||
content
|
||||
)
|
||||
|
|
@ -1237,7 +1239,7 @@ export default function CombinedDiffViewer({
|
|||
console.error('Save failed:', err)
|
||||
}
|
||||
},
|
||||
[file.filePath, file.runtimeEnvironmentId, file.worktreeId, sections]
|
||||
[file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections]
|
||||
)
|
||||
|
||||
const handleSectionSaveRef = useRef(handleSectionSave)
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
|
|||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'user edit'
|
||||
content: 'user edit',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
|
|
@ -160,7 +162,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
|
|||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'restored draft'
|
||||
content: 'restored draft',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
|
|
@ -179,7 +183,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
|
|||
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'user version'
|
||||
content: 'user version',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
const file = store.getState().openFiles[0]
|
||||
expect(file?.isDirty).toBe(false)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ export function createEditorStore(): StoreApi<AppState> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }]
|
||||
},
|
||||
detectedWorktreesByRepo: {},
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
removedRuntimeEnvironmentIds: new Set(),
|
||||
sshConnectionStates: {},
|
||||
sshStateByEnvironment: {},
|
||||
settings: {
|
||||
editorAutoSave: true,
|
||||
editorAutoSaveDelayMs: 1000
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ function createEditorStore(): StoreApi<AppState> {
|
|||
editorAutoSave: true,
|
||||
editorAutoSaveDelayMs: 1000
|
||||
},
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }]
|
||||
},
|
||||
detectedWorktreesByRepo: {},
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
removedRuntimeEnvironmentIds: new Set(),
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
}
|
||||
|
|
@ -177,7 +187,9 @@ describe('attachEditorAutosaveController', () => {
|
|||
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'edited'
|
||||
content: 'edited',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
|
||||
expect(store.getState().editorDrafts).toEqual({})
|
||||
|
|
@ -204,7 +216,30 @@ describe('attachEditorAutosaveController', () => {
|
|||
|
||||
const store = createEditorStore()
|
||||
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
|
||||
mocks.getConnectionIdForFile.mockReturnValue('ssh-1')
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
'folder-workspace-1': [
|
||||
{
|
||||
id: workspaceKey,
|
||||
repoId: 'folder-workspace-1',
|
||||
path: '/home/neil/platform',
|
||||
hostId: 'ssh:ssh-1'
|
||||
}
|
||||
] as never
|
||||
},
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 4
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
store.getState().openFile({
|
||||
filePath: '/home/neil/platform/api/src/file.ts',
|
||||
relativePath: 'api/src/file.ts',
|
||||
|
|
@ -219,14 +254,13 @@ describe('attachEditorAutosaveController', () => {
|
|||
try {
|
||||
await requestDirtyFileSave()
|
||||
|
||||
expect(mocks.getConnectionIdForFile).toHaveBeenCalledWith(
|
||||
workspaceKey,
|
||||
'/home/neil/platform/api/src/file.ts'
|
||||
)
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/home/neil/platform/api/src/file.ts',
|
||||
content: 'edited',
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
expectedExecutionHostId: 'ssh:ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
|
||||
} finally {
|
||||
|
|
@ -289,7 +323,13 @@ describe('attachEditorAutosaveController', () => {
|
|||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'files.write',
|
||||
params: { worktree: 'id:wt-1', relativePath: 'file.ts', content: 'edited' },
|
||||
expectedEnvironmentPairingRevision: undefined,
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'file.ts',
|
||||
content: 'edited',
|
||||
expectedExecutionHostId: 'local'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
|
|
@ -333,7 +373,9 @@ describe('attachEditorAutosaveController', () => {
|
|||
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.md',
|
||||
content: 'pending rich edit'
|
||||
content: 'pending rich edit',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
|
||||
expect(store.getState().editorDrafts).toEqual({})
|
||||
|
|
@ -568,7 +610,9 @@ describe('attachEditorAutosaveController', () => {
|
|||
await requestEditorFileSave({ fileId: '/repo/file.md' })
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.md',
|
||||
content: 'after save'
|
||||
content: 'after save',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { getConnectionIdForFile } from '@/lib/connection-context'
|
|||
import { shouldPersistWorkspaceSession } from '@/lib/workspace-session'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
|
||||
import {
|
||||
canAutoSaveOpenFile,
|
||||
getOpenFilesForExternalFileChange,
|
||||
|
|
@ -98,11 +98,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
}
|
||||
|
||||
const contentToSave = state.editorDrafts[file.id] ?? fallbackContent
|
||||
const connectionId =
|
||||
getConnectionIdForFile(liveFile.worktreeId, liveFile.filePath) ?? undefined
|
||||
const worktree = liveFile.worktreeId
|
||||
? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId)
|
||||
: null
|
||||
const fileContext = getEditorFileOperationContext(state, liveFile, worktree?.path ?? null)
|
||||
const connectionId = fileContext.connectionId
|
||||
// Why: stamp before writing so useEditorExternalWatch ignores our own fs:changed echo (editor-self-write-registry).
|
||||
recordSelfWrite(
|
||||
liveFile.filePath,
|
||||
|
|
@ -113,16 +113,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
: undefined
|
||||
)
|
||||
try {
|
||||
await writeRuntimeFile(
|
||||
{
|
||||
settings: settingsForRuntimeOwner(state.settings, liveFile.runtimeEnvironmentId),
|
||||
worktreeId: liveFile.worktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId
|
||||
},
|
||||
liveFile.filePath,
|
||||
contentToSave
|
||||
)
|
||||
await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave)
|
||||
} catch (error) {
|
||||
// Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update.
|
||||
clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ vi.mock('@/runtime/runtime-file-client', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: vi.fn(() => 'ssh-1')
|
||||
getConnectionId: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { dirname, basename } from '@/lib/path'
|
|||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
|
||||
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { extractIpcErrorMessage } from './rich-markdown-ipc-error-message'
|
||||
|
|
@ -29,9 +31,30 @@ export async function insertRichMarkdownImageFromPath({
|
|||
canInsert
|
||||
}: RichMarkdownImageInsertArgs): Promise<void> {
|
||||
try {
|
||||
const connectionId = getConnectionId(worktreeId) ?? undefined
|
||||
const settings = settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId)
|
||||
const state = useAppStore.getState()
|
||||
const worktreePath = getWorktreePath(worktreeId)
|
||||
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
|
||||
const resolvedConnectionId = getConnectionId(worktreeId)
|
||||
if (parsedWorkspace?.type === 'folder' && resolvedConnectionId === undefined) {
|
||||
throw new Error("Couldn't verify which host owns this file. Reopen the file and try again.")
|
||||
}
|
||||
const connectionId = resolvedConnectionId ?? undefined
|
||||
const fileContext =
|
||||
worktreeId && parsedWorkspace?.type !== 'folder'
|
||||
? getEditorFileOperationContext(state, { worktreeId, runtimeEnvironmentId }, worktreePath)
|
||||
: {
|
||||
settings: settingsForRuntimeOwner(state.settings, runtimeEnvironmentId),
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
expectedExecutionHostId: connectionId
|
||||
? (`ssh:${encodeURIComponent(connectionId)}` as const)
|
||||
: ('local' as const),
|
||||
...(connectionId
|
||||
? captureDirectSshMutationExpectation(state, connectionId, runtimeEnvironmentId)
|
||||
: {})
|
||||
}
|
||||
const settings = fileContext.settings
|
||||
if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) {
|
||||
toast.error(
|
||||
translate(
|
||||
|
|
@ -45,12 +68,7 @@ export async function insertRichMarkdownImageFromPath({
|
|||
// Why: image bytes should live beside the note instead of inside markdown;
|
||||
// this keeps rich-mode size checks based on document text, not binary data.
|
||||
const { results } = await importExternalPathsToRuntime(
|
||||
{
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
fileContext,
|
||||
[sourcePath],
|
||||
dirname(filePath)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { dirname, joinPath } from '@/lib/path'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { createRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
|
||||
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
|
||||
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
|
||||
|
|
@ -46,16 +45,11 @@ export function useUntitledFileRename({
|
|||
const oldPath = renameDialogFile.filePath
|
||||
const worktreeRoot = getUntitledFileRoot(renameDialogFile)
|
||||
const newPath = joinPath(worktreeRoot, newRelPath)
|
||||
const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined
|
||||
const fileContext = {
|
||||
settings: settingsForRuntimeOwner(
|
||||
useAppStore.getState().settings,
|
||||
renameDialogFile.runtimeEnvironmentId
|
||||
),
|
||||
worktreeId: renameDialogFile.worktreeId,
|
||||
worktreePath: worktreeRoot,
|
||||
connectionId
|
||||
}
|
||||
const fileContext = getEditorFileOperationContext(
|
||||
useAppStore.getState(),
|
||||
renameDialogFile,
|
||||
worktreeRoot
|
||||
)
|
||||
|
||||
if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) {
|
||||
setRenameError('A file with that name already exists')
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function state(overrides: Partial<AppState> = {}): AppState {
|
|||
projectGroups: [],
|
||||
repos: [{ id: 'repo', connectionId: null }],
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
sshConnectionStates: new Map(),
|
||||
tabsByWorktree: {
|
||||
'wt-1': [terminalTab()]
|
||||
},
|
||||
|
|
@ -68,10 +69,20 @@ describe('resolveNativeChatAttachmentOwner', () => {
|
|||
it('resolves an SSH repo worktree to ssh with the worktree path', () => {
|
||||
expect(
|
||||
resolveNativeChatAttachmentOwner(
|
||||
state({ repos: [{ id: 'repo', connectionId: 'conn-1' }] as never }),
|
||||
state({
|
||||
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
|
||||
sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]])
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
).toEqual({ kind: 'ssh', connectionId: 'conn-1', worktreePath: '/repo/worktree' })
|
||||
).toEqual({
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/repo/worktree',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a runtime-owned repo to runtime', () => {
|
||||
|
|
@ -122,7 +133,14 @@ describe('resolveNativeChatAttachmentOwner', () => {
|
|||
})
|
||||
|
||||
describe('uploadNativeChatAttachmentPaths', () => {
|
||||
const owner = { connectionId: 'conn-1', worktreePath: '/remote/worktree' }
|
||||
const owner = {
|
||||
kind: 'ssh' as const,
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/worktree',
|
||||
expectedExecutionHostId: 'ssh:conn-1' as const,
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
@ -143,7 +161,10 @@ describe('uploadNativeChatAttachmentPaths', () => {
|
|||
expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({
|
||||
paths: ['/local/a.txt'],
|
||||
worktreePath: '/remote/worktree',
|
||||
connectionId: 'conn-1'
|
||||
connectionId: 'conn-1',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
expect(mocks.toastLoading).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1')
|
||||
|
|
|
|||
|
|
@ -14,10 +14,20 @@ import {
|
|||
findTerminalTabWorktreeId,
|
||||
resolveNativeChatFileLinkContext
|
||||
} from './native-chat-file-link'
|
||||
import {
|
||||
captureDirectSshMutationExpectation,
|
||||
type DirectSshMutationExpectation
|
||||
} from '@/lib/ssh-mutation-expectation'
|
||||
|
||||
export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & {
|
||||
kind: 'ssh'
|
||||
connectionId: string
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
export type NativeChatAttachmentOwner =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'ssh'; connectionId: string; worktreePath: string }
|
||||
| NativeChatSshAttachmentOwner
|
||||
/** Runtime-owned (`remote:`) panes keep the composer's existing
|
||||
* local-attachment block; runtime upload support is a separate seam. */
|
||||
| { kind: 'runtime' }
|
||||
|
|
@ -33,6 +43,7 @@ type NativeChatAttachmentOwnerState = Pick<
|
|||
| 'projectGroups'
|
||||
| 'repos'
|
||||
| 'settings'
|
||||
| 'sshConnectionStates'
|
||||
| 'tabsByWorktree'
|
||||
| 'worktreesByRepo'
|
||||
>
|
||||
|
|
@ -61,7 +72,12 @@ export function resolveNativeChatAttachmentOwner(
|
|||
if (!worktreePath) {
|
||||
return { kind: 'not-ready' }
|
||||
}
|
||||
return { kind: 'ssh', connectionId, worktreePath }
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId,
|
||||
worktreePath,
|
||||
...captureDirectSshMutationExpectation(state, connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatWorktreeNotReadyNotice(): string {
|
||||
|
|
@ -79,7 +95,7 @@ export function nativeChatWorktreeNotReadyNotice(): string {
|
|||
*/
|
||||
export async function uploadNativeChatAttachmentPaths(
|
||||
paths: string[],
|
||||
owner: { connectionId: string; worktreePath: string }
|
||||
owner: NativeChatSshAttachmentOwner
|
||||
): Promise<string[] | null> {
|
||||
const pending = toast.loading(
|
||||
translate(
|
||||
|
|
@ -92,7 +108,10 @@ export async function uploadNativeChatAttachmentPaths(
|
|||
const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({
|
||||
paths,
|
||||
worktreePath: owner.worktreePath,
|
||||
connectionId: owner.connectionId
|
||||
connectionId: owner.connectionId,
|
||||
expectedExecutionHostId: owner.expectedExecutionHostId,
|
||||
expectedSshTargetId: owner.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration
|
||||
})
|
||||
reportTerminalDropUploadSkipsAndFailures(skipped, failed)
|
||||
return resolvedPaths
|
||||
|
|
|
|||
|
|
@ -121,7 +121,10 @@ function imagePasteEvent(): {
|
|||
const sshOwner: NativeChatAttachmentOwner = {
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt'
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,10 @@ describe('useNativeChatExternalAttachments', () => {
|
|||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt'
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
mocks.uploadNativeChatAttachmentPaths.mockResolvedValue(['/remote/wt/.orca/drops/a.txt'])
|
||||
const attachResolvedPaths = vi.fn()
|
||||
|
|
@ -115,7 +118,10 @@ describe('useNativeChatExternalAttachments', () => {
|
|||
expect(mocks.uploadNativeChatAttachmentPaths).toHaveBeenCalledWith(['/local/a.txt'], {
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt'
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'])
|
||||
})
|
||||
|
|
@ -136,7 +142,10 @@ describe('useNativeChatExternalAttachments', () => {
|
|||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt'
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
})
|
||||
let resolveUpload: (paths: string[]) => void = () => {}
|
||||
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(
|
||||
|
|
|
|||
|
|
@ -300,7 +300,8 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
expanded,
|
||||
toggleDir,
|
||||
refreshDir,
|
||||
scrollRef
|
||||
scrollRef,
|
||||
getOperationOwnerForPath: (path) => rowProjection.getRowByPath(path)?.operationOwner
|
||||
})
|
||||
|
||||
const lastResetWorktreePathRef = useRef<string | null>(null)
|
||||
|
|
@ -393,7 +394,8 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
refreshTree,
|
||||
inlineInput,
|
||||
dragSourcePath,
|
||||
isNativeDragOver
|
||||
isNativeDragOver,
|
||||
operationOwner: rootCache?.operationOwner
|
||||
})
|
||||
|
||||
useFileExplorerImport({
|
||||
|
|
@ -401,7 +403,8 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
activeWorktreeId,
|
||||
refreshDir,
|
||||
clearNativeDragState,
|
||||
setSelectedPath: setSingleSelectedPath
|
||||
setSelectedPath: setSingleSelectedPath,
|
||||
operationOwner: rootCache?.operationOwner
|
||||
})
|
||||
|
||||
const totalCount = visibleRowCount + (inlineInputIndex >= 0 ? 1 : 0)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export type InlineInput = {
|
|||
depth: number
|
||||
existingName?: string
|
||||
existingPath?: string
|
||||
operationOwner?: TreeNode['operationOwner']
|
||||
}
|
||||
|
||||
// ─── Inline Input Row ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getFileExplorerOperationRoute,
|
||||
getFileExplorerOwnerUnresolvedMessage
|
||||
} from './file-explorer-operation-owner'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
|
||||
export function needsRemoteDeleteConfirmation(node: TreeNode): boolean {
|
||||
const owner = node.operationOwner ?? { kind: 'unresolved' as const }
|
||||
return owner.kind !== 'local' && getFileExplorerOperationRoute(owner) !== null
|
||||
}
|
||||
|
||||
export function isLocalDeleteNode(node: TreeNode): boolean {
|
||||
return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local'
|
||||
}
|
||||
|
||||
export function getFileDeleteErrorMessage(error: unknown): string | null {
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
return error.message === getFileExplorerOwnerUnresolvedMessage()
|
||||
? translate(
|
||||
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
|
||||
"Couldn't determine which host owns this file. Check the workspace connection and try again."
|
||||
)
|
||||
: error.message
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
captureFileExplorerOperationGuard,
|
||||
getFileExplorerOperationOwner
|
||||
} from './file-explorer-operation-owner'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
const worktreeId = 'repo-1::/srv/project'
|
||||
|
||||
function worktree(hostId: Worktree['hostId'], runtimeOwnerEnvironmentId?: string): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
repoId: 'repo-1',
|
||||
path: '/srv/project',
|
||||
hostId,
|
||||
runtimeOwnerEnvironmentId
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.getState().setRuntimeEnvironments([])
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
describe('file explorer operation generations', () => {
|
||||
it('invalidates a nested SSH mutation when that target reconnects', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] }
|
||||
})
|
||||
useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', {
|
||||
targetId: 'private-target',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(worktreeId)
|
||||
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
|
||||
|
||||
useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', {
|
||||
targetId: 'private-target',
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
|
||||
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
|
||||
})
|
||||
|
||||
it('invalidates a direct SSH mutation when that target reconnects', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:client-target')] }
|
||||
})
|
||||
useAppStore.getState().setSshConnectionState('client-target', {
|
||||
targetId: 'client-target',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(worktreeId)
|
||||
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
|
||||
|
||||
useAppStore.getState().setSshConnectionState('client-target', {
|
||||
targetId: 'client-target',
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
|
||||
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
|
||||
})
|
||||
|
||||
it('invalidates a folder-workspace mutation when its SSH target reconnects', () => {
|
||||
const folderWorkspaceId = 'folder-1'
|
||||
const folderWorktreeId = folderWorkspaceKey(folderWorkspaceId)
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: folderWorkspaceId,
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'client-target'
|
||||
} as never
|
||||
],
|
||||
projectGroups: [{ id: 'group-1', connectionId: 'client-target' } as never],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
useAppStore.getState().setSshConnectionState('client-target', {
|
||||
targetId: 'client-target',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 1
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(folderWorktreeId)
|
||||
const guard = captureFileExplorerOperationGuard(folderWorktreeId, owner)
|
||||
|
||||
useAppStore.getState().setSshConnectionState('client-target', {
|
||||
targetId: 'client-target',
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 2
|
||||
})
|
||||
|
||||
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
|
||||
})
|
||||
|
||||
it('invalidates a mutation when the saved HUB points at a replacement runtime', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] }
|
||||
})
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', {
|
||||
status: { runtimeId: 'runtime-a' } as never,
|
||||
checkedAt: 1
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(worktreeId)
|
||||
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
|
||||
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', {
|
||||
status: { runtimeId: 'runtime-b' } as never,
|
||||
checkedAt: 2
|
||||
})
|
||||
|
||||
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
|
||||
})
|
||||
|
||||
it('fails closed when nested SSH ownership has no authoritative generation', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] }
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(worktreeId)
|
||||
|
||||
expect(() => captureFileExplorerOperationGuard(worktreeId, owner)).toThrow(
|
||||
"Couldn't determine which host owns"
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidates a mutation when the same saved HUB id is re-paired', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] }
|
||||
})
|
||||
useAppStore
|
||||
.getState()
|
||||
.setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 1 } as never])
|
||||
const owner = getFileExplorerOperationOwner(worktreeId)
|
||||
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
|
||||
|
||||
useAppStore
|
||||
.getState()
|
||||
.setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 2 } as never])
|
||||
|
||||
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
|
|
@ -14,13 +9,26 @@ import {
|
|||
getSettingsForWorktreeRuntimeOwner
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import type { FileExplorerOperationOwner } from './file-explorer-types'
|
||||
import {
|
||||
resolveWorktreeOperationRoute,
|
||||
type WorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
|
||||
|
||||
export type FileExplorerOperationRoute = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
connectionId?: string
|
||||
expectedExecutionHostId?: 'local' | `ssh:${string}`
|
||||
expectedSshTargetId?: string
|
||||
expectedSshConnectionGeneration?: number
|
||||
}
|
||||
|
||||
type FileExplorerOwnerState = Pick<
|
||||
export type FileExplorerOperationGuard = {
|
||||
route: FileExplorerOperationRoute
|
||||
assertCurrent: () => FileExplorerOperationRoute
|
||||
}
|
||||
|
||||
export type FileExplorerOwnerState = Pick<
|
||||
AppState,
|
||||
| 'settings'
|
||||
| 'repos'
|
||||
|
|
@ -37,21 +45,20 @@ export function getFileExplorerOperationOwnerFromState(
|
|||
): FileExplorerOperationOwner {
|
||||
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
|
||||
if (worktreeId && parsedWorkspace?.type !== 'folder') {
|
||||
const exactHostIds = getExactWorktreeHostIds(state, worktreeId)
|
||||
if (exactHostIds.size > 1) {
|
||||
const route = resolveWorktreeOperationRoute(state, worktreeId)
|
||||
if (!route) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
const exactHostId = exactHostIds.values().next().value
|
||||
if (exactHostId) {
|
||||
return operationOwnerFromHostId(exactHostId)
|
||||
if (route.runtimeEnvironmentId) {
|
||||
return {
|
||||
kind: 'runtime',
|
||||
environmentId: route.runtimeEnvironmentId,
|
||||
executionHostId:
|
||||
route.executionHostId ?? `runtime:${encodeURIComponent(route.runtimeEnvironmentId)}`
|
||||
}
|
||||
}
|
||||
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const repoHostIds = new Set(
|
||||
state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)
|
||||
)
|
||||
if (repoHostIds.size > 1) {
|
||||
return { kind: 'unresolved' }
|
||||
if (route.executionHostId) {
|
||||
return operationOwnerFromHostId(route.executionHostId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +77,11 @@ export function getFileExplorerOperationOwnerFromState(
|
|||
? null
|
||||
: settings.activeRuntimeEnvironmentId?.trim()
|
||||
if (runtimeEnvironmentId) {
|
||||
return { kind: 'runtime', environmentId: runtimeEnvironmentId }
|
||||
return {
|
||||
kind: 'runtime',
|
||||
environmentId: runtimeEnvironmentId,
|
||||
executionHostId: `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
|
||||
}
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
return { kind: 'unresolved' }
|
||||
|
|
@ -89,15 +100,142 @@ export function getFileExplorerOperationRoute(
|
|||
): FileExplorerOperationRoute | null {
|
||||
switch (owner.kind) {
|
||||
case 'local':
|
||||
return { settings: { activeRuntimeEnvironmentId: null } }
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
expectedExecutionHostId: 'local'
|
||||
}
|
||||
case 'ssh':
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
connectionId: owner.connectionId
|
||||
connectionId: owner.connectionId,
|
||||
expectedExecutionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`
|
||||
}
|
||||
case 'runtime': {
|
||||
const host = parseExecutionHostId(owner.executionHostId)
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: owner.environmentId },
|
||||
...(host?.kind === 'ssh'
|
||||
? { expectedExecutionHostId: host.id }
|
||||
: { expectedExecutionHostId: 'local' as const })
|
||||
}
|
||||
}
|
||||
case 'unresolved':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function requireFileExplorerOperationRoute(
|
||||
worktreeId: string | null | undefined
|
||||
): FileExplorerOperationRoute {
|
||||
const route = getFileExplorerOperationRoute(getFileExplorerOperationOwner(worktreeId))
|
||||
if (!route) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
export function requireMatchingFileExplorerOperationRoute(
|
||||
worktreeId: string | null | undefined,
|
||||
expectedOwner: FileExplorerOperationOwner | undefined
|
||||
): FileExplorerOperationRoute {
|
||||
if (!expectedOwner || expectedOwner.kind === 'unresolved') {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const currentOwner = getFileExplorerOperationOwner(worktreeId)
|
||||
if (JSON.stringify(currentOwner) !== JSON.stringify(expectedOwner)) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const route = getFileExplorerOperationRoute(expectedOwner)
|
||||
if (!route) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
export function captureFileExplorerOperationGuard(
|
||||
worktreeId: string | null | undefined,
|
||||
expectedOwner: FileExplorerOperationOwner | undefined
|
||||
): FileExplorerOperationGuard {
|
||||
if (!worktreeId) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const route = requireMatchingFileExplorerOperationRoute(worktreeId, expectedOwner)
|
||||
const operationRoute = getFileExplorerGenerationRoute(expectedOwner)
|
||||
if (!operationRoute) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const generationGuard = captureWorktreeOperationGenerationGuard(
|
||||
useAppStore.getState,
|
||||
worktreeId,
|
||||
operationRoute,
|
||||
() => new Error(getFileExplorerOwnerUnresolvedMessage()),
|
||||
() => getFileExplorerGenerationRoute(getFileExplorerOperationOwner(worktreeId))
|
||||
)
|
||||
const expectedSshConnectionGeneration = getExpectedSshConnectionGeneration(
|
||||
useAppStore.getState(),
|
||||
operationRoute
|
||||
)
|
||||
const operationHost = parseExecutionHostId(operationRoute.executionHostId)
|
||||
if (!operationHost) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
if (operationHost?.kind === 'ssh' && expectedSshConnectionGeneration === undefined) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const guardedRoute: FileExplorerOperationRoute = {
|
||||
...route,
|
||||
expectedExecutionHostId: operationHost.kind === 'ssh' ? operationHost.id : 'local',
|
||||
...(operationHost?.kind === 'ssh' ? { expectedSshTargetId: operationHost.targetId } : {}),
|
||||
...(expectedSshConnectionGeneration === undefined ? {} : { expectedSshConnectionGeneration })
|
||||
}
|
||||
return {
|
||||
route: guardedRoute,
|
||||
assertCurrent: () => {
|
||||
generationGuard.assertCurrent()
|
||||
if (
|
||||
getExpectedSshConnectionGeneration(useAppStore.getState(), operationRoute) !==
|
||||
expectedSshConnectionGeneration
|
||||
) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
return guardedRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedSshConnectionGeneration(
|
||||
state: Pick<AppState, 'sshConnectionStates' | 'sshStateByEnvironment'>,
|
||||
route: WorktreeOperationRoute
|
||||
): number | undefined {
|
||||
const host = parseExecutionHostId(route.executionHostId)
|
||||
if (host?.kind !== 'ssh') {
|
||||
return undefined
|
||||
}
|
||||
return route.runtimeEnvironmentId
|
||||
? state.sshStateByEnvironment
|
||||
.get(route.runtimeEnvironmentId)
|
||||
?.connectionStates.get(host.targetId)?.connectionGeneration
|
||||
: state.sshConnectionStates.get(host.targetId)?.connectionGeneration
|
||||
}
|
||||
|
||||
function getFileExplorerGenerationRoute(
|
||||
owner: FileExplorerOperationOwner | undefined
|
||||
): WorktreeOperationRoute | null {
|
||||
switch (owner?.kind) {
|
||||
case 'local':
|
||||
return { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
case 'ssh':
|
||||
return {
|
||||
executionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`,
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
case 'runtime':
|
||||
return { settings: { activeRuntimeEnvironmentId: owner.environmentId } }
|
||||
return {
|
||||
executionHostId: owner.executionHostId,
|
||||
runtimeEnvironmentId: owner.environmentId
|
||||
}
|
||||
case 'unresolved':
|
||||
case undefined:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -109,28 +247,6 @@ export function getFileExplorerOwnerUnresolvedMessage(): string {
|
|||
)
|
||||
}
|
||||
|
||||
function getExactWorktreeHostIds(
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): Set<ExecutionHostId> {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
for (const worktrees of Object.values(state.worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.id === worktreeId && worktree.hostId) {
|
||||
hostIds.add(worktree.hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const result of Object.values(state.detectedWorktreesByRepo)) {
|
||||
for (const worktree of result.worktrees) {
|
||||
if (worktree.id === worktreeId && worktree.hostId) {
|
||||
hostIds.add(worktree.hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hostIds
|
||||
}
|
||||
|
||||
function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
switch (parsed?.kind) {
|
||||
|
|
@ -139,7 +255,7 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio
|
|||
case 'ssh':
|
||||
return { kind: 'ssh', connectionId: parsed.targetId }
|
||||
case 'runtime':
|
||||
return { kind: 'runtime', environmentId: parsed.environmentId }
|
||||
return { kind: 'runtime', environmentId: parsed.environmentId, executionHostId: hostId }
|
||||
case undefined:
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
|
|||
const text = source(path)
|
||||
|
||||
expect(text).toMatch(
|
||||
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/
|
||||
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute|captureFileExplorerOperationGuard|requireFileExplorerOperationRoute|requireMatchingFileExplorerOperationRoute/
|
||||
)
|
||||
expect(text).not.toContain('settings: useAppStore.getState().settings')
|
||||
expect(text).not.toContain('const settings = useAppStore.getState().settings')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export type FileExplorerOperationOwner =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| { kind: 'runtime'; environmentId: string }
|
||||
| { kind: 'runtime'; environmentId: string; executionHostId: ExecutionHostId }
|
||||
| { kind: 'unresolved' }
|
||||
|
||||
export type TreeNode = {
|
||||
|
|
@ -18,4 +20,5 @@ export type TreeNode = {
|
|||
export type DirCache = {
|
||||
children: TreeNode[]
|
||||
loading: boolean
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
|||
import { isPathEqualOrDescendant } from './file-explorer-paths'
|
||||
import { runBatchDeletion, selectDeletionRoots } from './file-explorer-batch-deletion'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { getFileExplorerOperationRoute } from './file-explorer-operation-owner'
|
||||
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
|
||||
import {
|
||||
getFileDeleteErrorMessage,
|
||||
isLocalDeleteNode,
|
||||
needsRemoteDeleteConfirmation
|
||||
} from './file-explorer-delete-classification'
|
||||
import {
|
||||
requestEditorFileSave,
|
||||
requestEditorSaveQuiesce
|
||||
|
|
@ -39,21 +44,6 @@ type UseFileDeletionResult = {
|
|||
requestDeleteAll: (nodes: TreeNode[]) => void
|
||||
}
|
||||
|
||||
// Why: gate the batch prompt on the same condition runDelete uses to actually
|
||||
// show its per-node confirm — a non-local owner with a resolvable route.
|
||||
// Unresolved owners throw before prompting, so a batch of them must not pop a
|
||||
// destructive dialog for deletes that provably cannot proceed.
|
||||
function needsRemoteDeleteConfirmation(node: TreeNode): boolean {
|
||||
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
|
||||
return operationOwner.kind !== 'local' && getFileExplorerOperationRoute(operationOwner) !== null
|
||||
}
|
||||
|
||||
// Why: local deletes go to the OS Trash/Recycle Bin and stay recoverable, so a
|
||||
// mixed batch must not describe every item as a permanent remote delete.
|
||||
function isLocalDeleteNode(node: TreeNode): boolean {
|
||||
return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local'
|
||||
}
|
||||
|
||||
export function useFileDeletion({
|
||||
activeWorktreeId,
|
||||
openFiles,
|
||||
|
|
@ -64,10 +54,6 @@ export function useFileDeletion({
|
|||
}: UseFileDeletionParams): UseFileDeletionResult {
|
||||
const confirm = useConfirmationDialog()
|
||||
const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete')
|
||||
const unresolvedDeleteOwnerError = translate(
|
||||
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
|
||||
"Couldn't determine which host owns this file. Check the workspace connection and try again."
|
||||
)
|
||||
// Why: track in-flight deletes per-path so repeated Del presses on the same
|
||||
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
|
||||
const inFlightRef = useRef<Set<string>>(new Set())
|
||||
|
|
@ -80,27 +66,16 @@ export function useFileDeletion({
|
|||
inFlightRef.current.add(node.path)
|
||||
|
||||
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
|
||||
const operationRoute = getFileExplorerOperationRoute(operationOwner)
|
||||
// Why: treat every non-local owner (ssh, runtime, unresolved) as remote
|
||||
// for confirm/error copy, then fail closed below when the route is null
|
||||
// so an unresolved owner never reaches local filesystem authorization.
|
||||
const isRemote = operationOwner.kind !== 'local'
|
||||
|
||||
try {
|
||||
if (!operationRoute) {
|
||||
throw new Error(unresolvedDeleteOwnerError)
|
||||
}
|
||||
// Why: cached nodes can outlive host hydration changes; preserve the
|
||||
// listing-time owner so deletion cannot jump to a same-path file elsewhere.
|
||||
const state = useAppStore.getState()
|
||||
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
|
||||
const connectionId = operationRoute.connectionId
|
||||
const fileContext = {
|
||||
settings: operationRoute.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId
|
||||
}
|
||||
const operationGuard = captureFileExplorerOperationGuard(
|
||||
activeWorktreeId,
|
||||
node.operationOwner
|
||||
)
|
||||
// Why: remote deletes bypass OS Trash, and undo cannot recover
|
||||
// directories or unreadable files. Batch deletes confirm once up
|
||||
// front instead, so they skip the per-node prompt.
|
||||
|
|
@ -146,6 +121,20 @@ export function useFileDeletion({
|
|||
// writes cannot recreate the file after it's been trashed.
|
||||
await Promise.all(filesToClose.map((file) => requestEditorSaveQuiesce({ fileId: file.id })))
|
||||
|
||||
// Why: confirmation and autosave can outlive a reconnect or graph replacement; mutations require the owner generation that produced the row.
|
||||
const operationRoute = operationGuard.assertCurrent()
|
||||
const state = useAppStore.getState()
|
||||
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
|
||||
const fileContext = {
|
||||
settings: operationRoute.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId: operationRoute.connectionId,
|
||||
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
|
||||
expectedSshTargetId: operationRoute.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
|
||||
}
|
||||
|
||||
const parentDir = dirname(node.path)
|
||||
// Why: read file content before deleting so undo can restore it.
|
||||
// We capture content first but only commit the undo entry after the
|
||||
|
|
@ -158,7 +147,7 @@ export function useFileDeletion({
|
|||
filePath: node.path,
|
||||
relativePath: node.relativePath,
|
||||
worktreeId: activeWorktreeId ?? undefined,
|
||||
connectionId
|
||||
connectionId: operationRoute.connectionId
|
||||
})
|
||||
if (!rf.isBinary) {
|
||||
undoContent = rf.content
|
||||
|
|
@ -169,16 +158,35 @@ export function useFileDeletion({
|
|||
}
|
||||
}
|
||||
|
||||
operationGuard.assertCurrent()
|
||||
await deleteRuntimePath(fileContext, node.path, node.isDirectory)
|
||||
|
||||
if (undoContent !== undefined) {
|
||||
commitFileExplorerOp({
|
||||
undo: async () => {
|
||||
await writeRuntimeFile(fileContext, node.path, undoContent)
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await writeRuntimeFile(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
node.path,
|
||||
undoContent
|
||||
)
|
||||
await refreshDir(parentDir)
|
||||
},
|
||||
redo: async () => {
|
||||
await deleteRuntimePath(fileContext, node.path, node.isDirectory)
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await deleteRuntimePath(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
node.path,
|
||||
node.isDirectory
|
||||
)
|
||||
await refreshDir(parentDir)
|
||||
}
|
||||
})
|
||||
|
|
@ -218,9 +226,10 @@ export function useFileDeletion({
|
|||
return true
|
||||
} catch (error) {
|
||||
const action = isRemote ? 'delete' : isWindows ? 'move to Recycle Bin' : 'move to Trash'
|
||||
const errorMessage = getFileDeleteErrorMessage(error)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
errorMessage
|
||||
? errorMessage
|
||||
: translate(
|
||||
'auto.components.right.sidebar.useFileDeletion.72691dfebc',
|
||||
"Failed to {{value0}} '{{value1}}'.",
|
||||
|
|
@ -232,15 +241,7 @@ export function useFileDeletion({
|
|||
inFlightRef.current.delete(node.path)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeWorktreeId,
|
||||
closeFile,
|
||||
confirm,
|
||||
isWindows,
|
||||
openFiles,
|
||||
refreshDir,
|
||||
unresolvedDeleteOwnerError
|
||||
]
|
||||
[activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir]
|
||||
)
|
||||
|
||||
const requestDelete = useCallback(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { toast } from 'sonner'
|
|||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
|
||||
|
||||
/**
|
||||
* Electron's ipcRenderer.invoke wraps errors as:
|
||||
|
|
@ -42,11 +41,21 @@ export function useFileDuplicate({
|
|||
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
let operationGuard
|
||||
try {
|
||||
operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, node.operationOwner)
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, `Failed to duplicate '${name}'.`))
|
||||
return
|
||||
}
|
||||
const context = {
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
settings: operationGuard.route.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: getConnectionId(activeWorktreeId) ?? undefined
|
||||
connectionId: operationGuard.route.connectionId,
|
||||
expectedExecutionHostId: operationGuard.route.expectedExecutionHostId,
|
||||
expectedSshTargetId: operationGuard.route.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: operationGuard.route.expectedSshConnectionGeneration
|
||||
}
|
||||
// Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name
|
||||
// so we never collide with an existing file. pathExists checks are
|
||||
|
|
@ -69,6 +78,7 @@ export function useFileDuplicate({
|
|||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
operationGuard.assertCurrent()
|
||||
await copyRuntimePath(context, node.path, candidate)
|
||||
break
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import {
|
||||
getWorkspaceFileDragRejectionMessage,
|
||||
readWorkspaceFileDragPaths,
|
||||
|
|
@ -14,7 +13,8 @@ import {
|
|||
} from '@/lib/workspace-file-drag'
|
||||
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
|
||||
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import type { FileExplorerOperationOwner } from './file-explorer-types'
|
||||
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
|
||||
|
||||
function extractIpcErrorMessage(err: unknown, fallback: string): string {
|
||||
if (!(err instanceof Error)) {
|
||||
|
|
@ -32,6 +32,7 @@ type UseFileExplorerDragDropParams = {
|
|||
refreshDir: (dirPath: string) => Promise<void>
|
||||
// Explorer scroll viewport used to auto-scroll while dragging near top/bottom edges
|
||||
scrollRef: RefObject<HTMLDivElement | null>
|
||||
getOperationOwnerForPath: (path: string) => FileExplorerOperationOwner | undefined
|
||||
}
|
||||
|
||||
type UseFileExplorerDragDropResult = {
|
||||
|
|
@ -101,7 +102,8 @@ export function useFileExplorerDragDrop({
|
|||
expanded,
|
||||
toggleDir,
|
||||
refreshDir,
|
||||
scrollRef
|
||||
scrollRef,
|
||||
getOperationOwnerForPath
|
||||
}: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult {
|
||||
const [isRootDragOver, setIsRootDragOver] = useState(false)
|
||||
const rootDragCounterRef = useRef(0)
|
||||
|
|
@ -207,18 +209,22 @@ export function useFileExplorerDragDrop({
|
|||
}
|
||||
|
||||
const newPath = joinPath(destDir, fileName)
|
||||
const operationOwner = getOperationOwnerForPath(sourcePath)
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
const fileContext = {
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
}
|
||||
// The coordinator quiesces saves, retargets the open sessions in place,
|
||||
// and settles the move as one transaction (was: quiesce + rename + remap).
|
||||
try {
|
||||
const operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, operationOwner)
|
||||
const operationRoute = operationGuard.route
|
||||
const fileContext = {
|
||||
settings: operationRoute.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: operationRoute.connectionId,
|
||||
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
|
||||
expectedSshTargetId: operationRoute.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
|
||||
}
|
||||
operationGuard.assertCurrent()
|
||||
await executeOpenEditorPathMove({
|
||||
context: fileContext,
|
||||
fromPath: sourcePath,
|
||||
|
|
@ -228,6 +234,7 @@ export function useFileExplorerDragDrop({
|
|||
})
|
||||
commitFileExplorerOp({
|
||||
undo: async () => {
|
||||
operationGuard.assertCurrent()
|
||||
await executeOpenEditorPathMove({
|
||||
context: fileContext,
|
||||
fromPath: newPath,
|
||||
|
|
@ -238,6 +245,7 @@ export function useFileExplorerDragDrop({
|
|||
await Promise.all([refreshDir(destDir), refreshDir(sourceDir)])
|
||||
},
|
||||
redo: async () => {
|
||||
operationGuard.assertCurrent()
|
||||
await executeOpenEditorPathMove({
|
||||
context: fileContext,
|
||||
fromPath: sourcePath,
|
||||
|
|
@ -256,7 +264,7 @@ export function useFileExplorerDragDrop({
|
|||
}
|
||||
void run()
|
||||
},
|
||||
[worktreePath, activeWorktreeId, refreshDir]
|
||||
[worktreePath, activeWorktreeId, refreshDir, getOperationOwnerForPath]
|
||||
)
|
||||
|
||||
const clearNativeDragState = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { activateFileExplorerNode } from './useFileExplorerHandlers'
|
||||
|
||||
|
|
@ -16,7 +17,12 @@ describe('activateFileExplorerNode', () => {
|
|||
relativePath: 'linked-docs',
|
||||
isDirectory: false,
|
||||
isSymlink: true,
|
||||
depth: 0
|
||||
depth: 0,
|
||||
operationOwner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'runtime-env-1',
|
||||
executionHostId: 'runtime:runtime-env-1'
|
||||
}
|
||||
}
|
||||
|
||||
it('selects filtered folders without mutating persisted expansion', async () => {
|
||||
|
|
@ -68,6 +74,18 @@ describe('activateFileExplorerNode', () => {
|
|||
|
||||
it('falls back to opening a symlink as a file when directory loading fails', async () => {
|
||||
const openFile = vi.fn()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
hostId: 'runtime:runtime-env-1'
|
||||
} as never
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await activateFileExplorerNode({
|
||||
node: symlinkNode,
|
||||
|
|
@ -100,9 +118,15 @@ describe('activateFileExplorerNode', () => {
|
|||
path: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
isDirectory: false,
|
||||
depth: 0
|
||||
depth: 0,
|
||||
operationOwner: { kind: 'local' }
|
||||
}
|
||||
const openFile = vi.fn()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
|
||||
}
|
||||
})
|
||||
|
||||
await activateFileExplorerNode({
|
||||
node: fileNode,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { toast } from 'sonner'
|
|||
import type { TreeNode } from './file-explorer-types'
|
||||
import { FILE_EXPLORER_DRAGGABLE_SELECTOR } from './file-explorer-drag-scroll-marker'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getFileExplorerOwnerUnresolvedMessage,
|
||||
requireMatchingFileExplorerOperationRoute
|
||||
} from './file-explorer-operation-owner'
|
||||
|
||||
type UseFileExplorerHandlersParams = {
|
||||
activeWorktreeId: string | null
|
||||
|
|
@ -59,7 +63,6 @@ export async function activateFileExplorerNode(args: {
|
|||
const {
|
||||
node,
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId,
|
||||
openFile,
|
||||
toggleDir,
|
||||
canToggleDirectories = true,
|
||||
|
|
@ -115,12 +118,20 @@ export async function activateFileExplorerNode(args: {
|
|||
return
|
||||
}
|
||||
}
|
||||
let fileRuntimeEnvironmentId: string | null
|
||||
try {
|
||||
const route = requireMatchingFileExplorerOperationRoute(activeWorktreeId, node.operationOwner)
|
||||
fileRuntimeEnvironmentId = route.settings.activeRuntimeEnvironmentId?.trim() || null
|
||||
} catch {
|
||||
toast.error(getFileExplorerOwnerUnresolvedMessage())
|
||||
return
|
||||
}
|
||||
openFile(
|
||||
{
|
||||
filePath: node.path,
|
||||
relativePath: node.relativePath,
|
||||
worktreeId: activeWorktreeId,
|
||||
runtimeEnvironmentId: runtimeEnvironmentId ?? undefined,
|
||||
runtimeEnvironmentId: fileRuntimeEnvironmentId ?? undefined,
|
||||
language: detectLanguage(node.name),
|
||||
mode: 'edit'
|
||||
},
|
||||
|
|
@ -128,7 +139,7 @@ export async function activateFileExplorerNode(args: {
|
|||
preview: true,
|
||||
// Why: explicit local opens must not inherit the active runtime, so we
|
||||
// encode "no runtime owner" via the fallback-suppression option.
|
||||
suppressActiveRuntimeFallback: runtimeEnvironmentId === null
|
||||
suppressActiveRuntimeFallback: fileRuntimeEnvironmentId === null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import type { FileExplorerOperationOwner } from './file-explorer-types'
|
||||
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
|
||||
|
||||
type UseFileExplorerImportParams = {
|
||||
worktreePath: string | null
|
||||
|
|
@ -12,6 +12,7 @@ type UseFileExplorerImportParams = {
|
|||
refreshDir: (dirPath: string) => Promise<void>
|
||||
clearNativeDragState: () => void
|
||||
setSelectedPath: (path: string | null) => void
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,7 +29,8 @@ export function useFileExplorerImport({
|
|||
activeWorktreeId,
|
||||
refreshDir,
|
||||
clearNativeDragState,
|
||||
setSelectedPath
|
||||
setSelectedPath,
|
||||
operationOwner
|
||||
}: UseFileExplorerImportParams): void {
|
||||
// Refs to avoid re-subscribing IPC listener on every render
|
||||
const worktreePathRef = useRef(worktreePath)
|
||||
|
|
@ -41,6 +43,8 @@ export function useFileExplorerImport({
|
|||
clearNativeDragStateRef.current = clearNativeDragState
|
||||
const setSelectedPathRef = useRef(setSelectedPath)
|
||||
setSelectedPathRef.current = setSelectedPath
|
||||
const operationOwnerRef = useRef(operationOwner)
|
||||
operationOwnerRef.current = operationOwner
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.ui.onFileDrop((data) => {
|
||||
|
|
@ -59,19 +63,24 @@ export function useFileExplorerImport({
|
|||
}
|
||||
|
||||
const { paths, destinationDir } = data
|
||||
const connectionId = getConnectionId(wtId) ?? undefined
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const operationGuard = captureFileExplorerOperationGuard(wtId, operationOwnerRef.current)
|
||||
operationGuard.assertCurrent()
|
||||
const { results } = await importExternalPathsToRuntime(
|
||||
{
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(wtId),
|
||||
settings: operationGuard.route.settings,
|
||||
worktreeId: wtId,
|
||||
worktreePath: worktreePathRef.current,
|
||||
connectionId
|
||||
connectionId: operationGuard.route.connectionId,
|
||||
expectedExecutionHostId: operationGuard.route.expectedExecutionHostId,
|
||||
expectedSshTargetId: operationGuard.route.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: operationGuard.route.expectedSshConnectionGeneration
|
||||
},
|
||||
paths,
|
||||
destinationDir
|
||||
destinationDir,
|
||||
{ assertCurrent: operationGuard.assertCurrent }
|
||||
)
|
||||
|
||||
// Refresh the destination directory once per gesture
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ import { toast } from 'sonner'
|
|||
import { useAppStore } from '@/store'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { dirname, joinPath } from '@/lib/path'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file'
|
||||
import type { InlineInput } from './FileExplorerRow'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
|
||||
import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import {
|
||||
captureFileExplorerOperationGuard,
|
||||
getFileExplorerOperationOwner
|
||||
} from './file-explorer-operation-owner'
|
||||
|
||||
type UseFileExplorerInlineInputParams = {
|
||||
activeWorktreeId: string | null
|
||||
|
|
@ -74,7 +76,12 @@ export function useFileExplorerInlineInput({
|
|||
if (activeWorktreeId && parentPath !== worktreePath && !expanded.has(parentPath)) {
|
||||
toggleDir(activeWorktreeId, parentPath)
|
||||
}
|
||||
setInlineInput({ parentPath, type, depth })
|
||||
setInlineInput({
|
||||
parentPath,
|
||||
type,
|
||||
depth,
|
||||
operationOwner: getFileExplorerOperationOwner(activeWorktreeId)
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, worktreePath, expanded, toggleDir]
|
||||
)
|
||||
|
|
@ -86,7 +93,8 @@ export function useFileExplorerInlineInput({
|
|||
type: 'rename',
|
||||
depth: node.depth,
|
||||
existingName: node.name,
|
||||
existingPath: node.path
|
||||
existingPath: node.path,
|
||||
operationOwner: node.operationOwner
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
|
@ -109,24 +117,33 @@ export function useFileExplorerInlineInput({
|
|||
return
|
||||
}
|
||||
const run = async (): Promise<void> => {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
const fileContext = {
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
}
|
||||
if (inlineInput.type === 'rename' && inlineInput.existingPath) {
|
||||
await renameFileOnDisk({
|
||||
oldPath: inlineInput.existingPath,
|
||||
newName: name,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
operationOwner: inlineInput.operationOwner,
|
||||
refreshDir
|
||||
})
|
||||
} else {
|
||||
const fullPath = joinPath(inlineInput.parentPath, name)
|
||||
try {
|
||||
const operationGuard = captureFileExplorerOperationGuard(
|
||||
activeWorktreeId,
|
||||
inlineInput.operationOwner
|
||||
)
|
||||
const operationRoute = operationGuard.route
|
||||
const fileContext = {
|
||||
settings: operationRoute.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: operationRoute.connectionId,
|
||||
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
|
||||
expectedSshTargetId: operationRoute.expectedSshTargetId,
|
||||
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
|
||||
}
|
||||
operationGuard.assertCurrent()
|
||||
await createRuntimePath(
|
||||
fileContext,
|
||||
fullPath,
|
||||
|
|
@ -136,22 +153,57 @@ export function useFileExplorerInlineInput({
|
|||
if (inlineInput.type === 'folder') {
|
||||
commitFileExplorerOp({
|
||||
undo: async () => {
|
||||
await deleteRuntimePath(fileContext, fullPath, true)
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await deleteRuntimePath(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
fullPath,
|
||||
true
|
||||
)
|
||||
await refreshDir(parentForRefresh)
|
||||
},
|
||||
redo: async () => {
|
||||
await createRuntimePath(fileContext, fullPath, 'directory')
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await createRuntimePath(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
fullPath,
|
||||
'directory'
|
||||
)
|
||||
await refreshDir(parentForRefresh)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
commitFileExplorerOp({
|
||||
undo: async () => {
|
||||
await deleteRuntimePath(fileContext, fullPath)
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await deleteRuntimePath(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
fullPath
|
||||
)
|
||||
await refreshDir(parentForRefresh)
|
||||
},
|
||||
redo: async () => {
|
||||
await createRuntimePath(fileContext, fullPath, 'file')
|
||||
const currentRoute = operationGuard.assertCurrent()
|
||||
await createRuntimePath(
|
||||
{
|
||||
...fileContext,
|
||||
settings: currentRoute.settings,
|
||||
connectionId: currentRoute.connectionId
|
||||
},
|
||||
fullPath,
|
||||
'file'
|
||||
)
|
||||
await refreshDir(parentForRefresh)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ export async function refreshFileExplorerExpandedDirs({
|
|||
worktreePath,
|
||||
listing.operationOwner
|
||||
),
|
||||
loading: false
|
||||
loading: false,
|
||||
operationOwner: listing.operationOwner
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -184,7 +185,10 @@ export function useFileExplorerTree(
|
|||
worktreePath,
|
||||
listing.operationOwner
|
||||
)
|
||||
setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } }))
|
||||
setDirCache((prev) => ({
|
||||
...prev,
|
||||
[dirPath]: { children, loading: false, operationOwner: listing.operationOwner }
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!dirLoadTrackerRef.current.isCurrent(loadToken)) {
|
||||
|
|
|
|||
|
|
@ -202,4 +202,21 @@ describe('getFileExplorerWatchRuntimeEnvironmentId', () => {
|
|||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('disables a cached watch when its listing owner no longer matches', () => {
|
||||
expect(
|
||||
getFileExplorerWatchRuntimeEnvironmentId(
|
||||
makeState({
|
||||
activeRuntimeEnvironmentId: 'focused-runtime',
|
||||
executionHostId: 'runtime:owner-runtime'
|
||||
}),
|
||||
'wt-1',
|
||||
{
|
||||
kind: 'runtime',
|
||||
environmentId: 'old-owner-runtime',
|
||||
executionHostId: 'runtime:old-owner-runtime'
|
||||
}
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { FsChangedPayload } from '../../../../shared/types'
|
||||
import type { DirCache } from './file-explorer-types'
|
||||
import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types'
|
||||
import type { InlineInput } from './FileExplorerRow'
|
||||
import { joinPath, normalizeRelativePath, dirname } from '@/lib/path'
|
||||
import {
|
||||
|
|
@ -16,8 +15,16 @@ import {
|
|||
} from './file-explorer-watcher-reconcile'
|
||||
import { useAppStore } from '@/store'
|
||||
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
getFileExplorerOperationOwnerFromState,
|
||||
type FileExplorerOwnerState
|
||||
} from './file-explorer-operation-owner'
|
||||
|
||||
type FileExplorerWatchOwnerState = Pick<
|
||||
FileExplorerOwnerState,
|
||||
'settings' | 'repos' | 'worktreesByRepo'
|
||||
> &
|
||||
Partial<Omit<FileExplorerOwnerState, 'settings' | 'repos' | 'worktreesByRepo'>>
|
||||
|
||||
type UseFileExplorerWatchParams = {
|
||||
worktreePath: string | null
|
||||
|
|
@ -31,6 +38,7 @@ type UseFileExplorerWatchParams = {
|
|||
inlineInput: InlineInput | null
|
||||
dragSourcePath: string | null
|
||||
isNativeDragOver: boolean
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
export function getExternalFileChangeRelativePath(
|
||||
|
|
@ -65,10 +73,7 @@ export function canonicalizeFileExplorerWatchPath(
|
|||
}
|
||||
|
||||
function normalizeExplorerAbsolutePath(path: string): string {
|
||||
if (path === '/' || /^[A-Za-z]:[\\/]$/.test(path)) {
|
||||
return path
|
||||
}
|
||||
return path.replace(/[\\/]+$/, '')
|
||||
return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
|
||||
}
|
||||
|
||||
export function payloadRequiresDeferredTreeRefresh(
|
||||
|
|
@ -86,10 +91,29 @@ export function payloadRequiresDeferredTreeRefresh(
|
|||
}
|
||||
|
||||
export function getFileExplorerWatchRuntimeEnvironmentId(
|
||||
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
|
||||
activeWorktreeId: string | null
|
||||
): string | null {
|
||||
return getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)
|
||||
state: FileExplorerWatchOwnerState,
|
||||
activeWorktreeId: string | null,
|
||||
expectedOwner?: FileExplorerOperationOwner
|
||||
): string | null | undefined {
|
||||
const ownerState: FileExplorerOwnerState = {
|
||||
settings: state.settings,
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo,
|
||||
detectedWorktreesByRepo: state.detectedWorktreesByRepo ?? {},
|
||||
folderWorkspaces: state.folderWorkspaces ?? [],
|
||||
projectGroups: state.projectGroups ?? [],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey:
|
||||
state.restoredRuntimeHostIdByWorkspaceSessionKey ?? {}
|
||||
}
|
||||
const owner = getFileExplorerOperationOwnerFromState(ownerState, activeWorktreeId)
|
||||
if (expectedOwner && JSON.stringify(owner) !== JSON.stringify(expectedOwner)) {
|
||||
return undefined
|
||||
}
|
||||
return owner.kind === 'runtime'
|
||||
? owner.environmentId
|
||||
: owner.kind === 'unresolved'
|
||||
? undefined
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -108,11 +132,12 @@ export function useFileExplorerWatch({
|
|||
refreshTree,
|
||||
inlineInput,
|
||||
dragSourcePath,
|
||||
isNativeDragOver
|
||||
isNativeDragOver,
|
||||
operationOwner
|
||||
}: UseFileExplorerWatchParams): void {
|
||||
// Why: subscriptions follow the selected worktree; host focus is only a legacy default, not an ownership signal.
|
||||
const activeRuntimeEnvironmentId = useAppStore((s) =>
|
||||
getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId)
|
||||
getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId, operationOwner)
|
||||
)
|
||||
|
||||
// Keep refs for handler-accessed values so the IPC listener isn't re-subscribed on every render.
|
||||
|
|
@ -149,7 +174,7 @@ export function useFileExplorerWatch({
|
|||
|
||||
// Why: one atomic effect avoids a cleanup-ordering race that drops events on rapid worktree switches (review issue §3).
|
||||
useEffect(() => {
|
||||
if (!worktreePath) {
|
||||
if (!worktreePath || activeRuntimeEnvironmentId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -258,7 +258,10 @@ describe('useGitStatusPolling rerender stability', () => {
|
|||
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-2' } as AppState['settings']
|
||||
worktreesByRepo: {
|
||||
[REPO_ID]: [{ ...worktree, hostId: 'runtime:env-2' }],
|
||||
[REPO_ID2]: [worktree2]
|
||||
}
|
||||
})
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFil
|
|||
import { McpMissingConfigList } from './McpMissingConfigList'
|
||||
import { loadMcpConfigInspections } from './mcp-config-inspection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
|
||||
|
||||
type McpConfigSectionProps = {
|
||||
repo: Repo
|
||||
|
|
@ -192,9 +193,17 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
|
||||
const target = joinPath(targetRootPath, '.mcp.json')
|
||||
try {
|
||||
const sshExpectation = connectionId
|
||||
? captureDirectSshMutationExpectation(useAppStore.getState(), connectionId)
|
||||
: {}
|
||||
// Why: v1 only creates the root workspace config so we do not need to
|
||||
// guess per-agent directory layouts or mutate agent-specific files.
|
||||
await window.api.fs.writeFile({ filePath: target, content: MCP_STARTER_CONFIG, connectionId })
|
||||
await window.api.fs.writeFile({
|
||||
filePath: target,
|
||||
content: MCP_STARTER_CONFIG,
|
||||
connectionId,
|
||||
...sshExpectation
|
||||
})
|
||||
clearCreateConfirmResetTimer()
|
||||
if (mountedRef.current) {
|
||||
setCreateConfirm(false)
|
||||
|
|
|
|||
|
|
@ -69,11 +69,12 @@ afterEach(() => {
|
|||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
function renderSection(repo: Repo): void {
|
||||
function renderSection(repo: Repo, selectedProjectSetupId?: string): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(RepositoryHostSetupsSection, {
|
||||
repo,
|
||||
selectedProjectSetupId,
|
||||
forceVisible: true,
|
||||
searchQuery: '',
|
||||
searchEntries: []
|
||||
|
|
@ -201,12 +202,142 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
// The single project pane switches host in place — no navigation.
|
||||
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
|
||||
'github:stablyai/orca',
|
||||
toSshExecutionHostId('openclaw 2')
|
||||
toSshExecutionHostId('openclaw 2'),
|
||||
'remote-repo'
|
||||
)
|
||||
expect(openSettingsPage).not.toHaveBeenCalled()
|
||||
expect(openSettingsTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps nested SSH setups distinct and derives readiness from their HUB owner', () => {
|
||||
const remoteRepo = makeRepo({
|
||||
id: 'remote-repo',
|
||||
displayName: 'Orca',
|
||||
path: '/srv/orca',
|
||||
executionHostId: 'runtime:hub'
|
||||
})
|
||||
useAppStore.setState({
|
||||
repos: [remoteRepo],
|
||||
projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })],
|
||||
projectHostSetups: [
|
||||
makeSetup({
|
||||
id: 'direct-setup',
|
||||
projectId: 'github:stablyai/orca',
|
||||
repoId: 'remote-repo',
|
||||
hostId: 'runtime:hub',
|
||||
executionHostId: 'ssh:direct',
|
||||
runtimeOwnerEnvironmentId: 'hub',
|
||||
path: '/srv/orca'
|
||||
}),
|
||||
makeSetup({
|
||||
id: 'jump-setup',
|
||||
projectId: 'github:stablyai/orca',
|
||||
repoId: 'remote-repo',
|
||||
hostId: 'runtime:hub',
|
||||
executionHostId: 'ssh:jump',
|
||||
runtimeOwnerEnvironmentId: 'hub',
|
||||
path: '/srv/orca'
|
||||
})
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'hub',
|
||||
{
|
||||
checkedAt: 1,
|
||||
appVersion: '1.8.0',
|
||||
status: {
|
||||
runtimeId: 'runtime-hub',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: 1,
|
||||
capabilities: []
|
||||
}
|
||||
}
|
||||
]
|
||||
]),
|
||||
sshStateByEnvironment: new Map([
|
||||
[
|
||||
'hub',
|
||||
{
|
||||
connectionStates: new Map([
|
||||
[
|
||||
'direct',
|
||||
{
|
||||
targetId: 'direct',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
],
|
||||
[
|
||||
'jump',
|
||||
{
|
||||
targetId: 'jump',
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
]
|
||||
]),
|
||||
targetLabels: new Map([
|
||||
['direct', 'Direct box'],
|
||||
['jump', 'Jump box']
|
||||
]),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
renderSection(remoteRepo, 'jump-setup')
|
||||
|
||||
expect(container.textContent).toContain('Direct box')
|
||||
expect(container.textContent).toContain('Jump box')
|
||||
expect(container.textContent).toContain('Ready')
|
||||
expect(container.textContent).toContain('Disconnected')
|
||||
expect(findButton('Open')).toBeTruthy()
|
||||
const currentSetup = container.querySelector('[data-current="true"]')
|
||||
expect(currentSetup?.textContent).toContain('Jump box')
|
||||
expect(currentSetup?.textContent).toContain('Disconnected')
|
||||
expect(currentSetup?.textContent).not.toContain('Direct box')
|
||||
expect(currentSetup?.textContent).not.toContain('Ready')
|
||||
})
|
||||
|
||||
it('shows HUB-local setups as disconnected when their owning runtime is unreachable', () => {
|
||||
const remoteRepo = makeRepo({
|
||||
id: 'remote-repo',
|
||||
displayName: 'Orca',
|
||||
path: '/srv/orca',
|
||||
executionHostId: 'runtime:hub'
|
||||
})
|
||||
useAppStore.setState({
|
||||
repos: [remoteRepo],
|
||||
projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })],
|
||||
projectHostSetups: [
|
||||
makeSetup({
|
||||
id: 'hub-local-setup',
|
||||
projectId: 'github:stablyai/orca',
|
||||
repoId: 'remote-repo',
|
||||
hostId: 'runtime:hub',
|
||||
executionHostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'hub',
|
||||
path: '/srv/orca'
|
||||
})
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, status: null }]])
|
||||
})
|
||||
|
||||
renderSection(remoteRepo)
|
||||
|
||||
expect(container.textContent).toContain('Disconnected')
|
||||
expect(container.textContent).not.toContain('Ready')
|
||||
})
|
||||
|
||||
it('removes independent setup metadata instead of opening an empty repo target', async () => {
|
||||
const deleteProjectHostSetup = vi.fn().mockResolvedValue({
|
||||
project: makeProject({ id: 'github:stablyai/orca' }),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
|
@ -20,16 +20,40 @@ import type { SettingsSearchEntry } from './settings-search'
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options'
|
||||
import { RepositoryHostSetupActions } from './RepositoryHostSetupActions'
|
||||
import {
|
||||
selectRuntimeAwareSshStatus,
|
||||
selectRuntimeAwareSshTargetLabel
|
||||
} from '@/store/slices/runtime-environment-ssh'
|
||||
|
||||
type RepositoryHostSetupsSectionProps = {
|
||||
repo: Repo
|
||||
selectedProjectSetupId?: string
|
||||
forceVisible: boolean
|
||||
searchQuery: string
|
||||
searchEntries: SettingsSearchEntry[]
|
||||
}
|
||||
|
||||
function setupsByOwnedExecutionHost(
|
||||
setups: readonly ProjectHostSetup[],
|
||||
selectedSetupId: string
|
||||
): ProjectHostSetup[] {
|
||||
const byHost = new Map<string, ProjectHostSetup>()
|
||||
for (const setup of setups) {
|
||||
const key = JSON.stringify([
|
||||
setup.hostId,
|
||||
setup.executionHostId ?? setup.hostId,
|
||||
setup.runtimeOwnerEnvironmentId ?? null
|
||||
])
|
||||
if (!byHost.has(key) || setup.id === selectedSetupId) {
|
||||
byHost.set(key, setup)
|
||||
}
|
||||
}
|
||||
return [...byHost.values()]
|
||||
}
|
||||
|
||||
export function RepositoryHostSetupsSection({
|
||||
repo,
|
||||
selectedProjectSetupId,
|
||||
forceVisible,
|
||||
searchQuery,
|
||||
searchEntries
|
||||
|
|
@ -47,6 +71,9 @@ export function RepositoryHostSetupsSection({
|
|||
const settings = useAppStore((state) => state.settings)
|
||||
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId)
|
||||
const sshStateByEnvironment = useAppStore((state) => state.sshStateByEnvironment)
|
||||
const removedSshTargetLabels = useAppStore((state) => state.removedSshTargetLabels)
|
||||
const sshTargetsHydrated = useAppStore((state) => state.sshTargetsHydrated)
|
||||
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
|
|
@ -72,16 +99,29 @@ export function RepositoryHostSetupsSection({
|
|||
const projectHostSetupProjection = useAppStore((state) =>
|
||||
getProjectHostSetupProjectionFromState(state)
|
||||
)
|
||||
const selectedHostId = getRepoExecutionHostId(repo)
|
||||
const selectedProjectHostSetup = projectHostSetupProjection.setups.find(
|
||||
(setup) => setup.repoId === repo.id && setup.hostId === selectedHostId
|
||||
const repoProjectHostSetup = projectHostSetupProjection.setups.find(
|
||||
(setup) => setup.repoId === repo.id
|
||||
)
|
||||
const selectedProjectHostSetup =
|
||||
projectHostSetupProjection.setups.find(
|
||||
(setup) =>
|
||||
setup.id === selectedProjectSetupId &&
|
||||
setup.repoId === repo.id &&
|
||||
setup.projectId === repoProjectHostSetup?.projectId
|
||||
) ?? repoProjectHostSetup
|
||||
const projectHostSetups = selectedProjectHostSetup
|
||||
? projectHostSetupProjection.setups.filter(
|
||||
(setup) => setup.projectId === selectedProjectHostSetup.projectId
|
||||
? setupsByOwnedExecutionHost(
|
||||
projectHostSetupProjection.setups.filter(
|
||||
(setup) => setup.projectId === selectedProjectHostSetup.projectId
|
||||
),
|
||||
selectedProjectHostSetup.id
|
||||
)
|
||||
: []
|
||||
const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim())
|
||||
const switchableProjectHostSetups = setupsByOwnedExecutionHost(
|
||||
openableProjectHostSetups,
|
||||
selectedProjectHostSetup?.id ?? ''
|
||||
)
|
||||
const setupHostOptions = buildSetupHostOptions({
|
||||
projectHostSetups,
|
||||
hostOptions
|
||||
|
|
@ -96,6 +136,11 @@ export function RepositoryHostSetupsSection({
|
|||
setSettingsProjectHostSelection(projectId, hostId)
|
||||
}
|
||||
}
|
||||
const selectSetup = (setup: ProjectHostSetup) => {
|
||||
if (projectId) {
|
||||
setSettingsProjectHostSelection(projectId, setup.hostId, setup.id)
|
||||
}
|
||||
}
|
||||
if (
|
||||
(projectHostSetups.length <= 1 && setupHostOptions.length === 0) ||
|
||||
(!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries))
|
||||
|
|
@ -119,29 +164,34 @@ export function RepositoryHostSetupsSection({
|
|||
<Label className="text-sm font-semibold">
|
||||
{translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')}
|
||||
</Label>
|
||||
{openableProjectHostSetups.length > 1 ? (
|
||||
{switchableProjectHostSetups.length > 1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')}
|
||||
</span>
|
||||
<Select
|
||||
value={selectedHostId}
|
||||
onValueChange={(hostId) => {
|
||||
if (hostId === selectedHostId) {
|
||||
value={selectedProjectHostSetup?.id}
|
||||
onValueChange={(setupId) => {
|
||||
if (setupId === selectedProjectHostSetup?.id) {
|
||||
return
|
||||
}
|
||||
selectHost(hostId as ExecutionHostId)
|
||||
const setup = switchableProjectHostSetups.find(
|
||||
(candidate) => candidate.id === setupId
|
||||
)
|
||||
if (setup) {
|
||||
selectSetup(setup)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 min-w-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openableProjectHostSetups.map((setup) => (
|
||||
<SelectItem key={setup.hostId} value={setup.hostId}>
|
||||
{switchableProjectHostSetups.map((setup) => (
|
||||
<SelectItem key={setup.id} value={setup.id}>
|
||||
<span className="block min-w-0 truncate">
|
||||
{hostOptionById.get(setup.hostId)?.label ??
|
||||
getExecutionHostLabel(setup.hostId)}
|
||||
{hostOptionById.get(setup.executionHostId ?? setup.hostId)?.label ??
|
||||
getExecutionHostLabel(setup.executionHostId ?? setup.hostId)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -159,24 +209,86 @@ export function RepositoryHostSetupsSection({
|
|||
</div>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{projectHostSetups.map((setup) => {
|
||||
const isCurrentSetup = setup.hostId === selectedHostId
|
||||
const executionHost = parseExecutionHostId(setup.executionHostId ?? setup.hostId)
|
||||
const transportHost = parseExecutionHostId(setup.hostId)
|
||||
const runtimeOwnerEnvironmentId =
|
||||
setup.runtimeOwnerEnvironmentId?.trim() ||
|
||||
(transportHost?.kind === 'runtime' ? transportHost.environmentId : null)
|
||||
const runtimeOwnerReachable =
|
||||
!runtimeOwnerEnvironmentId ||
|
||||
Boolean(runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status)
|
||||
const nestedSshStatus =
|
||||
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
|
||||
? selectRuntimeAwareSshStatus(
|
||||
{
|
||||
sshConnectionStates,
|
||||
sshTargetLabels,
|
||||
removedSshTargetLabels,
|
||||
sshTargetsHydrated,
|
||||
sshStateByEnvironment,
|
||||
runtimeStatusByEnvironmentId
|
||||
},
|
||||
runtimeOwnerEnvironmentId,
|
||||
executionHost.targetId
|
||||
)
|
||||
: undefined
|
||||
const setupReady =
|
||||
setup.setupState === 'ready' &&
|
||||
runtimeOwnerReachable &&
|
||||
(nestedSshStatus === undefined || nestedSshStatus === 'connected')
|
||||
const setupStateLabel = !runtimeOwnerReachable
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateDisconnected',
|
||||
'Disconnected'
|
||||
)
|
||||
: nestedSshStatus === null
|
||||
? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown')
|
||||
: nestedSshStatus !== undefined && nestedSshStatus !== 'connected'
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.hostStateDisconnected',
|
||||
'Disconnected'
|
||||
)
|
||||
: getSetupStateLabel(setup.setupState)
|
||||
const setupHostLabel =
|
||||
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.nestedHostLabel',
|
||||
'{{value0}} via {{value1}}',
|
||||
{
|
||||
value0: selectRuntimeAwareSshTargetLabel(
|
||||
{
|
||||
sshConnectionStates,
|
||||
sshTargetLabels,
|
||||
removedSshTargetLabels,
|
||||
sshTargetsHydrated,
|
||||
sshStateByEnvironment,
|
||||
runtimeStatusByEnvironmentId
|
||||
},
|
||||
runtimeOwnerEnvironmentId,
|
||||
executionHost.targetId
|
||||
),
|
||||
value1:
|
||||
hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)
|
||||
}
|
||||
)
|
||||
: (hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId))
|
||||
const isCurrentSetup = setup.id === selectedProjectHostSetup?.id
|
||||
const canOpenSetup = setup.repoId.trim().length > 0
|
||||
const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id
|
||||
return (
|
||||
<div
|
||||
key={setup.hostId}
|
||||
key={setup.id}
|
||||
data-current={isCurrentSetup ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
isCurrentSetup ? 'bg-muted/30' : ''
|
||||
isCurrentSetup ? 'bg-accent' : ''
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)}
|
||||
</span>
|
||||
<SettingsBadge tone={setup.setupState === 'ready' ? 'accent' : 'muted'}>
|
||||
{getSetupStateLabel(setup.setupState)}
|
||||
<span className="truncate text-sm font-medium">{setupHostLabel}</span>
|
||||
<SettingsBadge tone={setupReady ? 'accent' : 'muted'}>
|
||||
{setupStateLabel}
|
||||
</SettingsBadge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
||||
|
|
@ -198,7 +310,7 @@ export function RepositoryHostSetupsSection({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
selectHost(setup.hostId)
|
||||
selectSetup(setup)
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.settings.RepositoryPane.openSetup', 'Open')}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ type RepositoryPaneProps = {
|
|||
) => void
|
||||
removeProject: (repoId: string) => void
|
||||
project?: Project | null
|
||||
selectedProjectSetupId?: string
|
||||
isLocalWindowsProject?: boolean
|
||||
wslAvailable?: boolean
|
||||
wslDistros?: string[]
|
||||
|
|
@ -74,6 +75,7 @@ export function RepositoryPane({
|
|||
updateRepo,
|
||||
removeProject,
|
||||
project = null,
|
||||
selectedProjectSetupId,
|
||||
isLocalWindowsProject = false,
|
||||
wslAvailable = false,
|
||||
wslDistros = EMPTY_WSL_DISTROS,
|
||||
|
|
@ -320,6 +322,7 @@ export function RepositoryPane({
|
|||
<>
|
||||
<RepositoryHostSetupsSection
|
||||
repo={repo}
|
||||
selectedProjectSetupId={selectedProjectSetupId}
|
||||
forceVisible={forceFullPaneForRepoMatch}
|
||||
searchQuery={searchQuery}
|
||||
searchEntries={hostSetupEntries}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ function Settings(): React.JSX.Element {
|
|||
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
|
||||
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
|
||||
const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection)
|
||||
const settingsProjectSetupSelection = useAppStore((s) => s.settingsProjectSetupSelection)
|
||||
const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection)
|
||||
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
|
||||
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
|
|
@ -871,14 +872,21 @@ function Settings(): React.JSX.Element {
|
|||
const repo = getSettingsProjectHostRepo(
|
||||
settingsProject,
|
||||
repos,
|
||||
settingsProjectHostSelection[settingsProject.projectId]
|
||||
settingsProjectHostSelection[settingsProject.projectId],
|
||||
settingsProjectSetupSelection[settingsProject.projectId]
|
||||
)
|
||||
if (repo) {
|
||||
reposByHostIdentity.set(getRepoHostIdentity(repo), repo)
|
||||
}
|
||||
}
|
||||
return [...reposByHostIdentity.values()]
|
||||
}, [neededSectionIds, repos, settingsProjectHostSelection, settingsProjectList])
|
||||
}, [
|
||||
neededSectionIds,
|
||||
repos,
|
||||
settingsProjectHostSelection,
|
||||
settingsProjectList,
|
||||
settingsProjectSetupSelection
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity))
|
||||
|
|
@ -1720,7 +1728,8 @@ function Settings(): React.JSX.Element {
|
|||
const repo = getSettingsProjectHostRepo(
|
||||
settingsProject,
|
||||
repos,
|
||||
settingsProjectHostSelection[settingsProject.projectId]
|
||||
settingsProjectHostSelection[settingsProject.projectId],
|
||||
settingsProjectSetupSelection[settingsProject.projectId]
|
||||
)
|
||||
if (!repo) {
|
||||
return null
|
||||
|
|
@ -1753,6 +1762,9 @@ function Settings(): React.JSX.Element {
|
|||
updateRepo={updateRepo}
|
||||
removeProject={() => void removeProjectAllHosts(settingsProject.setups)}
|
||||
project={project}
|
||||
selectedProjectSetupId={
|
||||
settingsProjectSetupSelection[settingsProject.projectId]
|
||||
}
|
||||
isLocalWindowsProject={
|
||||
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
|
||||
isWindowsTerminalHost
|
||||
|
|
|
|||
|
|
@ -211,6 +211,32 @@ describe('deep-link resolution', () => {
|
|||
getSettingsProjectHostRepo(sameIdProjects[0], sameIdRepos, 'runtime:home-mac')?.path
|
||||
).toBe('/remote/repo')
|
||||
})
|
||||
|
||||
it('selects a same-transport setup by setup id', () => {
|
||||
const directRepo = makeRepo({
|
||||
id: 'direct-repo',
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac',
|
||||
path: '/direct/repo'
|
||||
})
|
||||
const jumpRepo = makeRepo({
|
||||
id: 'jump-repo',
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac',
|
||||
path: '/jump/repo'
|
||||
})
|
||||
const sameHubProjects = buildSettingsProjectList([directRepo, jumpRepo])
|
||||
const jumpSetup = sameHubProjects[0].setups.find((setup) => setup.repoId === 'jump-repo')
|
||||
|
||||
expect(
|
||||
getSettingsProjectHostRepo(
|
||||
sameHubProjects[0],
|
||||
[directRepo, jumpRepo],
|
||||
'runtime:home-mac',
|
||||
jumpSetup?.id
|
||||
)?.path
|
||||
).toBe('/jump/repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSettingsProjectFromAllHosts', () => {
|
||||
|
|
|
|||
|
|
@ -174,13 +174,17 @@ export async function removeSettingsProjectFromAllHosts(
|
|||
export function getSettingsProjectHostRepo(
|
||||
settingsProject: SettingsProject,
|
||||
repos: readonly Repo[],
|
||||
selectedHostId: ExecutionHostId | undefined
|
||||
selectedHostId: ExecutionHostId | undefined,
|
||||
selectedSetupId?: string
|
||||
): Repo | undefined {
|
||||
const effectiveHostId = resolveEffectiveProjectHost(settingsProject.setups, selectedHostId)
|
||||
if (!effectiveHostId) {
|
||||
return undefined
|
||||
}
|
||||
const effectiveSetup =
|
||||
settingsProject.setups.find(
|
||||
(setup) => setup.id === selectedSetupId && setup.hostId === effectiveHostId
|
||||
) ??
|
||||
settingsProject.setups.find((setup) => setup.hostId === effectiveHostId) ??
|
||||
settingsProject.setups[0]
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function unified(
|
|||
}
|
||||
|
||||
function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTerminalSurfaceState {
|
||||
return {
|
||||
const state = {
|
||||
activeWorktreeId: null,
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
|
|
@ -36,6 +36,27 @@ function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTer
|
|||
pendingReconnectPtyIdByTabId: {},
|
||||
...overrides
|
||||
}
|
||||
const worktreeIds = new Set([
|
||||
...Object.keys(state.tabsByWorktree),
|
||||
...Object.keys(state.unifiedTabsByWorktree)
|
||||
])
|
||||
const runtimeOwnerEnvironmentId = state.settings?.activeRuntimeEnvironmentId ?? undefined
|
||||
return {
|
||||
...state,
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': [...worktreeIds].map((id) => ({
|
||||
id,
|
||||
repoId: 'fixture-repo',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId
|
||||
}))
|
||||
},
|
||||
detectedWorktreesByRepo: {},
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
removedRuntimeEnvironmentIds: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function removeSurface(current: KillAllTerminalSurfaceState, targetId: string): void {
|
||||
|
|
@ -489,14 +510,14 @@ describe('runKillAllTerminalSurfaces', () => {
|
|||
runtimeTerminals: [],
|
||||
cleanupOnlyPtyIds: ['remote:env-1@@terminal-1']
|
||||
})
|
||||
expect(killPty).toHaveBeenCalledWith('pty-first')
|
||||
expect(killPty).toHaveBeenCalledWith('pty-second')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-first')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-second')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-shared-after-yield')
|
||||
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['survivor'])
|
||||
expect(summary).toMatchObject({
|
||||
closeAttemptCount: 3,
|
||||
absentTargetCount: 3,
|
||||
exactKillAcceptedCount: 2,
|
||||
exactKillAcceptedCount: 0,
|
||||
closeYieldCount: 1
|
||||
})
|
||||
})
|
||||
|
|
@ -507,6 +528,11 @@ describe('runKillAllTerminalSurfaces', () => {
|
|||
const previousState = useAppStore.getState()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: null,
|
||||
repos: [],
|
||||
worktreesByRepo: {
|
||||
'fixture-repo': [{ id: 'wt', repoId: 'fixture-repo', hostId: 'local' }]
|
||||
} as never,
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: { wt: tabs },
|
||||
unifiedTabsByWorktree: {},
|
||||
ptyIdsByTabId
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ let WorktreeCard: typeof WorktreeCardComponent
|
|||
let sshConnectionStates = new Map<string, { status: string }>()
|
||||
let sshTargetLabels = new Map<string, string>()
|
||||
let runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
|
||||
let sshStateByEnvironment = new Map()
|
||||
let worktreesByRepo: Record<string, Worktree[]> = {}
|
||||
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
|
|
@ -31,10 +33,14 @@ vi.mock('@/store', () => ({
|
|||
projectGroups: [],
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
runtimeStatusByEnvironmentId,
|
||||
removedSshTargetLabels: new Map(),
|
||||
settings: null,
|
||||
sshConnectionStates,
|
||||
sshStateByEnvironment,
|
||||
sshTargetLabels,
|
||||
sshTargetsHydrated: true,
|
||||
updateWorktreeMeta,
|
||||
worktreesByRepo,
|
||||
worktreeCardProperties
|
||||
})
|
||||
}))
|
||||
|
|
@ -130,6 +136,8 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
|||
sshConnectionStates = new Map()
|
||||
sshTargetLabels = new Map()
|
||||
runtimeStatusByEnvironmentId = new Map()
|
||||
sshStateByEnvironment = new Map()
|
||||
worktreesByRepo = {}
|
||||
worktreeCardProperties = ['status']
|
||||
})
|
||||
|
||||
|
|
@ -174,4 +182,30 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
|||
expect(markup).not.toContain('Server disconnected')
|
||||
expect(markup).toContain('Project on Orca server')
|
||||
})
|
||||
|
||||
it('reads nested SSH readiness from the owning HUB instead of client-local SSH state', () => {
|
||||
runtimeStatusByEnvironmentId.set('hub-1', { status: { runtimeId: 'hub-runtime' } })
|
||||
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
|
||||
sshTargetLabels.set('ssh-target-1', 'Misleading client-local target')
|
||||
sshStateByEnvironment.set('hub-1', {
|
||||
connectionStates: new Map([['ssh-target-1', { status: 'connected' }]]),
|
||||
targetLabels: new Map([['ssh-target-1', 'HUB private target']]),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
})
|
||||
const worktree = {
|
||||
...makeWorktree(),
|
||||
hostId: 'ssh:ssh-target-1' as const,
|
||||
runtimeOwnerEnvironmentId: 'hub-1'
|
||||
}
|
||||
worktreesByRepo = { 'repo-1': [worktree] }
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('SSH disconnected')
|
||||
expect(markup).toContain('data-ssh-target-label="HUB private target"')
|
||||
expect(markup).not.toContain('Misleading client-local target')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
|
|||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
|
||||
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
selectRuntimeAwareSshStatus,
|
||||
selectRuntimeAwareSshTargetLabel
|
||||
} from '@/store/slices/runtime-environment-ssh'
|
||||
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
|
||||
|
||||
type WorktreeRenameRequest = {
|
||||
worktreeId: string
|
||||
|
|
@ -331,14 +337,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
)
|
||||
|
||||
// SSH disconnected state
|
||||
const sshOwnerEnvironmentId = useAppStore((s) =>
|
||||
repo?.connectionId ? getExplicitRuntimeEnvironmentIdForWorktree(s, worktree.id) : null
|
||||
)
|
||||
const sshStatus = useAppStore((s) => {
|
||||
// Why: runtime-owned SSH targets suppress their ssh:state-changed broadcasts, so don't show a false "disconnected" chip for them.
|
||||
if (!repo?.connectionId || isRuntimeOwnedSshTargetId(repo.connectionId)) {
|
||||
return null
|
||||
}
|
||||
const state = s.sshConnectionStates.get(repo.connectionId)
|
||||
return state?.status ?? 'disconnected'
|
||||
return selectRuntimeAwareSshStatus(s, sshOwnerEnvironmentId, repo.connectionId)
|
||||
})
|
||||
useEffect(() => {
|
||||
if (sshOwnerEnvironmentId) {
|
||||
void hydrateRuntimeEnvironmentSshState(sshOwnerEnvironmentId).catch(() => {})
|
||||
}
|
||||
}, [sshOwnerEnvironmentId])
|
||||
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
|
||||
// Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous).
|
||||
const activeViewIsTerminal = useAppStore(
|
||||
|
|
@ -357,9 +370,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
|
||||
const [titleRenaming, setTitleRenaming] = useState(false)
|
||||
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
|
||||
// Why: read the target label from the store (hydrated in useIpcEvents.ts) instead of a listTargets IPC per card.
|
||||
// Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state.
|
||||
const sshTargetLabel = useAppStore((s) =>
|
||||
repo?.connectionId ? (s.sshTargetLabels.get(repo.connectionId) ?? '') : ''
|
||||
repo?.connectionId
|
||||
? selectRuntimeAwareSshTargetLabel(s, sshOwnerEnvironmentId, repo.connectionId)
|
||||
: ''
|
||||
)
|
||||
|
||||
const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ const appStoreSnapshot: {
|
|||
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
|
||||
worktreesByRepo: Record<
|
||||
string,
|
||||
{ id: string; repoId: string; path?: string; projectId?: string }[]
|
||||
{
|
||||
id: string
|
||||
repoId: string
|
||||
path?: string
|
||||
projectId?: string
|
||||
hostId?: 'local' | `runtime:${string}` | `ssh:${string}`
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}[]
|
||||
>
|
||||
unifiedTabsByWorktree: Record<string, unknown[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
|
|
@ -562,6 +569,16 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
}
|
||||
})
|
||||
appStoreSnapshot.activeRuntimeEnvironmentId = 'web-env-1'
|
||||
appStoreSnapshot.worktreesByRepo = {
|
||||
fixture: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'fixture',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'web-env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities({
|
||||
force: true,
|
||||
|
|
@ -619,6 +636,16 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
}
|
||||
})
|
||||
appStoreSnapshot.activeRuntimeEnvironmentId = 'desktop-env-1'
|
||||
appStoreSnapshot.worktreesByRepo = {
|
||||
fixture: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'fixture',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'desktop-env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities({
|
||||
force: true,
|
||||
|
|
@ -864,6 +891,16 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
}
|
||||
})
|
||||
appStoreSnapshot.activeRuntimeEnvironmentId = 'serve-env-1'
|
||||
appStoreSnapshot.worktreesByRepo = {
|
||||
fixture: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'fixture',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'serve-env-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities({
|
||||
force: true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import {
|
||||
createRuntimePath,
|
||||
|
|
@ -14,7 +13,7 @@ import { useAppStore } from '@/store'
|
|||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import type { RuntimeFileListState } from '../quick-open-file-list'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
|
||||
import {
|
||||
classifyTabEntryQuery,
|
||||
type TabEntryActionClassification
|
||||
|
|
@ -219,14 +218,11 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
|
|||
if (!worktree) {
|
||||
throw new Error('No active worktree.')
|
||||
}
|
||||
const runtimeContext: RuntimeFileOperationArgs = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, args.worktreeId)
|
||||
},
|
||||
worktreeId: args.worktreeId,
|
||||
worktreePath: worktree.path,
|
||||
connectionId: getConnectionId(args.worktreeId) ?? undefined
|
||||
}
|
||||
const runtimeContext: RuntimeFileOperationArgs = getEditorFileOperationContext(
|
||||
state,
|
||||
{ worktreeId: args.worktreeId },
|
||||
worktree.path
|
||||
)
|
||||
await openTabEntryWithOperations({
|
||||
query: args.query,
|
||||
fileList: args.fileList,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import {
|
|||
selectRuntimeAwareSshTargetRemoved
|
||||
} from '@/store/slices/runtime-environment-ssh'
|
||||
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import {
|
||||
|
|
@ -330,11 +329,8 @@ export default function TerminalPane({
|
|||
isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId))
|
||||
)
|
||||
// Which machine's SSH store this target belongs to: a remote server's per-environment bucket, or null for this machine's local SSH maps.
|
||||
// Why: paired web clients force null — they mirror their one host through the local maps, not an explicit environment bucket.
|
||||
const sshReconnectEnvironmentId = useAppStore((store) =>
|
||||
sshReconnectTargetId && !isPairedWebClientWindow()
|
||||
? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId)
|
||||
: null
|
||||
sshReconnectTargetId ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) : null
|
||||
)
|
||||
const sshReconnectStatus = useAppStore((store) =>
|
||||
sshReconnectTargetId
|
||||
|
|
|
|||
|
|
@ -130,8 +130,12 @@ type StoreState = {
|
|||
displayName?: string
|
||||
branch?: string
|
||||
workspaceStatus?: string
|
||||
hostId?: string
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}[]
|
||||
>
|
||||
runtimeEnvironments?: { id: string }[]
|
||||
runtimeEnvironmentCatalogHydrated?: boolean
|
||||
repos: {
|
||||
id: string
|
||||
connectionId?: string | null
|
||||
|
|
@ -2566,12 +2570,15 @@ describe('connectPanePty', () => {
|
|||
|
||||
it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('terminal-old')
|
||||
let transportPtyId = 'terminal-old'
|
||||
const transport = createMockTransport(transportPtyId)
|
||||
transport.getPtyId = vi.fn(() => transportPtyId)
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
const pane = createPane(1)
|
||||
|
||||
connectPanePty(createPane(1) as never, manager as never, deps as never)
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as
|
||||
| ((ptyId: string, replacedPtyId: string) => void)
|
||||
| undefined
|
||||
|
|
@ -2579,7 +2586,11 @@ describe('connectPanePty', () => {
|
|||
expect(onPtyRebind).toBeTypeOf('function')
|
||||
expect(onPtyExit).toBeTypeOf('function')
|
||||
|
||||
transportPtyId = 'terminal-reconnected'
|
||||
onPtyRebind?.('terminal-reconnected', 'terminal-old')
|
||||
|
||||
expect((transport.getPtyId as unknown as () => string | null)()).toBe('terminal-reconnected')
|
||||
expect(pane.container.dataset.ptyId).toBe('terminal-reconnected')
|
||||
onPtyExit?.('terminal-reconnected')
|
||||
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected')
|
||||
|
|
@ -14937,7 +14948,7 @@ describe('connectPanePty', () => {
|
|||
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
it('constructs restored encoded remote PTYs with their owning runtime environment', async () => {
|
||||
it('does not let a restored encoded PTY override the current worktree owner', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createMockTransport()
|
||||
|
|
@ -14948,6 +14959,17 @@ describe('connectPanePty', () => {
|
|||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }]
|
||||
},
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/tmp/wt-1',
|
||||
displayName: 'feat/notis',
|
||||
runtimeOwnerEnvironmentId: 'env-2'
|
||||
}
|
||||
]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
activeRuntimeEnvironmentId: 'env-2'
|
||||
|
|
@ -14960,13 +14982,260 @@ describe('connectPanePty', () => {
|
|||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object))
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-2', expect.any(Object))
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' })
|
||||
)
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1')
|
||||
})
|
||||
|
||||
it('routes a paired-web mirrored pane through its session-scoped HUB owner', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const tabId = 'web-terminal-host-tab'
|
||||
const ptyId = 'remote:hub-web@@terminal-1'
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] },
|
||||
ptyIdsByTabId: { [tabId]: [ptyId] },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/srv/wt-1',
|
||||
hostId: 'local'
|
||||
}
|
||||
]
|
||||
},
|
||||
repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }]
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
tabId,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: ptyId }
|
||||
}) as never
|
||||
)
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-web', expect.any(Object))
|
||||
expect(createIpcPtyTransport).not.toHaveBeenCalled()
|
||||
expect(transport.attach).toHaveBeenCalledWith(expect.objectContaining({ existingPtyId: ptyId }))
|
||||
})
|
||||
|
||||
it('uses a paired-web pane owner to disambiguate duplicate HUB projections', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const tabId = 'web-terminal-host-tab'
|
||||
const ptyId = 'remote:hub-b@@terminal-1'
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1', hostId: 'local' },
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/srv/wt-1',
|
||||
hostId: 'ssh:private-target',
|
||||
runtimeOwnerEnvironmentId: 'hub-b'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
tabId,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: ptyId }
|
||||
}) as never
|
||||
)
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-b', expect.any(Object))
|
||||
})
|
||||
|
||||
it('ignores a stale runtime PTY on an explicitly local non-web pane', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const ptyId = 'remote:stale-hub@@terminal-1'
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] },
|
||||
worktreesByRepo: {
|
||||
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }]
|
||||
},
|
||||
repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }],
|
||||
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'stale-hub' }
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
|
||||
expect(createIpcPtyTransport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the focused runtime only for ownerless mixed-version publications', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
worktreesByRepo: {
|
||||
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1' }]
|
||||
},
|
||||
repos: [{ id: 'repo1', connectionId: null }],
|
||||
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'legacy-hub' }
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object))
|
||||
})
|
||||
|
||||
it('routes a HUB-owned SSH PTY wake hint through the HUB without direct SSH', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const hostPtyId = 'ssh:hub-private@@pty-2'
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: hostPtyId }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [hostPtyId] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: hostPtyId }
|
||||
}
|
||||
},
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/srv/wt-1',
|
||||
hostId: 'ssh:hub-private',
|
||||
runtimeOwnerEnvironmentId: 'hub-env'
|
||||
}
|
||||
]
|
||||
},
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
connectionId: 'hub-private',
|
||||
executionHostId: 'runtime:hub-env'
|
||||
}
|
||||
]
|
||||
} as StoreState
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: hostPtyId }
|
||||
})
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-env', expect.any(Object))
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: hostPtyId })
|
||||
)
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(window.api.ssh.connect).not.toHaveBeenCalled()
|
||||
expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the same SSH worktree id is projected by two HUBs', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/srv/same-worktree',
|
||||
hostId: 'ssh:same-private-target',
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
},
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo1',
|
||||
path: '/srv/same-worktree',
|
||||
hostId: 'ssh:same-private-target',
|
||||
runtimeOwnerEnvironmentId: 'hub-b'
|
||||
}
|
||||
]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
activeRuntimeEnvironmentId: 'hub-a'
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
|
||||
expect(createIpcPtyTransport).not.toHaveBeenCalled()
|
||||
expect(window.api.ssh.connect).not.toHaveBeenCalled()
|
||||
expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails a missing paired-client owner closed instead of creating a local PTY', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
worktreesByRepo: {
|
||||
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/stale-worktree' }]
|
||||
},
|
||||
runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' }
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
|
||||
expect(createIpcPtyTransport).not.toHaveBeenCalled()
|
||||
expect(window.api.ssh.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type { PtyTransportRecoveryState } from './pty-transport-types'
|
|||
import { createIpcPtyTransport } from './pty-transport'
|
||||
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
|
||||
import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation'
|
||||
import { createUnresolvedOwnerPtyTransport } from './unresolved-owner-pty-transport'
|
||||
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
|
|
@ -124,8 +126,8 @@ import {
|
|||
registerPtySerializer,
|
||||
registerPtyTitleSource
|
||||
} from './pty-buffer-serializer'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import {
|
||||
discardTerminalOutput,
|
||||
flushTerminalOutput,
|
||||
|
|
@ -231,8 +233,7 @@ import {
|
|||
import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getSettingsForWorktreeRuntimeOwner,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
getSettingsForWorktreeRuntimeOwner
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
|
|
@ -3199,15 +3200,47 @@ export function connectPanePty(
|
|||
// use the shared resolver instead of only looking up repo-backed worktrees.
|
||||
const worktree = getWorktreeMapFromState(state).get(deps.worktreeId)
|
||||
const worktreeConnectionId = getConnectionId(deps.worktreeId)
|
||||
const connectionId = worktreeConnectionId ?? null
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId)
|
||||
const restoredPtyIdForTransport =
|
||||
deps.restoredLeafId && deps.restoredPtyIdByLeafId
|
||||
? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
|
||||
: null
|
||||
const operationRouteResolution = resolveWorktreeOperationRouteResult(state, deps.worktreeId)
|
||||
const explicitRuntimeEnvironmentId =
|
||||
operationRouteResolution.kind === 'resolved'
|
||||
? operationRouteResolution.route.runtimeEnvironmentId
|
||||
: null
|
||||
// Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner.
|
||||
const mirroredRuntimeOwners = new Set(
|
||||
isWebTerminalSurfaceTabId(deps.tabId)
|
||||
? [restoredPtyIdForTransport, tab?.ptyId]
|
||||
.map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null))
|
||||
.filter((environmentId): environmentId is string => Boolean(environmentId))
|
||||
: []
|
||||
)
|
||||
const mirroredRuntimeEnvironmentId = mirroredRuntimeOwners.values().next().value ?? null
|
||||
const terminalOwnerUnresolved =
|
||||
mirroredRuntimeOwners.size > 1 ||
|
||||
(operationRouteResolution.kind !== 'resolved' && !mirroredRuntimeEnvironmentId)
|
||||
const runtimeEnvironmentId = explicitRuntimeEnvironmentId
|
||||
? explicitRuntimeEnvironmentId
|
||||
: mirroredRuntimeEnvironmentId
|
||||
? mirroredRuntimeEnvironmentId
|
||||
: null
|
||||
// Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host.
|
||||
const connectionId =
|
||||
!terminalOwnerUnresolved && runtimeEnvironmentId === null
|
||||
? (worktreeConnectionId ?? null)
|
||||
: null
|
||||
const shellOverride = tab?.shellOverride
|
||||
// Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so
|
||||
// the native-Windows ConPTY heuristic misfires on a Windows client and wrongly
|
||||
// enables ConPTY synchronized-output protection, which strips an agent's
|
||||
// transient cursor-show (?25h) and leaves the cursor invisible. The execution
|
||||
// host is the authoritative signal: only a 'local' host is a local native PTY.
|
||||
const executionHostId = getExecutionHostIdForWorktree(state, deps.worktreeId)
|
||||
const executionHostId = terminalOwnerUnresolved
|
||||
? ('runtime:unresolved-owner' as const)
|
||||
: getExecutionHostIdForWorktree(state, deps.worktreeId)
|
||||
const isNativeWindowsConpty = isLocalNativeWindowsConpty({
|
||||
userAgent: navigator.userAgent,
|
||||
connectionId,
|
||||
|
|
@ -3258,16 +3291,6 @@ export function connectPanePty(
|
|||
})
|
||||
}
|
||||
|
||||
const restoredPtyIdForTransport =
|
||||
deps.restoredLeafId && deps.restoredPtyIdByLeafId
|
||||
? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
|
||||
: null
|
||||
const remoteRuntimeOwnerForTransport =
|
||||
(restoredPtyIdForTransport
|
||||
? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport)
|
||||
: null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null)
|
||||
const runtimeEnvironmentId =
|
||||
remoteRuntimeOwnerForTransport ?? getRuntimeEnvironmentIdForWorktree(state, deps.worktreeId)
|
||||
const localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities()
|
||||
? getCachedWindowsTerminalCapabilities()
|
||||
: null
|
||||
|
|
@ -3372,6 +3395,7 @@ export function connectPanePty(
|
|||
? undefined
|
||||
: paneStartup?.startupCommandDelivery,
|
||||
connectionId,
|
||||
executionHostId,
|
||||
worktreeId: deps.worktreeId,
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by letting
|
||||
// main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before
|
||||
|
|
@ -3488,9 +3512,13 @@ export function connectPanePty(
|
|||
}
|
||||
: {})
|
||||
}
|
||||
const transport = runtimeEnvironmentId
|
||||
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
|
||||
: createIpcPtyTransport(transportOptions)
|
||||
const transport = terminalOwnerUnresolved
|
||||
? createUnresolvedOwnerPtyTransport(
|
||||
'Workspace identity is ambiguous across hosts. Refresh projects and try again.'
|
||||
)
|
||||
: runtimeEnvironmentId
|
||||
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
|
||||
: createIpcPtyTransport(transportOptions)
|
||||
const canSendDesktopQueryReply = (): boolean => {
|
||||
const ptyId = transport.getPtyId()
|
||||
return !ptyId || !isPtyLocked(ptyId)
|
||||
|
|
@ -7777,6 +7805,12 @@ export function connectPanePty(
|
|||
restoredSessionId && restoredSessionId !== detachedLivePtyId
|
||||
? restoredSessionId
|
||||
: detachedLivePtyId
|
||||
const runtimeHostPtyWakeHint =
|
||||
runtimeEnvironmentId &&
|
||||
candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId
|
||||
? buildColdRestoreAgentResumeStartup()
|
||||
: null
|
||||
|
|
@ -7801,12 +7835,13 @@ export function connectPanePty(
|
|||
// Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree.
|
||||
// Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh.
|
||||
const deferredReattachSessionId =
|
||||
candidateReattachSessionId &&
|
||||
runtimeHostPtyWakeHint ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
: null)
|
||||
recordPtyConnectDiagnostic(
|
||||
`pane=${pane.id} tab=${deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${hadExistingPaneTransportAtConnect} pendingKey=${pendingSpawnKey}`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../../../shared/proje
|
|||
import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
|
||||
export type PtyBufferSnapshot = {
|
||||
|
|
@ -152,6 +153,10 @@ export type PtyTransport = {
|
|||
/** The runtime captured by this transport; legacy remote PTY ids do not
|
||||
* encode their owner, and current worktree settings may have changed. */
|
||||
getRuntimeEnvironmentId?: () => string | null
|
||||
/** Execution host captured at spawn; nested SSH differs from its outer runtime owner. */
|
||||
getExecutionHostId?: () => ExecutionHostId | null
|
||||
/** Host platform captured by the PTY owner; paired-client OS is not authoritative. */
|
||||
getRemotePlatform?: () => NodeJS.Platform | null
|
||||
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
|
||||
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
|
||||
* model-restore marker reports dropped bytes — a carry spanning the gap
|
||||
|
|
@ -179,6 +184,7 @@ export type IpcPtyTransportOptions = {
|
|||
launchAgent?: TuiAgent
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
executionHostId?: ExecutionHostId | null
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
|
|
|
|||
|
|
@ -23,7 +23,17 @@ describe('remote transport sendInputImmediate (#7329)', () => {
|
|||
vi.clearAllMocks()
|
||||
subscriptionCallbacks = null
|
||||
subscriptionSendBinary.mockReset()
|
||||
runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
|
||||
subscriptionCallbacks = callbacks
|
||||
|
|
@ -58,6 +68,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
|
|||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
|
||||
|
||||
// Typed input: debounced — nothing sent before the 8ms flush.
|
||||
expect(transport.sendInput('yes')).toBe(true)
|
||||
|
|
@ -92,6 +103,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
|
|||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
|
||||
|
||||
expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) // CPR reply
|
||||
await Promise.resolve()
|
||||
|
|
@ -120,6 +132,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
|
|||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
|
||||
|
||||
// A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async
|
||||
// validation path, so its bytes are captured in validationTail, not pending.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,17 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => {
|
|||
vi.clearAllMocks()
|
||||
subscriptionCallbacks = null
|
||||
subscriptionSendBinary.mockReset()
|
||||
runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
|
||||
subscriptionCallbacks = callbacks
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue