Hide live capacity for inactive provisioning operations (Refs #85)

This commit is contained in:
2026-10-09 09:55:36 -04:00
parent 4f5725ee74
commit cdc400c6bb
2 changed files with 20 additions and 9 deletions
+2 -1
View File
@@ -95,8 +95,9 @@ function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): Prov
href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null }; href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null };
} }
/** These are current pool snapshots, not a second admission/eligibility engine. */ /** Active operations get current pool snapshots, not a second admission/eligibility engine. */
function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): ProvisioningCapacity { function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): ProvisioningCapacity {
if (!['queued', 'waiting', 'running', 'awaiting-health'].includes(deployment.state)) return { evaluatedAt, pools: [] };
const settings = fleetSettings(); const settings = fleetSettings();
const pools: ProvisioningCapacity['pools'] = []; const pools: ProvisioningCapacity['pools'] = [];
if (deployment.action === 'create' || deployment.action === 'rebuild') { if (deployment.action === 'create' || deployment.action === 'rebuild') {
+18 -8
View File
@@ -70,21 +70,29 @@ describe('provisioning detail projection', () => {
expect(getDeployment('legacy')!.warningMessages).toEqual(['Recorded warnings could not be read.']); expect(getDeployment('legacy')!.warningMessages).toEqual(['Recorded warnings could not be read.']);
}); });
it('correlates reservations by operation ID without assigning a later same-name slot to historical work', async () => { it.each(['create', 'rebuild', 'start', 'restart'] as const)('shows live capacity only for active %s operations, excluding historical work on the same server', async action => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { beginDeployment, getDeployment, finishDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { beginDeployment, getDeployment, finishDeployment } = await import('../../src/lib/server/provisioning/history.js');
db().prepare("INSERT INTO servers (name) VALUES ('server')").run(); db().prepare("INSERT INTO servers (name) VALUES ('server')").run();
const old = beginDeployment('server', 'restart'); const old = beginDeployment('server', action);
finishDeployment(old, { ok: false, message: 'Timeout' }); finishDeployment(old, { ok: false, message: 'Timeout' });
const current = beginDeployment('server', 'restart'); const current = beginDeployment('server', action);
db().prepare("INSERT INTO restart_rollout (server,requested_at,operation_id) VALUES ('server','2026-01-01',?)").run(current); db().prepare("INSERT INTO restart_rollout (server,requested_at,operation_id) VALUES ('server','2026-01-01',?)").run(current);
expect(getDeployment(old)!.capacity.pools[0]).toMatchObject({ kind: 'restart', used: 1, ownsSlot: false, const kinds = action === 'create' ? ['startup'] : action === 'rebuild' ? ['startup', 'restart'] : ['restart'];
holders: [{ id: current, server: 'server' }] }); expect(getDeployment(current)!.capacity.pools).toEqual(kinds.map(kind => expect.objectContaining({
kind, used: 1, ownsSlot: true, holders: [{ id: current, server: 'server' }]
})));
for (const state of ['done', 'warning', 'failed', 'interrupted', 'cancelled']) {
db().prepare('UPDATE server_deployments SET state = ? WHERE id = ?').run(state, old);
expect(getDeployment(old)!.capacity.pools, state).toEqual([]);
}
expect(getDeployment(old)!.progress!.recovery).toBeNull(); expect(getDeployment(old)!.progress!.recovery).toBeNull();
expect(getDeployment(current)!.capacity.pools[0].ownsSlot).toBe(true);
db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(new Date().toISOString()); db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(new Date().toISOString());
expect(getDeployment(old)!.capacity.pools[0].used).toBe(0); expect(getDeployment(old)!.capacity.pools).toEqual([]);
expect(getDeployment(old)!.deployment.state).toBe('failed'); for (const state of ['queued', 'waiting', 'running', 'awaiting-health']) {
db().prepare('UPDATE server_deployments SET state = ? WHERE id = ?').run(state, current);
expect(getDeployment(current)!.capacity.pools.map(pool => pool.kind), state).toEqual(kinds);
}
}); });
it('labels submission order independently of startup reservations and retains queued recipe status', async () => { it('labels submission order independently of startup reservations and retains queued recipe status', async () => {
@@ -113,10 +121,12 @@ describe('provisioning detail projection', () => {
history.finishDeployment(id, { ok: false, message: 'Observation interrupted' }); history.finishDeployment(id, { ok: false, message: 'Observation interrupted' });
const failed = history.getDeployment(id)!; const failed = history.getDeployment(id)!;
expect(failed.progress?.phase).toBe('verify-game'); expect(failed.progress?.phase).toBe('verify-game');
expect(failed.capacity.pools).toEqual([]);
expect(history.resumeVerification(id)).toBe(true); expect(history.resumeVerification(id)).toBe(true);
const resumed = history.getDeployment(id)!; const resumed = history.getDeployment(id)!;
expect(resumed.deployment).toMatchObject({ id, state: 'awaiting-health', finished_at: null, error: null }); expect(resumed.deployment).toMatchObject({ id, state: 'awaiting-health', finished_at: null, error: null });
expect(resumed.progress).toMatchObject({ phase: 'verify-container', checks: [], observedAt: null }); expect(resumed.progress).toMatchObject({ phase: 'verify-container', checks: [], observedAt: null });
expect(resumed.capacity.pools).toEqual([expect.objectContaining({ kind: 'startup', ownsSlot: true })]);
const outcomes = resumed.events.filter(event => event.kind === 'outcome'); const outcomes = resumed.events.filter(event => event.kind === 'outcome');
expect(outcomes).toHaveLength(1); expect(outcomes).toHaveLength(1);
expect(JSON.parse(outcomes[0].details_json!)).toMatchObject({ state: 'failed', finishedAt: failed.deployment.finished_at, expect(JSON.parse(outcomes[0].details_json!)).toMatchObject({ state: 'failed', finishedAt: failed.deployment.finished_at,