@viraatdas/rudder 3.1.7 → 3.1.12
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 +335 -219
- 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 +67 -0
- package/dist/predict.js +369 -0
- package/dist/predict.js.map +1 -0
- package/package.json +2 -1
package/dist/cloud.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { AttachSession, containsCtrlC } from "./attach-session.js";
|
|
3
4
|
import fsp from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -2464,7 +2465,7 @@ async function attachToWorkspaceResult(result, options) {
|
|
|
2464
2465
|
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2465
2466
|
return;
|
|
2466
2467
|
}
|
|
2467
|
-
await
|
|
2468
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2468
2469
|
}
|
|
2469
2470
|
async function workspaceAttachById(workspaceId, options) {
|
|
2470
2471
|
if (options.json) {
|
|
@@ -2477,7 +2478,7 @@ async function workspaceAttachById(workspaceId, options) {
|
|
|
2477
2478
|
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2478
2479
|
return;
|
|
2479
2480
|
}
|
|
2480
|
-
await
|
|
2481
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2481
2482
|
}
|
|
2482
2483
|
async function lookupWorkspaceForRepo(options) {
|
|
2483
2484
|
void options;
|
|
@@ -2744,45 +2745,101 @@ async function attach(args, options) {
|
|
|
2744
2745
|
await openOnHome(false, { ...options, reattach: true });
|
|
2745
2746
|
return;
|
|
2746
2747
|
}
|
|
2747
|
-
await
|
|
2748
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
2748
2749
|
}
|
|
2749
2750
|
const ATTACH_PING_MS = 5_000;
|
|
2750
2751
|
const ATTACH_PONG_TIMEOUT_MS = 20_000;
|
|
2751
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;
|
|
2752
2760
|
// The remote TUI pushes the kitty keyboard enhancement flags once, in its own
|
|
2753
2761
|
// setup_terminal(), and the worker only keeps the TAIL of its output — so by the
|
|
2754
2762
|
// time an attach connects, that push is long gone and this terminal never sees
|
|
2755
2763
|
// it. Same class of bug as the mouse modes below. Without the protocol, macOS
|
|
2756
2764
|
// terminals report Option+h as the character it TYPES (˙ on a US layout) rather
|
|
2757
2765
|
// than Alt+h, so ⌥h stops hiding panes, and Alt+[ / Alt+] cannot be encoded at
|
|
2758
|
-
// all.
|
|
2759
|
-
// REPORT_ALTERNATE_KEYS (4). Terminals without
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
}
|
|
2765
|
-
// Stay attached through a control-plane deploy or a machine restart: say what
|
|
2766
|
-
// 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.
|
|
2767
2772
|
async function attachWithReconnect(target, options, reopen) {
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
+
}
|
|
2778
2813
|
}
|
|
2779
|
-
|
|
2780
|
-
result = "
|
|
2814
|
+
if (session.hasLeft && result === "dropped") {
|
|
2815
|
+
result = "exited";
|
|
2781
2816
|
}
|
|
2782
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
|
+
}
|
|
2783
2827
|
return result;
|
|
2784
2828
|
}
|
|
2785
|
-
|
|
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) {
|
|
2786
2843
|
const client = await cloudClient({ requireToken: true });
|
|
2787
2844
|
const baseUrl = client.baseUrl;
|
|
2788
2845
|
const state = await loadCloudAuth();
|
|
@@ -2791,12 +2848,23 @@ async function runAttach(target, options) {
|
|
|
2791
2848
|
if (!token) {
|
|
2792
2849
|
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
2793
2850
|
}
|
|
2794
|
-
const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
|
2795
|
-
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
|
|
2796
2851
|
const stdin = process.stdin;
|
|
2797
2852
|
const stdout = process.stdout;
|
|
2798
2853
|
const probeMode = Boolean(options.latencyProbe);
|
|
2799
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)}`;
|
|
2800
2868
|
return await new Promise((resolve, reject) => {
|
|
2801
2869
|
const socket = new WebSocket(wsUrl, {
|
|
2802
2870
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -2809,6 +2877,8 @@ async function runAttach(target, options) {
|
|
|
2809
2877
|
// are bounded (~100KB worst-case repaint), so the bandwidth cost of
|
|
2810
2878
|
// skipping compression is negligible next to the latency it removes.
|
|
2811
2879
|
perMessageDeflate: false,
|
|
2880
|
+
// A connect into a dead network can otherwise sit for minutes.
|
|
2881
|
+
handshakeTimeout: ATTACH_HANDSHAKE_TIMEOUT_MS,
|
|
2812
2882
|
});
|
|
2813
2883
|
socket.binaryType = "nodebuffer";
|
|
2814
2884
|
// Every deliberate close goes through socket.close() from this side; any
|
|
@@ -2821,39 +2891,57 @@ async function runAttach(target, options) {
|
|
|
2821
2891
|
});
|
|
2822
2892
|
let opened = false;
|
|
2823
2893
|
let cleaned = false;
|
|
2824
|
-
let firstFrameRendered =
|
|
2825
|
-
|
|
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";
|
|
2826
2898
|
let hasControl = true;
|
|
2827
|
-
let readOnlyNoticeShown = false;
|
|
2828
2899
|
let result = "failed";
|
|
2829
|
-
const splashAllowed = isInteractive && !options.json && !options.quietBanner;
|
|
2900
|
+
const splashAllowed = isInteractive && !options.json && !options.quietBanner && !resuming;
|
|
2830
2901
|
const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
|
|
2902
|
+
// Predictive local echo (src/predict.ts): typed characters appear at the
|
|
2903
|
+
// caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
|
|
2904
|
+
// turns the drawing off; the model stays, the reconnect badge uses it.
|
|
2905
|
+
const predictor = session.predictor;
|
|
2831
2906
|
const sendResize = () => {
|
|
2832
2907
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
2833
2908
|
return;
|
|
2834
2909
|
}
|
|
2835
2910
|
const cols = stdout.columns ?? 120;
|
|
2836
2911
|
const rows = stdout.rows ?? 32;
|
|
2912
|
+
predictor?.resize(cols, rows);
|
|
2837
2913
|
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
2838
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
|
+
};
|
|
2839
2929
|
let lastCtrlC = 0;
|
|
2840
2930
|
const onStdin = (chunk) => {
|
|
2931
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
2841
2932
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
2842
2933
|
return;
|
|
2843
2934
|
}
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
//
|
|
2847
|
-
//
|
|
2848
|
-
|
|
2849
|
-
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) {
|
|
2850
2940
|
try {
|
|
2851
|
-
socket.close(1000, "client-cancel
|
|
2941
|
+
socket.close(1000, "client-cancel");
|
|
2852
2942
|
}
|
|
2853
2943
|
catch { /* ignore */ }
|
|
2854
|
-
|
|
2855
|
-
process.stderr.write("\nDetached from disconnected cloud worker.\n");
|
|
2856
|
-
resolve("exited");
|
|
2944
|
+
finish("exited", "\nCancelled.\n");
|
|
2857
2945
|
return;
|
|
2858
2946
|
}
|
|
2859
2947
|
// An observer must always be able to leave without sending an interrupt
|
|
@@ -2863,33 +2951,23 @@ async function runAttach(target, options) {
|
|
|
2863
2951
|
socket.close(1000, "read-only-client-cancel");
|
|
2864
2952
|
}
|
|
2865
2953
|
catch { /* ignore */ }
|
|
2866
|
-
|
|
2867
|
-
process.stderr.write("\nDetached read-only cloud attachment.\n");
|
|
2868
|
-
resolve("exited");
|
|
2954
|
+
finish("exited", "\nDetached read-only cloud attachment.\n");
|
|
2869
2955
|
return;
|
|
2870
2956
|
}
|
|
2871
2957
|
if (!hasControl) {
|
|
2872
|
-
|
|
2873
|
-
process.stderr.write("\nThis cloud attachment is read-only while another terminal controls the session.\n");
|
|
2874
|
-
readOnlyNoticeShown = true;
|
|
2875
|
-
}
|
|
2958
|
+
session.showStatus("Read-only · another terminal has control");
|
|
2876
2959
|
return;
|
|
2877
2960
|
}
|
|
2878
|
-
//
|
|
2879
|
-
// Ctrl+C
|
|
2880
|
-
//
|
|
2881
|
-
if (hasCtrlC &&
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
reject(new Error("Cloud attach cancelled"));
|
|
2889
|
-
}
|
|
2890
|
-
else {
|
|
2891
|
-
process.stderr.write("\nCancelled.\n");
|
|
2892
|
-
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");
|
|
2893
2971
|
}
|
|
2894
2972
|
return;
|
|
2895
2973
|
}
|
|
@@ -2899,24 +2977,24 @@ async function runAttach(target, options) {
|
|
|
2899
2977
|
if (hasCtrlC && firstFrameRendered) {
|
|
2900
2978
|
const now = Date.now();
|
|
2901
2979
|
if (now - lastCtrlC < 2000) {
|
|
2902
|
-
process.stderr.write("\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
2903
2980
|
try {
|
|
2904
2981
|
socket.close(1000, "client-force-quit");
|
|
2905
2982
|
}
|
|
2906
2983
|
catch { /* ignore */ }
|
|
2907
|
-
|
|
2908
|
-
resolve("exited");
|
|
2984
|
+
finish("exited", "\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
2909
2985
|
return;
|
|
2910
2986
|
}
|
|
2911
2987
|
lastCtrlC = now;
|
|
2912
2988
|
}
|
|
2913
2989
|
socket.send(buffer, { binary: true });
|
|
2990
|
+
predictor?.onInput(buffer);
|
|
2914
2991
|
};
|
|
2915
2992
|
const onResize = () => {
|
|
2916
2993
|
if (hasControl) {
|
|
2917
2994
|
sendResize();
|
|
2918
2995
|
}
|
|
2919
2996
|
splash?.redraw();
|
|
2997
|
+
session.noteResize();
|
|
2920
2998
|
};
|
|
2921
2999
|
// Liveness. A laptop that slept leaves this socket half-open: nothing
|
|
2922
3000
|
// errors, the screen just freezes, and the relay has meanwhile dropped us
|
|
@@ -2925,7 +3003,14 @@ async function runAttach(target, options) {
|
|
|
2925
3003
|
// as a dropped connection, which attachWithReconnect reopens.
|
|
2926
3004
|
let lastHeard = Date.now();
|
|
2927
3005
|
let lastTick = Date.now();
|
|
2928
|
-
|
|
3006
|
+
let pingSentAt = 0;
|
|
3007
|
+
socket.on("pong", () => {
|
|
3008
|
+
lastHeard = Date.now();
|
|
3009
|
+
if (pingSentAt) {
|
|
3010
|
+
predictor?.noteRtt(Date.now() - pingSentAt);
|
|
3011
|
+
pingSentAt = 0;
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
2929
3014
|
socket.on("message", () => { lastHeard = Date.now(); });
|
|
2930
3015
|
const liveness = setInterval(() => {
|
|
2931
3016
|
const now = Date.now();
|
|
@@ -2940,76 +3025,53 @@ async function runAttach(target, options) {
|
|
|
2940
3025
|
}
|
|
2941
3026
|
try {
|
|
2942
3027
|
socket.ping();
|
|
3028
|
+
pingSentAt = Date.now();
|
|
2943
3029
|
}
|
|
2944
3030
|
catch {
|
|
2945
3031
|
socket.terminate();
|
|
2946
3032
|
}
|
|
2947
3033
|
}, ATTACH_PING_MS);
|
|
2948
3034
|
liveness.unref?.();
|
|
2949
|
-
|
|
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) => {
|
|
2950
3049
|
if (cleaned) {
|
|
2951
3050
|
return;
|
|
2952
3051
|
}
|
|
2953
3052
|
cleaned = true;
|
|
3053
|
+
offLeave();
|
|
2954
3054
|
clearInterval(liveness);
|
|
2955
3055
|
stdin.off("data", onStdin);
|
|
2956
3056
|
stdout.off("resize", onResize);
|
|
2957
3057
|
process.off("SIGINT", onSigint);
|
|
2958
|
-
splash?.
|
|
2959
|
-
if (
|
|
2960
|
-
|
|
2961
|
-
// pushed, then disable local mouse capture before leaving raw mode so the
|
|
2962
|
-
// user's shell prompt does not get spammed with mouse SGR bytes.
|
|
2963
|
-
try {
|
|
2964
|
-
stdout.write(KITTY_KEYBOARD_POP);
|
|
2965
|
-
}
|
|
2966
|
-
catch {
|
|
2967
|
-
// ignore
|
|
2968
|
-
}
|
|
2969
|
-
try {
|
|
2970
|
-
stdout.write(KITTY_KEYBOARD_POP);
|
|
2971
|
-
stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
|
|
2972
|
-
}
|
|
2973
|
-
catch {
|
|
2974
|
-
// ignore
|
|
2975
|
-
}
|
|
2976
|
-
// Release the tab title so the user's shell prompt can rewrite it.
|
|
2977
|
-
try {
|
|
2978
|
-
stdout.write("\x1b]0;\x07");
|
|
2979
|
-
}
|
|
2980
|
-
catch {
|
|
2981
|
-
// ignore
|
|
2982
|
-
}
|
|
2983
|
-
try {
|
|
2984
|
-
stdin.setRawMode(false);
|
|
2985
|
-
}
|
|
2986
|
-
catch {
|
|
2987
|
-
// ignore
|
|
2988
|
-
}
|
|
3058
|
+
splash?.stop();
|
|
3059
|
+
if (keepTerminal) {
|
|
3060
|
+
session.enterGap();
|
|
2989
3061
|
}
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
// attach session (see AttachSplash.handoff). Restore the main buffer
|
|
2993
|
-
// and cursor now so the user is dropped back to their shell prompt
|
|
2994
|
-
// instead of a blank alt-screen with the dashboard frozen on it.
|
|
2995
|
-
try {
|
|
2996
|
-
stdout.write("\x1b[?1049l\x1b[?25h");
|
|
2997
|
-
}
|
|
2998
|
-
catch {
|
|
2999
|
-
// ignore
|
|
3000
|
-
}
|
|
3062
|
+
else {
|
|
3063
|
+
session.release();
|
|
3001
3064
|
}
|
|
3002
|
-
stdin.pause();
|
|
3003
3065
|
};
|
|
3004
3066
|
const onSigint = () => {
|
|
3005
3067
|
// Belt-and-suspenders: if raw mode failed for any reason, Node still
|
|
3006
3068
|
// sees SIGINT. Close cleanly so the user never gets a frozen terminal.
|
|
3007
|
-
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
3008
3069
|
try {
|
|
3009
3070
|
socket.close(1000, "sigint");
|
|
3010
3071
|
}
|
|
3011
3072
|
catch { /* ignore */ }
|
|
3012
|
-
cleanup();
|
|
3073
|
+
cleanup(false);
|
|
3074
|
+
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
3013
3075
|
if (!opened) {
|
|
3014
3076
|
reject(new Error("Cloud attach cancelled"));
|
|
3015
3077
|
}
|
|
@@ -3105,6 +3167,23 @@ async function runAttach(target, options) {
|
|
|
3105
3167
|
};
|
|
3106
3168
|
socket.on("open", () => {
|
|
3107
3169
|
opened = true;
|
|
3170
|
+
session.connections += 1;
|
|
3171
|
+
// From here this connection reads the keys; the gap hands them over.
|
|
3172
|
+
session.exitGap();
|
|
3173
|
+
// Measure the round trip at once: predictive echo waits on it.
|
|
3174
|
+
try {
|
|
3175
|
+
socket.ping();
|
|
3176
|
+
pingSentAt = Date.now();
|
|
3177
|
+
}
|
|
3178
|
+
catch {
|
|
3179
|
+
// the liveness timer retries
|
|
3180
|
+
}
|
|
3181
|
+
if (probeMode) {
|
|
3182
|
+
// An idle dashboard sends nothing; a resize makes it repaint, which is
|
|
3183
|
+
// the first frame the probe waits for.
|
|
3184
|
+
socket.send(JSON.stringify({ type: "resize", cols: 119, rows: 32 }));
|
|
3185
|
+
setTimeout(() => socket.send(JSON.stringify({ type: "resize", cols: 120, rows: 32 })), 200);
|
|
3186
|
+
}
|
|
3108
3187
|
// Disable Nagle on the underlying TCP socket so single keystrokes don't
|
|
3109
3188
|
// sit in the send buffer waiting for piggyback ACKs (~40ms savings per
|
|
3110
3189
|
// keypress on the WAN).
|
|
@@ -3117,48 +3196,35 @@ async function runAttach(target, options) {
|
|
|
3117
3196
|
}
|
|
3118
3197
|
// Label this terminal tab so the user can find the right rudder cloud
|
|
3119
3198
|
// session at a glance instead of squinting at a row of "ghostty" tabs.
|
|
3120
|
-
|
|
3121
|
-
|
|
3199
|
+
if (isInteractive) {
|
|
3200
|
+
try {
|
|
3201
|
+
stdout.write(`\x1b]0;Rudder cloud: ${target.label}\x07`);
|
|
3202
|
+
}
|
|
3203
|
+
catch {
|
|
3204
|
+
// ignore
|
|
3205
|
+
}
|
|
3122
3206
|
}
|
|
3123
|
-
|
|
3124
|
-
|
|
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();
|
|
3125
3212
|
}
|
|
3126
3213
|
if (splash) {
|
|
3127
3214
|
splash.start();
|
|
3128
3215
|
splash.setStatus(`Booting cloud workspace · ${target.label}`);
|
|
3129
3216
|
}
|
|
3130
|
-
else if (!options.json && !options.quietBanner) {
|
|
3217
|
+
else if (!options.json && !options.quietBanner && !resuming) {
|
|
3131
3218
|
const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
|
|
3132
3219
|
process.stderr.write(`Attached to ${target.label}${tail}\n`);
|
|
3133
3220
|
}
|
|
3134
3221
|
sendResize();
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
}
|
|
3139
|
-
catch {
|
|
3140
|
-
// ignore
|
|
3141
|
-
}
|
|
3142
|
-
// Enable mouse tracking on the LOCAL terminal so scroll/click events
|
|
3143
|
-
// get forwarded into stdin. The remote rudder TUI also emits these
|
|
3144
|
-
// enable sequences, but they arrive as PTY output bytes and the local
|
|
3145
|
-
// terminal does not interpret output bytes as mode toggles. We have
|
|
3146
|
-
// to enable mouse capture locally too.
|
|
3147
|
-
try {
|
|
3148
|
-
stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
|
|
3149
|
-
}
|
|
3150
|
-
catch {
|
|
3151
|
-
// ignore
|
|
3152
|
-
}
|
|
3153
|
-
// Ask the local terminal for the kitty keyboard protocol on the remote's
|
|
3154
|
-
// behalf, so real modifiers reach the remote dashboard.
|
|
3155
|
-
try {
|
|
3156
|
-
stdout.write(KITTY_KEYBOARD_PUSH);
|
|
3157
|
-
}
|
|
3158
|
-
catch {
|
|
3159
|
-
// ignore
|
|
3160
|
-
}
|
|
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 });
|
|
3161
3226
|
}
|
|
3227
|
+
requestRepaint();
|
|
3162
3228
|
if (!probeMode) {
|
|
3163
3229
|
stdin.resume();
|
|
3164
3230
|
stdin.on("data", onStdin);
|
|
@@ -3200,6 +3266,14 @@ async function runAttach(target, options) {
|
|
|
3200
3266
|
probeEchoWaiter?.(performance.now());
|
|
3201
3267
|
return;
|
|
3202
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
|
+
}
|
|
3203
3277
|
if (!firstFrameRendered) {
|
|
3204
3278
|
firstFrameRendered = true;
|
|
3205
3279
|
splash?.handoff();
|
|
@@ -3211,6 +3285,7 @@ async function runAttach(target, options) {
|
|
|
3211
3285
|
setTimeout(sendResize, 150);
|
|
3212
3286
|
}
|
|
3213
3287
|
stdout.write(data);
|
|
3288
|
+
predictor?.onServerData(data);
|
|
3214
3289
|
return;
|
|
3215
3290
|
}
|
|
3216
3291
|
const text = Buffer.isBuffer(data)
|
|
@@ -3221,18 +3296,20 @@ async function runAttach(target, options) {
|
|
|
3221
3296
|
handleControlText(text);
|
|
3222
3297
|
});
|
|
3223
3298
|
socket.on("close", (code, reason) => {
|
|
3224
|
-
|
|
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));
|
|
3225
3302
|
if (!opened) {
|
|
3226
3303
|
const reasonText = reason && reason.length ? reason.toString("utf8") : "";
|
|
3227
3304
|
reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
|
|
3228
3305
|
return;
|
|
3229
3306
|
}
|
|
3230
3307
|
// 4410: the workspace was deleted; nothing to come back to.
|
|
3231
|
-
resolve(
|
|
3308
|
+
resolve(dropped ? "dropped" : result);
|
|
3232
3309
|
});
|
|
3233
3310
|
socket.on("error", (err) => {
|
|
3234
3311
|
if (!opened) {
|
|
3235
|
-
cleanup();
|
|
3312
|
+
cleanup(reconnects && session.connections > 0);
|
|
3236
3313
|
reject(new Error(`Cloud attach failed: ${err.message}`));
|
|
3237
3314
|
}
|
|
3238
3315
|
});
|
|
@@ -3262,63 +3339,74 @@ async function runAttach(target, options) {
|
|
|
3262
3339
|
}
|
|
3263
3340
|
return;
|
|
3264
3341
|
}
|
|
3265
|
-
if (message.type
|
|
3266
|
-
|
|
3267
|
-
|
|
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();
|
|
3268
3360
|
}
|
|
3269
|
-
|
|
3270
|
-
|
|
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");
|
|
3271
3368
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
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}`);
|
|
3274
3378
|
}
|
|
3275
|
-
else if (message.
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
sendResize();
|
|
3280
|
-
}
|
|
3379
|
+
else if (message.state === "worker-connected") {
|
|
3380
|
+
splash.setStatus(`Cloud worker connected · ${target.label}`);
|
|
3381
|
+
sendResize();
|
|
3382
|
+
setTimeout(sendResize, 150);
|
|
3281
3383
|
}
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
// it'll come back; bail.
|
|
3285
|
-
if (message.state === "worker-disconnected" && !firstFrameRendered) {
|
|
3286
|
-
splash?.dispose();
|
|
3287
|
-
try {
|
|
3288
|
-
socket.close(1000, "worker-gone");
|
|
3289
|
-
}
|
|
3290
|
-
catch { /* ignore */ }
|
|
3291
|
-
cleanup();
|
|
3292
|
-
if (!opened) {
|
|
3293
|
-
reject(new Error("Cloud worker exited before the dashboard started"));
|
|
3294
|
-
}
|
|
3295
|
-
else {
|
|
3296
|
-
process.stderr.write("\nCloud worker exited before the dashboard started.\n");
|
|
3297
|
-
result = "failed";
|
|
3298
|
-
resolve(result);
|
|
3299
|
-
}
|
|
3300
|
-
return;
|
|
3384
|
+
else if (message.state === "input-buffered") {
|
|
3385
|
+
splash.setStatus(`Reconnecting cloud input · ${target.label}`);
|
|
3301
3386
|
}
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
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();
|
|
3314
3405
|
}
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
else if (message.state === "worker-connected") {
|
|
3320
|
-
process.stderr.write("Cloud worker connected.\n");
|
|
3321
|
-
}
|
|
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();
|
|
3322
3410
|
}
|
|
3323
3411
|
}
|
|
3324
3412
|
}
|
|
@@ -3337,12 +3425,14 @@ class AttachSplash {
|
|
|
3337
3425
|
this.label = label;
|
|
3338
3426
|
this.status = `Connecting to ${label}`;
|
|
3339
3427
|
}
|
|
3428
|
+
// The alternate screen is AttachSession's (held for the whole attach,
|
|
3429
|
+
// reconnects included); the splash only draws inside it.
|
|
3340
3430
|
start() {
|
|
3341
3431
|
if (this.active) {
|
|
3342
3432
|
return;
|
|
3343
3433
|
}
|
|
3344
3434
|
this.active = true;
|
|
3345
|
-
this.stdout.write("\x1b[?
|
|
3435
|
+
this.stdout.write("\x1b[?25l");
|
|
3346
3436
|
this.redraw();
|
|
3347
3437
|
this.timer = setInterval(() => {
|
|
3348
3438
|
this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
|
|
@@ -3381,25 +3471,14 @@ class AttachSplash {
|
|
|
3381
3471
|
return;
|
|
3382
3472
|
}
|
|
3383
3473
|
this.stop();
|
|
3384
|
-
//
|
|
3385
|
-
//
|
|
3386
|
-
// cleanup() will exit the alt screen on shutdown. Wipe the splash content
|
|
3387
|
-
// first so the remote's partial frames don't render on top of leftover
|
|
3388
|
-
// spinner text (and so any tiny window before the remote's first frame
|
|
3389
|
-
// 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.
|
|
3390
3476
|
this.stdout.write("\x1b[2J\x1b[H\x1b[?25h");
|
|
3391
3477
|
}
|
|
3392
|
-
|
|
3478
|
+
stop() {
|
|
3393
3479
|
if (!this.active) {
|
|
3394
3480
|
return;
|
|
3395
3481
|
}
|
|
3396
|
-
this.stop();
|
|
3397
|
-
// dispose() is the abort path (worker died, user cancelled before any
|
|
3398
|
-
// remote frame). Leave the alt screen so the user is dropped back to their
|
|
3399
|
-
// shell prompt instead of staring at a frozen spinner buffer.
|
|
3400
|
-
this.stdout.write("\x1b[?1049l\x1b[?25h");
|
|
3401
|
-
}
|
|
3402
|
-
stop() {
|
|
3403
3482
|
this.active = false;
|
|
3404
3483
|
if (this.timer) {
|
|
3405
3484
|
clearInterval(this.timer);
|
|
@@ -3432,7 +3511,7 @@ async function maybeAutoAttach(result, options) {
|
|
|
3432
3511
|
return;
|
|
3433
3512
|
}
|
|
3434
3513
|
try {
|
|
3435
|
-
await
|
|
3514
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
3436
3515
|
}
|
|
3437
3516
|
catch (error) {
|
|
3438
3517
|
console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -3673,13 +3752,18 @@ const LAPTOP_CONFIG_FILES = [
|
|
|
3673
3752
|
"~/.codex/config.toml",
|
|
3674
3753
|
"~/.codex/AGENTS.md",
|
|
3675
3754
|
"~/.codex/hooks.json",
|
|
3755
|
+
// Service logins agents use from scripts (Kalshi signs requests with this key).
|
|
3756
|
+
"~/.kalshi/config.json",
|
|
3757
|
+
"~/.kalshi/private_key.pem",
|
|
3676
3758
|
];
|
|
3677
3759
|
async function laptopConfigFiles() {
|
|
3678
3760
|
const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
|
|
3679
|
-
|
|
3680
|
-
for (const
|
|
3681
|
-
|
|
3682
|
-
|
|
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
|
+
}
|
|
3683
3767
|
}
|
|
3684
3768
|
}
|
|
3685
3769
|
const out = [];
|
|
@@ -4225,6 +4309,38 @@ async function homeCommand(args, options) {
|
|
|
4225
4309
|
}
|
|
4226
4310
|
return;
|
|
4227
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
|
+
}
|
|
4228
4344
|
if (sub === "open") {
|
|
4229
4345
|
const repo = args[1];
|
|
4230
4346
|
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
@@ -4244,7 +4360,7 @@ async function homeCommand(args, options) {
|
|
|
4244
4360
|
return;
|
|
4245
4361
|
}
|
|
4246
4362
|
if (sub !== "status") {
|
|
4247
|
-
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...>]");
|
|
4248
4364
|
}
|
|
4249
4365
|
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
4250
4366
|
if (options.json) {
|