refactor: wrap backup error in a tagged effect error

This commit is contained in:
Nicolas Meienberger 2026-04-08 17:53:29 +02:00
parent 39f0ec4085
commit c40198e7bf
9 changed files with 73 additions and 37 deletions

View file

@ -56,26 +56,28 @@ export const backupExecutor = {
const volumePath = getVolumePath(volume); const volumePath = getVolumePath(volume);
const backupOptions = createBackupOptions(schedule, volumePath, signal); const backupOptions = createBackupOptions(schedule, volumePath, signal);
const result = await Effect.runPromise( const execution = await Effect.runPromise(
restic.backup(repository.config, volumePath, { restic
.backup(repository.config, volumePath, {
...backupOptions, ...backupOptions,
compressionMode: repository.compressionMode ?? "auto", compressionMode: repository.compressionMode ?? "auto",
organizationId, organizationId,
onProgress, onProgress,
}), })
.pipe(
Effect.map((result) => ({ success: true as const, result })),
Effect.catchAll((error) => Effect.succeed({ success: false as const, error })),
),
); );
return { if (!execution.success) {
status: "completed", throw execution.error;
exitCode: result.exitCode, }
result: result.result,
warningDetails: result.warningDetails, const { exitCode, result, warningDetails } = execution.result;
} satisfies BackupExecutionResult; return { status: "completed", exitCode, result, warningDetails };
} catch (error) { } catch (error) {
return { return { status: "failed", error };
status: "failed",
error,
} satisfies BackupExecutionResult;
} }
}, },
cancel: (scheduleId: number) => { cancel: (scheduleId: number) => {

View file

@ -2,7 +2,7 @@
"name": "agent", "name": "agent",
"private": true, "private": true,
"type": "module", "type": "module",
"module": "index.ts", "module": "src/index.ts",
"scripts": { "scripts": {
"tsc": "tsc --noEmit" "tsc": "tsc --noEmit"
}, },
@ -15,6 +15,6 @@
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^6"
} }
} }

View file

