@viraatdas/rudder 3.1.3 → 3.1.8

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/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 { EchoPredictor } from "./predict.js";
3
4
  import fsp from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -2769,7 +2770,7 @@ async function attachWithReconnect(target, options, reopen) {
2769
2770
  let attempts = 0;
2770
2771
  while (result === "dropped" && attempts < 120) {
2771
2772
  attempts += 1;
2772
- process.stderr.write(`\r\nLost the connection to Rudder Cloud (it may be restarting). Reconnecting${attempts > 1 ? ` (try ${attempts})` : ""}...\r\n`);
2773
+ process.stderr.write(`\r\nLost the connection to Rudder Cloud (laptop slept or the network dropped; your agents kept running). Reconnecting${attempts > 1 ? ` (try ${attempts})` : ""}...\r\n`);
2773
2774
  await sleep(Math.min(15_000, 2_000 * attempts));
2774
2775
  try {
2775
2776
  await reopen?.();
@@ -2828,12 +2829,19 @@ async function runAttach(target, options) {
2828
2829
  let result = "failed";
2829
2830
  const splashAllowed = isInteractive && !options.json && !options.quietBanner;
2830
2831
  const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
2832
+ // Predictive local echo (src/predict.ts): typed characters appear at the
2833
+ // caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
2834
+ // turns it off; it only engages once the round trip is worth hiding.
2835
+ const predictor = isInteractive && process.env.RUDDER_PREDICT !== "0"
2836
+ ? new EchoPredictor(stdout.columns ?? 120, stdout.rows ?? 32, (text) => stdout.write(text))
2837
+ : null;
2831
2838
  const sendResize = () => {
2832
2839
  if (socket.readyState !== WebSocket.OPEN) {
2833
2840
  return;
2834
2841
  }
2835
2842
  const cols = stdout.columns ?? 120;
2836
2843
  const rows = stdout.rows ?? 32;
2844
+ predictor?.resize(cols, rows);
2837
2845
  socket.send(JSON.stringify({ type: "resize", cols, rows }));
2838
2846
  };
2839
2847
  let lastCtrlC = 0;
@@ -2911,6 +2919,7 @@ async function runAttach(target, options) {
2911
2919
  lastCtrlC = now;
2912
2920
  }
2913
2921
  socket.send(buffer, { binary: true });
2922
+ predictor?.onInput(buffer);
2914
2923
  };
2915
2924
  const onResize = () => {
2916
2925
  if (hasControl) {
@@ -2925,7 +2934,14 @@ async function runAttach(target, options) {
2925
2934
  // as a dropped connection, which attachWithReconnect reopens.
2926
2935
  let lastHeard = Date.now();
2927
2936
  let lastTick = Date.now();
2928
- socket.on("pong", () => { lastHeard = Date.now(); });
2937
+ let pingSentAt = 0;
2938
+ socket.on("pong", () => {
2939
+ lastHeard = Date.now();
2940
+ if (pingSentAt) {
2941
+ predictor?.noteRtt(Date.now() - pingSentAt);
2942
+ pingSentAt = 0;
2943
+ }
2944
+ });
2929
2945
  socket.on("message", () => { lastHeard = Date.now(); });
2930
2946
  const liveness = setInterval(() => {
2931
2947
  const now = Date.now();
@@ -2940,6 +2956,7 @@ async function runAttach(target, options) {
2940
2956
  }
2941
2957
  try {
2942
2958
  socket.ping();
2959
+ pingSentAt = Date.now();
2943
2960
  }
2944
2961
  catch {
2945
2962
  socket.terminate();
@@ -2952,6 +2969,7 @@ async function runAttach(target, options) {
2952
2969
  }
2953
2970
  cleaned = true;
2954
2971
  clearInterval(liveness);
2972
+ predictor?.dispose();
2955
2973
  stdin.off("data", onStdin);
2956
2974
  stdout.off("resize", onResize);
2957
2975
  process.off("SIGINT", onSigint);
@@ -3105,6 +3123,20 @@ async function runAttach(target, options) {
3105
3123
  };
3106
3124
  socket.on("open", () => {
3107
3125
  opened = true;
3126
+ // Measure the round trip at once: predictive echo waits on it.
3127
+ try {
3128
+ socket.ping();
3129
+ pingSentAt = Date.now();
3130
+ }
3131
+ catch {
3132
+ // the liveness timer retries
3133
+ }
3134
+ if (probeMode) {
3135
+ // An idle dashboard sends nothing; a resize makes it repaint, which is
3136
+ // the first frame the probe waits for.
3137
+ socket.send(JSON.stringify({ type: "resize", cols: 119, rows: 32 }));
3138
+ setTimeout(() => socket.send(JSON.stringify({ type: "resize", cols: 120, rows: 32 })), 200);
3139
+ }
3108
3140
  // Disable Nagle on the underlying TCP socket so single keystrokes don't
3109
3141
  // sit in the send buffer waiting for piggyback ACKs (~40ms savings per
3110
3142
  // keypress on the WAN).
@@ -3211,6 +3243,7 @@ async function runAttach(target, options) {
3211
3243
  setTimeout(sendResize, 150);
3212
3244
  }
3213
3245
  stdout.write(data);
3246
+ predictor?.onServerData(data);
3214
3247
  return;
3215
3248
  }
3216
3249
  const text = Buffer.isBuffer(data)
@@ -3662,16 +3695,40 @@ async function warnLocalOnlyWork(repoRoot) {
3662
3695
  }
3663
3696
  // The cloud's AWS is whatever ~/.aws the vault holds. When this laptop's copy
3664
3697
  // has changed since (new keys, a new profile), offer to bring the vault level.
3665
- async function ensureVaultAws(client) {
3666
- const files = ["config", "credentials"]
3667
- .map((name) => ({ name: `~/.aws/${name}`, absolute: path.join(os.homedir(), ".aws", name) }));
3668
- const present = [];
3669
- for (const file of files) {
3670
- const content = await fsp.readFile(file.absolute).catch(() => null);
3698
+ // The vault's copy of the laptop's agent and AWS config must match the
3699
+ // laptop: the cloud machine rewrites these files from the vault every boot,
3700
+ // and a drifted copy means different behavior there (verbose output, another
3701
+ // default model, hooks that no longer exist). Compared by checksum; AWS is
3702
+ // asked about once, everything else that is already shared just follows.
3703
+ const LAPTOP_CONFIG_FILES = [
3704
+ "~/.claude/settings.json",
3705
+ "~/.claude/CLAUDE.md",
3706
+ "~/.codex/config.toml",
3707
+ "~/.codex/AGENTS.md",
3708
+ "~/.codex/hooks.json",
3709
+ // Service logins agents use from scripts (Kalshi signs requests with this key).
3710
+ "~/.kalshi/config.json",
3711
+ "~/.kalshi/private_key.pem",
3712
+ ];
3713
+ async function laptopConfigFiles() {
3714
+ const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
3715
+ const hooksDir = path.join(os.homedir(), ".claude", "hooks");
3716
+ for (const entry of await fsp.readdir(hooksDir, { withFileTypes: true }).catch(() => [])) {
3717
+ if (entry.isFile()) {
3718
+ names.push(`~/.claude/hooks/${entry.name}`);
3719
+ }
3720
+ }
3721
+ const out = [];
3722
+ for (const name of names) {
3723
+ const content = await fsp.readFile(path.join(os.homedir(), name.slice(2))).catch(() => null);
3671
3724
  if (content && content.length > 0 && content.length <= MAX_SECRET_VALUE_BYTES) {
3672
- present.push({ name: file.name, content });
3725
+ out.push({ name, content });
3673
3726
  }
3674
3727
  }
3728
+ return out;
3729
+ }
3730
+ async function ensureVaultAws(client) {
3731
+ const present = await laptopConfigFiles();
3675
3732
  if (present.length === 0 || !isTty()) {
3676
3733
  return;
3677
3734
  }
@@ -3679,28 +3736,31 @@ async function ensureVaultAws(client) {
3679
3736
  if (!vault) {
3680
3737
  return;
3681
3738
  }
3682
- const stored = new Map((vault.secrets ?? []).map((secret) => [secret.name, secret.size ?? -1]));
3683
- const changed = present.filter((file) => stored.get(file.name) !== file.content.length);
3739
+ const stored = new Map((vault.secrets ?? []).map((secret) => [secret.name, secret.sha256 ?? ""]));
3740
+ const sha = (content) => createHash("sha256").update(content).digest("hex").slice(0, 8);
3741
+ const changed = present.filter((file) => stored.get(file.name) !== sha(file.content));
3684
3742
  if (changed.length === 0) {
3685
3743
  return;
3686
3744
  }
3687
- // Asked once. After AWS is in the vault, keeping it matched with this laptop
3688
- // is the point (rotating SSO/session credentials change it daily), so later
3689
- // changes are copied without a prompt on every `rudder cloud`.
3690
- const alreadyShared = files.some((file) => stored.has(file.name));
3691
- if (!alreadyShared) {
3692
- const yes = await promptConfirm(`Your AWS setup (${changed.map((file) => file.name).join(", ")}) differs from the cloud vault's copy. Copy it so cloud agents use the same AWS?`, true);
3693
- if (!yes) {
3694
- return;
3745
+ const isAws = (name) => name.startsWith("~/.aws/");
3746
+ const awsShared = [...stored.keys()].some(isAws);
3747
+ let toSend = changed.filter((file) => !isAws(file.name) || awsShared);
3748
+ const newAws = changed.filter((file) => isAws(file.name) && !awsShared);
3749
+ if (newAws.length > 0) {
3750
+ const yes = await promptConfirm(`Copy your AWS setup (${newAws.map((file) => file.name).join(", ")}) to the cloud vault so cloud agents use the same AWS?`, true);
3751
+ if (yes) {
3752
+ toSend = [...toSend, ...newAws];
3695
3753
  }
3696
3754
  }
3697
- for (const file of changed) {
3755
+ for (const file of toSend) {
3698
3756
  await client.request("/api/rudder/secrets/item", {
3699
3757
  method: "PUT",
3700
3758
  body: { name: file.name, kind: "file", filePath: file.name, valueBase64: file.content.toString("base64"), source: "rudder cloud" },
3701
3759
  });
3702
3760
  }
3703
- process.stderr.write("Updated ~/.aws in the cloud vault.\n");
3761
+ if (toSend.length > 0) {
3762
+ process.stderr.write(`Updated the cloud copy of ${toSend.map((file) => file.name).join(", ")} to match this laptop.\n`);
3763
+ }
3704
3764
  }
3705
3765
  async function localGithubToken() {
3706
3766
  if (!commandExists("gh")) {