A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755import { env } from "$env/dynamic/private";import { readFileSync } from "node:fs";
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", "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 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<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> { 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<ControllerRpcError> { 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, });}