@viraatdas/rudder 3.0.3 → 3.1.3
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/README.md +14 -6
- package/dist/cloud.d.ts +19 -0
- package/dist/cloud.js +346 -12
- package/dist/cloud.js.map +1 -1
- package/dist/jj.d.ts +17 -0
- package/dist/jj.js +2 -2
- package/dist/jj.js.map +1 -1
- package/dist/main.js +41 -2
- package/dist/main.js.map +1 -1
- package/dist/move-home.d.ts +80 -0
- package/dist/move-home.js +418 -0
- package/dist/move-home.js.map +1 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -479,6 +479,7 @@ control surface; you decide whether a task runs locally or is handed to the clou
|
|
|
479
479
|
rudder login # sign in with Google (opens straight to the account picker)
|
|
480
480
|
rudder cloud # open this repo on your home machine and attach to it
|
|
481
481
|
rudder cloud home # home machine status: projects, agents, previews
|
|
482
|
+
rudder cloud move # move this repo's running agents to the home machine
|
|
482
483
|
rudder cloud app # the phone app link
|
|
483
484
|
rudder cloud list # list cloud workers
|
|
484
485
|
rudder cloud logs <id> # worker status
|
|
@@ -503,7 +504,9 @@ machine of its own.
|
|
|
503
504
|
|
|
504
505
|
**Phone.** Open `rudder cloud app` on your phone and sign in with the same
|
|
505
506
|
Google account. You see every project and agent, read conversations as they
|
|
506
|
-
happen, text any agent, start tasks
|
|
507
|
+
happen, text any agent, start tasks (`/model` picks Claude or Codex for new ones),
|
|
508
|
+
stop, merge or delete agents (swipe left), and open previews. A message that can't
|
|
509
|
+
reach an agent (a menu is open in its pane) says so instead of vanishing. Claude
|
|
507
510
|
panes in the cloud also run with Remote Control, so they appear in the Claude
|
|
508
511
|
app too.
|
|
509
512
|
|
|
@@ -520,11 +523,16 @@ running when this laptop disconnects (`/cloud off` returns to local workers).
|
|
|
520
523
|
selected auth/config) so the whole dashboard runs on a cloud machine. Completed
|
|
521
524
|
cloud work returns through the same review and merge path as local work.
|
|
522
525
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
526
|
+
**Taking agents with you.** About to close the laptop? Press `H` in the
|
|
527
|
+
dashboard and every running agent moves to your home machine: its work so far
|
|
528
|
+
(the jj change, including unpushed local changes it builds on) and its
|
|
529
|
+
conversation go along, nothing is pushed to GitHub, and the conversation resumes
|
|
530
|
+
there, so it keeps going with the laptop closed. Quitting with agents running
|
|
531
|
+
offers the same thing ("3 agents are running here. Move them to the home
|
|
532
|
+
machine?"), and `rudder cloud` in a repo with agents asks too. Bare `/handoff`
|
|
533
|
+
moves just the selected agent. (`/handoff <session-id>` still adopts an outside
|
|
534
|
+
CLI chat, like `/resume`.) Delete an agent anywhere (`dd` here, swipe left on the
|
|
535
|
+
phone) and it is gone everywhere.
|
|
528
536
|
|
|
529
537
|
Cloud comes in two shapes: a **sail** is an ephemeral, task-scoped worker (it goes away
|
|
530
538
|
when the task is done; idle sails pause and can resume), while a **workspace** is a
|
package/dist/cloud.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type MigrationCandidate, type MigrationPlan } from "./migration.js";
|
|
2
|
+
import { type MoveCandidate } from "./move-home.js";
|
|
3
|
+
import type { JsonValue } from "./types.js";
|
|
2
4
|
type CloudCommandOptions = {
|
|
3
5
|
json?: boolean;
|
|
4
6
|
homePaths?: string[];
|
|
@@ -6,9 +8,18 @@ type CloudCommandOptions = {
|
|
|
6
8
|
noAttach?: boolean;
|
|
7
9
|
quietBanner?: boolean;
|
|
8
10
|
latencyProbe?: boolean;
|
|
11
|
+
/** Coming back to a repo already open on the home machine: skip the first-run offers. */
|
|
12
|
+
reattach?: boolean;
|
|
9
13
|
/** Skip the y/N confirmation of a destructive command (`--yes`/`--force`). */
|
|
10
14
|
yes?: boolean;
|
|
11
15
|
};
|
|
16
|
+
type CloudClient = {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
request<T>(pathOrUrl: string, init: {
|
|
19
|
+
method: string;
|
|
20
|
+
body?: JsonValue;
|
|
21
|
+
}): Promise<T>;
|
|
22
|
+
};
|
|
12
23
|
export declare function runCloudCommand(command: string, args: string[], options?: CloudCommandOptions): Promise<void>;
|
|
13
24
|
/**
|
|
14
25
|
* Scope a fleet migration plan down to one run: only `runId` moves, every
|
|
@@ -25,4 +36,12 @@ export declare function copyRudderState(repoRoot: string, repoStage: string, onl
|
|
|
25
36
|
runs: number;
|
|
26
37
|
files: string[];
|
|
27
38
|
}>;
|
|
39
|
+
/** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
|
|
40
|
+
export declare function moveAgentsToHome(client: CloudClient, repoRoot: string, slug: string, candidates: MoveCandidate[], options: CloudCommandOptions): Promise<{
|
|
41
|
+
adopted: string[];
|
|
42
|
+
failed: Array<{
|
|
43
|
+
runId: string;
|
|
44
|
+
error: string;
|
|
45
|
+
}>;
|
|
46
|
+
}>;
|
|
28
47
|
export {};
|
package/dist/cloud.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import fsp from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { WebSocket } from "ws";
|
|
7
7
|
import { currentBranch, currentCommit, findRepoRoot } from "./git.js";
|
|
8
8
|
import { applyDefaultDecisions, buildFreshHandoffPrompt, cloudWorkspaceAbsolutePath, findMigrationCandidates, formatCandidateReason, migrationSummary, summaryAsJson, } from "./migration.js";
|
|
9
|
+
import { buildMoveBundle, findMoveCandidates, markRunsMovedHome } from "./move-home.js";
|
|
9
10
|
import { cloudAuthPath } from "./state.js";
|
|
10
11
|
import { ensureDir, commandExists, expandHome, isTty, newRunId, nowIso, pathExists, promptConfirm, promptText, promptSelect, promptSecret, readJson, runCommand, shortenHome, shellQuote, writeJson, } from "./util.js";
|
|
11
12
|
const DEFAULT_LOGIN_INTERVAL_MS = 2000;
|
|
@@ -17,6 +18,9 @@ const DEFAULT_HOME_PATHS = [
|
|
|
17
18
|
// Agent CLIs
|
|
18
19
|
"~/.claude/.credentials.json",
|
|
19
20
|
"~/.claude/settings.json",
|
|
21
|
+
// Hook scripts settings.json points at; the home machine rewrites their
|
|
22
|
+
// /Users/<name> paths to its own home (cloud/worker/hook-paths.mjs).
|
|
23
|
+
"~/.claude/hooks",
|
|
20
24
|
"~/.claude/CLAUDE.md",
|
|
21
25
|
"~/.claude.json",
|
|
22
26
|
"~/.codex/auth.json",
|
|
@@ -180,6 +184,9 @@ export async function runCloudCommand(command, args, options = {}) {
|
|
|
180
184
|
case "onload":
|
|
181
185
|
await onload(rest, options);
|
|
182
186
|
return;
|
|
187
|
+
case "move":
|
|
188
|
+
await moveCommand(rest, options);
|
|
189
|
+
return;
|
|
183
190
|
case "bootstrap":
|
|
184
191
|
await bootstrap(rest, options);
|
|
185
192
|
return;
|
|
@@ -544,6 +551,9 @@ async function handoffRunToCloud(repoRoot, runId, options) {
|
|
|
544
551
|
body,
|
|
545
552
|
});
|
|
546
553
|
await printResult(result, options);
|
|
554
|
+
// The sail owns the conversation now. The dashboard watches this record
|
|
555
|
+
// for exactly that ("migrated") instead of assuming it at spawn time.
|
|
556
|
+
await markRunsMigrated(repoRoot, [runId]);
|
|
547
557
|
await maybeAutoAttach(result, options);
|
|
548
558
|
// The handoff SUCCEEDED the moment the sail launched: the cloud machine
|
|
549
559
|
// owns the conversation now. An attach that drops afterwards (network
|
|
@@ -2728,10 +2738,17 @@ async function workspaceList(options) {
|
|
|
2728
2738
|
async function attach(args, options) {
|
|
2729
2739
|
const sailId = args[0];
|
|
2730
2740
|
if (!sailId) {
|
|
2731
|
-
|
|
2741
|
+
// In a repo, `rudder cloud attach` is coming back to it: open it on the
|
|
2742
|
+
// home machine (waking it if needed) and attach, without the first-run
|
|
2743
|
+
// offers `rudder cloud` makes.
|
|
2744
|
+
await openOnHome(false, { ...options, reattach: true });
|
|
2745
|
+
return;
|
|
2732
2746
|
}
|
|
2733
2747
|
await runAttach({ kind: "sail", id: sailId, label: sailId }, options);
|
|
2734
2748
|
}
|
|
2749
|
+
const ATTACH_PING_MS = 5_000;
|
|
2750
|
+
const ATTACH_PONG_TIMEOUT_MS = 20_000;
|
|
2751
|
+
const ATTACH_SLEEP_GAP_MS = 15_000;
|
|
2735
2752
|
// The remote TUI pushes the kitty keyboard enhancement flags once, in its own
|
|
2736
2753
|
// setup_terminal(), and the worker only keeps the TAIL of its output — so by the
|
|
2737
2754
|
// time an attach connects, that push is long gone and this terminal never sees
|
|
@@ -2750,7 +2767,7 @@ function containsCtrlCInput(data) {
|
|
|
2750
2767
|
async function attachWithReconnect(target, options, reopen) {
|
|
2751
2768
|
let result = await runAttach(target, { ...options, quietBanner: false });
|
|
2752
2769
|
let attempts = 0;
|
|
2753
|
-
while (result === "dropped" && attempts <
|
|
2770
|
+
while (result === "dropped" && attempts < 120) {
|
|
2754
2771
|
attempts += 1;
|
|
2755
2772
|
process.stderr.write(`\r\nLost the connection to Rudder Cloud (it may be restarting). Reconnecting${attempts > 1 ? ` (try ${attempts})` : ""}...\r\n`);
|
|
2756
2773
|
await sleep(Math.min(15_000, 2_000 * attempts));
|
|
@@ -2901,11 +2918,40 @@ async function runAttach(target, options) {
|
|
|
2901
2918
|
}
|
|
2902
2919
|
splash?.redraw();
|
|
2903
2920
|
};
|
|
2921
|
+
// Liveness. A laptop that slept leaves this socket half-open: nothing
|
|
2922
|
+
// errors, the screen just freezes, and the relay has meanwhile dropped us
|
|
2923
|
+
// (or handed control to the next terminal). Ping the relay, and treat a
|
|
2924
|
+
// missed pong or a timer that fired far too late (the machine was asleep)
|
|
2925
|
+
// as a dropped connection, which attachWithReconnect reopens.
|
|
2926
|
+
let lastHeard = Date.now();
|
|
2927
|
+
let lastTick = Date.now();
|
|
2928
|
+
socket.on("pong", () => { lastHeard = Date.now(); });
|
|
2929
|
+
socket.on("message", () => { lastHeard = Date.now(); });
|
|
2930
|
+
const liveness = setInterval(() => {
|
|
2931
|
+
const now = Date.now();
|
|
2932
|
+
const slept = now - lastTick > ATTACH_SLEEP_GAP_MS;
|
|
2933
|
+
lastTick = now;
|
|
2934
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
if (slept || now - lastHeard > ATTACH_PONG_TIMEOUT_MS) {
|
|
2938
|
+
socket.terminate();
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
try {
|
|
2942
|
+
socket.ping();
|
|
2943
|
+
}
|
|
2944
|
+
catch {
|
|
2945
|
+
socket.terminate();
|
|
2946
|
+
}
|
|
2947
|
+
}, ATTACH_PING_MS);
|
|
2948
|
+
liveness.unref?.();
|
|
2904
2949
|
const cleanup = () => {
|
|
2905
2950
|
if (cleaned) {
|
|
2906
2951
|
return;
|
|
2907
2952
|
}
|
|
2908
2953
|
cleaned = true;
|
|
2954
|
+
clearInterval(liveness);
|
|
2909
2955
|
stdin.off("data", onStdin);
|
|
2910
2956
|
stdout.off("resize", onResize);
|
|
2911
2957
|
process.off("SIGINT", onSigint);
|
|
@@ -3407,6 +3453,10 @@ Usage:
|
|
|
3407
3453
|
holds every repo) and attach to its dashboard. --snapshot uploads this
|
|
3408
3454
|
folder to its own machine instead.
|
|
3409
3455
|
rudder cloud home [status|start|stop|open <owner/repo>]
|
|
3456
|
+
rudder cloud move [runId...] [--yes]
|
|
3457
|
+
move this repo's unfinished agents (or just the named ones) to the
|
|
3458
|
+
home machine: their jj changes and conversations go along, nothing is
|
|
3459
|
+
pushed to GitHub, and they keep working with this laptop closed
|
|
3410
3460
|
rudder cloud app
|
|
3411
3461
|
a one-time code that signs the phone app in to this account
|
|
3412
3462
|
rudder cloud doctor
|
|
@@ -3425,7 +3475,7 @@ Usage:
|
|
|
3425
3475
|
resumes its conversation there; no runId uploads the current
|
|
3426
3476
|
Rudder workspace state
|
|
3427
3477
|
rudder cloud logs <id>
|
|
3428
|
-
rudder cloud attach
|
|
3478
|
+
rudder cloud attach [id] (no id: reattach this repo on the home machine)
|
|
3429
3479
|
stream the live cloud worker terminal into this pane
|
|
3430
3480
|
rudder cloud talk <id> "<message>"
|
|
3431
3481
|
send a message to a running instance and show its reply
|
|
@@ -3509,6 +3559,15 @@ function cloudNameFromTask(task) {
|
|
|
3509
3559
|
.replace(/-+$/g, "");
|
|
3510
3560
|
return slug || randomCloudName();
|
|
3511
3561
|
}
|
|
3562
|
+
async function repoOpenOnHome(client, slug) {
|
|
3563
|
+
try {
|
|
3564
|
+
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
3565
|
+
return (result.projects ?? []).some((project) => String(project.repo ?? "").toLowerCase() === slug.toLowerCase());
|
|
3566
|
+
}
|
|
3567
|
+
catch {
|
|
3568
|
+
return false;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3512
3571
|
async function openOnHome(forceSnapshot, options) {
|
|
3513
3572
|
if (forceSnapshot) {
|
|
3514
3573
|
await workspaceAttach([], options);
|
|
@@ -3525,11 +3584,21 @@ async function openOnHome(forceSnapshot, options) {
|
|
|
3525
3584
|
}
|
|
3526
3585
|
const client = await cloudClient({ requireToken: true });
|
|
3527
3586
|
const name = slug.split("/")[1];
|
|
3587
|
+
// Coming back (laptop reopened, terminal closed): a repo already open on
|
|
3588
|
+
// the home machine reattaches straight away, like `rudder cloud attach`.
|
|
3589
|
+
// Credentials are still compared, and running local agents still offered a
|
|
3590
|
+
// move, because those are about this laptop now.
|
|
3591
|
+
const reattach = options.reattach || await repoOpenOnHome(client, slug);
|
|
3528
3592
|
if (!options.json) {
|
|
3529
|
-
|
|
3593
|
+
if (!reattach) {
|
|
3594
|
+
await warnLocalOnlyWork(repoRoot);
|
|
3595
|
+
}
|
|
3530
3596
|
await ensureVaultGithubToken(client, slug);
|
|
3531
3597
|
await ensureVaultAws(client);
|
|
3532
|
-
|
|
3598
|
+
if (!reattach) {
|
|
3599
|
+
await offerEnvFilesToVault(client, repoRoot, name);
|
|
3600
|
+
}
|
|
3601
|
+
await offerToMoveAgents(client, repoRoot, slug);
|
|
3533
3602
|
process.stderr.write(`Opening ${slug} on your home machine...\n`);
|
|
3534
3603
|
}
|
|
3535
3604
|
const branch = await pushedBranch(repoRoot);
|
|
@@ -3615,9 +3684,15 @@ async function ensureVaultAws(client) {
|
|
|
3615
3684
|
if (changed.length === 0) {
|
|
3616
3685
|
return;
|
|
3617
3686
|
}
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
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;
|
|
3695
|
+
}
|
|
3621
3696
|
}
|
|
3622
3697
|
for (const file of changed) {
|
|
3623
3698
|
await client.request("/api/rudder/secrets/item", {
|
|
@@ -3638,7 +3713,7 @@ async function localGithubToken() {
|
|
|
3638
3713
|
// The cloud machine clones from GitHub, so a private repo needs a GitHub token
|
|
3639
3714
|
// in the vault. gh keeps its token in the OS keychain (hosts.yml has none), so
|
|
3640
3715
|
// it never arrived with the synced gh config; offer to put it there.
|
|
3641
|
-
async function ensureVaultGithubToken(client, slug) {
|
|
3716
|
+
async function ensureVaultGithubToken(client, slug, interactive = true) {
|
|
3642
3717
|
const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
|
|
3643
3718
|
if (!vault || (vault.secrets ?? []).some((secret) => secret.name === "GH_TOKEN" || secret.name === "GITHUB_TOKEN")) {
|
|
3644
3719
|
return;
|
|
@@ -3648,7 +3723,7 @@ async function ensureVaultGithubToken(client, slug) {
|
|
|
3648
3723
|
process.stderr.write(`The cloud vault has no GitHub token, so private repos can't be cloned there. Run \`gh auth login\` here, or \`rudder cloud secrets set GH_TOKEN\`.\n`);
|
|
3649
3724
|
return;
|
|
3650
3725
|
}
|
|
3651
|
-
if (!isTty()) {
|
|
3726
|
+
if (!isTty() || !interactive) {
|
|
3652
3727
|
process.stderr.write("The cloud vault has no GitHub token; private repos will fail to clone. Run `rudder cloud secrets sync` from a terminal.\n");
|
|
3653
3728
|
return;
|
|
3654
3729
|
}
|
|
@@ -3662,6 +3737,211 @@ async function ensureVaultGithubToken(client, slug) {
|
|
|
3662
3737
|
});
|
|
3663
3738
|
process.stderr.write("Stored GH_TOKEN in the cloud vault.\n");
|
|
3664
3739
|
}
|
|
3740
|
+
const HOME_MOVE_POLL_MS = 1500;
|
|
3741
|
+
const HOME_MOVE_DEADLINE_MS = 15 * 60 * 1000;
|
|
3742
|
+
async function moveCommand(args, options) {
|
|
3743
|
+
const yes = options.yes || args.includes("--yes") || args.includes("-y");
|
|
3744
|
+
const runIds = args.filter((arg) => !arg.startsWith("-"));
|
|
3745
|
+
const repoRoot = findRepoRoot();
|
|
3746
|
+
const slug = await githubSlugFromOrigin(repoRoot);
|
|
3747
|
+
if (!slug) {
|
|
3748
|
+
throw new Error("This repo has no GitHub origin, so the home machine can't clone it. `rudder cloud workspace attach` moves agents into a snapshot workspace instead.");
|
|
3749
|
+
}
|
|
3750
|
+
const candidates = await findMoveCandidates(repoRoot, runIds.length > 0 ? runIds : undefined);
|
|
3751
|
+
const missing = runIds.filter((id) => !candidates.some((candidate) => candidate.runId === id));
|
|
3752
|
+
if (missing.length > 0) {
|
|
3753
|
+
throw new Error(`Not movable (finished, not in a workspace of its own, or unknown): ${missing.join(", ")}`);
|
|
3754
|
+
}
|
|
3755
|
+
if (candidates.length === 0) {
|
|
3756
|
+
if (!options.json) {
|
|
3757
|
+
process.stderr.write("No unfinished agents with workspaces of their own to move.\n");
|
|
3758
|
+
}
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
if (!yes && !options.json) {
|
|
3762
|
+
if (!isTty()) {
|
|
3763
|
+
throw new Error("Refusing to move agents without a terminal to confirm in; pass --yes.");
|
|
3764
|
+
}
|
|
3765
|
+
process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
|
|
3766
|
+
if (!(await promptConfirm(`Move ${agentCount(candidates.length)} to the home machine?`, true))) {
|
|
3767
|
+
process.stderr.write("Nothing moved.\n");
|
|
3768
|
+
return;
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
const client = await cloudClient({ requireToken: true });
|
|
3772
|
+
if (!options.json) {
|
|
3773
|
+
await ensureVaultGithubToken(client, slug, !yes);
|
|
3774
|
+
}
|
|
3775
|
+
const moved = await moveAgentsToHome(client, repoRoot, slug, candidates, options);
|
|
3776
|
+
if (options.json) {
|
|
3777
|
+
printJson(moved);
|
|
3778
|
+
}
|
|
3779
|
+
if (moved.adopted.length === 0) {
|
|
3780
|
+
process.exitCode = 1;
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
function agentCount(count) {
|
|
3784
|
+
return `${count} agent${count === 1 ? "" : "s"}`;
|
|
3785
|
+
}
|
|
3786
|
+
function describeMoveCandidates(candidates) {
|
|
3787
|
+
return candidates
|
|
3788
|
+
.map((candidate) => ` ${candidate.status.padEnd(8)} ${(candidate.taskSummary || candidate.task || candidate.runId).replace(/\s+/g, " ").slice(0, 72)}`)
|
|
3789
|
+
.join("\n");
|
|
3790
|
+
}
|
|
3791
|
+
/** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
|
|
3792
|
+
export async function moveAgentsToHome(client, repoRoot, slug, candidates, options) {
|
|
3793
|
+
const say = (line) => {
|
|
3794
|
+
if (!options.json) {
|
|
3795
|
+
process.stderr.write(`${line}\n`);
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
const stageDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-stage-"));
|
|
3799
|
+
const archiveDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-"));
|
|
3800
|
+
let job;
|
|
3801
|
+
try {
|
|
3802
|
+
say(`Packing ${agentCount(candidates.length)} (their jj changes and conversations)...`);
|
|
3803
|
+
await buildMoveBundle(repoRoot, candidates, stageDir);
|
|
3804
|
+
const archivePath = path.join(archiveDir, "move.tgz");
|
|
3805
|
+
await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
|
|
3806
|
+
const branch = await pushedBranch(repoRoot);
|
|
3807
|
+
job = await client.request("/api/rudder/home/move", {
|
|
3808
|
+
method: "POST",
|
|
3809
|
+
body: {
|
|
3810
|
+
repo: slug,
|
|
3811
|
+
...(branch ? { branch } : {}),
|
|
3812
|
+
bundle: await fsp.readFile(archivePath, "base64"),
|
|
3813
|
+
},
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
finally {
|
|
3817
|
+
await fsp.rm(stageDir, { recursive: true, force: true });
|
|
3818
|
+
await fsp.rm(archiveDir, { recursive: true, force: true });
|
|
3819
|
+
}
|
|
3820
|
+
const moveId = job.move?.id;
|
|
3821
|
+
if (!moveId) {
|
|
3822
|
+
throw new Error("the control plane did not start the move");
|
|
3823
|
+
}
|
|
3824
|
+
const deadline = Date.now() + HOME_MOVE_DEADLINE_MS;
|
|
3825
|
+
let lastDetail = "";
|
|
3826
|
+
while (job.move?.status !== "done" && job.move?.status !== "failed") {
|
|
3827
|
+
if (Date.now() > deadline) {
|
|
3828
|
+
throw new Error("the home machine did not take the agents in time; they stay here");
|
|
3829
|
+
}
|
|
3830
|
+
const detail = job.move?.detail ?? "";
|
|
3831
|
+
if (detail && detail !== lastDetail) {
|
|
3832
|
+
say(detail);
|
|
3833
|
+
lastDetail = detail;
|
|
3834
|
+
}
|
|
3835
|
+
await new Promise((resolve) => setTimeout(resolve, HOME_MOVE_POLL_MS));
|
|
3836
|
+
job = await client.request(`/api/rudder/home/move/${encodeURIComponent(moveId)}`, { method: "GET" });
|
|
3837
|
+
}
|
|
3838
|
+
if (job.move?.status === "failed") {
|
|
3839
|
+
throw new Error(`the home machine could not take the agents: ${job.move.error || "unknown error"}`);
|
|
3840
|
+
}
|
|
3841
|
+
const adopted = (job.move?.result?.adopted ?? []).map((entry) => entry.runId);
|
|
3842
|
+
const failed = job.move?.result?.failed ?? [];
|
|
3843
|
+
await markRunsMovedHome(repoRoot, adopted, slug);
|
|
3844
|
+
if (adopted.length > 0) {
|
|
3845
|
+
const fresh = (job.move?.result?.adopted ?? []).filter((entry) => entry.resumable === false).length;
|
|
3846
|
+
say(`Moved ${agentCount(adopted.length)} to the home machine. They keep working there with this laptop closed.`);
|
|
3847
|
+
if (fresh > 0) {
|
|
3848
|
+
say(`${agentCount(fresh)} had no saved conversation and restart from a handoff over their diff.`);
|
|
3849
|
+
}
|
|
3850
|
+
say(`Watch them: \`rudder cloud\` here, or on your phone: ${appUrl(client.baseUrl)}`);
|
|
3851
|
+
}
|
|
3852
|
+
for (const entry of failed) {
|
|
3853
|
+
say(`Could not move ${entry.runId}: ${entry.error}. It stays here.`);
|
|
3854
|
+
}
|
|
3855
|
+
return { adopted, failed };
|
|
3856
|
+
}
|
|
3857
|
+
/**
|
|
3858
|
+
* `rudder cloud` in a repo with agents: offer to take them along. A live
|
|
3859
|
+
* dashboard owns its agents' processes, so it does the move (it pauses them
|
|
3860
|
+
* first); with no dashboard running the agents are already stopped and this
|
|
3861
|
+
* process can move them itself.
|
|
3862
|
+
*/
|
|
3863
|
+
async function offerToMoveAgents(client, repoRoot, slug) {
|
|
3864
|
+
if (!isTty() || process.env.RUDDER_CLOUD_RUNTIME === "home" || process.env.RUDDER_CLOUD_NO_MOVE_OFFER === "1") {
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
const candidates = await findMoveCandidates(repoRoot).catch(() => []);
|
|
3868
|
+
if (candidates.length === 0) {
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
const dashboardLive = await dashboardServing(repoRoot);
|
|
3872
|
+
const running = candidates.filter((candidate) => candidate.status === "running");
|
|
3873
|
+
if (dashboardLive && running.length > 0) {
|
|
3874
|
+
const yes = await promptConfirm(`${agentCount(running.length)} ${running.length === 1 ? "is" : "are"} running here. Move them to the home machine?`, true);
|
|
3875
|
+
if (!yes) {
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
const receipt = await requestDashboardMove(repoRoot);
|
|
3879
|
+
process.stderr.write(receipt === "failed"
|
|
3880
|
+
? "The dashboard could not start the move; press H there to try again.\n"
|
|
3881
|
+
: "The dashboard is moving them; they appear on the home machine as they land.\n");
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
if (dashboardLive) {
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
|
|
3888
|
+
const yes = await promptConfirm(`${agentCount(candidates.length)} ${candidates.length === 1 ? "was" : "were"} left unfinished here. Move them to the home machine?`, true);
|
|
3889
|
+
if (!yes) {
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
try {
|
|
3893
|
+
await moveAgentsToHome(client, repoRoot, slug, candidates, {});
|
|
3894
|
+
}
|
|
3895
|
+
catch (error) {
|
|
3896
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
async function dashboardServing(repoRoot) {
|
|
3900
|
+
let socketPath;
|
|
3901
|
+
try {
|
|
3902
|
+
socketPath = await fsp.realpath(path.join(repoRoot, ".rudder", "attach.sock"));
|
|
3903
|
+
}
|
|
3904
|
+
catch {
|
|
3905
|
+
return false;
|
|
3906
|
+
}
|
|
3907
|
+
const net = await import("node:net");
|
|
3908
|
+
return await new Promise((resolve) => {
|
|
3909
|
+
const socket = net.connect(socketPath);
|
|
3910
|
+
const timer = setTimeout(() => {
|
|
3911
|
+
socket.destroy();
|
|
3912
|
+
resolve(false);
|
|
3913
|
+
}, 500);
|
|
3914
|
+
socket.once("connect", () => {
|
|
3915
|
+
clearTimeout(timer);
|
|
3916
|
+
socket.destroy();
|
|
3917
|
+
resolve(true);
|
|
3918
|
+
});
|
|
3919
|
+
socket.once("error", () => {
|
|
3920
|
+
clearTimeout(timer);
|
|
3921
|
+
resolve(false);
|
|
3922
|
+
});
|
|
3923
|
+
});
|
|
3924
|
+
}
|
|
3925
|
+
/** Ask the live dashboard (through its steer inbox) to move every agent it is running. */
|
|
3926
|
+
async function requestDashboardMove(repoRoot) {
|
|
3927
|
+
const requestId = randomUUID();
|
|
3928
|
+
const inbox = path.join(repoRoot, ".rudder", "steer");
|
|
3929
|
+
await ensureDir(inbox);
|
|
3930
|
+
const file = path.join(inbox, `${String(Date.now()).padStart(13, "0")}-move-home-${requestId}.json`);
|
|
3931
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
3932
|
+
await fsp.writeFile(temp, JSON.stringify({ requestId, kind: "move-home", taskId: "", instruction: "move-home", ts: nowIso() }));
|
|
3933
|
+
await fsp.rename(temp, file);
|
|
3934
|
+
const receiptPath = path.join(repoRoot, ".rudder", "steer-receipts", `${requestId}.json`);
|
|
3935
|
+
const deadline = Date.now() + 10_000;
|
|
3936
|
+
while (Date.now() < deadline) {
|
|
3937
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3938
|
+
const receipt = await readJson(receiptPath);
|
|
3939
|
+
if (receipt?.status === "delivered" || receipt?.status === "failed") {
|
|
3940
|
+
return receipt.status;
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return "queued";
|
|
3944
|
+
}
|
|
3665
3945
|
async function localIdentities() {
|
|
3666
3946
|
const [github, claude] = await Promise.all([
|
|
3667
3947
|
commandExists("gh")
|
|
@@ -3852,6 +4132,56 @@ async function printAppLink(options) {
|
|
|
3852
4132
|
or open ${pairing.link}
|
|
3853
4133
|
Browser: ${pairing.url}`);
|
|
3854
4134
|
}
|
|
4135
|
+
/**
|
|
4136
|
+
* `rudder cloud home runs <runId...>`: which of these agents (moved here from a
|
|
4137
|
+
* laptop) still exist on the home machine; `known: false` when it cannot
|
|
4138
|
+
* answer, which callers must read as "unknown", never "deleted".
|
|
4139
|
+
* `rudder cloud home delete <runId...>`: delete them there, through that repo's
|
|
4140
|
+
* dashboard, which also takes them off the phone. Queued if the machine is
|
|
4141
|
+
* asleep. This is what `dd` on a moved row in the laptop's dashboard runs.
|
|
4142
|
+
*/
|
|
4143
|
+
async function homeAgentsCommand(client, sub, args, options) {
|
|
4144
|
+
const yes = options.yes || args.includes("--yes") || args.includes("-y");
|
|
4145
|
+
const runIds = args.filter((arg) => !arg.startsWith("-"));
|
|
4146
|
+
if (runIds.length === 0) {
|
|
4147
|
+
throw new Error(`Usage: rudder cloud home ${sub} <runId...>`);
|
|
4148
|
+
}
|
|
4149
|
+
const repo = await githubSlugFromOrigin(findRepoRoot());
|
|
4150
|
+
if (!repo) {
|
|
4151
|
+
throw new Error("This repo has no GitHub origin, so none of its agents live on the home machine.");
|
|
4152
|
+
}
|
|
4153
|
+
if (sub === "delete" && !yes && !options.json) {
|
|
4154
|
+
if (!isTty()) {
|
|
4155
|
+
throw new Error("Refusing to delete agents without a terminal to confirm in; pass --yes.");
|
|
4156
|
+
}
|
|
4157
|
+
if (!(await promptConfirm(`Delete ${runIds.length === 1 ? "this agent" : `${runIds.length} agents`} on the home machine? This cannot be undone.`, false))) {
|
|
4158
|
+
console.log("Nothing deleted.");
|
|
4159
|
+
return;
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
const result = await client.request("/api/rudder/home/agents", {
|
|
4163
|
+
method: "POST",
|
|
4164
|
+
body: { repo, action: sub === "runs" ? "check" : "delete", runIds },
|
|
4165
|
+
});
|
|
4166
|
+
if (options.json) {
|
|
4167
|
+
printJson(result);
|
|
4168
|
+
return;
|
|
4169
|
+
}
|
|
4170
|
+
if (sub === "runs") {
|
|
4171
|
+
if (!result.known) {
|
|
4172
|
+
console.log("The home machine is not connected, so it can't say.");
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
const existing = new Set(result.existing ?? []);
|
|
4176
|
+
for (const id of runIds) {
|
|
4177
|
+
console.log(`${existing.has(id) ? "there " : "gone "} ${id}`);
|
|
4178
|
+
}
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
console.log(result.status === "queued"
|
|
4182
|
+
? "The home machine is asleep; it deletes them as soon as it is back."
|
|
4183
|
+
: "Deleted on the home machine.");
|
|
4184
|
+
}
|
|
3855
4185
|
async function homeCommand(args, options) {
|
|
3856
4186
|
const client = await cloudClient({ requireToken: true });
|
|
3857
4187
|
const sub = args[0] ?? "status";
|
|
@@ -3885,8 +4215,12 @@ async function homeCommand(args, options) {
|
|
|
3885
4215
|
}
|
|
3886
4216
|
return;
|
|
3887
4217
|
}
|
|
4218
|
+
if (sub === "runs" || sub === "delete") {
|
|
4219
|
+
await homeAgentsCommand(client, sub, args.slice(1), options);
|
|
4220
|
+
return;
|
|
4221
|
+
}
|
|
3888
4222
|
if (sub !== "status") {
|
|
3889
|
-
throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo
|
|
4223
|
+
throw new Error("Usage: rudder cloud home [status|start|stop|open <owner/repo>|runs <runId...>|delete <runId...>]");
|
|
3890
4224
|
}
|
|
3891
4225
|
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
3892
4226
|
if (options.json) {
|