Sort active provisioning jobs by progress

This commit is contained in:
2026-10-08 12:09:18 -04:00
parent a33b897cd0
commit 26de43639f
3 changed files with 89 additions and 1 deletions
+7
View File
@@ -12,6 +12,13 @@ are shown. Confirmations name the server and its current players and round;
disruptive actions still wait for a fresh empty-lobby check. **Manage server** disruptive actions still wait for a fresh empty-lobby check. **Manage server**
on the operations page offers the same actions for a selected server. on the operations page offers the same actions for a selected server.
The **Active** tab shows jobs verifying health first, then jobs executing work,
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands
awaiting host confirmation count as executing work. Within each group, older
requests appear first, with recording order breaking timestamp ties. **History**
shows the newest requests first. This display order does not change execution
priority or capacity admission.
**Create server** opens one dialog with game and map choices followed by automatic **Create server** opens one dialog with game and map choices followed by automatic
readiness checks. Blockers explain what needs attention before launch. Runtime readiness checks. Blockers explain what needs attention before launch. Runtime
and network overrides are under **Advanced settings**; image, configuration, and network overrides are under **Advanced settings**; image, configuration,
+19 -1
View File
@@ -15,6 +15,7 @@ import { canCancelProvisioningJob, provisioningCancelled } from './cancellation.
import { nativeDeployment } from '../backends/native.js'; import { nativeDeployment } from '../backends/native.js';
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js'; import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
import { serverTitle } from '../../ui/server-labels.js'; import { serverTitle } from '../../ui/server-labels.js';
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js'; import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string { export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
@@ -301,11 +302,28 @@ function deploymentListQuery(options: ProvisioningListOptions) {
limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) }; limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) };
} }
function activeDeploymentRank(row: ProvisioningListRow): number {
const { group } = provisioningQueueStatus(row);
if (group === 'verifying') return 0;
if (group === 'running') return 1;
// Launch preparation keeps its queued/waiting state until command submission.
if (row.phase === 'prepare' && !row.waitCode) return 1;
// Remote file staging currently records its progress only in the stage message.
if (row.waitCode === 'native-host' && ['Staging and verifying files on the host.',
'Waiting for verified remote file staging.'].includes(row.stage)) return 1;
if (group === 'queued') return 3;
// A submitted command remains in progress while its agent result is pending.
if (row.state === 'running' && row.phase === 'container-action' && row.waitCode === 'native-host') return 1;
return 2;
}
function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] { function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] {
const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where} const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where}
ORDER BY d.started_at DESC, d.rowid DESC ${active ? '' : 'LIMIT ? OFFSET ?'}`) ORDER BY ${active ? 'd.started_at ASC, d.rowid ASC' : 'd.started_at DESC, d.rowid DESC LIMIT ? OFFSET ?'}`)
.all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[]; .all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[];
if (!rows.length) return rows; if (!rows.length) return rows;
// Stable sorting keeps request/insertion order within each progress group.
if (active) rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources
WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as
(Omit<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[]; (Omit<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[];
+63
View File
@@ -70,6 +70,69 @@ describe('bounded provisioning lists', () => {
expect(JSON.stringify(deploymentListPage({ view: 'active' }))).not.toContain('private snapshot'); expect(JSON.stringify(deploymentListPage({ view: 'active' }))).not.toContain('private snapshot');
}); });
it('puts verification and executing work ahead of preflight waits and capacity queues', async () => {
const { add, handle, listDeployments, deploymentListPage } = await setup();
const addProgress = (id: string, state: string, second: number, phase: string,
waitCode: string | null = null, stage = 'Recorded', action = 'rebuild') => {
add(id, state, action, 't5_zombie_theater_1', null, stage, `2026-01-01T00:00:${String(second).padStart(2, '0')}.000Z`);
handle.prepare('UPDATE server_deployments SET phase_key=?, observation_json=? WHERE id=?')
.run(phase, JSON.stringify({ blocker: waitCode ? { code: waitCode } : null }), id);
};
// Request order is deliberately different from progress order.
addProgress('queued', 'queued', 1, 'requested');
addProgress('cleanup', 'waiting', 12, 'cleanup', null, 'Removing server files and registry', 'destroy');
addProgress('verify-newer', 'awaiting-health', 21, 'verify-map');
addProgress('capacity', 'running', 2, 'startup-queue', 'startup-capacity');
addProgress('operation-conflict', 'running', 6, 'container-action', 'operation-conflict');
addProgress('agent-wait', 'running', 11, 'container-action', 'native-host', 'Agent operation pending');
addProgress('host-wait', 'waiting', 5, 'eligibility', 'native-host', 'Host is unavailable; waiting for reconnection.');
addProgress('staging', 'waiting', 7, 'eligibility', 'native-host', 'Staging and verifying files on the host.');
addProgress('staging-wait', 'waiting', 8, 'eligibility', 'native-host', 'Waiting for verified remote file staging.');
addProgress('legacy-capacity', 'waiting', 3, 'eligibility', 'native-host', 'Queued to rebuild; waiting for another server to recover');
addProgress('verify-older', 'awaiting-health', 20, 'verify-container', 'native-host', 'Waiting for container readiness: starting');
addProgress('preparing-queued', 'queued', 9, 'prepare');
addProgress('executing', 'running', 10, 'prepare');
addProgress('eligibility', 'running', 4, 'eligibility', 'eligibility');
addProgress('interrupted', 'interrupted', 30, 'container-action', 'native-host');
addProgress('failed', 'failed', 31, 'verify-map', 'native-host');
const expected = ['verify-older', 'verify-newer', 'staging', 'staging-wait', 'preparing-queued', 'executing', 'agent-wait', 'cleanup',
'eligibility', 'host-wait', 'operation-conflict', 'queued', 'capacity', 'legacy-capacity'];
expect(listDeployments({ view: 'active' }).map(row => row.id)).toEqual(expected);
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
const active = await GET({ url: new URL('http://localhost/api/provisioning?view=active&limit=1') }).json();
expect(active.deployments.map((row: { id: string }) => row.id)).toEqual(expected);
expect(active).toMatchObject({ total: expected.length, needsReview: 2 });
// Progress priority applies only to Active; retained outcomes and mixed reads stay newest first.
expect(deploymentListPage({ view: 'history', limit: 1 }).deployments.map(row => row.id)).toEqual(['failed']);
expect(deploymentListPage({ view: 'history', limit: 1, offset: 1 }).deployments.map(row => row.id)).toEqual(['interrupted']);
expect(listDeployments({ view: 'all', limit: 3 }).map(row => row.id)).toEqual(['failed', 'interrupted', 'verify-newer']);
});
it('keeps oldest requests and insertion ties stable while active progress is polled', async () => {
const { add, handle, listDeployments } = await setup();
add('z-first', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking game status', '2026-01-02');
add('a-second', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking selected engine map', '2026-01-02');
add('old-inserted-last', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking container state', '2026-01-01');
add('queued', 'queued', 'rebuild', 't5_zombie_theater_1', null, 'Request recorded', '2025-12-31');
const ids = () => listDeployments({ view: 'active' }).map(row => row.id);
const expected = ['old-inserted-last', 'z-first', 'a-second', 'queued'];
expect(ids()).toEqual(expected);
expect(ids()).toEqual(expected);
handle.prepare(`UPDATE server_deployments SET phase_key='verify-container', phase_started_at='2026-01-03',
observed_at='2026-01-03', observation_json=? WHERE id='z-first'`)
.run(JSON.stringify({ blocker: { code: 'native-host', message: 'Waiting for container readiness: starting' } }));
handle.prepare("UPDATE server_deployments SET phase_key='verify-plutonium', phase_started_at='2026-01-04' WHERE id='a-second'").run();
expect(ids()).toEqual(expected);
handle.prepare("UPDATE server_deployments SET state='done', finished_at='2026-01-05' WHERE id='z-first'").run();
handle.prepare("UPDATE server_deployments SET state='awaiting-health', phase_key='verify-game' WHERE id='queued'").run();
expect(ids()).toEqual(['queued', 'old-inserted-last', 'a-second']);
expect(listDeployments({ view: 'all' }).map(row => row.id)).toEqual(['a-second', 'z-first', 'old-inserted-last', 'queued']);
});
it('polls every active row and counts review outcomes without transferring retained history', async () => { it('polls every active row and counts review outcomes without transferring retained history', async () => {
const { handle, add, REMOVING_SERVER } = await setup(); const { handle, add, REMOVING_SERVER } = await setup();
handle.transaction(() => { handle.transaction(() => {