From 26de43639f4ab189349d1c31435e717d4c1bdf4b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 8 Oct 2026 12:09:18 -0400 Subject: [PATCH] Sort active provisioning jobs by progress --- docs/guides/provisioning.md | 7 +++ src/lib/server/provisioning/history.ts | 20 +++++++- tests/unit/provisioning-list.test.ts | 63 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index e56c7f0..bc4baf2 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -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** 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 readiness checks. Blockers explain what needs attention before launch. Runtime and network overrides are under **Advanced settings**; image, configuration, diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 5040c12..101a5e0 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -15,6 +15,7 @@ import { canCancelProvisioningJob, provisioningCancelled } from './cancellation. import { nativeDeployment } from '../backends/native.js'; import { safetyOverride, safetyOverrideAvailability } from './safety-override.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'; 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) }; } +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, active: boolean): ProvisioningListRow[] { 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[]; 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 WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as (Omit & { operation_id: string })[]; diff --git a/tests/unit/provisioning-list.test.ts b/tests/unit/provisioning-list.test.ts index e421748..583785c 100644 --- a/tests/unit/provisioning-list.test.ts +++ b/tests/unit/provisioning-list.test.ts @@ -70,6 +70,69 @@ describe('bounded provisioning lists', () => { 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 () => { const { handle, add, REMOVING_SERVER } = await setup(); handle.transaction(() => {