A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696import { env } from "$env/dynamic/private";import { readFileSync } from "node:fs";
import { ControllerRpcError, controllerRpcCall,} from "$lib/server/controller-rpc";import { currentRequestSignal } from "$lib/server/request-context";
export { ControllerRpcError };
type RpcRequest = Record<string, unknown>;
const controllerRpcBasePath = "/api/controller";
export function controllerProcedure(path: string): string { return `${controllerRpcBasePath}${path}`;}
export type PaginatedResult<T> = { 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<TaskActionResult> { 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<string, string>; 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", "connect-timeout-ms", "content-type",]);
function parseControllerHeaders(): Record<string, string> { 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<string, unknown>; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("WEB_CONTROLLER_HEADERS must be a JSON object."); } const headers: Record<string, string> = {}; 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<DashboardData> { 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<SystemStatus> { const config = requireControllerConfig(); return rpcCall<SystemStatus>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetSystemStatus", ), {}, );}
export async function loadCurrentConfig(): Promise<CurrentConfig> { 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<SystemCapabilities> { 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<PaginatedResult<ServiceSummary>> { 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<ServiceWorkspaceSummary | null> { 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<NodeSummary[]> { 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<PaginatedResult<TaskSummary>> { 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<PaginatedResult<BackupSummary>> { 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<BackupDetail> { 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<TaskActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.BackupCommandService/RestoreBackup", ), { backupId, nodeId, }, );}
export async function loadRepoHead(): Promise<RepoHead> { const config = requireControllerConfig(); return rpcCall<RepoHead>( 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<RepoFileEntry[]> { 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<RepoFileContent> { const config = requireControllerConfig(); return rpcCall<RepoFileContent>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoQueryService/GetRepoFile"), { path }, );}
export async function updateRepoFile( path: string, content: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/UpdateRepoFile", ), { path, content, baseRevision, commitMessage }, );}
export async function createRepoDirectory( path: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( 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<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/MoveRepoPath", ), { sourcePath, destinationPath, baseRevision, commitMessage }, );}
export async function deleteRepoPath( path: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/DeleteRepoPath", ), { path, baseRevision, commitMessage }, );}
export async function syncRepo(): Promise<RepoSyncResult> { const config = requireControllerConfig(); return rpcCall<RepoSyncResult>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoCommandService/SyncRepo"), {}, );}
export async function loadSecret( serviceDir: string, filePath: string,): Promise<SecretEnv> { const config = requireControllerConfig(); return rpcCall<SecretEnv>( 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<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.SecretService/UpdateSecret"), { serviceDir, filePath, content, baseRevision, commitMessage }, );}
export async function loadServiceDetail( serviceName: string, includeContainers = false,): Promise<ServiceDetail> { 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<ServiceInstanceDetail[]> { 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<ServiceInstanceDetail | null> { 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<ServiceActionResult> { 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<ImageUpdateCheckSummary[]> { 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<NodeSummary | null> { 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<NodeDockerStats | null> { 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<string, string>; 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<string, string>; subnet: string; gateway: string; containersCount: number; ipv6Enabled: boolean;};
export type DockerVolumeSummary = { name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record<string, string>; 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 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<PaginatedResult<DockerContainerSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ containers?: Array<{ id: string; name: string; image: string; state: string; status: string; created: string; labels: Record<string, string>; 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<string> { 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<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RunContainerAction", ), { nodeId, containerId, action: toContainerActionEnum(action) }, );}
export async function removeNodeContainer( nodeId: string, containerId: string, options: ContainerRemoveOptions = {},): Promise<ContainerActionResult> { 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<ContainerExecSession> { const config = requireControllerConfig(); return rpcCall<ContainerExecSession>( 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<PaginatedResult<DockerNetworkSummary>> { 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<string, string>; 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<string> { 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<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveNetwork", ), { nodeId, networkId }, );}
export async function listNodeVolumes( nodeId: string, query: DockerListQuery = {},): Promise<PaginatedResult<DockerVolumeSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ volumes?: Array<{ name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record<string, string>; 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<string> { 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<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveVolume", ), { nodeId, volumeName }, );}
export async function listNodeImages( nodeId: string, query: DockerListQuery = {},): Promise<PaginatedResult<DockerImageSummary>> { 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<string> { 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<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveImage", ), { nodeId, imageId, force }, );}
export async function loadTaskDetail(taskId: string): Promise<TaskDetail> { 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<TaskActionResult> { return callTaskAction( controllerProcedure("/composia.controller.v1.TaskService/RunTaskAgain"), { taskId, }, );}
export async function resolveTaskConfirmation( taskId: string, decision: "approve" | "reject", comment = "",): Promise<TaskActionResult> { 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<TaskActionResult> { 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<ServiceActionResult> { 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<TaskActionResult> { 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<T>( baseUrl: string, token: string, procedure: string, body: RpcRequest, extraHeaders: Record<string, string> = {},): Promise<T> { return controllerRpcCall<T>({ baseUrl, token, procedure, body, controllerHeaders: parseControllerHeaders(), extraHeaders, requestSignal: currentRequestSignal(), });}