mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
Fixes #2624 When reconnecting to an existing server via `spawn ls` or `spawn last`, the CLI now queries the cloud provider API for the server's current IP before attempting SSH. This prevents silent SSH timeouts when a server's IP changes (e.g., after a restart or elastic IP reallocation). Changes: - Add `getServerIp()` to DigitalOcean, Hetzner, AWS, and GCP modules - Add `updateRecordIp()` to history.ts to persist IP changes - Add `refreshConnectionIp()` in list.ts that authenticates with the cloud provider and refreshes the IP before enter/reconnect/fix actions - If the server no longer exists, mark it deleted and inform the user - If refresh fails (e.g., no credentials), fall back to cached IP Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import * as v from "valibot";
|
|
import { getHistoryPath, getSpawnDir } from "./shared/paths.js";
|
|
import { isFileError, tryCatch, tryCatchIf } from "./shared/result.js";
|
|
import { getErrorMessage } from "./shared/type-guards.js";
|
|
import { logDebug, logWarn } from "./shared/ui.js";
|
|
|
|
export interface VMConnection {
|
|
ip: string;
|
|
user: string;
|
|
server_id?: string;
|
|
server_name?: string;
|
|
cloud?: string;
|
|
deleted?: boolean;
|
|
deleted_at?: string;
|
|
launch_cmd?: string;
|
|
metadata?: Record<string, string>;
|
|
}
|
|
|
|
export interface SpawnRecord {
|
|
id: string;
|
|
agent: string;
|
|
cloud: string;
|
|
timestamp: string;
|
|
name?: string;
|
|
prompt?: string;
|
|
connection?: VMConnection;
|
|
}
|
|
|
|
// ── Schema versioning ──────────────────────────────────────────────────────
|
|
|
|
export const HISTORY_SCHEMA_VERSION = 1;
|
|
|
|
const VMConnectionSchema = v.object({
|
|
ip: v.string(),
|
|
user: v.string(),
|
|
server_id: v.optional(v.string()),
|
|
server_name: v.optional(v.string()),
|
|
cloud: v.optional(v.string()),
|
|
deleted: v.optional(v.boolean()),
|
|
deleted_at: v.optional(v.string()),
|
|
launch_cmd: v.optional(v.string()),
|
|
metadata: v.optional(v.record(v.string(), v.string())),
|
|
});
|
|
|
|
const SpawnRecordSchema = v.object({
|
|
id: v.optional(v.string()),
|
|
agent: v.string(),
|
|
cloud: v.string(),
|
|
timestamp: v.string(),
|
|
name: v.optional(v.string()),
|
|
prompt: v.optional(v.string()),
|
|
connection: v.optional(VMConnectionSchema),
|
|
});
|
|
|
|
/** v1 history file format: { version: 1, records: SpawnRecord[] } */
|
|
const HistoryFileV1Schema = v.object({
|
|
version: v.literal(1),
|
|
records: v.array(SpawnRecordSchema),
|
|
});
|
|
|
|
/** Loose v1 schema — validates shape but not individual records */
|
|
const HistoryFileV1LooseSchema = v.object({
|
|
version: v.literal(1),
|
|
records: v.array(v.unknown()),
|
|
});
|
|
|
|
/** Generate a unique spawn ID. */
|
|
export function generateSpawnId(): string {
|
|
return randomUUID();
|
|
}
|
|
|
|
/** Atomically write a JSON file: write to .tmp, then rename into place. */
|
|
function atomicWriteJson(filePath: string, data: unknown): void {
|
|
const tmpPath = filePath + ".tmp";
|
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", {
|
|
mode: 0o600,
|
|
});
|
|
renameSync(tmpPath, filePath);
|
|
}
|
|
|
|
/** Write history records to disk in v1 format: { version: 1, records: [...] } */
|
|
function writeHistory(records: SpawnRecord[]): void {
|
|
atomicWriteJson(getHistoryPath(), {
|
|
version: HISTORY_SCHEMA_VERSION,
|
|
records,
|
|
});
|
|
}
|
|
|
|
/** Save launch command to a history record's connection.
|
|
* Matches by spawnId when provided; falls back to most recent record with a connection. */
|
|
export function saveLaunchCmd(launchCmd: string, spawnId?: string): void {
|
|
const result = tryCatchIf(isFileError, () => {
|
|
const history = loadHistory();
|
|
let found = false;
|
|
|
|
if (spawnId) {
|
|
const idx = history.findIndex((r) => r.id === spawnId);
|
|
if (idx >= 0 && history[idx].connection) {
|
|
history[idx].connection.launch_cmd = launchCmd;
|
|
found = true;
|
|
}
|
|
} else {
|
|
// Fallback: most recent record with a connection
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const conn = history[i].connection;
|
|
if (conn) {
|
|
conn.launch_cmd = launchCmd;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
writeHistory(history);
|
|
}
|
|
});
|
|
if (!result.ok) {
|
|
logWarn("Could not save launch command");
|
|
logDebug(getErrorMessage(result.error));
|
|
}
|
|
}
|
|
|
|
/** Merge metadata key-value pairs into a history record's connection.
|
|
* Matches by spawnId when provided; falls back to most recent record with a connection. */
|
|
export function saveMetadata(entries: Record<string, string>, spawnId?: string): void {
|
|
const result = tryCatchIf(isFileError, () => {
|
|
const history = loadHistory();
|
|
let found = false;
|
|
|
|
if (spawnId) {
|
|
const idx = history.findIndex((r) => r.id === spawnId);
|
|
if (idx >= 0 && history[idx].connection) {
|
|
const conn = history[idx].connection;
|
|
conn.metadata = {
|
|
...conn.metadata,
|
|
...entries,
|
|
};
|
|
found = true;
|
|
}
|
|
} else {
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const conn = history[i].connection;
|
|
if (conn) {
|
|
conn.metadata = {
|
|
...conn.metadata,
|
|
...entries,
|
|
};
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
writeHistory(history);
|
|
}
|
|
});
|
|
if (!result.ok) {
|
|
logWarn("Could not save metadata");
|
|
logDebug(getErrorMessage(result.error));
|
|
}
|
|
}
|
|
|
|
/** Back up a corrupted file before discarding it. Non-fatal (best-effort). */
|
|
function backupCorruptedFile(filePath: string): void {
|
|
const result = tryCatchIf(isFileError, () => {
|
|
copyFileSync(filePath, `${filePath}.corrupt.${Date.now()}`);
|
|
console.error(`Warning: ${filePath} was corrupted. A backup has been saved with .corrupt suffix.`);
|
|
});
|
|
if (!result.ok) {
|
|
logDebug(`Could not back up corrupted file: ${getErrorMessage(result.error)}`);
|
|
}
|
|
}
|
|
|
|
/** Try to parse valid records from a single archive file.
|
|
* Uses tryCatch (catch-all) because corrupted JSON is expected — SyntaxError is not a file error. */
|
|
function parseArchiveFile(dir: string, file: string): SpawnRecord[] | null {
|
|
const result = tryCatch(() => {
|
|
const text = readFileSync(join(dir, file), "utf-8");
|
|
const data: unknown = JSON.parse(text);
|
|
if (Array.isArray(data)) {
|
|
return data.filter((el) => v.safeParse(SpawnRecordSchema, el).success);
|
|
}
|
|
return [];
|
|
});
|
|
if (!result.ok) {
|
|
return null;
|
|
}
|
|
return result.data.length > 0 ? result.data : null;
|
|
}
|
|
|
|
/** Attempt to recover records from archive files (history-*.json).
|
|
* Uses tryCatch (catch-all) because archive recovery is best-effort — any failure returns []. */
|
|
function recoverFromArchives(): SpawnRecord[] {
|
|
const result = tryCatch(() => {
|
|
const dir = getSpawnDir();
|
|
const files = readdirSync(dir)
|
|
.filter((f) => /^history-\d{4}-\d{2}-\d{2}\.json$/.test(f))
|
|
.sort()
|
|
.reverse();
|
|
for (const file of files) {
|
|
const records = parseArchiveFile(dir, file);
|
|
if (records) {
|
|
console.error(`Recovered ${records.length} record(s) from archive ${file}.`);
|
|
return records;
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
return result.ok ? result.data : [];
|
|
}
|
|
|
|
/** Parse raw JSON into SpawnRecord[], handling all format versions. */
|
|
function parseHistoryData(raw: unknown): SpawnRecord[] | null {
|
|
// v1 format: { version: 1, records: [...] } — strict check
|
|
const v1 = v.safeParse(HistoryFileV1Schema, raw);
|
|
if (v1.success) {
|
|
return v1.output.records;
|
|
}
|
|
|
|
// Loose v1: version=1 but some individual records are malformed
|
|
const v1Loose = v.safeParse(HistoryFileV1LooseSchema, raw);
|
|
if (v1Loose.success) {
|
|
const allRecords = v1Loose.output.records;
|
|
const valid = allRecords.filter((el) => v.safeParse(SpawnRecordSchema, el).success);
|
|
const dropped = allRecords.length - valid.length;
|
|
if (dropped > 0) {
|
|
console.error(`Warning: Dropped ${dropped} malformed record(s) from history.`);
|
|
}
|
|
return valid;
|
|
}
|
|
|
|
// v0 format: bare array (pre-versioning; migrated to v1 on next write)
|
|
if (Array.isArray(raw)) {
|
|
return raw.filter((el) => v.safeParse(SpawnRecordSchema, el).success);
|
|
}
|
|
|
|
// Unrecognized format
|
|
return null;
|
|
}
|
|
|
|
export function loadHistory(): SpawnRecord[] {
|
|
const path = getHistoryPath();
|
|
if (!existsSync(path)) {
|
|
return [];
|
|
}
|
|
const readResult = tryCatchIf(isFileError, () => readFileSync(path, "utf-8"));
|
|
if (!readResult.ok) {
|
|
logWarn("Could not read spawn history");
|
|
logDebug(getErrorMessage(readResult.error));
|
|
return [];
|
|
}
|
|
const text = readResult.data;
|
|
if (!text.trim()) {
|
|
return [];
|
|
}
|
|
|
|
const parseResult = tryCatch((): unknown => JSON.parse(text));
|
|
if (!parseResult.ok) {
|
|
// JSON parse failed — file is corrupted
|
|
backupCorruptedFile(path);
|
|
return recoverFromArchives();
|
|
}
|
|
|
|
const records = parseHistoryData(parseResult.data);
|
|
if (records !== null) {
|
|
return records;
|
|
}
|
|
|
|
// Unrecognized format
|
|
backupCorruptedFile(path);
|
|
return recoverFromArchives();
|
|
}
|
|
|
|
const MAX_HISTORY_ENTRIES = 100;
|
|
|
|
/** Read existing records from an archive file, returning [] if missing or corrupted. */
|
|
function readExistingArchive(archivePath: string): SpawnRecord[] {
|
|
if (!existsSync(archivePath)) {
|
|
return [];
|
|
}
|
|
const result = tryCatch((): unknown => JSON.parse(readFileSync(archivePath, "utf-8")));
|
|
if (result.ok && Array.isArray(result.data)) {
|
|
return result.data;
|
|
}
|
|
// Corrupted archive — overwrite
|
|
return [];
|
|
}
|
|
|
|
/** Archive evicted records to a dated backup file so nothing is permanently lost. */
|
|
function archiveRecords(records: SpawnRecord[]): void {
|
|
if (records.length === 0) {
|
|
return;
|
|
}
|
|
// Non-fatal — archive failure should not block saving
|
|
tryCatchIf(isFileError, () => {
|
|
const dir = getSpawnDir();
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
const archivePath = join(dir, `history-${date}.json`);
|
|
const existing = readExistingArchive(archivePath);
|
|
const merged = [
|
|
...existing,
|
|
...records,
|
|
];
|
|
atomicWriteJson(archivePath, merged);
|
|
});
|
|
}
|
|
|
|
export function saveSpawnRecord(record: SpawnRecord): void {
|
|
const dir = getSpawnDir();
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, {
|
|
recursive: true,
|
|
mode: 0o700,
|
|
});
|
|
}
|
|
// Ensure every record has an id
|
|
if (!record.id) {
|
|
record.id = generateSpawnId();
|
|
}
|
|
let history = loadHistory();
|
|
history.push(record);
|
|
// Smart trim: evict deleted records first, then oldest, and archive evicted
|
|
if (history.length > MAX_HISTORY_ENTRIES) {
|
|
const nonDeleted: SpawnRecord[] = [];
|
|
const deleted: SpawnRecord[] = [];
|
|
for (const r of history) {
|
|
if (r.connection?.deleted) {
|
|
deleted.push(r);
|
|
} else {
|
|
nonDeleted.push(r);
|
|
}
|
|
}
|
|
if (nonDeleted.length <= MAX_HISTORY_ENTRIES) {
|
|
// Removing deleted records is enough
|
|
history = nonDeleted;
|
|
archiveRecords(deleted);
|
|
} else {
|
|
// Still over limit — trim oldest non-deleted records too
|
|
const overflow = nonDeleted.slice(0, nonDeleted.length - MAX_HISTORY_ENTRIES);
|
|
history = nonDeleted.slice(nonDeleted.length - MAX_HISTORY_ENTRIES);
|
|
archiveRecords([
|
|
...deleted,
|
|
...overflow,
|
|
]);
|
|
}
|
|
}
|
|
writeHistory(history);
|
|
}
|
|
|
|
export function clearHistory(): number {
|
|
const path = getHistoryPath();
|
|
if (!existsSync(path)) {
|
|
return 0;
|
|
}
|
|
const records = loadHistory();
|
|
const count = records.length;
|
|
if (count > 0) {
|
|
unlinkSync(path);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** Find a record's index by id, falling back to timestamp+agent+cloud for old records. */
|
|
function findRecordIndex(history: SpawnRecord[], record: SpawnRecord): number {
|
|
if (record.id) {
|
|
const idx = history.findIndex((r) => r.id === record.id);
|
|
if (idx >= 0) {
|
|
return idx;
|
|
}
|
|
}
|
|
// Fallback for records without id (pre-migration)
|
|
return history.findIndex(
|
|
(r) => r.timestamp === record.timestamp && r.agent === record.agent && r.cloud === record.cloud,
|
|
);
|
|
}
|
|
|
|
/** Remove a record from history entirely (soft delete — no cloud API call). */
|
|
export function removeRecord(record: SpawnRecord): boolean {
|
|
const history = loadHistory();
|
|
const index = findRecordIndex(history, record);
|
|
if (index < 0) {
|
|
return false;
|
|
}
|
|
history.splice(index, 1);
|
|
writeHistory(history);
|
|
return true;
|
|
}
|
|
|
|
export function markRecordDeleted(record: SpawnRecord): boolean {
|
|
const history = loadHistory();
|
|
const index = findRecordIndex(history, record);
|
|
if (index < 0) {
|
|
return false;
|
|
}
|
|
const found = history[index];
|
|
if (!found.connection) {
|
|
return false;
|
|
}
|
|
found.connection.deleted = true;
|
|
found.connection.deleted_at = new Date().toISOString();
|
|
writeHistory(history);
|
|
return true;
|
|
}
|
|
|
|
/** Update the IP address on a history record's connection. Returns true if the record was found and updated. */
|
|
export function updateRecordIp(record: SpawnRecord, newIp: string): boolean {
|
|
const history = loadHistory();
|
|
const index = findRecordIndex(history, record);
|
|
if (index < 0) {
|
|
return false;
|
|
}
|
|
const found = history[index];
|
|
if (!found.connection) {
|
|
return false;
|
|
}
|
|
found.connection.ip = newIp;
|
|
writeHistory(history);
|
|
return true;
|
|
}
|
|
|
|
export function getActiveServers(): SpawnRecord[] {
|
|
const records = loadHistory();
|
|
return records.filter((r) => r.connection?.cloud && r.connection.cloud !== "local" && !r.connection.deleted);
|
|
}
|
|
|
|
export function filterHistory(agentFilter?: string, cloudFilter?: string): SpawnRecord[] {
|
|
let records = loadHistory();
|
|
if (agentFilter) {
|
|
const lower = agentFilter.toLowerCase();
|
|
records = records.filter((r) => r.agent.toLowerCase() === lower);
|
|
}
|
|
if (cloudFilter) {
|
|
const lower = cloudFilter.toLowerCase();
|
|
records = records.filter((r) => r.cloud.toLowerCase() === lower);
|
|
}
|
|
// Show newest first (reverse chronological order)
|
|
records.reverse();
|
|
|
|
return records;
|
|
}
|