import { env } from "$env/dynamic/private"; import { readFileSync } from "node:fs"; type RpcRequest = Record; const controllerRpcBasePath = "/api/controller"; export function controllerProcedure(path: string): string { return `${controllerRpcBasePath}${path}`; } export type PaginatedResult = { items: T[]; totalCount: number; }; export type Capability = { enabled: boolean; reasonCode: string; }; export type GlobalCapabilities = { backup: Capability; dns: Capability; secrets: Capability; rusticMaintenance: Capability; }; export type SystemCapabilities = { global: GlobalCapabilities; }; export type ServiceActionCapabilities = { backup: Capability; restore: Capability; migrate: Capability; dnsUpdate: Capability; caddySync: Capability; cloudflareTunnelSync: Capability; }; export type NodeActionCapabilities = { caddySync: Capability; caddyReload: Capability; rusticMaintenance: Capability; }; export type BackupActionCapabilities = { restore: Capability; }; export type SystemStatus = { version: string; configuredNodeCount: number; onlineNodeCount: number; serviceCount: number; runningServiceCount: number; }; export type GitConfigSummary = { remoteUrl: string; branch: string; pullInterval: string; hasAuth: boolean; authorName: string; authorEmail: string; }; export type NodeConfigSummary = { id: string; displayName: string; enabled: boolean; publicIpv4: string; publicIpv6: string; }; export type AccessTokenSummary = { name: string; enabled: boolean; comment: string; }; export type CurrentConfig = { listenAddr: string; git?: GitConfigSummary; nodes: NodeConfigSummary[]; accessTokens: AccessTokenSummary[]; }; export type ServiceSummary = { name: string; folder?: string; isDeclared: boolean; runtimeStatus: string; updatedAt: string; instanceCount: number; runningCount: number; targetNodeCount: number; }; export type ServiceWorkspaceSummary = { folder: string; displayName: string; serviceName: string; hasMeta: boolean; isDeclared: boolean; runtimeStatus: string; updatedAt: string; nodes: string[]; enabled: boolean; actions: ServiceActionCapabilities; }; export type ServiceInstanceSummary = { serviceName: string; nodeId: string; runtimeStatus: string; updatedAt: string; isDeclared: boolean; pendingDeployRevision: string; }; export type ServiceContainerSummary = { containerId: string; name: string; image: string; state: string; status: string; created: string; composeProject: string; composeService: string; }; export type ServiceInstanceDetail = ServiceInstanceSummary & { containers: ServiceContainerSummary[]; }; export type NodeSummary = { nodeId: string; displayName: string; enabled: boolean; isOnline: boolean; lastHeartbeat: string; actions: NodeActionCapabilities; }; export type TaskSummary = { taskId: string; type: string; status: string; serviceName: string; nodeId: string; createdAt: string; }; export type TaskStepSummary = { stepName: string; status: string; startedAt: string; finishedAt: string; }; export type TaskDetail = { taskId: string; type: string; source: string; serviceName: string; nodeId: string; status: string; createdAt: string; startedAt: string; finishedAt: string; repoRevision: string; errorSummary: string; triggeredBy: string; resultRevision: string; attemptOfTaskId: string; steps: TaskStepSummary[]; }; export type NodeDockerStats = { containersTotal: number; containersRunning: number; containersStopped: number; containersPaused: number; images: number; networks: number; volumes: number; volumesSizeBytes: number; disksUsageBytes: number; dockerServerVersion: string; }; export type ServiceDetail = { name: string; runtimeStatus: string; updatedAt: string; nodes: string[]; enabled: boolean; directory: string; instances: ServiceInstanceDetail[]; actions: ServiceActionCapabilities; }; export type TaskActionResult = { taskId: string; status: string; repoRevision: string; }; export type ServiceActionResult = { tasks: TaskActionResult[]; repoWrite?: RepoWriteResult; }; export type ImageUpdateCheckSummary = { serviceName: string; nodeId: string; imageName: string; imageRef: string; policyType: string; currentValue: string; currentTag: string; currentDigest: string; candidateTag: string; candidateDigest: string; candidateTags: string[]; updateAvailable: boolean; checkStatus: string; errorSummary: string; checkedAt: string; }; export type ImageUpdateSelection = { imageName: string; targetTag?: string; useDetected?: boolean; }; export type ContainerRemoveOptions = { force?: boolean; removeVolumes?: boolean; }; export type ServiceAction = | "deploy" | "update" | "stop" | "restart" | "backup" | "dns_update" | "caddy_sync" | "cloudflare_tunnel_sync"; export type ComposeRecreateMode = "auto" | "no_recreate" | "force_recreate"; export async function migrateService( serviceName: string, sourceNodeId: string, targetNodeId: string, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.ServiceCommandService/MigrateService", ), { serviceName, sourceNodeId, targetNodeId }, ); } export type BackupSummary = { backupId: string; taskId: string; serviceName: string; nodeId: string; dataName: string; status: string; startedAt: string; finishedAt: string; }; export type BackupDetail = BackupSummary & { nodeId: string; artifactRef: string; errorSummary: string; actions: BackupActionCapabilities; }; export type RepoFileEntry = { path: string; name: string; isDir: boolean; size: number; }; export type RepoFileContent = { path: string; content: string; size: number; }; export type RepoHead = { branch: string; headRevision: string; cleanWorktree: boolean; hasRemote: boolean; syncStatus: string; lastSyncError: string; lastSuccessfulPullAt: string; }; export type RepoWriteResult = { commitId: string; syncStatus: string; pushError: string; lastSuccessfulPullAt: string; }; export type RepoSyncResult = { headRevision: string; branch: string; syncStatus: string; lastSyncError: string; lastSuccessfulPullAt: string; }; export type RepoCommitSummary = { commitId: string; subject: string; committedAt: string; }; export type SecretEnv = { serviceName: string; filePath: string; content: string; }; export type DashboardData = { system: SystemStatus; services: ServiceSummary[]; nodes: NodeSummary[]; tasks: TaskSummary[]; backups: BackupSummary[]; }; export function controllerConfig() { const baseUrl = env.WEB_CONTROLLER_ADDR?.trim(); const token = env.WEB_CONTROLLER_ACCESS_TOKEN?.trim(); let headers: Record; try { headers = parseControllerHeaders(); } catch (error) { return { ready: false as const, reason: error instanceof Error ? error.message : "Invalid controller headers.", }; } if (!baseUrl || !token) { return { ready: false as const, reason: "Set WEB_CONTROLLER_ADDR and WEB_CONTROLLER_ACCESS_TOKEN in the web server environment.", }; } return { ready: true as const, baseUrl: baseUrl.replace(/\/$/, ""), token, headers, }; } const reservedControllerHeaderNames = new Set([ "authorization", "connect-protocol-version", "content-type", ]); function parseControllerHeaders(): Record { const inline = env.WEB_CONTROLLER_HEADERS?.trim(); const file = env.WEB_CONTROLLER_HEADERS_FILE?.trim(); if (inline && file) { throw new Error( "Set only one of WEB_CONTROLLER_HEADERS or WEB_CONTROLLER_HEADERS_FILE.", ); } const raw = file ? readFileSync(file, "utf8").trim() : inline; if (!raw) { return {}; } const parsed = JSON.parse(raw) as Record; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("WEB_CONTROLLER_HEADERS must be a JSON object."); } const headers: Record = {}; for (const [name, value] of Object.entries(parsed)) { const normalizedName = name.trim(); if ( !normalizedName || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(normalizedName) ) { throw new Error(`Invalid WEB_CONTROLLER_HEADERS header name: ${name}`); } if (reservedControllerHeaderNames.has(normalizedName.toLowerCase())) { throw new Error( `WEB_CONTROLLER_HEADERS header ${normalizedName} is reserved.`, ); } if (typeof value !== "string" || !value.trim()) { throw new Error( `WEB_CONTROLLER_HEADERS header ${normalizedName} must be a non-empty string.`, ); } headers[normalizedName] = value.trim(); } return headers; } function parseCapability( capability?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; } | null, ): Capability { return { enabled: capability?.enabled === true, reasonCode: normalizeCapabilityReasonCode( capability?.reasonCode ?? capability?.reason_code, ), }; } function normalizeEnumSuffix(prefix: string, value: unknown): string { if (typeof value !== "string") { return ""; } const normalized = value.trim(); if (!normalized) { return ""; } if (normalized.startsWith(prefix)) { return normalized.slice(prefix.length).toLowerCase(); } return normalized.toLowerCase(); } function normalizeTaskStatus(value: unknown): string { return normalizeEnumSuffix("TASK_STATUS_", value); } function normalizeTaskType(value: unknown): string { return normalizeEnumSuffix("TASK_TYPE_", value); } function normalizeTaskSource(value: unknown): string { return normalizeEnumSuffix("TASK_SOURCE_", value); } function normalizeTaskStepName(value: unknown): string { return normalizeEnumSuffix("TASK_STEP_NAME_", value); } function normalizeCapabilityReasonCode(value: unknown): string { return normalizeEnumSuffix("CAPABILITY_REASON_CODE_", value); } function encodeTaskStatus(value: string): string { return `TASK_STATUS_${value.trim().toUpperCase()}`; } function encodeTaskType(value: string): string { return `TASK_TYPE_${value.trim().toUpperCase()}`; } function encodeTaskDecision(value: "approve" | "reject"): string { return value === "approve" ? "TASK_CONFIRMATION_DECISION_APPROVE" : "TASK_CONFIRMATION_DECISION_REJECT"; } function parseServiceActionCapabilities( actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; cloudflareTunnelSync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; cloudflare_tunnel_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null, ): ServiceActionCapabilities { return { backup: parseCapability(actions?.backup), restore: parseCapability(actions?.restore), migrate: parseCapability(actions?.migrate), dnsUpdate: parseCapability(actions?.dnsUpdate ?? actions?.dns_update), caddySync: parseCapability(actions?.caddySync ?? actions?.caddy_sync), cloudflareTunnelSync: parseCapability( actions?.cloudflareTunnelSync ?? actions?.cloudflare_tunnel_sync, ), }; } function parseNodeActionCapabilities( actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null, ): NodeActionCapabilities { return { caddySync: parseCapability(actions?.caddySync ?? actions?.caddy_sync), caddyReload: parseCapability(actions?.caddyReload ?? actions?.caddy_reload), rusticMaintenance: parseCapability( actions?.rusticMaintenance ?? actions?.rustic_maintenance, ), }; } function parseBackupActionCapabilities( actions?: { restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null, ): BackupActionCapabilities { return { restore: parseCapability(actions?.restore), }; } function parseGlobalCapabilities( global?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; dns?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; secrets?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null, ): GlobalCapabilities { return { backup: parseCapability(global?.backup), dns: parseCapability(global?.dns), secrets: parseCapability(global?.secrets), rusticMaintenance: parseCapability( global?.rusticMaintenance ?? global?.rustic_maintenance, ), }; } export async function loadDashboard(): Promise { const config = controllerConfig(); if (!config.ready) { throw new Error(config.reason); } const [system, tasksResult, backupsResult] = await Promise.all([ loadSystemStatus(), loadTasks(1, 6), loadBackups(1, 6), ]); return { system, services: [], nodes: [], tasks: tasksResult.items, backups: backupsResult.items, }; } export async function loadSystemStatus(): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetSystemStatus", ), {}, ); } export async function loadCurrentConfig(): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ listenAddr?: string; listen_addr?: string; git?: { remoteUrl?: string; remote_url?: string; branch?: string; pullInterval?: string; pull_interval?: string; hasAuth?: boolean; has_auth?: boolean; authorName?: string; author_name?: string; authorEmail?: string; author_email?: string; }; nodes?: Array<{ id?: string; displayName?: string; display_name?: string; enabled?: boolean; publicIpv4?: string; public_ipv4?: string; publicIpv6?: string; public_ipv6?: string; }>; accessTokens?: Array<{ name?: string; enabled?: boolean; comment?: string; }>; access_tokens?: Array<{ name?: string; enabled?: boolean; comment?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetCurrentConfig", ), {}, ); const rawGit = response.git; const rawTokens = response.accessTokens ?? response.access_tokens; return { listenAddr: response.listenAddr ?? response.listen_addr ?? "", git: rawGit ? { remoteUrl: rawGit.remoteUrl ?? rawGit.remote_url ?? "", branch: rawGit.branch ?? "", pullInterval: rawGit.pullInterval ?? rawGit.pull_interval ?? "", hasAuth: rawGit.hasAuth ?? rawGit.has_auth ?? false, authorName: rawGit.authorName ?? rawGit.author_name ?? "", authorEmail: rawGit.authorEmail ?? rawGit.author_email ?? "", } : undefined, nodes: (response.nodes ?? []).map((node) => ({ id: node.id ?? "", displayName: node.displayName ?? node.display_name ?? "", enabled: node.enabled ?? false, publicIpv4: node.publicIpv4 ?? node.public_ipv4 ?? "", publicIpv6: node.publicIpv6 ?? node.public_ipv6 ?? "", })), accessTokens: (rawTokens ?? []).map((token) => ({ name: token.name ?? "", enabled: token.enabled ?? false, comment: token.comment ?? "", })), }; } export async function reloadControllerConfig(): Promise<{ accepted: boolean }> { const config = requireControllerConfig(); return rpcCall<{ accepted: boolean }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/ReloadControllerConfig", ), {}, ); } export async function loadSystemCapabilities(): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ global?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; secrets?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetCapabilities", ), {}, ); return { global: parseGlobalCapabilities(response.global), }; } export async function loadServices( page = 1, pageSize = 50, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ services?: Array< ServiceSummary & { instance_count?: number; running_count?: number; target_node_count?: number; } >; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/ListServices", ), { page, pageSize }, ); return { items: (response.services ?? []).map((service) => ({ ...service, instanceCount: service.instanceCount ?? service.instance_count ?? 0, runningCount: service.runningCount ?? service.running_count ?? 0, targetNodeCount: service.targetNodeCount ?? service.target_node_count ?? 0, })), totalCount: response.totalCount ?? 0, }; } export async function loadServiceWorkspaces(): Promise< ServiceWorkspaceSummary[] > { const config = requireControllerConfig(); const response = await rpcCall<{ workspaces?: Array<{ folder: string; displayName?: string; display_name?: string; serviceName?: string; service_name?: string; hasMeta?: boolean; has_meta?: boolean; isDeclared?: boolean; is_declared?: boolean; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; nodes?: string[]; enabled?: boolean; actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/ListServiceWorkspaces", ), {}, ); return (response.workspaces ?? []).map((workspace) => ({ folder: workspace.folder, displayName: workspace.displayName ?? workspace.display_name ?? workspace.folder, serviceName: workspace.serviceName ?? workspace.service_name ?? "", hasMeta: workspace.hasMeta ?? workspace.has_meta ?? false, isDeclared: workspace.isDeclared ?? workspace.is_declared ?? false, runtimeStatus: workspace.runtimeStatus ?? workspace.runtime_status ?? "unknown", updatedAt: workspace.updatedAt ?? workspace.updated_at ?? "", nodes: workspace.nodes ?? [], enabled: workspace.enabled ?? false, actions: parseServiceActionCapabilities(workspace.actions), })); } export async function loadServiceWorkspace( folder: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ workspace?: { folder: string; displayName?: string; display_name?: string; serviceName?: string; service_name?: string; hasMeta?: boolean; has_meta?: boolean; isDeclared?: boolean; is_declared?: boolean; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; nodes?: string[]; enabled?: boolean; actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/GetServiceWorkspace", ), { folder }, ); if (!response.workspace) { return null; } const workspace = response.workspace; return { folder: workspace.folder, displayName: workspace.displayName ?? workspace.display_name ?? workspace.folder, serviceName: workspace.serviceName ?? workspace.service_name ?? "", hasMeta: workspace.hasMeta ?? workspace.has_meta ?? false, isDeclared: workspace.isDeclared ?? workspace.is_declared ?? false, runtimeStatus: workspace.runtimeStatus ?? workspace.runtime_status ?? "unknown", updatedAt: workspace.updatedAt ?? workspace.updated_at ?? "", nodes: workspace.nodes ?? [], enabled: workspace.enabled ?? false, actions: parseServiceActionCapabilities(workspace.actions), }; } export async function loadNodes(): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ nodes?: Array<{ nodeId?: string; node_id?: string; displayName?: string; display_name?: string; enabled?: boolean; isOnline?: boolean; is_online?: boolean; lastHeartbeat?: string; last_heartbeat?: string; actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.NodeQueryService/ListNodes"), {}, ); return (response.nodes ?? []).map((node) => ({ nodeId: node.nodeId ?? node.node_id ?? "", displayName: node.displayName ?? node.display_name ?? node.nodeId ?? node.node_id ?? "", enabled: node.enabled ?? false, isOnline: node.isOnline ?? node.is_online ?? false, lastHeartbeat: node.lastHeartbeat ?? node.last_heartbeat ?? "", actions: parseNodeActionCapabilities(node.actions), })); } export type TaskFilter = { serviceName?: string[]; nodeId?: string[]; status?: string[]; type?: string[]; excludeServiceName?: string[]; excludeNodeId?: string[]; excludeStatus?: string[]; excludeType?: string[]; }; export type BackupFilter = { serviceName?: string[]; status?: string[]; dataName?: string[]; nodeId?: string[]; excludeServiceName?: string[]; excludeStatus?: string[]; excludeDataName?: string[]; excludeNodeId?: string[]; }; export async function loadTasks( page = 1, pageSize = 50, filter?: TaskFilter, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ tasks?: Array<{ taskId?: string; task_id?: string; type?: unknown; status?: unknown; serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; createdAt?: string; created_at?: string; }>; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.TaskService/ListTasks"), { page, pageSize, status: (filter?.status ?? []).map(encodeTaskStatus), service_name: filter?.serviceName ?? [], node_id: filter?.nodeId ?? [], type: (filter?.type ?? []).map(encodeTaskType), exclude_status: (filter?.excludeStatus ?? []).map(encodeTaskStatus), exclude_service_name: filter?.excludeServiceName ?? [], exclude_node_id: filter?.excludeNodeId ?? [], exclude_type: (filter?.excludeType ?? []).map(encodeTaskType), }, ); return { items: (response.tasks ?? []).map((task) => ({ taskId: task.taskId ?? task.task_id ?? "", type: normalizeTaskType(task.type), status: normalizeTaskStatus(task.status), serviceName: task.serviceName ?? task.service_name ?? "", nodeId: task.nodeId ?? task.node_id ?? "", createdAt: task.createdAt ?? task.created_at ?? "", })), totalCount: response.totalCount ?? 0, }; } export async function loadBackups( page = 1, pageSize = 100, filter?: BackupFilter, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ backups?: BackupSummary[]; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.BackupQueryService/ListBackups", ), { page, pageSize, service_name: filter?.serviceName ?? [], status: filter?.status ?? [], data_name: filter?.dataName ?? [], node_id: filter?.nodeId ?? [], exclude_service_name: filter?.excludeServiceName ?? [], exclude_status: filter?.excludeStatus ?? [], exclude_data_name: filter?.excludeDataName ?? [], exclude_node_id: filter?.excludeNodeId ?? [], }, ); return { items: response.backups ?? [], totalCount: response.totalCount ?? 0, }; } export async function loadBackupDetail( backupId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ backupId?: string; backup_id?: string; taskId?: string; task_id?: string; serviceName?: string; service_name?: string; dataName?: string; data_name?: string; nodeId?: string; node_id?: string; status?: string; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; artifactRef?: string; artifact_ref?: string; errorSummary?: string; error_summary?: string; actions?: { restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.BackupQueryService/GetBackup"), { backupId }, ); return { backupId: response.backupId ?? response.backup_id ?? backupId, taskId: response.taskId ?? response.task_id ?? "", serviceName: response.serviceName ?? response.service_name ?? "", dataName: response.dataName ?? response.data_name ?? "", nodeId: response.nodeId ?? response.node_id ?? "", status: response.status ?? "", startedAt: response.startedAt ?? response.started_at ?? "", finishedAt: response.finishedAt ?? response.finished_at ?? "", artifactRef: response.artifactRef ?? response.artifact_ref ?? "", errorSummary: response.errorSummary ?? response.error_summary ?? "", actions: parseBackupActionCapabilities(response.actions), }; } export async function restoreBackup( backupId: string, nodeId: string, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.BackupCommandService/RestoreBackup", ), { backupId, nodeId, }, ); } export async function loadRepoHead(): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoQueryService/GetRepoHead"), {}, ); } export async function listRepoCommits( pageSize: number, cursor = "", ): Promise<{ commits: RepoCommitSummary[]; nextCursor: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ commits?: RepoCommitSummary[]; nextCursor?: string; next_cursor?: string; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoQueryService/ListRepoCommits", ), { pageSize, cursor }, ); return { commits: response.commits ?? [], nextCursor: response.nextCursor ?? response.next_cursor ?? "", }; } export async function loadRepoEntries( path = "", options: { recursive?: boolean } = {}, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ entries?: RepoFileEntry[] }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoQueryService/ListRepoFiles", ), { path, recursive: options.recursive ?? false }, ); return response.entries ?? []; } export async function loadRepoFile(path: string): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoQueryService/GetRepoFile"), { path }, ); } export async function updateRepoFile( path: string, content: string, baseRevision: string, commitMessage = "", ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/UpdateRepoFile", ), { path, content, baseRevision, commitMessage }, ); } export async function createRepoDirectory( path: string, baseRevision: string, commitMessage = "", ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/CreateRepoDirectory", ), { path, baseRevision, commitMessage }, ); } export async function moveRepoPath( sourcePath: string, destinationPath: string, baseRevision: string, commitMessage = "", ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/MoveRepoPath", ), { sourcePath, destinationPath, baseRevision, commitMessage }, ); } export async function deleteRepoPath( path: string, baseRevision: string, commitMessage = "", ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/DeleteRepoPath", ), { path, baseRevision, commitMessage }, ); } export async function syncRepo(): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoCommandService/SyncRepo"), {}, ); } export async function loadSecret( serviceDir: string, filePath: string, ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.SecretService/GetSecret"), { serviceDir, filePath }, ); } export async function updateSecret( serviceDir: string, filePath: string, content: string, baseRevision: string, commitMessage = "", ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.SecretService/UpdateSecret"), { serviceDir, filePath, content, baseRevision, commitMessage }, ); } export async function loadServiceDetail( serviceName: string, includeContainers = false, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ name: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; nodes?: string[]; enabled: boolean; directory: string; actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; instances?: Array<{ serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; isDeclared?: boolean; is_declared?: boolean; pendingDeployRevision?: string; pending_deploy_revision?: string; containers?: Array<{ containerId?: string; container_id?: string; name?: string; image?: string; state?: string; status?: string; created?: string; composeProject?: string; compose_project?: string; composeService?: string; compose_service?: string; }>; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/GetService", ), { serviceName, includeContainers }, ); return { name: response.name, runtimeStatus: response.runtimeStatus ?? response.runtime_status ?? "unknown", updatedAt: response.updatedAt ?? response.updated_at ?? "", nodes: response.nodes ?? [], enabled: response.enabled, directory: response.directory, actions: parseServiceActionCapabilities(response.actions), instances: (response.instances ?? []).map((instance) => ({ serviceName: instance.serviceName ?? instance.service_name ?? serviceName, nodeId: instance.nodeId ?? instance.node_id ?? "", runtimeStatus: instance.runtimeStatus ?? instance.runtime_status ?? "unknown", updatedAt: instance.updatedAt ?? instance.updated_at ?? "", isDeclared: instance.isDeclared ?? instance.is_declared ?? false, pendingDeployRevision: instance.pendingDeployRevision ?? instance.pending_deploy_revision ?? "", containers: (instance.containers ?? []).map((container) => ({ containerId: container.containerId ?? container.container_id ?? "", name: container.name ?? "", image: container.image ?? "", state: container.state ?? "unknown", status: container.status ?? "", created: container.created ?? "", composeProject: container.composeProject ?? container.compose_project ?? "", composeService: container.composeService ?? container.compose_service ?? "", })), })), }; } export async function loadServiceInstances( serviceName: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ instances?: Array<{ serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; isDeclared?: boolean; is_declared?: boolean; pendingDeployRevision?: string; pending_deploy_revision?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceInstanceService/ListServiceInstances", ), { serviceName }, ); return (response.instances ?? []).map((instance) => ({ serviceName: instance.serviceName ?? instance.service_name ?? serviceName, nodeId: instance.nodeId ?? instance.node_id ?? "", runtimeStatus: instance.runtimeStatus ?? instance.runtime_status ?? "unknown", updatedAt: instance.updatedAt ?? instance.updated_at ?? "", isDeclared: instance.isDeclared ?? instance.is_declared ?? false, pendingDeployRevision: instance.pendingDeployRevision ?? instance.pending_deploy_revision ?? "", containers: [], })); } export async function loadServiceInstance( serviceName: string, nodeId: string, includeContainers = false, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ instance?: { serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; isDeclared?: boolean; is_declared?: boolean; pendingDeployRevision?: string; pending_deploy_revision?: string; containers?: Array<{ containerId?: string; container_id?: string; name?: string; image?: string; state?: string; status?: string; created?: string; composeProject?: string; compose_project?: string; composeService?: string; compose_service?: string; }>; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceInstanceService/GetServiceInstance", ), { serviceName, nodeId, includeContainers }, ); const instance = response.instance; if (!instance) { return null; } return { serviceName: instance.serviceName ?? instance.service_name ?? serviceName, nodeId: instance.nodeId ?? instance.node_id ?? nodeId, runtimeStatus: instance.runtimeStatus ?? instance.runtime_status ?? "unknown", updatedAt: instance.updatedAt ?? instance.updated_at ?? "", isDeclared: instance.isDeclared ?? instance.is_declared ?? false, pendingDeployRevision: instance.pendingDeployRevision ?? instance.pending_deploy_revision ?? "", containers: (instance.containers ?? []).map((container) => ({ containerId: container.containerId ?? container.container_id ?? "", name: container.name ?? "", image: container.image ?? "", state: container.state ?? "unknown", status: container.status ?? "", created: container.created ?? "", composeProject: container.composeProject ?? container.compose_project ?? "", composeService: container.composeService ?? container.compose_service ?? "", })), }; } export async function runServiceAction( serviceName: string, action: ServiceAction, options: { nodeIds?: string[]; dataNames?: string[]; composeRecreateMode?: ComposeRecreateMode; imageUpdates?: ImageUpdateSelection[]; useAllDetectedImageUpdates?: boolean; backupBeforeUpdate?: boolean; baseRevision?: string; commitMessage?: string; } = {}, ): Promise { return callServiceAction( controllerProcedure( "/composia.controller.v1.ServiceCommandService/RunServiceAction", ), { serviceName, action: toServiceActionEnum(action), nodeIds: options.nodeIds ?? [], dataNames: options.dataNames ?? [], composeRecreateMode: toComposeRecreateModeEnum( options.composeRecreateMode ?? "auto", ), imageUpdates: (options.imageUpdates ?? []).map((update) => ({ imageName: update.imageName, targetTag: update.targetTag ?? "", useDetected: update.useDetected ?? false, })), useAllDetectedImageUpdates: options.useAllDetectedImageUpdates ?? false, backupBeforeUpdate: options.backupBeforeUpdate, baseRevision: options.baseRevision ?? "", commitMessage: options.commitMessage ?? "", }, ); } export async function loadServiceImageUpdateChecks( serviceName: string, nodeId?: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ checks?: Array<{ serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; imageName?: string; image_name?: string; imageRef?: string; image_ref?: string; policyType?: string; policy_type?: string; currentValue?: string; current_value?: string; currentTag?: string; current_tag?: string; currentDigest?: string; current_digest?: string; candidateTag?: string; candidate_tag?: string; candidateDigest?: string; candidate_digest?: string; candidateTags?: string[]; candidate_tags?: string[]; updateAvailable?: boolean; update_available?: boolean; checkStatus?: string; check_status?: string; errorSummary?: string; error_summary?: string; checkedAt?: string; checked_at?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/GetServiceImageUpdateChecks", ), { serviceName, nodeId: nodeId ?? "" }, ); return (response.checks ?? []).map((check) => ({ serviceName: check.serviceName ?? check.service_name ?? serviceName, nodeId: check.nodeId ?? check.node_id ?? "", imageName: check.imageName ?? check.image_name ?? "", imageRef: check.imageRef ?? check.image_ref ?? "", policyType: check.policyType ?? check.policy_type ?? "", currentValue: check.currentValue ?? check.current_value ?? "", currentTag: check.currentTag ?? check.current_tag ?? "", currentDigest: check.currentDigest ?? check.current_digest ?? "", candidateTag: check.candidateTag ?? check.candidate_tag ?? "", candidateDigest: check.candidateDigest ?? check.candidate_digest ?? "", candidateTags: check.candidateTags ?? check.candidate_tags ?? [], updateAvailable: check.updateAvailable ?? check.update_available ?? false, checkStatus: check.checkStatus ?? check.check_status ?? "unknown", errorSummary: check.errorSummary ?? check.error_summary ?? "", checkedAt: check.checkedAt ?? check.checked_at ?? "", })); } export async function loadNodeDetail( nodeId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ node?: { nodeId?: string; node_id?: string; displayName?: string; display_name?: string; enabled?: boolean; isOnline?: boolean; is_online?: boolean; lastHeartbeat?: string; last_heartbeat?: string; actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.NodeQueryService/GetNode"), { nodeId }, ); if (!response.node) { return null; } return { nodeId: response.node.nodeId ?? response.node.node_id ?? nodeId, displayName: response.node.displayName ?? response.node.display_name ?? response.node.nodeId ?? response.node.node_id ?? nodeId, enabled: response.node.enabled ?? false, isOnline: response.node.isOnline ?? response.node.is_online ?? false, lastHeartbeat: response.node.lastHeartbeat ?? response.node.last_heartbeat ?? "", actions: parseNodeActionCapabilities(response.node.actions), }; } export async function loadNodeDockerStats( nodeId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ stats?: NodeDockerStats }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeQueryService/GetNodeDockerStats", ), { nodeId }, ); return response.stats ?? null; } export async function pruneNodeDocker( nodeId: string, target = "all", ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/PruneNodeDocker", ), { nodeId, target }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export async function forgetNodeRustic( options: { nodeId?: string; serviceName?: string; dataName?: string; } = {}, ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/ForgetNodeRustic", ), { nodeId: options.nodeId ?? "", serviceName: options.serviceName ?? "", dataName: options.dataName ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export async function initNodeRustic( options: { nodeId?: string; } = {}, ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/InitNodeRustic", ), { nodeId: options.nodeId ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export async function pruneNodeRustic( options: { nodeId?: string; serviceName?: string; dataName?: string; } = {}, ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/PruneNodeRustic", ), { nodeId: options.nodeId ?? "", serviceName: options.serviceName ?? "", dataName: options.dataName ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export async function reloadNodeCaddy( nodeId: string, ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/ReloadNodeCaddy", ), { nodeId }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export async function syncNodeCaddyFiles( nodeId: string, options: { serviceName?: string; fullRebuild?: boolean } = {}, ): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/SyncNodeCaddyFiles", ), { nodeId, serviceName: options.serviceName ?? "", fullRebuild: options.fullRebuild ?? false, }, ); return { taskId: response.taskId ?? response.task_id ?? "" }; } export type DockerContainerSummary = { id: string; name: string; image: string; state: string; status: string; created: string; labels: Record; ports: string[]; networks: string[]; imageId: string; }; export type ContainerActionResult = TaskActionResult; export type ContainerAction = "start" | "stop" | "restart"; export type ContainerExecSession = { sessionId: string; websocketPath: string; }; export type DockerNetworkSummary = { id: string; name: string; driver: string; scope: string; internal: boolean; attachable: boolean; created: string; labels: Record; subnet: string; gateway: string; containersCount: number; ipv6Enabled: boolean; }; export type DockerVolumeSummary = { name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record; sizeBytes: number; containersCount: number; inUse: boolean; }; export type DockerImageSummary = { id: string; repoTags: string[]; size: number; created: string; repoDigests: string[]; virtualSize: number; architecture: string; os: string; author: string; containersCount: number; isDangling: boolean; }; export type DockerListQuery = { page?: number; pageSize?: number; search?: string; sortBy?: string; sortDesc?: boolean; }; export class ControllerRpcError extends Error { readonly status: number; readonly code: string | null; readonly procedure: string; constructor(options: { message: string; status: number; code?: string | null; procedure: string; }) { super(options.message); this.name = "ControllerRpcError"; this.status = options.status; this.code = options.code ?? null; this.procedure = options.procedure; } } export function controllerErrorStatus(error: unknown, fallback = 500): number { if (error instanceof ControllerRpcError) { return error.status; } return fallback; } export function controllerErrorCode(error: unknown): string | null { if (error instanceof ControllerRpcError) { return error.code; } return null; } export function controllerErrorMessage( error: unknown, fallback: string, ): string { if (error instanceof ControllerRpcError) { return error.message; } if (error instanceof Error) { return error.message; } return fallback; } function toNumber(value: unknown): number { if (typeof value === "number") { return value; } if (typeof value === "string") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } return 0; } export async function listNodeContainers( nodeId: string, query: DockerListQuery = {}, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ containers?: Array<{ id: string; name: string; image: string; state: string; status: string; created: string; labels: Record; ports: string[]; networks: string[]; imageId?: string; image_id?: string; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeContainers", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.containers ?? []).map((c) => ({ id: c.id, name: c.name, image: c.image, state: c.state, status: c.status, created: c.created, labels: c.labels, ports: c.ports ?? [], networks: c.networks ?? [], imageId: c.imageId ?? c.image_id ?? "", })), totalCount: response.totalCount ?? response.total_count ?? 0, }; } export async function inspectNodeContainer( nodeId: string, containerId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeContainer", ), { nodeId, containerId }, ); return response.rawJson ?? response.raw_json ?? "{}"; } export async function runContainerAction( nodeId: string, containerId: string, action: ContainerAction, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RunContainerAction", ), { nodeId, containerId, action: toContainerActionEnum(action) }, ); } export async function removeNodeContainer( nodeId: string, containerId: string, options: ContainerRemoveOptions = {}, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveContainer", ), { nodeId, containerId, force: options.force ?? false, removeVolumes: options.removeVolumes ?? false, }, ); } export async function openContainerExec( nodeId: string, containerId: string, command: string[] = [], rows = 24, cols = 80, browserOrigin: string, ): Promise { const config = requireControllerConfig(); return rpcCall( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerCommandService/OpenContainerExec", ), { nodeId, containerId, command, rows, cols }, { "X-Composia-Web-Origin": browserOrigin }, ); } export async function listNodeNetworks( nodeId: string, query: DockerListQuery = {}, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ networks?: Array<{ id: string; name: string; driver: string; scope: string; internal: boolean; attachable: boolean; created: string; labels: Record; subnet: string; gateway: string; containersCount?: number; containers_count?: number; ipv6Enabled?: boolean; ipv6_enabled?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeNetworks", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.networks ?? []).map((n) => ({ id: n.id, name: n.name, driver: n.driver, scope: n.scope, internal: n.internal, attachable: n.attachable, created: n.created, labels: n.labels, subnet: n.subnet, gateway: n.gateway, containersCount: n.containersCount ?? n.containers_count ?? 0, ipv6Enabled: n.ipv6Enabled ?? n.ipv6_enabled ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, }; } export async function inspectNodeNetwork( nodeId: string, networkId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeNetwork", ), { nodeId, networkId }, ); return response.rawJson ?? response.raw_json ?? "{}"; } export async function removeNodeNetwork( nodeId: string, networkId: string, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveNetwork", ), { nodeId, networkId }, ); } export async function listNodeVolumes( nodeId: string, query: DockerListQuery = {}, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ volumes?: Array<{ name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record; sizeBytes?: number | string; size_bytes?: number | string; containersCount?: number; containers_count?: number; inUse?: boolean; in_use?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeVolumes", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.volumes ?? []).map((v) => ({ name: v.name, driver: v.driver, mountpoint: v.mountpoint, scope: v.scope, created: v.created, labels: v.labels, sizeBytes: toNumber(v.sizeBytes ?? v.size_bytes), containersCount: v.containersCount ?? v.containers_count ?? 0, inUse: v.inUse ?? v.in_use ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, }; } export async function inspectNodeVolume( nodeId: string, volumeName: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeVolume", ), { nodeId, volumeName }, ); return response.rawJson ?? response.raw_json ?? "{}"; } export async function removeNodeVolume( nodeId: string, volumeName: string, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveVolume", ), { nodeId, volumeName }, ); } export async function listNodeImages( nodeId: string, query: DockerListQuery = {}, ): Promise> { const config = requireControllerConfig(); const response = await rpcCall<{ images?: Array<{ id: string; repoTags?: string[]; repo_tags?: string[]; size: number | string; created: string; repoDigests?: string[]; repo_digests?: string[]; virtualSize?: number | string; virtual_size?: number | string; architecture: string; os: string; author: string; containersCount?: number; containers_count?: number; isDangling?: boolean; is_dangling?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeImages", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.images ?? []).map((i) => ({ id: i.id, repoTags: i.repoTags ?? i.repo_tags ?? [], size: toNumber(i.size), created: i.created, repoDigests: i.repoDigests ?? i.repo_digests ?? [], virtualSize: toNumber(i.virtualSize ?? i.virtual_size), architecture: i.architecture, os: i.os, author: i.author, containersCount: i.containersCount ?? i.containers_count ?? 0, isDangling: i.isDangling ?? i.is_dangling ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, }; } export async function inspectNodeImage( nodeId: string, imageId: string, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeImage", ), { nodeId, imageId }, ); return response.rawJson ?? response.raw_json ?? "{}"; } export async function removeNodeImage( nodeId: string, imageId: string, force = false, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveImage", ), { nodeId, imageId, force }, ); } export async function loadTaskDetail(taskId: string): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string; type?: unknown; source?: unknown; serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; status?: unknown; createdAt?: string; created_at?: string; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; repoRevision?: string; repo_revision?: string; errorSummary?: string; error_summary?: string; triggeredBy?: string; triggered_by?: string; resultRevision?: string; result_revision?: string; attemptOfTaskId?: string; attempt_of_task_id?: string; steps?: Array<{ stepName?: unknown; step_name?: unknown; status?: unknown; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; }>; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.TaskService/GetTask"), { taskId }, ); return { taskId: response.taskId ?? response.task_id ?? taskId, type: normalizeTaskType(response.type), source: normalizeTaskSource(response.source), serviceName: response.serviceName ?? response.service_name ?? "", nodeId: response.nodeId ?? response.node_id ?? "", status: normalizeTaskStatus(response.status), createdAt: response.createdAt ?? response.created_at ?? "", startedAt: response.startedAt ?? response.started_at ?? "", finishedAt: response.finishedAt ?? response.finished_at ?? "", repoRevision: response.repoRevision ?? response.repo_revision ?? "", errorSummary: response.errorSummary ?? response.error_summary ?? "", triggeredBy: response.triggeredBy ?? response.triggered_by ?? "", resultRevision: response.resultRevision ?? response.result_revision ?? "", attemptOfTaskId: response.attemptOfTaskId ?? response.attempt_of_task_id ?? "", steps: (response.steps ?? []).map((step) => ({ stepName: normalizeTaskStepName(step.stepName ?? step.step_name), status: normalizeTaskStatus(step.status), startedAt: step.startedAt ?? step.started_at ?? "", finishedAt: step.finishedAt ?? step.finished_at ?? "", })), }; } export async function runTaskAgain(taskId: string): Promise { return callTaskAction( controllerProcedure("/composia.controller.v1.TaskService/RunTaskAgain"), { taskId, }, ); } export async function resolveTaskConfirmation( taskId: string, decision: "approve" | "reject", comment = "", ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.TaskService/ResolveTaskConfirmation", ), { taskId, decision: encodeTaskDecision(decision), comment, }, ); } export type MigrationRollbackOptions = { rollbackDns?: boolean; deploySource?: boolean; stopTarget?: boolean; cleanupTarget?: boolean; }; export async function createMigrationRollback( taskId: string, options: MigrationRollbackOptions, ): Promise { return callTaskAction( controllerProcedure( "/composia.controller.v1.TaskService/CreateMigrationRollback", ), { taskId, rollbackDns: options.rollbackDns ?? false, deploySource: options.deploySource ?? false, stopTarget: options.stopTarget ?? false, cleanupTarget: options.cleanupTarget ?? false, }, ); } function requireControllerConfig() { const config = controllerConfig(); if (!config.ready) { throw new Error(config.reason); } return config; } async function callServiceAction( procedure: string, body: RpcRequest, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ tasks?: Array<{ taskId?: string; task_id?: string; status?: string; repoRevision?: string; repo_revision?: string; }>; repoWrite?: { commitId?: string; commit_id?: string; syncStatus?: string; sync_status?: string; pushError?: string; push_error?: string; lastSuccessfulPullAt?: string; last_successful_pull_at?: string; }; repo_write?: { commitId?: string; commit_id?: string; syncStatus?: string; sync_status?: string; pushError?: string; push_error?: string; lastSuccessfulPullAt?: string; last_successful_pull_at?: string; }; }>(config.baseUrl, config.token, procedure, body); const repoWrite = response.repoWrite ?? response.repo_write; return { tasks: (response.tasks ?? []).map((task) => ({ taskId: task.taskId ?? task.task_id ?? "", status: normalizeTaskStatus(task.status), repoRevision: task.repoRevision ?? task.repo_revision ?? "", })), repoWrite: repoWrite ? { commitId: repoWrite.commitId ?? repoWrite.commit_id ?? "", syncStatus: repoWrite.syncStatus ?? repoWrite.sync_status ?? "", pushError: repoWrite.pushError ?? repoWrite.push_error ?? "", lastSuccessfulPullAt: repoWrite.lastSuccessfulPullAt ?? repoWrite.last_successful_pull_at ?? "", } : undefined, }; } async function callTaskAction( procedure: string, body: RpcRequest, ): Promise { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string; status?: string; repoRevision?: string; repo_revision?: string; }>(config.baseUrl, config.token, procedure, body); return { taskId: response.taskId ?? response.task_id ?? "", status: normalizeTaskStatus(response.status), repoRevision: response.repoRevision ?? response.repo_revision ?? "", }; } function toServiceActionEnum(action: ServiceAction): number { switch (action) { case "deploy": return 1; case "update": return 2; case "stop": return 3; case "restart": return 4; case "backup": return 5; case "dns_update": return 6; case "caddy_sync": return 7; case "cloudflare_tunnel_sync": return 8; } } function toComposeRecreateModeEnum(mode: ComposeRecreateMode): number { switch (mode) { case "no_recreate": return 2; case "force_recreate": return 3; case "auto": default: return 1; } } function toContainerActionEnum(action: ContainerAction): number { switch (action) { case "start": return 1; case "stop": return 2; case "restart": return 3; } } async function rpcCall( baseUrl: string, token: string, procedure: string, body: RpcRequest, extraHeaders: Record = {}, ): Promise { const controllerHeaders = parseControllerHeaders(); const response = await fetch(`${baseUrl}${procedure}`, { method: "POST", headers: { ...controllerHeaders, Authorization: `Bearer ${token}`, "Connect-Protocol-Version": "1", "Content-Type": "application/json", "X-Composia-Source": "web", ...extraHeaders, }, body: JSON.stringify(body), }); if (!response.ok) { throw await controllerRpcErrorFromResponse(response, procedure); } return (await response.json()) as T; } async function controllerRpcErrorFromResponse( response: Response, procedure: string, ): Promise { const text = await response.text(); const fallbackMessage = `Controller RPC ${procedure} failed.`; let message = fallbackMessage; let code: string | null = null; if (text) { try { const parsed = JSON.parse(text) as { code?: unknown; message?: unknown; error?: unknown; }; if (typeof parsed.code === "string") { code = parsed.code; } if (typeof parsed.message === "string" && parsed.message.trim()) { message = parsed.message; } else if (typeof parsed.error === "string" && parsed.error.trim()) { message = parsed.error; } else { message = text; } } catch { message = text; } } return new ControllerRpcError({ message, status: response.status, code, procedure, }); }