import "node:module";
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createLogger, createS3Client, sha256 } from "@kici-dev/shared";
import { promises } from "node:fs";
import { dirname, join } from "node:path";
import { createHmac, randomBytes, randomUUID } from "node:crypto";
import "node:fs/promises";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import.meta.url;
//#endregion
//#region src/storage/s3.ts
/**
* S3-backed cache storage.
*
* Stores cached items in S3 with custom metadata headers for TTL tracking.
* Uses AWS SDK v3 modular imports -- only loaded when S3 storage is configured.
*
* Metadata storage:
*   x-amz-meta-created-at      -- ISO timestamp of creation
*   x-amz-meta-last-accessed-at -- ISO timestamp of last access
*
* TTL is enforced lazily: expired items are deleted on access.
* Pre-signed URLs are generated for getUrl() with 15-minute expiry.
*/
/** Pre-signed download URL expiry in seconds (15 minutes) */
const PRESIGNED_URL_EXPIRY_SECONDS = 900;
/** Pre-signed upload URL expiry in seconds (30 minutes -- uploads take longer) */
const UPLOAD_URL_EXPIRY_SECONDS = 1800;
var S3CacheStorage = class {
	client;
	/** Separate client for pre-signed URL generation (uses externalEndpoint if configured). */
	presignClient;
	/** Separate client for the host CLI's pre-signed upload URL (uses uploadEndpoint if configured). */
	uploadPresignClient;
	bucket;
	prefix;
	ttlMs;
	constructor(options) {
		this.bucket = options.bucket;
		this.prefix = options.prefix;
		this.ttlMs = options.ttlMs;
		this.client = createS3Client(options);
		if (options.externalEndpoint) this.presignClient = createS3Client({
			...options,
			endpoint: options.externalEndpoint
		});
		else this.presignClient = this.client;
		if (options.uploadEndpoint) this.uploadPresignClient = createS3Client({
			...options,
			endpoint: options.uploadEndpoint
		});
		else this.uploadPresignClient = this.client;
	}
	/** Build the full S3 object key from a cache key. */
	objectKey(key) {
		return `${this.prefix}${key}`;
	}
	/** Check if metadata indicates an expired item, honoring a per-op TTL override. */
	isExpired(meta, ttlMsOverride) {
		const ttl = ttlMsOverride ?? this.ttlMs;
		const lastAccessed = new Date(meta.lastAccessedAt).getTime();
		return Date.now() - lastAccessed > ttl;
	}
	/**
	* Read metadata from an S3 object's custom headers.
	* Returns null if the object doesn't exist (NoSuchKey/NotFound).
	*/
	async readMeta(key) {
		try {
			const head = await this.client.send(new HeadObjectCommand({
				Bucket: this.bucket,
				Key: this.objectKey(key)
			}));
			const createdAt = head.Metadata?.["created-at"] ?? "";
			const lastAccessedAt = head.Metadata?.["last-accessed-at"] ?? "";
			if (!createdAt || !lastAccessedAt) return null;
			return {
				createdAt,
				lastAccessedAt
			};
		} catch (err) {
			if (this.isNotFoundError(err)) return null;
			throw err;
		}
	}
	/**
	* Update metadata on an S3 object by copying it to itself.
	* This is the standard S3 pattern for updating metadata without re-uploading data.
	*/
	async updateMeta(key, meta) {
		const objectKey = this.objectKey(key);
		await this.client.send(new CopyObjectCommand({
			Bucket: this.bucket,
			Key: objectKey,
			CopySource: `${this.bucket}/${objectKey}`,
			MetadataDirective: "REPLACE",
			Metadata: {
				"created-at": meta.createdAt,
				"last-accessed-at": meta.lastAccessedAt
			}
		}));
	}
	/** Check if an error is a "not found" error (NoSuchKey, NotFound, 404). */
	isNotFoundError(err) {
		if (typeof err !== "object" || err === null) return false;
		const name = err.name;
		if (name === "NoSuchKey" || name === "NotFound") return true;
		return err.$metadata?.httpStatusCode === 404;
	}
	async put(key, data) {
		const now = (/* @__PURE__ */ new Date()).toISOString();
		const body = typeof data === "string" ? Buffer.from(data) : data;
		await new Upload({
			client: this.client,
			params: {
				Bucket: this.bucket,
				Key: this.objectKey(key),
				Body: body,
				Metadata: {
					"created-at": now,
					"last-accessed-at": now
				}
			}
		}).done();
	}
	async get(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return null;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteObject(key);
			return null;
		}
		let data;
		try {
			const response = await this.client.send(new GetObjectCommand({
				Bucket: this.bucket,
				Key: this.objectKey(key)
			}));
			if (!response.Body) return null;
			data = Buffer.from(await response.Body.transformToByteArray());
		} catch (err) {
			if (this.isNotFoundError(err)) return null;
			throw err;
		}
		try {
			meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
			await this.updateMeta(key, meta);
		} catch {}
		return data;
	}
	async has(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return false;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteObject(key);
			return false;
		}
		return true;
	}
	async delete(key) {
		const existed = await this.readMeta(key) !== null;
		await this.deleteObject(key);
		return existed;
	}
	async touch(key) {
		const meta = await this.readMeta(key);
		if (!meta) return;
		meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
		await this.updateMeta(key, meta);
	}
	async getUrl(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return null;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteObject(key);
			return null;
		}
		return await getSignedUrl(this.presignClient, new GetObjectCommand({
			Bucket: this.bucket,
			Key: this.objectKey(key)
		}), { expiresIn: PRESIGNED_URL_EXPIRY_SECONDS });
	}
	async getUploadUrl(key) {
		const objectKey = this.objectKey(key);
		return getSignedUrl(this.presignClient, new PutObjectCommand({
			Bucket: this.bucket,
			Key: objectKey
		}), { expiresIn: UPLOAD_URL_EXPIRY_SECONDS });
	}
	async getInternalUploadUrl(key) {
		const objectKey = this.objectKey(key);
		return getSignedUrl(this.uploadPresignClient, new PutObjectCommand({
			Bucket: this.bucket,
			Key: objectKey
		}), { expiresIn: UPLOAD_URL_EXPIRY_SECONDS });
	}
	async initMeta(key) {
		const objectKey = this.objectKey(key);
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await this.client.send(new CopyObjectCommand({
			Bucket: this.bucket,
			Key: objectKey,
			CopySource: `${this.bucket}/${objectKey}`,
			MetadataDirective: "REPLACE",
			Metadata: {
				"created-at": now,
				"last-accessed-at": now
			}
		}));
	}
	async list(subPrefix) {
		const fullPrefix = this.objectKey(subPrefix);
		const items = [];
		let token;
		do {
			const resp = await this.client.send(new ListObjectsV2Command({
				Bucket: this.bucket,
				Prefix: fullPrefix,
				ContinuationToken: token
			}));
			for (const obj of resp.Contents ?? []) {
				if (!obj.Key) continue;
				items.push({
					key: obj.Key.slice(this.prefix.length),
					created: obj.LastModified ? obj.LastModified.getTime() : 0
				});
			}
			token = resp.IsTruncated ? resp.NextContinuationToken : void 0;
		} while (token);
		items.sort((a, b) => b.created - a.created);
		return items.map((i) => i.key);
	}
	async copy(srcKey, destKey) {
		const srcObj = this.objectKey(srcKey);
		const destObj = this.objectKey(destKey);
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await this.client.send(new CopyObjectCommand({
			Bucket: this.bucket,
			Key: destObj,
			CopySource: `${this.bucket}/${srcObj}`,
			MetadataDirective: "REPLACE",
			Metadata: {
				"created-at": now,
				"last-accessed-at": now
			}
		}));
	}
	/** Delete an S3 object. Idempotent (doesn't error if missing). */
	async deleteObject(key) {
		try {
			await this.client.send(new DeleteObjectCommand({
				Bucket: this.bucket,
				Key: this.objectKey(key)
			}));
		} catch (err) {
			if (!this.isNotFoundError(err)) throw err;
		}
	}
};
//#endregion
//#region src/storage/sign-url.ts
/**
* HMAC-signed URL tokens for the filesystem cache backend.
*
* Pre-signed S3 URLs are not available for filesystem storage, so the
* filesystem backend mints HMAC-SHA256 tokens that the orchestrator's HTTP
* blob route verifies before serving / writing a file. Token shape:
*
*   `${expiresUnixMs}.${hexSig}`
*
* Signature input: `${method}:${key}:${expiresUnixMs}` with HMAC-SHA256 keyed
* by a per-orchestrator-process secret (random 32-byte hex at boot, persists
* for the lifetime of the process). Tokens become invalid on orchestrator
* restart — fine for the filesystem backend's E2E / single-host use case.
*/
/** Default URL lifetime: 1 hour (matches S3 backend's PUT-URL lifetime). */
const DEFAULT_SIGN_URL_TTL_MS = 3600 * 1e3;
function computeSig(secret, method, key, expiresMs) {
	const hmac = createHmac("sha256", secret);
	hmac.update(`${method}:${key}:${expiresMs}`);
	return hmac.digest("hex");
}
/**
* Mint a signed token for the given (method, key, expiry) triple.
*/
function signToken(secret, method, key, ttlMs = DEFAULT_SIGN_URL_TTL_MS) {
	const expiresMs = Date.now() + ttlMs;
	return {
		token: `${expiresMs}.${computeSig(secret, method, key, expiresMs)}`,
		expiresMs
	};
}
//#endregion
//#region src/storage/filesystem.ts
/**
* Filesystem-backed cache storage.
*
* Mirrors the `CacheStorage` interface that `S3CacheStorage` provides, but
* stores blobs as files under a configured base directory. Pre-signed URLs
* are replaced by HMAC-signed `http://<orchUrl>/api/v1/cache/blob/...` URLs
* — see `sign-url.ts` for the token mechanics and `app.ts` for the route.
*
* Intended for single-host deployments and E2E sandboxes where standing up
* an S3-compatible service is overkill. Production should still use S3.
*
* Metadata is co-located as a sibling JSON file (`<key>.meta.json`) so the
* data file is byte-identical to what the agent receives over HTTP. TTL is
* enforced lazily on access — same model as the S3 backend.
*/
const DEFAULT_ROUTE_PREFIX = "/api/v1/cache/blob/";
var FilesystemCacheStorage = class {
	basePath;
	ttlMs;
	baseUrl;
	signingSecret;
	routePrefix;
	constructor(options) {
		this.basePath = options.basePath;
		this.ttlMs = options.ttlMs;
		this.baseUrl = options.baseUrl.replace(/\/$/, "");
		this.signingSecret = options.signingSecret;
		this.routePrefix = (options.routePrefix ?? DEFAULT_ROUTE_PREFIX).replace(/\/?$/, "/");
	}
	/**
	* Map a cache key to its on-disk path. Keys may contain `/`, which becomes a
	* directory separator. Reject keys containing `..` segments or NUL bytes to
	* prevent path traversal — the same defence the route layer enforces too.
	*/
	resolvePath(key) {
		if (key.includes("\0") || key.split("/").some((seg) => seg === ".." || seg === "")) throw new Error(`Invalid cache key: ${JSON.stringify(key)}`);
		return join(this.basePath, key);
	}
	/** Public for the HTTP route. The route validates the key before calling. */
	pathFor(key) {
		return this.resolvePath(key);
	}
	metaPath(key) {
		return `${this.resolvePath(key)}.meta.json`;
	}
	isExpired(meta, ttlMsOverride) {
		const ttl = ttlMsOverride ?? this.ttlMs;
		const lastAccessed = new Date(meta.lastAccessedAt).getTime();
		return Date.now() - lastAccessed > ttl;
	}
	async readMeta(key) {
		try {
			const raw = await promises.readFile(this.metaPath(key), "utf-8");
			const parsed = JSON.parse(raw);
			if (typeof parsed.createdAt !== "string" || typeof parsed.lastAccessedAt !== "string") return null;
			return parsed;
		} catch (err) {
			if (isNotFoundFsError(err)) return null;
			throw err;
		}
	}
	async writeMeta(key, meta) {
		const path = this.metaPath(key);
		await promises.mkdir(dirname(path), { recursive: true });
		await writeFileAtomic(path, JSON.stringify(meta));
	}
	async put(key, data) {
		const path = this.resolvePath(key);
		await promises.mkdir(dirname(path), { recursive: true });
		await writeFileAtomic(path, typeof data === "string" ? Buffer.from(data) : data);
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await this.writeMeta(key, {
			createdAt: now,
			lastAccessedAt: now
		});
	}
	async get(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return null;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteFiles(key);
			return null;
		}
		let data;
		try {
			data = await promises.readFile(this.resolvePath(key));
		} catch (err) {
			if (isNotFoundFsError(err)) return null;
			throw err;
		}
		try {
			meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
			await this.writeMeta(key, meta);
		} catch {}
		return data;
	}
	async has(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return false;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteFiles(key);
			return false;
		}
		return true;
	}
	async delete(key) {
		const existed = await this.readMeta(key) !== null;
		await this.deleteFiles(key);
		return existed;
	}
	async touch(key) {
		const meta = await this.readMeta(key);
		if (!meta) return;
		meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
		await this.writeMeta(key, meta);
	}
	async getUrl(key, ttlMsOverride) {
		const meta = await this.readMeta(key);
		if (!meta) return null;
		if (this.isExpired(meta, ttlMsOverride)) {
			await this.deleteFiles(key);
			return null;
		}
		return this.signedUrl("GET", key);
	}
	async getUploadUrl(key) {
		return this.signedUrl("PUT", key);
	}
	async getInternalUploadUrl(key) {
		return this.signedUrl("PUT", key);
	}
	async initMeta(key) {
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await this.writeMeta(key, {
			createdAt: now,
			lastAccessedAt: now
		});
	}
	async list(subPrefix) {
		const root = this.resolvePath(subPrefix.replace(/\/$/, ""));
		let entries;
		try {
			entries = await this.walkDataFiles(root, subPrefix.replace(/\/$/, ""));
		} catch (err) {
			if (isNotFoundFsError(err)) return [];
			throw err;
		}
		entries.sort((a, b) => b.mtime - a.mtime);
		return entries.map((e) => e.key);
	}
	async copy(srcKey, destKey) {
		const srcPath = this.resolvePath(srcKey);
		const destPath = this.resolvePath(destKey);
		await promises.mkdir(dirname(destPath), { recursive: true });
		await promises.copyFile(srcPath, destPath);
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await this.writeMeta(destKey, {
			createdAt: now,
			lastAccessedAt: now
		});
	}
	/**
	* Recursively collect data files under `dir`, returning each as a cache key
	* relative to `basePath` paired with its mtime. Metadata sidecars
	* (`*.meta.json`) and the atomic-write temp files (`*.tmp-*`) are skipped so
	* `list()` only surfaces real cache objects.
	*/
	async walkDataFiles(dir, keyPrefix) {
		const out = [];
		const dirents = await promises.readdir(dir, { withFileTypes: true });
		for (const dirent of dirents) {
			const childPath = join(dir, dirent.name);
			const childKey = keyPrefix ? `${keyPrefix}/${dirent.name}` : dirent.name;
			if (dirent.isDirectory()) {
				out.push(...await this.walkDataFiles(childPath, childKey));
				continue;
			}
			if (dirent.name.endsWith(".meta.json") || /\.tmp-[0-9a-f]+$/.test(dirent.name)) continue;
			const stat = await promises.stat(childPath);
			out.push({
				key: childKey,
				mtime: stat.mtimeMs
			});
		}
		return out;
	}
	signedUrl(method, key) {
		const { token } = signToken(this.signingSecret, method, key);
		const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
		return `${this.baseUrl}${this.routePrefix}${encodedKey}?sig=${encodeURIComponent(token)}`;
	}
	async deleteFiles(key) {
		await promises.rm(this.resolvePath(key), { force: true });
		await promises.rm(this.metaPath(key), { force: true });
	}
};
function isNotFoundFsError(err) {
	return typeof err === "object" && err !== null && err.code === "ENOENT";
}
/**
* Write a file atomically: write to a temp sibling, fsync, rename. Avoids
* partial reads if the orchestrator crashes mid-write. The temp suffix is
* randomised so concurrent writers don't collide on the same temp name.
*/
async function writeFileAtomic(path, data) {
	const tmp = `${path}.tmp-${randomBytes(6).toString("hex")}`;
	const handle = await promises.open(tmp, "w");
	try {
		await handle.writeFile(data);
		await handle.sync();
	} finally {
		await handle.close();
	}
	await promises.rename(tmp, path);
}
//#endregion
//#region src/storage/index.ts
/**
* Create a cache storage backend from configuration.
*/
function createCacheStorage(config) {
	if (config.type === "s3") return new S3CacheStorage({
		bucket: config.bucket,
		prefix: config.prefix,
		ttlMs: config.ttlMs,
		region: config.region,
		endpoint: config.endpoint,
		externalEndpoint: config.externalEndpoint,
		uploadEndpoint: config.uploadEndpoint,
		forcePathStyle: config.forcePathStyle
	});
	if (config.type === "filesystem") return new FilesystemCacheStorage({
		basePath: config.basePath,
		ttlMs: config.ttlMs,
		baseUrl: config.baseUrl,
		signingSecret: config.signingSecret
	});
	throw new Error(`Unknown cache storage type: ${config.type}`);
}
//#endregion
//#region src/cache/source-cache.ts
/**
* Source-tarball cache layer wrapping CacheStorage.
*
* Provides content-hash-keyed operations for customer-workflow source tarballs
* (the `.kici/` directory minus `node_modules/`, packed by the agent's build
* job via `source-packer.ts`). Tarballs are platform-independent: raw TypeScript
* source files are identical across architectures. Refreshes TTL on reads
* (touch-on-read) so actively used sources stay in cache longer.
*
* Cache key format: source/{contentHash}.tar.gz
*
* Replaces the former `BundleCache` / `bundles/{contentHash}.js` layout that
* stored Rolldown-bundled `.compiled.mjs` artifacts — the new execution path
* downloads the source tarball, extracts it, and imports the workflow entry
* via the shared oxc-transform ESM loader hook.
*/
const logger$2 = createLogger({ prefix: "source-cache" });
/** Cache key format: source/{contentHash}.tar.gz */
function sourceKey(contentHash) {
	return `source/${contentHash}.tar.gz`;
}
var SourceCache = class {
	storage;
	constructor(options) {
		this.storage = options.storage;
	}
	async has(contentHash) {
		const key = sourceKey(contentHash);
		const exists = await this.storage.has(key);
		logger$2.debug(`has(${contentHash}): ${exists}`);
		return exists;
	}
	async get(contentHash) {
		const key = sourceKey(contentHash);
		const data = await this.storage.get(key);
		if (data) {
			await this.storage.touch(key);
			logger$2.debug(`get(${contentHash}): hit (${data.length} bytes)`);
		} else logger$2.debug(`get(${contentHash}): miss`);
		return data;
	}
	async getUrl(contentHash) {
		const key = sourceKey(contentHash);
		const url = await this.storage.getUrl(key);
		if (url) {
			await this.storage.touch(key);
			logger$2.debug(`getUrl(${contentHash}): hit`);
		} else logger$2.debug(`getUrl(${contentHash}): miss`);
		return url;
	}
	async getUploadUrl(contentHash) {
		const key = sourceKey(contentHash);
		return this.storage.getUploadUrl(key);
	}
	async store(contentHash, tarball) {
		const key = sourceKey(contentHash);
		await this.storage.put(key, tarball);
		const size = typeof tarball === "string" ? Buffer.byteLength(tarball) : tarball.length;
		logger$2.info(`store(${contentHash}): stored (${size} bytes)`);
	}
	async remove(contentHash) {
		const key = sourceKey(contentHash);
		const removed = await this.storage.delete(key);
		logger$2.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`);
		return removed;
	}
};
//#endregion
//#region src/cache/dep-cache.ts
/**
* Dependency-specific cache layer wrapping CacheStorage.
*
* Stores and retrieves dependency tarballs keyed by lockfileHash + platform + arch.
* Shared CacheStorage backend with SourceCache (same S3 bucket).
* Refreshes TTL on reads (touch-on-read).
*
* Cache key format: deps/{platform}-{arch}/{lockfileHash}.tar.gz
*/
const logger$1 = createLogger({ prefix: "dep-cache" });
/** Default max tarball size: 500MB */
const DEFAULT_MAX_TARBALL_BYTES = 524288e3;
/** Build cache key for dependency tarball: deps/{platform}-{arch}/{lockfileHash}.tar.gz */
function depKey(lockfileHash, platform, arch) {
	return `deps/${platform}-${arch}/${lockfileHash}.tar.gz`;
}
var DepCache = class {
	storage;
	maxTarballBytes;
	constructor(options) {
		this.storage = options.storage;
		this.maxTarballBytes = options.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES;
	}
	/** Check if a dep tarball exists in cache. */
	async has(lockfileHash, platform, arch) {
		const key = depKey(lockfileHash, platform, arch);
		const exists = await this.storage.has(key);
		logger$1.debug(`has(${lockfileHash}): ${exists}`, {
			platform,
			arch
		});
		return exists;
	}
	/**
	* Get a pre-signed download URL for the dep tarball (for agent delivery).
	* Refreshes TTL on hit.
	*/
	async getUrl(lockfileHash, platform, arch) {
		const key = depKey(lockfileHash, platform, arch);
		const url = await this.storage.getUrl(key);
		if (url) {
			await this.storage.touch(key);
			logger$1.debug(`getUrl(${lockfileHash}): hit`, {
				platform,
				arch
			});
		} else logger$1.debug(`getUrl(${lockfileHash}): miss`, {
			platform,
			arch
		});
		return url;
	}
	/**
	* Get a pre-signed download URL and the tarball content hash (if available).
	* Returns null on cache miss. Hash may be undefined for old entries stored
	* before integrity tracking was added.
	*/
	async getUrlAndHash(lockfileHash, platform, arch) {
		const key = depKey(lockfileHash, platform, arch);
		const url = await this.storage.getUrl(key);
		if (!url) {
			logger$1.debug(`getUrlAndHash(${lockfileHash}): miss`, {
				platform,
				arch
			});
			return null;
		}
		await this.storage.touch(key);
		const hashKey = `deps/${platform}-${arch}/${lockfileHash}.hash`;
		const hash = (await this.storage.get(hashKey))?.toString("utf-8") || void 0;
		logger$1.debug(`getUrlAndHash(${lockfileHash}): hit`, {
			platform,
			arch,
			hasHash: !!hash
		});
		return {
			url,
			hash
		};
	}
	/**
	* Get a pre-signed upload URL for direct agent-to-S3 upload.
	*/
	async getUploadUrl(lockfileHash, platform, arch) {
		const key = depKey(lockfileHash, platform, arch);
		return this.storage.getUploadUrl(key);
	}
	/**
	* Store a dep tarball in cache.
	* Throws if tarball exceeds max size (per user decision).
	*/
	async store(lockfileHash, platform, arch, tarballData) {
		if (tarballData.length > this.maxTarballBytes) throw new Error(`Dep tarball exceeds max size: ${tarballData.length} bytes > ${this.maxTarballBytes} bytes limit`);
		const key = depKey(lockfileHash, platform, arch);
		await this.storage.put(key, tarballData);
		logger$1.info(`store: ${tarballData.length} bytes`, {
			lockfileHash,
			platform,
			arch
		});
	}
	/**
	* Compute SHA-256 hash of a tarball buffer.
	* Used by build agents to compute the hash for depsHash protocol field.
	*/
	static computeHash(data) {
		return sha256(data);
	}
	/** Remove a dep tarball from cache. */
	async remove(lockfileHash, platform, arch) {
		const key = depKey(lockfileHash, platform, arch);
		const removed = await this.storage.delete(key);
		logger$1.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
			platform,
			arch
		});
		return removed;
	}
};
//#endregion
//#region src/cache/user-cache.ts
/**
* User-facing cache layer wrapping CacheStorage.
*
* Namespacing: `cache/<orgId>/<repoId>/<refScope>/<key>.tar.gz`, where
* `refScope` is `shared` for trusted refs (org-shared, default-branch cache)
* or `iso/<runId>` for untrusted refs (per-run isolated scope). Restores from
* an untrusted ref read the shared scope as a fallback but writes can NEVER
* land in the shared scope — the GitHub Actions cache-isolation model. Keyed
* strictly per org so no tenant can read another tenant's cache.
*
* Saves are immutable (first save under an exact key wins) and atomic
* (upload to a `.tmp-<uuid>` key, then server-side copy to the final key and
* delete the temp), so a crashed save never leaves a corrupt final entry.
*
* Eviction: per-org byte quota plus the TTL the backing CacheStorage already
* enforces lazily on access. On a save that pushes the org over quota, oldest
* entries (by createdAt) are evicted until under quota; each eviction is
* logged. The companion `.hash` / `.size` sidecar objects carry the integrity
* hash and size accounting outside the tarball's own (presigned, metadata-less)
* upload.
*/
const logger = createLogger({ prefix: "user-cache" });
/**
* Cluster-wide default quota: 5 GiB. Serves as the fallback when an org has no
* per-org override in `org_settings.user_cache_quota_bytes`. The cluster-wide
* value is itself operator-configurable via KICI_USER_CACHE_QUOTA_BYTES.
*/
const DEFAULT_USER_CACHE_QUOTA_BYTES = 5 * 1024 * 1024 * 1024;
/**
* Cluster-wide default entry TTL: 7 days. Fallback when an org has no per-org
* override in `org_settings.user_cache_ttl_ms`. The cluster-wide value is
* operator-configurable via KICI_USER_CACHE_TTL_MS.
*/
const DEFAULT_USER_CACHE_TTL_MS = 10080 * 60 * 1e3;
/** Tarball suffix for committed cache entries. */
const TAR_SUFFIX = ".tar.gz";
var UserCache = class {
	storage;
	/** Cluster-wide default quota (the `KICI_USER_CACHE_QUOTA_BYTES` value). */
	defaultQuotaBytes;
	/** Cluster-wide default TTL (the `KICI_USER_CACHE_TTL_MS` value). */
	defaultTtlMs;
	/** Optional per-org override reader; absent = always use the cluster defaults. */
	orgLimitsReader;
	constructor(opts) {
		this.storage = opts.storage;
		this.defaultQuotaBytes = opts.quotaBytes ?? 5368709120;
		this.defaultTtlMs = opts.ttlMs ?? 6048e5;
		this.orgLimitsReader = opts.orgLimitsReader;
	}
	/**
	* Resolve the effective quota + TTL for an org: the per-org override from
	* `org_settings` when present, otherwise the cluster-wide default. A reader
	* failure falls back to the defaults (logged) — the cache must never fail a
	* restore/save because the settings lookup hiccupped.
	*/
	async resolveLimits(org) {
		if (!this.orgLimitsReader) return {
			quotaBytes: this.defaultQuotaBytes,
			ttlMs: this.defaultTtlMs
		};
		let limits = {};
		try {
			limits = await this.orgLimitsReader(org);
		} catch (err) {
			logger.warn("user-cache org-limits lookup failed — using cluster defaults", {
				org,
				error: err instanceof Error ? err.message : String(err)
			});
		}
		return {
			quotaBytes: limits.quotaBytes ?? this.defaultQuotaBytes,
			ttlMs: limits.ttlMs ?? this.defaultTtlMs
		};
	}
	/**
	* Sanitize a path segment so a key can never escape its org/repo/scope
	* namespace. Beyond stripping disallowed characters, a segment consisting
	* only of dots (`.`, `..`, …) is replaced wholesale: such a segment is a
	* dot-segment that HTTP/S3 path canonicalization collapses (`a/./b` → `a/b`,
	* `a/../b` → `b`), which both corrupts the namespace and breaks the SigV4
	* signature on a pre-signed PUT/GET. Repo identifiers like `.` (the internal
	* provider's repo id) hit exactly this case, so the all-dots guard keeps the
	* object key canonical and the namespace boundary intact.
	*/
	seg(s) {
		const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
		return /^\.+$/.test(cleaned) ? `_${cleaned}` : cleaned;
	}
	/** Org-level prefix: the per-tenant isolation boundary and quota scope. */
	orgPrefix(ref) {
		return `cache/${this.seg(ref.org)}/`;
	}
	/** Org + repo prefix shared by every scope of a repo. */
	repoPrefix(ref) {
		return `${this.orgPrefix(ref)}${this.seg(ref.repo)}`;
	}
	/** Namespace prefix for the WRITE scope of a ref (shared OR per-run isolated). */
	writePrefix(ref) {
		const base = this.repoPrefix(ref);
		if (ref.scope === "isolated") {
			if (!ref.runId) throw new Error("isolated cache scope requires a runId");
			return `${base}/iso/${this.seg(ref.runId)}/`;
		}
		return `${base}/shared/`;
	}
	/** Namespace prefixes the ref may READ, in priority order. Isolated reads its own run scope, then shared. */
	readPrefixes(ref) {
		const base = this.repoPrefix(ref);
		if (ref.scope === "isolated") {
			if (!ref.runId) throw new Error("isolated cache scope requires a runId");
			return [`${base}/iso/${this.seg(ref.runId)}/`, `${base}/shared/`];
		}
		return [`${base}/shared/`];
	}
	finalKey(prefix, key) {
		return `${prefix}${this.seg(key)}${TAR_SUFFIX}`;
	}
	/** Restore: try the exact key across read prefixes, then restoreKeys prefix scan (newest wins). */
	async restore(ref) {
		const { ttlMs } = await this.resolveLimits(ref.org);
		const prefixes = this.readPrefixes(ref);
		const exact = await this.restoreExact(ref, prefixes, ttlMs);
		if (exact) return exact;
		return await this.restoreByPrefix(ref, prefixes, ttlMs) ?? { hit: false };
	}
	/** Try the exact key in read-prefix priority order. */
	async restoreExact(ref, prefixes, ttlMs) {
		for (const prefix of prefixes) {
			const key = this.finalKey(prefix, ref.key);
			const url = await this.storage.getUrl(key, ttlMs);
			if (url) {
				await this.storage.touch(key);
				return {
					hit: true,
					matchedKey: ref.key,
					downloadUrl: url,
					tarHash: await this.readHash(key)
				};
			}
		}
		return null;
	}
	/** restoreKeys prefix fallback (ordered); within a prefix, list() returns newest-first. */
	async restoreByPrefix(ref, prefixes, ttlMs) {
		for (const rk of ref.restoreKeys ?? []) for (const prefix of prefixes) {
			const matches = (await this.storage.list(`${prefix}${this.seg(rk)}`)).filter((k) => k.endsWith(TAR_SUFFIX));
			if (matches.length === 0) continue;
			const winner = matches[0];
			const url = await this.storage.getUrl(winner, ttlMs);
			if (!url) continue;
			await this.storage.touch(winner);
			return {
				hit: true,
				matchedKey: winner.slice(prefix.length, -7),
				downloadUrl: url,
				tarHash: await this.readHash(winner)
			};
		}
		return null;
	}
	/** Begin a save: presigned PUT to a temp key, or skip=true when the immutable key exists. */
	async beginSave(ref) {
		const prefix = this.writePrefix(ref);
		const final = this.finalKey(prefix, ref.key);
		if (await this.storage.has(final)) {
			logger.info("user-cache save skipped (immutable key exists)", { key: ref.key });
			return { skip: true };
		}
		const tempKey = `${prefix}.tmp-${randomUUID()}${TAR_SUFFIX}`;
		return {
			skip: false,
			uploadUrl: await this.storage.getUploadUrl(tempKey),
			tempKey
		};
	}
	/** Commit a save: copy temp -> final, init metadata, store companion hash/size, delete temp, enforce quota. */
	async commitSave(ref) {
		const prefix = this.writePrefix(ref);
		const final = this.finalKey(prefix, ref.key);
		if (await this.storage.has(final)) return;
		if (ref.tempKey) {
			await this.storage.copy(ref.tempKey, final);
			await this.storage.delete(ref.tempKey);
		}
		await this.storage.initMeta(final);
		await this.storage.put(`${final}.hash`, ref.tarHash);
		await this.storage.put(`${final}.size`, String(ref.sizeBytes));
		logger.info("user-cache entry committed", {
			key: ref.key,
			sizeBytes: ref.sizeBytes
		});
		await this.enforceQuota(ref);
	}
	async readHash(key) {
		return (await this.storage.get(`${key}.hash`))?.toString("utf-8") || void 0;
	}
	/** Evict oldest entries for the org until total tarball size <= the per-org quota. */
	async enforceQuota(ref) {
		const { quotaBytes } = await this.resolveLimits(ref.org);
		const orgPrefix = this.orgPrefix(ref);
		const keys = (await this.storage.list(orgPrefix)).filter((k) => k.endsWith(TAR_SUFFIX));
		const sized = [];
		let total = 0;
		for (const k of keys) {
			const sizeData = await this.storage.get(`${k}.size`);
			const size = sizeData ? Number(sizeData.toString("utf-8")) : 0;
			total += size;
			sized.push({
				key: k,
				size
			});
		}
		if (total <= quotaBytes) return;
		for (let i = sized.length - 1; i >= 0 && total > quotaBytes; i--) {
			const { key, size } = sized[i];
			await this.storage.delete(key);
			await this.storage.delete(`${key}.hash`);
			await this.storage.delete(`${key}.size`);
			total -= size;
			logger.info("user-cache eviction (org over quota)", {
				org: ref.org,
				key,
				freedBytes: size
			});
		}
	}
};
//#endregion
//#region src/cluster/peer-credentials.ts
/**
* Peer credential management for cluster authentication.
*
* Provides two persistence layers:
* 1. DB CRUD (PeerCredentialStore) — coordinator stores credentials for all peers
* 2. File I/O (readCredentialFile/writeCredentialFile) — workers persist their
*    credential locally for reconnection after restart
*
* Credential files are written with 0600 permissions to protect the secret.
*/
/** Default credential expiry: 90 days. */
const DEFAULT_EXPIRY_DAYS = 90;
/**
* Database CRUD operations for peer credentials.
* Used by the coordinator to manage credentials for all cluster members.
*/
var PeerCredentialStore = class {
	constructor(db) {
		this.db = db;
	}
	/**
	* Save a new peer credential.
	*/
	async save(opts) {
		const expiryDays = opts.expiryDays ?? DEFAULT_EXPIRY_DAYS;
		const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1e3);
		const runOnce = async () => {
			await this.db.transaction().execute(async (trx) => {
				await trx.updateTable("peer_credentials").set({ revoked_at: /* @__PURE__ */ new Date() }).where("instance_id", "=", opts.instanceId).where("revoked_at", "is", null).execute();
				await trx.insertInto("peer_credentials").values({
					instance_id: opts.instanceId,
					credential_hash: opts.credentialHash,
					role: opts.role,
					routing_keys: sql`${sql.val(opts.routingKeys)}`,
					source_token_hash: opts.sourceTokenHash ?? null,
					expires_at: expiresAt
				}).execute();
			});
		};
		try {
			await runOnce();
		} catch (err) {
			if (err instanceof Error && "code" in err && err.code === "23505") {
				await runOnce();
				return;
			}
			throw err;
		}
	}
	/**
	* Find a non-revoked, non-expired credential by its hash.
	*/
	async findByCredentialHash(hash) {
		const row = await this.db.selectFrom("peer_credentials").selectAll().where("credential_hash", "=", hash).where("revoked_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
		return row ? mapRow(row) : null;
	}
	/**
	* Find a non-revoked, non-expired credential by instance ID.
	*/
	async findByInstanceId(instanceId) {
		const row = await this.db.selectFrom("peer_credentials").selectAll().where("instance_id", "=", instanceId).where("revoked_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).orderBy("created_at", "desc").executeTakeFirst();
		return row ? mapRow(row) : null;
	}
	/**
	* Update the last_seen_at timestamp for a credential.
	* Optionally records which coordinator validated it.
	*/
	async updateLastSeen(credentialHash, validatedBy) {
		const updates = { last_seen_at: /* @__PURE__ */ new Date() };
		if (validatedBy) updates.last_validated_by = validatedBy;
		await this.db.updateTable("peer_credentials").set(updates).where("credential_hash", "=", credentialHash).execute();
	}
	/**
	* Revoke a peer's credential by instance ID.
	*/
	async revoke(instanceId) {
		await this.db.updateTable("peer_credentials").set({ revoked_at: /* @__PURE__ */ new Date() }).where("instance_id", "=", instanceId).where("revoked_at", "is", null).execute();
	}
	/**
	* Revoke all active credentials.
	* @returns The number of credentials revoked.
	*/
	async revokeAll() {
		const result = await this.db.updateTable("peer_credentials").set({ revoked_at: /* @__PURE__ */ new Date() }).where("revoked_at", "is", null).executeTakeFirst();
		return Number(result?.numUpdatedRows ?? 0);
	}
	/**
	* List all active (non-revoked, non-expired) credentials.
	*/
	async listActive() {
		return (await this.db.selectFrom("peer_credentials").selectAll().where("revoked_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).orderBy("created_at", "asc").execute()).map(mapRow);
	}
};
/**
* Map a database row to a PeerCredential interface.
*/
function mapRow(row) {
	return {
		id: row.id,
		instanceId: row.instance_id,
		credentialHash: row.credential_hash,
		role: row.role,
		routingKeys: typeof row.routing_keys === "string" ? JSON.parse(row.routing_keys) : row.routing_keys,
		sourceTokenHash: row.source_token_hash ?? null,
		createdAt: new Date(row.created_at),
		lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null,
		lastValidatedBy: row.last_validated_by ?? null,
		expiresAt: new Date(row.expires_at),
		revokedAt: row.revoked_at ? new Date(row.revoked_at) : null
	};
}
/**
* Construct a PeerCredentialStore bound to a Postgres URL, returning the
* store plus a disposer that closes the underlying pool. Lets tests
* exercise the real Kysely-backed store without importing pg/kysely.
*
* Intended for e2e coverage of the store's own CRUD methods — production
* code constructs the store with an already-allocated Kysely instance
* shared with the orchestrator.
*/
async function createPeerCredentialStoreFromUrl(databaseUrl, opts) {
	const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({
		connectionString: databaseUrl,
		max: opts?.maxConnections ?? 3
	}) }) });
	return {
		store: new PeerCredentialStore(db),
		dispose: async () => {
			await db.destroy();
		}
	};
}
//#endregion
export { DEFAULT_USER_CACHE_QUOTA_BYTES, DEFAULT_USER_CACHE_TTL_MS, DepCache, PeerCredentialStore, S3CacheStorage, SourceCache, UserCache, createCacheStorage, createPeerCredentialStoreFromUrl };

//# sourceMappingURL=index.js.map