feat: apply agent profile settings atomically

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:11:39 +02:00
parent adc2e297a4
commit 8b5ccc2fd9
33 changed files with 794 additions and 163 deletions
+17 -3
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
@@ -75,7 +76,8 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
const merged = mergeSelectedMachineConfig(current.config, patch);
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
}
return reply.code(405).send({ error: "Method not allowed" });
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
return isRecord(body) ? body["config"] : undefined;
}
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
return reply.code(409).send({
error: "Remote machine did not persist the requested agent profile",
machineId,
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
});
}
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
return reply.send(selectedMachineConfigResponse(response));
}
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
}
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
+2 -2
View File
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
return health;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId);
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
return runtime;
});
+4 -1
View File
@@ -171,6 +171,7 @@ describe("MachineService", () => {
const first = await remoteService.runtime(machine.id);
const second = await remoteService.runtime(machine.id);
const forced = await remoteService.runtime(machine.id, true);
expect(first).toEqual({
machineId: machine.id,
@@ -182,7 +183,8 @@ describe("MachineService", () => {
capabilities: body.capabilities,
});
expect(second).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(forced).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
expect(factoryMachines).toEqual([
expect.objectContaining({
@@ -192,6 +194,7 @@ describe("MachineService", () => {
token: "secret",
headers: { "X-Pi-Web-Test": "yes" },
}),
expect.objectContaining({ id: machine.id }),
]);
});
+2 -2
View File
@@ -93,10 +93,10 @@ export class MachineService {
return health;
}
async runtime(id: string): Promise<MachineRuntime | undefined> {
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
const cached = this.runtimeCache.get(id);
const now = this.now().getTime();
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
if (runtime === undefined) return undefined;