Fix native provisioning scheduling and progress reporting
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user