From 5843b742547973fe6cd2ab3f4297d5323a03c302 Mon Sep 17 00:00:00 2001 From: Elena Erokhina Date: Tue, 25 Aug 2026 16:14:26 +0200 Subject: [PATCH 01/13] MILAB-6720: announce the file scan and block Run until it finishes Picking a file left the panel silent while every column of it was profiled, and the profile outputs are retentive, so the dropdowns kept answering with the previous file's headers as though nothing was happening. A mapping that had passed against that previous file also still satisfied bareSetValid, so Run stayed live over a file nobody had looked at and over headers it might not have. Prerun now states which file the profile came from, and the model pairs the two under retentive: getIsReadyOrError marks the read unstable, so the reported id stays the old file's until the new scan lands. The panel compares it with the loaded file, announces the wait, and withholds the mapping until the columns on offer are really this file's. Keyed to the file rather than to "prerun is busy" because prerun also re-runs on every mapping edit to re-check the identity column for collisions. Picking a different file drops the parts of the mapping that name columns, keeping the receptor declaration and the numbering scheme. That is what disables Run, and it is done on the gesture: args is a pure function of data and cannot consult prerun, and mirroring prerun's verdict back into data is the pattern this block is shedding. Re-picking the same file is not a swap. The import itself now shows the block's loader, which it never did, scoped to the main run so it cannot cover the settings panel during prerun. --- ...announce-the-file-scan-and-gate-the-run.md | 33 +++++++++++++ model/src/index.ts | 24 +++++++++ test/src/wf.test.ts | 6 +++ ui/src/app.ts | 5 +- ui/src/pages/MainPage.vue | 49 +++++++++++++++++-- workflow/src/prerun.tpl.tengo | 5 +- 6 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 .changeset/announce-the-file-scan-and-gate-the-run.md diff --git a/.changeset/announce-the-file-scan-and-gate-the-run.md b/.changeset/announce-the-file-scan-and-gate-the-run.md new file mode 100644 index 0000000..9209a04 --- /dev/null +++ b/.changeset/announce-the-file-scan-and-gate-the-run.md @@ -0,0 +1,33 @@ +--- +'@platforma-open/milaboratories.import-vdj.workflow': patch +'@platforma-open/milaboratories.import-vdj.model': patch +'@platforma-open/milaboratories.import-vdj.ui': patch +'@platforma-open/milaboratories.import-vdj': patch +--- + +Say when a loaded file is being read, and stop the run until it has been + +Picking a file left the panel silent. Every column of the file is profiled before anything can be +mapped — a whole-file pass, minutes on remote storage — and nothing said so: the profile outputs +are retentive, so the dropdowns kept answering with the *previous* file's headers as though +nothing had happened. Worse, a mapping that had passed every check against that previous file +still satisfied the validity rule, so Run stayed live over a file nobody had looked at yet, and +over headers it might not even contain. + +Three changes: + +- Prerun states which file the profile was taken from (`profiledSampleId`), and the model pairs + the two under `retentive` — while the new scan runs, the reported id stays the old file's. The + panel compares it with the loaded file and announces the wait, withholding the mapping until + the columns on offer are really this file's. Keyed to the file rather than to "prerun is busy" + on purpose: prerun also re-runs on every mapping edit, to re-check the identity column for + collisions, and a message that appeared on each dropdown pick would train the scientist to + ignore it. +- Picking a different file drops the parts of the mapping that name columns, keeping the receptor + declaration and the numbering scheme, which are statements about the data rather than about one + file. That is what disables Run. Done on the gesture rather than by reconciling against the new + profile: `args` is a pure function of the block's data and cannot consult prerun, and mirroring + prerun's verdict back into that data is the pattern this block is trying to shed. Re-picking the + same file is not a swap and keeps the mapping. +- The import itself now shows the block's loader, which it never did. Scoped to the main run: the + loader covers the whole block, and prerun runs while the settings panel is being edited. diff --git a/model/src/index.ts b/model/src/index.ts index 1d1479a..7295cd4 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -264,6 +264,30 @@ export const platforma = BlockModelV3.create(blockDataModel) } }) + /** + * Which file the profile the UI currently sees was taken from, so a mismatch with + * `data.fileSource.sampleId` means "the panel is showing the last file's columns". + * + * Keyed to the file, not to whether prerun is busy: `prerunArgs` carries `bareSet`, so prerun + * re-runs on every mapping edit to re-check the identity column for collisions. + */ + .retentiveOutput("profiledSampleId", (ctx) => { + const profile = ctx.prerun?.resolve({ field: "columnProfile", allowPermanentAbsence: true }); + if (profile === undefined) return undefined; + // Marks the read unstable (pl-tree/src/accessors.ts:347), so `retentive` keeps reporting the + // previous file's id until the new profile lands — the id and the profile can never disagree. + if (!profile.getIsReadyOrError()) return undefined; + return ctx.prerun + ?.resolve({ field: "profiledSampleId", allowPermanentAbsence: true }) + ?.getDataAsJsonOrUndefined(); + }) + + /** + * Drives the block's loader (`ui/src/app.ts`). Excludes prerun: the loader covers the whole + * block, and prerun re-runs while the settings panel is being edited. + */ + .output("isRunning", (ctx) => ctx.outputs?.getIsReadyOrError() === false) + /** Headers of the dataset selected from the pool. Absent on the file door. */ .retentiveOutput("datasetColumns", (ctx) => { const headers = ctx.prerun diff --git a/test/src/wf.test.ts b/test/src/wf.test.ts index 61fa2b2..da47757 100644 --- a/test/src/wf.test.ts +++ b/test/src/wf.test.ts @@ -511,6 +511,12 @@ blockTest( "Affinity (nM)": "Double", }); + // The profile names the file it came from. The panel reads this to tell this file's columns + // from the previous file's, still retained while the new one is scanned. + expect((state.outputs?.profiledSampleId as { value?: string } | undefined)?.value).toBe( + "SDIRECT000000000000000001", + ); + // Indistinguishable from the pool door: same axes, same key, same columns — abundance // alone on [sampleId, variantKey], every property of the record on the record axis. for (const c of columns) { diff --git a/ui/src/app.ts b/ui/src/app.ts index 064ee1d..d3004f4 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -3,8 +3,11 @@ import { defineAppV3 } from "@platforma-sdk/ui-vue"; import MainPage from "./pages/MainPage.vue"; import { watch } from "vue"; -export const sdkPlugin = defineAppV3(platforma, () => { +export const sdkPlugin = defineAppV3(platforma, (app) => { return { + // Main run only: this loader covers the whole block, and prerun re-runs while the settings + // panel is being edited. The file scan is announced inside the panel instead. + progress: () => app.model.outputs.isRunning, routes: { "/": () => MainPage, }, diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 401fd9c..378edec 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -239,7 +239,15 @@ function setBareScheme(value: string | undefined) { a.bareSet = { ...a.bareSet, scheme: value as BareSetArgs["scheme"] }; } -const isBareSet = computed(() => app.model.data.bareSet !== undefined); +/** + * A column has been assigned. Not `bareSet !== undefined`: picking a new file keeps the receptor + * declaration and the scheme (see forgetMappedColumns), so the object outlives the mapping. + */ +const isBareSet = computed(() => { + const bare = app.model.data.bareSet; + if (bare === undefined) return false; + return !!bare.identity || Object.values(bare.sequences ?? {}).some(Boolean); +}); /** Identity values the file repeats on rows that are not identical. The run cannot start * while any exist: the record key is the identity's hash, so a repeat would merge two @@ -407,6 +415,30 @@ function setSource(value: string | undefined) { /** The file door is showing exactly when a file is loaded. No stored flag decides it. */ const loadFromFile = computed(() => app.model.data.fileSource !== undefined); +/** + * The loaded file is still being profiled — the columns on offer are the previous file's, since + * the profile outputs are retentive. True between picking a file and its scan finishing. + */ +const fileScanning = computed(() => { + const file = app.model.data.fileSource; + if (file === undefined) return false; + return app.model.outputs.profiledSampleId !== file.sampleId; +}); + +/** + * The mapping with everything that names a column dropped. The receptor declaration and the + * numbering scheme describe the biology and outlive any one file; the column names do not. + */ +function forgetMappedColumns(bare: BareSetArgs | undefined): BareSetArgs | undefined { + if (bare === undefined) return undefined; + return { + identity: "", + chainSelection: bare.chainSelection, + sequences: {}, + scheme: bare.scheme, + }; +} + async function setFile(handle: ImportFileHandle | undefined) { fileSourceError.value = ""; const a = app.model.data; @@ -415,6 +447,7 @@ async function setFile(handle: ImportFileHandle | undefined) { return; } + const previous = a.fileSource; const name = getFileNameFromHandle(handle); // A workbook is identified by its name — there is no first line to read, and the workflow // converts it to csv before anything else looks at it. For text files the delimiter is @@ -435,6 +468,12 @@ async function setFile(handle: ImportFileHandle | undefined) { extension, }; a.datasetRef = undefined; + + // This is what disables Run: a mapping that passed against the last file still satisfies + // `bareSetValid`, so otherwise the block stays runnable over a file nothing has read yet. + // Cleared on the gesture because `args` sees only `data` and cannot consult prerun. + // Re-picking the same file is not a swap — the dialog is also how a file gets re-read. + if (previous?.handle !== handle) a.bareSet = forgetMappedColumns(a.bareSet); } function setReceptors(selected: string[]) { @@ -686,7 +725,11 @@ watch( {{ fileSourceError }} -