@viraatdas/rudder 3.1.8 → 3.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attach-session.d.ts +91 -0
- package/dist/attach-session.js +290 -0
- package/dist/attach-session.js.map +1 -0
- package/dist/cloud.js +304 -224
- package/dist/cloud.js.map +1 -1
- package/dist/move-home.d.ts +2 -0
- package/dist/move-home.js +58 -4
- package/dist/move-home.js.map +1 -1
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/predict.d.ts +10 -0
- package/dist/predict.js +28 -1
- package/dist/predict.js.map +1 -1
- package/package.json +1 -1
package/dist/cloud.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { AttachSession, containsCtrlC } from "./attach-session.js";
|
|
4
4
|
import fsp from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
@@ -2465,7 +2465,7 @@ async function attachToWorkspaceResult(result, options) {
|
|
|
2465
2465
|
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2466
2466
|
return;
|
|
2467
2467
|
}
|
|
2468
|
-
await
|
|
2468
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2469
2469
|
}
|
|
2470
2470
|
async function workspaceAttachById(workspaceId, options) {
|
|
2471
2471
|
if (options.json) {
|
|
@@ -2478,7 +2478,7 @@ async function workspaceAttachById(workspaceId, options) {
|
|
|
2478
2478
|
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2479
2479
|
return;
|
|
2480
2480
|
}
|
|
2481
|
-
await
|
|
2481
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2482
2482
|
}
|
|
2483
2483
|
async function lookupWorkspaceForRepo(options) {
|
|
2484
2484
|
void options;
|
|
@@ -2745,45 +2745,101 @@ async function attach(args, options) {
|
|
|
2745
2745
|
await openOnHome(false, { ...options, reattach: true });
|
|
2746
2746
|
return;
|
|
2747
2747
|
}
|
|
2748
|
-
await
|
|
2748
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
2749
2749
|
}
|
|
2750
2750
|
const ATTACH_PING_MS = 5_000;
|
|
2751
2751
|
const ATTACH_PONG_TIMEOUT_MS = 20_000;
|
|
2752
2752
|
const ATTACH_SLEEP_GAP_MS = 15_000;
|
|
2753
|
+
// Retry at once (a blip, a deploy), then back off; never more than 5s apart,
|
|
2754
|
+
// so coming back to a reachable cloud never waits long.
|
|
2755
|
+
const ATTACH_RETRY_DELAYS_MS = [250, 1_000, 2_000, 3_000, 5_000];
|
|
2756
|
+
// About half an hour of failed tries while awake (asleep, timers do not run).
|
|
2757
|
+
const ATTACH_MAX_RETRIES = 360;
|
|
2758
|
+
const ATTACH_REOPEN_TIMEOUT_MS = 20_000;
|
|
2759
|
+
const ATTACH_HANDSHAKE_TIMEOUT_MS = 20_000;
|
|
2753
2760
|
// The remote TUI pushes the kitty keyboard enhancement flags once, in its own
|
|
2754
2761
|
// setup_terminal(), and the worker only keeps the TAIL of its output — so by the
|
|
2755
2762
|
// time an attach connects, that push is long gone and this terminal never sees
|
|
2756
2763
|
// it. Same class of bug as the mouse modes below. Without the protocol, macOS
|
|
2757
2764
|
// terminals report Option+h as the character it TYPES (˙ on a US layout) rather
|
|
2758
2765
|
// than Alt+h, so ⌥h stops hiding panes, and Alt+[ / Alt+] cannot be encoded at
|
|
2759
|
-
// all.
|
|
2760
|
-
// REPORT_ALTERNATE_KEYS (4). Terminals without
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
}
|
|
2766
|
-
// Stay attached through a control-plane deploy or a machine restart: say what
|
|
2767
|
-
// happened and reconnect, instead of dropping back to the shell silently.
|
|
2766
|
+
// all. AttachSession.hold pushes exactly what the remote asked for:
|
|
2767
|
+
// DISAMBIGUATE_ESCAPE_CODES (1) | REPORT_ALTERNATE_KEYS (4). Terminals without
|
|
2768
|
+
// the protocol ignore it.
|
|
2769
|
+
// Stay attached through a dropped link, a control-plane deploy or a machine
|
|
2770
|
+
// restart. One AttachSession holds the terminal the whole time, so a drop is a
|
|
2771
|
+
// badge in the corner and a reconnect, never a trip back to the shell.
|
|
2768
2772
|
async function attachWithReconnect(target, options, reopen) {
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2773
|
+
if (options.latencyProbe) {
|
|
2774
|
+
return await runAttach(target, options);
|
|
2775
|
+
}
|
|
2776
|
+
const stdin = process.stdin;
|
|
2777
|
+
const stdout = process.stdout;
|
|
2778
|
+
const session = new AttachSession(target.label, stdin, stdout, {
|
|
2779
|
+
interactive: Boolean(stdin.isTTY && stdout.isTTY) && !options.json,
|
|
2780
|
+
predict: process.env.RUDDER_PREDICT !== "0",
|
|
2781
|
+
});
|
|
2782
|
+
let result = "failed";
|
|
2783
|
+
let gaveUp = false;
|
|
2784
|
+
try {
|
|
2785
|
+
result = await runAttach(target, { ...options, quietBanner: false }, session);
|
|
2786
|
+
let attempts = 0;
|
|
2787
|
+
while (result === "dropped" && !session.hasLeft) {
|
|
2788
|
+
attempts += 1;
|
|
2789
|
+
if (attempts > ATTACH_MAX_RETRIES) {
|
|
2790
|
+
gaveUp = true;
|
|
2791
|
+
break;
|
|
2792
|
+
}
|
|
2793
|
+
session.noteAttempt(attempts);
|
|
2794
|
+
const delay = ATTACH_RETRY_DELAYS_MS[Math.min(attempts, ATTACH_RETRY_DELAYS_MS.length) - 1] ?? 5_000;
|
|
2795
|
+
if (await session.wait(delay)) {
|
|
2796
|
+
break;
|
|
2797
|
+
}
|
|
2798
|
+
try {
|
|
2799
|
+
if (reopen) {
|
|
2800
|
+
// Wakes the machine if it was what went away. Bounded: a hung
|
|
2801
|
+
// request must not stall the retries, and Ctrl+C twice still leaves.
|
|
2802
|
+
await raceLeave(session, withTimeout(reopen(), ATTACH_REOPEN_TIMEOUT_MS));
|
|
2803
|
+
}
|
|
2804
|
+
if (session.hasLeft) {
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2807
|
+
result = await runAttach(target, { ...options, quietBanner: true }, session);
|
|
2808
|
+
attempts = 0;
|
|
2809
|
+
}
|
|
2810
|
+
catch {
|
|
2811
|
+
result = "dropped";
|
|
2812
|
+
}
|
|
2779
2813
|
}
|
|
2780
|
-
|
|
2781
|
-
result = "
|
|
2814
|
+
if (session.hasLeft && result === "dropped") {
|
|
2815
|
+
result = "exited";
|
|
2782
2816
|
}
|
|
2783
2817
|
}
|
|
2818
|
+
finally {
|
|
2819
|
+
session.release();
|
|
2820
|
+
}
|
|
2821
|
+
if (gaveUp) {
|
|
2822
|
+
process.stderr.write(`\nCould not reach Rudder Cloud for a while. Your agents are still running there; run \`rudder cloud\` to come back.\n`);
|
|
2823
|
+
}
|
|
2824
|
+
else if (session.hasLeft && result === "exited") {
|
|
2825
|
+
process.stderr.write(`\nLeft ${target.label}. Your agents keep running; run \`rudder cloud\` to come back.\n`);
|
|
2826
|
+
}
|
|
2784
2827
|
return result;
|
|
2785
2828
|
}
|
|
2786
|
-
|
|
2829
|
+
function withTimeout(promise, ms) {
|
|
2830
|
+
return new Promise((resolve, reject) => {
|
|
2831
|
+
const timer = setTimeout(() => reject(new Error("timed out")), ms);
|
|
2832
|
+
timer.unref?.();
|
|
2833
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
function raceLeave(session, promise) {
|
|
2837
|
+
return new Promise((resolve, reject) => {
|
|
2838
|
+
const off = session.onLeave(() => resolve(undefined));
|
|
2839
|
+
promise.then((value) => { off(); resolve(value); }, (error) => { off(); reject(error); });
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
async function runAttach(target, options, sharedSession) {
|
|
2787
2843
|
const client = await cloudClient({ requireToken: true });
|
|
2788
2844
|
const baseUrl = client.baseUrl;
|
|
2789
2845
|
const state = await loadCloudAuth();
|
|
@@ -2792,12 +2848,23 @@ async function runAttach(target, options) {
|
|
|
2792
2848
|
if (!token) {
|
|
2793
2849
|
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
2794
2850
|
}
|
|
2795
|
-
const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
|
2796
|
-
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
|
|
2797
2851
|
const stdin = process.stdin;
|
|
2798
2852
|
const stdout = process.stdout;
|
|
2799
2853
|
const probeMode = Boolean(options.latencyProbe);
|
|
2800
2854
|
const isInteractive = Boolean(stdin.isTTY && stdout.isTTY) && !probeMode;
|
|
2855
|
+
// A shared session outlives this connection (attachWithReconnect); an own
|
|
2856
|
+
// one is released with it.
|
|
2857
|
+
const session = sharedSession ?? new AttachSession(target.label, stdin, stdout, {
|
|
2858
|
+
interactive: isInteractive && !options.json,
|
|
2859
|
+
predict: process.env.RUDDER_PREDICT !== "0",
|
|
2860
|
+
});
|
|
2861
|
+
const reconnects = Boolean(sharedSession);
|
|
2862
|
+
// The dashboard is already on the screen (a reconnect, not a first attach).
|
|
2863
|
+
const resuming = session.connections > 0 && session.drewFrame;
|
|
2864
|
+
// The relay hands control back to this same terminal when it returns,
|
|
2865
|
+
// instead of keeping its dead socket in charge until a ping times out.
|
|
2866
|
+
const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
|
2867
|
+
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach?client=${encodeURIComponent(session.clientId)}`;
|
|
2801
2868
|
return await new Promise((resolve, reject) => {
|
|
2802
2869
|
const socket = new WebSocket(wsUrl, {
|
|
2803
2870
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -2810,6 +2877,8 @@ async function runAttach(target, options) {
|
|
|
2810
2877
|
// are bounded (~100KB worst-case repaint), so the bandwidth cost of
|
|
2811
2878
|
// skipping compression is negligible next to the latency it removes.
|
|
2812
2879
|
perMessageDeflate: false,
|
|
2880
|
+
// A connect into a dead network can otherwise sit for minutes.
|
|
2881
|
+
handshakeTimeout: ATTACH_HANDSHAKE_TIMEOUT_MS,
|
|
2813
2882
|
});
|
|
2814
2883
|
socket.binaryType = "nodebuffer";
|
|
2815
2884
|
// Every deliberate close goes through socket.close() from this side; any
|
|
@@ -2822,19 +2891,18 @@ async function runAttach(target, options) {
|
|
|
2822
2891
|
});
|
|
2823
2892
|
let opened = false;
|
|
2824
2893
|
let cleaned = false;
|
|
2825
|
-
let firstFrameRendered =
|
|
2826
|
-
|
|
2894
|
+
let firstFrameRendered = resuming;
|
|
2895
|
+
// What the relay last said about the machine end: "down" only once it has
|
|
2896
|
+
// said so, so a key typed before the first status still goes through.
|
|
2897
|
+
let workerState = "unknown";
|
|
2827
2898
|
let hasControl = true;
|
|
2828
|
-
let readOnlyNoticeShown = false;
|
|
2829
2899
|
let result = "failed";
|
|
2830
|
-
const splashAllowed = isInteractive && !options.json && !options.quietBanner;
|
|
2900
|
+
const splashAllowed = isInteractive && !options.json && !options.quietBanner && !resuming;
|
|
2831
2901
|
const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
|
|
2832
2902
|
// Predictive local echo (src/predict.ts): typed characters appear at the
|
|
2833
2903
|
// caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
|
|
2834
|
-
// turns
|
|
2835
|
-
const predictor =
|
|
2836
|
-
? new EchoPredictor(stdout.columns ?? 120, stdout.rows ?? 32, (text) => stdout.write(text))
|
|
2837
|
-
: null;
|
|
2904
|
+
// turns the drawing off; the model stays, the reconnect badge uses it.
|
|
2905
|
+
const predictor = session.predictor;
|
|
2838
2906
|
const sendResize = () => {
|
|
2839
2907
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
2840
2908
|
return;
|
|
@@ -2844,24 +2912,36 @@ async function runAttach(target, options) {
|
|
|
2844
2912
|
predictor?.resize(cols, rows);
|
|
2845
2913
|
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
2846
2914
|
};
|
|
2915
|
+
// Ask the dashboard to draw every cell again. The replay the relay sends
|
|
2916
|
+
// on attach is only the tail of its output; this makes the screen exact.
|
|
2917
|
+
const requestRepaint = () => {
|
|
2918
|
+
if (socket.readyState === WebSocket.OPEN && !probeMode) {
|
|
2919
|
+
socket.send(JSON.stringify({ type: "repaint" }));
|
|
2920
|
+
}
|
|
2921
|
+
};
|
|
2922
|
+
const finish = (value, message) => {
|
|
2923
|
+
cleanup(false);
|
|
2924
|
+
if (message) {
|
|
2925
|
+
process.stderr.write(message);
|
|
2926
|
+
}
|
|
2927
|
+
resolve(value);
|
|
2928
|
+
};
|
|
2847
2929
|
let lastCtrlC = 0;
|
|
2848
2930
|
const onStdin = (chunk) => {
|
|
2931
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
2849
2932
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
2850
2933
|
return;
|
|
2851
2934
|
}
|
|
2852
|
-
const
|
|
2853
|
-
|
|
2854
|
-
//
|
|
2855
|
-
//
|
|
2856
|
-
|
|
2857
|
-
if (hasCtrlC && !workerConnected) {
|
|
2935
|
+
const hasCtrlC = containsCtrlC(buffer);
|
|
2936
|
+
// While the loading splash is up (no remote frame has rendered yet),
|
|
2937
|
+
// Ctrl+C should cancel the local attach instead of being forwarded to
|
|
2938
|
+
// a remote dashboard the user can't see.
|
|
2939
|
+
if (hasCtrlC && !firstFrameRendered) {
|
|
2858
2940
|
try {
|
|
2859
|
-
socket.close(1000, "client-cancel
|
|
2941
|
+
socket.close(1000, "client-cancel");
|
|
2860
2942
|
}
|
|
2861
2943
|
catch { /* ignore */ }
|
|
2862
|
-
|
|
2863
|
-
process.stderr.write("\nDetached from disconnected cloud worker.\n");
|
|
2864
|
-
resolve("exited");
|
|
2944
|
+
finish("exited", "\nCancelled.\n");
|
|
2865
2945
|
return;
|
|
2866
2946
|
}
|
|
2867
2947
|
// An observer must always be able to leave without sending an interrupt
|
|
@@ -2871,33 +2951,23 @@ async function runAttach(target, options) {
|
|
|
2871
2951
|
socket.close(1000, "read-only-client-cancel");
|
|
2872
2952
|
}
|
|
2873
2953
|
catch { /* ignore */ }
|
|
2874
|
-
|
|
2875
|
-
process.stderr.write("\nDetached read-only cloud attachment.\n");
|
|
2876
|
-
resolve("exited");
|
|
2954
|
+
finish("exited", "\nDetached read-only cloud attachment.\n");
|
|
2877
2955
|
return;
|
|
2878
2956
|
}
|
|
2879
2957
|
if (!hasControl) {
|
|
2880
|
-
|
|
2881
|
-
process.stderr.write("\nThis cloud attachment is read-only while another terminal controls the session.\n");
|
|
2882
|
-
readOnlyNoticeShown = true;
|
|
2883
|
-
}
|
|
2958
|
+
session.showStatus("Read-only · another terminal has control");
|
|
2884
2959
|
return;
|
|
2885
2960
|
}
|
|
2886
|
-
//
|
|
2887
|
-
// Ctrl+C
|
|
2888
|
-
//
|
|
2889
|
-
if (hasCtrlC &&
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
reject(new Error("Cloud attach cancelled"));
|
|
2897
|
-
}
|
|
2898
|
-
else {
|
|
2899
|
-
process.stderr.write("\nCancelled.\n");
|
|
2900
|
-
resolve("exited");
|
|
2961
|
+
// The machine end is away (its dashboard restarting, the machine
|
|
2962
|
+
// waking). Forwarding Ctrl+C would kill the next dashboard as soon as it
|
|
2963
|
+
// is back, so it never goes; pressed twice it leaves this attach.
|
|
2964
|
+
if (hasCtrlC && workerState === "down") {
|
|
2965
|
+
if (session.ctrlCToLeave()) {
|
|
2966
|
+
try {
|
|
2967
|
+
socket.close(1000, "client-cancel-disconnected");
|
|
2968
|
+
}
|
|
2969
|
+
catch { /* ignore */ }
|
|
2970
|
+
finish("exited");
|
|
2901
2971
|
}
|
|
2902
2972
|
return;
|
|
2903
2973
|
}
|
|
@@ -2907,13 +2977,11 @@ async function runAttach(target, options) {
|
|
|
2907
2977
|
if (hasCtrlC && firstFrameRendered) {
|
|
2908
2978
|
const now = Date.now();
|
|
2909
2979
|
if (now - lastCtrlC < 2000) {
|
|
2910
|
-
process.stderr.write("\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
2911
2980
|
try {
|
|
2912
2981
|
socket.close(1000, "client-force-quit");
|
|
2913
2982
|
}
|
|
2914
2983
|
catch { /* ignore */ }
|
|
2915
|
-
|
|
2916
|
-
resolve("exited");
|
|
2984
|
+
finish("exited", "\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
2917
2985
|
return;
|
|
2918
2986
|
}
|
|
2919
2987
|
lastCtrlC = now;
|
|
@@ -2926,6 +2994,7 @@ async function runAttach(target, options) {
|
|
|
2926
2994
|
sendResize();
|
|
2927
2995
|
}
|
|
2928
2996
|
splash?.redraw();
|
|
2997
|
+
session.noteResize();
|
|
2929
2998
|
};
|
|
2930
2999
|
// Liveness. A laptop that slept leaves this socket half-open: nothing
|
|
2931
3000
|
// errors, the screen just freezes, and the relay has meanwhile dropped us
|
|
@@ -2963,71 +3032,46 @@ async function runAttach(target, options) {
|
|
|
2963
3032
|
}
|
|
2964
3033
|
}, ATTACH_PING_MS);
|
|
2965
3034
|
liveness.unref?.();
|
|
2966
|
-
|
|
3035
|
+
// Leaving (Ctrl+C twice) while this connection is still being made.
|
|
3036
|
+
const offLeave = session.onLeave(() => {
|
|
3037
|
+
if (!opened && !cleaned) {
|
|
3038
|
+
try {
|
|
3039
|
+
socket.terminate();
|
|
3040
|
+
}
|
|
3041
|
+
catch { /* ignore */ }
|
|
3042
|
+
cleanup(false);
|
|
3043
|
+
resolve("exited");
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
// keepTerminal: the link dropped (or a reconnect could not open) and the
|
|
3047
|
+
// caller reconnects, so the screen, raw mode and the keys stay ours.
|
|
3048
|
+
const cleanup = (keepTerminal) => {
|
|
2967
3049
|
if (cleaned) {
|
|
2968
3050
|
return;
|
|
2969
3051
|
}
|
|
2970
3052
|
cleaned = true;
|
|
3053
|
+
offLeave();
|
|
2971
3054
|
clearInterval(liveness);
|
|
2972
|
-
predictor?.dispose();
|
|
2973
3055
|
stdin.off("data", onStdin);
|
|
2974
3056
|
stdout.off("resize", onResize);
|
|
2975
3057
|
process.off("SIGINT", onSigint);
|
|
2976
|
-
splash?.
|
|
2977
|
-
if (
|
|
2978
|
-
|
|
2979
|
-
// pushed, then disable local mouse capture before leaving raw mode so the
|
|
2980
|
-
// user's shell prompt does not get spammed with mouse SGR bytes.
|
|
2981
|
-
try {
|
|
2982
|
-
stdout.write(KITTY_KEYBOARD_POP);
|
|
2983
|
-
}
|
|
2984
|
-
catch {
|
|
2985
|
-
// ignore
|
|
2986
|
-
}
|
|
2987
|
-
try {
|
|
2988
|
-
stdout.write(KITTY_KEYBOARD_POP);
|
|
2989
|
-
stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
|
|
2990
|
-
}
|
|
2991
|
-
catch {
|
|
2992
|
-
// ignore
|
|
2993
|
-
}
|
|
2994
|
-
// Release the tab title so the user's shell prompt can rewrite it.
|
|
2995
|
-
try {
|
|
2996
|
-
stdout.write("\x1b]0;\x07");
|
|
2997
|
-
}
|
|
2998
|
-
catch {
|
|
2999
|
-
// ignore
|
|
3000
|
-
}
|
|
3001
|
-
try {
|
|
3002
|
-
stdin.setRawMode(false);
|
|
3003
|
-
}
|
|
3004
|
-
catch {
|
|
3005
|
-
// ignore
|
|
3006
|
-
}
|
|
3058
|
+
splash?.stop();
|
|
3059
|
+
if (keepTerminal) {
|
|
3060
|
+
session.enterGap();
|
|
3007
3061
|
}
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
// attach session (see AttachSplash.handoff). Restore the main buffer
|
|
3011
|
-
// and cursor now so the user is dropped back to their shell prompt
|
|
3012
|
-
// instead of a blank alt-screen with the dashboard frozen on it.
|
|
3013
|
-
try {
|
|
3014
|
-
stdout.write("\x1b[?1049l\x1b[?25h");
|
|
3015
|
-
}
|
|
3016
|
-
catch {
|
|
3017
|
-
// ignore
|
|
3018
|
-
}
|
|
3062
|
+
else {
|
|
3063
|
+
session.release();
|
|
3019
3064
|
}
|
|
3020
|
-
stdin.pause();
|
|
3021
3065
|
};
|
|
3022
3066
|
const onSigint = () => {
|
|
3023
3067
|
// Belt-and-suspenders: if raw mode failed for any reason, Node still
|
|
3024
3068
|
// sees SIGINT. Close cleanly so the user never gets a frozen terminal.
|
|
3025
|
-
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
3026
3069
|
try {
|
|
3027
3070
|
socket.close(1000, "sigint");
|
|
3028
3071
|
}
|
|
3029
3072
|
catch { /* ignore */ }
|
|
3030
|
-
cleanup();
|
|
3073
|
+
cleanup(false);
|
|
3074
|
+
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
3031
3075
|
if (!opened) {
|
|
3032
3076
|
reject(new Error("Cloud attach cancelled"));
|
|
3033
3077
|
}
|
|
@@ -3123,6 +3167,9 @@ async function runAttach(target, options) {
|
|
|
3123
3167
|
};
|
|
3124
3168
|
socket.on("open", () => {
|
|
3125
3169
|
opened = true;
|
|
3170
|
+
session.connections += 1;
|
|
3171
|
+
// From here this connection reads the keys; the gap hands them over.
|
|
3172
|
+
session.exitGap();
|
|
3126
3173
|
// Measure the round trip at once: predictive echo waits on it.
|
|
3127
3174
|
try {
|
|
3128
3175
|
socket.ping();
|
|
@@ -3149,48 +3196,35 @@ async function runAttach(target, options) {
|
|
|
3149
3196
|
}
|
|
3150
3197
|
// Label this terminal tab so the user can find the right rudder cloud
|
|
3151
3198
|
// session at a glance instead of squinting at a row of "ghostty" tabs.
|
|
3152
|
-
|
|
3153
|
-
|
|
3199
|
+
if (isInteractive) {
|
|
3200
|
+
try {
|
|
3201
|
+
stdout.write(`\x1b]0;Rudder cloud: ${target.label}\x07`);
|
|
3202
|
+
}
|
|
3203
|
+
catch {
|
|
3204
|
+
// ignore
|
|
3205
|
+
}
|
|
3154
3206
|
}
|
|
3155
|
-
|
|
3156
|
-
|
|
3207
|
+
// The alternate screen, raw keys, local mouse capture (the remote's own
|
|
3208
|
+
// enable sequences arrive as output, which the local terminal never
|
|
3209
|
+
// treats as mode switches) and the kitty keyboard flags.
|
|
3210
|
+
if (isInteractive) {
|
|
3211
|
+
session.hold();
|
|
3157
3212
|
}
|
|
3158
3213
|
if (splash) {
|
|
3159
3214
|
splash.start();
|
|
3160
3215
|
splash.setStatus(`Booting cloud workspace · ${target.label}`);
|
|
3161
3216
|
}
|
|
3162
|
-
else if (!options.json && !options.quietBanner) {
|
|
3217
|
+
else if (!options.json && !options.quietBanner && !resuming) {
|
|
3163
3218
|
const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
|
|
3164
3219
|
process.stderr.write(`Attached to ${target.label}${tail}\n`);
|
|
3165
3220
|
}
|
|
3166
3221
|
sendResize();
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
}
|
|
3171
|
-
catch {
|
|
3172
|
-
// ignore
|
|
3173
|
-
}
|
|
3174
|
-
// Enable mouse tracking on the LOCAL terminal so scroll/click events
|
|
3175
|
-
// get forwarded into stdin. The remote rudder TUI also emits these
|
|
3176
|
-
// enable sequences, but they arrive as PTY output bytes and the local
|
|
3177
|
-
// terminal does not interpret output bytes as mode toggles. We have
|
|
3178
|
-
// to enable mouse capture locally too.
|
|
3179
|
-
try {
|
|
3180
|
-
stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
|
|
3181
|
-
}
|
|
3182
|
-
catch {
|
|
3183
|
-
// ignore
|
|
3184
|
-
}
|
|
3185
|
-
// Ask the local terminal for the kitty keyboard protocol on the remote's
|
|
3186
|
-
// behalf, so real modifiers reach the remote dashboard.
|
|
3187
|
-
try {
|
|
3188
|
-
stdout.write(KITTY_KEYBOARD_PUSH);
|
|
3189
|
-
}
|
|
3190
|
-
catch {
|
|
3191
|
-
// ignore
|
|
3192
|
-
}
|
|
3222
|
+
// Typed while the link was down: sent first, in order.
|
|
3223
|
+
const queued = session.takeQueued();
|
|
3224
|
+
if (queued) {
|
|
3225
|
+
socket.send(queued, { binary: true });
|
|
3193
3226
|
}
|
|
3227
|
+
requestRepaint();
|
|
3194
3228
|
if (!probeMode) {
|
|
3195
3229
|
stdin.resume();
|
|
3196
3230
|
stdin.on("data", onStdin);
|
|
@@ -3232,6 +3266,14 @@ async function runAttach(target, options) {
|
|
|
3232
3266
|
probeEchoWaiter?.(performance.now());
|
|
3233
3267
|
return;
|
|
3234
3268
|
}
|
|
3269
|
+
if (!session.drewFrame) {
|
|
3270
|
+
session.drewFrame = true;
|
|
3271
|
+
// A link that dropped while the loading screen was up leaves its
|
|
3272
|
+
// text behind; the splash wipes its own on handoff.
|
|
3273
|
+
if (!splash && isInteractive) {
|
|
3274
|
+
stdout.write("\x1b[2J\x1b[H");
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3235
3277
|
if (!firstFrameRendered) {
|
|
3236
3278
|
firstFrameRendered = true;
|
|
3237
3279
|
splash?.handoff();
|
|
@@ -3254,18 +3296,20 @@ async function runAttach(target, options) {
|
|
|
3254
3296
|
handleControlText(text);
|
|
3255
3297
|
});
|
|
3256
3298
|
socket.on("close", (code, reason) => {
|
|
3257
|
-
|
|
3299
|
+
const dropped = opened && !closedByUs && code !== 4410 && code !== 1000;
|
|
3300
|
+
// A reconnect that could not open is part of the same gap.
|
|
3301
|
+
cleanup(reconnects && session.connections > 0 && (dropped || !opened));
|
|
3258
3302
|
if (!opened) {
|
|
3259
3303
|
const reasonText = reason && reason.length ? reason.toString("utf8") : "";
|
|
3260
3304
|
reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
|
|
3261
3305
|
return;
|
|
3262
3306
|
}
|
|
3263
3307
|
// 4410: the workspace was deleted; nothing to come back to.
|
|
3264
|
-
resolve(
|
|
3308
|
+
resolve(dropped ? "dropped" : result);
|
|
3265
3309
|
});
|
|
3266
3310
|
socket.on("error", (err) => {
|
|
3267
3311
|
if (!opened) {
|
|
3268
|
-
cleanup();
|
|
3312
|
+
cleanup(reconnects && session.connections > 0);
|
|
3269
3313
|
reject(new Error(`Cloud attach failed: ${err.message}`));
|
|
3270
3314
|
}
|
|
3271
3315
|
});
|
|
@@ -3295,63 +3339,74 @@ async function runAttach(target, options) {
|
|
|
3295
3339
|
}
|
|
3296
3340
|
return;
|
|
3297
3341
|
}
|
|
3298
|
-
if (message.type
|
|
3299
|
-
|
|
3300
|
-
|
|
3342
|
+
if (message.type !== "status") {
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3345
|
+
const wasDown = workerState === "down";
|
|
3346
|
+
if (message.state === "worker-connected") {
|
|
3347
|
+
workerState = "up";
|
|
3348
|
+
}
|
|
3349
|
+
else if (message.state === "worker-disconnected" || message.state === "worker-waiting") {
|
|
3350
|
+
workerState = "down";
|
|
3351
|
+
}
|
|
3352
|
+
if (message.control === "read-only" || message.state === "read-only") {
|
|
3353
|
+
hasControl = false;
|
|
3354
|
+
}
|
|
3355
|
+
else if (message.control === "active" || message.state === "controller-promoted") {
|
|
3356
|
+
const promoted = !hasControl;
|
|
3357
|
+
hasControl = true;
|
|
3358
|
+
if (promoted || message.state === "controller-promoted") {
|
|
3359
|
+
sendResize();
|
|
3301
3360
|
}
|
|
3302
|
-
|
|
3303
|
-
|
|
3361
|
+
}
|
|
3362
|
+
// If the worker dies before we've ever seen a remote frame, the
|
|
3363
|
+
// session is effectively dead. Don't sit on a splash spinner pretending
|
|
3364
|
+
// it'll come back; bail.
|
|
3365
|
+
if (message.state === "worker-disconnected" && !firstFrameRendered) {
|
|
3366
|
+
try {
|
|
3367
|
+
socket.close(1000, "worker-gone");
|
|
3304
3368
|
}
|
|
3305
|
-
|
|
3306
|
-
|
|
3369
|
+
catch { /* ignore */ }
|
|
3370
|
+
cleanup(false);
|
|
3371
|
+
process.stderr.write("\nCloud worker exited before the dashboard started.\n");
|
|
3372
|
+
resolve("failed");
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
if (splash && !firstFrameRendered) {
|
|
3376
|
+
if (message.state === "worker-waiting") {
|
|
3377
|
+
splash.setStatus(`Waiting for cloud worker · ${target.label}`);
|
|
3307
3378
|
}
|
|
3308
|
-
else if (message.
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
sendResize();
|
|
3313
|
-
}
|
|
3379
|
+
else if (message.state === "worker-connected") {
|
|
3380
|
+
splash.setStatus(`Cloud worker connected · ${target.label}`);
|
|
3381
|
+
sendResize();
|
|
3382
|
+
setTimeout(sendResize, 150);
|
|
3314
3383
|
}
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
// it'll come back; bail.
|
|
3318
|
-
if (message.state === "worker-disconnected" && !firstFrameRendered) {
|
|
3319
|
-
splash?.dispose();
|
|
3320
|
-
try {
|
|
3321
|
-
socket.close(1000, "worker-gone");
|
|
3322
|
-
}
|
|
3323
|
-
catch { /* ignore */ }
|
|
3324
|
-
cleanup();
|
|
3325
|
-
if (!opened) {
|
|
3326
|
-
reject(new Error("Cloud worker exited before the dashboard started"));
|
|
3327
|
-
}
|
|
3328
|
-
else {
|
|
3329
|
-
process.stderr.write("\nCloud worker exited before the dashboard started.\n");
|
|
3330
|
-
result = "failed";
|
|
3331
|
-
resolve(result);
|
|
3332
|
-
}
|
|
3333
|
-
return;
|
|
3384
|
+
else if (message.state === "input-buffered") {
|
|
3385
|
+
splash.setStatus(`Reconnecting cloud input · ${target.label}`);
|
|
3334
3386
|
}
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (options.json || probeMode) {
|
|
3390
|
+
return;
|
|
3391
|
+
}
|
|
3392
|
+
// Past the splash nothing is written into the dashboard's picture: the
|
|
3393
|
+
// state goes in the corner badge and comes off when it is over.
|
|
3394
|
+
if (!hasControl) {
|
|
3395
|
+
session.showStatus("Read-only · another terminal has control");
|
|
3396
|
+
}
|
|
3397
|
+
else if (workerState === "down") {
|
|
3398
|
+
session.showStatus(message.state === "worker-waiting"
|
|
3399
|
+
? "Waiting for the cloud machine · your typing is kept"
|
|
3400
|
+
: "Cloud machine reconnecting · your typing is kept");
|
|
3401
|
+
}
|
|
3402
|
+
else if (workerState === "up" || message.state === "controller-promoted") {
|
|
3403
|
+
if (session.statusText) {
|
|
3404
|
+
session.clearStatus();
|
|
3347
3405
|
}
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
else if (message.state === "worker-connected") {
|
|
3353
|
-
process.stderr.write("Cloud worker connected.\n");
|
|
3354
|
-
}
|
|
3406
|
+
if (wasDown && workerState === "up") {
|
|
3407
|
+
// A dashboard that kept running behind a relay restart shows the
|
|
3408
|
+
// same picture; one that restarted paints anew. Either way, exact.
|
|
3409
|
+
requestRepaint();
|
|
3355
3410
|
}
|
|
3356
3411
|
}
|
|
3357
3412
|
}
|
|
@@ -3370,12 +3425,14 @@ class AttachSplash {
|
|
|
3370
3425
|
this.label = label;
|
|
3371
3426
|
this.status = `Connecting to ${label}`;
|
|
3372
3427
|
}
|
|
3428
|
+
// The alternate screen is AttachSession's (held for the whole attach,
|
|
3429
|
+
// reconnects included); the splash only draws inside it.
|
|
3373
3430
|
start() {
|
|
3374
3431
|
if (this.active) {
|
|
3375
3432
|
return;
|
|
3376
3433
|
}
|
|
3377
3434
|
this.active = true;
|
|
3378
|
-
this.stdout.write("\x1b[?
|
|
3435
|
+
this.stdout.write("\x1b[?25l");
|
|
3379
3436
|
this.redraw();
|
|
3380
3437
|
this.timer = setInterval(() => {
|
|
3381
3438
|
this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
|
|
@@ -3414,25 +3471,14 @@ class AttachSplash {
|
|
|
3414
3471
|
return;
|
|
3415
3472
|
}
|
|
3416
3473
|
this.stop();
|
|
3417
|
-
//
|
|
3418
|
-
//
|
|
3419
|
-
// cleanup() will exit the alt screen on shutdown. Wipe the splash content
|
|
3420
|
-
// first so the remote's partial frames don't render on top of leftover
|
|
3421
|
-
// spinner text (and so any tiny window before the remote's first frame
|
|
3422
|
-
// is a clean buffer, not main-buffer scrollback).
|
|
3474
|
+
// Wipe the splash content first so the remote's partial frames don't
|
|
3475
|
+
// render on top of leftover spinner text.
|
|
3423
3476
|
this.stdout.write("\x1b[2J\x1b[H\x1b[?25h");
|
|
3424
3477
|
}
|
|
3425
|
-
|
|
3478
|
+
stop() {
|
|
3426
3479
|
if (!this.active) {
|
|
3427
3480
|
return;
|
|
3428
3481
|
}
|
|
3429
|
-
this.stop();
|
|
3430
|
-
// dispose() is the abort path (worker died, user cancelled before any
|
|
3431
|
-
// remote frame). Leave the alt screen so the user is dropped back to their
|
|
3432
|
-
// shell prompt instead of staring at a frozen spinner buffer.
|
|
3433
|
-
this.stdout.write("\x1b[?1049l\x1b[?25h");
|
|
3434
|
-
}
|
|
3435
|
-
stop() {
|
|
3436
3482
|
this.active = false;
|
|
3437
3483
|
if (this.timer) {
|
|
3438
3484
|
clearInterval(this.timer);
|
|
@@ -3465,7 +3511,7 @@ async function maybeAutoAttach(result, options) {
|
|
|
3465
3511
|
return;
|
|
3466
3512
|
}
|
|
3467
3513
|
try {
|
|
3468
|
-
await
|
|
3514
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
3469
3515
|
}
|
|
3470
3516
|
catch (error) {
|
|
3471
3517
|
console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -3712,10 +3758,12 @@ const LAPTOP_CONFIG_FILES = [
|
|
|
3712
3758
|
];
|
|
3713
3759
|
async function laptopConfigFiles() {
|
|
3714
3760
|
const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
|
|
3715
|
-
|
|
3716
|
-
for (const
|
|
3717
|
-
|
|
3718
|
-
|
|
3761
|
+
// Hook scripts the settings above point at.
|
|
3762
|
+
for (const dir of [".claude/hooks", ".codex/libra-hooks"]) {
|
|
3763
|
+
for (const entry of await fsp.readdir(path.join(os.homedir(), dir), { withFileTypes: true }).catch(() => [])) {
|
|
3764
|
+
if (entry.isFile()) {
|
|
3765
|
+
names.push(`~/${dir}/${entry.name}`);
|
|
3766
|
+
}
|
|
3719
3767
|
}
|
|
3720
3768
|
}
|
|
3721
3769
|
const out = [];
|
|
@@ -4261,6 +4309,38 @@ async function homeCommand(args, options) {
|
|
|
4261
4309
|
}
|
|
4262
4310
|
return;
|
|
4263
4311
|
}
|
|
4312
|
+
if (sub === "region") {
|
|
4313
|
+
const region = (args[1] ?? "").trim().toLowerCase();
|
|
4314
|
+
if (!region) {
|
|
4315
|
+
throw new Error("Usage: rudder cloud home region <fly region, e.g. sjc>");
|
|
4316
|
+
}
|
|
4317
|
+
if (process.env.RUDDER_CLOUD_RUNTIME === "home") {
|
|
4318
|
+
throw new Error("run this from your laptop: moving the home machine restarts it.");
|
|
4319
|
+
}
|
|
4320
|
+
const started = await client.request("/api/rudder/home/region", { method: "POST", body: { region } });
|
|
4321
|
+
console.log(`Moving the home machine ${started.move.from} -> ${started.move.to}. Its disk is copied; the old one is kept as a backup.`);
|
|
4322
|
+
let last = "";
|
|
4323
|
+
for (let i = 0; i < 400; i += 1) {
|
|
4324
|
+
await sleep(3000);
|
|
4325
|
+
const { move } = await client.request("/api/rudder/home/region", { method: "GET" });
|
|
4326
|
+
if (!move) {
|
|
4327
|
+
continue;
|
|
4328
|
+
}
|
|
4329
|
+
if (move.state !== last) {
|
|
4330
|
+
console.log(` ${move.state}`);
|
|
4331
|
+
last = move.state;
|
|
4332
|
+
}
|
|
4333
|
+
if (move.state === "failed") {
|
|
4334
|
+
throw new Error(`the move failed: ${move.error ?? "unknown error"}`);
|
|
4335
|
+
}
|
|
4336
|
+
if (move.state === "done") {
|
|
4337
|
+
console.log(`Done. The old disk (${move.oldVolumeId ?? "?"}) in ${move.from} is untouched; delete it once everything checks out.`);
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
console.log("Still moving; check `rudder cloud home` in a few minutes.");
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4264
4344
|
if (sub === "open") {
|
|
4265
4345
|
const repo = args[1];
|
|
4266
4346
|
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
@@ -4280,7 +4360,7 @@ async function homeCommand(args, options) {
|
|
|
4280
4360
|
return;
|
|
4281
4361
|
}
|
|
4282
4362
|
if (sub !== "status") {
|
|
4283
|
-
throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>|runs <runId...>|delete <runId...>]");
|
|
4363
|
+
throw new Error("Usage: rudder cloud home [status|start|stop|region <code>|open <owner/repo>|runs <runId...>|delete <runId...>]");
|
|
4284
4364
|
}
|
|
4285
4365
|
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
4286
4366
|
if (options.json) {
|