zerobyte/apps/agent/src/commands/backup-run.ts
Nico e65a135676
feat(agents): create agent registry and service (#849)
* feat(agents): create agent registry and service

* fix: mark agent offline only if the session was removed properly

* refactor: centralize agent backup lifecycle state

* refactor: simplify session management

* refactor: move effect / async boundary in one place

* chore: regen migration

* refactor: improve error handling

* chore: pr feedback
2026-05-05 19:34:10 +02:00

117 lines
3.8 KiB
TypeScript

import { Effect, Runtime } from "effect";
import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import { logger } from "@zerobyte/core/node";
import { type ResticDeps } from "@zerobyte/core/restic";
import { createRestic } from "@zerobyte/core/restic/server";
import { toMessage } from "@zerobyte/core/utils";
import type { ControllerCommandContext } from "../context";
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
return Effect.gen(function* () {
const existing = yield* context.getRunningJob(payload.jobId);
if (existing) {
yield* context.offerOutbound(
createAgentMessage("backup.failed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
error: "Backup job is already running",
}),
);
return;
}
yield* logger.effect.info(`Starting backup ${payload.jobId} for schedule ${payload.scheduleId}`);
const abortController = new AbortController();
yield* context.setRunningJob(payload.jobId, { scheduleId: payload.scheduleId, abortController });
yield* Effect.fork(
Effect.gen(function* () {
const sendCancelled = (message?: string) => {
return context.offerOutbound(
createAgentMessage("backup.cancelled", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
message: message ?? "Backup was cancelled",
}),
);
};
yield* context.offerOutbound(
createAgentMessage("backup.started", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
}),
);
const deps: ResticDeps = {
resolveSecret: async (encrypted) => encrypted,
getOrganizationResticPassword: async () => payload.runtime.password,
resticCacheDir: payload.runtime.cacheDir,
resticPassFile: payload.runtime.passFile,
defaultExcludes: payload.runtime.defaultExcludes,
hostname: payload.runtime.hostname,
rcloneConfigFile: payload.runtime.rcloneConfigFile,
};
const restic = createRestic(deps);
const runtime = yield* Effect.runtime<never>();
const backupResult = yield* runBackupLifecycle({
restic,
repositoryConfig: payload.repositoryConfig,
sourcePath: payload.sourcePath,
jobId: payload.jobId,
scheduleId: payload.scheduleId,
organizationId: payload.organizationId,
options: payload.options,
webhooks: payload.webhooks,
webhookAllowedOrigins: payload.webhookAllowedOrigins,
webhookTimeoutMs: payload.webhookTimeoutMs,
signal: abortController.signal,
onProgress: (progress) => {
void Runtime.runPromise(
runtime,
context.offerOutbound(
createAgentMessage("backup.progress", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
progress,
}),
),
).catch((error) => {
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
});
},
});
switch (backupResult.status) {
case "completed":
yield* context.offerOutbound(
createAgentMessage("backup.completed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
exitCode: backupResult.exitCode,
result: backupResult.result,
warningDetails: backupResult.warningDetails ?? undefined,
}),
);
return;
case "failed":
yield* context.offerOutbound(
createAgentMessage("backup.failed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
error: toMessage(backupResult.error),
errorDetails: backupResult.error,
}),
);
return;
case "cancelled":
yield* sendCancelled(backupResult.message);
return;
}
}).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))),
);
}).pipe(Effect.asVoid);
};