mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(ssh): Recover server for a managed host refused on a stale lock, and surface that refusal in seconds (#32951)
* feat(orcad): run --reclaim-stale-lock on a managed host's active slot The desktop can now dry-run and confirm the stale instance-lock recovery on a Linux managed host, serialized with launches, then start the server again and reconnect the way the restart action does. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * feat(settings): offer Recover server for a managed host refused on a stale lock When a managed orcad start names --reclaim-stale-lock, the SSH host row offers Recover server. The dialog dry-runs the check, shows the host's evidence, and clears the lock only after the user confirms no other machine sharing the home runs Orca. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * feat(mobile): point a host refused on a stale lock to the desktop's Recover server Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): mount the Recover server dialog per opening A fresh mount starts each opening from a new check instead of resetting state in an effect. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): end the readiness wait once the host proves the launch exited A managed orcad refused at start (such as on a possibly stale instance lock) left the client waiting out the full readiness deadline. Each host-side wait now also checks the launch's PID file with kill -0 and ends early on a proven exit, so the caller reports the same pending readiness, with the orcad.log tail, within a poll. No PID, a denied probe or a reused PID keeps the old deadline. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): refuse a managed slot without --reclaim-stale-lock before running it An older orcad ignores the flag and starts serving in the foreground. The same remote exec now checks that the slot's server bundle names the flag and answers unsupported otherwise, without invoking orcad. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): show the manual stale-lock command as literal mono text The rendered dialog ran the command into the sentence, so it was hard to read or copy. It now sits in its own mono block, like Details. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): offer only a manual delete when the host can't reclaim, and format the lock's age An unsupported host was told to run the very command it lacks. Main now names the lock path for that case, and the dialog shows only a copyable rm of it with the shared-home caution. The lock's age uses the UI's relative-time formatter instead of a hand-rolled minutes count. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): pin a confirmed stale-lock reclaim to the record the user approved --yes reclaimed whatever record held the lock at that moment, so a lock a same-named machine wrote while the dialog sat open could be cleared. orcad now takes --expect-nonce and refuses unless the lock still holds it; the dry run reports the nonce, the desktop sends it back on confirm, and the managed slot gate requires a build that knows the pin. A reclaimed result carries the evidence it acted on. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): keep the Recover server dialog open through its own reclaim The dialog's mount was gated on the status that offered it, and the reclaim clears that note before its answer arrives, so the result was never shown and the card kept the dialog marked open. Only the triggers are gated now; an open dialog keeps the server it opened for until the user closes it. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): put the Recover server confirmation on the security surface It asks permission to run a remote command that clears an ownership lock and starts server code, which STYLEGUIDE puts on that surface. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): trust a gone launch PID only on the machine that launched it The PID file sits in a home other login nodes may share, so a wait exec that lands on another node read a starting server as exited. Both launch paths now record the launching machine's boot id and node name beside the PID, and the readiness wait ends early only when its own exec reads the same record. No record, a host without a boot id, or another machine keeps the deadline. The PID path is named once in the launch command so it stays within its sent-byte ceiling. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): keep a slot's unreadable stale-lock answer from offering a manual delete A slot that passed the gate but gave no JSON, or a status this client does not know, read as unsupported and offered rm of the lock. It now reads as no-answer, which points to the slot's own reclaim command, and unsupported is left to a slot without the command, a non-Linux host or no active version. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * style(settings): use the text-2xs step for the stale-lock command block Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(mobile): offer the desktop-recovery hint on the same refusal the desktop does Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * test(orcad): name the extra fields a fake stale-lock answer carries Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(settings): end the Recover server flow once the lock is cleared, and keep an unpinned reclaim manual After a reclaim whose restart failed, the dialog kept the old evidence, the enabled confirmation and manual recovery for a lock that was gone. It now shows only that the lock was cleared and why the server did not start, with Close. A slot that has the reclaim but not the nonce pin is now told apart from one without the reclaim, and is pointed to its own reclaim command rather than rm. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): keep a stale-lock reclaim's result when the restart after it throws A wake or tunnel rebuild that threw after the lock was cleared discarded the reclaimed result, so the caller skipped its reconnect and note clearing and the dialog kept a confirmation for a lock that was gone. The restart's failure now becomes the result's notRestarted reason. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * fix(orcad): report a reclaimed server as started when only its forward failed to follow A tunnel rebuild that failed after the wake started the server was reported as the server not starting. The rebuild now goes through followLaunchedServerPort, as other relaunches do, which warns and leaves the forward to the next connect. Also drops a caller test that only covered behaviour the fix did not change. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> * test(mobile): re-record the execution-target goldens for the recovery hint field The SSH gate's projected state gained `needsDesktopServerRecovery`, which is false for every recorded reply (no recorded host sends a managed server refusal). Re-recorded with `pnpm --dir mobile rpc:record` for the two pilot recordings and the four execution-target reply partitions; the only change is that field, plus the value pool reordering around it. Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> --------- Co-authored-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"family": "components.execution-target-local",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"1e4520fe6576": {
|
||||
"5779e5e854f7": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
@@ -13,19 +13,7 @@
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"986a776213e8": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
@@ -63,6 +51,20 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"d3a709c13f9d": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -115,7 +117,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "1e4520fe6576",
|
||||
"state": "5779e5e854f7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -127,7 +129,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "986a776213e8",
|
||||
"state": "d3a709c13f9d",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,22 +76,23 @@
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}"
|
||||
},
|
||||
"4aa26501c485": {
|
||||
"name": "preflight.detectRemoteAgents#2",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"4e2e9a890ced": {
|
||||
"1212a28487d8": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"4aa26501c485": {
|
||||
"name": "preflight.detectRemoteAgents#2",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"70b1e8fb528a": {
|
||||
"name": "preflight.detectRemoteAgents#1",
|
||||
"ordinal": 3,
|
||||
@@ -201,7 +202,15 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"d23b91bd7660": {
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"fe6c500525e6": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
@@ -210,19 +219,12 @@
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -236,7 +238,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "d23b91bd7660",
|
||||
"state": "fe6c500525e6",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -249,7 +251,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
+56
-53
@@ -4,21 +4,6 @@
|
||||
"family": "components.execution-target-local",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"1e4520fe6576": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2498f3d3369f": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -51,19 +36,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"365a6864b043": {
|
||||
"detected": [],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"453aa52cefe2": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -136,6 +108,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"5779e5e854f7": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"7097e9b8c337": {
|
||||
"detected": [],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"74def3993a7c": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -272,19 +274,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"986a776213e8": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"9b8ae0c8eec5": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 2,
|
||||
@@ -347,6 +336,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"d3a709c13f9d": {
|
||||
"detected": ["claude"],
|
||||
"gate": {
|
||||
"connectInProgress": true,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db1ccb3eedf8": {
|
||||
"name": "preflight.detectAgents#1",
|
||||
"ordinal": 1,
|
||||
@@ -468,7 +471,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "1e4520fe6576",
|
||||
"state": "5779e5e854f7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -480,7 +483,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "986a776213e8",
|
||||
"state": "d3a709c13f9d",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -492,7 +495,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -504,7 +507,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -516,7 +519,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -528,7 +531,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -540,7 +543,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -552,7 +555,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -564,7 +567,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -576,7 +579,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -588,7 +591,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -600,7 +603,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "365a6864b043",
|
||||
"state": "7097e9b8c337",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
+42
-39
@@ -111,6 +111,18 @@
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}"
|
||||
},
|
||||
"1212a28487d8": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"3dbb3eccbac3": {
|
||||
"name": "preflight.detectRemoteAgents#1",
|
||||
"ordinal": 3,
|
||||
@@ -151,17 +163,6 @@
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"4e2e9a890ced": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"4f7c1dcd099d": {
|
||||
"name": "preflight.detectRemoteAgents#1",
|
||||
"ordinal": 3,
|
||||
@@ -302,13 +303,14 @@
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"88ecd0c754ca": {
|
||||
"86a281a96c72": {
|
||||
"detected": [],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
@@ -518,21 +520,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"d23b91bd7660": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -574,6 +561,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fe6c500525e6": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -587,7 +590,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "d23b91bd7660",
|
||||
"state": "fe6c500525e6",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -600,7 +603,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -613,7 +616,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -626,7 +629,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -639,7 +642,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -652,7 +655,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -665,7 +668,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -678,7 +681,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -691,7 +694,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -704,7 +707,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -717,7 +720,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -730,7 +733,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "88ecd0c754ca",
|
||||
"state": "86a281a96c72",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
+66
-59
@@ -107,6 +107,18 @@
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}"
|
||||
},
|
||||
"1212a28487d8": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"1a1c006a53b2": {
|
||||
"name": "ssh.connect#1",
|
||||
"ordinal": 5,
|
||||
@@ -141,35 +153,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"2d47b46a5872": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "Unknown method",
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
"3828494e64df": {
|
||||
"2736bf269de9": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "",
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
"467f1a0954f0": {
|
||||
"31cf96607dc7": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "transport failure",
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
"362b036cb4aa": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "Unknown method",
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
@@ -214,15 +229,16 @@
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"4e2e9a890ced": {
|
||||
"detected": ["codex"],
|
||||
"53cfc4ee0cf6": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
"error": "outer refused",
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
"5bab1ad3009a": {
|
||||
@@ -332,17 +348,6 @@
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"990a404630b4": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "outer refused",
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
"9eb936085325": {
|
||||
"name": "ssh.connect#1",
|
||||
"ordinal": 5,
|
||||
@@ -550,28 +555,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"d23b91bd7660": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eaf7ed4ebea4": {
|
||||
"c2a9e7754a2e": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": "The host sent a reply this app could not read (ssh.connect)",
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": "error"
|
||||
}
|
||||
@@ -618,6 +609,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fe6c500525e6": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -631,7 +638,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "d23b91bd7660",
|
||||
"state": "fe6c500525e6",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -644,7 +651,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -657,7 +664,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "eaf7ed4ebea4",
|
||||
"state": "c2a9e7754a2e",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -670,7 +677,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "eaf7ed4ebea4",
|
||||
"state": "c2a9e7754a2e",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -683,7 +690,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -696,7 +703,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -709,7 +716,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -722,7 +729,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "990a404630b4",
|
||||
"state": "53cfc4ee0cf6",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -735,7 +742,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "3828494e64df",
|
||||
"state": "2736bf269de9",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -748,7 +755,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "2d47b46a5872",
|
||||
"state": "362b036cb4aa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -761,7 +768,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "467f1a0954f0",
|
||||
"state": "31cf96607dc7",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -774,7 +781,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "3828494e64df",
|
||||
"state": "2736bf269de9",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
+54
-51
@@ -76,6 +76,18 @@
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}"
|
||||
},
|
||||
"1212a28487d8": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"196cd7f8b6a9": {
|
||||
"name": "ssh.getState#1",
|
||||
"ordinal": 1,
|
||||
@@ -195,17 +207,6 @@
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}"
|
||||
},
|
||||
"4e2e9a890ced": {
|
||||
"detected": ["codex"],
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"70b1e8fb528a": {
|
||||
"name": "preflight.detectRemoteAgents#1",
|
||||
"ordinal": 3,
|
||||
@@ -238,6 +239,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"7f7bf8a1f3fa": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"8265ec539d5a": {
|
||||
"name": "preflight.detectRemoteAgents#1",
|
||||
"ordinal": 4,
|
||||
@@ -514,21 +529,6 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"d23b91bd7660": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"d4293c213b99": {
|
||||
"name": "ssh.getState#1",
|
||||
"ordinal": 1,
|
||||
@@ -598,19 +598,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"e314f3903bd3": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"requiresConnection": false,
|
||||
"status": "connected"
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -653,6 +640,22 @@
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"fe6c500525e6": {
|
||||
"detected": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"gate": {
|
||||
"connectInProgress": false,
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"needsDesktopServerRecovery": false,
|
||||
"requiresConnection": true,
|
||||
"status": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -666,7 +669,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "d23b91bd7660",
|
||||
"state": "fe6c500525e6",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -679,7 +682,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "4e2e9a890ced",
|
||||
"state": "1212a28487d8",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -692,7 +695,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -705,7 +708,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -718,7 +721,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -731,7 +734,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -744,7 +747,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -757,7 +760,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -770,7 +773,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -783,7 +786,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -796,7 +799,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
@@ -809,7 +812,7 @@
|
||||
"mount": "eb79a9b3682a",
|
||||
"connect": "eb79a9b3682a"
|
||||
},
|
||||
"state": "e314f3903bd3",
|
||||
"state": "7f7bf8a1f3fa",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export function NewWorkspaceSshConnectionField({
|
||||
)}
|
||||
</View>
|
||||
{sshGate.error ? <Text style={styles.errorInline}>{sshGate.error}</Text> : null}
|
||||
{sshGate.needsDesktopServerRecovery ? (
|
||||
<Text style={styles.sshSubtitle}>
|
||||
Its server needs recovering from the desktop: Settings → SSH hosts → Recover server.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,14 @@ describe('the SSH connection record', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the record when its managed-server note is malformed', () => {
|
||||
const parsed = sshConnectionStateSchema.safeParse({
|
||||
state: { ...connected, managedServer: { serving: { detail: 7 } } }
|
||||
})
|
||||
expect(parsed.success && parsed.data).toMatchObject({ status: 'connected' })
|
||||
expect(parsed.success && parsed.data?.managedServer?.serving?.detail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers undefined for a payload with no state member', () => {
|
||||
expect(sshConnectionStateSchema.safeParse({})).toMatchObject({ success: true, data: undefined })
|
||||
})
|
||||
|
||||
@@ -61,7 +61,21 @@ export const sshConnectionStateSchema = z
|
||||
status: openEnum(SSH_CONNECTION_STATUS, 'disconnected'),
|
||||
error: salvagedOptional('error', z.string().nullable()),
|
||||
reconnectAttempt: salvagedOptional('reconnectAttempt', z.number()),
|
||||
connectionGeneration: salvagedOptional('connectionGeneration', z.number())
|
||||
connectionGeneration: salvagedOptional('connectionGeneration', z.number()),
|
||||
// Only the start refusal is read, for the desktop-recovery hint; old hosts omit it.
|
||||
managedServer: salvagedOptional(
|
||||
'managedServer',
|
||||
z.looseObject({
|
||||
kind: salvagedOptional('kind', z.string()),
|
||||
serving: salvagedOptional(
|
||||
'serving',
|
||||
z.looseObject({
|
||||
state: salvagedOptional('state', z.string()),
|
||||
detail: salvagedOptional('detail', z.string())
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
.nullable()
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from './workspace-ssh-gate'
|
||||
import {
|
||||
deriveWorkspaceSshGate,
|
||||
workspaceSshStatusLabel,
|
||||
type WorkspaceSshRecord
|
||||
} from './workspace-ssh-gate'
|
||||
|
||||
describe('workspace SSH gate', () => {
|
||||
it('does not gate local repositories', () => {
|
||||
@@ -13,7 +17,8 @@ describe('workspace SSH gate', () => {
|
||||
status: null,
|
||||
requiresConnection: false,
|
||||
connectInProgress: false,
|
||||
error: null
|
||||
error: null,
|
||||
needsDesktopServerRecovery: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +51,8 @@ describe('workspace SSH gate', () => {
|
||||
status: null,
|
||||
requiresConnection: true,
|
||||
connectInProgress: false,
|
||||
error: null
|
||||
error: null,
|
||||
needsDesktopServerRecovery: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,6 +88,23 @@ describe('workspace SSH gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('asks for the desktop recovery only for the refusal the desktop offers it on', () => {
|
||||
const named = 'run `orcad --reclaim-stale-lock --data-root /home/dev/.orca` there'
|
||||
const gate = (managedServer: WorkspaceSshRecord['managedServer']) =>
|
||||
deriveWorkspaceSshGate({
|
||||
connectionId: 'ssh-1',
|
||||
state: { targetId: 'ssh-1', status: 'connected', managedServer },
|
||||
connecting: false
|
||||
}).needsDesktopServerRecovery
|
||||
const refused = { kind: 'managed', serving: { state: 'unverifiable', detail: named } }
|
||||
expect(gate(refused)).toBe(true)
|
||||
expect(gate({ ...refused, serving: { ...refused.serving, detail: 'did not answer' } })).toBe(
|
||||
false
|
||||
)
|
||||
expect(gate({ ...refused, kind: 'relay' })).toBe(false)
|
||||
expect(gate({ ...refused, serving: { detail: named } })).toBe(false)
|
||||
})
|
||||
|
||||
it('labels user-visible SSH states', () => {
|
||||
expect(workspaceSshStatusLabel(null)).toBe('Disconnected')
|
||||
expect(workspaceSshStatusLabel('deploying-relay')).toBe('Deploying relay')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SshConnectionState, SshConnectionStatus } from '../../../src/shared/ssh-types'
|
||||
import { ORCAD_RECLAIM_STALE_LOCK_FLAG } from '../../../src/shared/orcad-stale-lock-reclaim'
|
||||
|
||||
/**
|
||||
* What mobile stores for an SSH connection.
|
||||
@@ -9,9 +10,13 @@ import type { SshConnectionState, SshConnectionStatus } from '../../../src/share
|
||||
* keeps its `status` instead of dropping the whole record into the connect path's `'connected'`
|
||||
* fallback.
|
||||
*/
|
||||
export type WorkspaceSshRecord = Omit<SshConnectionState, 'error' | 'reconnectAttempt'> & {
|
||||
export type WorkspaceSshRecord = Omit<
|
||||
SshConnectionState,
|
||||
'error' | 'reconnectAttempt' | 'managedServer'
|
||||
> & {
|
||||
error?: string | null
|
||||
reconnectAttempt?: number
|
||||
managedServer?: { kind?: string; serving?: { state?: string; detail?: string } }
|
||||
}
|
||||
|
||||
export type WorkspaceSshGate = {
|
||||
@@ -19,6 +24,8 @@ export type WorkspaceSshGate = {
|
||||
requiresConnection: boolean
|
||||
connectInProgress: boolean
|
||||
error: string | null
|
||||
/** The host's server refused to start on a possibly stale lock; only the desktop can clear it. */
|
||||
needsDesktopServerRecovery: boolean
|
||||
}
|
||||
|
||||
function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean {
|
||||
@@ -62,6 +69,16 @@ export function deriveWorkspaceSshGate(args: {
|
||||
status,
|
||||
requiresConnection: args.connectionId !== null && status !== 'connected',
|
||||
connectInProgress: args.connecting || isWorkspaceSshConnectInProgress(status),
|
||||
error: matchingState?.error ?? null
|
||||
error: matchingState?.error ?? null,
|
||||
needsDesktopServerRecovery: needsDesktopServerRecovery(matchingState?.managedServer)
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop's own gate for Recover server (canRecoverSshHostServer), read from a lenient record. */
|
||||
function needsDesktopServerRecovery(server: WorkspaceSshRecord['managedServer']): boolean {
|
||||
return (
|
||||
server?.kind === 'managed' &&
|
||||
server.serving?.state === 'unverifiable' &&
|
||||
Boolean(server.serving.detail?.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
} from '../../ssh/orcad-runtime-maintenance'
|
||||
import { getOrcadSshDeployment } from '../../../shared/runtime-environments'
|
||||
import { resolveEnvironment } from '../../../shared/runtime-environment-store'
|
||||
import type { OrcadManagedRestartResult } from '../../../shared/orcad-managed-runtime'
|
||||
import type {
|
||||
OrcadManagedRestartResult,
|
||||
OrcadManagedStaleLockReclaimRequest,
|
||||
OrcadManagedStaleLockReclaimResult
|
||||
} from '../../../shared/orcad-managed-runtime'
|
||||
import { restartManagedOrcadEnvironment } from '../../managed-server/lifecycle/orcad-managed-launch-session'
|
||||
import { runManagedOrcadStaleLockReclaim } from '../../managed-server/lifecycle/orcad-managed-stale-lock-reclaim'
|
||||
import { clearLaunchSessionNote } from './runtime-environment-managed-tunnel'
|
||||
import { forgetLaunchSessionVerdict } from '../ssh/ssh-host-launch-session-note'
|
||||
import type { ManagedServerActions } from '../../runtime/managed-server-actions-registry'
|
||||
@@ -102,15 +107,36 @@ export async function restartManagedOrcad(
|
||||
): Promise<OrcadManagedRestartResult> {
|
||||
const result = await restartManagedOrcadEnvironment(options.getUserDataPath(), selector)
|
||||
if (result.outcome === 'restarted') {
|
||||
// Why: the restarted orcad dropped the old connection; reconnect on the new one.
|
||||
await options.invalidateTransport(result.environmentId)
|
||||
const environment = resolveEnvironment(options.getUserDataPath(), result.environmentId)
|
||||
const targetId = getOrcadSshDeployment(environment)?.sshTargetId
|
||||
if (targetId) {
|
||||
options.clearHostServerNotes(targetId, result.environmentId)
|
||||
forgetLaunchSessionVerdict(targetId)
|
||||
clearLaunchSessionNote(targetId, result.environmentId)
|
||||
}
|
||||
await reconnectRestartedManagedOrcad(options, result.environmentId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Desktop-only: the host status line offers it when a start refused a possibly stale lock (#32177). */
|
||||
export async function reclaimManagedOrcadStaleLock(
|
||||
options: ManagedOrcadActionOptions,
|
||||
selector: string,
|
||||
request: OrcadManagedStaleLockReclaimRequest
|
||||
): Promise<OrcadManagedStaleLockReclaimResult> {
|
||||
const result = await runManagedOrcadStaleLockReclaim(options.getUserDataPath(), selector, request)
|
||||
// Even unstarted: the reconnect's own start replaces the note that offered this.
|
||||
if (result.status === 'reclaimed') {
|
||||
await reconnectRestartedManagedOrcad(options, result.environmentId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function reconnectRestartedManagedOrcad(
|
||||
options: ManagedOrcadActionOptions,
|
||||
environmentId: string
|
||||
): Promise<void> {
|
||||
// Why: the restarted orcad dropped the old connection; reconnect on the new one.
|
||||
await options.invalidateTransport(environmentId)
|
||||
const environment = resolveEnvironment(options.getUserDataPath(), environmentId)
|
||||
const targetId = getOrcadSshDeployment(environment)?.sshTargetId
|
||||
if (targetId) {
|
||||
options.clearHostServerNotes(targetId, environmentId)
|
||||
forgetLaunchSessionVerdict(targetId)
|
||||
clearLaunchSessionNote(targetId, environmentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
|
||||
import { registerManagedServerActions } from '../../runtime/managed-server-actions-registry'
|
||||
import {
|
||||
createManagedOrcadActions,
|
||||
reclaimManagedOrcadStaleLock,
|
||||
restartManagedOrcad,
|
||||
type ManagedOrcadActionOptions
|
||||
} from './managed-orcad-actions'
|
||||
@@ -27,6 +28,18 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: ManagedOrcadAct
|
||||
ipcMain.handle('runtimeEnvironments:restartOrcad', async (_event, args: { selector: string }) =>
|
||||
restartManagedOrcad(options, requiredString(args?.selector, 'Server'))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:reclaimOrcadStaleLock',
|
||||
async (_event, args: { selector: string; yes?: boolean; expectNonce?: string }) =>
|
||||
reclaimManagedOrcadStaleLock(
|
||||
options,
|
||||
requiredString(args?.selector, 'Server'),
|
||||
// A confirmation without the checked record's nonce could clear a lock written since.
|
||||
args?.yes === true
|
||||
? { yes: true, expectNonce: requiredString(args.expectNonce, 'The checked lock') }
|
||||
: { yes: false }
|
||||
)
|
||||
)
|
||||
ipcMain.handle('runtimeEnvironments:stopOrcad', async (_event, args: { selector: string }) =>
|
||||
actions.stop(requiredString(args?.selector, 'Server'))
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:restartOrcad',
|
||||
'runtimeEnvironments:reclaimOrcadStaleLock',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:forgetOrcad',
|
||||
|
||||
@@ -34,6 +34,11 @@ export const ORCAD_READINESS_MAX_BYTES = 256 * 1024
|
||||
|
||||
/** PID of the launched orcad, written into its own version dir at launch. */
|
||||
export const ORCAD_PID_FILENAME = '.orcad-pid'
|
||||
/** The machine that wrote `.orcad-pid` (boot id, then node name): a shared home is no machine. */
|
||||
export const ORCAD_PID_MACHINE_FILENAME = `${ORCAD_PID_FILENAME}-machine`
|
||||
/** Prints that record, or nothing on a host with no boot id; a launch and a later wait compare it. */
|
||||
export const ORCAD_LAUNCH_MACHINE_COMMAND = 'cat /proc/sys/kernel/random/boot_id&&uname -n'
|
||||
|
||||
/** Windows' `.orcad-pid`: `{"pid":N,"creationTimeMs":M|null}`, since a PID alone is no identity there. */
|
||||
export const ORCAD_WINDOWS_PROCESS_FILENAME = '.orcad-process.json'
|
||||
|
||||
|
||||
@@ -46,11 +46,12 @@ describe('the WSL launch (setsid)', () => {
|
||||
|
||||
it('has the child record its own PID, since the setsid -f parent exits at once', () => {
|
||||
const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid')
|
||||
const removeStale = command.indexOf(`rm -f '/d/orcad-1/${ORCAD_PID_FILENAME}'`)
|
||||
expect(command.startsWith(`orcad_pid='/d/orcad-1/${ORCAD_PID_FILENAME}';`)).toBe(true)
|
||||
const removeStale = command.indexOf('rm -f "$orcad_pid"')
|
||||
const recordOwn = command.indexOf(`echo $$ > "$1" && umask "$2" && shift 2 && exec "$@"`)
|
||||
expect(removeStale).toBeGreaterThan(-1)
|
||||
expect(recordOwn).toBeGreaterThan(removeStale)
|
||||
expect(command.trimEnd().endsWith(`cat '/d/orcad-1/${ORCAD_PID_FILENAME}'`)).toBe(true)
|
||||
expect(command.trimEnd().endsWith('cat "$orcad_pid"')).toBe(true)
|
||||
})
|
||||
|
||||
it('passes host facts the guest cannot read, quoted, and refuses a malformed name', () => {
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
assertPosixOrcadHost as assertPosixHost,
|
||||
ORCAD_LOG_FILENAME,
|
||||
ORCAD_PID_FILENAME,
|
||||
ORCAD_LAUNCH_MACHINE_COMMAND,
|
||||
ORCAD_PID_MACHINE_FILENAME,
|
||||
ORCAD_READINESS_FILENAME,
|
||||
ORCAD_READINESS_MAX_BYTES,
|
||||
posixProcessAliveShellFunction
|
||||
@@ -105,7 +107,8 @@ export function orcadLaunchCommand(
|
||||
const readiness = quotePosixShell(ORCAD_READINESS_FILENAME)
|
||||
const log = quotePosixShell(ORCAD_LOG_FILENAME)
|
||||
// Absolute: a nohup launch backgrounds the whole list, `cd` included, then writes this outside it.
|
||||
const pidFile = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME))
|
||||
// Named once: the path is long, and every byte counts against the sent-command budget.
|
||||
const pidFile = '"$orcad_pid"'
|
||||
// Absolute: liveness matches the runtime's args against this path.
|
||||
const entry = quotePosixShell(
|
||||
joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME)
|
||||
@@ -124,6 +127,9 @@ export function orcadLaunchCommand(
|
||||
...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${quotePosixShell(value)}`)
|
||||
]
|
||||
return [
|
||||
`orcad_pid=${quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME))};`,
|
||||
// In the launching shell, so on the launched process's machine; empty where there is no boot id.
|
||||
`{ ${ORCAD_LAUNCH_MACHINE_COMMAND};}>"$orcad_pid${ORCAD_PID_MACHINE_FILENAME.slice(ORCAD_PID_FILENAME.length)}" 2>/dev/null;`,
|
||||
...(recordSession ? [captureOrcadLaunchSshSessionCommand()] : []),
|
||||
// The daemon gets this back: the git, terminals and agents it spawns create the user's files.
|
||||
'orcad_login_umask=$(umask);',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { orcadReadinessWaitCommand } from './orcad-remote-readiness-wait'
|
||||
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
|
||||
import { quotePosixShell } from '../../../shared/shell-argument-quoting'
|
||||
import { ORCAD_LAUNCH_MACHINE_COMMAND } from './orcad-remote-host-support'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -37,4 +39,44 @@ describe.skipIf(process.platform === 'win32')('the host-side readiness wait', ()
|
||||
// A counted loop would take about 8 x (0.25 s + 1 s) here.
|
||||
expect(Date.now() - startedAt).toBeLessThan(4_500)
|
||||
})
|
||||
|
||||
async function waitAfter(gone: boolean, machine: 'here' | 'elsewhere' | 'unrecorded') {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orcad-readiness-wait-'))
|
||||
roots.push(root)
|
||||
const slot = join(root, 'slot')
|
||||
mkdirSync(slot)
|
||||
const pidFile = quotePosixShell(join(slot, '.orcad-pid'))
|
||||
const machineFile = quotePosixShell(join(slot, '.orcad-pid-machine'))
|
||||
const command = orcadReadinessWaitCommand(getRemoteHostPlatform('linux-x64'), slot, 2)
|
||||
const prefix = [
|
||||
gone ? `sh -c 'echo $$ > "$1"' orcad-test ${pidFile};` : `echo ${process.pid} > ${pidFile};`,
|
||||
...(machine === 'here'
|
||||
? [`{ ${ORCAD_LAUNCH_MACHINE_COMMAND};} > ${machineFile} 2>/dev/null;`]
|
||||
: machine === 'elsewhere'
|
||||
? [`echo 'other-boot other-node' > ${machineFile};`]
|
||||
: [])
|
||||
].join(' ')
|
||||
const { stdout } = await runProcess({
|
||||
program: '/bin/sh',
|
||||
args: ['-c', `${prefix} ${command}`],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
return stdout.startsWith('ORCAD_LAUNCH_EXITED\n')
|
||||
}
|
||||
|
||||
// Only Linux has the boot id that makes a PID evidence; elsewhere the wait keeps its deadline.
|
||||
it.skipIf(process.platform !== 'linux')(
|
||||
'ends at once when the launched PID is gone here',
|
||||
async () => {
|
||||
expect(await waitAfter(true, 'here')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['waits while the launched PID still runs', false, 'here'],
|
||||
['waits for a PID another machine sharing the home launched', true, 'elsewhere'],
|
||||
['waits for a launch that recorded no machine', true, 'unrecorded']
|
||||
] as const)('%s', async (_case, gone, machine) => {
|
||||
expect(await waitAfter(gone, machine)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
joinRemotePath,
|
||||
type RemoteHostPlatform
|
||||
} from '../../ssh/ssh-remote-platform'
|
||||
import { ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES } from './orcad-remote-host-support'
|
||||
import {
|
||||
ORCAD_PID_FILENAME,
|
||||
ORCAD_PID_MACHINE_FILENAME,
|
||||
ORCAD_READINESS_FILENAME,
|
||||
ORCAD_READINESS_MAX_BYTES,
|
||||
ORCAD_LAUNCH_MACHINE_COMMAND
|
||||
} from './orcad-remote-host-support'
|
||||
import {
|
||||
parseOrcadReadinessOutput,
|
||||
readOrcadReadinessCommand,
|
||||
@@ -27,6 +33,9 @@ import { ORCAD_WINDOWS_READINESS_MARKER } from '../host-state/orcad-windows-host
|
||||
/** Under the 30 s exec timeout, so one wait never reads as an unanswered host. */
|
||||
export const ORCAD_READINESS_WAIT_MAX_SECONDS = 20
|
||||
|
||||
/** Printed ahead of the readiness bytes when the host proved the launched PID gone. */
|
||||
const ORCAD_LAUNCH_EXITED_MARKER = 'ORCAD_LAUNCH_EXITED'
|
||||
|
||||
/** Settles on a newline (a finished line) or more than the cap; both are final for the parser. */
|
||||
function posixReadinessWaitCommand(
|
||||
host: RemoteHostPlatform,
|
||||
@@ -35,7 +44,18 @@ function posixReadinessWaitCommand(
|
||||
): string {
|
||||
const file = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME))
|
||||
const read = `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${file} 2>/dev/null`
|
||||
const pidFile = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME))
|
||||
const pidMachine = quotePosixShell(
|
||||
joinRemotePath(host, remoteInstallDir, ORCAD_PID_MACHINE_FILENAME)
|
||||
)
|
||||
return [
|
||||
// Why: a start refused at once (#32177) otherwise reads as pending until the client's deadline.
|
||||
// Proven exit only: no PID, a denied probe or a reused PID keeps waiting. A PID is evidence
|
||||
// only on the machine that launched it; a shared home may put this exec on another one.
|
||||
`orcad_launch_here() { orcad_recorded=$(cat ${pidMachine} 2>/dev/null) && [ -n "$orcad_recorded" ] && [ "$orcad_recorded" = "$(${ORCAD_LAUNCH_MACHINE_COMMAND} 2>/dev/null)" ]; };`,
|
||||
`orcad_launch_exited() { orcad_launch_here || return 1; pid=$(cat ${pidFile} 2>/dev/null); case "$pid" in "" | *[!0-9]*) return 1;; esac;`,
|
||||
'probe_error=$(LC_ALL=C kill -0 "$pid" 2>&1) || { case "$probe_error" in *"No such process"*) return 0;; esac; return 1; };',
|
||||
'case "$(ps -o stat= -p "$pid" 2>/dev/null)" in Z*) return 0;; esac; return 1; };',
|
||||
// Fractional sleep is not POSIX; probe it once so polling stays fine-grained where it can.
|
||||
'if sleep 0.25 2>/dev/null; then orcad_readiness_wait_step=0.25;',
|
||||
'else orcad_readiness_wait_step=1; fi;',
|
||||
@@ -46,6 +66,7 @@ function posixReadinessWaitCommand(
|
||||
'while [ "$(date +%s)" -lt "$orcad_readiness_wait_end" ]; do',
|
||||
`[ "$(${read} | wc -l)" -gt 0 ] && break;`,
|
||||
`[ "$(${read} | wc -c)" -gt ${ORCAD_READINESS_MAX_BYTES} ] && break;`,
|
||||
`orcad_launch_exited && { echo ${ORCAD_LAUNCH_EXITED_MARKER}; break; };`,
|
||||
'sleep "$orcad_readiness_wait_step"; done;',
|
||||
`${read} || true`
|
||||
].join(' ')
|
||||
@@ -75,10 +96,21 @@ export function parseOrcadReadinessWaitOutput(
|
||||
return parseOrcadReadinessOutput(
|
||||
isWindowsRemoteHost(host)
|
||||
? (readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_READINESS_MARKER) ?? '')
|
||||
: output
|
||||
: withoutLaunchExitedMarker(output)
|
||||
)
|
||||
}
|
||||
|
||||
/** The host proved the launched process gone while its readiness was still unwritten. */
|
||||
export function orcadLaunchExitedDuringWait(host: RemoteHostPlatform, output: string): boolean {
|
||||
return !isWindowsRemoteHost(host) && output.startsWith(`${ORCAD_LAUNCH_EXITED_MARKER}\n`)
|
||||
}
|
||||
|
||||
function withoutLaunchExitedMarker(output: string): string {
|
||||
return output.startsWith(`${ORCAD_LAUNCH_EXITED_MARKER}\n`)
|
||||
? output.slice(ORCAD_LAUNCH_EXITED_MARKER.length + 1)
|
||||
: output
|
||||
}
|
||||
|
||||
/** Reads the readiness line as it stands; parse with `parseOrcadReadinessWaitOutput`. */
|
||||
export function readOrcadReadinessNowCommand(
|
||||
host: RemoteHostPlatform,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control'
|
||||
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
|
||||
import type { OrcadLaunchSpec } from './orcad-remote-launch'
|
||||
import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options'
|
||||
import { launchOrcadSlotAndAwaitReadiness } from '../../ssh/orcad-active-readiness'
|
||||
|
||||
const host = getRemoteHostPlatform('linux-arm64')
|
||||
const spec: OrcadLaunchSpec = {
|
||||
@@ -77,3 +78,60 @@ describe('waiting for a launched candidate', () => {
|
||||
await expect(launch(controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a launch that exits before it is ready', () => {
|
||||
const REFUSAL = 'orcad: failed to start: another machine may hold the data root'
|
||||
let now = 0
|
||||
beforeEach(() => {
|
||||
mockExec.mockReset()
|
||||
now = 0
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
})
|
||||
|
||||
// The host answers each wait at once; only the client's injected sleep moves the clock.
|
||||
function waitAnswers(exitedOnPoll: number | null) {
|
||||
let polls = 0
|
||||
mockExec.mockImplementation(async (_conn, command) => {
|
||||
if (command.includes('tail -c')) {
|
||||
return `${REFUSAL}\n`
|
||||
}
|
||||
if (command.includes('orcad_readiness_wait_end')) {
|
||||
polls += 1
|
||||
return polls === exitedOnPoll ? 'ORCAD_LAUNCH_EXITED\n' : ''
|
||||
}
|
||||
return '1786\n'
|
||||
})
|
||||
return () => polls
|
||||
}
|
||||
|
||||
function launchSlot() {
|
||||
return launchOrcadSlotAndAwaitReadiness(
|
||||
{
|
||||
conn: Object.create(null),
|
||||
host,
|
||||
readinessTimeoutMs: 180_000,
|
||||
sleep: async (ms) => {
|
||||
now += ms
|
||||
}
|
||||
},
|
||||
spec,
|
||||
{ fullVersion: spec.fullVersion, buildHash: 'b1' }
|
||||
)
|
||||
}
|
||||
|
||||
it('fails at once with the log’s refusal once the host proved the PID gone', async () => {
|
||||
const polls = waitAnswers(2)
|
||||
const failure = launchSlot()
|
||||
await expect(failure).rejects.toMatchObject({ verdict: 'rejected' })
|
||||
await expect(failure).rejects.toThrow(REFUSAL)
|
||||
expect(polls()).toBe(2)
|
||||
expect(now).toBeLessThan(5_000)
|
||||
})
|
||||
|
||||
it('waits out the deadline when the host never proves an exit', async () => {
|
||||
const polls = waitAnswers(null)
|
||||
await expect(launchSlot()).rejects.toThrow(REFUSAL)
|
||||
expect(now).toBeGreaterThanOrEqual(180_000)
|
||||
expect(polls()).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './orcad-remote-launch-windows'
|
||||
import {
|
||||
ORCAD_READINESS_WAIT_MAX_SECONDS,
|
||||
orcadLaunchExitedDuringWait,
|
||||
orcadReadinessWaitCommand,
|
||||
parseOrcadReadinessWaitOutput
|
||||
} from './orcad-remote-readiness-wait'
|
||||
@@ -165,7 +166,8 @@ export async function launchOrcadAndAwaitReadiness(
|
||||
target.signal?.throwIfAborted()
|
||||
lastWaitError = undefined
|
||||
last = parseOrcadReadinessWaitOutput(target.host, output)
|
||||
if (last.state !== 'pending') {
|
||||
// An exited launch answers as the deadline would, so callers attach the same log tail.
|
||||
if (last.state !== 'pending' || orcadLaunchExitedDuringWait(target.host, output)) {
|
||||
return last
|
||||
}
|
||||
await sleep(READINESS_RETRY_PAUSE_MS)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RuntimeMaintenanceModule from '../../ssh/orcad-runtime-maintenance'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform'
|
||||
import { sshCommandExitError } from '../../ssh/remote-exec/remote-exec-command'
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { quotePosixShell } from '../../../shared/shell-argument-quoting'
|
||||
import { ORCAD_INSTALL_MODEL } from '../install/remote-install-model'
|
||||
import { computeRemoteInstallDir } from '../install/remote-versioned-install'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const host: { current: RemoteHostPlatform | null } = { current: null }
|
||||
const home = { current: '/home/dev' }
|
||||
return { host, home, exec: vi.fn(), wake: vi.fn(), rebuild: vi.fn(async () => {}) }
|
||||
})
|
||||
|
||||
vi.mock('../../ssh/orcad-runtime-maintenance', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RuntimeMaintenanceModule>()),
|
||||
withManagedOrcadLifecycle: (
|
||||
_path: string,
|
||||
_selector: string,
|
||||
run: (managed: unknown) => Promise<unknown>
|
||||
) => run({ environment: { id: 'env-1' }, deployment: { remotePort: 7777 } })
|
||||
}))
|
||||
vi.mock('./orcad-managed-runtime-context', () => ({
|
||||
resolveLinkedOrcadContext: async () => ({ host: mocks.host.current }),
|
||||
managedOrcadSlot: () => ({
|
||||
host: mocks.host.current,
|
||||
remoteHome: mocks.home.current,
|
||||
userDataDir: '/home/dev/.orca'
|
||||
})
|
||||
}))
|
||||
vi.mock('../activation/orcad-activation-lock', () => ({
|
||||
withOrcadActivationLock: (_slot: unknown, run: () => Promise<unknown>) => run()
|
||||
}))
|
||||
vi.mock('../activation/orcad-activation-record-store', () => ({
|
||||
readOrcadActivationRecord: async () => ({ active: '1.0.0' })
|
||||
}))
|
||||
vi.mock('./orcad-managed-wake', () => ({ wakeStoppedManagedOrcad: mocks.wake }))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({ rebuildOrcadManagedTunnel: mocks.rebuild }))
|
||||
vi.mock('../install/orcad-remote-runtime-control', () => ({ execOrcadRemote: mocks.exec }))
|
||||
|
||||
const { runManagedOrcadStaleLockReclaim } = await import('./orcad-managed-stale-lock-reclaim')
|
||||
|
||||
const evidence = {
|
||||
pid: 41,
|
||||
host: 'box',
|
||||
bootId: 'b1',
|
||||
currentBootId: 'b2',
|
||||
ageMinutes: 90,
|
||||
nonce: 'n-1'
|
||||
}
|
||||
// What orcad adds to a status line beyond the fields every answer carries.
|
||||
type AnswerExtras = { exitCode?: number; evidence?: typeof evidence }
|
||||
|
||||
const answer = (status: string, extra: AnswerExtras = {}) =>
|
||||
JSON.stringify({ status, lockPath: '/home/dev/.orca/orcad.lock', message: status, ...extra })
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.home.current = '/home/dev'
|
||||
mocks.host.current = getRemoteHostPlatform('linux-x64')
|
||||
mocks.wake.mockResolvedValue({ outcome: 'started', readiness: {} })
|
||||
mocks.rebuild.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('runManagedOrcadStaleLockReclaim', () => {
|
||||
it('reads the dry run’s evidence from the command’s exit-2 output', async () => {
|
||||
mocks.exec.mockRejectedValue(
|
||||
sshCommandExitError('orcad', 2, `${answer('eligible', { exitCode: 2, evidence })}\n`)
|
||||
)
|
||||
await expect(runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false })).resolves.toEqual({
|
||||
status: 'eligible',
|
||||
lockPath: '/home/dev/.orca/orcad.lock',
|
||||
message: 'eligible',
|
||||
evidence
|
||||
})
|
||||
const command: string = mocks.exec.mock.calls[0][1]
|
||||
expect(command).toContain("--reclaim-stale-lock --data-root '/home/dev/.orca' --json")
|
||||
expect(command).not.toContain('--yes')
|
||||
// Only the slot gate names the pin; a dry run passes no nonce to orcad.
|
||||
expect(command.split('--expect-nonce')).toHaveLength(2)
|
||||
expect(mocks.wake).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never guesses from an orcad that answers without JSON', async () => {
|
||||
mocks.exec.mockRejectedValue(sshCommandExitError('orcad', 1, 'Error: lock held'))
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
// The slot supports the command, so nothing here argues for deleting the lock by hand.
|
||||
expect(result).toEqual({ status: 'no-answer', message: expect.any(String) })
|
||||
expect(mocks.wake).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the reclaimed result when the wake throws after the lock is gone', async () => {
|
||||
mocks.wake.mockRejectedValue(new Error('readiness timed out'))
|
||||
mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`)
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: 'reclaimed',
|
||||
environmentId: 'env-1',
|
||||
evidence,
|
||||
notRestarted: expect.stringContaining('readiness timed out')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a started server as restarted even when its forward could not follow it', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.rebuild.mockRejectedValue(new Error('forward refused'))
|
||||
mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`)
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
expect(result).toMatchObject({ status: 'reclaimed', evidence })
|
||||
expect(result).not.toHaveProperty('notRestarted')
|
||||
})
|
||||
|
||||
it('reads a status a newer orcad added as no answer', async () => {
|
||||
mocks.exec.mockRejectedValue(sshCommandExitError('orcad', 5, `${answer('deferred')}\n`))
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false })
|
||||
expect(result.status).toBe('no-answer')
|
||||
})
|
||||
|
||||
it('names the lock to delete by hand only for a slot without the command', async () => {
|
||||
mocks.exec.mockResolvedValue('ORCA_STALE_LOCK_RECLAIM_UNSUPPORTED\n')
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false })
|
||||
expect(result).toMatchObject({ status: 'unsupported', lockPath: '/home/dev/.orca/orcad.lock' })
|
||||
})
|
||||
|
||||
it('runs nothing on a host whose starts never refuse a shared-home lock', async () => {
|
||||
mocks.host.current = getRemoteHostPlatform('darwin-arm64')
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
expect(result.status).toBe('unsupported')
|
||||
expect(mocks.exec).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts the server again once the confirmed reclaim cleared the lock', async () => {
|
||||
mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`)
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
expect(mocks.exec.mock.calls[0][1]).toContain("--json --expect-nonce 'n-1' --yes")
|
||||
expect(result).toMatchObject({ status: 'reclaimed', environmentId: 'env-1', evidence })
|
||||
expect(result).not.toHaveProperty('notRestarted')
|
||||
expect(mocks.wake).toHaveBeenCalledOnce()
|
||||
expect(mocks.rebuild).toHaveBeenCalledWith('/u', 'env-1')
|
||||
})
|
||||
|
||||
// Runs the real command in a shell against a slot whose runtime records any invocation.
|
||||
// The fake runtime prints nothing, which a slot that runs the command reports as no answer.
|
||||
it.skipIf(process.platform === 'win32').each([
|
||||
// A build with the reclaim but not the nonce pin must never be confirmed from here.
|
||||
[
|
||||
'never starts an orcad that has the reclaim but not the pin',
|
||||
'argv[2] === "--reclaim-stale-lock"',
|
||||
false,
|
||||
{ status: 'unsupported', reclaimWithoutPin: true }
|
||||
],
|
||||
[
|
||||
'never starts an orcad without the reclaim',
|
||||
'orcad server',
|
||||
false,
|
||||
{ status: 'unsupported', lockPath: expect.stringContaining('orcad.lock') }
|
||||
],
|
||||
[
|
||||
'runs the slot that names the pin',
|
||||
'"--reclaim-stale-lock" "--expect-nonce"',
|
||||
true,
|
||||
{ status: 'no-answer' }
|
||||
]
|
||||
])('%s', async (_case, bundle, invoked, expected) => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orcad-reclaim-gate-'))
|
||||
roots.push(home)
|
||||
mocks.home.current = home
|
||||
const slotDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, home, '1.0.0')
|
||||
mkdirSync(slotDir, { recursive: true })
|
||||
writeFileSync(join(slotDir, 'orcad-server.js'), bundle)
|
||||
writeFileSync(join(slotDir, '.build-target'), 'linux-x64')
|
||||
const runtime = join(slotDir, 'bun-runtime')
|
||||
writeFileSync(runtime, `#!/bin/sh\ntouch ${quotePosixShell(join(home, 'invoked'))}\n`)
|
||||
chmodSync(runtime, 0o755)
|
||||
mocks.exec.mockImplementation(async (_slot: unknown, command: string) => {
|
||||
const { stdout } = await runProcess({ program: '/bin/sh', args: ['-c', command] })
|
||||
return stdout
|
||||
})
|
||||
const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', {
|
||||
yes: true,
|
||||
expectNonce: 'n-1'
|
||||
})
|
||||
expect(result).toMatchObject(expected)
|
||||
expect(existsSync(join(home, 'invoked'))).toBe(invoked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Runs `orcad --reclaim-stale-lock` from a managed host's active slot, for a start refused because
|
||||
* its instance lock may belong to another machine sharing the home (#32177). The slot's own build
|
||||
* gathers the evidence and clears the lock; this client only relays the user's confirmation.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { quotePosixShell } from '../../../shared/shell-argument-quoting'
|
||||
import {
|
||||
ORCAD_RECLAIM_STALE_LOCK_FLAG,
|
||||
ORCAD_STALE_LOCK_DATA_ROOT_FLAG,
|
||||
ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG
|
||||
} from '../../../shared/orcad-stale-lock-reclaim'
|
||||
import type {
|
||||
OrcadManagedStaleLockReclaimRequest,
|
||||
OrcadManagedStaleLockReclaimResult
|
||||
} from '../../../shared/orcad-managed-runtime'
|
||||
import {
|
||||
ORCAD_LAUNCHER_FILENAME,
|
||||
ORCAD_SERVER_ENTRY_FILENAME
|
||||
} from '../../../shared/orcad-artifacts'
|
||||
import { withOrcadActivationLock } from '../activation/orcad-activation-lock'
|
||||
import { readOrcadActivationRecord } from '../activation/orcad-activation-record-store'
|
||||
import {
|
||||
followLaunchedServerPort,
|
||||
withManagedOrcadLifecycle
|
||||
} from '../../ssh/orcad-runtime-maintenance'
|
||||
import { managedOrcadSlot, resolveLinkedOrcadContext } from './orcad-managed-runtime-context'
|
||||
import { selectOrcadSlotRuntimeCommand } from '../install/orcad-remote-runtime'
|
||||
import { execOrcadRemote } from '../install/orcad-remote-runtime-control'
|
||||
import { orcadSlotDir, type OrcadSlotOptions } from '../../ssh/orcad-recovery-slot'
|
||||
import { joinRemotePath } from '../../ssh/ssh-remote-platform'
|
||||
import { isSshCommandExitError } from '../../ssh/remote-exec/remote-exec-command'
|
||||
import { wakeStoppedManagedOrcad } from './orcad-managed-wake'
|
||||
import { ORCAD_LOCK_FILE_NAME } from '../../orcad/orcad-instance-lock'
|
||||
import { errorMessage } from '../../../shared/error-message'
|
||||
|
||||
const ReclaimStatusSchema = z.enum(['none', 'eligible', 'refused', 'reclaimed'])
|
||||
|
||||
// Why a string: a newer orcad's status must read as no answer, not as an unparseable reply.
|
||||
const ReclaimAnswerSchema = z.object({
|
||||
status: z.string(),
|
||||
lockPath: z.string(),
|
||||
message: z.string(),
|
||||
evidence: z
|
||||
.object({
|
||||
pid: z.number(),
|
||||
host: z.string(),
|
||||
bootId: z.string(),
|
||||
currentBootId: z.string(),
|
||||
ageMinutes: z.number(),
|
||||
nonce: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const RECLAIM_UNSUPPORTED_SENTINEL = 'ORCA_STALE_LOCK_RECLAIM_UNSUPPORTED'
|
||||
const RECLAIM_UNPINNED_SENTINEL = 'ORCA_STALE_LOCK_RECLAIM_UNPINNED'
|
||||
|
||||
type ReclaimAnswer = Omit<z.infer<typeof ReclaimAnswerSchema>, 'status'> & {
|
||||
status: z.infer<typeof ReclaimStatusSchema>
|
||||
}
|
||||
|
||||
type SlotReclaim =
|
||||
| { status: 'answered'; answer: ReclaimAnswer }
|
||||
/** `unpinned`: the slot has the reclaim but not `--expect-nonce`, so only a person may run it. */
|
||||
| { status: 'unsupported' | 'unpinned' | 'no-answer' | 'busy'; message: string }
|
||||
|
||||
export function runManagedOrcadStaleLockReclaim(
|
||||
userDataPath: string,
|
||||
selector: string,
|
||||
request: OrcadManagedStaleLockReclaimRequest
|
||||
): Promise<OrcadManagedStaleLockReclaimResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment)
|
||||
// Only Linux starts refuse a lock another machine may hold; elsewhere the slot has no such lock.
|
||||
if (context.host.os !== 'linux') {
|
||||
return unsupported('This host’s Orca server has no stale-lock recovery.')
|
||||
}
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort)
|
||||
// Serialized with launches, so a wake never reads the lock while it is being cleared.
|
||||
const ran = await withOrcadActivationLock(
|
||||
slot,
|
||||
() => runOnActiveSlot(slot, request),
|
||||
(): SlotReclaim => ({
|
||||
status: 'busy',
|
||||
message: 'An update, rollback or recovery holds this host; try again when it finishes.'
|
||||
})
|
||||
)
|
||||
if (ran.status === 'unsupported') {
|
||||
const lockPath = joinRemotePath(slot.host, slot.userDataDir, ORCAD_LOCK_FILE_NAME)
|
||||
return { status: 'unsupported', message: ran.message, lockPath }
|
||||
}
|
||||
if (ran.status === 'unpinned') {
|
||||
return { status: 'unsupported', message: ran.message, reclaimWithoutPin: true }
|
||||
}
|
||||
if (ran.status !== 'answered') {
|
||||
return { status: ran.status, message: ran.message }
|
||||
}
|
||||
const { answer } = ran
|
||||
if (answer.status !== 'reclaimed') {
|
||||
return { ...answer, status: answer.status }
|
||||
}
|
||||
const reclaimed = {
|
||||
status: 'reclaimed',
|
||||
environmentId: environment.id,
|
||||
message: answer.message,
|
||||
lockPath: answer.lockPath,
|
||||
...(answer.evidence ? { evidence: answer.evidence } : {})
|
||||
} as const
|
||||
const notRestarted = await restartAfterReclaim(userDataPath, environment.id, slot)
|
||||
return notRestarted ? { ...reclaimed, notRestarted } : reclaimed
|
||||
})
|
||||
}
|
||||
|
||||
/** Why the server did not start again, or null; never throws, since the lock is already gone. */
|
||||
async function restartAfterReclaim(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
slot: OrcadSlotOptions
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const wake = await wakeStoppedManagedOrcad(slot)
|
||||
if (wake.outcome !== 'started' && wake.outcome !== 'serving') {
|
||||
return wake.outcome === 'recovery-refused'
|
||||
? wake.reason
|
||||
: `The lock was cleared, but this host’s Orca server did not start (${wake.outcome}); reconnect to start it.`
|
||||
}
|
||||
// The server runs now; a forward that fails to follow it is the next connect's to rebuild.
|
||||
await followLaunchedServerPort(userDataPath, environmentId)
|
||||
return null
|
||||
} catch (error) {
|
||||
// A lost result would leave the caller's reconnect and note clearing undone.
|
||||
return `The lock was cleared, but this host’s Orca server did not start: ${errorMessage(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnActiveSlot(
|
||||
slot: OrcadSlotOptions,
|
||||
request: OrcadManagedStaleLockReclaimRequest
|
||||
): Promise<SlotReclaim> {
|
||||
// Re-read under the fence: another client may have changed the active slot.
|
||||
const active = (await readOrcadActivationRecord(slot)).active
|
||||
if (!active) {
|
||||
return { status: 'unsupported', message: 'This host has no activated Orca server.' }
|
||||
}
|
||||
const slotDir = orcadSlotDir(slot, active)
|
||||
const entry = joinRemotePath(slot.host, slotDir, ORCAD_LAUNCHER_FILENAME)
|
||||
const bundle = joinRemotePath(slot.host, slotDir, ORCAD_SERVER_ENTRY_FILENAME)
|
||||
// The confirmation clears only the record the user approved, never one written since.
|
||||
const confirm = request.yes
|
||||
? ` ${ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG} ${quotePosixShell(request.expectNonce)} --yes`
|
||||
: ''
|
||||
// Why first: an orcad without the flag would ignore it and start serving in the foreground, and
|
||||
// one without the nonce pin could clear a lock written after the check.
|
||||
const command =
|
||||
`if grep -qF -- ${quotePosixShell(ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG)} ${quotePosixShell(bundle)} 2>/dev/null; then ` +
|
||||
`${selectOrcadSlotRuntimeCommand(slot.host, slotDir, '')} && ` +
|
||||
`"$orcad_runtime" ${quotePosixShell(entry)} ${ORCAD_RECLAIM_STALE_LOCK_FLAG} ` +
|
||||
`${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} ${quotePosixShell(slot.userDataDir)} --json${confirm}; ` +
|
||||
`elif grep -qF -- ${quotePosixShell(ORCAD_RECLAIM_STALE_LOCK_FLAG)} ${quotePosixShell(bundle)} 2>/dev/null; then ` +
|
||||
`echo ${RECLAIM_UNPINNED_SENTINEL}; else echo ${RECLAIM_UNSUPPORTED_SENTINEL}; fi`
|
||||
let output: string
|
||||
try {
|
||||
output = await execOrcadRemote(slot, command)
|
||||
} catch (error) {
|
||||
// Every status but `reclaimed` exits non-zero and still prints its answer.
|
||||
if (!isSshCommandExitError(error)) {
|
||||
throw error
|
||||
}
|
||||
output = error.stdout
|
||||
}
|
||||
const lines = output.split('\n').map((line) => line.trim())
|
||||
if (lines.includes(RECLAIM_UNSUPPORTED_SENTINEL)) {
|
||||
return unsupported('This host’s Orca server predates the stale-lock recovery.')
|
||||
}
|
||||
if (lines.includes(RECLAIM_UNPINNED_SENTINEL)) {
|
||||
return {
|
||||
status: 'unpinned',
|
||||
message: 'This host’s Orca server is too old for Orca to clear the lock from here.'
|
||||
}
|
||||
}
|
||||
const answer = parseReclaimAnswer(output)
|
||||
// The slot supports the command, so this is no reason to delete the lock by hand.
|
||||
return answer
|
||||
? { status: 'answered', answer }
|
||||
: {
|
||||
status: 'no-answer',
|
||||
message: 'This host’s Orca server gave no answer Orca understands to the stale-lock check.'
|
||||
}
|
||||
}
|
||||
|
||||
function parseReclaimAnswer(output: string): ReclaimAnswer | null {
|
||||
const line = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.findLast((candidate) => candidate.trim().startsWith('{'))
|
||||
if (!line) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = ReclaimAnswerSchema.safeParse(JSON.parse(line))
|
||||
const status = ReclaimStatusSchema.safeParse(parsed.data?.status)
|
||||
return parsed.success && status.success ? { ...parsed.data, status: status.data } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function unsupported(message: string): { status: 'unsupported'; message: string } {
|
||||
return { status: 'unsupported', message }
|
||||
}
|
||||
@@ -97,6 +97,28 @@ describe.skipIf(process.platform === 'win32')('reclaimStaleOrcadInstanceLock (#3
|
||||
expect(existsSync(lockPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaims under --expect-nonce only the record the dry run showed', async () => {
|
||||
const root = makeRoot()
|
||||
const lockPath = writeLock(root, holderRecord())
|
||||
const changed = await reclaimStaleOrcadInstanceLock(
|
||||
root,
|
||||
{ yes: true, expectNonce: 'approved-earlier', hintRuntime: null },
|
||||
rebootedBox()
|
||||
)
|
||||
expect(changed).toMatchObject({
|
||||
status: 'refused',
|
||||
message: 'The lock changed after you checked it. Check it again.'
|
||||
})
|
||||
expect(existsSync(lockPath)).toBe(true)
|
||||
const pinned = await reclaimStaleOrcadInstanceLock(
|
||||
root,
|
||||
{ yes: true, expectNonce: 'stale-holder', hintRuntime: null },
|
||||
rebootedBox()
|
||||
)
|
||||
expect(pinned).toMatchObject({ status: 'reclaimed', evidence: { nonce: 'stale-holder' } })
|
||||
expect(existsSync(lockPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('never reports an empty data root as a recovery', async () => {
|
||||
const result = await reclaimStaleOrcadInstanceLock(
|
||||
makeRoot(),
|
||||
|
||||
@@ -32,8 +32,10 @@ import { RUNTIME_SOCKET_NAME_REGEX } from '../runtime/runtime-rpc/runtime-rpc-so
|
||||
import {
|
||||
ORCAD_RECLAIM_STALE_LOCK_FLAG,
|
||||
ORCAD_STALE_LOCK_DATA_ROOT_FLAG,
|
||||
ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG,
|
||||
ORCAD_STALE_LOCK_RECLAIM_EXIT_CODES,
|
||||
type OrcadReclaimHintRuntime,
|
||||
type OrcadStaleLockEvidence,
|
||||
type OrcadStaleLockReclaimStatus
|
||||
} from '../../shared/orcad-stale-lock-reclaim'
|
||||
|
||||
@@ -54,14 +56,6 @@ export type OrcadStaleLockReclaimHooks = {
|
||||
lockHooks?: OrcadInstanceLockHooks
|
||||
}
|
||||
|
||||
export type OrcadStaleLockEvidence = {
|
||||
pid: number
|
||||
host: string
|
||||
bootId: string
|
||||
currentBootId: string
|
||||
ageMinutes: number
|
||||
}
|
||||
|
||||
export type OrcadStaleLockReclaimResult = {
|
||||
status: OrcadStaleLockReclaimStatus
|
||||
exitCode: number
|
||||
@@ -74,6 +68,8 @@ export async function reclaimStaleOrcadInstanceLock(
|
||||
dataRoot: string,
|
||||
options: {
|
||||
yes: boolean
|
||||
/** Refuse unless the lock still holds this nonce: the record the user approved. */
|
||||
expectNonce?: string
|
||||
/** Who to name in a refusal's command: an orcad names its own runtime, the CLI `orca serve`. */
|
||||
hintRuntime: OrcadReclaimHintRuntime | null
|
||||
},
|
||||
@@ -106,6 +102,9 @@ export async function reclaimStaleOrcadInstanceLock(
|
||||
if (!holder) {
|
||||
return result('refused', `The lock ${lockPath} is not a complete record. Check it by hand.`)
|
||||
}
|
||||
if (options.expectNonce !== undefined && holder.nonce !== options.expectNonce) {
|
||||
return result('refused', 'The lock changed after you checked it. Check it again.')
|
||||
}
|
||||
const currentBoot = (hooks.bootId ?? currentBootId)()
|
||||
const checked = await checkStaleLock(dataRoot, lockPath, holder, currentBoot, hooks)
|
||||
if ('refusal' in checked) {
|
||||
@@ -205,7 +204,8 @@ async function checkStaleLock(
|
||||
host: holder.host,
|
||||
bootId: holder.bootId,
|
||||
currentBootId: bootId,
|
||||
ageMinutes: Math.round((now - Date.parse(holder.acquiredAt)) / 60_000)
|
||||
ageMinutes: Math.round((now - Date.parse(holder.acquiredAt)) / 60_000),
|
||||
nonce: holder.nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,26 +291,34 @@ export function printOrcadStaleLockReclaimResult(
|
||||
;(result.status === 'reclaimed' ? console.log : console.error)(result.message)
|
||||
}
|
||||
|
||||
/** `orcad --reclaim-stale-lock [--data-root <dir>] [--yes] [--json]`. */
|
||||
/** `orcad --reclaim-stale-lock [--data-root <dir>] [--expect-nonce <nonce>] [--yes] [--json]`. */
|
||||
export async function runOrcadStaleLockReclaimCommandAndExit(argv: string[]): Promise<never> {
|
||||
const args = argv.slice(1)
|
||||
const json = args.includes('--json')
|
||||
const dataRootIndex = args.indexOf(ORCAD_STALE_LOCK_DATA_ROOT_FLAG)
|
||||
const dataRoot = dataRootIndex === -1 ? resolveUserDataPath() : args[dataRootIndex + 1]
|
||||
const valueFlags = [ORCAD_STALE_LOCK_DATA_ROOT_FLAG, ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG]
|
||||
const valueIndexes = valueFlags.map((flag) => args.indexOf(flag)).filter((index) => index !== -1)
|
||||
const valueOf = (flag: string): string | undefined => {
|
||||
const index = args.indexOf(flag)
|
||||
return index === -1 ? undefined : (args[index + 1] ?? '')
|
||||
}
|
||||
const dataRoot = valueOf(ORCAD_STALE_LOCK_DATA_ROOT_FLAG) ?? resolveUserDataPath()
|
||||
const expectNonce = valueOf(ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG)
|
||||
const unknown = args.filter(
|
||||
(arg, index) =>
|
||||
!['--yes', '--json', ORCAD_STALE_LOCK_DATA_ROOT_FLAG].includes(arg) &&
|
||||
(dataRootIndex === -1 || index !== dataRootIndex + 1)
|
||||
!['--yes', '--json', ...valueFlags].includes(arg) && !valueIndexes.includes(index - 1)
|
||||
)
|
||||
// A missing value must never fall through to another data root, nor read a flag as a path.
|
||||
if (!dataRoot?.trim() || dataRoot.startsWith('-') || unknown.length) {
|
||||
// A missing value must never fall through to another data root or nonce, nor read a flag as one.
|
||||
const badValue = (value: string | undefined): boolean =>
|
||||
value !== undefined && (!value.trim() || value.startsWith('-'))
|
||||
if (!dataRoot || badValue(dataRoot) || badValue(expectNonce) || unknown.length) {
|
||||
console.error(
|
||||
`Usage: orcad ${ORCAD_RECLAIM_STALE_LOCK_FLAG} [${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} <dir>] [--yes] [--json]`
|
||||
`Usage: orcad ${ORCAD_RECLAIM_STALE_LOCK_FLAG} [${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} <dir>] [${ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG} <nonce>] [--yes] [--json]`
|
||||
)
|
||||
process.exit(ORCAD_STALE_LOCK_RECLAIM_EXIT_CODES.refused)
|
||||
}
|
||||
const result = await reclaimStaleOrcadInstanceLock(dataRoot, {
|
||||
yes: args.includes('--yes'),
|
||||
...(expectNonce !== undefined ? { expectNonce } : {}),
|
||||
hintRuntime: { execPath: process.execPath, entry: process.argv[1] }
|
||||
})
|
||||
printOrcadStaleLockReclaimResult(result, json)
|
||||
|
||||
@@ -261,6 +261,8 @@ describe('orcad launch command session record', () => {
|
||||
})
|
||||
|
||||
it('still prints the detached PID last', () => {
|
||||
expect(orcadLaunchCommand(mac, spec)).toMatch(/&& cat '[^']+\/\.orcad-pid'$/u)
|
||||
const command = orcadLaunchCommand(mac, spec)
|
||||
expect(command).toMatch(/^orcad_pid='[^']+\/\.orcad-pid';/u)
|
||||
expect(command).toMatch(/&& cat "\$orcad_pid"$/u)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,7 +82,7 @@ function refreshPairing(
|
||||
}
|
||||
|
||||
/** A launched slot may bind another port than the forward in use, so forward anew. */
|
||||
async function followLaunchedServerPort(
|
||||
export async function followLaunchedServerPort(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
): Promise<void> {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
OrcadManagedPendingMigrationRow,
|
||||
OrcadManagedRecoveryResult,
|
||||
OrcadManagedRestartResult,
|
||||
OrcadManagedStaleLockReclaimRequest,
|
||||
OrcadManagedStaleLockReclaimResult,
|
||||
OrcadManagedRollbackResult,
|
||||
OrcadManagedRuntimeStatus,
|
||||
OrcadManagedStopResult
|
||||
@@ -36,6 +38,10 @@ export type ManagedOrcadPreloadApi = {
|
||||
}) => Promise<OrcadManagedRecoveryResult>
|
||||
/** Restarts from this client's SSH session; refused while terminals run. */
|
||||
restart: (args: { selector: string }) => Promise<OrcadManagedRestartResult>
|
||||
/** Without `yes`, only reports whether the host's instance lock may be cleared. */
|
||||
reclaimStaleLock: (
|
||||
args: { selector: string } & OrcadManagedStaleLockReclaimRequest
|
||||
) => Promise<OrcadManagedStaleLockReclaimResult>
|
||||
stop: (args: { selector: string }) => Promise<OrcadManagedStopResult>
|
||||
cancelStop: (args: { selector: string }) => Promise<OrcadManagedCancelStopResult>
|
||||
forget: (args: { selector: string }) => Promise<OrcadManagedForgetResult>
|
||||
@@ -60,6 +66,7 @@ export const managedOrcadApi: ManagedOrcadPreloadApi = {
|
||||
rollback: (args) => ipcRenderer.invoke('runtimeEnvironments:rollbackOrcad', args),
|
||||
recover: (args) => ipcRenderer.invoke('runtimeEnvironments:recoverOrcad', args),
|
||||
restart: (args) => ipcRenderer.invoke('runtimeEnvironments:restartOrcad', args),
|
||||
reclaimStaleLock: (args) => ipcRenderer.invoke('runtimeEnvironments:reclaimOrcadStaleLock', args),
|
||||
stop: (args) => ipcRenderer.invoke('runtimeEnvironments:stopOrcad', args),
|
||||
cancelStop: (args) => ipcRenderer.invoke('runtimeEnvironments:cancelOrcadStop', args),
|
||||
forget: (args) => ipcRenderer.invoke('runtimeEnvironments:forgetOrcad', args),
|
||||
|
||||
@@ -56,6 +56,7 @@ function api(next: OrcadDeltaMovePreview, result: OrcadDeltaMoveResult): Managed
|
||||
rollback: unused,
|
||||
recover: unused,
|
||||
restart: unused,
|
||||
reclaimStaleLock: unused,
|
||||
stop: unused,
|
||||
cancelStop: unused,
|
||||
forget: unused,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcadManagedStaleLockReclaimResult } from '../../../../shared/orcad-managed-runtime'
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import {
|
||||
canRecoverSshHostServer,
|
||||
SshHostRecoverServerDialog,
|
||||
staleLockReclaimCommandFrom
|
||||
} from './SshHostRecoverServerAction'
|
||||
|
||||
vi.mock('../ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
const COMMAND = '/opt/bun /slot/orcad.js --reclaim-stale-lock --data-root /home/dev/.orca'
|
||||
const DETAIL = `orcad holds the data root. If it is not running anywhere, run \`${COMMAND}\` on the machine it ran on.`
|
||||
const refusedStart: SshManagedServerStatus = {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-1',
|
||||
serving: { state: 'unverifiable', detail: DETAIL }
|
||||
}
|
||||
|
||||
const eligible: OrcadManagedStaleLockReclaimResult = {
|
||||
status: 'eligible',
|
||||
lockPath: '/home/dev/.orca/orcad.lock',
|
||||
message: 'eligible',
|
||||
evidence: {
|
||||
pid: 41,
|
||||
host: 'box',
|
||||
bootId: 'boot-old',
|
||||
currentBootId: 'boot-new',
|
||||
ageMinutes: 90,
|
||||
nonce: 'n-approved'
|
||||
}
|
||||
}
|
||||
const reclaimStaleLock = vi.fn(
|
||||
async (args: { yes: boolean }): Promise<OrcadManagedStaleLockReclaimResult> =>
|
||||
args.yes
|
||||
? { status: 'reclaimed', environmentId: 'env-1', message: 'cleared', lockPath: '/l' }
|
||||
: eligible
|
||||
)
|
||||
const roots: Root[] = []
|
||||
beforeEach(() => {
|
||||
reclaimStaleLock.mockClear()
|
||||
Object.assign(window, { api: { runtimeEnvironments: { managedOrcad: { reclaimStaleLock } } } })
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function render(onOpenChange = vi.fn(), onChanged = vi.fn()) {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
const show = (status: SshManagedServerStatus) =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<SshHostRecoverServerDialog
|
||||
status={status}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)
|
||||
})
|
||||
await show(refusedStart)
|
||||
return Object.assign(container, { show })
|
||||
}
|
||||
|
||||
function button(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
return [...container.querySelectorAll('button')].find((entry) => entry.textContent === text)!
|
||||
}
|
||||
|
||||
describe('Recover server', () => {
|
||||
it('is offered only when the slot’s own refusal named the command', () => {
|
||||
expect(canRecoverSshHostServer(refusedStart)).toBe(true)
|
||||
expect(
|
||||
canRecoverSshHostServer({
|
||||
kind: 'managed',
|
||||
environmentId: 'env-1',
|
||||
serving: { state: 'unverifiable', detail: 'orcad holds the data root.' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is not offered on the web client, which has no managed-server API', () => {
|
||||
Object.assign(window, { api: { runtimeEnvironments: {} } })
|
||||
expect(canRecoverSshHostServer(refusedStart)).toBe(false)
|
||||
})
|
||||
|
||||
it('names the refusal’s command for a manual recovery', () => {
|
||||
expect(staleLockReclaimCommandFrom(DETAIL)).toBe(COMMAND)
|
||||
})
|
||||
|
||||
it('clears the lock only after the user rules out another machine', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onChanged = vi.fn()
|
||||
const container = await render(onOpenChange, onChanged)
|
||||
expect(reclaimStaleLock).toHaveBeenCalledWith({ selector: 'env-1', yes: false })
|
||||
expect(container.textContent).toContain('boot-old')
|
||||
const confirm = button(container, 'Clear lock and restart')
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[role="checkbox"]')!.click())
|
||||
expect(confirm.disabled).toBe(false)
|
||||
expect(reclaimStaleLock).toHaveBeenCalledTimes(1)
|
||||
await act(async () => confirm.click())
|
||||
expect(reclaimStaleLock).toHaveBeenLastCalledWith({
|
||||
selector: 'env-1',
|
||||
yes: true,
|
||||
expectNonce: 'n-approved'
|
||||
})
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// The reclaim clears the note that offered it, so the status no longer passes the gate.
|
||||
it('stays open with its result once the status that offered it is gone', async () => {
|
||||
reclaimStaleLock.mockResolvedValueOnce(eligible).mockResolvedValueOnce({
|
||||
status: 'reclaimed',
|
||||
environmentId: 'env-1',
|
||||
message: 'cleared',
|
||||
lockPath: '/l',
|
||||
notRestarted: 'did not start'
|
||||
})
|
||||
const onOpenChange = vi.fn()
|
||||
const container = await render(onOpenChange)
|
||||
await container.show({ kind: 'managed', environmentId: 'env-1' })
|
||||
expect(container.textContent).toContain('boot-old')
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[role="checkbox"]')!.click())
|
||||
await act(async () => button(container, 'Clear lock and restart').click())
|
||||
expect(container.textContent).toContain('did not start')
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
// The lock is gone: no stale evidence, no second confirmation, no manual recovery.
|
||||
expect(container.textContent).not.toContain('boot-old')
|
||||
expect(button(container, 'Clear lock and restart')).toBeUndefined()
|
||||
expect(container.textContent).not.toContain('--reclaim-stale-lock')
|
||||
expect(container.textContent).not.toContain('rm /')
|
||||
expect(button(container, 'Close')).toBeDefined()
|
||||
})
|
||||
|
||||
it('offers only a delete by hand when the host can’t run the check', async () => {
|
||||
reclaimStaleLock.mockResolvedValueOnce({
|
||||
status: 'unsupported',
|
||||
message: 'no answer',
|
||||
lockPath: '/home/dev/.orca/orcad.lock'
|
||||
})
|
||||
const container = await render()
|
||||
expect(container.textContent).toContain('no answer')
|
||||
expect(container.textContent).toContain('rm /home/dev/.orca/orcad.lock')
|
||||
expect(container.textContent).not.toContain('--reclaim-stale-lock')
|
||||
expect(button(container, 'Clear lock and restart')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 'refused',
|
||||
message: 'still running',
|
||||
lockPath: '/home/dev/.orca/orcad.lock'
|
||||
} as const,
|
||||
{ status: 'no-answer', message: 'no answer understood' } as const,
|
||||
{ status: 'unsupported', message: 'too old to pin', reclaimWithoutPin: true } as const
|
||||
])('names the reclaim command, never a delete, for $status', async (result) => {
|
||||
reclaimStaleLock.mockResolvedValueOnce(result)
|
||||
const container = await render()
|
||||
expect(container.textContent).toContain(COMMAND)
|
||||
expect(container.textContent).not.toContain('rm /home')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { OrcadManagedStaleLockReclaimResult } from '../../../../shared/orcad-managed-runtime'
|
||||
import {
|
||||
ORCAD_RECLAIM_STALE_LOCK_FLAG,
|
||||
type OrcadStaleLockEvidence
|
||||
} from '../../../../shared/orcad-stale-lock-reclaim'
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { quotePosixShellUnlessPlain } from '../../../../shared/shell-argument-quoting'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Label } from '../ui/label'
|
||||
import { getManagedOrcadApi } from './use-managed-server-catalog'
|
||||
|
||||
/** Offered only when the slot's own refusal named the command, so that build supports it. */
|
||||
export function canRecoverSshHostServer(status: SshManagedServerStatus | undefined): boolean {
|
||||
return Boolean(
|
||||
getManagedOrcadApi() &&
|
||||
status?.kind === 'managed' &&
|
||||
status.serving?.state === 'unverifiable' &&
|
||||
status.serving.detail.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG)
|
||||
)
|
||||
}
|
||||
|
||||
/** The command the refusal names, for the user to run on the host by hand. */
|
||||
export function staleLockReclaimCommandFrom(detail: string): string | null {
|
||||
return (
|
||||
detail
|
||||
.match(/`([^`]*)`/g)
|
||||
?.find((quoted) => quoted.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG))
|
||||
?.slice(1, -1) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a managed server's instance lock that a start refused because another machine sharing
|
||||
* the home may hold it (#32177). The host checks first; only the user can rule that machine out.
|
||||
*/
|
||||
export function SshHostRecoverServerDialog({
|
||||
status,
|
||||
open,
|
||||
onOpenChange,
|
||||
onChanged
|
||||
}: {
|
||||
status: SshManagedServerStatus | undefined
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onChanged: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const api = getManagedOrcadApi()
|
||||
if (!api || !open) {
|
||||
return null
|
||||
}
|
||||
// Mounted per opening, so each one starts from a fresh check.
|
||||
return (
|
||||
<OpenRecoverServerDialog
|
||||
api={api}
|
||||
status={status}
|
||||
onOpenChange={onOpenChange}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenRecoverServerDialog({
|
||||
api,
|
||||
status,
|
||||
onOpenChange,
|
||||
onChanged
|
||||
}: {
|
||||
api: ManagedOrcadPreloadApi
|
||||
status: SshManagedServerStatus | undefined
|
||||
onOpenChange: (open: boolean) => void
|
||||
onChanged: () => void
|
||||
}): React.JSX.Element | null {
|
||||
// Frozen at opening: the reclaim itself clears the note that offered it, and a later status
|
||||
// push must neither close the dialog nor hide the result the user is reading.
|
||||
const [opened] = useState(() =>
|
||||
status?.kind === 'managed'
|
||||
? { environmentId: status.environmentId, detail: status.serving?.detail ?? '' }
|
||||
: null
|
||||
)
|
||||
const mountedRef = useMountedRef()
|
||||
const [check, setCheck] = useState<OrcadManagedStaleLockReclaimResult | null>(null)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
api.reclaimStaleLock({ selector: opened.environmentId, yes: false }).then(
|
||||
(next) => mountedRef.current && setCheck(next),
|
||||
(cause: unknown) => mountedRef.current && setError(messageOf(cause))
|
||||
)
|
||||
}, [api, mountedRef, opened])
|
||||
|
||||
if (!opened) {
|
||||
return null
|
||||
}
|
||||
const { environmentId, detail } = opened
|
||||
const reclaim = async (expectNonce: string): Promise<void> => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.reclaimStaleLock({ selector: environmentId, yes: true, expectNonce })
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (result.status === 'reclaimed') {
|
||||
onChanged()
|
||||
if (!result.notRestarted) {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A cleared lock ends the flow: its evidence and the confirmation no longer apply.
|
||||
setCheck(result)
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setError(messageOf(cause))
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
const eligible = check?.status === 'eligible'
|
||||
// The confirmation is pinned to the record the user saw; without its nonce there is none to pin.
|
||||
const approvedNonce = eligible ? check.evidence?.nonce : undefined
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => !next && !busy && onOpenChange(false)}>
|
||||
<DialogContent surface="security" showCloseButton={!busy}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.recoverServer.title', 'Recover Orca’s server')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.description',
|
||||
'Orca’s server on this host won’t start because a lock from an earlier run is still in place.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!check && !error ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate('auto.components.settings.recoverServer.checking', 'Checking the lock…')}
|
||||
</p>
|
||||
) : null}
|
||||
{eligible ? <EligibleLock evidence={check.evidence} /> : null}
|
||||
{eligible ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="ssh-host-recover-server-confirm"
|
||||
checked={confirmed}
|
||||
disabled={busy}
|
||||
onCheckedChange={(next) => setConfirmed(next === true)}
|
||||
/>
|
||||
<Label htmlFor="ssh-host-recover-server-confirm">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.confirmCheck',
|
||||
'No other machine sharing this home is running Orca'
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
{check && !eligible ? <CheckOutcome check={check} detail={detail} /> : null}
|
||||
{error ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<ManualReclaim detail={detail} />
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
{check?.status === 'reclaimed'
|
||||
? translate('auto.components.settings.recoverServer.close', 'Close')
|
||||
: translate('auto.components.settings.recoverServer.cancel', 'Cancel')}
|
||||
</Button>
|
||||
{eligible ? (
|
||||
<Button
|
||||
onClick={() => approvedNonce && void reclaim(approvedNonce)}
|
||||
disabled={busy || !confirmed || !approvedNonce}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.confirm',
|
||||
'Clear lock and restart'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function EligibleLock({
|
||||
evidence
|
||||
}: {
|
||||
evidence: OrcadStaleLockEvidence | undefined
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.eligible',
|
||||
'Nothing answers on this machine at its old address, but Orca can’t rule out another machine with the same name that shares this home folder.'
|
||||
)}
|
||||
</p>
|
||||
{evidence ? (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-muted-foreground">
|
||||
{translate('auto.components.settings.recoverServer.host', 'Host')}
|
||||
</dt>
|
||||
<dd className="font-mono">{evidence.host}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{translate('auto.components.settings.recoverServer.lockBoot', 'Written during boot')}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{evidence.bootId}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{translate('auto.components.settings.recoverServer.currentBoot', 'Current boot')}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{evidence.currentBootId}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{translate('auto.components.settings.recoverServer.written', 'Written')}
|
||||
</dt>
|
||||
<dd>{formatUiRelativeTime(-evidence.ageMinutes * 60_000)}</dd>
|
||||
</dl>
|
||||
) : null}
|
||||
<p className="font-medium text-status-warning">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.warning',
|
||||
'Only continue if no other machine sharing this home runs Orca for this profile.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckOutcome({
|
||||
check,
|
||||
detail
|
||||
}: {
|
||||
check: OrcadManagedStaleLockReclaimResult
|
||||
detail: string
|
||||
}): React.JSX.Element {
|
||||
if (check.status === 'reclaimed') {
|
||||
// The lock is gone, so neither the reclaim command nor a delete by hand applies.
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.reclaimedNotStarted',
|
||||
'The lock was cleared, but Orca’s server did not start.'
|
||||
)}
|
||||
</p>
|
||||
{check.notRestarted ? <p className="text-destructive">{check.notRestarted}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (check.status === 'none') {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.none',
|
||||
'There is no lock to clear any more. Reconnect to start this host’s Orca server.'
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-destructive">{check.message}</p>
|
||||
{check.status === 'refused' || check.status === 'no-answer' ? (
|
||||
<ManualReclaim detail={detail} />
|
||||
) : null}
|
||||
{/* Its orcad's own reclaim is safer than a delete; only a host without one gets rm. */}
|
||||
{check.status === 'unsupported' ? (
|
||||
check.reclaimWithoutPin ? (
|
||||
<ManualReclaim detail={detail} />
|
||||
) : (
|
||||
<ManualLockDelete lockPath={check.lockPath} />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualReclaim({ detail }: { detail: string }): React.JSX.Element {
|
||||
const command = staleLockReclaimCommandFrom(detail)
|
||||
if (!command) {
|
||||
return <ManualLockDelete lockPath={undefined} />
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.manualCommand',
|
||||
'Run this on the host, or delete the lock file shown in Details:'
|
||||
)}
|
||||
</p>
|
||||
<LiteralCommand command={command} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualLockDelete({ lockPath }: { lockPath: string | undefined }): React.JSX.Element {
|
||||
if (!lockPath) {
|
||||
return (
|
||||
<p className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.manualDelete',
|
||||
'Delete the lock file shown in Details once you are sure no other machine uses it.'
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.manualDeleteCommand',
|
||||
'Delete the lock on the host by hand:'
|
||||
)}
|
||||
</p>
|
||||
<LiteralCommand command={`rm ${quotePosixShellUnlessPlain(lockPath)}`} />
|
||||
<p className="font-medium text-status-warning">
|
||||
{translate(
|
||||
'auto.components.settings.recoverServer.manualDeleteWarning',
|
||||
'Only do this if no other machine sharing this home runs Orca for this profile.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LiteralCommand({ command }: { command: string }): React.JSX.Element {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-all rounded-md border border-border bg-background px-3 py-2 font-mono text-2xs leading-relaxed text-muted-foreground select-text">
|
||||
{command}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function messageOf(cause: unknown): string {
|
||||
return cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export function SshTargetCard({
|
||||
{state?.error && state.managedServer?.kind !== 'relay' ? (
|
||||
<p className="mt-0.5 text-xs text-destructive [overflow-wrap:anywhere]">{state.error}</p>
|
||||
) : null}
|
||||
<SshTargetServerStatusLine target={target} state={state} />
|
||||
<SshTargetServerStatusLine target={target} state={state} onOpenDialog={setServerDialog} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
||||
@@ -13,6 +13,8 @@ import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
|
||||
import { OpenPlainSshTerminalButton } from '../plain-ssh-terminal/OpenPlainSshTerminalButton'
|
||||
import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction'
|
||||
import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction'
|
||||
import { canRecoverSshHostServer, SshHostRecoverServerDialog } from './SshHostRecoverServerAction'
|
||||
import { Button } from '../ui/button'
|
||||
import { getManagedOrcadApi } from './use-managed-server-catalog'
|
||||
|
||||
export type SshTargetServerDialog =
|
||||
@@ -20,15 +22,18 @@ export type SshTargetServerDialog =
|
||||
| 'delta-move'
|
||||
| 'keep-server'
|
||||
| 'restart'
|
||||
| 'recover'
|
||||
| 'move-anyway'
|
||||
|
||||
/** Whether Orca on this SSH host serves it, and why not when it doesn't. */
|
||||
export function SshTargetServerStatusLine({
|
||||
target,
|
||||
state
|
||||
state,
|
||||
onOpenDialog
|
||||
}: {
|
||||
target: SshTarget
|
||||
state: SshConnectionState | undefined
|
||||
onOpenDialog?: (dialog: SshTargetServerDialog) => void
|
||||
}): React.JSX.Element | null {
|
||||
const line = sshHostServerStatusLine(target, state)
|
||||
if (!line) {
|
||||
@@ -54,6 +59,11 @@ export function SshTargetServerStatusLine({
|
||||
targetLabel={target.label}
|
||||
status={state?.managedServer}
|
||||
/>
|
||||
{onOpenDialog && canRecoverSshHostServer(state?.managedServer) ? (
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => onOpenDialog('recover')}>
|
||||
{translate('auto.components.settings.recoverServer.action', 'Recover server…')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,8 +82,9 @@ export function SshTargetServerMenuItems({
|
||||
const hasDetail = Boolean(sshHostServerStatusLine(target, state)?.detail)
|
||||
const sourceChanged = hasSshHostSourceChanged(target)
|
||||
const canRestart = canRestartSshHostServer(status)
|
||||
const canRecover = canRecoverSshHostServer(status)
|
||||
const canMoveAnyway = canMoveSshHostAnyway(status)
|
||||
if (!hasDetail && !sourceChanged && !canRestart && !canMoveAnyway) {
|
||||
if (!hasDetail && !sourceChanged && !canRestart && !canRecover && !canMoveAnyway) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
@@ -94,6 +105,11 @@ export function SshTargetServerMenuItems({
|
||||
{translate('auto.components.settings.restartServer.action', 'Restart Orca’s server…')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canRecover ? (
|
||||
<DropdownMenuItem onSelect={() => onOpenDialog('recover')}>
|
||||
{translate('auto.components.settings.recoverServer.action', 'Recover server…')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canMoveAnyway ? (
|
||||
<DropdownMenuItem onSelect={() => onOpenDialog('move-anyway')}>
|
||||
{translate('auto.components.settings.moveAnyway.action', 'Move anyway…')}
|
||||
@@ -151,6 +167,12 @@ export function SshTargetServerDialogs({
|
||||
onOpenChange={openChange('restart')}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<SshHostRecoverServerDialog
|
||||
status={status}
|
||||
open={dialog === 'recover'}
|
||||
onOpenChange={openChange('recover')}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<SshHostMoveAnywayDialog
|
||||
target={target}
|
||||
status={status}
|
||||
|
||||
@@ -13358,6 +13358,28 @@
|
||||
"attentionSharedNotCurrent": "Needs attention · The shared copy this folder uses is not Orca’s",
|
||||
"resultUpdateSkippedPlaces": "{{result}} · Update skipped in {{places}} places: {{reason}}",
|
||||
"resultUpdateSkippedAgent": "{{result}} · Update skipped in {{agent}}: {{reason}}"
|
||||
},
|
||||
"recoverServer": {
|
||||
"title": "Recover Orca’s server",
|
||||
"description": "Orca’s server on this host won’t start because a lock from an earlier run is still in place.",
|
||||
"checking": "Checking the lock…",
|
||||
"confirmCheck": "No other machine sharing this home is running Orca",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Clear lock and restart",
|
||||
"eligible": "Nothing answers on this machine at its old address, but Orca can’t rule out another machine with the same name that shares this home folder.",
|
||||
"host": "Host",
|
||||
"lockBoot": "Written during boot",
|
||||
"currentBoot": "Current boot",
|
||||
"warning": "Only continue if no other machine sharing this home runs Orca for this profile.",
|
||||
"none": "There is no lock to clear any more. Reconnect to start this host’s Orca server.",
|
||||
"manualCommand": "Run this on the host, or delete the lock file shown in Details:",
|
||||
"manualDelete": "Delete the lock file shown in Details once you are sure no other machine uses it.",
|
||||
"action": "Recover server…",
|
||||
"written": "Written",
|
||||
"manualDeleteCommand": "Delete the lock on the host by hand:",
|
||||
"manualDeleteWarning": "Only do this if no other machine sharing this home runs Orca for this profile.",
|
||||
"close": "Close",
|
||||
"reclaimedNotStarted": "The lock was cleared, but Orca’s server did not start."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OrcadMigrationBlocker } from './orcad-migration/orcad-migration-pr
|
||||
import type { OrcadMigrationSourceCutoverPhase } from './orcad-migration/orcad-migration-source-cutover'
|
||||
import type { OrcadDaemonRetirementVerdict } from './orcad-stop-request'
|
||||
import type { SshManagedServerLinkedProject } from './ssh-types'
|
||||
import type { OrcadStaleLockEvidence } from './orcad-stale-lock-reclaim'
|
||||
|
||||
export const ORCAD_MANAGED_REMOTE_PORT = 6_768
|
||||
|
||||
@@ -78,6 +79,40 @@ export type OrcadManagedRestartResult =
|
||||
| { outcome: 'terminals-running'; liveSessions: number | null }
|
||||
| { outcome: 'not-restarted'; reason: string }
|
||||
|
||||
/** A dry run, or a confirmation pinned to the lock record that dry run showed. */
|
||||
export type OrcadManagedStaleLockReclaimRequest =
|
||||
| { yes: false }
|
||||
| { yes: true; expectNonce: string }
|
||||
|
||||
/** `orcad --reclaim-stale-lock` run on a managed host's active slot (#32177). */
|
||||
export type OrcadManagedStaleLockReclaimResult =
|
||||
| {
|
||||
status: 'none' | 'eligible' | 'refused'
|
||||
message: string
|
||||
lockPath: string
|
||||
evidence?: OrcadStaleLockEvidence
|
||||
}
|
||||
/** `notRestarted` says why the server did not start again once its lock was cleared. */
|
||||
| {
|
||||
status: 'reclaimed'
|
||||
environmentId: string
|
||||
message: string
|
||||
lockPath: string
|
||||
/** The record the reclaim acted on. */
|
||||
evidence?: OrcadStaleLockEvidence
|
||||
notRestarted?: string
|
||||
}
|
||||
/**
|
||||
* The host can't run the command from here; nothing changed. `reclaimWithoutPin`: its orcad has
|
||||
* the reclaim, but not the pin a confirmation from here needs, so the user runs the reclaim
|
||||
* themselves. Otherwise `lockPath` names the lock to delete by hand.
|
||||
*/
|
||||
| { status: 'unsupported'; message: string; lockPath?: string; reclaimWithoutPin?: true }
|
||||
/** The slot runs the command but gave no answer this client understands; nothing is known. */
|
||||
| { status: 'no-answer'; message: string }
|
||||
/** Another lifecycle step holds the host; nothing changed. */
|
||||
| { status: 'busy'; message: string }
|
||||
|
||||
/**
|
||||
* Unlinks the server from this desktop without reaching its host, for a host that is gone or
|
||||
* unreachable. Never a claim of exit: the server may still run there, so the verdict stays
|
||||
|
||||
@@ -7,10 +7,25 @@ import { quotePosixShellUnlessPlain } from './shell-argument-quoting'
|
||||
|
||||
export const ORCAD_RECLAIM_STALE_LOCK_FLAG = '--reclaim-stale-lock'
|
||||
export const ORCAD_STALE_LOCK_DATA_ROOT_FLAG = '--data-root'
|
||||
/**
|
||||
* Pins `--yes` to the record a dry run showed. Clients also grep a slot's bundle for it: a build
|
||||
* that knows it can be confirmed safely, while one that only knows the reclaim flag cannot.
|
||||
*/
|
||||
export const ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG = '--expect-nonce'
|
||||
|
||||
/** `--json` prints one of these; the exit code names the status too. */
|
||||
export type OrcadStaleLockReclaimStatus = 'none' | 'eligible' | 'refused' | 'reclaimed'
|
||||
|
||||
export type OrcadStaleLockEvidence = {
|
||||
pid: number
|
||||
host: string
|
||||
bootId: string
|
||||
currentBootId: string
|
||||
ageMinutes: number
|
||||
/** The record's own nonce, for `--expect-nonce`; absent from builds before the pin. */
|
||||
nonce?: string
|
||||
}
|
||||
|
||||
// Why `none` is not 0: a command pointed at the wrong data root must never read as a recovery.
|
||||
export const ORCAD_STALE_LOCK_RECLAIM_EXIT_CODES: Record<OrcadStaleLockReclaimStatus, number> = {
|
||||
reclaimed: 0,
|
||||
|
||||
Reference in New Issue
Block a user