@ -3,8 +3,8 @@ import { type BackupCancelPayload } from "@zerobyte/contracts/agent-protocol";
import { logger } from "@zerobyte/core/node"; import { logger } from "@zerobyte/core/node";
import type { ControllerCommandContext } from "../context"; import type { ControllerCommandContext } from "../context";
export const handleBackupCancelCommand = (context: ControllerCommandContext, payload: BackupCancelPayload) => export const handleBackupCancelCommand = (context: ControllerCommandContext, payload: BackupCancelPayload) => {
Effect.gen(function* () { return Effect.gen(function* () {
const running = yield* context.getRunningJob(payload.jobId); const running = yield* context.getRunningJob(payload.jobId);
if (!running) { if (!running) {
logger.warn(`Backup ${payload.jobId} is not running`); logger.warn(`Backup ${payload.jobId} is not running`);
@ -18,3 +18,4 @@ export const handleBackupCancelCommand = (context: ControllerCommandContext, pay
running.abortController.abort(); running.abortController.abort();
}); });
};

View file

@ -6,8 +6,8 @@ import { createRestic } from "@zerobyte/core/restic/server";
import { toErrorDetails, toMessage } from "@zerobyte/core/utils"; import { toErrorDetails, toMessage } from "@zerobyte/core/utils";
import type { ControllerCommandContext } from "../context"; import type { ControllerCommandContext } from "../context";
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
Effect.fork( return Effect.fork(
Effect.gen(function* () { Effect.gen(function* () {
const existing = yield* context.getRunningJob(payload.jobId); const existing = yield* context.getRunningJob(payload.jobId);
if (existing) { if (existing) {
@ -110,3 +110,4 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
); );
}), }),
).pipe(Effect.asVoid); ).pipe(Effect.asVoid);
};

View file

@ -3,9 +3,10 @@ import type { ControllerCommandContext } from "../context";
type HeartbeatPingPayload = Extract<ControllerMessage, { type: "heartbeat.ping" }>["payload"]; type HeartbeatPingPayload = Extract<ControllerMessage, { type: "heartbeat.ping" }>["payload"];
export const handleHeartbeatPingCommand = (context: ControllerCommandContext, payload: HeartbeatPingPayload) => export const handleHeartbeatPingCommand = (context: ControllerCommandContext, payload: HeartbeatPingPayload) => {
context.offerOutbound( return context.offerOutbound(
createAgentMessage("heartbeat.pong", { createAgentMessage("heartbeat.pong", {
sentAt: payload.sentAt, sentAt: payload.sentAt,
}), }),
); );
};

View file

@ -106,11 +106,6 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => {
}); });
}, },
onMessage: (data) => { onMessage: (data) => {
if (typeof data !== "string") {
logger.warn("Agent received a non-text message");
return;
}
void Effect.runPromise(offerInbound(data as ControllerWireMessage)).catch((error) => { void Effect.runPromise(offerInbound(data as ControllerWireMessage)).catch((error) => {
logger.error(`Failed to queue inbound message: ${toMessage(error)}`); logger.error(`Failed to queue inbound message: ${toMessage(error)}`);
}); });

View file

@ -3,12 +3,29 @@ import { createControllerSession, type ControllerSession } from "./controller-se
const controllerUrl = process.env.ZEROBYTE_CONTROLLER_URL; const controllerUrl = process.env.ZEROBYTE_CONTROLLER_URL;
const agentToken = process.env.ZEROBYTE_AGENT_TOKEN; const agentToken = process.env.ZEROBYTE_AGENT_TOKEN;
const RECONNECT_DELAY_MS = 1000;
class Agent { class Agent {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private controllerSession: ControllerSession | null = null; private controllerSession: ControllerSession | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private scheduleReconnect() {
if (this.reconnectTimeout) {
return;
}
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, RECONNECT_DELAY_MS);
}
connect() { connect() {
if (this.ws) {
return;
}
if (!controllerUrl) { if (!controllerUrl) {
throw new Error("Env variable ZEROBYTE_CONTROLLER_URL is not set"); throw new Error("Env variable ZEROBYTE_CONTROLLER_URL is not set");
} }
@ -36,6 +53,7 @@ class Agent {
this.controllerSession = null; this.controllerSession = null;
this.ws = null; this.ws = null;
logger.info("Agent disconnected from controller"); logger.info("Agent disconnected from controller");
this.scheduleReconnect();
}; };
this.ws.onerror = (error) => { this.ws.onerror = (error) => {
logger.error("Agent encountered an error:", error); logger.error("Agent encountered an error:", error);
@ -43,5 +61,7 @@ class Agent {
} }
} }
if (import.meta.main) {
const agent = new Agent(); const agent = new Agent();
agent.connect(); agent.connect();
}

View file

@ -128,7 +128,7 @@
"@types/bun": "latest", "@types/bun": "latest",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^6",
}, },
}, },
"packages/contracts": { "packages/contracts": {

View file

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { Effect } from "effect"; import { Data, Effect } from "effect";
import { throttle } from "es-toolkit"; import { throttle } from "es-toolkit";
import type { CompressionMode, RepositoryConfig } from "../schemas"; import type { CompressionMode, RepositoryConfig } from "../schemas";
import { type ResticBackupProgressDto, resticBackupOutputSchema, resticBackupProgressSchema } from "../restic-dto"; import { type ResticBackupProgressDto, resticBackupOutputSchema, resticBackupProgressSchema } from "../restic-dto";
@ -13,6 +13,12 @@ import { validateCustomResticParams } from "../helpers/validate-custom-params";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { logger, safeSpawn } from "../../node"; import { logger, safeSpawn } from "../../node";
import type { ResticDeps } from "../types"; import type { ResticDeps } from "../types";
import { toMessage } from "../../utils";
class ResticBackupCommandError extends Data.TaggedError("ResticBackupCommandError")<{
cause: unknown;
message: string;
}> {}
export const backup = ( export const backup = (
config: RepositoryConfig, config: RepositoryConfig,
@ -31,8 +37,8 @@ export const backup = (
customResticParams?: string[]; customResticParams?: string[];
}, },
deps: ResticDeps, deps: ResticDeps,
) => ) => {
Effect.tryPromise({ return Effect.tryPromise({
try: async () => { try: async () => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps); const env = await buildEnv(config, options.organizationId, deps);
@ -214,5 +220,15 @@ export const backup = (
warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null, warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null,
}; };
}, },
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => {
if (error instanceof ResticError) {
return error;
}
return new ResticBackupCommandError({
cause: error,
message: toMessage(error),
}); });
},
});
};