From d13512ef2d0f96b20940c7f3ad3fd9e6ef49e50a Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Wed, 25 Mar 2026 21:05:38 -0700 Subject: [PATCH] feat: pull child spawn history back to parent for `spawn tree` When the interactive session ends, the parent now downloads the child VM's history.json and merges it into local history via mergeChildHistory. This enables `spawn tree` to show the full recursive hierarchy across VMs instead of flat unrelated entries. Also sets parent_id and depth on all saveSpawnRecord calls using SPAWN_PARENT_ID from the environment, so child VMs properly link back to their parent in the spawn tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- .../cli/src/__tests__/pull-history.test.ts | 210 ++++++++++++++++++ packages/cli/src/commands/index.ts | 2 + packages/cli/src/commands/pull-history.ts | 168 ++++++++++++++ packages/cli/src/index.ts | 5 + packages/cli/src/shared/orchestrate.ts | 140 +++++++++++- 6 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/__tests__/pull-history.test.ts create mode 100644 packages/cli/src/commands/pull-history.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 0352f96c..574bf81b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.11", + "version": "0.27.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/pull-history.test.ts b/packages/cli/src/__tests__/pull-history.test.ts new file mode 100644 index 00000000..abe02ecc --- /dev/null +++ b/packages/cli/src/__tests__/pull-history.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseAndMergeChildHistory } from "../commands/pull-history.js"; +import { loadHistory } from "../history.js"; + +// ─── parseAndMergeChildHistory tests ───────────────────────────────────────── + +describe("parseAndMergeChildHistory", () => { + let origSpawnHome: string | undefined; + + beforeEach(() => { + origSpawnHome = process.env.SPAWN_HOME; + // Use isolated temp dir for history (preload sets HOME to a temp dir) + const tmpHome = process.env.HOME ?? "/tmp"; + const spawnDir = join(tmpHome, `.spawn-test-${Date.now()}-${Math.random()}`); + mkdirSync(spawnDir, { + recursive: true, + }); + process.env.SPAWN_HOME = spawnDir; + // Write empty history + writeFileSync( + join(spawnDir, "history.json"), + JSON.stringify({ + version: 1, + records: [], + }), + ); + }); + + afterEach(() => { + if (origSpawnHome === undefined) { + delete process.env.SPAWN_HOME; + } else { + process.env.SPAWN_HOME = origSpawnHome; + } + }); + + it("returns 0 for empty string", () => { + expect(parseAndMergeChildHistory("", "parent-123")).toBe(0); + }); + + it("returns 0 for empty object", () => { + expect(parseAndMergeChildHistory("{}", "parent-123")).toBe(0); + }); + + it("returns 0 for invalid JSON", () => { + expect(parseAndMergeChildHistory("not json", "parent-123")).toBe(0); + }); + + it("returns 0 for empty records array", () => { + const json = JSON.stringify({ + version: 1, + records: [], + }); + expect(parseAndMergeChildHistory(json, "parent-123")).toBe(0); + }); + + it("parses and merges valid child records", () => { + const json = JSON.stringify({ + version: 1, + records: [ + { + id: "child-1", + agent: "claude", + cloud: "hetzner", + timestamp: "2026-03-26T00:00:00Z", + }, + { + id: "child-2", + agent: "codex", + cloud: "digitalocean", + timestamp: "2026-03-26T00:01:00Z", + name: "test-spawn", + }, + ], + }); + + const count = parseAndMergeChildHistory(json, "parent-123"); + expect(count).toBe(2); + + // Verify records were merged into history + const history = loadHistory(); + const child1 = history.find((r) => r.id === "child-1"); + const child2 = history.find((r) => r.id === "child-2"); + expect(child1).toBeDefined(); + expect(child1!.agent).toBe("claude"); + expect(child1!.parent_id).toBe("parent-123"); + expect(child2).toBeDefined(); + expect(child2!.name).toBe("test-spawn"); + expect(child2!.parent_id).toBe("parent-123"); + }); + + it("preserves existing parent_id from child records", () => { + const json = JSON.stringify({ + version: 1, + records: [ + { + id: "grandchild-1", + agent: "claude", + cloud: "aws", + timestamp: "2026-03-26T00:00:00Z", + parent_id: "child-abc", + depth: 2, + }, + ], + }); + + const count = parseAndMergeChildHistory(json, "parent-123"); + expect(count).toBe(1); + + const history = loadHistory(); + const gc = history.find((r) => r.id === "grandchild-1"); + expect(gc).toBeDefined(); + // parent_id should be preserved from the child record, not overwritten + // (mergeChildHistory only sets parent_id if it's not already set) + expect(gc!.parent_id).toBe("child-abc"); + expect(gc!.depth).toBe(2); + }); + + it("skips records without an id", () => { + const json = JSON.stringify({ + version: 1, + records: [ + { + agent: "claude", + cloud: "hetzner", + timestamp: "2026-03-26T00:00:00Z", + }, + { + id: "valid-1", + agent: "codex", + cloud: "gcp", + timestamp: "2026-03-26T00:01:00Z", + }, + ], + }); + + const count = parseAndMergeChildHistory(json, "parent-123"); + expect(count).toBe(1); + }); + + it("preserves connection info from child records", () => { + const json = JSON.stringify({ + version: 1, + records: [ + { + id: "child-conn", + agent: "claude", + cloud: "digitalocean", + timestamp: "2026-03-26T00:00:00Z", + connection: { + ip: "10.0.0.1", + user: "root", + server_id: "12345", + }, + }, + ], + }); + + const count = parseAndMergeChildHistory(json, "parent-123"); + expect(count).toBe(1); + + const history = loadHistory(); + const child = history.find((r) => r.id === "child-conn"); + expect(child!.connection?.ip).toBe("10.0.0.1"); + expect(child!.connection?.server_id).toBe("12345"); + }); + + it("deduplicates — calling twice with same records only merges once", () => { + const json = JSON.stringify({ + version: 1, + records: [ + { + id: "dedup-1", + agent: "claude", + cloud: "hetzner", + timestamp: "2026-03-26T00:00:00Z", + }, + ], + }); + + parseAndMergeChildHistory(json, "parent-123"); + parseAndMergeChildHistory(json, "parent-123"); + + const history = loadHistory(); + const matches = history.filter((r) => r.id === "dedup-1"); + expect(matches.length).toBe(1); + }); + + it("handles whitespace-only input", () => { + expect(parseAndMergeChildHistory(" \n ", "parent-123")).toBe(0); + }); + + it("handles history without version field", () => { + const json = JSON.stringify({ + records: [ + { + id: "no-version", + agent: "hermes", + cloud: "sprite", + timestamp: "2026-03-26T00:00:00Z", + }, + ], + }); + + const count = parseAndMergeChildHistory(json, "parent-123"); + expect(count).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ab8c493b..bf8a35ca 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -34,6 +34,8 @@ export { } from "./list.js"; // pick.ts — cmdPick export { cmdPick } from "./pick.js"; +// pull-history.ts — cmdPullHistory (recursive child history pull) +export { cmdPullHistory } from "./pull-history.js"; // run.ts — cmdRun, cmdRunHeadless, script failure guidance export { cmdRun, diff --git a/packages/cli/src/commands/pull-history.ts b/packages/cli/src/commands/pull-history.ts new file mode 100644 index 00000000..a02a376b --- /dev/null +++ b/packages/cli/src/commands/pull-history.ts @@ -0,0 +1,168 @@ +// commands/pull-history.ts — `spawn pull-history`: recursively pull child spawn history +// Called automatically by the parent after a session ends, or manually. +// SSHes into each active child, tells it to pull from ITS children first, +// then downloads its history.json and merges into local history. + +import type { SpawnRecord } from "../history.js"; + +import * as v from "valibot"; +import { getActiveServers, mergeChildHistory, SpawnRecordSchema } from "../history.js"; +import { parseJsonWith } from "../shared/parse.js"; +import { asyncTryCatch } from "../shared/result.js"; +import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; +import { logDebug, logInfo } from "../shared/ui.js"; + +const ChildHistorySchema = v.object({ + version: v.optional(v.number()), + records: v.array(SpawnRecordSchema), +}); + +/** + * Parse a child's history.json content and merge valid records into local history. + * Exported for testing — the SSH transport is in cmdPullHistory/pullFromChild. + */ +export function parseAndMergeChildHistory(json: string, parentSpawnId: string): number { + if (!json.trim() || json.trim() === "{}") { + return 0; + } + + const parsed = parseJsonWith(json, ChildHistorySchema); + if (!parsed || parsed.records.length === 0) { + return 0; + } + + const validRecords: SpawnRecord[] = []; + for (const r of parsed.records) { + if (r.id) { + validRecords.push({ + id: r.id, + agent: r.agent, + cloud: r.cloud, + timestamp: r.timestamp, + ...(r.name + ? { + name: r.name, + } + : {}), + ...(r.parent_id + ? { + parent_id: r.parent_id, + } + : {}), + ...(r.depth !== undefined + ? { + depth: r.depth, + } + : {}), + ...(r.connection + ? { + connection: r.connection, + } + : {}), + }); + } + } + + if (validRecords.length > 0) { + mergeChildHistory(parentSpawnId, validRecords); + } + return validRecords.length; +} + +/** + * Pull history from all active child VMs recursively. + * For each active child: + * 1. SSH in, run `spawn pull-history` (recurse into grandchildren) + * 2. Download the child's history.json + * 3. Merge into local history with parent_id links + */ +export async function cmdPullHistory(): Promise { + const active = getActiveServers(); + + if (active.length === 0) { + return; + } + + const keysResult = await asyncTryCatch(() => ensureSshKeys()); + if (!keysResult.ok) { + logDebug("Could not load SSH keys for history pull"); + return; + } + const sshKeyOpts = getSshKeyOpts(keysResult.data); + + for (const record of active) { + if (!record.connection?.ip || !record.connection?.user) { + continue; + } + + const { ip, user } = record.connection; + const spawnId = record.id; + + await pullFromChild(ip, user, spawnId, sshKeyOpts); + } +} + +async function pullFromChild(ip: string, user: string, parentSpawnId: string, sshKeyOpts: string[]): Promise { + const result = await asyncTryCatch(async () => { + const sshBase = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", + ...sshKeyOpts, + `${user}@${ip}`, + ]; + + // Step 1: Tell the child to recursively pull from its own children + const recurseProc = Bun.spawnSync( + [ + ...sshBase, + 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"; spawn pull-history 2>/dev/null || true', + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + timeout: 60_000, + }, + ); + if (recurseProc.exitCode !== 0) { + logDebug(`Recursive pull on ${ip} returned ${recurseProc.exitCode} (may not support pull-history)`); + } + + // Step 2: Download the child's history.json via SSH + cat + const catProc = Bun.spawnSync( + [ + ...sshBase, + "cat ~/.spawn/history.json 2>/dev/null || cat ~/.config/spawn/history.json 2>/dev/null || echo '{}'", + ], + { + stdio: [ + "ignore", + "pipe", + "ignore", + ], + timeout: 30_000, + }, + ); + + if (catProc.exitCode !== 0) { + return; + } + + const json = new TextDecoder().decode(catProc.stdout); + const merged = parseAndMergeChildHistory(json, parentSpawnId); + if (merged > 0) { + logInfo(`Pulled ${merged} record(s) from ${ip}`); + } + }); + + if (!result.ok) { + logDebug(`Could not pull history from ${ip}`); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 77990eba..23bb2c7c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -23,6 +23,7 @@ import { cmdListClear, cmdMatrix, cmdPick, + cmdPullHistory, cmdRun, cmdRunHeadless, cmdStatus, @@ -732,6 +733,10 @@ async function dispatchCommand( await cmdTree(jsonFlag); return; } + if (cmd === "pull-history") { + await cmdPullHistory(); + return; + } if (LIST_COMMANDS.has(cmd)) { // Handle "history export" subcommand if (cmd === "history" && filteredArgs[1] === "export") { diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 41dbfa65..34a93370 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -1,20 +1,28 @@ // shared/orchestrate.ts — Shared orchestration pipeline for deploying agents // Each cloud implements CloudOrchestrator and calls runOrchestration(). -import type { VMConnection } from "../history.js"; +import type { SpawnRecord, VMConnection } from "../history.js"; import type { CloudRunner } from "./agent-setup.js"; import type { AgentConfig } from "./agents.js"; import type { SshTunnelHandle } from "./ssh.js"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { getErrorMessage } from "@openrouter/spawn-shared"; import * as v from "valibot"; -import { generateSpawnId, saveLaunchCmd, saveMetadata, saveSpawnRecord } from "../history.js"; +import { + generateSpawnId, + mergeChildHistory, + SpawnRecordSchema, + saveLaunchCmd, + saveMetadata, + saveSpawnRecord, +} from "../history.js"; import { offerGithubAuth, setupAutoUpdate, wrapSshCall } from "./agent-setup.js"; import { tryTarballInstall } from "./agent-tarball.js"; import { generateEnvConfig } from "./agents.js"; import { getOrPromptApiKey } from "./oauth.js"; -import { getSpawnCloudConfigPath, getSpawnPreferencesPath } from "./paths.js"; +import { parseJsonWith } from "./parse.js"; +import { getSpawnCloudConfigPath, getSpawnPreferencesPath, getTmpDir } from "./paths.js"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatch } from "./result.js"; import { isWindows } from "./shell.js"; import { injectSpawnSkill } from "./spawn-skill.js"; @@ -204,6 +212,25 @@ export async function delegateCloudCredentials(runner: CloudRunner): Promise 0 + ? { + depth, + } + : {}; +} + /** Append recursive-spawn env vars to the envPairs array when --beta recursive is active. */ export function appendRecursiveEnvVars(envPairs: string[], spawnId: string): void { const currentDepth = Number(process.env.SPAWN_DEPTH) || 0; @@ -298,6 +325,7 @@ export async function runOrchestration( name: spawnName, } : {}), + ...getParentFields(), connection: conn, }); await cloud.waitForReady(); @@ -340,6 +368,7 @@ export async function runOrchestration( name: spawnName2, } : {}), + ...getParentFields(), connection, }); await cloud.waitForReady(); @@ -447,6 +476,7 @@ export async function runOrchestration( name: spawnName, } : {}), + ...getParentFields(), connection, }); @@ -688,6 +718,10 @@ async function postInstall( if (tunnelHandle) { tunnelHandle.stop(); } + // Pull child history even in headless mode so parent trees stay complete + if (cloud.cloudName !== "local") { + await pullChildHistory(cloud.runner, spawnId); + } process.exit(0); } @@ -734,5 +768,103 @@ async function postInstall( if (tunnelHandle) { tunnelHandle.stop(); } + + // Pull child's spawn history back to the parent for `spawn tree` + if (cloud.cloudName !== "local") { + await pullChildHistory(cloud.runner, spawnId); + } + process.exit(exitCode); } + +/** + * Pull spawn history from a child VM and merge it into local history. + * This enables `spawn tree` to show the full recursive hierarchy. + */ +async function pullChildHistory(runner: CloudRunner, parentSpawnId: string): Promise { + const result = await asyncTryCatch(async () => { + const tmpPath = `${getTmpDir()}/child-history-${parentSpawnId}.json`; + + // Recursive pull: tell the child to pull from ALL its children first. + // `spawn pull-history` recursively SSHes into each active child, pulls + // their history, and merges it into the child's local history.json. + // After this, the child's history contains the full subtree. + const recursePull = await asyncTryCatch(() => + runner.runServer( + 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"; spawn pull-history 2>/dev/null || true', + 120, + ), + ); + if (!recursePull.ok) { + logDebug("Recursive history pull skipped (spawn CLI may not support pull-history yet)"); + } + + // Copy the child's history via the runner's downloadFile + // Try both possible history locations (legacy ~/.spawn/ and new ~/.config/spawn/) + const copyResult = await asyncTryCatch(() => + runner.runServer( + "cp ~/.spawn/history.json /tmp/_spawn_history.json 2>/dev/null || cp ~/.config/spawn/history.json /tmp/_spawn_history.json 2>/dev/null || echo '{}' > /tmp/_spawn_history.json", + ), + ); + if (!copyResult.ok) { + return; + } + + await runner.downloadFile("/tmp/_spawn_history.json", tmpPath); + + const json = readFileSync(tmpPath, "utf-8"); + const ChildHistorySchema = v.object({ + version: v.optional(v.number()), + records: v.array(SpawnRecordSchema), + }); + const parsed = parseJsonWith(json, ChildHistorySchema); + if (!parsed || parsed.records.length === 0) { + return; + } + + // Filter to valid records with an id + const validRecords: SpawnRecord[] = []; + for (const r of parsed.records) { + if (r.id) { + validRecords.push({ + id: r.id, + agent: r.agent, + cloud: r.cloud, + timestamp: r.timestamp, + ...(r.name + ? { + name: r.name, + } + : {}), + ...(r.parent_id + ? { + parent_id: r.parent_id, + } + : {}), + ...(r.depth !== undefined + ? { + depth: r.depth, + } + : {}), + ...(r.connection + ? { + connection: r.connection, + } + : {}), + }); + } + } + + if (validRecords.length > 0) { + mergeChildHistory(parentSpawnId, validRecords); + logInfo(`Pulled ${validRecords.length} spawn record(s) from child VM`); + } + + // Clean up temp file + tryCatch(() => unlinkSync(tmpPath)); + }); + + if (!result.ok) { + logDebug(`Could not pull child history: ${getErrorMessage(result.error)}`); + } +}