From da169d5078ecb0c4ffe3a401135325441ec6a41f Mon Sep 17 00:00:00 2001 From: Florian Paproth Date: Fri, 28 Aug 2026 11:30:54 +0200 Subject: [PATCH] fix(frontend): stabilize generated schema snapshots --- frontend/blok.json | 14 ++--- frontend/plugins/generate-app.ts | 59 ++++++++++++++----- .../default/hooks/states/FilterBankState.ts | 4 +- .../default/hooks/states/IlluminationState.ts | 4 +- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/frontend/blok.json b/frontend/blok.json index 5738f7a..0f96f28 100644 --- a/frontend/blok.json +++ b/frontend/blok.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-08-24T23:08:33.167Z", + "generatedAt": "2026-08-28T09:29:26.782Z", "app": { "description": "", "name": "newswitch", @@ -4764,7 +4764,7 @@ ], "choices": null, "default": null, - "description": "Configuration for a single optical filter.\n\nAttributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n", + "description": "Configuration for a single optical filter.\n\n Attributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n ", "dimension": null, "effects": [], "identifier": "filter", @@ -5086,7 +5086,7 @@ ], "choices": null, "default": null, - "description": "Shared state for illumination parameters.\n\nNote: intensity is divided by 1000 in the detector for actual scaling,\nso 10000.0 gives an effective intensity of 10.0.\n", + "description": "Shared state for illumination parameters.\n\n Note: intensity is divided by 1000 in the detector for actual scaling,\n so 10000.0 gives an effective intensity of 10.0.\n ", "dimension": null, "effects": [], "identifier": "illumination", @@ -14264,12 +14264,12 @@ "stage_position" ], "manipulates": [ - "StageState", - "ObjectiveState", - "ExpanseState", "CameraState", + "ExpanseState", + "FilterBankState", "IlluminationState", - "FilterBankState" + "ObjectiveState", + "StageState" ], "needsToken": true, "optimistics": [], diff --git a/frontend/plugins/generate-app.ts b/frontend/plugins/generate-app.ts index 03611dd..3b5bef1 100644 --- a/frontend/plugins/generate-app.ts +++ b/frontend/plugins/generate-app.ts @@ -245,6 +245,33 @@ const sortKeysDeep = (value: unknown): unknown => { return value; }; +// Rekuest derives these fields through Python sets, so their wire order changes with +// Python's per-process hash seed. Their order has no meaning; normalize it before the +// schema snapshot reaches blok.json so a fresh CI process cannot create false drift. +const sortSetLikeSchemaArrays = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortSetLikeSchemaArrays); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, child]) => { + const normalized = sortSetLikeSchemaArrays(child); + if ( + (key === 'locks' || key === 'manipulates') && + Array.isArray(normalized) && + normalized.every((entry) => typeof entry === 'string') + ) { + return [key, [...normalized].sort(byCodepoint)]; + } + return [key, normalized]; + }), + ); + } + + return value; +}; + // Every write goes through here. Rewriting a byte-identical file would churn its mtime, // which re-triggers Vite's watcher and makes `git status` dirty after a no-op codegen. const writeIfChanged = (filePath: string, content: string) => { @@ -1614,22 +1641,24 @@ export type AppDefinition = AppsDefinition[AppKey]; // Everything except generatedAt. Key order comes from the backend's JSON and is not // stable, so deep-sort it before comparing or writing. - const blokPayload = sortKeysDeep({ - app: { - name: (packageData.name as string | undefined) ?? 'unknown', - version: (packageData.version as string | undefined) ?? '0.0.0', - description: (packageData.description as string | undefined) ?? '', - startPage: - (packageData.homepage as string | undefined) ?? '/index.html', - type: (packageData.type as string | undefined) ?? 'unknown', - scripts: { - dev: scripts.dev ?? '', - build: scripts.build ?? '', - preview: scripts.preview ?? '', + const blokPayload = sortKeysDeep( + sortSetLikeSchemaArrays({ + app: { + name: (packageData.name as string | undefined) ?? 'unknown', + version: (packageData.version as string | undefined) ?? '0.0.0', + description: (packageData.description as string | undefined) ?? '', + startPage: + (packageData.homepage as string | undefined) ?? '/index.html', + type: (packageData.type as string | undefined) ?? 'unknown', + scripts: { + dev: scripts.dev ?? '', + build: scripts.build ?? '', + preview: scripts.preview ?? '', + }, }, - }, - apps: blokApps, - }) as Record; + apps: blokApps, + }), + ) as Record; // Only stamp a new generatedAt when the content actually changed. Otherwise every // dev-server start would dirty blok.json (780KB) for no reason. diff --git a/frontend/src/apps/default/hooks/states/FilterBankState.ts b/frontend/src/apps/default/hooks/states/FilterBankState.ts index 8cfdace..fe6cc17 100644 --- a/frontend/src/apps/default/hooks/states/FilterBankState.ts +++ b/frontend/src/apps/default/hooks/states/FilterBankState.ts @@ -14,7 +14,7 @@ export const FilterSchema = z }) .brand('filter') .describe( - 'Configuration for a single optical filter.\n\nAttributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n', + 'Configuration for a single optical filter.\n\n Attributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n ', ); export type Filter = z.input; export type FilterOutput = z.infer; @@ -23,7 +23,7 @@ export type FilterOutput = z.infer; export const FilterBankStateSchema = z.object({ filters: z.array( FilterSchema.describe( - 'Configuration for a single optical filter.\n\nAttributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n', + 'Configuration for a single optical filter.\n\n Attributes:\n slot: Filter wheel slot number.\n name: Human-readable filter name.\n center_wavelength: Center wavelength of the filter passband (nm).\n bandwidth: Full width at half maximum of the passband (nm).\n transmission: Peak transmission efficiency (0.0 to 1.0).\n is_active: Whether this filter is currently in the light path.\n ', ), ), current_slot: z.number(), diff --git a/frontend/src/apps/default/hooks/states/IlluminationState.ts b/frontend/src/apps/default/hooks/states/IlluminationState.ts index 38a0ae3..1d10004 100644 --- a/frontend/src/apps/default/hooks/states/IlluminationState.ts +++ b/frontend/src/apps/default/hooks/states/IlluminationState.ts @@ -17,7 +17,7 @@ export const IlluminationSchema = z }) .brand('illumination') .describe( - 'Shared state for illumination parameters.\n\nNote: intensity is divided by 1000 in the detector for actual scaling,\nso 10000.0 gives an effective intensity of 10.0.\n', + 'Shared state for illumination parameters.\n\n Note: intensity is divided by 1000 in the detector for actual scaling,\n so 10000.0 gives an effective intensity of 10.0.\n ', ); export type Illumination = z.input; export type IlluminationOutput = z.infer; @@ -26,7 +26,7 @@ export type IlluminationOutput = z.infer; export const IlluminationStateSchema = z.object({ illuminations: z.array( IlluminationSchema.describe( - 'Shared state for illumination parameters.\n\nNote: intensity is divided by 1000 in the detector for actual scaling,\nso 10000.0 gives an effective intensity of 10.0.\n', + 'Shared state for illumination parameters.\n\n Note: intensity is divided by 1000 in the detector for actual scaling,\n so 10000.0 gives an effective intensity of 10.0.\n ', ), ), });