Fix native provisioning scheduling and progress reporting

This commit is contained in:
2026-10-04 14:50:59 -04:00
parent 1bbee98b62
commit 2adadb0d05
18 changed files with 569 additions and 84 deletions
+62 -34
View File
@@ -13,6 +13,25 @@ import { selectedZombieMap } from './map-aliases.js';
import { getServer, listServers } from './servers.js';
import { promotedArtifacts } from './promoted-artifacts.js';
import { queueFileChange } from './staged-files.js';
import { trackActivity } from '../pipeline/activity.js';
/** Queue a small batch for the agent's next poll, preserving input order. */
async function scanBatch<T, R>(items: readonly T[], work: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
let failed = false;
async function pump() {
while (!failed && cursor < items.length) {
const index = cursor++;
try { results[index] = await work(items[index]); }
catch (error) { failed = true; throw error; }
}
}
const workers = await Promise.allSettled(Array.from({ length: Math.min(4, items.length) }, pump));
const failure = workers.find(result => result.status === 'rejected');
if (failure?.status === 'rejected') throw failure.reason;
return results;
}
// Destinations drift scans, in a stable order so the UI shows scripts first.
// Baseline shares the config destination — one physical dir, two logical kinds.
@@ -71,22 +90,26 @@ async function readExistingFile(path: string): Promise<Buffer | null> {
async function walkFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function recur(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
for (const e of entries) {
if (DRIFT_IGNORE_FILENAMES.has(e.name)) continue;
const abs = join(dir, e.name);
if (e.isDirectory()) await recur(abs);
else if (e.isFile()) out.push(toPosix(relative(root, abs)));
}
let directories = [root];
while (directories.length) {
const children = await scanBatch(directories, async dir => {
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
const next: string[] = [];
for (const entry of entries) {
if (DRIFT_IGNORE_FILENAMES.has(entry.name)) continue;
const abs = join(dir, entry.name);
if (entry.isDirectory()) next.push(abs);
else if (entry.isFile()) out.push(toPosix(relative(root, abs)));
}
return next;
});
directories = children.flat();
}
await recur(root);
return out.sort();
}
@@ -98,6 +121,10 @@ async function walkFiles(root: string): Promise<string[]> {
* artifacts with the promoted versions.
*/
export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
return trackActivity(`drift:${server}`, `Checking files on ${server}`, () => scanServerFiles(server));
}
async function scanServerFiles(server: string): Promise<DriftEntry[]> {
const srv = getServer(server);
if (!srv || srv.destroyed_at || hasActiveBeta(server)) return [];
// A missing mount is unavailable evidence, not an empty managed tree.
@@ -121,27 +148,28 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
const onDisk = await walkFiles(dest);
if (onDisk.length === 0) continue;
for (const relPath of onDisk) {
if (managed.has(`${kind}:${relPath}`)) continue;
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) continue;
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
if (managed.has(`${kind}:${relPath}`)) return null;
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) return null;
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) continue;
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) return null;
const abs = join(dest, relPath);
try {
const st = await stat(abs);
const buf = await readFile(abs);
drift.push({
return {
server, relPath,
size: st.size,
size: buf.byteLength,
sha256: sha256Buffer(buf),
kind,
driftKind: 'untracked'
});
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
// A file disappeared between walk and stat; the next scan sees it.
}
}
return null;
});
drift.push(...untracked.filter((entry): entry is DriftEntry => entry !== null));
}
await requireServerDirectory(server);
@@ -171,18 +199,19 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
// Inventory records the last managed write, not the current disk bytes.
// Check both so edits and outdated versions are detected on tracked files.
for (const { kind, script, version } of promotedArtifacts(srv.game, srv.map)) {
const managed = await scanBatch(promotedArtifacts(srv.game, srv.map), async ({ kind, script, version }): Promise<DriftEntry | null> => {
const content = await readExistingFile(join(destOfKind(server, kind), script.target_rel_path));
const inventory = getServerScript(server, script.target_rel_path);
if (content?.equals(version.content) && inventory?.sha256 === version.sha256
&& inventory.script_version_id === version.id) continue;
out.push({
&& inventory.script_version_id === version.id) return null;
return {
server, relPath: script.target_rel_path, kind,
driftKind: content ? 'replace' : 'missing',
size: version.size, sha256: version.sha256,
scriptId: script.id, scriptVersionId: version.id
});
}
};
});
out.push(...managed.filter((entry): entry is DriftEntry => entry !== null));
// Composed cfg: the effective dedicated_sp.cfg is baseline + all promoted
// overlays for this server's scope. It's "missing" when either the file
@@ -329,15 +358,14 @@ export interface PreparedDriftCampaign {
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
const servers = driftTargets([server]);
const entries = await scanServerDrift(server);
const files: PreparedDriftCampaign['files'] = [];
for (const entry of entries) {
const files = await scanBatch(entries, async entry => {
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(server, entry);
if (entry.driftKind !== 'untracked' && !after) throw new Error(`Cannot resolve promoted content for ${entry.relPath}`);
files.push({ before, after, file: { server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
return { before, after, file: { server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null } });
}
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null } };
});
await requireServerDirectory(server);
if (hasActiveBeta(server)) throw new Error('Active beta owns this server; reconciliation deferred');
return { plan: buildPlan(servers, files.map(({ file }) => file)), files };