mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-08-14 19:13:35 +00:00
fix(export): support sprite spawns (drop sprite-console exclusion) (#3382)
The export filter was excluding records with ip === "sprite-console" on
the assumption they weren't reachable. Sprite has its own exec channel
(sprite exec / sprite cp), so they ARE reachable — we just weren't
wiring it up.
- exportableClaudeRecords no longer drops sprite-console records.
- New buildRunnerForRecord() branches on record.cloud:
sprite -> sprite.runSprite / uploadFileSprite / downloadFileSprite
else -> makeSshRunner with the connection's ip/user
- sprite/sprite.ts gains setSpriteName(name) so the export flow can
point the sprite module at an existing sprite by name (the runner
functions key off the module's _state.name, which previously was
only set during the createSprite() flow).
Bumps CLI 1.0.32 -> 1.0.33.
This commit is contained in:
parent
3e99bdd604
commit
46bbeea751
4 changed files with 73 additions and 15 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.32",
|
||||
"version": "1.0.33",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -317,22 +317,44 @@ describe("cmdExport", () => {
|
|||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("filters out sprite-console connections", async () => {
|
||||
const spriteConsole: SpawnRecord = {
|
||||
it("includes sprite-console connections (sprite has its own runner)", async () => {
|
||||
const spriteRecord: SpawnRecord = {
|
||||
...baseRecord,
|
||||
cloud: "sprite",
|
||||
connection: {
|
||||
...baseRecord.connection!,
|
||||
cloud: "sprite",
|
||||
ip: "sprite-console",
|
||||
},
|
||||
};
|
||||
// The injected runner short-circuits the sprite-module import, so we just
|
||||
// need cmdExport to attempt the export rather than filtering the record out.
|
||||
const ranWith: {
|
||||
script?: string;
|
||||
} = {};
|
||||
const stubRunner = {
|
||||
runServer: async (cmd: string) => {
|
||||
ranWith.script = cmd;
|
||||
},
|
||||
uploadFile: async () => {},
|
||||
// downloadFile must succeed so the parser sees a result file. Make it
|
||||
// throw a recognisable error and assert we got past the filter step.
|
||||
downloadFile: async () => {
|
||||
throw new Error("__downloadFile_called__");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
cmdExport(undefined, {
|
||||
records: [
|
||||
spriteConsole,
|
||||
spriteRecord,
|
||||
],
|
||||
visibility: "private",
|
||||
makeRunner: () => stubRunner,
|
||||
}),
|
||||
).rejects.toThrow("__exit__");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
// The script ran (record passed the filter), then the download stub threw,
|
||||
// which cmdExport surfaces as exit(1). What matters: ranWith.script is set.
|
||||
expect(ranWith.script).toBeDefined();
|
||||
});
|
||||
|
||||
it("errors with a target hint when the named spawn doesn't exist", async () => {
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ const ResultSchema = v.union([
|
|||
}),
|
||||
]);
|
||||
|
||||
/** Filter to records the export can actually drive: claude, with a live SSH
|
||||
* connection, not deleted, not sprite-console. */
|
||||
/** Filter to records the export can actually drive: claude, with a live
|
||||
* connection (SSH or sprite-console), not deleted. */
|
||||
function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] {
|
||||
return records.filter((r) => {
|
||||
if (r.agent !== CLAUDE_AGENT) {
|
||||
|
|
@ -91,9 +91,6 @@ function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] {
|
|||
if (c.deleted) {
|
||||
return false;
|
||||
}
|
||||
if (c.ip === "sprite-console") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
@ -322,6 +319,37 @@ function shSingleQuote(s: string): string {
|
|||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
interface ExportRunner {
|
||||
runServer: (cmd: string, timeoutSecs?: number) => Promise<void>;
|
||||
uploadFile: (localPath: string, remotePath: string) => Promise<void>;
|
||||
downloadFile: (remotePath: string, localPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Build the runner for a specific spawn record. Sprite has its own exec
|
||||
* channel (`sprite exec`, etc.); everything else uses SSH. */
|
||||
async function buildRunnerForRecord(record: SpawnRecord): Promise<ExportRunner> {
|
||||
const conn = record.connection;
|
||||
if (!conn) {
|
||||
throw new Error("Cannot build runner: spawn has no connection info.");
|
||||
}
|
||||
if (record.cloud === "sprite") {
|
||||
if (!conn.server_name) {
|
||||
throw new Error("Cannot export sprite: connection is missing server_name.");
|
||||
}
|
||||
const sprite = await import("../sprite/sprite.js");
|
||||
await sprite.ensureSpriteCli();
|
||||
await sprite.ensureSpriteAuthenticated();
|
||||
sprite.setSpriteName(conn.server_name);
|
||||
return {
|
||||
runServer: sprite.runSprite,
|
||||
uploadFile: sprite.uploadFileSprite,
|
||||
downloadFile: sprite.downloadFileSprite,
|
||||
};
|
||||
}
|
||||
const keyOpts = getSshKeyOpts(await ensureSshKeys());
|
||||
return makeSshRunner(conn.ip, conn.user, keyOpts);
|
||||
}
|
||||
|
||||
/** Pick one record from a list of claude spawns. */
|
||||
async function pickOne(records: SpawnRecord[]): Promise<SpawnRecord | null> {
|
||||
const options = records.map((r) => ({
|
||||
|
|
@ -428,11 +456,9 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti
|
|||
resultPath: REMOTE_RESULT_PATH,
|
||||
});
|
||||
|
||||
// SSH runner
|
||||
const keyOpts = options?.makeRunner ? [] : getSshKeyOpts(await ensureSshKeys());
|
||||
const runner = options?.makeRunner
|
||||
? options.makeRunner(conn.ip, conn.user, keyOpts)
|
||||
: makeSshRunner(conn.ip, conn.user, keyOpts);
|
||||
// Pick a runner: tests inject one; sprite uses sprite's exec channel; everything
|
||||
// else goes over SSH using the connection's ip/user.
|
||||
const runner = options?.makeRunner ? options.makeRunner(conn.ip, conn.user, []) : await buildRunnerForRecord(r);
|
||||
|
||||
// Run the export script. 10-min timeout — large repos take time to push.
|
||||
p.log.step("Running export on the VM (claude is naming the repo)...");
|
||||
|
|
|
|||
|
|
@ -270,6 +270,16 @@ function orgFlags(): string[] {
|
|||
|
||||
// ─── Server Name ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Set the active sprite name for subsequent runSprite/uploadFileSprite/
|
||||
* downloadFileSprite calls. Used by reconnect-style flows (e.g. spawn export)
|
||||
* that operate on an existing sprite without going through createSprite. */
|
||||
export function setSpriteName(name: string): void {
|
||||
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
||||
throw new Error("setSpriteName: name must be non-empty and match [a-zA-Z0-9_.-]+");
|
||||
}
|
||||
_state.name = name;
|
||||
}
|
||||
|
||||
export async function promptSpawnName(): Promise<void> {
|
||||
return promptSpawnNameShared("Sprite");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue