import { r as __exportAll } from "./chunk-BSw8zbkd.mjs";
import { p as wakeEnvironment, u as sleepEnvironment } from "./creators-D4wiR4rK.mjs";
import { t as require_main } from "./main-BY-64ifo.mjs";
import { a as deriveStudioPowerState, n as AUTOMATION_USER_AGENTS, o as isEligibleForSleep, r as DEPLOYMENT_FAILED_STATUS, t as AUTOMATION_PATHS } from "./policy-CuTTYCuo.mjs";
import { timingSafeEqual } from "node:crypto";
import { BaseSubgraph } from "@powerhousedao/reactor-api";
const schema = require_main().gql`
  "Coarse power state of a studio, derived from its environment document status."
  enum StudioPowerStatus {
    AWAKE
    SLEEPING
    WAKING
    UNKNOWN
  }

  type StudioPowerState {
    "Studio host queried, e.g. tall-duck-ab12cd34.vetra.io."
    host: String!
    "Environment document id, or null if no studio matches the host."
    envId: String
    subdomain: String
    owner: String
    status: StudioPowerStatus!
  }

  "Loki-derived activity verdict for a studio host over the idle window."
  enum HostActivity {
    "Had real (non-automation) requests in the window."
    ACTIVE
    "Access logs exist but only automation (pings) — the sleep signal."
    IDLE
    "No access logs at all / query failed — cannot measure (never slept)."
    UNKNOWN
  }

  "What the idle detector sees for one claimed READY studio (read-only)."
  type StudioActivity {
    host: String!
    subdomain: String
    envId: String!
    owner: String
    "Environment-document status (these are all claimed + READY)."
    status: String!
    "Passes the sleep policy (claimed, not allowlisted)."
    eligible: Boolean!
    activity: HostActivity!
    "eligible AND activity == IDLE — i.e. the detector would sleep it this pass."
    wouldSleep: Boolean!
  }

  "Raw claimed-READY studio row, for an external detector to run eligibility itself (no policy applied here)."
  type StudioCandidate {
    "Derived apex host, e.g. tall-duck-ab12cd34.vetra.io."
    host: String!
    subdomain: String
    envId: String!
    owner: String
    "Environment-document status (these are all claimed + READY)."
    status: String!
    poolState: String
    tenantId: String
    "JSON string of the env's services, as stored (read-model environments.services)."
    services: String
  }

  type VetraHousekeepingQueries {
    "Resolve a studio host to its current power state (used by the wake activator)."
    studioPowerState(host: String!): StudioPowerState!
    "Ops view of the idle detector: classify every claimed READY studio (ACTIVE/IDLE/UNKNOWN) without sleeping. Admin only."
    studioActivity: [StudioActivity!]!
    "Raw claimed-READY candidate rows for an external detector to run eligibility itself. Internal-service key or admin only."
    readyStudios: [StudioCandidate!]!
  }

  type VetraHousekeepingMutations {
    "Put a claimed, eligible studio to sleep. Idempotent. Admin only."
    sleepStudio(host: String!): StudioPowerState!
    "Wake a sleeping studio. No-op if already awake/waking. Idempotent. Admin only."
    wakeStudio(host: String!): StudioPowerState!
  }

  type Query {
    VetraHousekeeping: VetraHousekeepingQueries
  }

  type Mutation {
    VetraHousekeeping: VetraHousekeepingMutations!
  }
`;
//#endregion
//#region subgraphs/vetra-housekeeping/resolvers.ts
/**
* Admin gate. reactor-api verifies the Renown bearer and populates `ctx.user`,
* but does NOT inject a `ctx.isAdmin` helper into subgraph resolver context — it
* checks admin-ness via `authorizationService.isSupremeAdmin`, which compares the
* caller against the `ADMINS` env. We do the same (the old `ctx.isAdmin?.()` was
* always undefined → FORBIDDEN for everyone, even valid admins).
*/
function requireAdmin(ctx) {
	const address = ctx.user?.address?.toLowerCase();
	const admins = (process.env.ADMINS ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
	if (!address || !admins.includes(address)) throw new Error("FORBIDDEN");
}
/** Constant-time string compare, guarded against length mismatch (which `timingSafeEqual` throws on). */
function safeEqual(a, b) {
	const bufA = Buffer.from(a);
	const bufB = Buffer.from(b);
	if (bufA.length !== bufB.length) return false;
	return timingSafeEqual(bufA, bufB);
}
/** Prefer an already-resolved `ctx.internalKey` (unit tests); else read the raw request header. */
function resolveInternalKey(ctx) {
	if (typeof ctx.internalKey === "string") return ctx.internalKey;
	const raw = ctx.headers?.["x-housekeeping-key"];
	return Array.isArray(raw) ? raw[0] : raw;
}
/**
* Gate for the external-detector surface: passes if the caller presents the
* shared `HOUSEKEEPING_INTERNAL_KEY` (constant-time compare, non-empty on both
* sides) OR is an admin (`requireAdmin`). Used by `readyStudios` here; Task 1.2
* reuses this same helper for `sleepStudio`.
*/
function requireInternalOrAdmin(ctx) {
	const expected = process.env.HOUSEKEEPING_INTERNAL_KEY ?? "";
	const provided = resolveInternalKey(ctx) ?? "";
	if (expected.length > 0 && provided.length > 0 && safeEqual(provided, expected)) return;
	requireAdmin(ctx);
}
function createResolvers(deps) {
	return {
		Query: { VetraHousekeeping: () => ({}) },
		VetraHousekeepingQueries: {
			studioPowerState: (_p, args) => deps.powerState(args.host),
			studioActivity: (_p, _a, ctx) => {
				requireAdmin(ctx);
				return deps.studioActivity();
			},
			readyStudios: async (_p, _a, ctx) => {
				requireInternalOrAdmin(ctx);
				return deps.readyStudios();
			}
		},
		Mutation: { VetraHousekeeping: () => ({}) },
		VetraHousekeepingMutations: {
			sleepStudio: async (_p, args, ctx) => {
				requireInternalOrAdmin(ctx);
				return deps.sleep(args.host);
			},
			wakeStudio: (_p, args) => deps.wake(args.host)
		}
	};
}
//#endregion
//#region subgraphs/vetra-housekeeping/db.ts
/**
* Service host prefixes/suffixes the gitops renders (see resolveGenericHost).
* A CLINT studio is served at its apex (`<sub>.vetra.io`); other services are
* `<sub>-<prefix>.vetra.io` (flat) or, on legacy envs, `<prefix>.<sub>.vetra.io`.
*/
const SERVICE_TOKENS = [
	"connect",
	"switchboard",
	"vetra-agent"
];
/**
* Extract the studio/env subdomain from a request host, so the wake activator
* can wake an env when ANY of its service hosts is accessed (not just the CLINT
* apex). Handles: apex `<sub>.vetra.io`, flat `<sub>-<prefix>.vetra.io`, and
* legacy subdomain-style `<prefix>.<sub>.vetra.io`. Strips port and lowercases.
*/
function hostToSubdomain(host) {
	if (!host) return null;
	const parts = host.toLowerCase().split(":")[0].trim().split(".");
	const first = parts[0];
	if (!first) return null;
	if (parts.length >= 3 && SERVICE_TOKENS.includes(first)) return parts[1] || null;
	for (const t of SERVICE_TOKENS) if (first.endsWith(`-${t}`)) return first.slice(0, -(t.length + 1)) || null;
	return first;
}
/**
* Look up the environment read-model row for a studio host. Returns null when
* no environment matches (unknown host, or a non-apex host we don't manage).
*/
async function findStudioByHost(db, host) {
	const subdomain = hostToSubdomain(host);
	if (!subdomain) return null;
	const row = await db.selectFrom("environments").select([
		"id",
		"subdomain",
		"status",
		"owner",
		"poolState",
		"tenantId",
		"services"
	]).where("subdomain", "=", subdomain).executeTakeFirst();
	if (!row) return null;
	return {
		envId: row.id,
		subdomain: row.subdomain,
		status: row.status,
		owner: row.owner,
		poolState: row.poolState,
		tenantId: row.tenantId,
		services: row.services
	};
}
/**
* All claimed studios currently READY — the sleep candidates the in-process
* keeper evaluates (pre-eligibility; `isEligibleForSleep` filters further).
* Also surfaces studios stuck in DEPLOYMENt_FAILED so housekeeping can
* reclaim failed deploys that never got a chance to run again.
*/
async function listReadyStudios(db) {
	return (await db.selectFrom("environments").select([
		"id",
		"subdomain",
		"status",
		"owner",
		"poolState",
		"tenantId",
		"services"
	]).where("status", "in", ["READY", DEPLOYMENT_FAILED_STATUS]).where("owner", "is not", null).execute()).map((row) => ({
		envId: row.id,
		subdomain: row.subdomain,
		status: row.status,
		owner: row.owner,
		poolState: row.poolState,
		tenantId: row.tenantId,
		services: row.services
	}));
}
//#endregion
//#region subgraphs/vetra-housekeeping/loki.ts
const DEFAULT_LOKI_FIELDS = {
	host: process.env.LOKI_FIELD_HOST ?? "RequestHost",
	path: process.env.LOKI_FIELD_PATH ?? "RequestPath",
	userAgent: process.env.LOKI_FIELD_UA ?? "request_User_Agent"
};
function escapeRe(s) {
	return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Regex (anchored) matching automation request paths from the shared denylist. */
function automationPathRegex() {
	return `^(${AUTOMATION_PATHS.map((p) => p.endsWith("/") ? `${escapeRe(p)}.*` : escapeRe(p)).join("|")})$`;
}
/** Case-insensitive regex matching automation user-agents from the shared denylist. */
function automationUaRegex() {
	return `(?i)(${AUTOMATION_USER_AGENTS.map(escapeRe).join("|")})`;
}
/**
* Stream selector for MANY hosts at once: `| json` then `RequestHost=~` on the
* host alternation. Grouping downstream (`sum by (RequestHost)`) yields a per-host
* count from a single stream scan.
*
* NB: deliberately NO `|~` line pre-filter here. For a single host a `|=` literal
* grep is cheap, but a `|~` regex alternation of 30–40 full domains evaluated
* against every raw line before `| json` is pathological — it turned a ~6s scan
* into a >120s timeout. `| json | RequestHost=~` alone does 32 hosts × 24h in ~6s.
*/
function hostsStream(selector, hosts, fields) {
	const alt = hosts.map(escapeRe).join("|");
	return `${selector} | json | ${fields.host}=~\`${alt}\``;
}
/**
* Grouped variant of {@link buildProperRequestCountQuery} — one query counting
* *proper* (non-automation) requests for every host in `hosts`, grouped by host.
*/
function buildGroupedProperRequestCountQuery(selector, hosts, windowSeconds, fields = DEFAULT_LOKI_FIELDS) {
	const stream = `${hostsStream(selector, hosts, fields)} | ${fields.path}!~\`${automationPathRegex()}\` | ${fields.userAgent}!~\`${automationUaRegex()}\``;
	return `sum by (${fields.host}) (count_over_time((${stream})[${windowSeconds}s]))`;
}
/** Grouped variant of {@link buildTotalRequestCountQuery} — all requests per host. */
function buildGroupedTotalRequestCountQuery(selector, hosts, windowSeconds, fields = DEFAULT_LOKI_FIELDS) {
	return `sum by (${fields.host}) (count_over_time((${hostsStream(selector, hosts, fields)})[${windowSeconds}s]))`;
}
/**
* Verdict for one host from its request counts + whether the log pipeline is
* proven live this scan (a canary host known to always receive traffic had
* requests in the window).
*
*  - proper > 0            → ACTIVE  (real user traffic)
*  - total  > 0, proper 0  → IDLE    (logs exist, all automation)
*  - total == 0, healthy   → IDLE    (pipeline proven live ⇒ zero requests is
*                                     genuine idleness, not a missing signal)
*  - total == 0, !healthy  → UNKNOWN (can't tell idle from a broken pipeline —
*                                     fail safe, never slept; the dev.140 guard)
*
* The `pipelineHealthy` canary is what lets us reclaim genuinely-silent studios
* (the common case) without risking a fleet-wide sleep when Traefik→Loki breaks.
*/
function classifyActivity(proper, total, pipelineHealthy) {
	if (proper > 0) return "ACTIVE";
	if (total > 0) return "IDLE";
	return pipelineHealthy ? "IDLE" : "UNKNOWN";
}
/** Max hosts per grouped query — keeps the alternation regex + URL bounded. */
const HOSTS_PER_QUERY = 40;
function createLokiClient(opts) {
	const fields = opts.fields ?? DEFAULT_LOKI_FIELDS;
	const timeout = opts.fetchTimeoutMs ?? 3e4;
	/** Run a grouped query; return host→count for the hosts that had matches. */
	async function countByHost(query) {
		const url = `${opts.lokiUrl}/loki/api/v1/query?query=${encodeURIComponent(query)}`;
		const ac = new AbortController();
		const t = setTimeout(() => ac.abort(), timeout);
		try {
			const res = await fetch(url, { signal: ac.signal });
			if (!res.ok) throw new Error(`loki ${res.status}`);
			const body = await res.json();
			const out = /* @__PURE__ */ new Map();
			for (const r of body.data?.result ?? []) {
				const host = r.metric?.[fields.host];
				if (host) out.set(host, Number(r.value?.[1] ?? 0));
			}
			return out;
		} finally {
			clearTimeout(t);
		}
	}
	/** True if the canary host had ANY traffic in the window ⇒ pipeline is live. */
	async function canaryHealthy(windowSeconds) {
		if (!opts.canaryHost) return false;
		try {
			return ((await countByHost(buildGroupedTotalRequestCountQuery(opts.selector, [opts.canaryHost], windowSeconds, fields))).get(opts.canaryHost) ?? 0) > 0;
		} catch {
			return false;
		}
	}
	async function classifyChunk(hosts, windowSeconds, pipelineHealthy, out) {
		try {
			const [proper, total] = await Promise.all([countByHost(buildGroupedProperRequestCountQuery(opts.selector, hosts, windowSeconds, fields)), countByHost(buildGroupedTotalRequestCountQuery(opts.selector, hosts, windowSeconds, fields))]);
			for (const host of hosts) {
				const p = proper.get(host) ?? 0;
				const tot = total.get(host) ?? 0;
				out.set(host, classifyActivity(p, tot, pipelineHealthy));
			}
		} catch (err) {
			opts.logger?.warn(`[loki] grouped query failed for ${hosts.length} host(s): ${err instanceof Error ? err.message : String(err)} — UNKNOWN`);
			for (const host of hosts) if (!out.has(host)) out.set(host, "UNKNOWN");
		}
	}
	const client = {
		async classifyHosts(hosts, windowSeconds) {
			const out = /* @__PURE__ */ new Map();
			const healthy = await canaryHealthy(windowSeconds);
			for (let i = 0; i < hosts.length; i += HOSTS_PER_QUERY) await classifyChunk(hosts.slice(i, i + HOSTS_PER_QUERY), windowSeconds, healthy, out);
			return out;
		},
		async classifyHostActivity(host, windowSeconds) {
			return (await client.classifyHosts([host], windowSeconds)).get(host) ?? "UNKNOWN";
		}
	};
	return client;
}
//#endregion
//#region subgraphs/vetra-housekeeping/keeper.ts
function loadKeeperConfig(env = process.env) {
	const int = (name, fallback) => {
		const v = env[name];
		if (!v) return fallback;
		const n = Number.parseInt(v, 10);
		return Number.isNaN(n) || n <= 0 ? fallback : n;
	};
	return {
		enabled: (env.HOUSEKEEPING_DETECTOR_ENABLED ?? "false").toLowerCase() === "true",
		dryRun: (env.HOUSEKEEPING_DRY_RUN ?? "true").toLowerCase() !== "false",
		idleThresholdSeconds: int("HOUSEKEEPING_IDLE_THRESHOLD_SECONDS", 1440 * 60),
		scanIntervalMs: int("HOUSEKEEPING_SCAN_INTERVAL_MS", 900 * 1e3),
		lokiTimeoutMs: int("HOUSEKEEPING_LOKI_TIMEOUT_MS", 12e4),
		baseDomain: env.STUDIO_BASE_DOMAIN ?? "vetra.io",
		allowlist: (env.HOUSEKEEPING_ALLOWLIST ?? "").split(",").map((s) => s.trim()).filter(Boolean)
	};
}
/** Apex studio host for a subdomain (CLINT studios are served at the apex). */
function studioHost(subdomain, baseDomain) {
	return `${subdomain}.${baseDomain}`;
}
var HousekeepingKeeper = class {
	timer = null;
	running = false;
	constructor(deps) {
		this.deps = deps;
	}
	/**
	* Read-only: classify every claimed READY studio (eligibility + Loki idle
	* verdict) WITHOUT sleeping anything. Backs the `studioActivity` query so ops
	* can see exactly what the detector sees — including what it WOULD sleep.
	*/
	async classifyAll() {
		const { listStudios, loki, config } = this.deps;
		const studios = await listStudios();
		const hosts = studios.map((s) => s.subdomain ? studioHost(s.subdomain, config.baseDomain) : null).filter((h) => h !== null);
		const verdicts = await loki.classifyHosts(hosts, config.idleThresholdSeconds);
		return studios.map((s) => {
			const eligible = isEligibleForSleep(s, { allowlist: config.allowlist });
			const host = s.subdomain ? studioHost(s.subdomain, config.baseDomain) : "";
			const activity = host ? verdicts.get(host) ?? "UNKNOWN" : "UNKNOWN";
			return {
				host,
				subdomain: s.subdomain ?? null,
				envId: s.envId,
				owner: s.owner ?? null,
				status: s.status ?? "UNKNOWN",
				eligible,
				activity,
				wouldSleep: eligible && activity === "IDLE"
			};
		});
	}
	/** One detection pass. Returns the hosts slept (or that would be, in dry-run). */
	async reconcileOnce() {
		const { listStudios, loki, sleepEnv, config } = this.deps;
		const log = this.deps.logger ?? console;
		const studios = await listStudios();
		const eligible = studios.filter((s) => isEligibleForSleep(s, { allowlist: config.allowlist }));
		log.info(`[housekeeping-keeper] ${studios.length} READY, ${eligible.length} eligible (dryRun=${config.dryRun})`);
		const candidates = eligible.map((s) => s.subdomain ? {
			s,
			host: studioHost(s.subdomain, config.baseDomain)
		} : null).filter((c) => c !== null);
		const verdicts = await loki.classifyHosts(candidates.map((c) => c.host), config.idleThresholdSeconds);
		const slept = [];
		for (const { s, host } of candidates) {
			if ((verdicts.get(host) ?? "UNKNOWN") !== "IDLE") continue;
			if (config.dryRun) {
				log.info(`[housekeeping-keeper] would sleep ${host} (idle ≥ ${config.idleThresholdSeconds}s)`);
				slept.push(host);
				continue;
			}
			try {
				await sleepEnv(s.envId);
				log.info(`[housekeeping-keeper] slept ${host}`);
				slept.push(host);
			} catch (err) {
				log.warn(`[housekeeping-keeper] sleep failed for ${host}: ${err instanceof Error ? err.message : String(err)}`);
			}
		}
		return slept;
	}
	start() {
		if (this.timer) return;
		const log = this.deps.logger ?? console;
		const tick = () => {
			if (this.running) return;
			this.running = true;
			this.reconcileOnce().catch((err) => log.warn(`[housekeeping-keeper] pass failed: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
				this.running = false;
			});
		};
		tick();
		this.timer = setInterval(tick, this.deps.config.scanIntervalMs);
	}
	stop() {
		if (this.timer) clearInterval(this.timer);
		this.timer = null;
	}
};
//#endregion
//#region subgraphs/vetra-housekeeping/index.ts
var vetra_housekeeping_exports = /* @__PURE__ */ __exportAll({ VetraHousekeepingSubgraph: () => VetraHousekeepingSubgraph });
/**
* Studio housekeeping subgraph. Server-side home of the sleep/wake transitions,
* the host→power-state lookup, AND the in-process idle detector (the
* HousekeepingKeeper, modeled on the studio-pool keeper) — so auto-sleep
* dispatches system actions via the reactor client with no auth token.
*
* Sleep/wake dispatch system actions on the environment document; the gitops
* processor renders global.disabled for STOPPED and re-enables on wake (which
* flips the env back to CHANGES_APPROVED, reusing the normal deploy pipeline).
*/
var VetraHousekeepingSubgraph = class extends BaseSubgraph {
	name = "vetra-housekeeping";
	typeDefs = schema;
	resolvers = {};
	additionalContextFields = {};
	keeper = null;
	async onSetup() {
		const envDb = await this.relationalDb.createNamespace("vetra-cloud-environments");
		const keeperConfig = loadKeeperConfig(process.env);
		const allowlist = keeperConfig.allowlist;
		const dispatch = (documentId, action) => this.reactorClient.execute(documentId, "main", [action]).then(() => void 0);
		const result = (host, row, status) => ({
			host,
			envId: row?.envId ?? null,
			subdomain: row?.subdomain ?? null,
			owner: row?.owner ?? null,
			status
		});
		const powerState = async (host) => {
			const row = await findStudioByHost(envDb, host);
			return result(host, row, deriveStudioPowerState(row ?? void 0));
		};
		const sleep = async (host) => {
			const row = await findStudioByHost(envDb, host);
			if (!row) throw new Error("STUDIO_NOT_FOUND");
			if (deriveStudioPowerState(row) === "SLEEPING") return result(host, row, "SLEEPING");
			if (!isEligibleForSleep(row, { allowlist })) throw new Error("STUDIO_NOT_ELIGIBLE");
			await dispatch(row.envId, sleepEnvironment({}));
			return result(host, row, "SLEEPING");
		};
		const wake = async (host) => {
			const row = await findStudioByHost(envDb, host);
			if (!row) throw new Error("STUDIO_NOT_FOUND");
			const current = deriveStudioPowerState(row);
			if (current !== "SLEEPING") return result(host, row, current);
			await dispatch(row.envId, wakeEnvironment({}));
			return result(host, row, "WAKING");
		};
		const readyStudios = async () => {
			return (await listReadyStudios(envDb)).map((row) => ({
				host: studioHost(row.subdomain ?? "", keeperConfig.baseDomain),
				subdomain: row.subdomain ?? null,
				envId: row.envId,
				owner: row.owner ?? null,
				status: row.status ?? "",
				poolState: row.poolState ?? null,
				tenantId: row.tenantId ?? null,
				services: row.services ?? null
			}));
		};
		const keeper = new HousekeepingKeeper({
			listStudios: () => listReadyStudios(envDb),
			loki: createLokiClient({
				lokiUrl: (process.env.LOKI_URL ?? "http://loki-gateway.monitoring.svc").replace(/\/$/, ""),
				selector: process.env.LOKI_SELECTOR ?? "{namespace=\"traefik\"}",
				fetchTimeoutMs: keeperConfig.lokiTimeoutMs,
				canaryHost: process.env.HOUSEKEEPING_CANARY_HOST,
				logger: console
			}),
			sleepEnv: (documentId) => dispatch(documentId, sleepEnvironment({})),
			config: keeperConfig,
			logger: console
		});
		this.keeper = keeper;
		this.resolvers = createResolvers({
			powerState,
			sleep,
			wake,
			studioActivity: () => keeper.classifyAll(),
			readyStudios
		});
		if (keeperConfig.enabled) {
			keeper.start();
			console.info(`[vetra-housekeeping] detector keeper started (dryRun=${keeperConfig.dryRun}, idle=${keeperConfig.idleThresholdSeconds}s, scan=${keeperConfig.scanIntervalMs}ms)`);
		} else console.info("[vetra-housekeeping] detector loop disabled (HOUSEKEEPING_DETECTOR_ENABLED!=true); studioActivity query still available");
	}
	async onDisconnect() {
		this.keeper?.stop();
		this.keeper = null;
	}
};
//#endregion
export { vetra_housekeeping_exports as n, VetraHousekeepingSubgraph as t };

//# sourceMappingURL=vetra-housekeeping-D9LSjNvR.mjs.map