Compare commits

...
13 Commits
99 changed files with 1750 additions and 373 deletions
+4
View File
@@ -32,3 +32,7 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# Optional request/report timings and event-loop delay diagnostics.
EC_PERF_LOG=false
# Log statements taking at least this many milliseconds; 0 disables logging.
EC_SLOW_SQL_MS=0
# For a local SQLite disk, set to 256 for a 256 MiB memory map. Leave 0 for network mounts.
EC_SQLITE_MMAP_MB=0
+18
View File
@@ -129,6 +129,13 @@ stopping games; activation uses the same occupancy and concurrency gates as
lifecycle operations. Agent before images preserve exact rollback bytes and
operation history.
Artifact bodies are stored once by SHA-256 in SQLite. Versions, staging plans,
rollback snapshots and launch commands reference those shared bytes. The first
startup after this upgrade backfills existing rows transactionally; preserve the
data-directory backup when upgrading. Current agents fetch assigned artifacts
from an authenticated endpoint and verify the hash. Older agents continue to
receive inline content until their automatic update completes.
T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
after the main config. T6 variants retain their distinct catalog selections.
@@ -184,6 +191,17 @@ record an `ec:navigation` performance measure. `npm run benchmark:leaderboard`
uses an isolated synthetic database; pass `-- --scenario all --baseline-ref <commit>`
to compare full response parity and warm query timings with an earlier revision.
Set `EC_SLOW_SQL_MS=20` to log runtime statements taking at least 20 ms; bound
values and string literals are omitted. It is disabled by default. Before and
after database changes, capture these timings under the same fleet load and run
`npm run diagnostics:database -- /data/cherry.db` inside the controller to report
the largest tables/indexes, database and WAL sizes, and free pages. This command
opens an existing database read-only and does not run app migrations.
The controller uses a 128 MiB page cache and each reader worker uses 32 MiB.
For a local data disk, set `EC_SQLITE_MMAP_MB=256` to enable memory-mapped reads.
Query statistics are optimized at startup and hourly; WAL reuse is capped at
64 MiB after checkpoints (active transactions can temporarily grow it larger).
Local controller
metadata defaults to `./data/cherry.db`; use a separate test database when
exercising enrollment. Production agent transport requires HTTPS.
+15 -3
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.5.3"
VERSION = "1.6.0"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -1356,7 +1356,7 @@ class Agent:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation):
@@ -1559,6 +1559,18 @@ class Agent:
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
def decode_file(self, item):
if "contentSha256" in item:
sha = item["contentSha256"]
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
raise AgentError("Invalid file content hash")
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
with self.artifact_open("GET", url) as response:
content = response.read(MAX_FILE + 1)
if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit")
if digest(content) != sha or item.get("sha256", sha) != sha:
raise AgentError("Staged file checksum mismatch")
return content
try:
content = base64.b64decode(item["contentBase64"], validate=True)
except (ValueError, KeyError, TypeError):
@@ -1677,7 +1689,7 @@ class Agent:
if item["path"] in paths:
raise AgentError("Duplicate staged file path")
paths.add(item["path"])
deleting = item.get("delete") is True or item.get("contentBase64") is None
deleting = item.get("delete") is True or (item.get("contentBase64") is None and "contentSha256" not in item)
content = None if deleting else self.decode_file(item)
total_size += len(content) if content is not None else 0
if total_size > 64 * 1024 * 1024:
+20
View File
@@ -832,6 +832,26 @@ class AgentTests(unittest.TestCase):
self.assertEqual(result["state"], "completed", result)
return payload
def test_hash_file_downloads_are_verified_and_staged_as_writes(self):
self.create()
content = b"downloaded plugin"
sha = module.digest(content)
file = {"id": 1, "path": "configs/server.cfg", "contentSha256": sha, "sha256": sha}
with patch.object(self.agent, "artifact_open", return_value=Response(content)) as fetch:
result = self.agent.stage(INSTANCE, 1, {"stageId": "hash-stage", "files": [file]}, "hash-operation")
self.assertEqual(result["state"], "staged")
self.assertTrue(fetch.call_args.args[1].endswith("/api/agent/blobs/" + sha))
manifest = json.loads((self.agent.instance_dir(INSTANCE, 1) / "stages/hash-stage/manifest.json").read_text())
self.assertFalse(manifest["files"][0]["delete"])
self.assertEqual(base64.b64decode(manifest["files"][0]["contentBase64"]), content)
with patch.object(self.agent, "artifact_open", return_value=Response(b"wrong bytes")):
with self.assertRaisesRegex(AgentError, "checksum mismatch"):
self.agent.decode_file(file)
with patch.object(self.agent, "artifact_open") as fetch:
with self.assertRaisesRegex(AgentError, "Invalid file content hash"):
self.agent.decode_file({"contentSha256": "../../secret"})
fetch.assert_not_called()
def test_same_id_reports_saved_result_without_executing_again(self):
self.create()
operation = self.operation("instance.restart")
+2 -2
View File
@@ -52,8 +52,8 @@ An already available Docker installation (including Docker CE) is reused. If the
daemon is unavailable, the installer reports the Docker error and stops before
enrollment; inspect `systemctl status docker` and `journalctl -u docker`.
The controller reconciles automatic setup on agent heartbeats and operation
polls. It first pulls `PLUTAINER_IMAGE` (default
The controller reconciles automatic setup on agent heartbeats. Command polls only
deliver work already queued. Setup first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for
T5 and for T6 when available, one setup operation at a time. Uploading new files
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
+14 -1
View File
@@ -47,7 +47,8 @@ minimum count as well as versions younger than the age threshold.
| Telemetry diagnostics | 7 days; 200 per server; 10,000 overall |
| Completed/interrupted matches | Indefinite |
| Closed betas and test evidence | Indefinite |
| Campaign file bodies and closed beta snapshots | No separate expiry |
| Campaign file bodies and closed beta snapshots | 30 days |
| Completed launch and command file bodies | 30 days |
| Unused artifact versions | No automatic expiry; no minimum count |
| Unused game files | No grace period; keep latest 1 version per game |
| Incomplete browser uploads | Indefinite |
@@ -83,6 +84,18 @@ state and rollback requests fail explicitly. Active beta rollback images stay
protected. Expiring matches removes their player participation rows and changes
leaderboard/player totals; lifetime aggregates are not retained separately.
Completed launch recipes and command receipts also retain their metadata when
their file bodies expire. Unfinished workflows, unreconciled commands, unresolved
failures and explicit holds preserve the bytes they need. Shared file bytes are
deleted only after their final reference expires; library versions can keep a file
available longer. Previously saved retention choices are preserved on upgrade.
The database upgrade normalizes retention clocks to UTC and enables incremental
vacuum. An existing database without a vacuum page map is rebuilt once at startup;
allow time for this rebuild and free disk space up to twice the database size.
Normal cleanup releases unused file blobs in bounded batches and incrementally
returns free pages to disk after larger prunes.
Game-file minimums apply per game. Grace starts when a version is superseded.
Live, stopped, rebuilding and provisioning instances pin their files; unresolved
operations and offline host copies also prevent controller deletion. Controller
+1
View File
@@ -12,6 +12,7 @@
"test": "vitest run",
"test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"diagnostics:database": "node scripts/database-diagnostics.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts",
"agent:package": "python3 agent/package_release.py",
"prebuild": "npm run agent:package",
+16
View File
@@ -0,0 +1,16 @@
import Database from 'better-sqlite3';
import { statSync } from 'node:fs';
const path = process.argv[2] ?? process.env.DB_PATH ?? './data/cherry.db';
const db = new Database(path, { readonly: true, fileMustExist: true });
try {
console.table(['', '-wal', '-shm'].map(suffix => {
try { return { file: path + suffix, bytes: statSync(path + suffix).size }; }
catch (error) { if (error.code !== 'ENOENT') throw error; return { file: path + suffix, bytes: 0 }; }
}));
console.table(db.prepare(`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`).all());
console.table(['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map(pragma => ({
pragma, value: db.pragma(pragma, { simple: true })
})));
} finally { db.close(); }
+6 -2
View File
@@ -13,6 +13,7 @@ export interface FleetSettings {
matchKeepDays: number;
betaKeepDays: number;
fileContentKeepDays: number;
payloadKeepDays: number;
versionKeepDays: number;
versionKeepCount: number;
gameFileGraceDays: number;
@@ -49,7 +50,8 @@ export const defaultFleetSettings: FleetSettings = {
telemetryMaxTotal: 10000,
matchKeepDays: 0,
betaKeepDays: 0,
fileContentKeepDays: 0,
fileContentKeepDays: 30,
payloadKeepDays: 30,
versionKeepDays: 0,
versionKeepCount: 0,
gameFileGraceDays: 0,
@@ -86,6 +88,7 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
matchKeepDays: 'Match history retention',
betaKeepDays: 'Completed beta history retention',
fileContentKeepDays: 'File diffs and rollback snapshot retention',
payloadKeepDays: 'Launch and command file retention',
versionKeepDays: 'Unused artifact version retention',
versionKeepCount: 'Minimum artifact versions to keep',
gameFileGraceDays: 'Unused game-file grace period',
@@ -129,7 +132,7 @@ export function validateFleetSettings(input: unknown): FleetSettings {
export const additionalConcurrencyKeys: (keyof FleetSettings)[] = ["restartConcurrencyPerHost", "createConcurrencyPerHost", "destroyConcurrencyPerHost", "lifecycleConcurrency", "rebuildConcurrency", "preparationConcurrency"];
export const concurrencyKeys: (keyof FleetSettings)[] = ['restartConcurrency', 'createConcurrency', 'destroyConcurrency', ...additionalConcurrencyKeys];
export const retentionKeys: (keyof FleetSettings)[] = ['deployKeepDays', 'observationKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'failureKeepDays', 'maintenanceKeepDays', 'recoveryKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays', 'metricsKeepDays', 'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'matchKeepDays', 'betaKeepDays', 'fileContentKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays', 'auditKeepDays', 'historyKeepCount'];
export const retentionKeys: (keyof FleetSettings)[] = ['deployKeepDays', 'observationKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'failureKeepDays', 'maintenanceKeepDays', 'recoveryKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays', 'metricsKeepDays', 'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'matchKeepDays', 'betaKeepDays', 'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays', 'auditKeepDays', 'historyKeepCount'];
export function settingMinimum(key: keyof FleetSettings): number {
return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0;
}
@@ -149,6 +152,7 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files. 0 keeps files indefinitely.',
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. Current and referenced versions are always protected.',
versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected. 0 adds no delay.',
+1
View File
@@ -22,6 +22,7 @@ export function ensureAuthSchema(handle: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL,
target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at);
`);
initialized.add(handle);
}
+9
View File
@@ -41,7 +41,16 @@ export function migrateNativeInstances(handle: Database.Database): void {
creation_id TEXT PRIMARY KEY REFERENCES server_deployments(id),
cleanup_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id)
);
CREATE INDEX IF NOT EXISTS idx_ec_host_migrations_instance ON ec_host_migrations(instance_id);
`);
if (!handle.prepare("SELECT 1 FROM settings WHERE key='native_file_plans_v1'").get()) {
handle.transaction(() => {
handle.exec(`UPDATE native_provisioning SET files_json=(SELECT json_group_array(json_remove(value,'$.contentBase64'))
FROM json_each(native_provisioning.files_json)) WHERE files_json IS NOT NULL;
INSERT INTO settings(key,value) VALUES('native_file_plans_v1','1');`);
})();
}
}
/** Accepted native creations receive their host port only when provisioning places them. */
+12
View File
@@ -0,0 +1,12 @@
import type Database from 'better-sqlite3';
export function tuneConnection(handle: Database.Database, reader = false): void {
handle.pragma(`cache_size = -${reader ? 32768 : 131072}`);
handle.pragma('temp_store = MEMORY');
// mmap is opt-in because the controller data directory can be a network mount.
const mmapMiB = Number(process.env.EC_SQLITE_MMAP_MB ?? 0);
if (Number.isSafeInteger(mmapMiB) && mmapMiB >= 0 && mmapMiB <= 32768) {
handle.pragma(`mmap_size = ${mmapMiB * 1024 * 1024}`);
}
if (!reader) handle.pragma('journal_size_limit = 67108864');
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from './index.js';
export function ensureFileBlobSchema(handle: Database.Database): void {
handle.exec(`CREATE TABLE IF NOT EXISTS file_blobs(sha256 TEXT PRIMARY KEY,size INTEGER NOT NULL,content BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS file_blob_references(owner_type TEXT NOT NULL,owner_id TEXT NOT NULL,
sha256 TEXT NOT NULL REFERENCES file_blobs(sha256),PRIMARY KEY(owner_type,owner_id,sha256));
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);`);
}
export function putFileBlob(content: Uint8Array | null, handle: Database.Database = db()): string | null {
if (content === null) return null;
const sha256 = createHash('sha256').update(content).digest('hex');
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(sha256, content.byteLength, content);
return sha256;
}
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as { content: Buffer } | undefined;
if (!row) throw new Error('File content is unavailable or has expired.');
return row.content;
}
/** SQL fragments are for fixed schema identifiers, never user input. */
export const blobContent = (column: string) => `(SELECT content FROM file_blobs WHERE sha256=${column})`;
export const blobSize = (column: string) => `(SELECT size FROM file_blobs WHERE sha256=${column})`;
export function referenceBlobs(ownerType: string, ownerId: string, hashes: Iterable<string>, handle: Database.Database = db()): void {
handle.prepare('DELETE FROM file_blob_references WHERE owner_type=? AND owner_id=?').run(ownerType, ownerId);
const insert = handle.prepare('INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256) VALUES(?,?,?)');
for (const hash of hashes) insert.run(ownerType, ownerId, hash);
}
/** Immutable file bytes in JSON are replaced with hash references, including before images. */
export function packFileJson(value: unknown, handle: Database.Database = db(), hashes = new Set<string>()): { value: unknown; hashes: Set<string> } {
if (Array.isArray(value)) return { value: value.map(item => packFileJson(item, handle, hashes).value), hashes };
if (!value || typeof value !== 'object') return { value, hashes };
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if ((key === 'contentBase64' || key === 'content' && 'path' in value && 'sha256' in value) && typeof item === 'string') {
const hash = putFileBlob(Buffer.from(item, 'base64'), handle)!;
result[key === 'content' ? 'contentHash' : 'contentSha256'] = hash;
hashes.add(hash);
} else {
result[key] = packFileJson(item, handle, hashes).value;
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') hashes.add(item);
}
}
return { value: result, hashes };
}
export function unpackFileJson(value: unknown, handle: Database.Database = db()): any {
if (Array.isArray(value)) return value.map(item => unpackFileJson(item, handle));
if (!value || typeof value !== 'object') return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') {
result[key === 'contentHash' ? 'content' : 'contentBase64'] = getFileBlob(item, handle).toString('base64');
} else result[key] = unpackFileJson(item, handle);
}
return result;
}
export const blobColumns = [
['script_versions', 'content'], ['staged_file_changes', 'content'],
['deployment_files', 'content_before'], ['deployment_files', 'content_after'],
['beta_file_snapshots', 'prior_content'], ['beta_revision_files', 'content']
] as const;
/** Finish each SELECT before writing; better-sqlite3 iterators hold an active cursor. */
export function* migrationRows<T>(handle: Database.Database, sql: string): Generator<T & { rid: number }> {
const select = handle.prepare(sql);
let cursor = -Number.MAX_SAFE_INTEGER;
for (;;) {
const row = select.get(cursor) as (T & { rid: number }) | undefined;
if (!row) return;
cursor = row.rid;
yield row;
}
}
/** Backfill one row at a time so large libraries do not exhaust startup memory. */
export function migrateFileBlobs(handle: Database.Database): void {
for (const [table, column] of blobColumns) {
if (!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(item => item.name === column)) continue;
handle.transaction(() => {
handle.exec(`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`);
const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`);
for (const row of migrationRows<{ content: Buffer | null }>(handle, `SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`)) {
update.run(putFileBlob(row.content, handle), row.rid);
}
handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
})();
}
for (const [table, column] of blobColumns) handle.exec(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`);
if (!handle.prepare("SELECT 1 FROM settings WHERE key='recipe_blobs_v1'").get()) {
handle.transaction(() => {
for (const row of migrationRows<{ deployment_id: string; recipe: string }>(handle,
'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1')) {
const packed = packFileJson(JSON.parse(row.recipe), handle);
handle.prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(packed.value), row.deployment_id);
referenceBlobs('recipe', row.deployment_id, packed.hashes, handle);
}
handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')");
})();
}
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_recipe_blobs AFTER DELETE ON server_launch_recipes BEGIN
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=OLD.deployment_id; END;`);
for (const action of ['INSERT', 'UPDATE OF recipe']) handle.exec(`CREATE TRIGGER IF NOT EXISTS reference_recipe_blobs_${action.startsWith('UPDATE') ? 'update' : 'insert'}
AFTER ${action} ON server_launch_recipes BEGIN
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=NEW.deployment_id;
INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256)
SELECT 'recipe',NEW.deployment_id,json_extract(value,'$.contentHash') FROM json_each(NEW.recipe,'$.files')
WHERE json_extract(value,'$.contentHash') IS NOT NULL; END;`);
}
export function pruneFileBlobs(limit = 100): number {
return db().prepare(`DELETE FROM file_blobs WHERE sha256 IN (SELECT b.sha256 FROM file_blobs b
WHERE NOT EXISTS (SELECT 1 FROM file_blob_references r WHERE r.sha256=b.sha256)
${blobColumns.map(([table, column]) => `AND NOT EXISTS (SELECT 1 FROM ${table} WHERE ${column}_hash=b.sha256)`).join('\n')}
LIMIT ?)`).run(limit).changes;
}
+30 -1
View File
@@ -10,8 +10,15 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js';
import { migrateNativeInstances } from '../backends/schema.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { migrateVersionLayout } from './version-layout.js';
import { migrateFileBlobs } from './file-blobs.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { tuneConnection } from './connection.js';
import { timeStatement } from './slow-statements.js';
let _db: Database.Database | null = null;
let optimizeTimer: NodeJS.Timeout | undefined;
// Desired shape of the `servers` table. Every column here is added to the
// table on boot if missing — introspection via PRAGMA table_info handles
@@ -312,7 +319,9 @@ export function db(): Database.Database {
// silently breaks every subsequent query without a chance to retry.
try {
handle.pragma('journal_mode = WAL');
handle.pragma('auto_vacuum = INCREMENTAL');
handle.pragma('foreign_keys = ON');
tuneConnection(handle);
dropLegacyBetaTables(handle);
handle.exec(schemaSql);
migrateProvisioningActions(handle);
@@ -386,6 +395,8 @@ export function db(): Database.Database {
backfillLeaderboard(handle);
initializeFleetSettings(handle);
migrateNativeInstances(handle);
migrateVersionLayout(handle);
migrateFileBlobs(handle);
// Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history.
// Install after migrations that rebuild this table or add server_deleted_at.
@@ -403,7 +414,17 @@ export function db(): Database.Database {
ON server_deployments(finished_at) WHERE server_deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
installPerformanceIndexes(handle);
installReportRevisions(handle);
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'], server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'], watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'], script_versions: ['uploaded_at'], retention_controls: ['resolved_at']
});
// Existing databases require one rebuild to gain the incremental-vacuum page map.
enableIncrementalVacuum(handle);
handle.pragma('optimize = 0x10002');
} catch (err) {
handle.close();
throw err;
@@ -414,21 +435,29 @@ export function db(): Database.Database {
// Callers share statement options (e.g. raw()); use consistent options for
// identical SQL and pass values to run/get/all rather than binding permanently.
const statements = new Map<string, Database.Statement>();
const slowMs = Number(process.env.EC_SLOW_SQL_MS ?? 0);
const prepare = handle.prepare.bind(handle);
handle.prepare = ((sql: string) => {
let statement = statements.get(sql);
if (!statement) {
statement = prepare(sql);
statement = timeStatement(prepare(sql), sql, slowMs);
statements.set(sql, statement);
}
return statement;
}) as typeof handle.prepare;
_db = handle;
optimizeTimer = setInterval(() => {
try { handle.pragma('optimize'); }
catch (error) { console.error('[database] optimize failed:', error); }
}, 60 * 60_000);
optimizeTimer.unref();
return _db;
}
export function closeDb(): void {
clearInterval(optimizeTimer);
optimizeTimer = undefined;
if (_db) {
_db.close();
_db = null;
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** Install after additive column and history migrations, on new and existing databases. */
export function installPerformanceIndexes(handle: Database.Database): void {
handle.exec(`
CREATE INDEX IF NOT EXISTS idx_staged_files_deployment_file ON staged_file_changes(deployment_file_id);
CREATE INDEX IF NOT EXISTS idx_beta_test_runs_beta_revision ON beta_test_runs(beta_id, revision);
CREATE INDEX IF NOT EXISTS idx_deployments_beta ON deployments(beta_id);
CREATE INDEX IF NOT EXISTS idx_deployments_script ON deployments(script_id);
CREATE INDEX IF NOT EXISTS idx_deployments_version ON deployments(script_version_id);
CREATE INDEX IF NOT EXISTS idx_deployments_prior ON deployments(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_script ON deployment_scripts(script_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_version ON deployment_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_prior ON deployment_scripts(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_server_scripts_version ON server_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_betas_version ON betas(script_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_scripts_version ON beta_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_servers_prior ON beta_servers(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_script ON beta_server_scripts(script_id);
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_prior ON beta_server_scripts(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_file_snapshots_server ON beta_file_snapshots(server);
CREATE INDEX IF NOT EXISTS idx_server_launch_previews_deployment ON server_launch_previews(deployment_id);
CREATE INDEX IF NOT EXISTS idx_server_collection_previews_collection ON server_collection_previews(collection_id);
CREATE INDEX IF NOT EXISTS idx_server_collection_removals_deployment ON server_collection_removals(deployment_id);
CREATE INDEX IF NOT EXISTS idx_server_creation_dependencies_removal ON server_creation_dependencies(removal_id);
`);
}
+45
View File
@@ -0,0 +1,45 @@
import type Database from 'better-sqlite3';
/** Normalize age-policy clocks once, then compare indexed ISO UTC strings directly. */
export function retentionClocks(handle: Database.Database, clocks: Record<string, string[]>): void {
handle.exec('CREATE TABLE IF NOT EXISTS database_maintenance_migrations(name TEXT PRIMARY KEY)');
for (const [table, columns] of Object.entries(clocks)) {
const marker = `iso-clocks:${table}:v1`;
if (!handle.prepare('SELECT 1 FROM database_maintenance_migrations WHERE name=?').get(marker)) {
handle.transaction(() => {
for (const column of columns) handle.exec(`UPDATE ${table} SET ${column}=strftime('%Y-%m-%dT%H:%M:%fZ',${column})
WHERE julianday(${column}) IS NOT NULL AND ${column}!=strftime('%Y-%m-%dT%H:%M:%fZ',${column});`);
handle.prepare('INSERT INTO database_maintenance_migrations(name) VALUES(?)').run(marker);
})();
}
const indexes = handle.prepare(`PRAGMA index_list(${table})`).all() as { name: string; partial: number }[];
const indexed = new Set(indexes.filter(index => !index.partial).map(index =>
(handle.prepare(`PRAGMA index_info("${index.name.replaceAll('"', '""')}")`).get() as { name: string }).name));
for (const column of columns) if (!indexed.has(column)) {
handle.exec(`CREATE INDEX IF NOT EXISTS idx_retention_${table}_${column} ON ${table}(${column})`);
}
}
}
/** VACUUM can renumber implicit rowids. Keep host enable cutoffs tied to command IDs across crashes. */
export function enableIncrementalVacuum(handle: Database.Database): void {
if (handle.pragma('auto_vacuum', { simple: true }) !== 2) {
handle.exec(`CREATE TABLE IF NOT EXISTS database_vacuum_boundaries(host_id TEXT PRIMARY KEY, operation_id TEXT)`);
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) {
handle.exec(`INSERT OR IGNORE INTO database_vacuum_boundaries
SELECT e.host_id,(SELECT o.id FROM ec_host_operations o WHERE o.host_id=e.host_id
AND o.rowid<=e.operation_floor ORDER BY o.rowid DESC LIMIT 1) FROM ec_host_enable_requests e`);
}
handle.pragma('auto_vacuum = INCREMENTAL');
handle.exec('VACUUM');
}
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()) {
handle.transaction(() => {
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) handle.exec(`
UPDATE ec_host_enable_requests AS e SET operation_floor=COALESCE((
SELECT o.rowid FROM database_vacuum_boundaries b JOIN ec_host_operations o ON o.id=b.operation_id
WHERE b.host_id=e.host_id),0) WHERE e.host_id IN (SELECT host_id FROM database_vacuum_boundaries)`);
handle.exec('DROP TABLE database_vacuum_boundaries');
})();
}
}
+16 -6
View File
@@ -1,3 +1,13 @@
-- Immutable file bodies are shared across versions, campaigns, betas and host commands.
CREATE TABLE IF NOT EXISTS file_blobs (
sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, content BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS file_blob_references (
owner_type TEXT NOT NULL, owner_id TEXT NOT NULL, sha256 TEXT NOT NULL REFERENCES file_blobs(sha256),
PRIMARY KEY(owner_type,owner_id,sha256)
);
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model
@@ -130,11 +140,11 @@ CREATE TABLE IF NOT EXISTS script_versions (
release_version TEXT, -- publisher's version/tag, independent of upload order
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
content BLOB NOT NULL,
notes TEXT,
state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed'
kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind for join-free filtering
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
content_hash TEXT REFERENCES file_blobs(sha256),
UNIQUE(script_id, version)
);
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
@@ -201,7 +211,7 @@ CREATE TABLE IF NOT EXISTS beta_revision_files (
version INTEGER NOT NULL,
release_version TEXT,
sha256 TEXT NOT NULL,
content BLOB NOT NULL,
content_hash TEXT REFERENCES file_blobs(sha256),
PRIMARY KEY (beta_id, revision, script_id),
FOREIGN KEY (beta_id, revision) REFERENCES beta_revisions(beta_id, revision) ON DELETE CASCADE
);
@@ -306,7 +316,7 @@ CREATE TABLE IF NOT EXISTS beta_file_snapshots (
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
rel_path TEXT NOT NULL,
prior_content BLOB NOT NULL,
prior_content_hash TEXT REFERENCES file_blobs(sha256),
prior_sha256 TEXT NOT NULL,
prior_size INTEGER NOT NULL,
PRIMARY KEY (beta_id, server, rel_path)
@@ -364,8 +374,8 @@ CREATE TABLE IF NOT EXISTS deployment_files (
rel_path TEXT NOT NULL,
sha256_before TEXT,
sha256_after TEXT,
content_before BLOB,
content_after BLOB,
content_before_hash TEXT REFERENCES file_blobs(sha256),
content_after_hash TEXT REFERENCES file_blobs(sha256),
-- An unknown before image is distinct from a confirmed absent file.
snapshot_pending INTEGER NOT NULL DEFAULT 0
);
@@ -382,7 +392,7 @@ CREATE TABLE IF NOT EXISTS staged_file_changes (
server TEXT NOT NULL,
kind TEXT NOT NULL,
rel_path TEXT NOT NULL,
content BLOB,
content_hash TEXT REFERENCES file_blobs(sha256),
script_version_id INTEGER,
inventory_source TEXT NOT NULL,
deployment_file_id INTEGER REFERENCES deployment_files(id) ON DELETE CASCADE,
+19
View File
@@ -0,0 +1,19 @@
import type Database from 'better-sqlite3';
/** Log query shapes, never bound values or SQL string literals. Disabled by default. */
export function timeStatement(statement: Database.Statement, sql: string, thresholdMs: number): Database.Statement {
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return statement;
const shape = sql.replace(/'(?:''|[^'])*'/g, '?').replace(/\s+/g, ' ').trim().slice(0, 200);
for (const method of ['run', 'get', 'all'] as const) {
const original = statement[method].bind(statement) as (...args: unknown[]) => unknown;
Object.defineProperty(statement, method, { value: (...args: unknown[]) => {
const started = performance.now();
try { return original(...args); }
finally {
const ms = performance.now() - started;
if (ms >= thresholdMs) console.warn('[sql]', { ms: Math.round(ms), method, sql: shape });
}
} });
}
return statement;
}
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** Keep metadata ahead of overflow pages, preserving IDs and all references. */
export function migrateVersionLayout(handle: Database.Database): void {
const columns = handle.pragma('table_info(script_versions)') as { name: string }[];
if (!columns.some(column => column.name === 'content') || columns.at(-1)?.name === 'content') return;
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE name='script_versions'").get() as { sql: string };
const content = /\bcontent\s+BLOB\s+NOT\s+NULL\s*,/i;
if (!content.test(sql)) throw new Error('Unrecognized script version content layout');
const replacement = sql.replace(content, '').replace(/,\s*UNIQUE\s*\(/i, ', content BLOB NOT NULL, UNIQUE(')
.replace('script_versions', 'script_versions_metadata');
const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name='script_versions' AND type IN ('index','trigger') AND sql IS NOT NULL")
.all() as { sql: string }[];
const sequence = handle.prepare("SELECT seq FROM sqlite_sequence WHERE name='script_versions'").get() as { seq: number } | undefined;
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement);
const names = columns.map(column => `"${column.name}"`).join(',');
handle.exec(`INSERT INTO script_versions_metadata (${names}) SELECT ${names} FROM script_versions;
DROP TABLE script_versions; ALTER TABLE script_versions_metadata RENAME TO script_versions;`);
if (sequence) handle.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='script_versions'").run(sequence.seq);
for (const dependent of dependents) handle.exec(dependent.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Version layout migration found broken references');
})();
} finally { handle.pragma('foreign_keys = ON'); }
}
+40
View File
@@ -0,0 +1,40 @@
import { db } from '../db/index.js';
import type { BetaRow } from './beta.js';
const positive = (value: string | null, fallback: number) => {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
};
export function betaListPage(params: URLSearchParams) {
const state = params.get('state'), view = params.get('view'), search = (params.get('q') ?? '').slice(0, 200);
const predicates: string[] = [], args: (string | number)[] = [];
if (state) { predicates.push('b.state=?'); args.push(state); }
else if (view === 'active') predicates.push("b.state IN ('running','ready-promote')");
else if (view === 'history') predicates.push("b.state IN ('promoted','rolled-back')");
if (search) {
predicates.push(`(instr(lower('#'||b.id||' '||COALESCE(b.notes,'')),lower(?))>0 OR EXISTS (
SELECT 1 FROM beta_scripts bs JOIN scripts s ON s.id=bs.script_id WHERE bs.beta_id=b.id AND instr(lower(s.name),lower(?))>0))`);
args.push(search, search);
}
const where = predicates.length ? ' WHERE ' + predicates.join(' AND ') : '';
const total = (db().prepare('SELECT COUNT(*) n FROM betas b' + where).get(...args) as { n: number }).n;
const pageSize = Math.min(100, positive(params.get('limit'), 25));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(totalPages, positive(params.get('page'), 1));
const betas = db().prepare('SELECT b.* FROM betas b' + where + ' ORDER BY b.id DESC LIMIT ? OFFSET ?')
.all(...args, pageSize, (page - 1) * pageSize) as BetaRow[];
const counts = db().prepare(`SELECT COUNT(CASE WHEN state IN ('running','ready-promote') THEN 1 END) active,
COUNT(CASE WHEN state IN ('promoted','rolled-back') THEN 1 END) history FROM betas`).get() as { active: number; history: number };
return { betas, page, pageSize, total, totalPages, counts };
}
export function betaActivityPage(id: number, params = new URLSearchParams()) {
const before = positive(params.get('before'), Number.MAX_SAFE_INTEGER);
const limit = Math.min(100, positive(params.get('limit'), 50));
const rows = db().prepare('SELECT id,created_at,message FROM beta_events WHERE beta_id=? AND id<? ORDER BY id DESC LIMIT ?')
.all(id, before, limit + 1) as { id: number; created_at: string; message: string }[];
const more = rows.length > limit;
const events = rows.slice(0, limit).reverse();
return { events, nextBefore: more ? events[0].id : null };
}
+5 -4
View File
@@ -12,8 +12,8 @@ export function archiveBetaRevision(id: number, revision: number, startedAt: str
db().prepare('INSERT OR IGNORE INTO beta_revisions (beta_id, revision, started_at, notes) VALUES (?, ?, ?, ?)')
.run(id, revision, startedAt, notes);
db().prepare(`INSERT OR IGNORE INTO beta_revision_files
(beta_id, revision, script_id, name, kind, path, version_id, version, release_version, sha256, content)
SELECT bs.beta_id, ?, s.id, s.name, s.kind, s.target_rel_path, v.id, v.version, v.release_version, v.sha256, v.content
(beta_id, revision, script_id, name, kind, path, version_id, version, release_version, sha256, content_hash)
SELECT bs.beta_id, ?, s.id, s.name, s.kind, s.target_rel_path, v.id, v.version, v.release_version, v.sha256, v.content_hash
FROM beta_scripts bs JOIN scripts s ON s.id = bs.script_id JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ?`).run(revision, id);
}
@@ -21,9 +21,10 @@ export function archiveBetaRevision(id: number, revision: number, startedAt: str
export function betaRevisionHistory(id: number): BetaRevision[] {
const rows = db().prepare('SELECT revision, started_at, notes, results_json FROM beta_revisions WHERE beta_id = ? ORDER BY revision DESC')
.all(id) as Array<{ revision: number; started_at: string; notes: string | null; results_json: string | null }>;
const files = db().prepare(`SELECT revision, script_id, name, kind, path, version_id, version, release_version, sha256
FROM beta_revision_files WHERE beta_id = ? ORDER BY script_id`).all(id) as Array<BetaRevision['files'][number] & { revision: number }>;
return rows.map(({ results_json, ...row }) => ({ ...row,
results: results_json ? JSON.parse(results_json) : null,
files: db().prepare(`SELECT script_id, name, kind, path, version_id, version, release_version, sha256
FROM beta_revision_files WHERE beta_id = ? AND revision = ? ORDER BY script_id`).all(id, row.revision) as BetaRevision['files']
files: files.filter(file => file.revision === row.revision).map(({ revision, ...file }) => file)
}));
}
+3 -2
View File
@@ -7,6 +7,7 @@ import type { GameTelemetry } from '../../telemetry.js';
import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { currentBetaRevision } from './beta-revisions.js';
import { invalidateBeta } from '../pipeline/bus.js';
export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string }
export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null }
@@ -15,7 +16,7 @@ const activeParticipant = (id: number, server: string) => !!db().prepare(`SELECT
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server);
export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined;
export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined;
export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); }
export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); invalidateBeta(id); }
export function setOutcome(id: number, outcome: string, reason: string | null): void {
const previous = storedPlan(id);
if (!previous || (previous.outcome === outcome && previous.reason === reason)) return;
@@ -23,7 +24,7 @@ export function setOutcome(id: number, outcome: string, reason: string | null):
activity(id, `Test plan ${outcome}${reason ? `: ${reason}` : ''}`);
}
export function readMetrics(run: TestRun): TestMetrics { return { ...emptyMetrics(), ...JSON.parse(run.metrics) }; }
export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); }
export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); invalidateBeta(run.beta_id); }
export function evidence(run: TestRun, eventId: string, kind: string, payload: unknown): boolean {
return db().prepare('INSERT OR IGNORE INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, ?, ?, ?, ?)')
.run(run.id, eventId, nowIso(), kind, JSON.stringify(payload)).changes > 0;
+20 -18
View File
@@ -1,3 +1,5 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
@@ -89,7 +91,7 @@ function composeCfgForServer(
if (inputs.inBetaBaseline) {
const s = getScript(inputs.inBetaBaseline.script_id);
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
baselineText = inputs.inBetaBaseline.content.toString('utf8');
baselineText = getVersionContent(inputs.inBetaBaseline.id).toString('utf8');
baselineScriptId = s.id;
}
}
@@ -99,7 +101,7 @@ function composeCfgForServer(
if (promoted?.current_version_id) {
const ver = getVersion(promoted.current_version_id);
if (ver) {
baselineText = ver.content.toString('utf8');
baselineText = getVersionContent(ver.id).toString('utf8');
baselineScriptId = promoted.id;
}
}
@@ -122,7 +124,7 @@ function composeCfgForServer(
if (!s.current_version_id) continue;
const ver = getVersion(s.current_version_id);
if (!ver) continue;
promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') });
promotedOverlays.push({ id: s.id, text: getVersionContent(ver.id).toString('utf8') });
promotedIdsSeen.add(s.id);
}
};
@@ -132,7 +134,7 @@ function composeCfgForServer(
// In-beta overlays scoped to this server.
const inBetaScopedOverlays = inputs.inBetaOverlays
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
.map(({ script, version }) => ({ id: script.id, text: getVersionContent(version.id).toString('utf8') }));
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
return { text: composed, baselineScriptId };
@@ -604,7 +606,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
const server = getServer(name)!;
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
relPath: script.target_rel_path, content: version.content, scriptVersionId: version.id, inventorySource: `beta:${betaId}` }));
relPath: script.target_rel_path, content: getVersionContent(version.id), scriptVersionId: version.id, inventorySource: `beta:${betaId}` }));
if (resolved.some(row => isCollapsingKind(row.script.kind))) {
const composed = composeCfgForServer(name, server.game, server.map, {
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
@@ -639,7 +641,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) {
const base = posix.basename(r.script.target_rel_path);
await writeFile(join(archive, base), r.version.content);
await writeFile(join(archive, base), getVersionContent(r.version.id));
}
// Persist the requested bytes without touching any live destination. Provisioning
@@ -650,7 +652,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
try {
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
kind: script.kind, relPath: script.target_rel_path, content: version.content,
kind: script.kind, relPath: script.target_rel_path, content: getVersionContent(version.id),
scriptVersionId: version.id, inventorySource: `beta:${betaId}`
}));
if (collapsingBaseline || collapsingOverlays.length > 0) {
@@ -733,7 +735,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
@@ -796,10 +798,10 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
}
for (const change of restorations) {
const file = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, NULL, ?, NULL, ?)`).run(deploymentId, change.server,
change.content === null ? 'removed' : 'updated', change.relPath,
change.content === null ? null : sha256Buffer(change.content), change.content);
change.content === null ? null : sha256Buffer(change.content), putFileBlob(change.content));
queueFileChange({ sourceKind: 'campaign', sourceId: deploymentId, server: change.server,
kind: change.kind, relPath: change.relPath, content: change.content,
scriptVersionId: change.versionId, inventorySource: `rollback:${betaId}`,
@@ -968,7 +970,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
const plans: Plan[] = [];
const getSnapshotForPromote = db().prepare(
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256 FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
@@ -990,7 +992,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
priorSha = snap.prior_sha256;
} else if (bss.prior_version_id) {
const pv = getVersion(bss.prior_version_id);
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
if (pv) { priorContent = getVersionContent(pv.id); priorSha = pv.sha256; }
}
plan = {
script, version, server: name,
@@ -1095,7 +1097,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
);
const insertDepFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after, snapshot_pending)
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash, snapshot_pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
@@ -1120,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
p.script.target_rel_path,
p.priorSha,
p.version.sha256,
p.priorContent,
p.version.content,
putFileBlob(p.priorContent),
putFileBlob(getVersionContent(p.version.id)),
betaServerSet.has(p.server) ? 0 : 1
);
if (plansToActivate.has(p)) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
kind: p.script.kind, relPath: p.script.target_rel_path, content: p.version.content,
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
}
@@ -1140,8 +1142,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
configFileOf(cp.server),
cp.priorSha,
cp.composedSha,
cp.priorContent,
cp.composedContent,
putFileBlob(cp.priorContent),
putFileBlob(cp.composedContent),
betaServerSet.has(cp.server) ? 0 : 1
);
if (cp.requiresActivation) {
+8 -6
View File
@@ -1,3 +1,5 @@
import { putFileBlob } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { readdir, readFile, stat, nativeFileManifest, type FileFingerprint } from '../backends/files.js';
import { randomUUID } from 'node:crypto';
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
@@ -275,7 +277,7 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
}
if (!entry.scriptVersionId) return null;
const ver = getVersion(entry.scriptVersionId);
return ver?.content ?? null;
return ver ? getVersionContent(ver.id) : null;
}
function composeExpectedCfg(
@@ -400,11 +402,11 @@ export function createDriftRebuildCampaign(
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'drift', ?, 'watchdog')")
.run(id, new Date().toISOString());
const insertFile = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
for (const { file, before, after } of prepared.files) {
insertFile.run(id, server, after === null ? 'removed' : before === null ? 'added' : 'updated',
file.relPath, file.beforeSha256, file.afterSha256, before, after);
file.relPath, file.beforeSha256, file.afterSha256, putFileBlob(before), putFileBlob(after));
}
db().prepare(`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
VALUES (?, ?, ?, 'pending', ?)`).run(id, server, prepared.files.length, operationId);
@@ -479,7 +481,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
);
const insertFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
@@ -512,7 +514,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
const content = await readFile(abs);
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
db().transaction(() => {
const file = insertFile.run(depId, s.server, 'removed', entry.relPath, sha256Buffer(content), null, content, null);
const file = insertFile.run(depId, s.server, 'removed', entry.relPath, sha256Buffer(content), null, putFileBlob(content), null);
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
kind: entry.kind, relPath: entry.relPath, content: null,
inventorySource: `drift:${depId}`, deploymentFileId: Number(file.lastInsertRowid),
@@ -537,7 +539,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
db().transaction(() => {
const file = insertFile.run(depId, s.server, before ? 'updated' : 'added', entry.relPath,
before ? sha256Buffer(before) : null, sha256Buffer(written), before, written);
before ? sha256Buffer(before) : null, sha256Buffer(written), putFileBlob(before), putFileBlob(written));
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
kind: entry.kind, relPath: entry.relPath, content: written,
scriptVersionId: entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
+41 -37
View File
@@ -8,6 +8,7 @@ import { rconStatusResult, agentRconStatusResult, type AgentRconReply } from '..
import { withOperationSource } from '../hosts/operation-context.js';
import { getServer } from './servers.js';
import { latestGameSubmission } from './lifecycle-evidence.js';
import type { MonitoringSnapshot } from './monitoring-snapshot.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
const MAX_AGE_MS = 30_000;
@@ -25,19 +26,20 @@ interface Entry<T> {
export class MonitoringChangedError extends Error {}
export class MonitoringSkippedError extends Error {}
function identity(server: string, probe: Probe): Identity {
const row = getServer(server);
function identity(server: string, probe: Probe, snapshot?: MonitoringSnapshot): Identity {
const row = snapshot ? snapshot.get(server)?.server : getServer(server);
if (!row || row.destroyed_at) throw new MonitoringChangedError('Server is no longer registered; monitoring evidence is unavailable.');
const native = row.native_instance_id ? nativeInstance(server) : null;
if (row.native_instance_id && (!native || getHost(native.host_id)?.connectionState !== 'online')) {
const native = snapshot ? snapshot.get(server)?.native : row.native_instance_id ? nativeInstance(server) : null;
if (row.native_instance_id && (!native || (snapshot ? snapshot.get(server)?.host : getHost(native.host_id))?.connectionState !== 'online')) {
throw new Error('Host agent is offline; monitoring evidence is unavailable.');
}
// Stop requests also invalidate observations, even though they do not
// establish a new game-telemetry epoch in latestGameSubmission().
const lifecycle = db().prepare(`SELECT id, action, submitted_at FROM server_deployments
const savedLifecycle = snapshot?.get(server)?.lifecycle;
const lifecycle = snapshot ? savedLifecycle ? { id: savedLifecycle.id, action: savedLifecycle.action, submitted_at: savedLifecycle.submitted_at } : null : db().prepare(`SELECT id, action, submitted_at FROM server_deployments
WHERE server=? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`).get(server) ?? null;
return {
key: JSON.stringify([row.created_at, row.desired_state, latestGameSubmission(server), lifecycle,
key: JSON.stringify([row.created_at, row.desired_state, snapshot ? snapshot.get(server)?.submission : latestGameSubmission(server), lifecycle,
native ? [native.host_id, native.id, native.generation] : null,
probe === 'rcon' ? [row.rcon_host, config.rcon] : null]),
port: probe === 'rcon' ? native?.port ?? row.game_port : null,
@@ -57,9 +59,9 @@ function reusable(startedAt: number): boolean {
}
async function observe<T extends { ok: boolean }>(server: string, fresh: boolean, probe: Probe,
entries: Map<string, Entry<T>>, request: (server: string) => Promise<T>): Promise<Observation<T>> {
entries: Map<string, Entry<T>>, request: (server: string) => Promise<T>, snapshot?: MonitoringSnapshot, batch = false): Promise<Observation<T>> {
let current: Identity;
try { current = identity(server, probe); }
try { current = identity(server, probe, snapshot); }
catch (error) { entries.delete(server); throw error; }
const saved = entries.get(server);
const savedAt = saved?.observation ? Date.parse(saved.observation.observedAt) : saved?.startedAt ?? 0;
@@ -75,7 +77,8 @@ async function observe<T extends { ok: boolean }>(server: string, fresh: boolean
entries.set(server, entry);
while (entries.size > MAX_SERVERS) entries.delete(entries.keys().next().value!);
const pending = Promise.resolve().then(() => request(server)).then(result => {
const latest = identity(server, probe);
if (!batch) snapshot?.refresh(server);
const latest = identity(server, probe, snapshot);
if (!sameIdentity(current, latest) || newerEvidence(latest, entry.startedAt)) {
throw new MonitoringChangedError('Server changed during monitoring; the previous observation was discarded.');
}
@@ -105,17 +108,17 @@ interface MonitoringStatus {
// this snapshot must never dispatch a request or renew an observation's age.
const statuses = new Map<string, { snapshot?: MonitoringStatus }>();
export function monitoringIdentity(server: string): string {
const container = identity(server, 'inspect');
const game = identity(server, 'rcon');
export function monitoringIdentity(server: string, snapshot?: MonitoringSnapshot): string {
const container = identity(server, 'inspect', snapshot);
const game = identity(server, 'rcon', snapshot);
// Identity includes connection configuration; persist only its fingerprint.
return createHash('sha256').update(JSON.stringify([container.key, game.key, game.port])).digest('hex');
}
/** Capture ownership before a status pass; publish only evidence for that instance. */
export function monitoringStatusWriter(server: string) {
export function monitoringStatusWriter(server: string, snapshot?: MonitoringSnapshot) {
let before: string;
try { before = identity(server, 'inspect').key; }
try { before = identity(server, 'inspect', snapshot).key; }
catch { return () => {}; }
const slot = { snapshot: statuses.get(server)?.snapshot };
statuses.delete(server);
@@ -126,16 +129,16 @@ export function monitoringStatusWriter(server: string) {
// network requests are still in flight.
if (statuses.get(server) !== slot) return;
try {
if (identity(server, 'inspect').key !== before) return;
if (identity(server, 'inspect', snapshot).key !== before) return;
const current = slot.snapshot;
if (current && current.container.observedAt > container.observedAt) return;
slot.snapshot = { identity: monitoringIdentity(server), container, game };
slot.snapshot = { identity: monitoringIdentity(server, snapshot), container, game };
} catch { statuses.delete(server); }
};
}
/** Latest completed status-monitor pass, with stale or superseded evidence excluded. */
export function monitoringStatus(server: string): MonitoringStatus | null {
export function monitoringStatus(server: string, snapshot?: MonitoringSnapshot): MonitoringStatus | null {
const saved = statuses.get(server)?.snapshot;
if (!saved) return null;
const fresh = (at: string) => {
@@ -143,28 +146,28 @@ export function monitoringStatus(server: string): MonitoringStatus | null {
return age >= 0 && age <= ONLINE_MAX_MS;
};
try {
if (saved.identity !== monitoringIdentity(server)
if (saved.identity !== monitoringIdentity(server, snapshot)
|| !fresh(saved.container.observedAt)
|| newerEvidence(identity(server, 'inspect'), Date.parse(saved.container.observedAt))) return null;
|| newerEvidence(identity(server, 'inspect', snapshot), Date.parse(saved.container.observedAt))) return null;
const game = saved.game && fresh(saved.game.observedAt)
&& !newerEvidence(identity(server, 'rcon'), Date.parse(saved.game.observedAt)) ? saved.game : undefined;
&& !newerEvidence(identity(server, 'rcon', snapshot), Date.parse(saved.game.observedAt)) ? saved.game : undefined;
return { ...saved, game };
} catch { return null; }
}
/** Keep the actual sample time: a later heartbeat must not renew old evidence. */
export function heartbeatInspection(server: string): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null {
const row = getServer(server);
export function heartbeatInspection(server: string, snapshot?: MonitoringSnapshot): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null {
const row = snapshot ? snapshot.get(server)?.server : getServer(server);
if (!row?.native_instance_id || !row.host_id) return null;
const host = getHost(row.host_id);
const host = snapshot ? snapshot.get(server)?.host : getHost(row.host_id);
if (!host?.connected || !host.inventory.capabilities?.includes('instance.heartbeat-state')) return null;
const state = host.inventory.instanceStates?.find(state => state.instanceId === row.native_instance_id && state.generation === row.instance_generation);
if (!state) return null;
const at = Date.parse(state.observedAt);
const lifecycle = db().prepare(`SELECT MAX(MAX(submitted_at,COALESCE(finished_at,submitted_at))) AS at
const lifecycle = snapshot ? { at: snapshot.get(server)?.lifecycleAt } : db().prepare(`SELECT MAX(MAX(submitted_at,COALESCE(finished_at,submitted_at))) AS at
FROM server_deployments WHERE server=? AND server_deleted_at IS NULL`).get(server) as { at: string | null };
if (!reusable(at) || at < Date.parse(row.created_at) || at < Date.parse(lifecycle.at ?? '')
|| at < Date.parse(latestGameSubmission(server) ?? '') || at < Date.parse(row.container_checked_at ?? '')) return null;
|| at < Date.parse((snapshot ? snapshot.get(server)?.submission : latestGameSubmission(server)) ?? '') || at < Date.parse(row.container_checked_at ?? '')) return null;
return { observedAt: new Date(at).toISOString(), result: state.healthy
? { ok: true, state: state.state } : { ok: false, state: state.state, reason: `Container ${state.state}` } };
}
@@ -172,17 +175,17 @@ export function heartbeatInspection(server: string): Observation<Awaited<ReturnT
type RconObservation = Observation<Awaited<ReturnType<typeof rconStatusResult>>>;
/** Prime a whole host before the per-host queue admits just its first two servers. */
export function prepareMonitoringRcon(servers: string[]): Map<string, () => Promise<RconObservation>> {
export function prepareMonitoringRcon(servers: string[], snapshot?: MonitoringSnapshot): Map<string, () => Promise<RconObservation>> {
const probes = new Map<string, () => Promise<RconObservation>>();
const batches = new Map<string, { instances: NativeInstance[]; result: Promise<{ results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> }> }>();
for (const server of servers) {
const instance = nativeInstance(server);
const instance = snapshot ? snapshot.get(server)?.native : nativeInstance(server);
if (!instance) continue;
const host = getHost(instance.host_id);
const host = snapshot ? snapshot.get(server)?.host : getHost(instance.host_id);
if (!host?.connected || !host.inventory.capabilities?.includes('rcon.status-batch')) continue;
const container = heartbeatInspection(server);
const container = heartbeatInspection(server, snapshot);
if (container && !container.result.ok && container.result.state !== 'unknown') continue;
const before = identity(server, 'rcon');
const before = identity(server, 'rcon', snapshot);
const request = async () => {
let batch = batches.get(instance.host_id);
if (!batch) {
@@ -195,6 +198,7 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
}));
// Allow delivery and receipt overhead beyond the agent's 20-second budget.
const receipt = await waitHostOperationStatus(operation.id, 25_000);
snapshot?.refresh();
if (receipt.state !== 'completed') throw new Error(receipt.error ?? 'Host status batch did not complete.');
return receipt.result as { results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> };
});
@@ -216,11 +220,11 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
// Observe before dispatch, including servers still waiting for their
// queue slot. Consume failures too, so a failed batch is never retried
// once per server or left as an unhandled rejected promise.
const pending = observe(server, false, 'rcon', rcon, request).then(value => ({ value }), error => ({ error }));
const pending = observe(server, false, 'rcon', rcon, request, snapshot, true).then(value => ({ value }), error => ({ error }));
probes.set(server, async () => {
const result = await pending;
if ('error' in result) throw result.error;
const current = identity(server, 'rcon');
const current = identity(server, 'rcon', snapshot);
if (!sameIdentity(before, current) || newerEvidence(current, Date.parse(result.value.observedAt))) {
throw new MonitoringChangedError('Server changed before consuming the host status batch.');
}
@@ -231,13 +235,13 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
}
/** Routine monitoring only. Manual diagnostics and disruption gates use fresh probes directly. */
export function inspectForMonitoring(server: string, fresh = false) {
export function inspectForMonitoring(server: string, fresh = false, snapshot?: MonitoringSnapshot) {
if (fresh) statuses.delete(server);
const heartbeat = !fresh && heartbeatInspection(server);
const heartbeat = !fresh && heartbeatInspection(server, snapshot);
if (heartbeat) return Promise.resolve(heartbeat);
return observe(server, fresh, 'inspect', inspections, nativeHealth);
return observe(server, fresh, 'inspect', inspections, nativeHealth, snapshot);
}
export function rconForMonitoring(server: string, fresh = false) {
export function rconForMonitoring(server: string, fresh = false, snapshot?: MonitoringSnapshot) {
if (fresh) statuses.delete(server);
return observe(server, fresh, 'rcon', rcon, rconStatusResult);
return observe(server, fresh, 'rcon', rcon, rconStatusResult, snapshot);
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { packFileJson, unpackFileJson } from '../db/file-blobs.js';
import { randomUUID } from 'node:crypto';
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js';
import { config, configFileForGame } from '../config.js';
@@ -118,7 +119,7 @@ export function summarizeRecipe(recipe: LaunchRecipePlan, fingerprint = sha256Bu
/** Keep the small detail response beside the immutable attempted file bytes. */
export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } {
const encoded = JSON.stringify(recipe);
return { recipe: encoded, recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) };
return { recipe: JSON.stringify(packFileJson(recipe).value), recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) };
}
export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null {
@@ -137,7 +138,7 @@ export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSu
export function recipeForDeployment(id: string): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
.get(id) as { recipe: string; blueprint_name: string | null } | undefined;
return row ? { recipe: JSON.parse(row.recipe), blueprintName: row.blueprint_name } : null;
return row ? { recipe: unpackFileJson(JSON.parse(row.recipe)), blueprintName: row.blueprint_name } : null;
}
export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } {
+46
View File
@@ -0,0 +1,46 @@
import { db } from '../db/index.js';
import type { ArtifactKind, ScriptRow, ScriptVersionRow } from './scripts.js';
/** Fixed query count regardless of the number of artifacts or retained versions. */
export function scriptCatalog(kind?: ArtifactKind) {
const version = (alias: string) => `CASE WHEN ${alias}.id IS NOT NULL THEN json_object(
'id',${alias}.id,'version',${alias}.version,'release_version',${alias}.release_version,
'sha256',${alias}.sha256,'size',${alias}.size,'state',${alias}.state,'uploaded_at',${alias}.uploaded_at) END`;
const rows = db().prepare(`SELECT s.*, ${version('c')} current_json, ${version('v')} latest_json,
COALESCE(counts.n,0) versionCount, active.beta_id runningBetaId
FROM scripts s
LEFT JOIN (SELECT script_id, COUNT(*) n, MAX(version) latest FROM script_versions GROUP BY script_id) counts ON counts.script_id=s.id
LEFT JOIN script_versions v ON v.script_id=s.id AND v.version=counts.latest
LEFT JOIN script_versions c ON c.id=s.current_version_id
LEFT JOIN (SELECT bs.script_id, MIN(b.id) beta_id FROM beta_scripts bs JOIN betas b ON b.id=bs.beta_id
WHERE b.state IN ('running','ready-promote') GROUP BY bs.script_id) active ON active.script_id=s.id
${kind ? 'WHERE s.kind=?' : ''} ORDER BY s.name`).all(...(kind ? [kind] : [])) as Array<ScriptRow & {
current_json: string | null; latest_json: string | null; versionCount: number; runningBetaId: number | null;
}>;
const servers = db().prepare('SELECT name, game, map FROM servers ORDER BY name').all() as { name: string; game: string | null; map: string | null }[];
return rows.map(({ current_json, latest_json, ...row }) => ({ ...row,
currentVersion: current_json ? JSON.parse(current_json) as ScriptVersionRow : null,
latestVersion: latest_json ? JSON.parse(latest_json) as ScriptVersionRow : null,
servers: row.scope_type && row.scope_value ? servers.filter(server => server[row.scope_type!] === row.scope_value).map(server => server.name) : []
}));
}
export function betaScriptMetadata(ids: number[]) {
const rows = db().prepare(`SELECT bs.beta_id, bs.script_id id, COALESCE(s.name,'(deleted)') name,
COALESCE(s.target_rel_path,'') target_rel_path, COALESCE(s.kind,bs.kind,'script') kind,
v.id version_id, v.version, v.release_version, v.sha256
FROM beta_scripts bs LEFT JOIN scripts s ON s.id=bs.script_id
LEFT JOIN script_versions v ON v.id=bs.script_version_id
WHERE bs.beta_id IN (SELECT value FROM json_each(?)) ORDER BY bs.script_id`).all(JSON.stringify(ids)) as Array<{
beta_id: number; id: number; name: string; target_rel_path: string; kind: ArtifactKind;
version_id: number | null; version: number; release_version: string | null; sha256: string;
}>;
const grouped = new Map<number, Array<{ id: number; name: string; target_rel_path: string; kind: ArtifactKind;
version: { id: number; version: number; release_version: string | null; sha256: string } | null }>>();
for (const { beta_id, version_id, version, release_version, sha256, ...script } of rows) {
const group = grouped.get(beta_id) ?? [];
group.push({ ...script, version: version_id === null ? null : { id: version_id, version, release_version, sha256 } });
grouped.set(beta_id, group);
}
return grouped;
}
@@ -0,0 +1,91 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { HEARTBEAT_MAX_AGE_MS } from '../hosts/index.js';
import { parseHostInventory } from '../hosts/request-context.js';
import type { Host } from '../../hosts.js';
import type { NativeInstance } from '../backends/native.js';
import type { ServerRow } from './servers.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { serverObservation } from './status.js';
const nativeFields = ['id','server','host_id','generation','library_id','image','bind_address','port','public_address','public_port','created_at','retired_at'];
const sql = `SELECT s.*, n.id AS native_instance_id,n.host_id,n.generation AS instance_generation,n.library_id AS game_library_id,
CASE WHEN n.id IS NOT NULL THEN json_object(${nativeFields.map(key => `'${key}',n.${key}`).join(',')}) END AS instance_json,
h.inventory AS host_inventory,h.last_heartbeat AS host_heartbeat,
EXISTS(SELECT 1 FROM ec_host_credentials c WHERE c.host_id=h.id AND c.revoked_at IS NULL) AS credential_active,
(SELECT json_object('id',d.id,'action',d.action,'state',d.state,'submitted_at',d.submitted_at)
FROM server_deployments d WHERE d.server=s.name AND d.server_deleted_at IS NULL ORDER BY d.rowid DESC LIMIT 1) AS lifecycle_json,
(SELECT MAX(MAX(d.submitted_at,COALESCE(d.finished_at,d.submitted_at))) FROM server_deployments d
WHERE d.server=s.name AND d.server_deleted_at IS NULL) AS lifecycle_at,
(SELECT MAX(at) FROM (SELECT MAX(d.submitted_at) AS at FROM server_deployments d
WHERE d.server=s.name AND d.server_deleted_at IS NULL AND d.action IN ('create','start','restart','rebuild')
UNION ALL SELECT r.requested_at)) AS submission,
r.requested_at AS restart_at,r.healthy_at AS restart_healthy,t.received_at AS telemetry_at,t.payload AS telemetry_json
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
LEFT JOIN ec_hosts h ON h.id=n.host_id AND h.deleted_at IS NULL
LEFT JOIN restart_rollout r ON r.server=s.name LEFT JOIN server_telemetry t ON t.server=s.name`;
interface Row extends ServerRow {
instance_json: string | null; host_inventory: string | null; host_heartbeat: string | null; credential_active: number;
lifecycle_json: string | null; lifecycle_at: string | null; submission: string | null;
restart_at: string | null; restart_healthy: string | null; telemetry_at: string | null; telemetry_json: string | null;
}
interface Record {
server: ServerRow; native: NativeInstance | null;
host: Pick<Host, 'connected' | 'connectionState' | 'inventory'> | null;
lifecycle: { id: string; action: string; state: string; submitted_at: string | null } | null;
lifecycleAt: string | null; submission: string | null; telemetry: GameTelemetry | null;
restartAt: string | null; restartHealthy: string | null;
}
/** A pass shares its reads; each asynchronous evidence boundary refreshes in one joined query. */
export class MonitoringSnapshot {
private records = new Map<string, Record>();
constructor() { ensureHostSchema(db()); this.refresh(); }
refresh(server?: string): void {
const rows = server === undefined ? db().prepare(sql + ' WHERE s.destroyed_at IS NULL ORDER BY s.name').all() as Row[]
: db().prepare(sql + ' WHERE s.name=? AND s.destroyed_at IS NULL').all(server) as Row[];
if (server === undefined) this.records.clear(); else this.records.delete(server);
for (const row of rows) {
const { instance_json,host_inventory,host_heartbeat,credential_active,lifecycle_json,lifecycle_at,submission,
restart_at,restart_healthy,telemetry_at,telemetry_json,...fields } = row;
const connected = !!credential_active && !!host_heartbeat && Date.now() - Date.parse(host_heartbeat) <= HEARTBEAT_MAX_AGE_MS;
let telemetry: GameTelemetry | null = null;
if (telemetry_json) try {
const saved = JSON.parse(telemetry_json) as GameTelemetry;
const event = parseGameEvent(saved);
if (event.server === row.name) telemetry = { ...event, receivedAt: telemetry_at!, timing: saved.timing };
} catch { /* Invalid telemetry cannot establish health. */ }
this.records.set(row.name, { server: fields, native: instance_json ? JSON.parse(instance_json) : null,
host: host_inventory ? { connected, connectionState: connected ? 'online' : 'offline',
inventory: parseHostInventory(row.host_id!, host_inventory) } : null,
lifecycle: lifecycle_json ? JSON.parse(lifecycle_json) : null, lifecycleAt: lifecycle_at, submission, telemetry,
restartAt: restart_at, restartHealthy: restart_healthy });
}
}
get(server: string) { return this.records.get(server); }
servers() { return [...this.records.values()].map(record => record.server); }
provisioning(server: string) {
const operation = this.get(server)?.lifecycle;
return !!operation && ['create','rebuild','start'].includes(operation.action) && ['queued','running','awaiting-health'].includes(operation.state);
}
settling(server: string) {
const record = this.get(server);
return !!record?.restartAt && !record.restartHealthy && Date.now() - Date.parse(record.restartAt) < 15_000;
}
telemetryEvidence(server: string) {
const record = this.get(server), report = record?.telemetry;
if (!report) return null;
const received = Date.parse(report.receivedAt), age = Date.now() - received;
return Number.isFinite(age) && age >= 0 && age <= ONLINE_MAX_MS
&& (!record?.submission || received > Date.parse(record.submission)) ? report : null;
}
observe = (server: ServerRow) => serverObservation(server, {
provisioning: this.provisioning(server.name), settling: this.settling(server.name),
submission: this.get(server.name)?.submission ?? null, telemetry: this.get(server.name)?.telemetry ?? null
});
patch(server: string, fields: Partial<ServerRow>) {
const record = this.get(server);
if (record) record.server = { ...record.server, ...fields };
}
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { getVersion, getVersionMetadata, listScripts } from './scripts.js';
import { getVersion, getVersionContent, getVersionMetadata, listScripts } from './scripts.js';
function selectedArtifacts<Version>(game: string | null, map: string | null, readVersion: (id: number) => Version | undefined) {
return (['script', 'plugin'] as const).flatMap((kind) =>
@@ -14,7 +14,10 @@ function selectedArtifacts<Version>(game: string | null, map: string | null, rea
/** Published file artifacts shared by provisioning and drift reconciliation. */
export function promotedArtifacts(game: string | null, map: string | null) {
return selectedArtifacts(game, map, getVersion);
return selectedArtifacts(game, map, id => {
const version = getVersion(id);
return version ? { ...version, content: getVersionContent(id) } : undefined;
});
}
/** Metadata uses the same promotion and scope rules without reading file content. */
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import type { ArtifactKind } from '../config.js';
import { db } from '../db/index.js';
import { emit } from '../pipeline/bus.js';
@@ -101,7 +102,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
const fileRows = db()
.prepare(
`SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, f.content_before, f.content_after, f.snapshot_pending
`SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, ${blobContent('f.content_before_hash')} AS content_before, ${blobContent('f.content_after_hash')} AS content_after, f.snapshot_pending
FROM deployment_files f JOIN deployment_servers ds ON ds.deployment_id = f.deployment_id AND ds.server = f.server
JOIN servers s ON s.name = f.server
WHERE f.deployment_id = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL ORDER BY f.server, f.rel_path`
@@ -184,7 +185,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
);
const insertDepFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
@@ -226,8 +227,8 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
f.rel_path,
f.sha256_after,
f.sha256_before,
f.content_after,
f.content_before
putFileBlob(f.content_after),
putFileBlob(f.content_before)
);
const owningScript = scriptByTarget.get(f.rel_path);
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
+16 -8
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
import { configFileForGame, type ArtifactKind } from '../config.js';
@@ -32,7 +33,6 @@ export interface ScriptVersionRow {
release_version: string | null;
sha256: string;
size: number;
content: Buffer;
notes: string | null;
state: ScriptVersionState;
kind: ArtifactKind;
@@ -137,14 +137,22 @@ export function listPromotedOverlaysForScope(
return rows.filter((r) => !skip.has(r.id));
}
export const VERSION_COLUMNS = 'id, script_id, version, release_version, sha256, size, notes, state, kind, uploaded_at';
export function getVersionContent(id: number): Buffer {
const row = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM script_versions WHERE id = ?`).get(id) as { content: Buffer } | undefined;
if (!row?.content) throw new ScriptError(`version content is unavailable (id=${id})`, 'no-such-version');
return row.content;
}
export function listVersions(scriptId: number): ScriptVersionRow[] {
return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC')
.prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE script_id = ? ORDER BY version DESC`)
.all(scriptId) as ScriptVersionRow[];
}
export function getVersion(id: number): ScriptVersionRow | undefined {
return db().prepare('SELECT * FROM script_versions WHERE id = ?').get(id) as
return db().prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE id = ?`).get(id) as
| ScriptVersionRow
| undefined;
}
@@ -162,7 +170,7 @@ export function getVersionMetadata(id: number): ScriptVersionMetadata | undefine
// upload, whether or not it has been promoted (current_version_id).
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1')
.prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1`)
.get(scriptId) as ScriptVersionRow | undefined;
}
@@ -234,7 +242,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
VALUES (?, ?, ?, ?, ?)`
);
const insertVersion = db().prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version)
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`
);
@@ -243,7 +251,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
db().transaction(() => {
const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind);
scriptId = Number(s.lastInsertRowid);
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind, releaseVersion);
const v = insertVersion.run(scriptId, sha, size, putFileBlob(input.content), input.notes ?? null, kind, releaseVersion);
versionId = Number(v.lastInsertRowid);
})();
@@ -274,10 +282,10 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri
const res = db()
.prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version)
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind, release);
.run(scriptId, nextVersion, sha, content.byteLength, putFileBlob(content), notes ?? null, script.kind, release);
return getVersion(Number(res.lastInsertRowid))!;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from './scripts.js';
import { composeServerCfg } from './config-compose.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { selectedZombieMap, mapRotation } from './map-aliases.js';
@@ -23,7 +24,7 @@ export function getPromotedServerConfig(game: string | null, map: string | null,
...(map ? listPromotedOverlaysForScope('map', map) : [])
].flatMap((overlay) => {
const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : [];
return current ? [{ id: overlay.id, text: getVersionContent(current.id).toString('utf8') }] : [];
});
return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map, mapSlug), 'utf8');
return Buffer.from(composeServerCfg(getVersionContent(version.id).toString('utf8'), overlays, game, map, mapSlug), 'utf8');
}
+11 -10
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve, isAbsolute } from 'node:path';
import { destOfKind, serverDir, type ArtifactKind } from '../config.js';
@@ -39,22 +40,22 @@ function targetPath(server: string, kind: ArtifactKind, relPath: string): string
/** Capture immutable desired bytes without changing the server or its inventory. */
export function queueFileChange(change: FileChange): void {
targetPath(change.server, change.kind, change.relPath);
db().prepare(`INSERT INTO staged_file_changes (source_kind, source_id, server, kind, rel_path, content,
db().prepare(`INSERT INTO staged_file_changes (source_kind, source_id, server, kind, rel_path, content_hash,
script_version_id, inventory_source, deployment_file_id, preserve_before_image, check_before, expected_before_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_kind, source_id, server, kind, rel_path) DO UPDATE SET
content = excluded.content, script_version_id = excluded.script_version_id,
content_hash = excluded.content_hash, script_version_id = excluded.script_version_id,
inventory_source = excluded.inventory_source, deployment_file_id = excluded.deployment_file_id,
preserve_before_image = excluded.preserve_before_image, check_before = excluded.check_before,
expected_before_sha = excluded.expected_before_sha
WHERE staged_file_changes.state = 'pending'`)
.run(change.sourceKind, change.sourceId, change.server, change.kind, change.relPath, change.content,
.run(change.sourceKind, change.sourceId, change.server, change.kind, change.relPath, putFileBlob(change.content),
change.scriptVersionId ?? null, change.inventorySource, change.deploymentFileId ?? null,
change.preserveBeforeImage ? 1 : 0, change.expectedBeforeSha !== undefined ? 1 : 0, change.expectedBeforeSha ?? null);
}
export function stagedFileChanges(sourceKind: SourceKind, sourceId: string, server?: string): StagedFileChange[] {
return db().prepare(`SELECT * FROM staged_file_changes WHERE source_kind = ? AND source_id = ?
return db().prepare(`SELECT *, ${blobContent('content_hash')} AS content FROM staged_file_changes WHERE source_kind = ? AND source_id = ?
${server === undefined ? '' : 'AND server = ?'} ORDER BY id`)
.all(sourceKind, sourceId, ...(server === undefined ? [] : [server])) as StagedFileChange[];
}
@@ -81,13 +82,13 @@ function readExisting(path: string): Buffer | null {
}
/** The first activation owns the rollback image, including an absent file. */
export function captureBetaBeforeImage(row: StagedFileChange, before: Buffer | null): void {
export function captureBetaBeforeImage(row: Pick<StagedFileChange, 'source_id' | 'server' | 'rel_path' | 'kind'>, before: Buffer | null): void {
if (db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?')
.get(row.source_id, row.server, row.rel_path)) return;
const prior = getServerScript(row.server, row.rel_path);
if (before !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots
(beta_id, server, rel_path, prior_content, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`)
.run(row.source_id, row.server, row.rel_path, before, sha256Buffer(before), before.length);
(beta_id, server, rel_path, prior_content_hash, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`)
.run(row.source_id, row.server, row.rel_path, putFileBlob(before), sha256Buffer(before), before.length);
db().prepare(`UPDATE beta_server_scripts SET had_original = ?, prior_version_id = ? WHERE beta_id = ? AND server = ?
AND script_id IN (SELECT id FROM scripts WHERE target_rel_path = ? AND kind = ?)`)
.run(before === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, row.server, row.rel_path, row.kind);
@@ -100,7 +101,7 @@ export function captureBetaBeforeImage(row: StagedFileChange, before: Buffer | n
* from interleaving between the final gates and dispatching the restart.
*/
export function applyStagedFilesForOperation(operationId: string, server: string): void {
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
const rows = db().prepare(`SELECT f.*, ${blobContent('f.content_hash')} AS content FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind = f.source_kind AND s.source_id = f.source_id AND s.server = f.server
WHERE s.operation_id = ? AND f.server = ? AND f.state IN ('pending','applying') ORDER BY f.id`)
.all(operationId, server) as StagedFileChange[];
@@ -132,8 +133,8 @@ export function applyStagedFilesForOperation(operationId: string, server: string
}
if (row.deployment_file_id && !row.preserve_before_image) {
db().prepare(`UPDATE deployment_files SET
verb = ?, content_before = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`)
.run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', before,
verb = ?, content_before_hash = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`)
.run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', putFileBlob(before),
before === null ? null : sha256Buffer(before), row.deployment_file_id);
if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)')
.run(row.source_id, `${server}: captured rollback snapshot for ${row.rel_path}`);
+1
View File
@@ -48,6 +48,7 @@ export function ensureGameFileSchema(handle: Database.Database): void {
FROM game_file_versions v,json_each(v.manifest) f;
CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
CREATE INDEX IF NOT EXISTS idx_host_library_caches_version ON host_library_caches(version_id);
`);
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[];
if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
+42
View File
@@ -0,0 +1,42 @@
import type Database from 'better-sqlite3';
import { importantOperationSql } from './observations.js';
const important = (alias: string) => importantOperationSql.replaceAll('o.', `${alias}.`);
const visible = (alias: string) => `${alias}.history_expired_at IS NULL`;
/** Durable totals for global, host, server, and host/server filters; receipts update only changed membership. */
export function installHistoryCounts(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM ec_host_schema_migrations WHERE name='history-counts-v1'").get()) return;
handle.transaction(() => {
handle.exec(`CREATE TABLE ec_host_operation_history_counts (
host_id TEXT NOT NULL, server_name TEXT NOT NULL, total INTEGER NOT NULL,
routine INTEGER NOT NULL, important INTEGER NOT NULL, PRIMARY KEY(host_id,server_name)
) WITHOUT ROWID;
DROP INDEX ec_host_operations_important_history;
CREATE INDEX ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE history_expired_at IS NULL AND ${importantOperationSql.replaceAll('o.', '')};`);
for (const [host, server] of [['', ''], ['o.host_id', ''], ['', 'o.server_name'], ['o.host_id', 'o.server_name']]) {
const groups = [host, server].filter(Boolean);
handle.exec(`INSERT INTO ec_host_operation_history_counts
SELECT ${host || "''"},${server || "''"},COUNT(*),COALESCE(SUM(o.routine=1),0),COALESCE(SUM(${important('o')}),0)
FROM ec_host_operations o WHERE ${visible('o')} ${server ? 'AND o.server_name IS NOT NULL' : ''}
${groups.length ? 'GROUP BY ' + groups.join(',') : ''}`);
}
const adjust = (alias: string, direction: 1 | -1) => `
INSERT INTO ec_host_operation_history_counts(host_id,server_name,total,routine,important)
SELECT host_id,server_name,${direction},${direction}*(${alias}.routine=1),${direction}*${important(alias)} FROM (
SELECT '' host_id,'' server_name UNION ALL SELECT ${alias}.host_id,''
UNION ALL SELECT '',${alias}.server_name WHERE ${alias}.server_name IS NOT NULL
UNION ALL SELECT ${alias}.host_id,${alias}.server_name WHERE ${alias}.server_name IS NOT NULL
) WHERE ${visible(alias)}
ON CONFLICT(host_id,server_name) DO UPDATE SET total=total+excluded.total,
routine=routine+excluded.routine,important=important+excluded.important;`;
handle.exec(`CREATE TRIGGER operation_history_count_insert AFTER INSERT ON ec_host_operations BEGIN ${adjust('NEW', 1)} END;
CREATE TRIGGER operation_history_count_delete AFTER DELETE ON ec_host_operations BEGIN ${adjust('OLD', -1)} END;
CREATE TRIGGER operation_history_count_update AFTER UPDATE OF host_id,server_name,routine,state,observation_success,error,history_expired_at ON ec_host_operations
WHEN OLD.host_id IS NOT NEW.host_id OR OLD.server_name IS NOT NEW.server_name OR OLD.routine IS NOT NEW.routine
OR (${visible('OLD')}) IS NOT (${visible('NEW')}) OR ${important('OLD')} IS NOT ${important('NEW')}
BEGIN ${adjust('OLD', -1)} ${adjust('NEW', 1)} END;`);
handle.prepare("INSERT INTO ec_host_schema_migrations(name) VALUES('history-counts-v1')").run();
})();
}
+21 -8
View File
@@ -1,3 +1,4 @@
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
import { pruneHistory } from '../retention.js';
import { recordAgentReceipt } from '../provisioning/agent-events.js';
import { EventEmitter } from 'node:events';
@@ -396,7 +397,7 @@ export type HostOperationStatus = Omit<HostOperation, 'payload'>;
const operationStatusColumns = 'o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.result,o.error,o.created_at,o.updated_at';
const operationPayloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)';
function operationStatusFromRow(row: Omit<OperationRow, 'payload'>): HostOperationStatus { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
function operationFromRow(row: OperationRow): HostOperation { return { ...operationStatusFromRow(row), payload: JSON.parse(row.payload) }; }
function operationFromRow(row: OperationRow): HostOperation { const status = operationStatusFromRow(row); return { ...status, result: unpackFileJson(status.result), payload: unpackFileJson(JSON.parse(row.payload)) }; }
export function getHostOperation(id: string): HostOperation | null {
const row = database().prepare(`SELECT ${operationStatusColumns},${operationPayloadSql} AS payload FROM ec_host_operations o WHERE o.id=?`)
.get(id) as OperationRow | undefined;
@@ -425,7 +426,9 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number };
const countColumn = filter === 'all' ? 'total' : filter;
const total = (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
@@ -474,20 +477,24 @@ function enqueueHostOperation(hostId: string, kind: string, payload: string | Bu
FROM ec_host_operations o WHERE o.host_id=? AND o.idempotency_key=?`).get(payload, hostId, options.idempotencyKey) as
{ id: string; kind: string; instance_id: string | null; generation: number | null; history_expired_at: string | null; request_hash: string | null; same_payload: number } | undefined;
if (existing) {
const samePayload = existing.history_expired_at ? existing.request_hash === createHash('sha256').update(payload).digest('hex') : !!existing.same_payload;
const samePayload = existing.request_hash ? existing.request_hash === createHash('sha256').update(payload).digest('hex') : !!existing.same_payload;
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || !samePayload) throw new Error('Operation idempotency key is already associated with a different request.');
return getHostOperationStatus(existing.id)!;
}
}
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled' && kind !== 'instance.cancel-create' && !(host.enablingSince && ['runtime.pull','library.download','host.inspect'].includes(kind))) throw new Error('Host is disabled. Enable it before requesting operations.');
const requestHash = createHash('sha256').update(payload).digest('hex');
const packed = packFileJson(JSON.parse(payload.toString()));
payload = JSON.stringify(packed.value);
const id = options.id ?? randomUUID(); const timestamp = now();
const serverName = options.instanceId ? (database().prepare('SELECT server FROM native_instances WHERE id=?')
.get(options.instanceId) as { server: string } | undefined)?.server ?? null : null;
database().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at,source,server_name)
VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?)`)
database().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at,source,server_name,request_hash)
VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?,?)`)
.run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null,
routingPayload ?? payload, timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200), serverName);
routingPayload ?? payload, timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200), serverName, requestHash);
referenceBlobs('operation', id, packed.hashes);
if (routingPayload !== undefined) database().prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,CAST(? AS TEXT))').run(id, payload);
database().prepare(`UPDATE ec_host_operations AS o SET routine=CASE WHEN ${routineObservationSql} THEN 1 ELSE 0 END,
target_path=${operationTargetSql} WHERE o.id=?`).run(id);
@@ -562,7 +569,8 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
const row = database().prepare(`SELECT CAST(${operationPayloadSql} AS BLOB) AS payload FROM ec_host_operations o WHERE o.id=? AND o.host_id=?`)
.get(id, hostId) as { payload: Buffer } | undefined;
if (!row) throw new Error('Host operation was not found for this host.');
return row.payload;
if (!row.payload.includes('"contentSha256"') || getHost(hostId)?.inventory.capabilities?.includes('files.blobs')) return row.payload;
return Buffer.from(JSON.stringify(unpackFileJson(JSON.parse(row.payload.toString()))));
}
export function reportHostOperationStatus(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperationStatus {
const operation = database().transaction(() => {
@@ -575,8 +583,13 @@ export function reportHostOperationStatus(hostId: string, id: string, input: { s
const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation;
const error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000);
const result = input.result === undefined ? null : JSON.stringify(input.result);
let result = input.result === undefined ? null : JSON.stringify(input.result);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.');
if (result !== null) {
const packed = packFileJson(input.result);
result = JSON.stringify(packed.value);
referenceBlobs('operation-result', id, packed.hashes);
}
const observation = state === 'completed' && !error && successfulObservation(operation.kind,
operation.kind === 'rcon.execute' ? JSON.parse(getHostOperationPayload(hostId, id).toString('utf8')) : {}, input.result);
database().prepare('UPDATE ec_host_operations SET state=?,result=?,error=?,updated_at=?,observation_success=? WHERE id=?')
+2 -2
View File
@@ -173,7 +173,7 @@ function supersedeUndeliveredDownloads(hostId: string): void {
}
}
/** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */
/** Heartbeats drive durable preparation; idle checks do not reserve the SQLite writer. */
export function reconcileHostPreparation(hostId: string): HostPreparation {
return db().transaction(() => {
const host = getHost(hostId);
@@ -203,5 +203,5 @@ export function reconcileHostPreparation(hostId: string): HostPreparation {
scheduleAvailabilityRefresh();
}
return status;
}).immediate();
})();
}
+50
View File
@@ -1,3 +1,7 @@
import { createHash } from 'node:crypto';
import { ensureFileBlobSchema, migrationRows, packFileJson, referenceBlobs } from '../db/file-blobs.js';
import { retentionClocks } from '../db/retention-clocks.js';
import { installHistoryCounts } from './history-counts.js';
import type Database from 'better-sqlite3';
import { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js';
@@ -6,6 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
// Background readers open only after controller migrations have completed.
if (handle.readonly) { initialized.add(handle); return; }
ensureFileBlobSchema(handle);
handle.exec(`
CREATE TABLE IF NOT EXISTS ec_hosts (
id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL DEFAULT '',
@@ -89,6 +94,8 @@ export function ensureHostSchema(handle: Database.Database): void {
CHECK(stage IN ('queued','running','cleaned','failed','completed')),
requested_at TEXT NOT NULL, updated_at TEXT NOT NULL, cleaned_at TEXT, error TEXT
);
CREATE INDEX IF NOT EXISTS idx_ec_host_credentials_host ON ec_host_credentials(host_id);
CREATE INDEX IF NOT EXISTS idx_ec_host_enrollments_host ON ec_host_enrollments(host_id);
`);
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
if (!columns.some(column => column.name === 'history_expired_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN history_expired_at TEXT');
@@ -120,6 +127,15 @@ export function ensureHostSchema(handle: Database.Database): void {
CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE ${importantOperationSql.replaceAll('o.', '')}`);
handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)');
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('staged-operation-payloads')) {
handle.transaction(() => {
handle.exec(`INSERT OR IGNORE INTO ec_host_operation_payloads(operation_id,payload)
SELECT id,payload FROM ec_host_operations WHERE kind='files.stage' AND json_type(payload,'$.files')='array';
UPDATE ec_host_operations SET payload=json_object('stageId',json_extract(payload,'$.stageId'))
WHERE kind='files.stage' AND json_type(payload,'$.files')='array';`);
handle.prepare('INSERT INTO ec_host_schema_migrations(name) VALUES(?)').run('staged-operation-payloads');
})();
}
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('completed-status-batches')) {
handle.transaction(() => {
// Reclassify existing completed batches without discarding their per-server replies.
@@ -139,5 +155,39 @@ export function ensureHostSchema(handle: Database.Database): void {
handle.exec('UPDATE ec_resource_allowances SET cpu_cores=cpu_cores/1.25, memory_bytes=CAST(memory_bytes/1.25 AS INTEGER)');
})();
}
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('operation-file-blobs')) {
handle.transaction(() => {
for (const row of migrationRows<{ id: string; payload: string; result: string | null }>(handle,
`SELECT o.rowid AS rid,o.id,COALESCE(p.payload,o.payload) payload,o.result
FROM ec_host_operations o LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id
WHERE o.rowid>? AND o.kind IN ('files.stage','instance.create','instance.rebuild','instance.restart','instance.start','files.read')
ORDER BY o.rowid LIMIT 1`)) {
const packed = packFileJson(JSON.parse(row.payload), handle);
handle.prepare('UPDATE ec_host_operations SET request_hash=COALESCE(request_hash,?) WHERE id=?')
.run(createHash('sha256').update(row.payload).digest('hex'), row.id);
if (packed.hashes.size) {
handle.prepare(`INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)
ON CONFLICT(operation_id) DO UPDATE SET payload=excluded.payload`).run(row.id, JSON.stringify(packed.value));
handle.prepare("UPDATE ec_host_operations SET payload=json_remove(payload,'$.files') WHERE id=?").run(row.id);
referenceBlobs('operation', row.id, packed.hashes, handle);
}
if (row.result) {
const result = packFileJson(JSON.parse(row.result), handle);
if (result.hashes.size) {
handle.prepare('UPDATE ec_host_operations SET result=? WHERE id=?').run(JSON.stringify(result.value), row.id);
referenceBlobs('operation-result', row.id, result.hashes, handle);
}
}
}
handle.prepare('INSERT INTO ec_host_schema_migrations(name) VALUES(?)').run('operation-file-blobs');
})();
}
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_operation_blobs AFTER DELETE ON ec_host_operations BEGIN
DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=OLD.id; END;
CREATE TRIGGER IF NOT EXISTS expire_operation_blobs AFTER UPDATE OF history_expired_at ON ec_host_operations
WHEN NEW.history_expired_at IS NOT NULL BEGIN
DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=NEW.id; END;`);
retentionClocks(handle, { ec_host_operations: ['created_at', 'updated_at'] });
installHistoryCounts(handle);
initialized.add(handle);
}
+11
View File
@@ -46,6 +46,7 @@ export type BetaStage =
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent =
| { type: 'beta-changed'; betaId: number }
| { type: 'fleet-invalidated' }
| { type: 'server-telemetry'; entries: GameTelemetry[] }
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
@@ -79,6 +80,15 @@ class Bus extends EventEmitter {
}
const bus = new Bus();
const changedBetas = new Set<number>();
export function invalidateBeta(betaId: number): void {
const scheduled = changedBetas.size > 0;
changedBetas.add(betaId);
if (!scheduled) queueMicrotask(() => {
const ids = [...changedBetas]; changedBetas.clear();
for (const betaId of ids) bus.emit('event', { type: 'beta-changed', betaId });
});
}
let invalidationPending = false;
export function invalidateFleet(): void {
if (invalidationPending) return;
@@ -90,6 +100,7 @@ export function invalidateFleet(): void {
export function emit(event: PipelineEvent): void {
bus.emit('event', event);
if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet();
if ('betaId' in event && event.type !== 'beta-changed') invalidateBeta(event.betaId);
}
export function subscribe(listener: (event: PipelineEvent) => void): () => void {
+45 -35
View File
@@ -6,18 +6,17 @@ import { betaTick } from './beta-runner.js';
import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js';
import type { RconFailure } from '../integrations/rcon.js';
import { getServer, listServers, setServerFields, type ServerRow } from '../fleet/servers.js';
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
import { restartSettling } from '../provisioning/index.js';
import { serverObservation, observationReader } from '../fleet/status.js';
import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js';
import { isProvisioning } from '../fleet/provisioning.js';
import { setServerFields, type ServerRow } from '../fleet/servers.js';
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, monitoringIdentity, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
import { serverObservation } from '../fleet/status.js';
import { STATUS_REFRESH_MS } from '../../server-freshness.js';
import { recordMetrics } from '../fleet/metrics.js';
import { selectedZombieMap } from '../../map-aliases.js';
import { runMaintenance } from '../maintenance/index.js';
import { cleanupControllerLibraries } from '../game-files/index.js';
import { trackActivity } from './activity.js';
import { MonitoringSnapshot } from '../fleet/monitoring-snapshot.js';
import { withHostRequestContext } from '../hosts/request-context.js';
import { withOperationSource } from '../hosts/operation-context.js';
const TICK_MS = 15_000;
@@ -104,7 +103,7 @@ function drainStatusQueue(host: string, queue: StatusQueue): void {
if (!queue.active && !queue.queued.length) statusQueues.delete(host);
}
function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
function queueStatus(srv: ServerRow, snapshot: MonitoringSnapshot, probe?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
const server = srv.name;
const pending = pendingStatus.get(server);
if (pending) return pending;
@@ -118,10 +117,10 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
queue.queued.push(async () => {
let capacity: number | null = null;
try {
capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, probe));
capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, snapshot, probe));
// Publish this server's latest evidence without waiting for the fleet.
const current = getServer(server);
if (current) publishStatus(statusEntry(current));
const current = snapshot.get(server)?.server;
if (current) publishStatus(statusEntry(current, snapshot.observe));
} catch (err) { console.error(`[worker] status error for ${server}:`, err); }
finally { pendingStatus.delete(server); finish(capacity); }
});
@@ -129,19 +128,24 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
return work;
}
export async function fanoutStatus(): Promise<void> {
const servers = listServers();
export function fanoutStatus(): Promise<void> {
return withHostRequestContext(statusPass);
}
async function statusPass(): Promise<void> {
const snapshot = new MonitoringSnapshot();
const servers = snapshot.servers();
if (!servers.length) return;
const probes = prepareMonitoringRcon(servers.filter(server => !pendingStatus.has(server.name)
&& !isProvisioning(server.name) && !restartSettling(server.name) && !telemetryEvidence(server.name)).map(server => server.name));
const results = await Promise.all(servers.map(server => queueStatus(server, probes.get(server.name))));
&& !snapshot.provisioning(server.name) && !snapshot.settling(server.name) && !snapshot.telemetryEvidence(server.name)).map(server => server.name), snapshot);
const results = await Promise.all(servers.map(server => queueStatus(server, snapshot, probes.get(server.name))));
const capacities = new Map(servers.map((server, i) => [server.name, results[i]]));
try {
// Metrics remain complete fleet collections, using freshness at capture
// time rather than the status saved at the beginning of a long scan.
const observe = observationReader();
recordMetrics(listServers().map(srv => {
const current = observe(srv);
snapshot.refresh();
recordMetrics(snapshot.servers().map(srv => {
const current = snapshot.observe(srv);
return { server: srv.name, game: srv.game ?? 'unknown',
map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map',
players: current.health === 'online' ? current.last_players : null,
@@ -150,33 +154,38 @@ export async function fanoutStatus(): Promise<void> {
} catch (err) { console.error('[worker] metrics error:', err); }
}
async function refreshServerStatus(server: string, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
const srv = getServer(server);
async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
snapshot.refresh(server);
const srv = snapshot.get(server)?.server;
if (!srv) return null;
let capacity: number | null = null;
let updates: Partial<Record<keyof ServerRow, string | number | null>> = {};
const flush = () => { setServerFields(srv.name, updates); updates = {}; };
if (!isProvisioning(srv.name)) {
const previousGame = monitoringStatus(srv.name)?.game;
const publishObservation = monitoringStatusWriter(srv.name);
const flush = () => { setServerFields(srv.name, updates); snapshot.patch(srv.name, updates as Partial<ServerRow>); updates = {}; };
if (!snapshot.provisioning(srv.name)) {
const previousGame = monitoringStatus(srv.name, snapshot)?.game;
const publishObservation = monitoringStatusWriter(srv.name, snapshot);
let gameObservation: Awaited<ReturnType<typeof rconForMonitoring>> | undefined;
const settling = restartSettling(srv.name);
const epoch = latestGameSubmission(srv.name);
const settling = snapshot.settling(srv.name);
const epoch = snapshot.get(srv.name)?.submission;
let identity: string | undefined;
try { identity = monitoringIdentity(srv.name, snapshot); } catch { /* Offline hosts still need an unavailable sample. */ }
const unchanged = () => {
const current = getServer(srv.name);
return epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name)
const current = snapshot.get(srv.name)?.server;
if (identity) try { if (identity !== monitoringIdentity(srv.name, snapshot)) return false; } catch { return false; }
return epoch === snapshot.get(srv.name)?.submission && !snapshot.provisioning(srv.name)
&& current?.created_at === srv.created_at && current.desired_state === srv.desired_state
&& current.native_instance_id === srv.native_instance_id && current.instance_generation === srv.instance_generation;
};
const inspectionStartedAt = new Date().toISOString();
const inspected = settling
? { result: { ok: false as const, state: 'restarting', reason: 'Restart requested' }, observedAt: inspectionStartedAt }
: await inspectForMonitoring(srv.name).catch(err => err instanceof MonitoringChangedError ? null
: await inspectForMonitoring(srv.name, false, snapshot).catch(err => err instanceof MonitoringChangedError ? null
: { result: { ok: false as const, state: 'unknown', reason: (err as Error).message }, observedAt: inspectionStartedAt });
snapshot.refresh(srv.name);
if (!inspected) return null;
const container = inspected.result;
// A lifecycle request may be submitted while an inspection is in flight.
if (unchanged() && (getServer(srv.name)?.container_checked_at ?? '') <= inspected.observedAt) {
if (unchanged() && (snapshot.get(srv.name)?.server?.container_checked_at ?? '') <= inspected.observedAt) {
updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown';
updates.container_checked_at = inspected.observedAt;
// An unavailable inspection is not evidence that the game stopped.
@@ -187,10 +196,11 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
updates.last_error = null;
updates.rcon_failures = 0;
updates.last_reply_snippet = null;
} else if (unavailable || !telemetryEvidence(srv.name)) {
} else if (unavailable || !snapshot.telemetryEvidence(srv.name)) {
flush();
const probeStartedAt = new Date().toISOString();
const probe = await (probeStatus ? probeStatus() : rconForMonitoring(srv.name)).catch(err => {
const probe = await (probeStatus ? probeStatus() : rconForMonitoring(srv.name, false, snapshot)).catch(err => {
snapshot.refresh(srv.name);
if (err instanceof MonitoringSkippedError) {
// The container was checked, but game evidence retains its original age.
if (unchanged()) publishObservation(inspected, previousGame);
@@ -199,9 +209,10 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
if (err instanceof MonitoringChangedError) return null;
return { result: { ok: false as const, endpoint: null, reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }, observedAt: probeStartedAt };
});
snapshot.refresh(srv.name);
if (!probe) return null;
const result = probe.result;
const previous = getServer(srv.name);
const previous = snapshot.get(srv.name)?.server;
if (unchanged() && previous && (previous.last_checked_at ?? '') <= probe.observedAt) {
gameObservation = probe;
const now = probe.observedAt;
@@ -234,9 +245,8 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
updates.rcon_failures = 0;
updates.last_reply_snippet = null;
}
flush();
const current = getServer(srv.name) ?? srv;
const status = serverObservation(current);
const current = { ...(snapshot.get(srv.name)?.server ?? srv), ...updates } as ServerRow;
const status = snapshot.observe(current);
// Occupancy timers and match clearing follow the same source as the UI.
if (status.health === 'online' && status.last_players !== null) {
if (status.last_players > 0) {
@@ -20,6 +20,7 @@ export interface NativeLaunchInput {
export interface PreparedNativeLaunch {
recipe: LaunchRecipePlan;
recipeSummary: string;
blobs?: Array<{ sha256: string; content: Uint8Array }>;
recipeJson: Uint8Array;
payloadJson: Uint8Array;
}
+8 -5
View File
@@ -29,17 +29,20 @@ parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeL
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey },
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
contentBase64: file.content, sha256: file.sha256 })),
contentSha256: file.sha256, sha256: file.sha256 })),
...(input.rebuild ? { nextGeneration: instance.generation + 1, ...(input.safetyOverride ? { safetyOverride: true } : {}) } : {})
};
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
const recipeJson = encode(recipe);
const blobs = [...new Map(recipe.files.map(file => [file.sha256,
{ sha256: file.sha256, content: new Uint8Array(Buffer.from(file.content, 'base64')) }])).values()];
const storedRecipe = { ...recipe, files: recipe.files.map(({ content, ...file }) => ({ ...file, contentHash: file.sha256 })) };
const recipeJson = encode(storedRecipe);
const payloadJson = encode(payload);
const plan = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) };
const recipeSummary = JSON.stringify(summarizeRecipe(plan, sha256Buffer(Buffer.from(recipeJson.buffer))));
const recipeSummary = JSON.stringify(summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe)))));
// Transfer ownership of the encoded bytes; do not clone giant strings or objects.
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson } },
[recipeJson.buffer, payloadJson.buffer]);
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
[recipeJson.buffer, payloadJson.buffer, ...blobs.map(blob => blob.content.buffer)]);
} catch (error) {
parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' });
}
+40 -23
View File
@@ -1,3 +1,4 @@
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
import { concurrencyBlocker, preparingJobs } from './concurrency.js';
import { isIP } from 'node:net';
import { setImmediate as yieldToController } from 'node:timers/promises';
@@ -36,8 +37,8 @@ import { safetyOverride, safetyOverrideCapability } from './safety-override.js';
export type NativeSelection = import('../../hosts.js').NativePlacement;
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; applied_at: string | null }
interface FilePlan { id: number; path: string; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
/** Validate a launch request without selecting a host or consuming capacity. */
export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection {
@@ -216,39 +217,50 @@ export function queueNativeRebuild(name: string, triggeredBy: string): CreateSer
}
function filesFor(job: Job): FilePlan[] {
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
const rows = db().prepare(`SELECT f.id,f.kind,f.rel_path,f.content_hash,f.check_before,f.expected_before_sha
FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[];
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as Array<
Pick<StagedFileChange, 'id' | 'kind' | 'rel_path' | 'check_before' | 'expected_before_sha'> & { content_hash: string | null }>;
return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path),
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null,
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
sha256: row.content_hash, checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
}
function fileMetadata({ contentBase64, ...file }: FilePlan & { contentBase64?: string | null }): FilePlan { return file; }
function storedFiles(job: Job): FilePlan[] {
const row = db().prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?').get(job.id) as { files_json: string | null } | undefined;
return (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
}
/** Agent receipts are the authoritative before images; do not read a controller mount. */
function applyReceipt(job: Job, result: Record<string, unknown>) {
if (job.applied_at || !job.files_json) return;
const plans = JSON.parse(job.files_json) as FilePlan[];
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
if (job.applied_at) return;
const plans = storedFiles(job);
if (!plans.length) return;
const before = result.before as { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[] | undefined;
db().transaction(() => {
for (const plan of plans) {
const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path);
if (!snapshot) throw new Error('Agent activation receipt is missing a before image.');
const row = db().prepare('SELECT * FROM staged_file_changes WHERE id=?').get(plan.id) as StagedFileChange;
const row = db().prepare(`SELECT *, ${blobSize('content_hash')} AS size FROM staged_file_changes WHERE id=?`).get(plan.id) as
Omit<StagedFileChange, 'content'> & { content_hash: string | null; size: number };
if (!row || row.state === 'applied') continue;
const bytes = snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
const bytes = snapshot.contentSha256 ? getFileBlob(snapshot.contentSha256)
: snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
if (row.source_kind === 'beta') {
captureBetaBeforeImage(row, bytes);
db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server);
}
if (row.deployment_file_id && !row.preserve_before_image) {
db().prepare('UPDATE deployment_files SET verb=?,content_before=?,sha256_before=?,snapshot_pending=0 WHERE id=?')
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
db().prepare('UPDATE deployment_files SET verb=?,content_before_hash=?,sha256_before=?,snapshot_pending=0 WHERE id=?')
.run(row.content_hash === null ? 'removed' : bytes === null ? 'added' : 'updated', putFileBlob(bytes), bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)')
.run(row.source_id, `${job.server}: captured rollback snapshot for ${row.rel_path}`);
}
if (row.content === null) removeScript(job.server, row.rel_path);
if (row.content_hash === null) removeScript(job.server, row.rel_path);
else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null,
sha256Buffer(row.content), row.content.length, row.inventory_source);
row.content_hash, row.size, row.inventory_source);
db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id);
}
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
@@ -257,7 +269,7 @@ function applyReceipt(job: Job, result: Record<string, unknown>) {
/** Retain confirmed file snapshots and instance identity even after observation is cancelled. */
export function reconcileNativeProvisioningReceipt(id: string): void {
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.files_json,n.applied_at
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at
FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
if (!job) return;
@@ -280,7 +292,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
if (!job.operation_id) return;
const operation = getHostOperationStatus(job.operation_id);
if (operation?.state === 'failed') {
for (const file of JSON.parse(job.files_json ?? '[]') as FilePlan[]) {
for (const file of storedFiles(job)) {
db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'")
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
}
@@ -348,7 +360,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
}
}
reconcileHostDrains();
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at,q.selection
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.applied_at,q.selection
FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id
LEFT JOIN native_creation_intents q ON q.deployment_id=d.id
WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
@@ -494,7 +506,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
// post-mutation failures remain unknown and retain their capacity.
db().transaction(() => {
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[];
const files = storedFiles(job);
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
})();
@@ -585,10 +597,14 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
}
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job);
const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
if (files.length && !job.stage_operation_id) {
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
db().transaction(() => {
const stage = queueHostOperationSerialized(instance.host_id, 'files.stage', Buffer.from(JSON.stringify({ stageId: job.id, files: files.map(file => ({ ...file, ...(file.sha256 ? { contentSha256: file.sha256 } : { contentBase64: null }) })) })),
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` }, { stageId: job.id });
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?')
.run(stage.id, JSON.stringify(files.map(fileMetadata)), job.id);
})();
hold(job, 'Staging and verifying files on the host.'); return;
}
if (job.stage_operation_id) {
@@ -671,7 +687,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
const beta = activeBetaRemovalBlocker(job.server);
if (beta) { hold(job, beta); return; }
}
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) {
if (JSON.stringify(filesFor(job).map(fileMetadata)) !== JSON.stringify(files.map(fileMetadata))) {
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
}
const capacityBlocker = concurrencyBlocker(job.id, job.action, instance!.host_id);
@@ -701,6 +717,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (prepared) {
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
for (const blob of prepared.blobs ?? []) putFileBlob(blob.content);
const recipe = prepared.recipe;
db().prepare('UPDATE server_launch_recipes SET recipe=CAST(? AS TEXT),recipe_summary=? WHERE deployment_id=?')
.run(Buffer.from(prepared.recipeJson.buffer, prepared.recipeJson.byteOffset, prepared.recipeJson.byteLength), prepared.recipeSummary, job.id);
+2
View File
@@ -1,5 +1,7 @@
import Database from 'better-sqlite3';
import { workerData } from 'node:worker_threads';
import { tuneConnection } from '../db/connection.js';
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
tuneConnection(handle, true);
export function db() { return handle; }
+23 -7
View File
@@ -3,12 +3,28 @@ import type Database from 'better-sqlite3';
export function installReportRevisions(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'],
leaderboard: ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players'] })) {
for (const table of tables) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='${topic}'; END;`);
}
for (const table of ['metric_samples', 'servers']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
if (handle.prepare("SELECT 1 FROM settings WHERE key='completed_report_revisions_v1'").get()) return;
handle.transaction(() => {
for (const table of ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players']) {
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const name = `report_${table}_${action}`;
const versions = action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const condition = table === 'leaderboard_matches'
? `WHEN ${versions.map(row => `${row}.status='completed'`).join(' OR ')}`
: table === 'leaderboard_match_players'
? `WHEN ${versions.map(row => `(SELECT status FROM leaderboard_matches WHERE id=${row}.match_id)='completed'`).join(' OR ')}`
: action === 'UPDATE' ? 'WHEN OLD.name IS NOT NEW.name' : '';
const event = table === 'leaderboard_players' && action === 'UPDATE' ? 'UPDATE OF name' : action;
handle.exec(`DROP TRIGGER IF EXISTS ${name};
CREATE TRIGGER ${name} AFTER ${event} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='leaderboard'; END;`);
}
}
handle.exec("INSERT INTO settings(key,value) VALUES('completed_report_revisions_v1','1')");
})();
}
+62 -15
View File
@@ -1,3 +1,4 @@
import { blobSize, pruneFileBlobs, referenceBlobs } from './db/file-blobs.js';
import { createHash } from 'node:crypto';
import { db } from './db/index.js';
import { fleetSettings } from './settings.js';
@@ -42,7 +43,7 @@ function policies(): Policy[] {
return [
{ category: 'campaign', label: 'Campaigns', table: 'deployments', id: 'id', clock: 'started_at', days: 'deployKeepDays',
eligible: `${campaignSafe} AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='file-content' AND c.record_id=o.id AND c.protected=1)`, failed: failedCampaign,
bytes: `(SELECT COALESCE(SUM(COALESCE(length(content_before),0)+COALESCE(length(content_after),0)),0) FROM deployment_files WHERE deployment_id=o.id)` },
bytes: `(SELECT COALESCE(SUM(COALESCE(${blobSize('content_before_hash')},0)+COALESCE(${blobSize('content_after_hash')},0)),0) FROM deployment_files WHERE deployment_id=o.id)` },
{ category: 'provisioning', label: 'Provisioning workflows', table: 'server_deployments', id: 'id', clock: 'finished_at', days: 'provisioningKeepDays', failed: "o.state='failed'", eligible: `o.state IN ${terminal} AND NOT (${unsettledDeployment.replaceAll('d.', 'o.')})
AND NOT EXISTS (SELECT 1 FROM restart_rollout r WHERE r.operation_id=o.id AND r.healthy_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN ec_host_operations a ON a.id IN (n.operation_id,n.stage_operation_id) WHERE n.deployment_id=o.id AND a.state NOT IN ('completed','failed'))
@@ -70,22 +71,37 @@ function policies(): Policy[] {
{ category: 'beta', label: 'Completed betas', table: 'betas', id: 'id', clock: 'closed_at', days: 'betaKeepDays',
eligible: `o.state IN ('promoted','rolled-back') AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta-file-content' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.beta_id=o.id)
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`,
bytes: '(SELECT COALESCE(SUM(length(content)),0) FROM beta_revision_files WHERE beta_id=o.id)' },
bytes: `(SELECT COALESCE(SUM(${blobSize('content_hash')}),0) FROM beta_revision_files WHERE beta_id=o.id)` },
{ category: 'file-content', label: 'Campaign file bodies', table: 'deployments', id: 'id', clock: 'finished_at', days: 'fileContentKeepDays',
eligible: `${campaignSafe} AND NOT (${failedCampaign})
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='campaign' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='file-content' AND e.record_id=o.id)`,
bytes: '(SELECT COALESCE(SUM(COALESCE(length(content_before),0)+COALESCE(length(content_after),0)),0) FROM deployment_files WHERE deployment_id=o.id)' },
bytes: `(SELECT COALESCE(SUM(COALESCE(${blobSize('content_before_hash')},0)+COALESCE(${blobSize('content_after_hash')},0)),0) FROM deployment_files WHERE deployment_id=o.id)` },
{ category: 'beta-file-content', label: 'Closed beta file snapshots', table: 'betas', id: 'id', clock: 'closed_at', days: 'fileContentKeepDays',
eligible: `o.state IN ('promoted','rolled-back') AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.beta_id=o.id)
eligible: `o.state IN ('promoted','rolled-back')
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='beta-file-content' AND e.record_id=CAST(o.id AS TEXT))
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`,
bytes: '(SELECT COALESCE(SUM(length(content)),0) FROM beta_revision_files WHERE beta_id=o.id)' },
bytes: `(SELECT COALESCE(SUM(${blobSize('content_hash')}),0) FROM beta_revision_files WHERE beta_id=o.id)` },
{ category: 'workflow-payload', label: 'Completed launch file bodies', table: 'server_deployments', id: 'id', clock: 'finished_at', days: 'payloadKeepDays',
failed: "o.state='failed'",
eligible: `o.state IN ${terminal} AND NOT (${unsettledDeployment.replaceAll('d.', 'o.')})
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='workflow-payload' AND e.record_id=o.id)`,
bytes: "(SELECT COALESCE(SUM(b.size),0) FROM file_blob_references r JOIN file_blobs b ON b.sha256=r.sha256 WHERE r.owner_type='recipe' AND r.owner_id=o.id)" },
{ category: 'operation-payload', label: 'Completed command file bodies', table: 'ec_host_operations', id: 'id', clock: 'updated_at', days: 'payloadKeepDays',
failed: "o.state='failed'",
eligible: `o.state IN ('completed','failed') AND o.kind IN ('instance.create','instance.rebuild','instance.restart','instance.start','files.stage','files.read')
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='operation' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id
WHERE o.id IN (n.operation_id,n.stage_operation_id) AND ((${unsettledDeployment}) OR EXISTS (
SELECT 1 FROM retention_controls c WHERE c.category IN ('provisioning','workflow-payload') AND c.record_id=d.id AND c.protected=1)))
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='operation-payload' AND e.record_id=o.id)`,
bytes: "(SELECT COALESCE(SUM(b.size),0) FROM file_blob_references r JOIN file_blobs b ON b.sha256=r.sha256 WHERE r.owner_type IN ('operation','operation-result') AND r.owner_id=o.id)" },
{ category: 'version', label: 'Unused artifact versions', table: 'script_versions', id: 'id', clock: 'uploaded_at', days: 'versionKeepDays', minimum: false,
eligible: `NOT EXISTS (SELECT 1 FROM scripts s WHERE s.current_version_id=o.id)
AND NOT EXISTS (SELECT 1 FROM staged_file_changes s WHERE s.script_version_id=o.id)
AND ${versionReferences()}`, bytes: 'length(o.content)' },
AND ${versionReferences()}`, bytes: `${blobSize('o.content_hash')}` },
{ category: 'audit', label: 'Authentication audit events', table: 'auth_audit', id: 'id', clock: 'created_at', days: 'auditKeepDays', eligible: '1', bytes: 'length(o.details)' }
];
}
@@ -104,11 +120,37 @@ function versionReferences(): string {
return clauses.join(' AND ') || '1';
}
function initialize() { ensureHostSchema(db()); ensureAuthSchema(db()); }
function withoutFileBodies(value: unknown): unknown {
if (Array.isArray(value)) return value.map(withoutFileBodies);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).filter(([key]) => !['contentHash','contentSha256','contentBase64'].includes(key))
.map(([key, item]) => [key, withoutFileBodies(item)]));
}
function expirePayload(category: string, id: string): void {
if (category === 'workflow-payload') {
const row = db().prepare('SELECT recipe FROM server_launch_recipes WHERE deployment_id=?').get(id) as { recipe: string } | undefined;
if (row) db().prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(withoutFileBodies(JSON.parse(row.recipe))), id);
db().prepare('UPDATE native_provisioning SET files_json=NULL WHERE deployment_id=?').run(id);
referenceBlobs('recipe', id, []);
return;
}
const row = db().prepare(`SELECT COALESCE(p.payload,o.payload) payload,o.result FROM ec_host_operations o
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(id) as { payload: string; result: string | null };
const payload = JSON.stringify(withoutFileBodies(JSON.parse(row.payload)));
db().prepare(`UPDATE ec_host_operations SET payload=?,result=?,request_hash=COALESCE(request_hash,?) WHERE id=?`)
.run(payload, row.result === null ? null : JSON.stringify(withoutFileBodies(JSON.parse(row.result))),
createHash('sha256').update(row.payload).digest('hex'), id);
db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(id);
referenceBlobs('operation', id, []);
referenceBlobs('operation-result', id, []);
}
function selection(policy: Policy, settings = fleetSettings()) {
const days = settings[policy.days];
const failureDays = settings.failureKeepDays;
const cut = (n: number) => new Date(Date.now() - n * 86400_000).toISOString();
const age = ['campaign','observation','operation'].includes(policy.category) ? `o.${policy.clock} < @cutoff` : policy.category === 'audit' ? "o.created_at < @numericCutoff" : `julianday(o.${policy.clock}) < julianday(@cutoff)`;
const age = policy.category === 'audit' ? 'o.created_at < @numericCutoff' : `o.${policy.clock} < @cutoff`;
const failed = policy.failed ?? '0';
const enabled = days > 0 || policy.category === 'version' && settings.versionKeepCount > 0;
// Keep age outside the correlated failure checks so SQLite can seek the
@@ -119,8 +161,8 @@ function selection(policy: Policy, settings = fleetSettings()) {
WHERE ${policy.category === 'version' && !days ? '1' : age} AND NOT (${failed})`);
if (failureDays > 0 && policy.failed) candidates.push(`SELECT o.rowid FROM retention_controls c
JOIN ${policy.table} o ON o.${policy.id}=c.record_id
WHERE c.category=@category AND julianday(c.resolved_at)<julianday(@failureCutoff)
AND julianday(c.resolved_at)>=julianday(o.${policy.clock}) AND (${failed})`);
WHERE c.category=@category AND c.resolved_at<@failureCutoff
AND c.resolved_at>=o.${policy.clock} AND (${failed})`);
const minimum = policy.minimum === false ? 0 : settings.historyKeepCount;
const minimumScope = policy.category === 'observation' ? "WHERE observation_success=1 AND state='completed'"
: policy.category === 'operation' ? "WHERE history_expired_at IS NULL AND (routine=0 OR observation_success=0)" : '';
@@ -181,18 +223,21 @@ export function pruneHistory(only?: string): number {
db().prepare('DELETE FROM ec_host_operation_events WHERE operation_id=?').run(row.id);
if (policy.category === 'operation') {
const payload = db().prepare(`SELECT COALESCE(p.payload,o.payload) AS payload FROM ec_host_operations o LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(row.id) as { payload: string };
db().prepare(`UPDATE ec_host_operations SET request_hash=?,payload='{}',result='{"historyExpired":true}',error=NULL,history_expired_at=? WHERE id=?`)
db().prepare(`UPDATE ec_host_operations SET request_hash=COALESCE(request_hash,?),payload='{}',result='{"historyExpired":true}',error=NULL,history_expired_at=? WHERE id=?`)
.run(createHash('sha256').update(payload.payload).digest('hex'),new Date().toISOString(),row.id);
}
db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(row.id);
}
if (policy.category === 'beta-file-content') {
db().prepare("UPDATE beta_revision_files SET content=X'' WHERE beta_id=?").run(row.id);
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id=?').run(row.id);
if (policy.category === 'workflow-payload' || policy.category === 'operation-payload') {
expirePayload(policy.category, row.id);
db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category === 'beta-file-content') {
db().prepare("UPDATE beta_revision_files SET content_hash=NULL WHERE beta_id=?").run(row.id);
db().prepare('UPDATE beta_file_snapshots SET prior_content_hash=NULL WHERE beta_id=?').run(row.id);
db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category === 'file-content') {
db().prepare('UPDATE deployment_files SET content_before=NULL,content_after=NULL WHERE deployment_id=?').run(row.id);
db().prepare("UPDATE staged_file_changes SET content=NULL WHERE source_kind='campaign' AND source_id=? AND state IN ('applied','cancelled')").run(row.id);
db().prepare('UPDATE deployment_files SET content_before_hash=NULL,content_after_hash=NULL WHERE deployment_id=?').run(row.id);
db().prepare("UPDATE staged_file_changes SET content_hash=NULL WHERE source_kind='campaign' AND source_id=? AND state IN ('applied','cancelled')").run(row.id);
db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category !== 'operation') {
db().prepare(`DELETE FROM ${policy.table} WHERE rowid=?`).run(row.rid);
@@ -202,6 +247,8 @@ export function pruneHistory(only?: string): number {
return rows.length;
})();
}
const blobs = pruneFileBlobs();
if ((removed || blobs) && Number(db().pragma('freelist_count', { simple: true })) >= 256) db().pragma('incremental_vacuum(256)');
return removed;
}
+30
View File
@@ -0,0 +1,30 @@
/** Coalesce pushes, serialize refreshes, and recover missed events after reconnect or tab activation. */
export function betaUpdates(load: () => Promise<unknown>, betaId?: () => number): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false, running = false, pending = false;
const schedule = () => {
pending = true;
if (stopped || running || timer || document.hidden) return;
timer = setTimeout(async () => {
timer = undefined;
if (stopped || document.hidden) return;
running = true; pending = false;
try { await load(); }
catch { /* The page owns its error state; later events can retry. */ }
finally { running = false; if (pending) schedule(); }
}, 200);
};
const changed = (event: Event) => { if (!betaId || (event as CustomEvent).detail?.betaId === betaId()) schedule(); };
const visible = () => { if (!document.hidden) schedule(); };
window.addEventListener('ec:beta-changed', changed);
window.addEventListener('ec:pipeline-connected', schedule);
window.addEventListener('ec:fleet-changed', schedule);
document.addEventListener('visibilitychange', visible);
return () => {
stopped = true; clearTimeout(timer);
window.removeEventListener('ec:beta-changed', changed);
window.removeEventListener('ec:pipeline-connected', schedule);
window.removeEventListener('ec:fleet-changed', schedule);
document.removeEventListener('visibilitychange', visible);
};
}
+5
View File
@@ -26,6 +26,11 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return;
source = new EventSource('/api/events/pipeline');
source.addEventListener('open', () => window.dispatchEvent(new Event('ec:pipeline-connected')));
source.addEventListener('beta-changed', event => {
try { window.dispatchEvent(new CustomEvent('ec:beta-changed', { detail: JSON.parse((event as MessageEvent).data) })); }
catch { /* Reconnect refreshes authoritative state if an event is malformed. */ }
});
for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) {
source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed')));
}
@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { db } from '$lib/server/db/index.js';
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
const host = authenticateAgent(request);
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
const row = db().prepare(`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
SELECT 1 FROM file_blob_references r JOIN ec_host_operations o ON o.id=r.owner_id
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`).get(params.sha256, host.id) as
{ content: Buffer; size: number } | undefined;
if (!row) error(404, 'File not found.');
return new Response(new Uint8Array(row.content), { headers: {
'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size),
'Cache-Control': 'private, no-store', ETag: `"${params.sha256}"`
} });
}
@@ -1,10 +1,8 @@
import { pendingHostOperationRows, waitForHostWork } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request);
reconcileHostPreparation(host.id);
const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
let operations = pendingHostOperationRows(host.id, lane, false);
+21 -30
View File
@@ -1,50 +1,41 @@
import { json } from '@sveltejs/kit';
import {
listBetas,
listBetaServers,
listBetaScripts,
openBeta,
BetaError
} from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { db } from '$lib/server/db/index.js';
import type { BetaServerRow } from '$lib/server/fleet/beta.js';
import type { StoredPlan } from '$lib/server/fleet/beta-tests.js';
import { kick } from '$lib/server/pipeline/worker.js';
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
import { storedPlan } from '$lib/server/fleet/beta-tests.js';
import { currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
import { betaListPage } from '$lib/server/fleet/beta-pages.js';
export async function GET({ url }: { url: URL }) {
const stateFilter = url.searchParams.get('state') as
| 'running'
| 'ready-promote'
| 'promoted'
| 'rolled-back'
| null;
const rows = listBetas(stateFilter ?? undefined).map((b) => {
const scripts = listBetaScripts(b.id).map((bs) => {
const script = getScript(bs.script_id);
const version = getVersion(bs.script_version_id);
return {
id: bs.script_id,
name: script?.name ?? '(deleted)',
target_rel_path: script?.target_rel_path ?? '',
kind: script?.kind ?? bs.kind ?? 'script',
version: version
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
: null
};
});
const test = storedPlan(b.id);
const state = url.searchParams.get('state');
if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
const { betas, ...pagination } = betaListPage(url.searchParams);
const ids = JSON.stringify(betas.map(beta => beta.id));
const bundles = betaScriptMetadata(betas.map(beta => beta.id));
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
FROM beta_test_plans p JOIN betas b ON b.id=p.beta_id
WHERE p.beta_id IN (SELECT value FROM json_each(?))`).all(ids) as Array<StoredPlan & { started_at: string }>).map(plan => [plan.beta_id, plan]));
const members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
const rows = betas.map(b => {
const scripts = bundles.get(b.id) ?? [];
const test = plans.get(b.id);
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
return {
...b,
testOutcome: test?.outcome ?? null,
testReason: test?.reason ?? null,
deadlineAt: plan ? new Date(Date.parse(currentBetaRevision(b.id).started_at) + plan.timeoutHours * 3600000).toISOString() : null,
deadlineAt: plan ? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString() : null,
scripts,
servers: listBetaServers(b.id)
servers: members.filter(member => member.beta_id === b.id)
};
});
return json({ betas: rows });
return json({ betas: rows, ...pagination });
}
// POST body accepts either:
+6 -17
View File
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { testView } from '$lib/server/fleet/beta-tests.js';
import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10);
@@ -16,28 +17,16 @@ export async function GET({ params }: { params: { id: string } }) {
const beta = getBeta(id);
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
const scripts = listBetaScripts(id).map((bs) => {
const script = getScript(bs.script_id);
const version = getVersion(bs.script_version_id);
return {
id: bs.script_id,
name: script?.name ?? '(deleted)',
target_rel_path: script?.target_rel_path ?? '',
kind: script?.kind ?? bs.kind ?? 'script',
version: version
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
: null
};
});
const scripts = betaScriptMetadata([id]).get(id) ?? [];
const servers = listBetaServers(id);
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
.get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, test: testView(id), promotion, rollback: rollback ?? null,
return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
}
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { getBeta } from '$lib/server/fleet/beta.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
export function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
return json(betaActivityPage(id, url.searchParams));
}
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { db } from '$lib/server/db/index.js';
/** Current files can be reviewed before the first revision is archived. */
@@ -5,12 +6,12 @@ export async function GET({ params }: { params: { id: string; revision: string;
const values = [params.id, params.revision, params.scriptId].map(Number);
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
const [id, revision, scriptId] = values;
const file = db().prepare('SELECT content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?')
const file = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`)
.get(id, revision, scriptId) as { content: Buffer } | undefined
?? (revision === 1 ? db().prepare(`SELECT v.content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
?? (revision === 1 ? db().prepare(`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
if (!file) return new Response('File not found', { status: 404 });
if (!file?.content) return new Response('File not found', { status: 404 });
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { blobSize } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
@@ -16,8 +17,8 @@ export async function GET({ params }: { params: { id: string } }) {
const files = db()
.prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, snapshot_pending,
LENGTH(content_before) AS size_before,
LENGTH(content_after) AS size_after,
${blobSize('content_before_hash')} AS size_before,
${blobSize('content_after_hash')} AS size_after,
COALESCE((SELECT state FROM staged_file_changes WHERE deployment_file_id = deployment_files.id), 'applied') AS apply_state
FROM deployment_files
WHERE deployment_id = ?
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
@@ -10,7 +11,7 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
}
const row = db()
.prepare(
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after, snapshot_pending
`SELECT verb, rel_path, sha256_before, sha256_after, ${blobContent('content_before_hash')} AS content_before, ${blobContent('content_after_hash')} AS content_after, snapshot_pending
FROM deployment_files
WHERE deployment_id = ? AND id = ?`
)
+2 -1
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit';
import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scripts.js';
@@ -9,5 +10,5 @@ export async function GET({ params, url }: { params: { id: string }; url: URL })
const requested = url.searchParams.get('version');
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
return json({ text: version.content.toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
return json({ text: getVersionContent(version.id).toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit';
import { getServer, listServers } from '$lib/server/fleet/servers.js';
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
@@ -20,7 +21,7 @@ function inspect(serverName: string, draft?: { artifactId: number; text: string
if (!isDraft && !version) return null;
return {
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
text: isDraft ? draft!.text : version!.content.toString('utf8')
text: isDraft ? draft!.text : getVersionContent(version!.id).toString('utf8')
};
}
const base = baseline ? layer(baseline) : null;
+3 -2
View File
@@ -1,3 +1,4 @@
import { blobSize } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { getDeployment } from '$lib/server/provisioning/index.js';
@@ -19,8 +20,8 @@ export async function GET({ params, url }: { params: { id: string }; url?: URL }
const files = db()
.prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after,
LENGTH(content_before) AS size_before,
LENGTH(content_after) AS size_after
${blobSize('content_before_hash')} AS size_before,
${blobSize('content_after_hash')} AS size_after
FROM deployment_files
WHERE deployment_id = ?
ORDER BY server, rel_path`
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
@@ -7,7 +8,7 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { id: string; fileId: string } }) {
const row = db()
.prepare(
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after
`SELECT verb, rel_path, sha256_before, sha256_after, ${blobContent('content_before_hash')} AS content_before, ${blobContent('content_after_hash')} AS content_after
FROM deployment_files
WHERE deployment_id = ? AND id = ?`
)
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
event.type === 'server-state' ||
event.type === 'deployment-opened' ||
event.type === 'server-changed' ||
event.type === 'sweep-tick' || event.type === 'fleet-invalidated'
event.type === 'sweep-tick' || event.type === 'fleet-invalidated' || event.type === 'beta-changed'
) {
send(controller, event);
}
+2 -34
View File
@@ -2,14 +2,11 @@ import { json } from '@sveltejs/kit';
import {
createScript,
isArtifactKind,
listScripts,
listVersions,
serversMatchingScope,
ScriptError,
type ArtifactKind,
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
import { scriptCatalog } from '$lib/server/fleet/library-metadata.js';
function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
if (v === null) return null;
@@ -29,36 +26,7 @@ function kindOrDefault(v: FormDataEntryValue | string | null, fallback: Artifact
export async function GET({ url }: { url: URL }) {
const kindParam = url.searchParams.get('kind');
const kindFilter = kindParam ? (isArtifactKind(kindParam) ? kindParam : null) : null;
const rows = kindFilter ? listScripts(kindFilter) : listScripts();
const scripts = rows.map((s) => {
const versions = listVersions(s.id);
const matched = s.scope_type && s.scope_value
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
: [];
const runningBeta = findRunningBetaForScript(s.id);
const current = versions.find(v => v.id === s.current_version_id);
return {
...s,
currentVersion: current ? {
id: current.id, version: current.version, sha256: current.sha256,
size: current.size, state: current.state, uploaded_at: current.uploaded_at
} : null,
latestVersion: versions[0]
? {
id: versions[0].id,
version: versions[0].version,
release_version: versions[0].release_version,
sha256: versions[0].sha256,
size: versions[0].size,
state: versions[0].state,
uploaded_at: versions[0].uploaded_at
}
: null,
versionCount: versions.length,
servers: matched,
runningBetaId: runningBeta?.id ?? null
};
});
const scripts = scriptCatalog(kindFilter ?? undefined);
return json({ scripts });
}
+33 -17
View File
@@ -4,14 +4,13 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
import { betaUpdates } from '$ui/beta-updates.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { statusPresentation } from '$lib/ui/status.js';
import { serverTitle } from '$ui/server-labels.js';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
@@ -38,11 +37,13 @@
let error = $state('');
let tab = $state('active');
let search = $state('');
let limit = $state(25);
let pageNumber = $state(1), totalPages = $state(1), total = $state(0);
let counts = $state({ active: 0, history: 0 });
let request = 0;
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
let now = $state(Date.now());
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
function blocker(beta:BetaListRow) {
if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
@@ -58,23 +59,38 @@
const params = $page.url.searchParams;
tab = params.get('view') === 'history' ? 'history' : 'active';
search = params.get('q') ?? '';
limit = Math.min(10000, Math.max(25, Number(params.get('limit')) || 25));
pageNumber = Math.max(1, Number(params.get('page')) || 1);
void load(params);
});
function saveContext() {
const url = new URL($page.url);
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
url.searchParams.delete('limit');
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber)); else url.searchParams.delete('page');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function load() {
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; }
catch (e) { error = (e as Error).message; } finally { loading = false; }
async function load(params = new URLSearchParams(window.location.search)) {
const current = ++request;
params = new URLSearchParams(params);
params.set('view', params.get('view') === 'history' ? 'history' : 'active');
params.set('limit', '25');
try {
const res = await fetch('/api/betas?' + params);
if (!res.ok) throw new Error('Could not refresh betas.');
const body = await res.json();
if (current !== request) return;
betas = body.betas; counts = body.counts; total = body.total; totalPages = body.totalPages; pageNumber = body.page;
now = Date.now(); error = '';
} catch (e) { if (current === request) error = (e as Error).message; }
finally { if (current === request) loading = false; }
}
onMount(() => {
const query = new URLSearchParams(window.location.search);
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
return pollVisible(load, 5000);
const stop = betaUpdates(() => load());
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; };
});
</script>
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
@@ -82,18 +98,18 @@
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
</SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
<Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; pageNumber = 1; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" />
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
{:else if !betas.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each betas as beta (beta.id)}
<a href={contextHref('/betas/' + beta.id, $page.url)} class="row">
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
</a>
{/each}</div><ListFooter total={filtered.length} bind:limit onchange={saveContext} label="betas" />{/if}
{/each}</div><div class="ui-toolbar" aria-label="Beta pagination"><span>{total} betas · Page {pageNumber} of {totalPages}</span><Button size="sm" disabled={pageNumber <= 1} onclick={() => { pageNumber--; saveContext(); }}>Previous</Button><Button size="sm" disabled={pageNumber >= totalPages} onclick={() => { pageNumber++; saveContext(); }}>Next</Button></div>{/if}
</div>
</div>
<style>
+25 -4
View File
@@ -9,7 +9,7 @@
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import type { ActivityEvent } from '$lib/operation-flow.js';
import { pollVisible } from '$ui/poll.js';
import { betaUpdates } from '$ui/beta-updates.js';
import { confirmAction } from '$ui/confirm.js';
import type { BetaRevision } from '$lib/beta-revision.js';
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
@@ -34,6 +34,8 @@
let scripts = $state<BetaFile[]>([]);
let servers = $state<BetaServer[]>([]);
let events = $state<ActivityEvent[]>([]);
let activityBefore = $state<number|null>(null), nextBefore = $state<number|null>(null), activityLoading = $state(false);
let activityRequest = 0;
let promotion = $state<Promotion|null>(null);
let rollbackCampaign = $state<{ id: string }|null>(null);
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
@@ -67,15 +69,33 @@
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.');
const body = await res.json();
if (current !== request || requestedId !== id) return;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers;
if (activityBefore === null) { events = body.events; nextBefore = body.eventsNextBefore ?? null; }
promotion = body.promotion ?? null;
rollbackCampaign = body.rollback ?? null;
revision = body.revision; revisions = body.revisions ?? [];
now = Date.now(); loadError = '';
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
finally { if (current === request && requestedId === id) loading = false; }
}
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
onMount(() => pollVisible(load, 5000, false));
$effect(() => { id; activityRequest++; activityBefore = null; activityLoading = false; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
onMount(() => {
const stop = betaUpdates(load, () => id);
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; activityRequest++; };
});
async function loadActivity(before: number|null) {
const current = ++activityRequest, requestedId = id;
activityLoading = true;
try {
const res = await fetch(`/api/betas/${id}/events` + (before === null ? '' : `?before=${before}`));
if (!res.ok) throw new Error('Could not load beta activity.');
const body = await res.json();
if (current !== activityRequest || requestedId !== id) return;
activityBefore = before; events = body.events; nextBefore = body.nextBefore; loadError = '';
} catch (error) { if (current === activityRequest) loadError = (error as Error).message; }
finally { if (current === activityRequest) activityLoading = false; }
}
async function reviewPromotion() {
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
try {
@@ -181,6 +201,7 @@
</div></details>
{/if}
<OperationHistory {events} active={!closed} showOverview={false}/>
<div class="ui-toolbar" aria-label="Activity pagination">{#if activityBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(null)}>Latest activity</Button>{/if}{#if nextBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(nextBefore)}>Older activity</Button>{/if}</div>
</div>
{/if}
+15 -3
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.5.3"
VERSION = "1.6.0"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -1356,7 +1356,7 @@ class Agent:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation):
@@ -1559,6 +1559,18 @@ class Agent:
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
def decode_file(self, item):
if "contentSha256" in item:
sha = item["contentSha256"]
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
raise AgentError("Invalid file content hash")
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
with self.artifact_open("GET", url) as response:
content = response.read(MAX_FILE + 1)
if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit")
if digest(content) != sha or item.get("sha256", sha) != sha:
raise AgentError("Staged file checksum mismatch")
return content
try:
content = base64.b64decode(item["contentBase64"], validate=True)
except (ValueError, KeyError, TypeError):
@@ -1677,7 +1689,7 @@ class Agent:
if item["path"] in paths:
raise AgentError("Duplicate staged file path")
paths.add(item["path"])
deleting = item.get("delete") is True or item.get("contentBase64") is None
deleting = item.get("delete") is True or (item.get("contentBase64") is None and "contentSha256" not in item)
content = None if deleting else self.decode_file(item)
total_size += len(content) if content is not None else 0
if total_size > 64 * 1024 * 1024:
+2 -2
View File
@@ -1,8 +1,8 @@
{
"version": "1.5.3",
"version": "1.6.0",
"protocolVersion": 1,
"platform": "debian-amd64",
"sha256": "ebd6f67a44fdb4bd0683597d4abd54999311473561204de368864b04a5436256",
"sha256": "bcc0a207125f2a1ad754c294decf2238064995ba93183b845a589a41a41925f6",
"updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
it('bounds beta pages, searches across retained history, and seeks activity without duplicates', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { betaListPage, betaActivityPage } = await import('../../src/lib/server/fleet/beta-pages.js');
db().exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Plugin','plugin.dll');
INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0)`);
db().transaction(() => {
for (let id = 1; id <= 125; id++) {
db().prepare('INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,notes) VALUES(?,1,1,?,?,?)')
.run(id, 'ui', id <= 5 ? 'running' : 'promoted', `Test ${id}`);
db().prepare('INSERT INTO beta_scripts(beta_id,script_id,script_version_id) VALUES(?,1,1)').run(id);
}
for (let i = 0; i < 150; i++) db().prepare('INSERT INTO beta_events(beta_id,message) VALUES(1,?)').run(`Activity ${i}`);
})();
const page = betaListPage(new URLSearchParams());
expect(page.betas).toHaveLength(25);
expect(page).toMatchObject({ total: 125, page: 1, totalPages: 5, counts: { active: 5, history: 120 } });
expect(betaListPage(new URLSearchParams('view=active')).betas.map(beta => beta.id)).toEqual([5,4,3,2,1]);
expect(betaListPage(new URLSearchParams('view=history&q=Test%20101')).betas.map(beta => beta.id)).toEqual([101]);
expect(betaListPage(new URLSearchParams('q=Plugin&limit=100000')).betas).toHaveLength(100);
const second = betaListPage(new URLSearchParams('page=2'));
expect(second.betas.some(beta => page.betas.some(other => other.id === beta.id))).toBe(false);
const latest = betaActivityPage(1);
expect(latest.events).toHaveLength(50);
db().prepare("INSERT INTO beta_events(beta_id,message) VALUES(1,'New event')").run();
const previous = betaActivityPage(1, new URLSearchParams({ before: String(latest.nextBefore) }));
expect(previous.events).toHaveLength(50);
expect(previous.events.at(-1)!.id).toBeLessThan(latest.events[0].id);
const { GET } = await import('../../src/routes/api/betas/[id]/+server.js');
expect((await (await GET({ params: { id: '1' } })).json()).events).toHaveLength(50);
});
it('coalesces beta progress notifications after synchronous database work', async () => {
const { invalidateBeta, subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const events: unknown[] = [];
const stop = subscribe(event => events.push(event));
try {
invalidateBeta(1); invalidateBeta(1); invalidateBeta(2);
expect(events).toEqual([]);
await Promise.resolve();
expect(events).toEqual([{ type: 'beta-changed', betaId: 1 }, { type: 'beta-changed', betaId: 2 }]);
} finally { stop(); }
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
@@ -35,13 +36,13 @@ describe('reviewed beta promotion', () => {
it('upgrades existing campaign history without marking known snapshots pending', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments (id, action, started_at) VALUES ('legacy', 'promote', ?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
.run(servers[0], Buffer.from('original'));
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
.run(servers[0], putFileBlob(Buffer.from('original'), db()));
closeDb();
const legacy = new Database(fixture.dbPath);
try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); }
finally { legacy.close(); }
expect(db().prepare('SELECT snapshot_pending, content_before FROM deployment_files').get())
expect(db().prepare('SELECT snapshot_pending, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files').get())
.toEqual({ snapshot_pending: 0, content_before: Buffer.from('original') });
});
it.each(['script', 'baseline'] as const)('queues %s promotion without contacting additional host agents', async kind => {
@@ -82,7 +83,7 @@ describe('reviewed beta promotion', () => {
await writeFile(path, before);
}
await activateFiles('campaign', result.deploymentId);
expect(db().prepare('SELECT snapshot_pending, verb, content_before FROM deployment_files WHERE id = ?').get(file.id))
expect(db().prepare('SELECT snapshot_pending, verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE id = ?').get(file.id))
.toEqual({ snapshot_pending: 0, verb: before === null ? 'added' : 'updated', content_before: before === null ? null : Buffer.from(before) });
expect(db().prepare('SELECT message FROM campaign_events WHERE deployment_id = ?').all(result.deploymentId))
.toContainEqual({ message: `${servers[1]}: captured rollback snapshot for ${kind === 'script' ? 'review.gsc' : configFileOf(servers[1])}` });
+2 -2
View File
@@ -61,7 +61,7 @@ describe('beta revisions', () => {
expect(history[0]).toMatchObject({ revision: 2, notes: 'Small fix', results: null });
expect(history[1].results?.test?.servers[0].passed).toBe(true);
expect(history[1].results?.test?.evidence).toHaveLength(1);
expect(x.db.prepare('SELECT content FROM beta_revision_files WHERE beta_id = ? AND revision = 1').get(x.betaId))
expect(x.db.prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM beta_revision_files WHERE beta_id = ? AND revision = 1').get(x.betaId))
.toEqual({ content: Buffer.from('revision one') });
expect(await readFile(x.path, 'utf8')).toBe('revision one');
await expect(x.promoteBeta(x.betaId)).rejects.toThrow('has not passed');
@@ -154,7 +154,7 @@ describe('beta revisions', () => {
it.each(['stale', 'identical', 'empty', 'unbundled', 'duplicate', 'closed', 'deleted', 'interrupted', 'activating'])('rejects %s updates without saving a revision', async reason => {
const x = await setup();
const upload = { scriptId: x.script.id, content: Buffer.from('changed') };
if (reason === 'identical') upload.content = Buffer.from(x.version.content);
if (reason === 'identical') upload.content = Buffer.from((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(x.version.id));
if (reason === 'empty') upload.content = Buffer.alloc(0);
if (reason === 'unbundled') upload.scriptId = 999;
if (reason === 'closed') x.db.prepare("UPDATE betas SET state = 'promoted' WHERE id = ?").run(x.betaId);
+2 -2
View File
@@ -193,7 +193,7 @@ describe('staged beta files', () => {
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
const relPath = kind === 'script' ? script.target_rel_path : configFileOf(servers[1]);
expect(await readFile(join(destOfKind(servers[1], kind), relPath), 'utf8')).toContain(newestBytes.trim());
expect(db().prepare('SELECT content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(latest.deploymentId, servers[1])).toEqual({ content_before: null });
});
@@ -219,7 +219,7 @@ describe('staged beta files', () => {
await activateFiles('campaign', result.deploymentId);
expect(await readFile(path, 'utf8')).toContain('set sv_voice "0"');
}
const history = db().prepare('SELECT content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
const history = db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(result.deploymentId, servers[0]) as { content_before: Buffer };
expect(history.content_before.toString()).toBe('original configuration');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from 'vitest';
import { betaUpdates } from '../../src/lib/ui/beta-updates.js';
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it('refreshes relevant pushes and reconnects without polling, pauses hidden tabs, and cleans up', async () => {
vi.useFakeTimers();
const window = new EventTarget(), document = Object.assign(new EventTarget(), { hidden: false });
vi.stubGlobal('window', window); vi.stubGlobal('document', document);
let finish!: () => void;
const load = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
const stop = betaUpdates(load, () => 7);
const change = (id: number) => window.dispatchEvent(Object.assign(new Event('ec:beta-changed'), { detail: { betaId: id } }));
try {
change(2); await vi.advanceTimersByTimeAsync(1000); expect(load).not.toHaveBeenCalled();
change(7); change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
finish(); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(2);
finish(); await vi.advanceTimersByTimeAsync(30000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = true; change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = false; document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(3); finish();
window.dispatchEvent(new Event('ec:pipeline-connected'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(4); finish();
stop(); change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(4);
} finally { stop(); }
});
+1 -1
View File
@@ -50,7 +50,7 @@ describe('campaign resume API', () => {
expect(db().prepare('SELECT restart_state, error, provisioning_id FROM deployment_servers WHERE deployment_id = ?').get(campaign))
.toEqual({ restart_state: 'pending', error: null, provisioning_id: null });
expect(db().prepare('SELECT finished_at FROM deployments WHERE id = ?').get(campaign)).toEqual({ finished_at: null });
expect(db().prepare('SELECT state, content FROM staged_file_changes WHERE source_id = ?').get(campaign))
expect(db().prepare('SELECT state, (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_id = ?').get(campaign))
.toEqual({ state: 'pending', content: restored });
expect(await readFile(destination())).toEqual(before);
expect(kick).toHaveBeenCalledTimes(1);
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
it('indexes campaign cascades and beta evidence on fresh and reopened databases', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
for (let pass = 0; pass < 2; pass++) {
const handle = db();
for (const [query, index] of [
['SELECT id FROM staged_file_changes WHERE deployment_file_id = 1', 'idx_staged_files_deployment_file'],
['SELECT id FROM beta_test_runs WHERE beta_id = 1 AND revision = 2', 'idx_beta_test_runs_beta_revision']
]) {
const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[];
expect(plan.some(row => row.detail.includes(index))).toBe(true);
}
expect(handle.pragma('foreign_key_check')).toEqual([]);
closeDb();
}
});
it('covers every foreign key child prefix, including lazy host and library schemas', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
const handle = db();
ensureHostSchema(handle); ensureGameFileSchema(handle);
const tables = handle.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
const missing: string[] = [];
for (const { name } of tables) {
const foreignKeys = handle.pragma(`foreign_key_list(${name})`) as { from: string; seq: number }[];
const indexes = handle.pragma(`index_list(${name})`) as { name: string; partial: number }[];
const prefixes = indexes.filter(index => !index.partial).map(index =>
(handle.pragma(`index_info(${index.name})`) as { name: string }[])[0]?.name);
const columns = handle.pragma(`table_info(${name})`) as { name: string; type: string; pk: number }[];
if (columns.filter(column => column.pk).length === 1) prefixes.push(columns.find(column => column.pk)!.name);
for (const key of foreignKeys.filter(key => key.seq === 0)) {
if (!prefixes.includes(key.from)) missing.push(`${name}.${key.from}`);
}
}
expect(missing).toEqual([]);
});
+17
View File
@@ -9,6 +9,23 @@ beforeEach(async () => {
afterEach(async () => { await fixture.cleanup(); });
describe('prepared statement caching', () => {
it('tunes the controller connection and stops periodic optimization when closed', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
try {
const handle = db();
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
expect(handle.pragma('temp_store', { simple: true })).toBe(2);
expect(handle.pragma('journal_size_limit', { simple: true })).toBe(67108864);
const pragma = vi.spyOn(handle, 'pragma');
vi.advanceTimersByTime(60 * 60_000);
expect(pragma).toHaveBeenCalledWith('optimize');
pragma.mockClear();
closeDb();
vi.advanceTimersByTime(60 * 60_000);
expect(pragma).not.toHaveBeenCalled();
} finally { vi.useRealTimers(); }
});
it('reuses statements with fresh parameters and remains usable after rollback', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
@@ -143,7 +144,7 @@ describe('reviewed drift plans', () => {
const path = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg');
await writeFile(path, 'existing settings');
const plan = await previewDrift(['t5_zombie_theater_1']);
db().prepare('UPDATE script_versions SET content = ? WHERE id = ?').run(Buffer.from('set sv_voice "0"\n'), version.id);
db().prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?').run(putFileBlob(Buffer.from('set sv_voice "0"\n'), db()), version.id);
await expect(applyDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged);
expect(await readFile(path, 'utf8')).toBe('existing settings');
});
@@ -316,7 +317,7 @@ describe('drift scanning', () => {
expect(result).toMatchObject({ totalRemoved: 0, totalRestored: 1 });
expect(await readFile(target)).toEqual(content);
expect(getServerScript(server, 'zm_spawn_fix.gsc')?.script_version_id).toBe(version.id);
expect(db().prepare('SELECT verb, content_before, content_after FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId))
expect(db().prepare('SELECT verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before, (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId))
.toEqual([{ verb: 'updated', content_before: before, content_after: content }]);
expect(await scanServerDrift(server)).toEqual([]);
expect((await applyDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
@@ -376,9 +377,9 @@ describe('drift rebuild campaigns', () => {
expect(campaignId).toBeTruthy();
expect(await readFile(target, 'utf8')).toBe('old content');
expect(await readFile(stray, 'utf8')).toBe('untracked');
expect(db().prepare('SELECT rel_path, verb, content_before, content_after FROM deployment_files WHERE deployment_id = ? ORDER BY rel_path').all(campaignId))
expect(db().prepare('SELECT rel_path, verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before, (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files WHERE deployment_id = ? ORDER BY rel_path').all(campaignId))
.toEqual([
{ rel_path: 'managed.gsc', verb: 'updated', content_before: Buffer.from('old content'), content_after: version.content },
{ rel_path: 'managed.gsc', verb: 'updated', content_before: Buffer.from('old content'), content_after: (await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id) },
{ rel_path: 'stray.cfg', verb: 'removed', content_before: Buffer.from('untracked'), content_after: null }
]);
await sweep();
+85
View File
@@ -0,0 +1,85 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import schema from '../../src/lib/server/db/schema.sql?raw';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
it('shares bytes across versions, staged copies and commands, and scopes downloads to the assigned host', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { pruneFileBlobs } = await import('../../src/lib/server/db/file-blobs.js');
const content = Buffer.alloc(1024 * 1024, 71);
const hash = createHash('sha256').update(content).digest('hex');
for (let i = 0; i < 20; i++) {
createScript({ name: `plugin-${i}`, kind: 'plugin', targetRelPath: 'plugin.dll', content });
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: `t5_server_${i}`, kind: 'plugin', relPath: 'plugin.dll', content, inventorySource: 'test' });
}
const host = hosts.createHost({ name: 'Owner' });
const credential = hosts.enrollHost(hosts.createEnrollment(host.id).token, { capabilities: ['files.blobs'] }).credential;
const other = hosts.createHost({ name: 'Other' });
const otherCredential = hosts.enrollHost(hosts.createEnrollment(other.id).token, {}).credential;
const payload = { stageId: 'one', files: [{ path: 'plugins/plugin.dll', contentBase64: content.toString('base64'), sha256: hash }] };
const op = hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'one' });
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'one' }).id).toBe(op.id);
expect(() => hosts.queueHostOperation(host.id, 'files.stage', { ...payload, stageId: 'two' }, { idempotencyKey: 'one' })).toThrow('different request');
expect(db().prepare('SELECT COUNT(*) n,SUM(size) bytes FROM file_blobs').get()).toEqual({ n: 1, bytes: content.length });
const packed = JSON.parse(hosts.getHostOperationPayload(host.id, op.id).toString());
expect(packed.files[0]).toEqual({ path: 'plugins/plugin.dll', contentSha256: hash, sha256: hash });
expect(pruneFileBlobs()).toBe(0);
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
const request = (token: string) => new Request(`https://ec.test/api/agent/blobs/${hash}`, { headers: { authorization: `Bearer ${token}` } });
expect(Buffer.from(await GET({ request: request(credential), params: { sha256: hash } }).arrayBuffer())).toEqual(content);
expect(() => GET({ request: request(otherCredential), params: { sha256: hash } })).toThrow();
closeDb();
expect(hosts.getHostOperation(op.id)?.payload).toEqual(payload);
// Previous releases receive their original inline protocol during a rolling upgrade.
hosts.heartbeat(host.id, {});
expect(JSON.parse(hosts.getHostOperationPayload(host.id, op.id).toString())).toEqual(payload);
});
it('backfills legacy bytes once while preserving foreign keys, IDs and pending work', async () => {
const legacy = new Database(fixture.dbPath);
const content = Buffer.from([0, 255, 1, 88]);
const hash = createHash('sha256').update(content).digest('hex');
try {
legacy.exec(schema.replace(/\b(content|content_before|content_after|prior_content)_hash(\s+)TEXT REFERENCES file_blobs\(sha256\)/g, '$1$2BLOB'));
legacy.exec("INSERT INTO scripts(id,name,target_rel_path) VALUES(7,'legacy','plugin.dll')");
legacy.prepare('INSERT INTO script_versions(id,script_id,version,sha256,size,content) VALUES(9,7,1,?,?,?)').run(hash, content.length, content);
legacy.prepare("INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,content,inventory_source) VALUES('campaign','old','t5_one','plugin','plugin.dll',?,'test')").run(content);
legacy.exec("INSERT INTO deployments(id,action,started_at) VALUES('old','promote','2026-10-01T00:00:00.000Z')");
legacy.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before,content_after) VALUES('old','t5_one','updated','plugin.dll',?,?)").run(content, content);
} finally { legacy.close(); }
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getVersionContent } = await import('../../src/lib/server/fleet/scripts.js');
for (let pass = 0; pass < 2; pass++) {
expect(getVersionContent(9)).toEqual(content);
expect(db().prepare('SELECT content_hash,state FROM staged_file_changes').get()).toEqual({ content_hash: hash, state: 'pending' });
expect(db().prepare('SELECT COUNT(*) n FROM file_blobs').get()).toEqual({ n: 1 });
expect(db().pragma('foreign_key_check')).toEqual([]);
expect((db().pragma('table_info(script_versions)') as { name: string }[]).some(column => column.name === 'content')).toBe(false);
closeDb();
}
});
it('migrates legacy command and receipt bytes without changing replay identity', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Legacy' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, {});
const payload = { stageId: 'legacy', files: [{ path: 'plugin.dll', contentBase64: 'AAEC' }] };
const result = { before: [{ path: 'plugin.dll', contentBase64: 'AAEC' }] };
db().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,idempotency_key,payload,state,result,created_at,updated_at)
VALUES('legacy',?,'files.stage','legacy',?,'running',?, ?,?)`).run(host.id, JSON.stringify(payload), JSON.stringify(result), new Date().toISOString(), new Date().toISOString());
db().prepare("DELETE FROM ec_host_schema_migrations WHERE name IN ('staged-operation-payloads','operation-file-blobs')").run();
closeDb();
expect(hosts.getHostOperation('legacy')).toMatchObject({ payload, result, state: 'running' });
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'legacy' }).id).toBe('legacy');
expect(db().prepare('SELECT COUNT(*) n FROM file_blobs').get()).toEqual({ n: 1 });
expect(hosts.getHostOperationStatus('legacy')?.result).toEqual({ before: [{ path: 'plugin.dll', contentSha256: createHash('sha256').update(Buffer.from('AAEC', 'base64')).digest('hex') }] });
});
+1 -1
View File
@@ -113,7 +113,7 @@ describe('persisted Fleet operations', () => {
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
expect(fleetOperations(now)[0]).toMatchObject({ kind: 'recovery', state: 'attention', detail: 'RCON not answering' });
handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 0, X'')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)").run();
handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run();
handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')").run();
expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' }));
+2
View File
@@ -121,6 +121,8 @@ it('applies saved retention to cleanup while protecting unfinished campaigns and
expect(db().prepare('SELECT id FROM deployments').all()).toHaveLength(4);
saveFleetSettings({ ...fleetSettings(), deployKeepDays: 7 });
await sweep();
const { pruneHistory } = await import('../../src/lib/server/retention.js');
pruneHistory();
expect(db().prepare('SELECT id FROM deployments ORDER BY id').all()).toEqual([{ id: 'failed' }, { id: 'held' }, { id: 'pending' }]);
expect(db().prepare("SELECT server FROM deployment_servers WHERE deployment_id = 'completed'").all()).toEqual([]);
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([{ id: 'history' }]);
+2 -1
View File
@@ -72,7 +72,8 @@ describe('GitHub DLL imports', () => {
expect(imported.sha256).toBe(sha256);
expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`);
expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null });
expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', content, notes: metadata().body, state: 'untested' });
expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', notes: metadata().body, state: 'untested' });
expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(imported.versionId)).toEqual(content);
expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url });
const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId })));
expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true);
@@ -179,6 +179,9 @@ describe('routine host observation retention', () => {
retained.push(malformed);
api.handle.exec('DROP INDEX ec_host_operations_observation_retention');
api.handle.exec('DROP INDEX ec_host_operations_important_history');
api.handle.exec(`DROP TRIGGER operation_history_count_insert; DROP TRIGGER operation_history_count_delete;
DROP TRIGGER operation_history_count_update; DROP TABLE ec_host_operation_history_counts;
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1'`);
api.handle.exec('ALTER TABLE ec_host_operations DROP COLUMN observation_success');
api.closeDb();
+39
View File
@@ -43,6 +43,40 @@ async function operationHistory() {
}
describe('host operation history', () => {
it('maintains exact scoped totals through receipts, expiration, rollback, deletion and reopening without recounting history', async () => {
const api = await operationHistory();
const { importantOperationSql } = await import('../../src/lib/server/hosts/observations.js');
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
const ids = Array.from({ length: 35 }, (_, i) => api.add(i % 2 ? 'instance.inspect' : 'instance.restart', { state: 'pending' }));
const verify = () => {
for (const host of [undefined, api.first.id, api.second.id]) for (const server of [undefined, 'server-one']) {
for (const filter of ['all', 'routine', 'important'] as const) {
const where = ['o.history_expired_at IS NULL']; const args: string[] = [];
if (host) { where.push('o.host_id=?'); args.push(host); }
if (server) { where.push('o.server_name=?'); args.push(server); }
if (filter !== 'all') where.push(filter === 'routine' ? 'o.routine=1' : importantOperationSql);
const expected = db().prepare(`SELECT COUNT(*) total FROM ec_host_operations o WHERE ${where.join(' AND ')}`).get(...args) as { total: number };
expect(api.listHostOperationHistory(1, filter, { host, server }).total).toBe(expected.total);
}
}
};
verify();
for (const [index, id] of ids.entries()) api.reportHostOperationStatus(api.first.id, id,
index % 3 ? { state: 'completed', result: { healthy: index % 5 !== 0 } } : { state: 'failed', error: 'failure' });
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one',host_id=? WHERE rowid%2=0").run(api.second.id);
api.handle.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE rowid%4=0').run(new Date().toISOString());
api.handle.exec('DELETE FROM ec_host_operation_events WHERE operation_id IN (SELECT id FROM ec_host_operations WHERE rowid%7=0); DELETE FROM ec_host_operations WHERE rowid%7=0');
expect(() => api.handle.transaction(() => { api.handle.exec('DELETE FROM ec_host_operation_events; DELETE FROM ec_host_operations'); throw new Error('rollback'); })()).toThrow('rollback');
verify(); closeDb(); verify();
const prepare = vi.spyOn(db(), 'prepare');
api.listHostOperationHistory(1); api.listHostOperationHistory(2);
expect(prepare.mock.calls.some(([sql]) => /COUNT\(/i.test(sql))).toBe(false);
const count = prepare.mock.calls.find(([sql]) => sql.includes('FROM ec_host_operation_history_counts'))![0];
expect(db().prepare('EXPLAIN QUERY PLAN ' + count).all('', '')).toEqual(expect.arrayContaining([
expect.objectContaining({ detail: expect.stringContaining('USING PRIMARY KEY') })
]));
});
it('scopes counts and pages to one host or server and preserves deleted-host history', async () => {
const api = await seedHistory(90);
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE host_id=?").run(api.first.id);
@@ -223,6 +257,11 @@ describe('host operation history', () => {
const manual = api.add('rcon.execute', { payload: { command: 'say command-secret' } });
const unsafe = api.add('files.read', { payload: { path: '../private-secret' } });
api.handle.exec(`DROP INDEX ec_host_operations_routine_history;
DROP TRIGGER operation_history_count_insert;
DROP TRIGGER operation_history_count_delete;
DROP TRIGGER operation_history_count_update;
DROP TABLE ec_host_operation_history_counts;
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1';
DROP INDEX ec_host_operations_important_history;
DROP INDEX ec_host_operations_server_history;
ALTER TABLE ec_host_operations DROP COLUMN routine;
+8
View File
@@ -183,6 +183,14 @@ describe('automatic host preparation', () => {
});
expect(operations(host.id)).toEqual([]);
// Poll lanes only deliver queued work. One inventory heartbeat drives setup.
const reconcile = vi.spyOn(preparation, 'reconcileHostPreparation');
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
expect(reconcile).not.toHaveBeenCalled();
expect((await reportHeartbeat({ request: request('heartbeat', { inventory }) })).status).toBe(200);
expect(reconcile).toHaveBeenCalledOnce();
reconcile.mockRestore();
const firstPoll = await (await pollOperations({ request: request('operations') })).json();
expect(firstPoll.operations).toHaveLength(1);
const runtimeOperation = firstPoll.operations[0];
+18
View File
@@ -45,6 +45,24 @@ async function setup(count = 5, hostCount = 1) {
}
describe('host status observations', () => {
it('bounds monitoring reads below five per server across thirty instances', async () => {
const api = await setup(10, 3);
const hosts = await import('../../src/lib/server/hosts/index.js');
const wait = vi.spyOn(hosts, 'waitHostOperationStatus').mockImplementation(async id => {
const operation = hosts.getHostOperation(id)!;
return { ...operation, state: 'completed', result: { results: (operation.payload.instances as { instanceId: string; generation: number }[])
.map(target => ({ ...target, reply: { ok: true, text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n' } })) } };
});
const prepare = vi.spyOn(api.db, 'prepare');
try {
await api.fanoutStatus();
const reads = prepare.mock.calls.filter(([sql]) => /^\s*(SELECT|WITH)\b/i.test(sql));
expect(reads.length).toBeLessThan(30 * 5);
expect(reads.filter(([sql]) => sql.includes('instance_json')).length).toBeLessThanOrEqual(30 * 3 + 5);
expect(api.getServer('server-2-9')?.last_players).toBe(0);
} finally { prepare.mockRestore(); wait.mockRestore(); }
});
it('uses one batch per host for a fifteen-server pass and shares overlapping scans', async () => {
const api = await setup(5, 3);
const hosts = await import('../../src/lib/server/hosts/index.js');
@@ -41,6 +41,8 @@ describe('persisted launch recipe summaries', () => {
expect(recipeSummaryForDeployment('missing')).toBeNull();
} finally { parsing.mockRestore(); }
expect(recipeForDeployment('legacy')).toEqual({ recipe, blueprintName: 'Old collection' });
expect(db().prepare('SELECT recipe FROM server_launch_recipes').get()).toEqual({ recipe: encoded });
const stored = db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string };
expect(stored.recipe.length).toBeLessThan(1024);
expect(JSON.parse(stored.recipe).files[0]).not.toHaveProperty('content');
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } }))
}));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
let fixture: Fixture;
const selection = { game: 't5' as const, mapSlug: 'theater' };
+25
View File
@@ -34,6 +34,31 @@ async function matches() {
}>;
}
it('keeps report revisions stable during a match and invalidates completed data and renamed players', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const revision = () => (db().prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'").get() as { revision: number }).revision;
await report('game_start');
const started = revision();
for (let round = 1; round <= 30; round++) {
await report('round_change', round, [player(undefined, { kills: round * 10 })]);
await report('heartbeat', round);
}
expect(revision()).toBe(started);
await report('game_end', 30);
expect(revision()).toBeGreaterThan(started);
const completed = revision();
db().prepare('UPDATE leaderboard_players SET last_seen_at=?').run('2026-10-06T12:00:00.000Z');
expect(revision()).toBe(completed);
db().prepare("UPDATE leaderboard_players SET name='Renamed'").run();
expect(revision()).toBe(completed + 1);
db().prepare('UPDATE leaderboard_match_players SET kills=kills+1').run();
expect(revision()).toBe(completed + 2);
closeDb();
const reopened = revision();
db().prepare('DELETE FROM leaderboard_matches').run();
expect(revision()).toBeGreaterThan(reopened);
});
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
+53
View File
@@ -0,0 +1,53 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { migrateVersionLayout } from '../../src/lib/server/db/version-layout.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
it('returns metadata without plugin bytes and lists many artifacts in two queries', async () => {
const { createScript, getVersion, getVersionContent, getLatestVersion, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
const { scriptCatalog, betaScriptMetadata } = await import('../../src/lib/server/fleet/library-metadata.js');
const { db } = await import('../../src/lib/server/db/index.js');
const content = Buffer.alloc(1024 * 1024, 77);
let id = 0;
for (let i = 0; i < 20; i++) id = createScript({ name: `plugin-${i}`, kind: 'plugin', targetRelPath: 'plugin.dll', content }).version.id;
expect(getVersionContent(id)).toEqual(content);
expect(getVersion(id)).not.toHaveProperty('content');
expect(getLatestVersion(getVersion(id)!.script_id)).not.toHaveProperty('content');
expect(listVersions(getVersion(id)!.script_id)[0]).not.toHaveProperty('content');
const prepare = vi.spyOn(db(), 'prepare');
expect(scriptCatalog('plugin')).toHaveLength(20);
expect(prepare).toHaveBeenCalledTimes(2);
betaScriptMetadata([]);
expect(prepare.mock.calls.every(([sql]) => !/\bcontent\b/.test(sql))).toBe(true);
});
it('moves legacy content last without losing bytes, IDs, indexes, triggers or references', () => {
const handle = new Database(':memory:');
try {
handle.exec(`PRAGMA foreign_keys=ON;
CREATE TABLE script_versions(id INTEGER PRIMARY KEY AUTOINCREMENT, script_id INTEGER,
version INTEGER, content BLOB NOT NULL, state TEXT, release_version TEXT, UNIQUE(script_id,version));
CREATE INDEX version_state ON script_versions(state);
CREATE TABLE child(version INTEGER REFERENCES script_versions(id));
CREATE TABLE updates(n INTEGER); INSERT INTO updates VALUES(0);
CREATE TRIGGER version_update AFTER UPDATE ON script_versions BEGIN UPDATE updates SET n=n+1; END;
INSERT INTO script_versions VALUES(7,1,1,x'00ff','tested','v1');
INSERT INTO child VALUES(7);
INSERT INTO script_versions VALUES(100,1,2,x'aa','untested',NULL);
DELETE FROM script_versions WHERE id=100;`);
const before = handle.prepare('SELECT * FROM script_versions').all();
migrateVersionLayout(handle);
migrateVersionLayout(handle);
expect(handle.prepare('SELECT * FROM script_versions').all()).toEqual(before);
expect((handle.pragma('table_info(script_versions)') as { name: string }[]).at(-1)?.name).toBe('content');
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(handle.prepare("INSERT INTO script_versions(script_id,version,content) VALUES(1,3,x'01')").run().lastInsertRowid).toBe(101);
handle.prepare("UPDATE script_versions SET state='tested' WHERE id=101").run();
expect(handle.prepare('SELECT n FROM updates').get()).toEqual({ n: 1 });
expect((handle.pragma('index_list(script_versions)') as { name: string }[]).some(index => index.name === 'version_state')).toBe(true);
} finally { handle.close(); }
});
+1 -1
View File
@@ -40,7 +40,7 @@ async function setup(capable = true) {
const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n'));
for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) {
const { version } = artifact;
contents.set(`${root}/${artifact.script.target_rel_path}`, version.content);
contents.set(`${root}/${artifact.script.target_rel_path}`, (await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id));
recordScript(server, artifact.script.target_rel_path, version.id, version.sha256, version.size, 'test');
}
const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js');
+6 -3
View File
@@ -64,7 +64,10 @@ describe('native host enrollment and scoped authentication', () => {
const api = await enrolled();
const { db } = await import('../../src/lib/server/db/index.js');
const payload = { files: [{ path: 'plugins/fixture.dll', contentBase64: Buffer.alloc(8 * 1024 ** 2, 42).toString('base64') }] };
const bytes = Buffer.from(JSON.stringify(payload));
const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(Buffer.from(payload.files[0].contentBase64, 'base64'));
// Launch preparation transfers bytes separately and serializes only references.
const bytes = Buffer.from(JSON.stringify({ files: [{ path: payload.files[0].path, contentSha256: hash }] }));
const options = { instanceId: 'immutable-instance', generation: 1, idempotencyKey: 'large-launch' };
const parse = JSON.parse;
const parsing = vi.spyOn(JSON, 'parse').mockImplementation((value, reviver) => {
@@ -115,8 +118,8 @@ describe('native host enrollment and scoped authentication', () => {
{ payload: string; last_delivered_at: string | null };
expect(stored.payload).toBe(JSON.stringify(routing));
expect(stored.last_delivered_at).not.toBeNull();
expect(db().prepare('SELECT length(CAST(payload AS BLOB)) AS bytes FROM ec_host_operation_payloads WHERE operation_id=?').get(launch.id))
.toEqual({ bytes: bytes.length });
const saved = db().prepare('SELECT length(CAST(payload AS BLOB)) AS bytes FROM ec_host_operation_payloads WHERE operation_id=?').get(launch.id) as { bytes: number };
expect(saved.bytes).toBeLessThan(1024);
expect(api.getHostOperationPayload(api.host.id, launch.id)).toEqual(bytes);
expect(api.getHostOperationPayload(api.host.id, inline.id).toString('utf8')).toBe(JSON.stringify(inline.payload));
expect(db().prepare('SELECT 1 FROM ec_host_operation_payloads WHERE operation_id=?').get(inline.id)).toBeUndefined();
+20 -6
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { createHash } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
@@ -697,7 +698,7 @@ describe('native instance provisioning', () => {
const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(job.name);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)").run(job.name, after).lastInsertRowid);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after_hash,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)").run(job.name, putFileBlob(after, db())).lastInsertRowid);
const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
@@ -708,19 +709,32 @@ describe('native instance provisioning', () => {
const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const stage = getHostOperation(staged.stage_operation_id)!;
expect(stage.kind).toBe('files.stage'); expect(await operationFor(restart.id)).toBeNull();
const routing = db().prepare('SELECT payload FROM ec_host_operations WHERE id=?').get(stage.id) as { payload: string };
expect(JSON.parse(routing.payload)).toEqual({ stageId: restart.id });
const plan = db().prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { files_json: string };
expect(JSON.parse(plan.files_json)[0]).not.toHaveProperty('contentBase64');
const storedPayload = db().prepare('SELECT payload FROM ec_host_operation_payloads WHERE operation_id=?').get(stage.id) as { payload: string };
expect(JSON.parse(storedPayload.payload).files[0]).toMatchObject({ contentSha256: createHash('sha256').update(after).digest('hex') });
expect(JSON.parse(storedPayload.payload).files[0]).not.toHaveProperty('contentBase64');
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const { pendingHostOperations } = await import('../../src/lib/server/hosts/index.js');
expect(pendingHostOperations(hostId).find(operation => operation.id === stage.id)?.payload).toEqual(stage.payload);
await advance();
expect((db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string }).stage_operation_id).toBe(stage.id);
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('pending');
reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } });
await advance(); expect(await operationFor(restart.id)).toBeNull();
remote.players = 0; await advance();
const activation = (await operationFor(restart.id))!;
expect(activation).toMatchObject({ kind: 'instance.restart', generation: 1, payload: { stageId: restart.id } });
expect(db().prepare('SELECT content_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, snapshot_pending: 1 });
expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, snapshot_pending: 1 });
const files = stage.payload.files as Array<{ id: number; path: string }>;
reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } });
await advance();
expect((await detail(restart.id)).state).toBe('done');
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied');
expect(db().prepare('SELECT content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 });
expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe(createHash('sha256').update(after).digest('hex'));
});
@@ -815,7 +829,7 @@ describe('native instance provisioning', () => {
expect((await detail(restart.id)).state).toBe('failed');
expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('pending');
expect(db().prepare('SELECT COUNT(*) AS count FROM restart_rollout WHERE healthy_at IS NULL').get()).toEqual({ count: 0 });
expect(db().prepare('SELECT content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, sha256_before: null });
expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, sha256_before: null });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')).toBeUndefined();
});
@@ -1265,7 +1279,7 @@ describe('native provisioning cancellation and rollback', () => {
expect((await detail(restart.id)).state).toBe('cancelled');
const replacement = db().prepare('SELECT provisioning_id FROM deployment_servers WHERE deployment_id=?').get(rollback.deploymentId) as {provisioning_id:string};
expect(replacement.provisioning_id).not.toBe(restart.id);
expect(db().prepare("SELECT content FROM staged_file_changes WHERE source_kind='campaign' AND source_id=?").get(rollback.deploymentId))
expect(db().prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind='campaign' AND source_id=?").get(rollback.deploymentId))
.toEqual({content:Buffer.from('working')});
await advance();
const stagedRollback = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(replacement.provisioning_id) as {stage_operation_id:string};
@@ -1307,7 +1321,7 @@ describe('cancelled native mutation bookkeeping', () => {
await reviseBeta(betaId, 1, [{ scriptId: script.id, content: Buffer.from('revision two') }]);
await activate('revision one');
const rollback = await rollbackBeta(betaId);
expect(db().prepare("SELECT content FROM staged_file_changes WHERE source_kind = 'campaign' AND source_id = ?").get(rollback.deploymentId))
expect(db().prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind = 'campaign' AND source_id = ?").get(rollback.deploymentId))
.toEqual({ content: original === null ? null : Buffer.from(original) });
});
+1 -1
View File
@@ -12,7 +12,7 @@ describe('persistent activity', () => {
const handle = db();
handle.prepare("INSERT INTO servers (name) VALUES ('server')").run();
handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 0, X'')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)").run();
handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run();
handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'deploying')").run();
handle.prepare("UPDATE beta_servers SET stage = 'failed', error = 'write failed' WHERE beta_id = 1").run();
+3 -2
View File
@@ -26,7 +26,7 @@ describe('plugin release versions', () => {
[3, 'v3.0.4'], [2, 'v3.0.4'], [1, 'v3.1.0']
]);
expect(older.sha256).not.toBe(rebuild.sha256);
expect(getVersion(version.id)!.content).toEqual(plugin.content);
expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id)).toEqual(plugin.content);
});
it('allows unlabeled uploads and non-semver release tags', async () => {
@@ -53,8 +53,9 @@ describe('plugin release versions', () => {
db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
closeDb();
expect(getVersion(version.id)).toMatchObject({
id: version.id, version: 1, release_version: null, sha256: version.sha256, content: plugin.content
id: version.id, version: 1, release_version: null, sha256: version.sha256
});
expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id)).toEqual(plugin.content);
closeDb();
expect(getVersion(version.id)!.release_version).toBeNull();
});
+81
View File
@@ -0,0 +1,81 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { enableIncrementalVacuum, retentionClocks } from '../../src/lib/server/db/retention-clocks.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const old = new Date(Date.now() - 90 * 86400000).toISOString();
it('expires completed file payloads while keeping history, replay identity, holds and active work', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { pruneHistory, setRetentionControl } = await import('../../src/lib/server/retention.js');
const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const host = hosts.createHost({ name: 'Files' });
const payload = { files: [{ path: 'plugin.dll', contentBase64: Buffer.from('file').toString('base64') }] };
const completed = hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' });
hosts.reportHostOperation(host.id, completed.id, { state: 'completed', result: { before: [{ path: 'plugin.dll', contentBase64: 'YmVmb3Jl' }] } });
const active = hosts.queueHostOperation(host.id, 'files.stage', payload);
const held = hosts.queueHostOperation(host.id, 'files.stage', payload);
hosts.reportHostOperation(host.id, held.id, { state: 'completed', result: {} });
setRetentionControl({ category: 'operation', id: held.id, protected: true });
const failed = hosts.queueHostOperation(host.id, 'files.stage', payload);
hosts.reportHostOperation(host.id, failed.id, { state: 'failed', error: 'unfinished repair' });
db().prepare('UPDATE ec_host_operations SET updated_at=?').run(old);
db().exec("INSERT INTO servers(name) VALUES('s')");
const hash = putFileBlob(Buffer.from('recipe-only'));
db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by)
VALUES('launch','s','create','done','done',?,?,'test')`).run(old, old);
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)')
.run('launch', JSON.stringify({ files: [{ path: 'launch.dll', sha256: hash, contentHash: hash }] }));
expect(pruneHistory('workflow-payload')).toBe(1);
expect(db().prepare("SELECT state FROM server_deployments WHERE id='launch'").get()).toEqual({ state: 'done' });
expect(JSON.parse((db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string }).recipe))
.toEqual({ files: [{ path: 'launch.dll', sha256: hash }] });
expect(db().prepare('SELECT 1 FROM file_blobs WHERE sha256=?').get(hash)).toBeUndefined();
expect(pruneHistory('operation-payload')).toBe(1);
expect(hosts.getHostOperation(completed.id)).toMatchObject({ state: 'completed', payload: { files: [{ path: 'plugin.dll' }] }, result: { before: [{ path: 'plugin.dll' }] } });
expect(hosts.getHostOperation(active.id)?.payload).toEqual(payload);
expect(hosts.getHostOperation(held.id)?.payload).toEqual(payload);
expect(hosts.getHostOperation(failed.id)?.payload).toEqual(payload);
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' }).id).toBe(completed.id);
expect(() => hosts.queueHostOperation(host.id, 'files.stage', { files: [] }, { idempotencyKey: 'finished' })).toThrow('different request');
expect(pruneHistory('operation-payload')).toBe(0);
});
it('normalizes legacy retention clocks once and reuses clock indexes', () => {
const handle = new Database(':memory:');
try {
handle.exec(`CREATE TABLE history(id INTEGER PRIMARY KEY, at TEXT); CREATE INDEX history_age ON history(at);
INSERT INTO history(at) VALUES('2026-01-01 12:00:00'),('2026-01-01T08:00:00-04:00'),(NULL)`);
retentionClocks(handle, { history: ['at'] });
expect(handle.prepare('SELECT at FROM history ORDER BY id').all()).toEqual([
{ at: '2026-01-01T12:00:00.000Z' }, { at: '2026-01-01T12:00:00.000Z' }, { at: null }
]);
expect(handle.pragma('index_list(history)')).toHaveLength(1);
const prepare = vi.spyOn(handle, 'prepare');
retentionClocks(handle, { history: ['at'] });
expect(prepare.mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false);
} finally { handle.close(); }
});
it('enables incremental vacuum once and preserves host enable command boundaries', () => {
const handle = new Database(fixture.dbPath);
try {
handle.exec(`CREATE TABLE ec_host_operations(id TEXT, host_id TEXT);
CREATE TABLE ec_host_enable_requests(host_id TEXT, operation_floor INTEGER);
INSERT INTO ec_host_operations(rowid,id,host_id) VALUES(10,'before','h'),(30,'after','h');
INSERT INTO ec_host_enable_requests VALUES('h',20)`);
enableIncrementalVacuum(handle);
expect(handle.pragma('auto_vacuum', { simple: true })).toBe(2);
const eligible = () => handle.prepare(`SELECT id FROM ec_host_operations o JOIN ec_host_enable_requests e ON e.host_id=o.host_id
WHERE o.rowid>e.operation_floor`).all();
expect(eligible()).toEqual([{ id: 'after' }]);
const exec = vi.spyOn(handle, 'exec');
enableIncrementalVacuum(handle);
expect(exec.mock.calls).toHaveLength(0);
expect(eligible()).toEqual([{ id: 'after' }]);
} finally { handle.close(); }
});
+7 -6
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
@@ -72,11 +73,11 @@ it('selects resolved failures separately while preserving age, protection and ba
it('expires file bodies without silently turning rollback into file deletion', async () => {
const a = await api();
a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES('p','promote',?,?)").run(old,old);
a.db.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before,content_after) VALUES('p','s','updated','test.cfg',?,?)").run(Buffer.from('before'),Buffer.from('after'));
a.db.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash,content_after_hash) VALUES('p','s','updated','test.cfg',?,?)").run(putFileBlob(Buffer.from('before'), a.db),putFileBlob(Buffer.from('after'), a.db));
a.saveFleetSettings({ ...a.fleetSettings(),deployKeepDays: 0,fileContentKeepDays: 1 });
expect(a.retentionPreview().find(p=>p.category==='file-content')?.payloadBytes).toBe(11);
expect(a.pruneHistory('file-content')).toBe(1);
expect(a.db.prepare("SELECT content_before,content_after FROM deployment_files").get()).toEqual({ content_before:null,content_after:null });
expect(a.db.prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,(SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files").get()).toEqual({ content_before:null,content_after:null });
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
await expect(rollbackPromoteDeployment('p')).rejects.toThrow('expired');
expect(a.pruneHistory('file-content')).toBe(0);
@@ -119,7 +120,7 @@ it('deletes old maintenance jobs while preserving update suppression evidence an
it('keeps current and referenced artifact versions and honors the minimum version count', async () => {
const a = await api();
a.db.prepare("INSERT INTO scripts(id,name,target_rel_path,current_version_id) VALUES(1,'test','test.gsc',1)").run();
for(let n=1;n<=4;n++) a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content,uploaded_at) VALUES(?,1,?,'sha',1,X'01',?)").run(n,n,old);
for(let n=1;n<=4;n++) a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash,uploaded_at) VALUES(?,1,?,'sha',1,?,?)").run(n,n,putFileBlob(Buffer.from([1]), a.db),old);
a.saveFleetSettings({...a.fleetSettings(),versionKeepCount:2});
expect(a.pruneHistory('version')).toBe(1);
expect(a.db.prepare('SELECT id FROM script_versions ORDER BY id').all()).toEqual([{id:1},{id:3},{id:4}]);
@@ -153,15 +154,15 @@ it('prunes closed matches and audit events without deleting active matches or pr
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
const a = await api();
a.db.prepare("INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'s','s.gsc')").run();
a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content) VALUES(1,1,1,'sha',3,X'010203')").run();
a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'sha',3,?)").run(putFileBlob(Buffer.from([1,2,3]), a.db));
for (const [id,state] of [[1,'running'],[2,'rolled-back']] as const) {
a.db.prepare("INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at,closed_at) VALUES(?,1,1,'test',?,?,?)").run(id,state,old,old);
a.db.prepare("INSERT INTO beta_revisions(beta_id,revision,started_at) VALUES(?,1,?)").run(id,old);
a.db.prepare("INSERT INTO beta_revision_files(beta_id,revision,script_id,name,kind,path,version_id,version,sha256,content) VALUES(?,1,1,'s','script','s.gsc',1,1,'sha',X'010203')").run(id);
a.db.prepare("INSERT INTO beta_revision_files(beta_id,revision,script_id,name,kind,path,version_id,version,sha256,content_hash) VALUES(?,1,1,'s','script','s.gsc',1,1,'sha',?)").run(id,putFileBlob(Buffer.from([1,2,3]), a.db));
}
a.saveFleetSettings({...a.fleetSettings(),fileContentKeepDays:1});
expect(a.pruneHistory('beta-file-content')).toBe(1);
expect(a.db.prepare('SELECT beta_id,length(content) AS bytes FROM beta_revision_files ORDER BY beta_id').all()).toEqual([{beta_id:1,bytes:3},{beta_id:2,bytes:0}]);
expect(a.db.prepare('SELECT beta_id,COALESCE((SELECT size FROM file_blobs WHERE sha256=content_hash),0) AS bytes FROM beta_revision_files ORDER BY beta_id').all()).toEqual([{beta_id:1,bytes:3},{beta_id:2,bytes:0}]);
a.saveFleetSettings({...a.fleetSettings(),betaKeepDays:1});
expect(a.pruneHistory('beta')).toBe(1);
expect(a.db.prepare('SELECT id FROM betas').all()).toEqual([{id:1}]);
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -31,8 +32,8 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('promote', ?, 1, 'pending')").run(server);
db().prepare("INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)")
.run(script.id, version.id, prior.id, kind);
const file = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), before, after);
const file = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), putFileBlob(before, db()), putFileBlob(after, db()));
const path = join(destOfKind(server, kind), relPath);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, staged ? before : after);
@@ -86,8 +87,8 @@ describe('deferred promotion rollback', () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after)
VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, Buffer.from('queued'));
const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash)
VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, putFileBlob(Buffer.from('queued'), db()));
queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind: 'script', relPath: 'never.gsc',
content: Buffer.from('queued'), inventorySource: 'promote:promote', deploymentFileId: Number(pending.lastInsertRowid) });
const result = await rollbackPromoteDeployment('promote');
+4 -3
View File
@@ -21,8 +21,9 @@ function database(legacy = false) {
function seedHistory(db: Database.Database) {
db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960);
INSERT INTO file_blobs(sha256,size,content) VALUES('hash',4,X'74657374');
INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'script', 'test.gsc');
INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 4, X'74657374');
INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 4, 'hash');
INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'promoted');
INSERT INTO beta_servers (beta_id, server, had_original, stage, healthy_at) VALUES (1, 'server', 0, 'ready', 'healthy');
INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original) VALUES (1, 'server', 1, 0);
@@ -32,7 +33,7 @@ function seedHistory(db: Database.Database) {
INSERT INTO beta_file_changes (beta_id, server, rel_path) VALUES (1, 'server', 'test.gsc');
INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('campaign', 'promote', 'before', 'after');
INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, restarted_at) VALUES ('campaign', 'server', 1, 'done', 'restarted');
INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after) VALUES ('campaign', 'server', 'updated', 'test.gsc', X'74657374');
INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash) VALUES ('campaign', 'server', 'updated', 'test.gsc', 'hash');
INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES ('created', 'server', 'create', 'done', 'Ready', 'before', 'after', 'ui');
INSERT INTO maintenance_jobs (server, state, requested_at, finished_at, detail) VALUES ('server', 'done', 'before', 'after', 'Updated');
INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('incident', 'server', 'health', 'attention', 'Unhealthy', 'before', 'after');
@@ -76,7 +77,7 @@ describe('durable server history', () => {
expect(JSON.parse(retained.retained_server_deletions)).toEqual({ server: expect.any(String) });
expect(db.prepare('SELECT stage, healthy_at FROM beta_servers').get()).toEqual({ stage: 'ready', healthy_at: 'healthy' });
expect(db.prepare('SELECT restart_state, restarted_at FROM deployment_servers').get()).toEqual({ restart_state: 'done', restarted_at: 'restarted' });
expect(db.prepare('SELECT content_after FROM deployment_files').get()).toEqual({ content_after: Buffer.from('test') });
expect(db.prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files').get()).toEqual({ content_after: Buffer.from('test') });
expect(db.prepare('SELECT status, highest_round, ended_at FROM leaderboard_matches').get()).toEqual({ status: 'interrupted', highest_round: 25, ended_at: expect.any(String) });
db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960);
INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'new');
+3 -2
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
@@ -20,8 +21,8 @@ it('exposes retained campaign and provisioning history without rebinding links t
finishDeployment(operation, { ok: true });
db().prepare("INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('history-campaign', 'promote', ?, ?)").run(at, at);
db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('history-campaign', ?, 1, 'done')").run(server);
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before, content_after) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)")
.run(server, Buffer.from('before'), Buffer.from('after'));
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash, content_after_hash) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)")
.run(server, putFileBlob(Buffer.from('before'), db()), putFileBlob(Buffer.from('after'), db()));
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
closeDb();
+32
View File
@@ -0,0 +1,32 @@
import Database from 'better-sqlite3';
import { afterEach, expect, it, vi } from 'vitest';
import { timeStatement } from '../../src/lib/server/db/slow-statements.js';
afterEach(() => vi.restoreAllMocks());
it('preserves results and errors, logs slow query shapes without values', () => {
const db = new Database(':memory:');
const log = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(21)
.mockReturnValueOnce(30).mockReturnValueOnce(31).mockReturnValueOnce(40).mockReturnValueOnce(62);
try {
const query = timeStatement(db.prepare("SELECT ? AS value, 'private''literal' AS literal"), "SELECT ? AS value, 'private''literal' AS literal", 20);
expect(query.get('secret')).toEqual({ value: 'secret', literal: "private'literal" });
expect(query.all('other')).toHaveLength(1);
expect(() => query.get()).toThrow();
expect(log).toHaveBeenCalledTimes(2);
expect(log.mock.calls[0]).toEqual(['[sql]', { ms: 21, method: 'get', sql: 'SELECT ? AS value, ? AS literal' }]);
expect(JSON.stringify(log.mock.calls)).not.toMatch(/secret|private|other/);
} finally { db.close(); }
});
it('does not wrap statements when disabled or invalid', () => {
const db = new Database(':memory:');
try {
const statement = db.prepare('SELECT 1');
const get = statement.get;
for (const threshold of [0, -1, NaN, Infinity]) {
expect(timeStatement(statement, 'SELECT 1', threshold).get).toBe(get);
}
} finally { db.close(); }
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { existsSync, writeFileSync } from 'node:fs';
@@ -104,8 +105,8 @@ describe.each<Source>(['campaign', 'beta'])('%s staged file activation', (source
it('applies the exact queued bytes even if artifact storage changes while waiting', async () => {
const { replacement } = await queueChange(source);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE script_versions SET content = ? WHERE id = ?')
.run(Buffer.from('changed after review'), replacement.id);
db().prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?')
.run(putFileBlob(Buffer.from('changed after review'), db()), replacement.id);
await tick(source);
expect(await readFile(destination())).toEqual(after);
});