From 3169627b79cdd7f469c559d244ffff703c94d24c Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:17:15 +0200 Subject: [PATCH] feat(contracts): add shared agent/controller protocol (#759) * feat(contracts): add shared agent/controller protocol * chore: pr feedback --- bun.lock | 15 ++ package.json | 1 + packages/contracts/package.json | 24 ++++ packages/contracts/src/agent-protocol.ts | 175 +++++++++++++++++++++++ packages/contracts/tsconfig.json | 30 ++++ 5 files changed, 245 insertions(+) create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/agent-protocol.ts create mode 100644 packages/contracts/tsconfig.json diff --git a/bun.lock b/bun.lock index 764156eb..cda43919 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@tanstack/react-router": "^1.168.1", "@tanstack/react-router-ssr-query": "^1.166.10", "@tanstack/react-start": "^1.167.1", + "@zerobyte/contracts": "workspace:*", "@zerobyte/core": "workspace:*", "better-auth": "^1.5.5", "class-variance-authority": "^0.7.1", @@ -114,6 +115,18 @@ "wait-for-expect": "^4.0.0", }, }, + "packages/contracts": { + "name": "@zerobyte/contracts", + "dependencies": { + "@zerobyte/core": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^6", + }, + }, "packages/core": { "name": "@zerobyte/core", "devDependencies": { @@ -1044,6 +1057,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@zerobyte/contracts": ["@zerobyte/contracts@workspace:packages/contracts"], + "@zerobyte/core": ["@zerobyte/core@workspace:packages/core"], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], diff --git a/package.json b/package.json index f17d48f0..c0903187 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@tanstack/react-router": "^1.168.1", "@tanstack/react-router-ssr-query": "^1.166.10", "@tanstack/react-start": "^1.167.1", + "@zerobyte/contracts": "workspace:*", "@zerobyte/core": "workspace:*", "better-auth": "^1.5.5", "class-variance-authority": "^0.7.1", diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..35718d90 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,24 @@ +{ + "name": "@zerobyte/contracts", + "private": true, + "type": "module", + "exports": { + "./agent-protocol": { + "types": "./src/agent-protocol.ts", + "import": "./src/agent-protocol.ts", + "default": "./src/agent-protocol.ts" + } + }, + "scripts": { + "tsc": "tsc --noEmit" + }, + "dependencies": { + "@zerobyte/core": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^6" + } +} diff --git a/packages/contracts/src/agent-protocol.ts b/packages/contracts/src/agent-protocol.ts new file mode 100644 index 00000000..ab6a99b2 --- /dev/null +++ b/packages/contracts/src/agent-protocol.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import { safeJsonParse } from "@zerobyte/core/utils"; +import { + repositoryConfigSchema, + resticBackupOutputSchema, + resticBackupProgressSchema, + type CompressionMode, +} from "@zerobyte/core/restic"; + +const compressionModeSchema = z.enum(["off", "auto", "max"]) satisfies z.ZodType; + +const backupExecutionOptionsSchema = z.object({ + tags: z.array(z.string()).optional(), + oneFileSystem: z.boolean().optional(), + exclude: z.array(z.string()).optional(), + excludeIfPresent: z.array(z.string()).optional(), + includePaths: z.array(z.string()).optional(), + includePatterns: z.array(z.string()).optional(), + customResticParams: z.array(z.string()).optional(), + compressionMode: compressionModeSchema.optional(), +}); + +const backupRuntimeSchema = z.object({ + password: z.string(), + cacheDir: z.string(), + passFile: z.string(), + defaultExcludes: z.array(z.string()), + hostname: z.string().optional(), +}); + +const backupRunSchema = z.object({ + type: z.literal("backup.run"), + payload: z.object({ + jobId: z.string(), + scheduleId: z.string(), + organizationId: z.string(), + sourcePath: z.string(), + repositoryConfig: repositoryConfigSchema, + options: backupExecutionOptionsSchema, + runtime: backupRuntimeSchema, + }), +}); + +const backupCancelSchema = z.object({ + type: z.literal("backup.cancel"), + payload: z.object({ jobId: z.string(), scheduleId: z.string() }), +}); + +const heartbeatPingSchema = z.object({ + type: z.literal("heartbeat.ping"), + payload: z.object({ sentAt: z.number() }), +}); + +const agentReadySchema = z.object({ + type: z.literal("agent.ready"), + payload: z.object({ agentId: z.string() }), +}); + +const backupStartedSchema = z.object({ + type: z.literal("backup.started"), + payload: z.object({ jobId: z.string(), scheduleId: z.string() }), +}); + +const backupProgressSchema = z.object({ + type: z.literal("backup.progress"), + payload: z.object({ + jobId: z.string(), + scheduleId: z.string(), + progress: resticBackupProgressSchema, + }), +}); + +const backupCompletedSchema = z.object({ + type: z.literal("backup.completed"), + payload: z.object({ + jobId: z.string(), + scheduleId: z.string(), + exitCode: z.number(), + result: resticBackupOutputSchema.nullable(), + warningDetails: z.string().optional(), + }), +}); + +const backupFailedSchema = z.object({ + type: z.literal("backup.failed"), + payload: z.object({ + jobId: z.string(), + scheduleId: z.string(), + error: z.string(), + errorDetails: z.string().optional(), + }), +}); + +const backupCancelledSchema = z.object({ + type: z.literal("backup.cancelled"), + payload: z.object({ + jobId: z.string(), + scheduleId: z.string(), + message: z.string().optional(), + }), +}); + +const heartbeatPongSchema = z.object({ + type: z.literal("heartbeat.pong"), + payload: z.object({ sentAt: z.number() }), +}); + +const controllerMessageSchema = z.discriminatedUnion("type", [ + backupRunSchema, + backupCancelSchema, + heartbeatPingSchema, +]); +const agentMessageSchema = z.discriminatedUnion("type", [ + agentReadySchema, + backupStartedSchema, + backupProgressSchema, + backupCompletedSchema, + backupFailedSchema, + backupCancelledSchema, + heartbeatPongSchema, +]); + +export type BackupRunPayload = z.infer["payload"]; +export type BackupCancelPayload = z.infer["payload"]; +export type BackupStartedPayload = z.infer["payload"]; +export type BackupProgressPayload = z.infer["payload"]; +export type BackupCompletedPayload = z.infer["payload"]; +export type BackupFailedPayload = z.infer["payload"]; +export type BackupCancelledPayload = z.infer["payload"]; +export type ControllerMessage = z.infer; +export type AgentMessage = z.infer; + +type Brand = TValue & { + readonly __brand: TBrand; +}; + +export type ControllerWireMessage = Brand; +export type AgentWireMessage = Brand; + +type PayloadForMessage = Extract< + TMessage, + { type: TType } +>["payload"]; + +export const parseControllerMessage = (data: string) => { + const parsed = safeJsonParse(data); + if (parsed === null) { + return null; + } + + return controllerMessageSchema.safeParse(parsed); +}; + +export const parseAgentMessage = (data: string) => { + const parsed = safeJsonParse(data); + if (parsed === null) { + return null; + } + + return agentMessageSchema.safeParse(parsed); +}; + +export const createControllerMessage = ( + type: TType, + payload: PayloadForMessage, +) => { + return JSON.stringify(controllerMessageSchema.parse({ type, payload })) as ControllerWireMessage; +}; + +export const createAgentMessage = ( + type: TType, + payload: PayloadForMessage, +) => { + return JSON.stringify(agentMessageSchema.parse({ type, payload })) as AgentWireMessage; +}; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 00000000..846638e1 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "types": ["bun", "node"], + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}