import { n as childLogger } from "./dist-COZZnek9.js";
import { execFile, execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
//#region processors/vetra-cloud-environment/gc.ts
/**
* Orphan tenant-dir garbage collection.
*
* Env deletion is non-atomic: the read-model row is removed, then the gitops
* tenant dir. If the git push fails (contention/rebase conflict) the error was
* historically swallowed → the env doc is gone but the tenant dir lingers, so
* ArgoCD keeps deploying an unmanaged studio forever (a resource leak that
* pinned the cluster during the 2026-07 incident). This module is the
* self-healing safety net: periodically remove tenant dirs THIS processor owns
* that no longer have a backing env doc.
*/
/**
* First line stamped into every values file `generateValuesYaml` produces. The
* GC only ever removes dirs carrying this marker, so hand-written / infra / app
* tenants (academy, warm-eel, …) — which never have it — are structurally
* impossible to delete.
*/
const MANAGED_MARKER = "# managed-by: vetra-cloud-environment";
/** True if `yaml` was generated by this processor (safe to GC). */
function isManagedValues(yaml) {
	return yaml.trimStart().startsWith(MANAGED_MARKER);
}
/**
* Decide which managed tenant dirs are orphans.
*
* @param managedDirs  tenant dir names (== tenantId) that carry {@link MANAGED_MARKER}.
*                     The caller MUST pass only marker-managed dirs; infra/app
*                     tenants are filtered out before this point.
* @param liveTenantIds tenantIds that still have an env doc (read-model).
* @param opts.maxDeleteFraction circuit-breaker: if more than this fraction of
*   managed dirs would be removed, abort and remove nothing. Guards against a
*   failed/empty live-set query nuking the whole fleet. Default 0.5.
*/
function computeOrphanTenantDirs(managedDirs, liveTenantIds, opts = {}) {
	const toRemove = managedDirs.filter((d) => !liveTenantIds.has(d));
	const cap = opts.maxDeleteFraction ?? .5;
	const fraction = managedDirs.length === 0 ? 0 : toRemove.length / managedDirs.length;
	if (toRemove.length > 0 && fraction > cap) return {
		toRemove: [],
		skippedForSafety: true
	};
	return {
		toRemove,
		skippedForSafety: false
	};
}
//#endregion
//#region processors/vetra-cloud-environment/gitops.ts
const execFileAsync = promisify(execFile);
const logger = childLogger(["gitops"]);
var GitMutex = class {
	queue = [];
	locked = false;
	async acquire() {
		if (!this.locked) {
			this.locked = true;
			return;
		}
		return new Promise((resolve) => {
			this.queue.push(resolve);
		});
	}
	release() {
		const next = this.queue.shift();
		if (next) next();
		else this.locked = false;
	}
};
const gitMutex = new GitMutex();
const MAX_PUSH_RETRIES = 3;
const GIT_AUTHOR_NAME = process.env.GITOPS_AUTHOR_NAME ?? "vetra-cloud-processor";
const GIT_AUTHOR_EMAIL = process.env.GITOPS_AUTHOR_EMAIL ?? "noreply@vetra.io";
function getConfig() {
	return {
		repoUrl: getRepoUrl(),
		remote: process.env.GITOPS_REMOTE ?? "origin",
		branch: process.env.GITOPS_BRANCH ?? "main"
	};
}
/**
* Resolve the remote repo URL.
*
* Supports two env-var modes:
*  1. GITOPS_REPO_URL  — explicit URL (preferred in production)
*  2. GITOPS_REPO_PATH — local checkout; we read the remote URL from it
*     (backwards-compatible with existing .env files)
*
* When GITOPS_GITHUB_PAT is set, injects the token into https:// URLs so
* ephemeral clones can authenticate.
*/
function getRepoUrl() {
	let url = process.env.GITOPS_REPO_URL;
	if (!url) {
		const repoPath = process.env.GITOPS_REPO_PATH;
		if (!repoPath) throw new Error("Either GITOPS_REPO_URL or GITOPS_REPO_PATH environment variable is required");
		url = execFileSync("git", [
			"-C",
			repoPath,
			"remote",
			"get-url",
			process.env.GITOPS_REMOTE ?? "origin"
		], {
			encoding: "utf-8",
			timeout: 1e4
		}).trim();
	}
	const pat = process.env.GITOPS_GITHUB_PAT;
	if (pat && url.startsWith("https://")) {
		const parsed = new URL(url);
		parsed.username = pat;
		parsed.password = "x-oauth-basic";
		url = parsed.toString();
	}
	return url;
}
/**
* Redact credentials from a URL for safe logging.
*/
function redactUrl(url) {
	try {
		const parsed = new URL(url);
		if (parsed.username || parsed.password) {
			parsed.username = "***";
			parsed.password = "***";
			return parsed.toString();
		}
		return url;
	} catch {
		return url;
	}
}
async function git(args, cwd) {
	const safeArgs = args.map((arg) => arg.startsWith("https://") ? redactUrl(arg) : arg);
	logger.info(`Running: git ${safeArgs.join(" ")}`);
	const { stdout } = await execFileAsync("git", args, {
		cwd,
		encoding: "utf-8",
		timeout: 6e4
	});
	return stdout.trim();
}
function toKebabCase(name) {
	return name.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
}
/**
* Build a unique infrastructure tenant ID.
*
* Format: `<subdomain>-<shortDocId>`
*
* The subdomain alone can collide (250k namespace), so we append the first 8
* hex chars of the documentId to guarantee uniqueness.
*/
function getTenantId(subdomain, documentId) {
	return `${subdomain}-${documentId.replace(/-/g, "").slice(0, 8)}`;
}
/**
* Escape a string for safe interpolation into a YAML value.
* Wraps in double quotes and escapes inner backslashes, double quotes,
* and newlines.
*/
function yamlQuote(value) {
	return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n")}"`;
}
/**
* cert-manager ClusterIssuer for per-env ingress certs. Defaults to
* `letsencrypt-prod`; set `TENANT_CLUSTER_ISSUER=zerossl-prod` (on the
* switchboard) to move per-env issuance onto ZeroSSL's independent budget so
* warm-pool churn no longer exhausts the LE 50-certs/week cap on `vetra.io`.
* Read lazily so env changes apply without a process restart (and tests can set it).
*/
function tenantClusterIssuer() {
	return process.env.TENANT_CLUSTER_ISSUER ?? "letsencrypt-prod";
}
function generateCustomDomainIngress(service, customDomain) {
	const host = `${service}.${customDomain}`;
	const secretSuffix = customDomain.replace(/\./g, "-");
	return `
  additionalIngresses:
    - enabled: true
      className: traefik
      host: ${yamlQuote(host)}
      tls:
        enabled: true
        secretName: ${service}-custom-${secretSuffix}-tls
      annotations:
        cert-manager.io/cluster-issuer: ${tenantClusterIssuer()}`;
}
/**
* Narrow `state.apexService` to the service types the processor actually
* renders apex routing for. Returns null when no apex is requested or when
* the pinned type is one we don't emit (e.g. FUSION — no chart ingress).
*/
function readApexService(state) {
	const v = state.apexService;
	if (v === "CONNECT" || v === "SWITCHBOARD" || v === "FUSION") return v;
	return null;
}
/**
* Per-service TLS secret name. When a service is routed at the apex of a
* custom domain, the cert covers that host; otherwise it covers the
* generic `<service>.<subdomain>.vetra.io` host.
*/
function tlsSecretName(service, subdomain, apexDomain) {
	if (apexDomain) return `${service}-${apexDomain.replace(/\./g, "-")}-tls`;
	return `${service}-${subdomain}-vetra-io-tls`;
}
/**
* A service's generic `.vetra.io` ingress host, as a SINGLE DNS label so it is
* covered by the cluster `*.vetra.io` wildcard cert (served via Traefik's
* TLSStore/default — see infrastructure/wildcard-tls):
*   apex  -> `<subdomain>.<baseDomain>`              (e.g. tall-duck-ab12.vetra.io)
*   other -> `<subdomain>-<prefix>.<baseDomain>`     (e.g. tall-duck-ab12-connect.vetra.io)
*/
function resolveGenericHost(subdomain, prefix, isApex, baseDomain) {
	return isApex ? `${subdomain}.${baseDomain}` : `${subdomain}-${prefix}.${baseDomain}`;
}
/**
* The service TYPE served at the env apex (`<subdomain>.vetra.io`). Explicit
* `apexService` wins; otherwise a lone enabled service auto-claims the apex (so a
* single-CLINT Studio gets the bare subdomain). Null when ambiguous (multiple
* enabled services, none pinned).
*/
function effectiveApexType(state) {
	if (state.apexService) return state.apexService;
	const enabled = (state.services ?? []).filter((s) => s.enabled);
	return enabled.length === 1 ? enabled[0].type : null;
}
/**
* Whether a given service type is served at the apex. The bare apex host can
* belong to only ONE service, so the type must have exactly one enabled instance
* (guards multi-agent CLINT envs from colliding on the bare host).
*/
function isTypeAtApex(state, type) {
	if (effectiveApexType(state) !== type) return false;
	return (state.services ?? []).filter((s) => s.enabled && s.type === type).length === 1;
}
/** Guard: a single DNS label must be ≤63 chars (RFC 1035). */
function assertHostLabelLength(host) {
	const firstLabel = host.split(".")[0] ?? "";
	if (firstLabel.length > 63) throw new Error(`Ingress host label exceeds 63 chars: "${firstLabel}" (${firstLabel.length}). Use a shorter service prefix.`);
}
const CLINT_RUNTIME_IMAGE = process.env.CLINT_RUNTIME_IMAGE_REPOSITORY ?? "cr.vetra.io/powerhouse-inc-powerhouse/clint-runtime";
const CLINT_AGENT_IMAGE_BASE = process.env.CLINT_AGENT_IMAGE_BASE ?? CLINT_RUNTIME_IMAGE.replace(/\/[^/]+$/, "");
/** Container repo paths can't hold "@" and a scoped "/" would add an unintended
*  path segment: strip a leading "@" and replace "/" with "-". Mirrors the
*  sanitization in clint-image-builder's image-ref.ts. */
function sanitizeAgentPackageName(name) {
	return name.replace(/^@/, "").replace(/\//g, "-");
}
function clintAgentImageRepository(pkgName) {
	return `${CLINT_AGENT_IMAGE_BASE}/clint-agent/${sanitizeAgentPackageName(pkgName)}`;
}
/**
* Resolve a package "version" that may be a dist-tag (latest/dev/staging) or
* null into a concrete published version. The clint-image-builder only pushes
* a prebuilt image per CONCRETE version, so the emitted image tag must be the
* concrete version — otherwise a `latest`-pinned agent would reference a
* `clint-agent/<pkg>:latest` tag that is never built (ImagePullBackOff).
* Concrete versions pass through unchanged; falls back to the input (or
* "latest") if the registry is unreachable.
*/
async function resolveConcreteVersion(registry, pkgName, version) {
	const requested = version ?? "latest";
	try {
		const base = registry.replace(/\/$/, "");
		const res = await fetch(`${base}/${pkgName}`);
		if (res.ok) {
			const concrete = ((await res.json())["dist-tags"] ?? {})[requested];
			if (concrete) return concrete;
		}
	} catch (e) {
		logger.warn(`clint version resolution failed for ${pkgName}@${requested}: ${String(e)}`);
	}
	return requested;
}
/**
* App-services map (Switchboard/Connect/Fusion). Calibrated so S equals
* today's powerhouse-chart default — preventing regression on next sync for
* any environment that hasn't yet picked a size.
*/
const APP_RESOURCE_MAP = {
	VETRA_AGENT_S: {
		requests: {
			cpu: "250m",
			memory: "512Mi"
		},
		limits: {
			cpu: "1",
			memory: "1Gi"
		},
		nodeMaxOldSpaceMb: 768
	},
	VETRA_AGENT_M: {
		requests: {
			cpu: "500m",
			memory: "1Gi"
		},
		limits: {
			cpu: "2",
			memory: "2Gi"
		},
		nodeMaxOldSpaceMb: 1536
	},
	VETRA_AGENT_L: {
		requests: {
			cpu: "1",
			memory: "2Gi"
		},
		limits: {
			cpu: "4",
			memory: "4Gi"
		},
		nodeMaxOldSpaceMb: 3072
	},
	VETRA_AGENT_XL: {
		requests: {
			cpu: "2",
			memory: "4Gi"
		},
		limits: {
			cpu: "6",
			memory: "8Gi"
		},
		nodeMaxOldSpaceMb: 6144
	},
	VETRA_AGENT_XXL: {
		requests: {
			cpu: "4",
			memory: "8Gi"
		},
		limits: {
			cpu: "8",
			memory: "16Gi"
		},
		nodeMaxOldSpaceMb: 12288
	}
};
/** Persistent volume size for a clint agent's workdir (.ph: reactor store,
*  agent memory, identity). PGlite + documents are small; 2Gi is ample for a
*  studio. The chart provisions a per-agent PVC when this is emitted. */
const CLINT_AGENT_STORAGE = "25Gi";
/** CLINT agents — small-footprint runtime. nodeMaxOldSpaceMb is unused for
*  CLINT (the runtime image isn't necessarily Node) but kept for type
*  symmetry with APP_RESOURCE_MAP. */
const CLINT_RESOURCE_MAP = {
	VETRA_AGENT_S: {
		requests: {
			cpu: "100m",
			memory: "256Mi"
		},
		limits: {
			cpu: "500m",
			memory: "512Mi"
		},
		nodeMaxOldSpaceMb: 384
	},
	VETRA_AGENT_M: {
		requests: {
			cpu: "250m",
			memory: "512Mi"
		},
		limits: {
			cpu: "1",
			memory: "1Gi"
		},
		nodeMaxOldSpaceMb: 768
	},
	VETRA_AGENT_L: {
		requests: {
			cpu: "500m",
			memory: "1Gi"
		},
		limits: {
			cpu: "2",
			memory: "2Gi"
		},
		nodeMaxOldSpaceMb: 1536
	},
	VETRA_AGENT_XL: {
		requests: {
			cpu: "1",
			memory: "2Gi"
		},
		limits: {
			cpu: "4",
			memory: "4Gi"
		},
		nodeMaxOldSpaceMb: 3072
	},
	VETRA_AGENT_XXL: {
		requests: {
			cpu: "750m",
			memory: "3Gi"
		},
		limits: {
			cpu: "4",
			memory: "6Gi"
		},
		nodeMaxOldSpaceMb: 4608
	}
};
/**
* Connect is a static asset server (serves the built SPA). Measured 7-day peak
* across the fleet: ~3m CPU / 35Mi memory, FLAT across all studios and
* insensitive to studio activity — event load (e.g. DWeb Camp, ~651 studios)
* only adds more connect pods, never more per-pod work. So connect gets its own
* tiny, size-independent map instead of sharing the heavy APP_RESOURCE_MAP
* (which reserved up to 4 cores / 8Gi for a workload that never exceeds 35Mi).
* Requests are ~2x the observed max memory and generous on CPU; limits keep a
* large burst ceiling (Node GC / page-load spikes) so nothing ever throttles.
* Size selection is intentionally ignored for connect — every tier is identical.
*/
const CONNECT_RESOURCES = {
	requests: {
		cpu: "25m",
		memory: "64Mi"
	},
	limits: {
		cpu: "500m",
		memory: "512Mi"
	},
	nodeMaxOldSpaceMb: 384
};
/**
* Resolve the effective t-shirt size for a service. Reads the top-level
* `selectedRessource` first, falls back to the legacy CLINT
* `config.selectedRessource` (one-release transition), then to S.
*/
function readServiceSize(svc) {
	if (!svc) return "VETRA_AGENT_S";
	return svc.selectedRessource ?? svc.config?.selectedRessource ?? "VETRA_AGENT_S";
}
/**
* Heuristic for classifying legacy env entries (those without an
* explicit isSecret flag — pre-isSecret schema or pre-UI-update data).
* Matches names commonly used for sensitive values; conservative on
* purpose, only the well-known suffixes. Anything not matching is
* treated as a plain env var. Explicit isSecret on the entry always
* wins over this heuristic.
*/
const LEGACY_SECRET_NAME_PATTERN = /(_API_KEY|_SECRET|_PASSWORD|_TOKEN|_PRIVATE_KEY)$/;
function classifyEnv(e) {
	if (e.isSecret === true) return "secret";
	if (e.isSecret === false) return "plain";
	return LEGACY_SECRET_NAME_PATTERN.test(e.name) ? "secret" : "plain";
}
function switchboardAuthEnv(owner) {
	const entries = [
		{
			name: "AUTH_ENABLED",
			value: "true"
		},
		{
			name: "DOCUMENT_PERMISSIONS_ENABLED",
			value: "true"
		},
		{
			name: "DEFAULT_PROTECTION",
			value: "true"
		},
		{
			name: "SKIP_CREDENTIAL_VERIFICATION",
			value: "true"
		},
		{
			name: "ALLOW_INSECURE_SKIP_CREDENTIAL_VERIFICATION",
			value: "true"
		}
	];
	if (owner) entries.push({
		name: "ADMINS",
		value: owner.toLowerCase()
	});
	return entries;
}
async function generateClintBlock(state, documentId, subdomain, baseDomain, tenantId, secretsService) {
	const clintServices = (state.services ?? []).filter((s) => s.type === "CLINT" && s.enabled);
	if (clintServices.length === 0) return `clint:\n  enabled: false\n  agents: []`;
	const lines = [
		`clint:`,
		`  enabled: true`,
		`  certClusterIssuer: ${yamlQuote(tenantClusterIssuer())}`,
		`  agents:`
	];
	for (const svc of clintServices) {
		const cfg = svc.config;
		const pkg = cfg?.package;
		if (!pkg) {
			logger.warn(`CLINT service "${svc.prefix}" has no config.package — skipping in YAML emit`);
			continue;
		}
		const resources = CLINT_RESOURCE_MAP[readServiceSize(svc)];
		const command = cfg?.serviceCommand ?? pkg.name;
		const envVars = cfg?.env ?? [];
		const registry = pkg.registry || state.defaultPackageRegistry || "https://registry.dev.vetra.io/";
		const agentVersion = await resolveConcreteVersion(registry, pkg.name, pkg.version);
		lines.push(`    - name: ${yamlQuote(svc.prefix)}`);
		lines.push(`      image:`);
		lines.push(`        repository: ${yamlQuote(clintAgentImageRepository(pkg.name))}`);
		lines.push(`        tag: ${yamlQuote(agentVersion)}`);
		lines.push(`        pullPolicy: IfNotPresent`);
		lines.push(`      package: ${yamlQuote(pkg.name)}`);
		lines.push(`      version: ${yamlQuote(agentVersion)}`);
		lines.push(`      registry: ${yamlQuote(registry)}`);
		lines.push(`      command: ${yamlQuote(command)}`);
		lines.push(`      storage: ${yamlQuote(CLINT_AGENT_STORAGE)}`);
		lines.push(`      resources:`);
		lines.push(`        requests: { cpu: ${yamlQuote(resources.requests.cpu)}, memory: ${yamlQuote(resources.requests.memory)} }`);
		lines.push(`        limits: { cpu: ${yamlQuote(resources.limits.cpu)}, memory: ${yamlQuote(resources.limits.memory)} }`);
		lines.push(`      env:`);
		lines.push(`        - { name: "NODE_OPTIONS", value: "--max-old-space-size=${resources.nodeMaxOldSpaceMb}" }`);
		if (pkg.name === "vetra-cli") for (const e of switchboardAuthEnv(state.owner)) lines.push(`        - { name: ${yamlQuote(e.name)}, value: ${yamlQuote(e.value)} }`);
		for (const e of envVars) {
			const kind = classifyEnv(e);
			const value = e.value ?? "";
			if (kind === "secret") {
				if (secretsService && value !== "") try {
					await secretsService.setSecret(tenantId, e.name, value);
				} catch (err) {
					logger.warn(`Failed to upsert secret ${e.name} for tenant ${tenantId}: ${String(err)} — skipping inline emit to avoid plaintext leak`);
				}
				else if (!secretsService) logger.warn(`[secrets] No SecretsService available — secret entry ${e.name} for tenant ${tenantId} cannot be routed; SKIPPING (will not be available to the agent until OPENBAO_ADDR is configured)`);
				continue;
			}
			if (secretsService) try {
				await secretsService.setEnvVar(tenantId, e.name, value);
				continue;
			} catch (err) {
				logger.warn(`Failed to upsert env var ${e.name} for tenant ${tenantId}: ${String(err)} — falling back to inline emit`);
			}
			lines.push(`        - { name: ${yamlQuote(e.name)}, value: ${yamlQuote(value)} }`);
		}
		lines.push(`      ingress:`);
		lines.push(`        enabled: true`);
		const clintHost = resolveGenericHost(subdomain, svc.prefix, isTypeAtApex(state, "CLINT"), baseDomain);
		assertHostLabelLength(clintHost);
		lines.push(`        host: ${yamlQuote(clintHost)}`);
	}
	return lines.join("\n");
}
function defaultAppImageTag() {
	return process.env.DEFAULT_APP_IMAGE_TAG ?? "dev";
}
async function generateValuesYaml(db, state, documentId, secretsService = null) {
	const subdomain = state.genericSubdomain;
	const tenantId = getTenantId(subdomain, documentId);
	const name = toKebabCase(state.label ?? "unnamed");
	const switchboardEnabled = state.services.some((s) => s.type === "SWITCHBOARD" && s.enabled);
	const connectEnabled = state.services.some((s) => s.type === "CONNECT" && s.enabled);
	const clintEnabled = state.services.some((s) => s.type === "CLINT" && s.enabled);
	const tenantSecretsControllerEnabled = switchboardEnabled || clintEnabled;
	const switchboardService = state.services.find((s) => s.type === "SWITCHBOARD");
	const connectService = state.services.find((s) => s.type === "CONNECT");
	const switchboardTag = switchboardService?.version ?? defaultAppImageTag();
	const connectTag = connectService?.version ?? defaultAppImageTag();
	const switchboardResources = APP_RESOURCE_MAP[readServiceSize(switchboardService)];
	const connectResources = CONNECT_RESOURCES;
	const disabled = new Set([
		"TERMINATING",
		"DESTROYED",
		"ARCHIVED",
		"STOPPED"
	]).has(state.status);
	const databaseEnabled = switchboardEnabled;
	const phPackages = state.packages?.map((p) => `${p.name}@${p.version ?? "latest"}`).join(",") ?? "";
	const customDomain = state.customDomain?.enabled ? state.customDomain.domain ?? null : null;
	const apexService = readApexService(state);
	const switchboardApexDomain = customDomain && apexService === "SWITCHBOARD" && switchboardEnabled ? customDomain : null;
	const connectApexDomain = customDomain && apexService === "CONNECT" && connectEnabled ? customDomain : null;
	const switchboardCustomIngress = customDomain && switchboardEnabled && !switchboardApexDomain ? generateCustomDomainIngress("switchboard", customDomain) : "";
	const connectCustomIngress = customDomain && connectEnabled && !connectApexDomain ? generateCustomDomainIngress("connect", customDomain) : "";
	const switchboardGenericHost = resolveGenericHost(subdomain, switchboardService?.prefix ?? "switchboard", isTypeAtApex(state, "SWITCHBOARD"), state.genericBaseDomain ?? "vetra.io");
	const connectGenericHost = resolveGenericHost(subdomain, connectService?.prefix ?? "connect", isTypeAtApex(state, "CONNECT"), state.genericBaseDomain ?? "vetra.io");
	if (switchboardEnabled && !switchboardApexDomain) assertHostLabelLength(switchboardGenericHost);
	if (connectEnabled && !connectApexDomain) assertHostLabelLength(connectGenericHost);
	const switchboardHostLine = switchboardApexDomain ? `\n    host: ${yamlQuote(switchboardApexDomain)}` : `\n    host: ${yamlQuote(switchboardGenericHost)}`;
	const connectHostLine = connectApexDomain ? `\n    host: ${yamlQuote(connectApexDomain)}` : `\n    host: ${yamlQuote(connectGenericHost)}`;
	const switchboardTlsSecret = tlsSecretName("switchboard", subdomain, switchboardApexDomain);
	const connectTlsSecret = tlsSecretName("connect", subdomain, connectApexDomain);
	const tenantName = yamlQuote(state.label ?? name);
	const dbName = tenantId.replace(/-/g, "_");
	const clintBlock = await generateClintBlock(state, documentId, subdomain, state.genericBaseDomain ?? "vetra.io", tenantId, secretsService);
	const runtimeConfig = state.runtimeConfig;
	let connectConfigPayload = {};
	if (typeof runtimeConfig === "string" && runtimeConfig.trim() !== "") try {
		const parsed = JSON.parse(runtimeConfig);
		if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) connectConfigPayload = { ...parsed };
	} catch {}
	delete connectConfigPayload.packages;
	const connectPackages = (state.packages ?? []).map((p) => ({
		packageName: p.name,
		...p.version ? { version: p.version } : {}
	}));
	if (connectPackages.length > 0) {
		connectConfigPayload.packages = connectPackages;
		if (typeof connectConfigPayload.packageRegistryUrl !== "string") connectConfigPayload.packageRegistryUrl = state.defaultPackageRegistry || "https://registry.dev.vetra.io";
	}
	const connectConfigEnvLine = Object.keys(connectConfigPayload).length > 0 ? `\n    PH_CONNECT_CONFIG_JSON: ${yamlQuote(JSON.stringify(connectConfigPayload))}` : "";
	return `${MANAGED_MARKER}
${tenantSecretsControllerEnabled ? `# Route tenant env vars & secrets through the standalone vetra-secrets-controller
# (Postgres-backed; transit-encrypted secrets; auto rolling restart via Reloader).
tenantSecretsController:
  enabled: true

` : ""}global:
  disabled: ${disabled}
  subdomain: ${yamlQuote(subdomain)}
  wildcardTls: ${!customDomain}
  imagePullSecrets:
    enabled: true
    name: harbor-credentials
    useExisting: true
database:
  cnpg:
    enabled: ${databaseEnabled}
    name: ${tenantId}-pg
    instances: 1
    storageClass: longhorn-studio
    storageSize: 50Gi
    postgresql:
      maxConnections: "600"
      sharedBuffers: 512MB
      effectiveCacheSize: 2GB
      workMem: 32MB
    pooler:
      enabled: true
      instances: 1
      poolMode: transaction
      defaultPoolSize: 50
      maxClientConnections: 400
    backup:
      enabled: true
      destinationPath: s3://powerhouse-cnpg-backups/${tenantId}/
      endpointURL: https://hel1.your-objectstorage.com
      credentialsSecret: s3-credentials
      retentionPolicy: 180d
      useExistingSecret: true
      scheduledBackup:
        enabled: true
        schedule: 0 2 * * *
        immediate: true
    resources:
      # Studio DBs are tiny reactor doc stores: measured p90 ~106m CPU / ~842Mi
      # mem. The old flat 2-core / 2Gi *request* over-reserved ~20-40x and was
      # the single biggest CPU reservation in the cluster (~60 cores across ~30
      # studio DBs vs ~1.5 cores actually used). Cut requests to a real-usage
      # floor; limits stay 8 cores / 8Gi so the DB still bursts freely under
      # event load — this reclaims the idle scheduling floor only, no OOM or
      # throttle risk. (Right-sized 2026-08-05; trimmed again 2026-08-11 to
      # 512Mi/100m after live steady-state measured median ~161Mi/40m, p90
      # ~178Mi over 34 DBs — 1Gi/250m was still ~5-6x over-reserved.)
      requests:
        memory: 512Mi
        cpu: "100m"
      limits:
        memory: 8Gi
        cpu: "8"
    bootstrap:
      database: ${dbName}_db
      owner: ${dbName}_user
switchboard:
  enabled: ${switchboardEnabled}
  gitops:
    enabled: false
  name: switchboard
  replicaCount: 1
  image:
    repository: cr.vetra.io/powerhouse-inc-powerhouse/switchboard
    tag: ${switchboardTag}
    pullPolicy: IfNotPresent
  service:
    type: ClusterIP
    port: 80
    targetPort: 3000
  ingress:
    enabled: true
    className: traefik${switchboardHostLine}
    tls:
      enabled: true
      secretName: ${switchboardTlsSecret}
    annotations:
      cert-manager.io/cluster-issuer: ${tenantClusterIssuer()}${switchboardCustomIngress}
  env:
    PORT: "3000"
    NODE_ENV: production
    NODE_OPTIONS: ${yamlQuote(`--max-old-space-size=${switchboardResources.nodeMaxOldSpaceMb}`)}
    PH_REGISTRY_URL: ${yamlQuote(state.defaultPackageRegistry || "https://registry.dev.vetra.io")}
    PH_REGISTRY_PACKAGES: ${yamlQuote(phPackages)}
    OPENBAO_ADDR: https://openbao.vetra.io
    PROMETHEUS_URL: http://prometheus-server.monitoring.svc
    LOKI_URL: http://loki.monitoring.svc:3100
  envConfigMap:
    TENANT_ID: ${tenantId}
    TENANT_NAME: ${tenantName}
  envFrom:
    - configMapRef:
        name: ${tenantId}-env
        optional: true
    - secretRef:
        name: ${tenantId}-secrets
        optional: true
  livenessProbe:
    enabled: true
    exec:
      command:
        - /bin/sh
        - -c
        - |
          wget --post-data='{"query":"{__typename}"}' --header='Content-Type: application/json' -qO- http://localhost:3000/graphql
    initialDelaySeconds: 120
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 6
  readinessProbe:
    enabled: true
    exec:
      command:
        - /bin/sh
        - -c
        - |
          wget --post-data='{"query":"{__typename}"}' --header='Content-Type: application/json' -qO- http://localhost:3000/graphql
    initialDelaySeconds: 15
    periodSeconds: 5
    timeoutSeconds: 5
    failureThreshold: 12
  securityContext:
    runAsNonRoot: false
    runAsUser: 0
    fsGroup: 0
  resources:
    requests:
      cpu: ${yamlQuote(switchboardResources.requests.cpu)}
      memory: ${yamlQuote(switchboardResources.requests.memory)}
    limits:
      cpu: ${yamlQuote(switchboardResources.limits.cpu)}
      memory: ${yamlQuote(switchboardResources.limits.memory)}
  autoscaling:
    enabled: false
connect:
  enabled: ${connectEnabled}
  name: connect
  replicaCount: 1
  image:
    repository: cr.vetra.io/powerhouse-inc-powerhouse/connect
    tag: ${connectTag}
    pullPolicy: IfNotPresent
  service:
    type: ClusterIP
    port: 80
    targetPort: 3001
  ingress:
    enabled: true
    className: traefik${connectHostLine}
    tls:
      enabled: true
      secretName: ${connectTlsSecret}
    annotations:
      cert-manager.io/cluster-issuer: ${tenantClusterIssuer()}${connectCustomIngress}
  env:
    PORT: "3001"
    NODE_ENV: production
    NODE_OPTIONS: ${yamlQuote(`--max-old-space-size=${connectResources.nodeMaxOldSpaceMb}`)}
    PH_REGISTRY_URL: ${yamlQuote(state.defaultPackageRegistry || "https://registry.dev.vetra.io")}
    PH_REGISTRY_PACKAGES: ${yamlQuote(phPackages)}${connectConfigEnvLine}
  envConfigMap:
    TENANT_ID: ${tenantId}
    TENANT_NAME: ${tenantName}
  envFrom:
    - configMapRef:
        name: ${tenantId}-env
        optional: true
    - secretRef:
        name: ${tenantId}-secrets
        optional: true
  livenessProbe:
    enabled: true
    httpGet:
      path: /health
      port: 3001
    initialDelaySeconds: 120
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 6
  readinessProbe:
    enabled: true
    httpGet:
      path: /health
      port: 3001
    initialDelaySeconds: 15
    periodSeconds: 5
    timeoutSeconds: 5
    failureThreshold: 12
  securityContext:
    runAsNonRoot: false
    runAsUser: 0
    fsGroup: 0
  resources:
    requests:
      cpu: ${yamlQuote(connectResources.requests.cpu)}
      memory: ${yamlQuote(connectResources.requests.memory)}
    limits:
      cpu: ${yamlQuote(connectResources.limits.cpu)}
      memory: ${yamlQuote(connectResources.limits.memory)}
  autoscaling:
    enabled: false
podDisruptionBudget:
  enabled: true
  minAvailable: 1
serviceMonitor:
  enabled: true
  interval: 30s
  path: /metrics
glitchtip:
  enabled: false
  environment: ${tenantId}
sentry:
  enabled: true
  environment: ${tenantId}
networkPolicy:
  enabled: false
${clintBlock}
`;
}
/**
* Sync an environment's values to the gitops repo.
*
* Reuses a single persistent working clone, refreshed to a pristine
* origin/branch (fetch + reset --hard + clean) before each render, then writes
* the values file, commits, and pushes. Reusing the clone — instead of a fresh
* clone per sync — is what keeps a claim's gitops re-render off the multi-second
* critical path.
*
* A mutex serializes syncs within the same process so the shared working tree
* is never touched concurrently. Cross-process / cross-pod push races are
* handled by pull-rebase-retry.
*/
async function syncEnvironment(db, state, documentId, secretsService = null) {
	if (!state.label) {
		logger.warn("Environment has no label, skipping gitops sync");
		return;
	}
	if (!state.genericSubdomain) {
		logger.warn(`Environment "${state.label}" has no subdomain, skipping gitops sync`);
		return;
	}
	await gitMutex.acquire();
	try {
		await syncEnvironmentEphemeral(db, state, documentId, secretsService);
	} finally {
		gitMutex.release();
	}
}
/**
* Remove a tenant's directory from the gitops repo.
*
* Called when an environment document is deleted. Removes the tenant
* directory, commits, and pushes so ArgoCD/Flux tears down the deployment.
*/
async function deleteEnvironmentFromGitops(tenantId) {
	await gitMutex.acquire();
	try {
		await withWorkingClone(async (cloneDir, config) => {
			if (!existsSync(join(cloneDir, "tenants", tenantId))) {
				logger.info(`Tenant directory "tenants/${tenantId}" does not exist in gitops repo, nothing to remove`);
				return;
			}
			await git([
				"rm",
				"-r",
				`tenants/${tenantId}`
			], cloneDir);
			const commitMsg = `chore(${tenantId}): remove tenant — environment deleted`;
			logger.info(`Committing: ${commitMsg}`);
			await git([
				"commit",
				"-m",
				commitMsg
			], cloneDir);
			await pushWithRetry(cloneDir, config);
			logger.info(`Successfully removed tenant "${tenantId}" from gitops repo`);
		});
	} finally {
		gitMutex.release();
	}
}
/** Tenant dirs in the working clone that THIS processor owns (marker present). */
function listManagedTenantDirs(cloneDir) {
	const tenantsDir = join(cloneDir, "tenants");
	if (!existsSync(tenantsDir)) return [];
	return readdirSync(tenantsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).filter((name) => {
		const vp = join(tenantsDir, name, "powerhouse-values.yaml");
		if (!existsSync(vp)) return false;
		try {
			return isManagedValues(readFileSync(vp, "utf-8"));
		} catch {
			return false;
		}
	});
}
/**
* Garbage-collect orphaned tenant dirs — managed (marker-stamped) dirs with no
* backing env doc. This is the self-healing net for the non-atomic delete: a
* gitops-delete that fails leaves an orphan, and this reclaims it on the next
* tick. `liveTenantIds` MUST be the COMPLETE set of tenantIds that still have an
* env doc — a partial/empty set trips the circuit-breaker in
* {@link computeOrphanTenantDirs} and removes nothing (safe failure mode).
* Only marker-managed dirs are ever considered, so infra/app tenants
* (academy, warm-eel, …) can never be touched. Returns the tenantIds removed.
*/
async function gcOrphanTenantDirs(liveTenantIds) {
	await gitMutex.acquire();
	try {
		let removed = [];
		await withWorkingClone(async (cloneDir, config) => {
			const managed = listManagedTenantDirs(cloneDir);
			const plan = computeOrphanTenantDirs(managed, liveTenantIds);
			if (plan.skippedForSafety) {
				logger.warn(`[gc] circuit-breaker tripped: ${managed.length} managed dirs, ${liveTenantIds.size} live env docs — >50% would be removed; skipping`);
				return;
			}
			if (plan.toRemove.length === 0) return;
			for (const tenantId of plan.toRemove) await git([
				"rm",
				"-r",
				`tenants/${tenantId}`
			], cloneDir);
			await git([
				"commit",
				"-m",
				`chore(gc): remove ${plan.toRemove.length} orphaned tenant dir(s) — no backing env doc`
			], cloneDir);
			await pushWithRetry(cloneDir, config);
			removed = plan.toRemove;
			logger.info(`[gc] removed ${removed.length} orphaned tenant dir(s): ${removed.join(", ")}`);
		});
		return removed;
	} finally {
		gitMutex.release();
	}
}
async function syncEnvironmentEphemeral(db, state, documentId, secretsService) {
	const subdomain = state.genericSubdomain;
	const tenantId = getTenantId(subdomain, documentId);
	logger.info(`Syncing environment "${tenantId}" (subdomain=${subdomain})`);
	logger.info(`Environment state: status=${state.status}, services=[${state.services?.map((s) => `${s.type}:${s.enabled}`).join(", ")}], packages=[${state.packages?.map((p) => `${p.name}@${p.version}`).join(", ") ?? ""}], subdomain=${subdomain}, customDomain=${state.customDomain?.domain ?? "unset"}`);
	await withWorkingClone(async (cloneDir, config) => {
		const tenantDir = join(cloneDir, "tenants", tenantId);
		mkdirSync(tenantDir, { recursive: true });
		const valuesPath = join(tenantDir, "powerhouse-values.yaml");
		writeFileSync(valuesPath, await generateValuesYaml(db, state, documentId, secretsService), "utf-8");
		logger.info(`Wrote values file to ${valuesPath}`);
		await git(["add", `tenants/${tenantId}/powerhouse-values.yaml`], cloneDir);
		const hasChanges = await git([
			"diff",
			"--cached",
			"--name-only"
		], cloneDir);
		if (!hasChanges) {
			logger.info("No changes detected in values file, skipping commit");
			return;
		}
		logger.info(`Changes detected: ${hasChanges}`);
		const commitMsg = `chore(${tenantId}): ${new Set([
			"TERMINATING",
			"DESTROYED",
			"ARCHIVED",
			"STOPPED"
		]).has(state.status) ? "disable" : "update"} tenant — synced from vetra-cloud-environment`;
		logger.info(`Committing: ${commitMsg}`);
		await git([
			"commit",
			"-m",
			commitMsg
		], cloneDir);
		await pushWithRetry(cloneDir, config);
		logger.info(`Successfully synced and pushed environment "${tenantId}"`);
	});
}
/** Resolve the persistent gitops working-clone path (read lazily so tests and
*  config changes take effect without a process restart). */
function getWorkDir() {
	return process.env.GITOPS_WORK_DIR ?? join(tmpdir(), "vetra-gitops-work");
}
/**
* Ensure a pristine working clone at origin/branch and return its path.
*
* First use (or a missing work dir) does a full clone. Every subsequent call
* refreshes the SAME directory with `fetch` + `reset --hard` + `clean -fd` —
* far cheaper than re-cloning on every sync, which is what dominated the
* post-claim latency. The gitMutex serializes callers within the process, so
* reusing one working tree is safe.
*/
async function ensureWorkingClone(config) {
	const dir = getWorkDir();
	if (existsSync(join(dir, ".git"))) {
		logger.info(`Refreshing persistent gitops clone: ${dir}`);
		await git([
			"fetch",
			config.remote,
			config.branch
		], dir);
		await git([
			"reset",
			"--hard",
			`${config.remote}/${config.branch}`
		], dir);
		await git(["clean", "-fd"], dir);
		return dir;
	}
	logger.info(`Creating persistent gitops clone: ${dir}`);
	rmSync(dir, {
		recursive: true,
		force: true
	});
	mkdirSync(dir, { recursive: true });
	await git([
		"clone",
		"--branch",
		config.branch,
		config.repoUrl,
		"."
	], dir);
	await git([
		"config",
		"user.name",
		GIT_AUTHOR_NAME
	], dir);
	await git([
		"config",
		"user.email",
		GIT_AUTHOR_EMAIL
	], dir);
	return dir;
}
async function withWorkingClone(fn) {
	const config = getConfig();
	let cloneDir;
	try {
		cloneDir = await ensureWorkingClone(config);
	} catch (error) {
		logger.warn(`Working clone refresh failed, recreating from scratch: ${String(error)}`);
		rmSync(getWorkDir(), {
			recursive: true,
			force: true
		});
		cloneDir = await ensureWorkingClone(config);
	}
	await fn(cloneDir, config);
}
async function pushWithRetry(cloneDir, config) {
	for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) try {
		logger.info(`Pushing to ${config.remote}/${config.branch} (attempt ${attempt})...`);
		await git([
			"push",
			config.remote,
			config.branch
		], cloneDir);
		return;
	} catch (error) {
		if (attempt === MAX_PUSH_RETRIES) throw error;
		logger.warn(`Push attempt ${attempt} failed, pulling with rebase and retrying: ${String(error)}`);
		await git([
			"fetch",
			config.remote,
			config.branch
		], cloneDir);
		try {
			await git(["rebase", `${config.remote}/${config.branch}`], cloneDir);
		} catch (rebaseError) {
			logger.warn(`Rebase failed with conflict, aborting: ${String(rebaseError)}`);
			await git(["rebase", "--abort"], cloneDir);
			throw rebaseError;
		}
	}
}
//#endregion
//#region processors/vetra-cloud-environment/cleanup.ts
/**
* Remove an environment's read-model row by document id.
*
* Shared between the processor's DELETE_NODE drive-operation path and the
* subgraph's `onDocumentDeleted` subscription (which fires for
* `deleteDocument(identifier)` calls that never emit a DELETE_NODE the
* processor sees). Both paths must clean up the same row so the UI never shows
* a "phantom studio" whose backing document is gone.
*
* Selects (id, name, subdomain), deletes the row, and returns
* `{ subdomain, name }` so the caller can derive the gitops tenant id and tear
* down the tenant directory. Returns `null` (a safe no-op) when no row exists
* for the id — which is the common case when this is invoked for a
* non-environment document id.
*/
async function removeEnvironmentRecord(db, documentId) {
	const environment = await db.selectFrom("environments").select([
		"id",
		"name",
		"subdomain"
	]).where("id", "=", documentId).executeTakeFirst();
	if (!environment) return null;
	await db.deleteFrom("environments").where("id", "=", documentId).execute();
	return {
		subdomain: environment.subdomain,
		name: environment.name
	};
}
//#endregion
export { syncEnvironment as a, getTenantId as i, deleteEnvironmentFromGitops as n, gcOrphanTenantDirs as r, removeEnvironmentRecord as t };

//# sourceMappingURL=cleanup-CXaZPU9h.js.map