fix: use raius pi id for persistence

This commit is contained in:
Cristina Poncela Cubeiro
2026-06-18 15:16:03 +02:00
parent 9bfafc8c02
commit a6d88ddc3a
7 changed files with 119 additions and 45 deletions
+1
View File
@@ -25,6 +25,7 @@ function toInstanceSummary(instance: InstanceRecord): InstanceSummary {
label: instance.label, label: instance.label,
sessionId: instance.sessionId, sessionId: instance.sessionId,
sessionFile: instance.sessionFile, sessionFile: instance.sessionFile,
radiusPiId: instance.radiusPiId,
}; };
} }
@@ -46,6 +46,7 @@ export interface InstanceSummary {
label?: string; label?: string;
sessionId?: string; sessionId?: string;
sessionFile?: string; sessionFile?: string;
radiusPiId?: string;
} }
export interface ResponseBase { export interface ResponseBase {
+54 -24
View File
@@ -1,21 +1,18 @@
import { hostname, platform } from "node:os"; import { hostname, platform } from "node:os";
import { getOrchestratorDir, getSocketPath } from "./config.ts"; import { getOrchestratorDir, getSocketPath } from "./config.ts";
import { loadMachine, saveMachine } from "./storage.ts"; import { loadMachine, saveMachine } from "./storage.ts";
import type { InstanceRecord, MachineRecord } from "./types.ts"; import type { InstanceRecord, MachineRecord, RadiusRegistration } from "./types.ts";
const DEFAULT_RADIUS_URL = "https://radius.pi.dev/"; const DEFAULT_RADIUS_URL = "https://radius.pi.dev/";
const DEFAULT_ORCHESTRATOR_BASE_PATH = "/v1/"; const DEFAULT_ORCHESTRATOR_BASE_PATH = "/v1/";
const ORCHESTRATOR_VERSION = "0.79.6";
interface RegisterMachineResponse { interface RegisterMachineResponse extends RadiusRegistration {
id: string; id: string;
heartbeatIntervalMs: number;
expiresInMs: number;
} }
interface RegisterPiResponse { interface RegisterPiResponse extends RadiusRegistration {
id: string; id: string;
heartbeatIntervalMs: number;
expiresInMs: number;
} }
async function post<T>(path: string, body: unknown): Promise<T> { async function post<T>(path: string, body: unknown): Promise<T> {
@@ -35,8 +32,8 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
function maybePost(path: string, body: unknown): Promise<Response> { async function maybePost(path: string, body: unknown): Promise<void> {
return fetch(new URL(path, getRadiusOrchestratorBaseUrl()), { const response = await fetch(new URL(path, getRadiusOrchestratorBaseUrl()), {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${getRadiusApiKey()}`, Authorization: `Bearer ${getRadiusApiKey()}`,
@@ -44,6 +41,9 @@ function maybePost(path: string, body: unknown): Promise<Response> {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!response.ok) {
throw new Error(`Radius request failed: ${response.status} ${await response.text()}`);
}
} }
export function getRadiusUrl(): string { export function getRadiusUrl(): string {
@@ -72,7 +72,8 @@ export function isRadiusEnabled(): boolean {
} }
export class RadiusPresence { export class RadiusPresence {
private heartbeatTimer?: NodeJS.Timeout; private machineHeartbeatTimer?: NodeJS.Timeout;
private readonly piHeartbeatTimers = new Map<string, NodeJS.Timeout>();
private machine?: MachineRecord; private machine?: MachineRecord;
async start(label?: string): Promise<MachineRecord | undefined> { async start(label?: string): Promise<MachineRecord | undefined> {
@@ -80,39 +81,44 @@ export class RadiusPresence {
return undefined; return undefined;
} }
const registered = await post<RegisterMachineResponse>("/v1/machines/register", { const existingMachine = loadMachine();
const registered = await post<RegisterMachineResponse>("machines/register", {
machineId: existingMachine?.id,
label, label,
hostname: hostname(), hostname: hostname(),
platform: platform(), platform: platform(),
arch: process.arch, arch: process.arch,
version: "0.79.6", version: ORCHESTRATOR_VERSION,
capabilities: { spawn: true, relay: false, iroh: false }, capabilities: { spawn: true, relay: false, iroh: false },
}); });
const now = new Date().toISOString(); const timestamp = new Date().toISOString();
this.machine = { this.machine = {
id: registered.id, id: registered.id,
createdAt: now, createdAt: existingMachine?.createdAt ?? timestamp,
lastSeenAt: now, lastSeenAt: timestamp,
label, label,
}; };
saveMachine(this.machine); saveMachine(this.machine);
this.heartbeatTimer = setInterval(() => { this.machineHeartbeatTimer = setInterval(() => {
void this.heartbeatMachine(); void this.heartbeatMachine();
}, registered.heartbeatIntervalMs); }, registered.heartbeatIntervalMs);
return this.machine; return this.machine;
} }
async stop(): Promise<void> { async stop(): Promise<void> {
if (this.heartbeatTimer) { if (this.machineHeartbeatTimer) {
clearInterval(this.heartbeatTimer); clearInterval(this.machineHeartbeatTimer);
this.heartbeatTimer = undefined; this.machineHeartbeatTimer = undefined;
}
for (const [instanceId, timer] of this.piHeartbeatTimers) {
clearInterval(timer);
this.piHeartbeatTimers.delete(instanceId);
} }
if (!this.machine || !isRadiusEnabled()) { if (!this.machine || !isRadiusEnabled()) {
return; return;
} }
await maybePost(`/v1/machines/${this.machine.id}/disconnect`, {}); await maybePost(`machines/${this.machine.id}/disconnect`, {});
this.machine = undefined;
} }
async registerPi(instance: InstanceRecord): Promise<InstanceRecord> { async registerPi(instance: InstanceRecord): Promise<InstanceRecord> {
@@ -123,7 +129,7 @@ export class RadiusPresence {
if (!machine) { if (!machine) {
throw new Error("No registered machine available for Pi registration"); throw new Error("No registered machine available for Pi registration");
} }
const registered = await post<RegisterPiResponse>("/v1/pis/register", { const registered = await post<RegisterPiResponse>("pis/register", {
machineId: machine.id, machineId: machine.id,
label: instance.label, label: instance.label,
cwd: instance.cwd, cwd: instance.cwd,
@@ -133,25 +139,49 @@ export class RadiusPresence {
capabilities: { rpc: true, relay: false, iroh: false }, capabilities: { rpc: true, relay: false, iroh: false },
sessionId: instance.sessionId, sessionId: instance.sessionId,
}); });
this.startPiHeartbeat(instance.id, registered.heartbeatIntervalMs, registered.id);
return { ...instance, radiusPiId: registered.id }; return { ...instance, radiusPiId: registered.id };
} }
async disconnectPi(instance: InstanceRecord): Promise<void> { async disconnectPi(instance: InstanceRecord): Promise<void> {
const timer = this.piHeartbeatTimers.get(instance.id);
if (timer) {
clearInterval(timer);
this.piHeartbeatTimers.delete(instance.id);
}
if (!isRadiusEnabled() || !instance.radiusPiId) { if (!isRadiusEnabled() || !instance.radiusPiId) {
return; return;
} }
await maybePost(`/v1/pis/${instance.radiusPiId}/disconnect`, {}); await maybePost(`pis/${instance.radiusPiId}/disconnect`, {});
}
private startPiHeartbeat(instanceId: string, intervalMs: number, radiusPiId: string): void {
const existingTimer = this.piHeartbeatTimers.get(instanceId);
if (existingTimer) {
clearInterval(existingTimer);
}
const timer = setInterval(() => {
void this.heartbeatPi(radiusPiId);
}, intervalMs);
this.piHeartbeatTimers.set(instanceId, timer);
} }
private async heartbeatMachine(): Promise<void> { private async heartbeatMachine(): Promise<void> {
if (!this.machine || !isRadiusEnabled()) { if (!this.machine || !isRadiusEnabled()) {
return; return;
} }
await maybePost(`/v1/machines/${this.machine.id}/heartbeat`, { await maybePost(`machines/${this.machine.id}/heartbeat`, {
cwd: getOrchestratorDir(), cwd: getOrchestratorDir(),
socketPath: getSocketPath(), socketPath: getSocketPath(),
}); });
} }
private async heartbeatPi(radiusPiId: string): Promise<void> {
if (!isRadiusEnabled()) {
return;
}
await maybePost(`pis/${radiusPiId}/heartbeat`, {});
}
} }
export const radiusPresence = new RadiusPresence(); export const radiusPresence = new RadiusPresence();
+29 -19
View File
@@ -4,47 +4,57 @@ import { getSocketPath } from "./config.ts";
import { handleIpcRequest } from "./handler.ts"; import { handleIpcRequest } from "./handler.ts";
import { startIpcServer } from "./ipc/server.ts"; import { startIpcServer } from "./ipc/server.ts";
import { getRadiusOrchestratorBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts"; import { getRadiusOrchestratorBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts";
import { supervisor } from "./supervisor.ts";
export async function serve(): Promise<void> { export async function serve(): Promise<void> {
const socketPath = getSocketPath(); const socketPath = getSocketPath();
mkdirSync(dirname(socketPath), { recursive: true }); mkdirSync(dirname(socketPath), { recursive: true });
await supervisor.recoverAfterRestart();
if (isRadiusEnabled()) { if (isRadiusEnabled()) {
await radiusPresence.start(); const machine = await radiusPresence.start();
console.log(`radius integration enabled: ${socketPath} -> ${getRadiusOrchestratorBaseUrl()}`); console.log(`radius integration enabled: ${socketPath} -> ${getRadiusOrchestratorBaseUrl()}`);
if (machine) {
console.log(`radius machine id: ${machine.id}`);
}
} else { } else {
console.log("radius integration disabled: set PI_RADIUS_API_KEY to enable"); console.log("radius integration disabled: set PI_RADIUS_API_KEY to enable");
} }
const server = await startIpcServer(handleIpcRequest); const server = await startIpcServer(handleIpcRequest);
console.log(`orchestrator listening on ${socketPath}`); console.log(`orchestrator listening on ${socketPath}`);
let cleanedUp = false; let shutdownPromise: Promise<void> | undefined;
const cleanup = () => { const shutdown = async (exitCode: number) => {
if (cleanedUp) { if (shutdownPromise) {
return; await shutdownPromise;
process.exit(exitCode);
} }
cleanedUp = true;
server.close();
void radiusPresence.stop();
if (existsSync(socketPath)) {
unlinkSync(socketPath);
}
};
const shutdown = (exitCode: number) => { shutdownPromise = (async () => {
cleanup(); server.close();
await supervisor.shutdown();
await radiusPresence.stop();
if (existsSync(socketPath)) {
unlinkSync(socketPath);
}
})();
await shutdownPromise;
process.exit(exitCode); process.exit(exitCode);
}; };
process.on("SIGINT", () => shutdown(0)); process.on("SIGINT", () => {
process.on("SIGTERM", () => shutdown(0)); void shutdown(0);
process.on("exit", cleanup); });
process.on("SIGTERM", () => {
void shutdown(0);
});
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error(error); console.error(error);
shutdown(1); void shutdown(1);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
console.error(reason); console.error(reason);
shutdown(1); void shutdown(1);
}); });
await new Promise<void>(() => { await new Promise<void>(() => {
+9 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { getInstancesPath, getMachinePath, getOrchestratorDir } from "./config.ts"; import { getInstancesPath, getMachinePath, getOrchestratorDir } from "./config.ts";
import type { InstanceRecord, MachineRecord } from "./types.ts"; import type { InstanceRecord, MachineRecord } from "./types.ts";
@@ -24,6 +24,14 @@ export function saveMachine(machine: MachineRecord): void {
writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2)); writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2));
} }
export function deleteMachine(): void {
const machinePath = getMachinePath();
if (!existsSync(machinePath)) {
return;
}
rmSync(machinePath);
}
export function loadInstances(): InstanceRecord[] { export function loadInstances(): InstanceRecord[] {
const instancesPath = getInstancesPath(); const instancesPath = getInstancesPath();
if (!existsSync(instancesPath)) { if (!existsSync(instancesPath)) {
+20 -1
View File
@@ -12,7 +12,7 @@ import {
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { radiusPresence } from "./radius.ts"; import { radiusPresence } from "./radius.ts";
import { handleRpcCommand } from "./rpc-bridge.ts"; import { handleRpcCommand } from "./rpc-bridge.ts";
import { getInstance, loadInstances, removeInstance, upsertInstance } from "./storage.ts"; import { getInstance, loadInstances, removeInstance, saveInstances, upsertInstance } from "./storage.ts";
import type { InstanceRecord } from "./types.ts"; import type { InstanceRecord } from "./types.ts";
interface LiveInstance { interface LiveInstance {
@@ -56,6 +56,19 @@ async function createRuntime(cwd: string): Promise<AgentSessionRuntime> {
export class OrchestratorSupervisor { export class OrchestratorSupervisor {
private readonly liveInstances = new Map<string, LiveInstance>(); private readonly liveInstances = new Map<string, LiveInstance>();
async recoverAfterRestart(): Promise<void> {
const recoveredAt = new Date().toISOString();
const instances = loadInstances().map((instance) => ({
...instance,
status: instance.status === "online" || instance.status === "starting" ? "stopped" : instance.status,
lastSeenAt: recoveredAt,
}));
for (const instance of instances) {
await radiusPresence.disconnectPi(instance);
}
saveInstances(instances);
}
listInstances(): InstanceRecord[] { listInstances(): InstanceRecord[] {
return loadInstances().map(cloneInstance); return loadInstances().map(cloneInstance);
} }
@@ -110,6 +123,12 @@ export class OrchestratorSupervisor {
return handleRpcCommand(live.runtime, command); return handleRpcCommand(live.runtime, command);
} }
async shutdown(): Promise<void> {
for (const instanceId of [...this.liveInstances.keys()]) {
await this.stopInstance(instanceId);
}
}
} }
export const supervisor = new OrchestratorSupervisor(); export const supervisor = new OrchestratorSupervisor();
+5
View File
@@ -7,6 +7,11 @@ export interface MachineRecord {
label?: string; label?: string;
} }
export interface RadiusRegistration {
heartbeatIntervalMs: number;
expiresInMs: number;
}
export interface InstanceRecord { export interface InstanceRecord {
id: string; id: string;
status: InstanceStatus; status: InstanceStatus;