// SPEC: PJ2026-01060305 AgentRun execution policy + PJ2026-01020108 cancel lifecycle draft-2026-06-25-p0. // Parses AgentRun YAML lane policy, including cancel lifecycle values owned by config/agentrun.yaml. import { rootPath } from "./config"; import { asRecord, arrayField, booleanField, integerField, optionalIntegerField, optionalStringField, readYamlRecord, recordField, stringArrayField, stringField, } from "./platform-infra-ops-library"; export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml"; export interface AgentRunGitMirrorRepositorySpec { readonly key: string; readonly repository: string; readonly sourceBranch: string; readonly gitopsBranch?: string; } export interface AgentRunSecretRef { readonly namespace: string; readonly name: string; readonly key: string; } export interface AgentRunLaneSecretSpec { readonly id: string; readonly sourceRef: string; readonly sourceMode: "env" | "file"; readonly sourceKey: string | null; readonly targetRef: AgentRunSecretRef; readonly providerCredentialProfile: string | null; } export interface AgentRunProviderCredentialRef { readonly profile: string; readonly secretRef: { readonly namespace: string; readonly name: string; readonly keys: readonly string[]; }; } export interface AgentRunLaneSpec { readonly lane: string; readonly nodeId: string; readonly nodeRoute: string; readonly nodeKubeRoute: string; readonly version: string; readonly source: { readonly repository: string; readonly branch: string; readonly bootstrapFromBranch: string | null; readonly bootstrapTimeoutSeconds: number; readonly bootstrapPollSeconds: number; readonly remote: string; readonly workspace: string; }; readonly runtime: { readonly namespace: string; readonly managerDeployment: string; readonly managerService: string; readonly managerPort: number; readonly internalBaseUrl: string; }; readonly ci: { readonly namespace: string; readonly pipeline: string; readonly pipelineRunPrefix: string; readonly serviceAccountName: string; readonly registryPrefix: string; readonly toolsImage: string; }; readonly gitops: { readonly branch: string; readonly path: string; readonly argoNamespace: string; readonly argoApplication: string; readonly repoURL: string; }; readonly deployment: { readonly format: "unidesk-yaml-only"; readonly gitopsRoot: string; readonly runtimeRenderDir: string; readonly artifactCatalogPath: string; readonly argocd: { readonly project: string; readonly applicationFile: string; }; readonly manager: { readonly serviceAccount: string; readonly apiKeySecretRef: { readonly name: string; readonly key: string }; readonly env: Readonly>; readonly unideskSshEndpointEnv: { readonly name: string; readonly value: string } | null; readonly bootRepoUrl: string; readonly imageBuild: AgentRunImageBuildSpec; readonly resources: AgentRunContainerResources; }; readonly runner: { readonly serviceAccount: string; readonly jobNamePrefix: string; readonly idleTimeoutMs: number; readonly backendRetry: { readonly maxAttempts: number; readonly initialBackoffMs: number; readonly maxBackoffMs: number; }; readonly apiKeySecretRef: { readonly name: string; readonly key: string }; readonly egressProxyUrl: string | null; readonly noProxyExtra: readonly string[]; readonly retention: AgentRunRunnerRetentionSpec; readonly cancelLifecycle: AgentRunCancelLifecycleSpec; }; readonly localPostgres: { readonly enabled: boolean; readonly serviceName: string | null; readonly image: string | null; readonly storage: string | null; readonly port: number | null; }; }; readonly gitMirror: { readonly namespace: string; readonly readService: string; readonly readDeployment: string; readonly writeService: string; readonly writeDeployment: string; readonly readUrl: string; readonly writeUrl: string; readonly cachePvc: string; readonly cacheHostPath: string | null; readonly sshSecretName: string; readonly githubProxy: { readonly host: string; readonly port: number; }; readonly toolsImage: string; readonly syncJobPrefix: string; readonly flushJobPrefix: string; readonly repositories: readonly AgentRunGitMirrorRepositorySpec[]; }; readonly database: { readonly mode: "local-postgres" | "external-postgres"; readonly provider: string | null; readonly configRef: string | null; readonly database: string | null; readonly user: string | null; readonly sslmode: "require" | null; readonly secretSourceRef: string | null; readonly secretRef: { readonly name: string; readonly key: string }; readonly localPostgresExpectedAbsent: boolean; }; readonly secrets: readonly AgentRunLaneSecretSpec[]; } export interface AgentRunContainerResources { readonly requests: { readonly cpu: string; readonly memory: string; }; readonly limits: { readonly cpu: string; readonly memory: string; }; } export interface AgentRunImageBuildSpec { readonly context: string; readonly containerfile: string; readonly repository: string; readonly network: string; readonly buildArgs: Readonly>; readonly httpProxy: string | null; readonly httpsProxy: string | null; readonly noProxy: readonly string[]; readonly envIdentityFiles: readonly string[]; readonly timeoutSeconds: number; readonly pollSeconds: number; } export interface AgentRunRunnerRetentionSpec { readonly maxRunners: number; readonly cleanupOrder: "oldest-inactive-last-active-first"; readonly activeHeartbeatMaxAgeMs: number; readonly selectors: { readonly matchLabels: Readonly>; readonly jobNamePrefixes: readonly string[]; }; readonly ageBasedCleanup: { readonly enabled: boolean; readonly maxAgeHours: number | null; }; } export type AgentRunCancelLifecycleStage = "accepted" | "persisted" | "delivered" | "aborting" | "terminalized" | "fenced" | "late-write-rejected"; export interface AgentRunCancelLifecycleSpec { readonly deliveryMode: "manager-epoch"; readonly gracefulAbortMs: number; readonly killEscalationMs: number; readonly staleHeartbeatFencingMs: number; readonly lateWriteFencing: { readonly enabled: boolean; }; readonly eventStages: readonly AgentRunCancelLifecycleStage[]; } export interface AgentRunLaneTarget { readonly configPath: string; readonly spec: AgentRunLaneSpec; } interface AgentRunNodeSpec { readonly id: string; readonly route: string; readonly kubeRoute: string; } interface AgentRunControlPlaneConfig { readonly sourcePath: string; readonly defaultTarget: { readonly node: string; readonly lane: string; }; readonly nodes: Record; readonly lanes: Record; } export function resolveAgentRunLaneTarget(options: { node?: string | null; lane?: string | null }, env: NodeJS.ProcessEnv = process.env): AgentRunLaneTarget { const config = readAgentRunControlPlaneConfig(env); const requestedNode = options.node ?? null; const requestedLane = options.lane ?? null; if (requestedLane !== null) { const spec = config.lanes[requestedLane]; if (spec === undefined) throw new Error(`${config.sourcePath}: controlPlane.lanes.${requestedLane} is not declared`); if (requestedNode !== null && requestedNode !== spec.nodeId) throw new Error(`--node ${requestedNode} does not match controlPlane.lanes.${requestedLane}.node=${spec.nodeId}`); return { configPath: config.sourcePath, spec }; } if (requestedNode !== null) { const candidates = Object.values(config.lanes).filter((lane) => lane.nodeId === requestedNode); if (candidates.length === 0) throw new Error(`${config.sourcePath}: no AgentRun lane is declared for node ${requestedNode}`); if (candidates.length > 1) throw new Error(`--lane is required because node ${requestedNode} has multiple AgentRun lanes: ${candidates.map((lane) => lane.lane).join(", ")}`); return { configPath: config.sourcePath, spec: candidates[0] }; } const spec = config.lanes[config.defaultTarget.lane]; if (spec === undefined) throw new Error(`${config.sourcePath}: default controlPlane lane ${config.defaultTarget.lane} is not declared`); if (spec.nodeId !== config.defaultTarget.node) throw new Error(`${config.sourcePath}: controlPlane.default.node does not match default lane node`); return { configPath: config.sourcePath, spec }; } export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record { return { node: { id: spec.nodeId, route: spec.nodeRoute, kubeRoute: spec.nodeKubeRoute, }, lane: spec.lane, version: spec.version, source: { repository: spec.source.repository, branch: spec.source.branch, bootstrapFromBranch: spec.source.bootstrapFromBranch, bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds, bootstrapPollSeconds: spec.source.bootstrapPollSeconds, workspace: spec.source.workspace, }, runtime: { namespace: spec.runtime.namespace, managerDeployment: spec.runtime.managerDeployment, managerService: spec.runtime.managerService, managerPort: spec.runtime.managerPort, internalBaseUrl: spec.runtime.internalBaseUrl, }, ci: { namespace: spec.ci.namespace, pipeline: spec.ci.pipeline, pipelineRunPrefix: spec.ci.pipelineRunPrefix, serviceAccountName: spec.ci.serviceAccountName, registryPrefix: spec.ci.registryPrefix, }, gitops: { branch: spec.gitops.branch, path: spec.gitops.path, argoNamespace: spec.gitops.argoNamespace, argoApplication: spec.gitops.argoApplication, repoURL: spec.gitops.repoURL, }, deployment: { format: spec.deployment.format, gitopsRoot: spec.deployment.gitopsRoot, runtimeRenderDir: spec.deployment.runtimeRenderDir, artifactCatalogPath: spec.deployment.artifactCatalogPath, argocd: spec.deployment.argocd, manager: { serviceAccount: spec.deployment.manager.serviceAccount, apiKeySecretRef: spec.deployment.manager.apiKeySecretRef, envNames: Object.keys(spec.deployment.manager.env).sort(), unideskSshEndpointEnv: spec.deployment.manager.unideskSshEndpointEnv === null ? null : { name: spec.deployment.manager.unideskSshEndpointEnv.name, valuesPrinted: false }, bootRepoUrl: spec.deployment.manager.bootRepoUrl, imageBuild: { context: spec.deployment.manager.imageBuild.context, containerfile: spec.deployment.manager.imageBuild.containerfile, repository: spec.deployment.manager.imageBuild.repository, network: spec.deployment.manager.imageBuild.network, buildArgNames: Object.keys(spec.deployment.manager.imageBuild.buildArgs).sort(), proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null, noProxyCount: spec.deployment.manager.imageBuild.noProxy.length, envIdentityFileCount: spec.deployment.manager.imageBuild.envIdentityFiles.length, timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds, pollSeconds: spec.deployment.manager.imageBuild.pollSeconds, }, resources: spec.deployment.manager.resources, }, runner: { serviceAccount: spec.deployment.runner.serviceAccount, jobNamePrefix: spec.deployment.runner.jobNamePrefix, idleTimeoutMs: spec.deployment.runner.idleTimeoutMs, apiKeySecretRef: spec.deployment.runner.apiKeySecretRef, egressProxyUrl: spec.deployment.runner.egressProxyUrl, noProxyExtra: spec.deployment.runner.noProxyExtra, retention: spec.deployment.runner.retention, cancelLifecycle: spec.deployment.runner.cancelLifecycle, }, localPostgres: spec.deployment.localPostgres, }, gitMirror: { namespace: spec.gitMirror.namespace, readService: spec.gitMirror.readService, readDeployment: spec.gitMirror.readDeployment, writeService: spec.gitMirror.writeService, writeDeployment: spec.gitMirror.writeDeployment, readUrl: spec.gitMirror.readUrl, writeUrl: spec.gitMirror.writeUrl, cachePvc: spec.gitMirror.cachePvc, cacheHostPath: spec.gitMirror.cacheHostPath, sshSecretName: spec.gitMirror.sshSecretName, githubProxy: { host: spec.gitMirror.githubProxy.host, port: spec.gitMirror.githubProxy.port }, repositories: spec.gitMirror.repositories.map((repo) => ({ key: repo.key, repository: repo.repository, sourceBranch: repo.sourceBranch, gitopsBranch: repo.gitopsBranch ?? null, })), }, database: { mode: spec.database.mode, provider: spec.database.provider, configRef: spec.database.configRef, database: spec.database.database, user: spec.database.user, sslmode: spec.database.sslmode, secretSourceRef: spec.database.secretSourceRef, secretRef: spec.database.secretRef, localPostgresExpectedAbsent: spec.database.localPostgresExpectedAbsent, valuesPrinted: false, }, secrets: spec.secrets.map((secret) => ({ id: secret.id, sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`, sourceMode: secret.sourceMode, sourceKey: secret.sourceKey, targetRef: secret.targetRef, providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile }, valuesPrinted: false, })), providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({ profile: credential.profile, secretRef: credential.secretRef, valuesPrinted: false, })), }; } export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?: string | null): readonly AgentRunProviderCredentialRef[] { const groups = new Map(); for (const secret of spec.secrets) { const credentialProfile = secret.providerCredentialProfile; if (credentialProfile === null) continue; if (profile !== undefined && profile !== null && credentialProfile !== profile) continue; const groupKey = `${credentialProfile}\0${secret.targetRef.namespace}\0${secret.targetRef.name}`; const group = groups.get(groupKey) ?? { profile: credentialProfile, namespace: secret.targetRef.namespace, name: secret.targetRef.name, keys: [], }; if (!group.keys.includes(secret.targetRef.key)) group.keys.push(secret.targetRef.key); groups.set(groupKey, group); } return Array.from(groups.values()).map((group) => ({ profile: group.profile, secretRef: { namespace: group.namespace, name: group.name, keys: group.keys, }, })); } export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string { return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`; } function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig { const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH); const root = readYamlRecord>(configPath); validateConfigEnvelope(root, configPath); const controlPlane = recordField(root, "controlPlane", configPath); const defaultTarget = recordField(controlPlane, "default", `${configPath}.controlPlane`); const nodes = parseNodes(recordField(controlPlane, "nodes", `${configPath}.controlPlane`), configPath); const lanes = parseLanes(recordField(controlPlane, "lanes", `${configPath}.controlPlane`), nodes, configPath); return { sourcePath: configPath, defaultTarget: { node: stringField(defaultTarget, "node", `${configPath}.controlPlane.default`), lane: stringField(defaultTarget, "lane", `${configPath}.controlPlane.default`), }, nodes, lanes, }; } function validateConfigEnvelope(root: Record, configPath: string): void { if (root.version !== 1) throw new Error(`${configPath}.version must be 1`); if (root.kind !== "AgentRunConfig") throw new Error(`${configPath}.kind must be AgentRunConfig`); const metadata = recordField(root, "metadata", configPath); if (stringField(metadata, "name", `${configPath}.metadata`) !== "agentrun") { throw new Error(`${configPath}.metadata.name must be agentrun`); } } function parseNodes(input: Record, configPath: string): Record { const result: Record = {}; for (const [id, raw] of Object.entries(input)) { validateSimpleId(id, `${configPath}.controlPlane.nodes`); const node = asRecord(raw, `${configPath}.controlPlane.nodes.${id}`); result[id] = { id, route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`), kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`), }; } if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`); return result; } function parseLanes(input: Record, nodes: Record, configPath: string): Record { const result: Record = {}; for (const [lane, raw] of Object.entries(input)) { validateSimpleId(lane, `${configPath}.controlPlane.lanes`); const item = asRecord(raw, `${configPath}.controlPlane.lanes.${lane}`); const nodeId = stringField(item, "node", `${configPath}.controlPlane.lanes.${lane}`); const node = nodes[nodeId]; if (node === undefined) throw new Error(`${configPath}.controlPlane.lanes.${lane}.node references undeclared node ${nodeId}`); result[lane] = parseLane(lane, node, item, configPath); } if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.lanes must declare at least one lane`); return result; } function parseLane(lane: string, node: AgentRunNodeSpec, input: Record, configPath: string): AgentRunLaneSpec { const path = `${configPath}.controlPlane.lanes.${lane}`; const source = recordField(input, "source", path); const runtime = recordField(input, "runtime", path); const ci = recordField(input, "ci", path); const gitops = recordField(input, "gitops", path); const deployment = recordField(input, "deployment", path); const gitMirror = recordField(input, "gitMirror", path); const database = recordField(input, "database", path); return { lane, nodeId: node.id, nodeRoute: node.route, nodeKubeRoute: node.kubeRoute, version: stringField(input, "version", path), source: { repository: stringField(source, "repository", `${path}.source`), branch: stringField(source, "branch", `${path}.source`), bootstrapFromBranch: optionalStringField(source, "bootstrapFromBranch", `${path}.source`) ?? null, bootstrapTimeoutSeconds: integerField(source, "bootstrapTimeoutSeconds", `${path}.source`), bootstrapPollSeconds: integerField(source, "bootstrapPollSeconds", `${path}.source`), remote: stringField(source, "remote", `${path}.source`), workspace: absolutePathField(source, "workspace", `${path}.source`), }, runtime: { namespace: stringField(runtime, "namespace", `${path}.runtime`), managerDeployment: stringField(runtime, "managerDeployment", `${path}.runtime`), managerService: stringField(runtime, "managerService", `${path}.runtime`), managerPort: integerField(runtime, "managerPort", `${path}.runtime`), internalBaseUrl: urlField(runtime, "internalBaseUrl", `${path}.runtime`), }, ci: { namespace: stringField(ci, "namespace", `${path}.ci`), pipeline: stringField(ci, "pipeline", `${path}.ci`), pipelineRunPrefix: stringField(ci, "pipelineRunPrefix", `${path}.ci`), serviceAccountName: stringField(ci, "serviceAccountName", `${path}.ci`), registryPrefix: stringField(ci, "registryPrefix", `${path}.ci`), toolsImage: stringField(ci, "toolsImage", `${path}.ci`), }, gitops: { branch: stringField(gitops, "branch", `${path}.gitops`), path: relativePathField(gitops, "path", `${path}.gitops`), argoNamespace: stringField(gitops, "argoNamespace", `${path}.gitops`), argoApplication: stringField(gitops, "argoApplication", `${path}.gitops`), repoURL: urlField(gitops, "repoURL", `${path}.gitops`), }, deployment: parseDeployment(deployment, `${path}.deployment`), gitMirror: { namespace: stringField(gitMirror, "namespace", `${path}.gitMirror`), readService: stringField(gitMirror, "readService", `${path}.gitMirror`), readDeployment: stringField(gitMirror, "readDeployment", `${path}.gitMirror`), writeService: stringField(gitMirror, "writeService", `${path}.gitMirror`), writeDeployment: stringField(gitMirror, "writeDeployment", `${path}.gitMirror`), readUrl: urlField(gitMirror, "readUrl", `${path}.gitMirror`), writeUrl: urlField(gitMirror, "writeUrl", `${path}.gitMirror`), cachePvc: stringField(gitMirror, "cachePvc", `${path}.gitMirror`), cacheHostPath: optionalAbsolutePathField(gitMirror, "cacheHostPath", `${path}.gitMirror`) ?? null, sshSecretName: stringField(gitMirror, "sshSecretName", `${path}.gitMirror`), githubProxy: parseGithubProxy(recordField(gitMirror, "githubProxy", `${path}.gitMirror`), `${path}.gitMirror.githubProxy`), toolsImage: stringField(gitMirror, "toolsImage", `${path}.gitMirror`), syncJobPrefix: stringField(gitMirror, "syncJobPrefix", `${path}.gitMirror`), flushJobPrefix: stringField(gitMirror, "flushJobPrefix", `${path}.gitMirror`), repositories: arrayField(gitMirror, "repositories", `${path}.gitMirror`).map((repo, index) => parseGitMirrorRepository(repo, `${path}.gitMirror.repositories[${index}]`)), }, database: parseDatabase(database, `${path}.database`), secrets: arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`)), }; } function parseDeployment(input: Record, path: string): AgentRunLaneSpec["deployment"] { const argocd = recordField(input, "argocd", path); const manager = recordField(input, "manager", path); const runner = recordField(input, "runner", path); const localPostgres = recordField(input, "localPostgres", path); return { format: enumField(input, "format", path, ["unidesk-yaml-only"]), gitopsRoot: relativePathField(input, "gitopsRoot", path), runtimeRenderDir: relativePathField(input, "runtimeRenderDir", path), artifactCatalogPath: relativePathField(input, "artifactCatalogPath", path), argocd: { project: stringField(argocd, "project", `${path}.argocd`), applicationFile: relativePathField(argocd, "applicationFile", `${path}.argocd`), }, manager: { serviceAccount: stringField(manager, "serviceAccount", `${path}.manager`), apiKeySecretRef: parseSecretRef(recordField(manager, "apiKeySecretRef", `${path}.manager`), `${path}.manager.apiKeySecretRef`), env: optionalEnvRecord(manager, "env", `${path}.manager`), unideskSshEndpointEnv: optionalEnvPair(manager, "unideskSshEndpointEnv", `${path}.manager`), bootRepoUrl: urlField(manager, "bootRepoUrl", `${path}.manager`), imageBuild: parseImageBuild(recordField(manager, "imageBuild", `${path}.manager`), `${path}.manager.imageBuild`), resources: parseContainerResources(recordField(manager, "resources", `${path}.manager`), `${path}.manager.resources`), }, runner: { serviceAccount: stringField(runner, "serviceAccount", `${path}.runner`), jobNamePrefix: stringField(runner, "jobNamePrefix", `${path}.runner`), idleTimeoutMs: positiveIntegerField(runner, "idleTimeoutMs", `${path}.runner`), backendRetry: parseRunnerBackendRetry(recordField(runner, "backendRetry", `${path}.runner`), `${path}.runner.backendRetry`), apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`), egressProxyUrl: optionalStringField(runner, "egressProxyUrl", `${path}.runner`) ?? null, noProxyExtra: optionalStringArrayField(runner, "noProxyExtra", `${path}.runner`), retention: parseRunnerRetention(recordField(runner, "retention", `${path}.runner`), `${path}.runner.retention`), cancelLifecycle: parseCancelLifecycle(recordField(runner, "cancelLifecycle", `${path}.runner`), `${path}.runner.cancelLifecycle`), }, localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`), }; } function parseCancelLifecycle(input: Record, path: string): AgentRunCancelLifecycleSpec { const lateWriteFencing = recordField(input, "lateWriteFencing", path); return { deliveryMode: enumField(input, "deliveryMode", path, ["manager-epoch"]), gracefulAbortMs: positiveIntegerField(input, "gracefulAbortMs", path), killEscalationMs: positiveIntegerField(input, "killEscalationMs", path), staleHeartbeatFencingMs: positiveIntegerField(input, "staleHeartbeatFencingMs", path), lateWriteFencing: { enabled: booleanField(lateWriteFencing, "enabled", `${path}.lateWriteFencing`), }, eventStages: parseCancelLifecycleStages(input.eventStages, `${path}.eventStages`), }; } function parseCancelLifecycleStages(input: unknown, path: string): readonly AgentRunCancelLifecycleStage[] { const values: readonly AgentRunCancelLifecycleStage[] = ["accepted", "persisted", "delivered", "aborting", "terminalized", "fenced", "late-write-rejected"]; if (!Array.isArray(input)) throw new Error(`${path} must be an array`); if (input.length === 0) throw new Error(`${path} must declare at least one stage`); const result = input.map((value, index) => { if (typeof value !== "string" || !values.includes(value as AgentRunCancelLifecycleStage)) throw new Error(`${path}[${index}] must be one of ${values.join(", ")}`); return value as AgentRunCancelLifecycleStage; }); const duplicates = result.filter((value, index) => result.indexOf(value) !== index); if (duplicates.length > 0) throw new Error(`${path} must not contain duplicate stages: ${[...new Set(duplicates)].join(", ")}`); return result; } function parseRunnerRetention(input: Record, path: string): AgentRunRunnerRetentionSpec { const selectors = recordField(input, "selectors", path); const ageBasedCleanup = recordField(input, "ageBasedCleanup", path); return { maxRunners: positiveIntegerField(input, "maxRunners", path), cleanupOrder: enumField(input, "cleanupOrder", path, ["oldest-inactive-last-active-first"]), activeHeartbeatMaxAgeMs: positiveIntegerField(input, "activeHeartbeatMaxAgeMs", path), selectors: { matchLabels: kubernetesLabelRecordField(recordField(selectors, "matchLabels", `${path}.selectors`), `${path}.selectors.matchLabels`), jobNamePrefixes: stringArrayField(selectors, "jobNamePrefixes", `${path}.selectors`).map((prefix, index) => { validateKubernetesNamePrefix(prefix, `${path}.selectors.jobNamePrefixes[${index}]`); return prefix; }), }, ageBasedCleanup: { enabled: booleanField(ageBasedCleanup, "enabled", `${path}.ageBasedCleanup`), maxAgeHours: optionalPositiveIntegerField(ageBasedCleanup, "maxAgeHours", `${path}.ageBasedCleanup`) ?? null, }, }; } function parseRunnerBackendRetry(input: Record, path: string): AgentRunLaneSpec["deployment"]["runner"]["backendRetry"] { return { maxAttempts: positiveIntegerField(input, "maxAttempts", path), initialBackoffMs: positiveIntegerField(input, "initialBackoffMs", path), maxBackoffMs: positiveIntegerField(input, "maxBackoffMs", path), }; } function positiveIntegerField(input: Record, key: string, path: string): number { const value = integerField(input, key, path); if (value <= 0) throw new Error(`${path}.${key} must be a positive integer`); return value; } function optionalPositiveIntegerField(input: Record, key: string, path: string): number | undefined { const value = optionalIntegerField(input, key, path); if (value === undefined) return undefined; if (value <= 0) throw new Error(`${path}.${key} must be a positive integer when set`); return value; } function parseLocalPostgres(input: Record, path: string): AgentRunLaneSpec["deployment"]["localPostgres"] { const enabled = booleanField(input, "enabled", path); if (!enabled) { return { enabled: false, serviceName: optionalStringField(input, "serviceName", path) ?? null, image: optionalStringField(input, "image", path) ?? null, storage: optionalStringField(input, "storage", path) ?? null, port: optionalIntegerField(input, "port", path) ?? null, }; } return { enabled: true, serviceName: stringField(input, "serviceName", path), image: stringField(input, "image", path), storage: stringField(input, "storage", path), port: integerField(input, "port", path), }; } function parseContainerResources(input: Record, path: string): AgentRunContainerResources { const requests = recordField(input, "requests", path); const limits = recordField(input, "limits", path); return { requests: { cpu: stringField(requests, "cpu", `${path}.requests`), memory: stringField(requests, "memory", `${path}.requests`), }, limits: { cpu: stringField(limits, "cpu", `${path}.limits`), memory: stringField(limits, "memory", `${path}.limits`), }, }; } function parseImageBuild(input: Record, path: string): AgentRunImageBuildSpec { return { context: relativePathField(input, "context", path), containerfile: relativePathField(input, "containerfile", path), repository: stringField(input, "repository", path), network: stringField(input, "network", path), buildArgs: stringRecordField(recordField(input, "buildArgs", path), `${path}.buildArgs`), httpProxy: optionalStringField(input, "httpProxy", path) ?? null, httpsProxy: optionalStringField(input, "httpsProxy", path) ?? null, noProxy: stringArrayField(input, "noProxy", path), envIdentityFiles: stringArrayField(input, "envIdentityFiles", path).map((item, index) => { if (item.startsWith("/") || item.includes("..")) throw new Error(`${path}.envIdentityFiles[${index}] must be a relative path without ..`); return item; }), timeoutSeconds: integerField(input, "timeoutSeconds", path), pollSeconds: integerField(input, "pollSeconds", path), }; } function parseLaneSecret(input: Record, path: string): AgentRunLaneSecretSpec { const sourceMode = optionalStringField(input, "sourceMode", path) ?? "env"; if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`); const sourceKey = optionalStringField(input, "sourceKey", path) ?? null; if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`); const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`); return { id: stringField(input, "id", path), sourceRef: secretSourceRefField(input, "sourceRef", path), sourceMode, sourceKey, targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`), providerCredentialProfile: providerCredential, }; } function parseProviderCredentialBinding(input: Record, path: string): string | null { const value = input.providerCredential; if (value === undefined || value === null) return null; const record = asRecord(value, path); const profile = stringField(record, "profile", path); validateSimpleId(profile, path); return profile; } function parseGitMirrorRepository(input: Record, path: string): AgentRunGitMirrorRepositorySpec { const gitopsBranch = optionalStringField(input, "gitopsBranch", path); return { key: stringField(input, "key", path), repository: stringField(input, "repository", path), sourceBranch: stringField(input, "sourceBranch", path), ...(gitopsBranch === undefined ? {} : { gitopsBranch }), }; } function parseDatabase(input: Record, path: string): AgentRunLaneSpec["database"] { const mode = enumField(input, "mode", path, ["local-postgres", "external-postgres"]); const sslmode = optionalStringField(input, "sslmode", path); if (sslmode !== undefined && sslmode !== "require") throw new Error(`${path}.sslmode must be require when set`); return { mode, provider: optionalStringField(input, "provider", path) ?? null, configRef: optionalStringField(input, "configRef", path) ?? null, database: optionalStringField(input, "database", path) ?? null, user: optionalStringField(input, "user", path) ?? null, sslmode: sslmode === undefined ? null : "require", secretSourceRef: optionalStringField(input, "secretSourceRef", path) ?? null, secretRef: parseSecretRef(recordField(input, "secretRef", path), `${path}.secretRef`), localPostgresExpectedAbsent: booleanField(input, "localPostgresExpectedAbsent", path), }; } function parseSecretRef(input: Record, path: string): { name: string; key: string } { return { name: stringField(input, "name", path), key: stringField(input, "key", path), }; } function parseNamespacedSecretRef(input: Record, path: string): AgentRunSecretRef { return { namespace: stringField(input, "namespace", path), name: stringField(input, "name", path), key: stringField(input, "key", path), }; } function parseGithubProxy(input: Record, path: string): { host: string; port: number } { return { host: stringField(input, "host", path), port: integerField(input, "port", path), }; } function optionalEnvPair(obj: Record, key: string, path: string): { name: string; value: string } | null { const value = obj[key]; if (value === undefined || value === null) return null; const record = asRecord(value, `${path}.${key}`); return { name: stringField(record, "name", `${path}.${key}`), value: stringField(record, "value", `${path}.${key}`), }; } function optionalEnvRecord(obj: Record, key: string, path: string): Readonly> { const value = obj[key]; if (value === undefined || value === null) return {}; return stringRecordField(asRecord(value, `${path}.${key}`), `${path}.${key}`); } function optionalStringArrayField(obj: Record, key: string, path: string): readonly string[] { const value = obj[key]; if (value === undefined || value === null) return []; return stringArrayField(obj, key, path); } function stringRecordField(obj: Record, path: string): Readonly> { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be a valid build arg name`); if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); result[key] = value.trim(); } return result; } function kubernetesLabelRecordField(obj: Record, path: string): Readonly> { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { validateKubernetesLabelKey(key, `${path}.${key}`); if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); const trimmed = value.trim(); validateKubernetesLabelValue(trimmed, `${path}.${key}`); result[key] = trimmed; } if (Object.keys(result).length === 0) throw new Error(`${path} must declare at least one label`); return result; } function enumField(obj: Record, key: string, path: string, values: readonly T[]): T { const value = stringField(obj, key, path); if (!values.includes(value as T)) throw new Error(`${path}.${key} must be one of ${values.join(", ")}`); return value as T; } function absolutePathField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (!value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`); return value; } function optionalAbsolutePathField(obj: Record, key: string, path: string): string | undefined { const value = obj[key]; if (value === undefined || value === null) return undefined; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`); const trimmed = value.trim(); if (!trimmed.startsWith("/") || trimmed.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`); return trimmed; } function relativePathField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be a relative path without ..`); return value; } function secretSourceRefField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (value.includes("..")) throw new Error(`${path}.${key} must not contain ..`); if (!value.startsWith("/") && value.startsWith(".")) throw new Error(`${path}.${key} must be absolute or relative without a leading dot`); return value; } function urlField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); try { const parsed = new URL(value); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("unsupported protocol"); } catch { throw new Error(`${path}.${key} must be an http(s) URL`); } return value; } function validateSimpleId(value: string, path: string): void { if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${value} must use a simple id`); } function validateKubernetesNamePrefix(value: string, path: string): void { if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${path} must be a Kubernetes name prefix`); } function validateKubernetesLabelKey(value: string, path: string): void { const parts = value.split("/"); const name = parts.length === 2 ? parts[1] : parts[0]; if (parts.length > 2 || !name || !/^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(name)) throw new Error(`${path} must be a Kubernetes label key`); } function validateKubernetesLabelValue(value: string, path: string): void { if (!/^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(value)) throw new Error(`${path} must be a Kubernetes label value`); }