From ba9bed6a0dcf40db3226aa99a785ef04a59c6a89 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 09:45:53 -0400 Subject: [PATCH] Fix release version labels across deployment views --- src/lib/artifact-version.ts | 13 +++ src/lib/deployment-plan.ts | 2 + src/lib/server/fleet/launch-recipes.ts | 31 +++++- src/lib/server/fleet/scripts.ts | 9 +- .../server/provisioning/activation-files.ts | 4 +- src/lib/server/provisioning/move-recipes.ts | 11 ++- src/lib/server/provisioning/native.ts | 3 +- src/lib/ui/campaigns.ts | 29 ++++-- .../components/DeployCollectionDialog.svelte | 5 +- .../ui/components/DeployServerDialog.svelte | 3 +- src/lib/ui/components/LaunchRecipe.svelte | 3 +- src/routes/api/campaigns/+server.ts | 16 ++- src/routes/api/campaigns/[id]/+server.ts | 6 +- src/routes/api/deployments/[id]/+server.ts | 6 +- tests/unit/campaign-list.test.ts | 48 ++++++++- .../launch-recipe-summary-migration.test.ts | 89 +++++++++++++++++ tests/unit/launch-recipes.test.ts | 50 ++++++++++ tests/unit/native-provisioning.test.ts | 98 +++++++++++++++++-- tests/unit/ui-campaigns.test.ts | 48 ++++++++- tests/unit/ui-launch-recipe.test.ts | 50 ++++++++++ 20 files changed, 485 insertions(+), 39 deletions(-) create mode 100644 tests/unit/ui-launch-recipe.test.ts diff --git a/src/lib/artifact-version.ts b/src/lib/artifact-version.ts index 66d76eb..a928668 100644 --- a/src/lib/artifact-version.ts +++ b/src/lib/artifact-version.ts @@ -1,3 +1,5 @@ +import type { LaunchFileSummary } from './deployment-plan.js'; + /** Keep the upload number visible: two builds may share a publisher's release tag. */ export function artifactVersionLabel( version: { version: number; release_version?: string | null } | null | undefined, @@ -10,3 +12,14 @@ export function artifactVersionLabel( ? `Unversioned (upload ${version.version})` : `v${version.version}`; } + +export function launchFileVersionLabel( + file: Pick +): string { + return file.version === null + ? 'Generated' + : artifactVersionLabel( + { version: file.version, release_version: file.releaseVersion }, + file.kind + ); +} diff --git a/src/lib/deployment-plan.ts b/src/lib/deployment-plan.ts index 71d9618..498c350 100644 --- a/src/lib/deployment-plan.ts +++ b/src/lib/deployment-plan.ts @@ -6,6 +6,8 @@ export interface LaunchFileSummary { path: string; versionId: number | null; version: number | null; + /** Missing on snapshots recorded before publisher release versions were retained. */ + releaseVersion?: string | null; sha256: string; size: number; } diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts index 26c32f9..6b3d04e 100644 --- a/src/lib/server/fleet/launch-recipes.ts +++ b/src/lib/server/fleet/launch-recipes.ts @@ -171,6 +171,7 @@ function buildRecipe( path: script.target_rel_path, versionId: version.id, version: version.version, + releaseVersion: version.release_version, sha256: version.sha256, size: version.size }); @@ -187,6 +188,7 @@ function buildRecipe( kind: 'config', versionId: null, version: null, + releaseVersion: null, sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { content: content.toString('base64') } : {}) @@ -249,18 +251,41 @@ export function recipeSummaryForDeployment( ) .get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined; if (!row) return null; - if (row.recipe_summary !== null) - return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name }; + if (row.recipe_summary !== null) { + const saved: LaunchRecipeSummary = JSON.parse(row.recipe_summary); + const summary = withHistoricalReleaseVersions(saved); + if (summary !== saved) + db() + .prepare( + 'UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?' + ) + .run(JSON.stringify(summary), id); + return { recipe: summary, blueprintName: row.blueprint_name }; + } // Old snapshots remain readable. Populate on first access, without making // controller startup scan and decode every retained plugin in its history. const launch = recipeForDeployment(id)!; - const summary = summarizeRecipe(launch.recipe); + const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe)); db() .prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?') .run(JSON.stringify(summary), id); return { recipe: summary, blueprintName: row.blueprint_name }; } +/** Restore display metadata from the exact retained upload, preserving the captured recipe. */ +function withHistoricalReleaseVersions(summary: LaunchRecipeSummary): LaunchRecipeSummary { + const files = summary.files.map((file) => { + if (file.releaseVersion !== undefined || file.versionId === null) return file; + const version = getVersionMetadata(file.versionId); + if (!version || version.version !== file.version || version.sha256 !== file.sha256) + return file; + return { ...file, releaseVersion: version.release_version }; + }); + return files.some((file, index) => file !== summary.files[index]) + ? { ...summary, files } + : summary; +} + export function recipeForDeployment( id: string ): { recipe: LaunchRecipePlan; blueprintName: string | null } | null { diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 21eca18..44728ea 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -192,12 +192,17 @@ export function getVersion(id: number): ScriptVersionRow | undefined { ScriptVersionRow | undefined; } -export type ScriptVersionMetadata = Pick; +export type ScriptVersionMetadata = Pick< + ScriptVersionRow, + 'id' | 'version' | 'release_version' | 'sha256' | 'size' +>; /** Planning a launch never needs to load potentially large script or plugin bytes. */ export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined { return db() - .prepare('SELECT id, version, sha256, size FROM script_versions WHERE id = ?') + .prepare( + 'SELECT id, version, release_version, sha256, size FROM script_versions WHERE id = ?' + ) .get(id) as ScriptVersionMetadata | undefined; } diff --git a/src/lib/server/provisioning/activation-files.ts b/src/lib/server/provisioning/activation-files.ts index 09312e5..1904b8a 100644 --- a/src/lib/server/provisioning/activation-files.ts +++ b/src/lib/server/provisioning/activation-files.ts @@ -12,7 +12,7 @@ export function applyActivationFiles( ): void { const changes = db() .prepare( - `SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version FROM staged_file_changes f + `SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version, v.release_version FROM staged_file_changes f JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server LEFT JOIN script_versions v ON v.id=f.script_version_id WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id` @@ -21,6 +21,7 @@ export function applyActivationFiles( content_hash: string | null; size: number; version: number | null; + release_version: string | null; })[]; for (const change of changes) { assertPublicFileTarget(change.server, change.kind, change.rel_path); @@ -36,6 +37,7 @@ export function applyActivationFiles( path: change.rel_path, versionId: change.script_version_id, version: change.version, + releaseVersion: change.release_version, sha256: change.content_hash, size: change.size, ...(includeContent diff --git a/src/lib/server/provisioning/move-recipes.ts b/src/lib/server/provisioning/move-recipes.ts index ecde12a..c89f9e5 100644 --- a/src/lib/server/provisioning/move-recipes.ts +++ b/src/lib/server/provisioning/move-recipes.ts @@ -38,11 +38,17 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan { ? undefined : (db() .prepare( - `SELECT v.version,v.kind,s.name + `SELECT v.version,v.release_version,v.kind,s.name FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?` ) .get(file.script_version_id) as - { version: number; kind: string; name: string } | undefined); + | { + version: number; + release_version: string | null; + kind: string; + name: string; + } + | undefined); const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind); if (!['script', 'plugin', 'config'].includes(kind ?? '')) throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`); @@ -59,6 +65,7 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan { path: file.rel_path, versionId: file.script_version_id, version: version?.version ?? null, + releaseVersion: version?.release_version ?? null, sha256: file.sha256, size: file.size }; diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index 9a245a0..98fb83c 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -1149,7 +1149,8 @@ function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boo expectedMap: recipe.expectedMap, configFile: recipe.configFile, configSource: recipe.configSource, - files: recipe.files + // Older frozen recipes lack release labels; display metadata does not change a launch. + files: recipe.files.map(({ releaseVersion: _releaseVersion, ...file }) => file) }); return JSON.stringify(selected(first)) === JSON.stringify(selected(second)); } diff --git a/src/lib/ui/campaigns.ts b/src/lib/ui/campaigns.ts index 22214f2..6a40bb5 100644 --- a/src/lib/ui/campaigns.ts +++ b/src/lib/ui/campaigns.ts @@ -1,15 +1,21 @@ import { statusPresentation, type StatusTone } from './status.js'; import { plural } from './plural.js'; +import { artifactVersionLabel } from '../artifact-version.js'; export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive'; -export interface CampaignSummary { +interface CampaignScriptSummary { + script_name: string | null; + script_version: number | null; + script_release_version: string | null; + script_kind: string | null; +} + +export interface CampaignSummary extends CampaignScriptSummary { action: string; beta_id: number | null; rolled_back_by: string | null; - script_name: string | null; - script_version: number | null; - scripts: { script_name: string | null; script_version: number | null }[]; + scripts: CampaignScriptSummary[]; servers: { restart_state: string; server_deleted_at?: string | null }[]; } @@ -23,8 +29,19 @@ export function campaignTitle(campaign: CampaignSummary): string { return `${action} · Beta #${campaign.beta_id}`; if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`; const script = campaign.scripts[0] ?? campaign; - if (script.script_name) - return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`; + if (script.script_name) { + const version = + script.script_version === null + ? '' + : ` ${artifactVersionLabel( + { + version: script.script_version, + release_version: script.script_release_version + }, + script.script_kind ?? 'script' + )}`; + return `${action} · ${script.script_name}${version}`; + } return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action; } diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte index 9a1efe8..183df3c 100644 --- a/src/lib/ui/components/DeployCollectionDialog.svelte +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -15,6 +15,7 @@ } from '$ui/collection-summary.js'; import { serverTitle } from '$ui/server-labels.js'; import { timeFormat } from '$ui/stores/time-zone.js'; + import { launchFileVersionLabel } from '$lib/artifact-version.js'; import type { NativeHostSelectionStatus } from '$ui/native-game-files.js'; import type { NativeDeploymentSelection } from '$lib/hosts.js'; import type { ZombieGame } from '$lib/map-aliases.js'; @@ -609,9 +610,7 @@
    {#each entry.recipe.files as file}
  • {file.name}{file.version === null - ? 'Generated' - : `v${file.version}`} · {file.kind}{launchFileVersionLabel(file)} · {file.kind}{file.path}
  • {/each}
diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 0e0ad6b..838b6aa 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -9,6 +9,7 @@ import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js'; import { serverTitle } from '$ui/server-labels.js'; import { timeFormat } from '$ui/stores/time-zone.js'; + import { launchFileVersionLabel } from '$lib/artifact-version.js'; import type { NativeHostSelectionStatus } from '$ui/native-game-files.js'; import type { NativeDeploymentSelection } from '$lib/hosts.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; @@ -378,7 +379,7 @@ {#each preview.recipe.files as file}
  • {file.name}{file.version === null ? 'Generated' : `v${file.version}`} · + >{launchFileVersionLabel(file)} · {file.kind}
    diff --git a/src/lib/ui/components/LaunchRecipe.svelte b/src/lib/ui/components/LaunchRecipe.svelte index b0d4da3..72a5c0e 100644 --- a/src/lib/ui/components/LaunchRecipe.svelte +++ b/src/lib/ui/components/LaunchRecipe.svelte @@ -1,5 +1,6 @@