mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
feat: recursive spawn tree passback (#3023)
* feat: pull child spawn history back to parent for `spawn tree` When the interactive session ends (or headless mode completes), the parent downloads the child VM's history.json and merges records into local history. Before downloading, it runs `spawn pull-history` on the child, which recursively pulls from all grandchildren — so the full tree collapses up to the root regardless of depth. Changes: - Add getParentFields() — sets parent_id/depth on saveSpawnRecord calls - Add pullChildHistory() — downloads + merges child history after session - Add `spawn pull-history` command for recursive SSH-based history pull - Add 11 tests for parseAndMergeChildHistory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger CI recompute Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): validate user/ip params before SSH exec in pull-history Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): use shared validators for SSH params in pull-history and delete Replace inline regex checks in pull-history.ts with validateUsername() and validateConnectionIP() from security.ts, matching the pattern used across connect.ts, fix.ts, and link.ts. Also add the same validation to delete.ts:pullChildHistory which had no SSH parameter validation. orchestrate.ts uses the runner abstraction (not raw user@ip), so its SSH params come from the cloud provider, not untrusted history records. Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
parent
a8e63648da
commit
4ac4a7e0cf
7 changed files with 584 additions and 6 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.26.13",
|
||||
"version": "0.27.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
253
packages/cli/src/__tests__/pull-history.test.ts
Normal file
253
packages/cli/src/__tests__/pull-history.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { cmdPullHistory, parseAndMergeChildHistory } from "../commands/pull-history.js";
|
||||
import * as historyModule from "../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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdPullHistory tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("cmdPullHistory", () => {
|
||||
it("returns immediately when no active servers", async () => {
|
||||
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([]);
|
||||
await cmdPullHistory();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("skips servers without connection info", async () => {
|
||||
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
|
||||
{
|
||||
id: "test-1",
|
||||
agent: "claude",
|
||||
cloud: "hetzner",
|
||||
timestamp: "2026-03-26T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
// Should not throw — just skips the record with no connection
|
||||
await cmdPullHistory();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("skips servers with missing ip", async () => {
|
||||
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
|
||||
{
|
||||
id: "test-2",
|
||||
agent: "claude",
|
||||
cloud: "hetzner",
|
||||
timestamp: "2026-03-26T00:00:00Z",
|
||||
connection: {
|
||||
ip: "",
|
||||
user: "root",
|
||||
},
|
||||
},
|
||||
]);
|
||||
await cmdPullHistory();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,7 +16,12 @@ import {
|
|||
import { ensureHcloudToken, destroyServer as hetznerDestroyServer } from "../hetzner/hetzner.js";
|
||||
import { getActiveServers, loadHistory, markRecordDeleted, mergeChildHistory, SpawnRecordSchema } from "../history.js";
|
||||
import { loadManifest } from "../manifest.js";
|
||||
import { validateMetadataValue, validateServerIdentifier } from "../security.js";
|
||||
import {
|
||||
validateConnectionIP,
|
||||
validateMetadataValue,
|
||||
validateServerIdentifier,
|
||||
validateUsername,
|
||||
} from "../security.js";
|
||||
import { getHistoryPath } from "../shared/paths.js";
|
||||
import { asyncTryCatch, asyncTryCatchIf, isNetworkError, tryCatch } from "../shared/result.js";
|
||||
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
|
||||
|
|
@ -250,6 +255,14 @@ export async function pullChildHistory(record: SpawnRecord): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const connValidation = tryCatch(() => {
|
||||
validateUsername(conn.user);
|
||||
validateConnectionIP(conn.ip);
|
||||
});
|
||||
if (!connValidation.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ensureSshKeys, getSshKeyOpts } = await import("../shared/ssh-keys.js");
|
||||
const { SSH_BASE_OPTS } = await import("../shared/ssh.js");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
178
packages/cli/src/commands/pull-history.ts
Normal file
178
packages/cli/src/commands/pull-history.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// 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 { validateConnectionIP, validateUsername } from "../security.js";
|
||||
import { parseJsonWith } from "../shared/parse.js";
|
||||
import { asyncTryCatch, tryCatch } 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<void> {
|
||||
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;
|
||||
|
||||
const validation = tryCatch(() => {
|
||||
validateUsername(user);
|
||||
validateConnectionIP(ip);
|
||||
});
|
||||
if (!validation.ok) {
|
||||
logDebug(`Skipping record with invalid connection: ${user}@${ip}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await pullFromChild(ip, user, spawnId, sshKeyOpts);
|
||||
}
|
||||
}
|
||||
|
||||
async function pullFromChild(ip: string, user: string, parentSpawnId: string, sshKeyOpts: string[]): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
cmdListClear,
|
||||
cmdMatrix,
|
||||
cmdPick,
|
||||
cmdPullHistory,
|
||||
cmdRun,
|
||||
cmdRunHeadless,
|
||||
cmdStatus,
|
||||
|
|
@ -744,6 +745,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") {
|
||||
|
|
|
|||
|
|
@ -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<voi
|
|||
logInfo("Cloud credentials delegated to VM");
|
||||
}
|
||||
|
||||
/** Get parent_id and depth fields for spawn records (set when running inside a child VM). */
|
||||
function getParentFields(): {
|
||||
parent_id?: string;
|
||||
depth?: number;
|
||||
} {
|
||||
const parentId = process.env.SPAWN_PARENT_ID;
|
||||
const depth = Number(process.env.SPAWN_DEPTH) || 0;
|
||||
return parentId
|
||||
? {
|
||||
parent_id: parentId,
|
||||
depth,
|
||||
}
|
||||
: depth > 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;
|
||||
|
|
@ -299,6 +326,7 @@ export async function runOrchestration(
|
|||
name: spawnName,
|
||||
}
|
||||
: {}),
|
||||
...getParentFields(),
|
||||
connection: conn,
|
||||
});
|
||||
await cloud.waitForReady();
|
||||
|
|
@ -341,6 +369,7 @@ export async function runOrchestration(
|
|||
name: spawnName2,
|
||||
}
|
||||
: {}),
|
||||
...getParentFields(),
|
||||
connection,
|
||||
});
|
||||
await cloud.waitForReady();
|
||||
|
|
@ -448,6 +477,7 @@ export async function runOrchestration(
|
|||
name: spawnName,
|
||||
}
|
||||
: {}),
|
||||
...getParentFields(),
|
||||
connection,
|
||||
});
|
||||
|
||||
|
|
@ -689,6 +719,9 @@ async function postInstall(
|
|||
if (tunnelHandle) {
|
||||
tunnelHandle.stop();
|
||||
}
|
||||
if (cloud.cloudName !== "local") {
|
||||
await pullChildHistory(cloud.runner, spawnId);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -735,5 +768,99 @@ 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.
|
||||
* First tells the child to recursively pull from ITS children via
|
||||
* `spawn pull-history`, then downloads the child's history.json.
|
||||
* This enables `spawn tree` to show the full recursive hierarchy.
|
||||
*/
|
||||
async function pullChildHistory(runner: CloudRunner, parentSpawnId: string): Promise<void> {
|
||||
const result = await asyncTryCatch(async () => {
|
||||
const tmpPath = `${getTmpDir()}/child-history-${parentSpawnId}.json`;
|
||||
|
||||
// Recursive pull: tell the child to pull from ALL its children first.
|
||||
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");
|
||||
}
|
||||
|
||||
// Copy the child's history to a temp location then download
|
||||
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;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
tryCatch(() => unlinkSync(tmpPath));
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
logDebug(`Could not pull child history: ${getErrorMessage(result.error)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue