diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts new file mode 100644 index 0000000..701a06a --- /dev/null +++ b/.svelte-kit/ambient.d.ts @@ -0,0 +1,360 @@ + +// this file is generated — do not edit it + + +/// + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private'; + * + * console.log(ENVIRONMENT); // => "production" + * console.log(PUBLIC_BASE_URL); // => throws error during build + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/private' { + export const CLAUDE_CODE_MESSAGING_TOKEN: string; + export const NoDefaultCurrentDirectoryInExePath: string; + export const CLAUDE_EFFORT: string; + export const CLAUDE_CODE_ENTRYPOINT: string; + export const VSCODE_CRASH_REPORTER_PROCESS_TYPE: string; + export const NODE: string; + export const INIT_CWD: string; + export const SHELL: string; + export const npm_config_allow_scripts: string; + export const CLAUDE_PID: string; + export const CLAUDE_CODE_CHILD_SESSION: string; + export const TMPDIR: string; + export const HOMEBREW_REPOSITORY: string; + export const npm_config_global_prefix: string; + export const FPATH: string; + export const CLAUDE_AGENT_SDK_VERSION: string; + export const MallocNanoZone: string; + export const COLOR: string; + export const npm_config_noproxy: string; + export const npm_config_local_prefix: string; + export const PNPM_HOME: string; + export const GIT_EDITOR: string; + export const AI_AGENT: string; + export const USER: string; + export const COMMAND_MODE: string; + export const npm_config_globalconfig: string; + export const RAILWAY_HOME: string; + export const SSH_AUTH_SOCK: string; + export const __CF_USER_TEXT_ENCODING: string; + export const npm_execpath: string; + export const ELECTRON_RUN_AS_NODE: string; + export const PATH: string; + export const MCP_CONNECTION_NONBLOCKING: string; + export const npm_package_json: string; + export const _: string; + export const LaunchInstanceID: string; + export const npm_config_userconfig: string; + export const npm_config_init_module: string; + export const __CFBundleIdentifier: string; + export const npm_command: string; + export const PWD: string; + export const VSCODE_HANDLES_UNCAUGHT_ERRORS: string; + export const npm_lifecycle_event: string; + export const EDITOR: string; + export const VSCODE_ESM_ENTRYPOINT: string; + export const npm_package_name: string; + export const LANG: string; + export const npm_config_npm_version: string; + export const XPC_FLAGS: string; + export const MACH_PORT_RENDEZVOUS_PEER_VALDATION: string; + export const npm_config_node_gyp: string; + export const npm_package_version: string; + export const CLAUDE_CODE_ENABLE_TASKS: string; + export const XPC_SERVICE_NAME: string; + export const GEMINI_API_KEY: string; + export const CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: string; + export const SHLVL: string; + export const HOME: string; + export const CLAUDE_CODE_EXECPATH: string; + export const VSCODE_NLS_CONFIG: string; + export const APPLICATION_INSIGHTS_NO_STATSBEAT: string; + export const HOMEBREW_PREFIX: string; + export const npm_config_cache: string; + export const LOGNAME: string; + export const npm_lifecycle_script: string; + export const VSCODE_IPC_HOOK: string; + export const VSCODE_CODE_CACHE_PATH: string; + export const COREPACK_ENABLE_AUTO_PIN: string; + export const npm_config_user_agent: string; + export const VSCODE_PID: string; + export const CLAUDE_CODE_SESSION_ID: string; + export const INFOPATH: string; + export const HOMEBREW_CELLAR: string; + export const OSLogRateLimit: string; + export const CLAUDECODE: string; + export const CLAUDE_CODE_MESSAGING_SOCKET: string; + export const VSCODE_L10N_BUNDLE_LOCATION: string; + export const VSCODE_CWD: string; + export const SECURITYSESSIONID: string; + export const npm_node_execpath: string; + export const npm_config_prefix: string; + export const TEST: string; + export const VITEST: string; + export const NODE_ENV: string; + export const PROD: string; + export const DEV: string; + export const BASE_URL: string; + export const MODE: string; +} + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public'; + * + * console.log(ENVIRONMENT); // => throws error during build + * console.log(PUBLIC_BASE_URL); // => "http://site.com" + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/public' { + +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/private'; + * + * console.log(env.ENVIRONMENT); // => "production" + * console.log(env.PUBLIC_BASE_URL); // => undefined + * ``` + */ +declare module '$env/dynamic/private' { + export const env: { + CLAUDE_CODE_MESSAGING_TOKEN: string; + NoDefaultCurrentDirectoryInExePath: string; + CLAUDE_EFFORT: string; + CLAUDE_CODE_ENTRYPOINT: string; + VSCODE_CRASH_REPORTER_PROCESS_TYPE: string; + NODE: string; + INIT_CWD: string; + SHELL: string; + npm_config_allow_scripts: string; + CLAUDE_PID: string; + CLAUDE_CODE_CHILD_SESSION: string; + TMPDIR: string; + HOMEBREW_REPOSITORY: string; + npm_config_global_prefix: string; + FPATH: string; + CLAUDE_AGENT_SDK_VERSION: string; + MallocNanoZone: string; + COLOR: string; + npm_config_noproxy: string; + npm_config_local_prefix: string; + PNPM_HOME: string; + GIT_EDITOR: string; + AI_AGENT: string; + USER: string; + COMMAND_MODE: string; + npm_config_globalconfig: string; + RAILWAY_HOME: string; + SSH_AUTH_SOCK: string; + __CF_USER_TEXT_ENCODING: string; + npm_execpath: string; + ELECTRON_RUN_AS_NODE: string; + PATH: string; + MCP_CONNECTION_NONBLOCKING: string; + npm_package_json: string; + _: string; + LaunchInstanceID: string; + npm_config_userconfig: string; + npm_config_init_module: string; + __CFBundleIdentifier: string; + npm_command: string; + PWD: string; + VSCODE_HANDLES_UNCAUGHT_ERRORS: string; + npm_lifecycle_event: string; + EDITOR: string; + VSCODE_ESM_ENTRYPOINT: string; + npm_package_name: string; + LANG: string; + npm_config_npm_version: string; + XPC_FLAGS: string; + MACH_PORT_RENDEZVOUS_PEER_VALDATION: string; + npm_config_node_gyp: string; + npm_package_version: string; + CLAUDE_CODE_ENABLE_TASKS: string; + XPC_SERVICE_NAME: string; + GEMINI_API_KEY: string; + CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: string; + SHLVL: string; + HOME: string; + CLAUDE_CODE_EXECPATH: string; + VSCODE_NLS_CONFIG: string; + APPLICATION_INSIGHTS_NO_STATSBEAT: string; + HOMEBREW_PREFIX: string; + npm_config_cache: string; + LOGNAME: string; + npm_lifecycle_script: string; + VSCODE_IPC_HOOK: string; + VSCODE_CODE_CACHE_PATH: string; + COREPACK_ENABLE_AUTO_PIN: string; + npm_config_user_agent: string; + VSCODE_PID: string; + CLAUDE_CODE_SESSION_ID: string; + INFOPATH: string; + HOMEBREW_CELLAR: string; + OSLogRateLimit: string; + CLAUDECODE: string; + CLAUDE_CODE_MESSAGING_SOCKET: string; + VSCODE_L10N_BUNDLE_LOCATION: string; + VSCODE_CWD: string; + SECURITYSESSIONID: string; + npm_node_execpath: string; + npm_config_prefix: string; + TEST: string; + VITEST: string; + NODE_ENV: string; + PROD: string; + DEV: string; + BASE_URL: string; + MODE: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://example.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.ENVIRONMENT); // => undefined, not public + * console.log(env.PUBLIC_BASE_URL); // => "http://example.com" + * ``` + * + * ``` + * + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/.svelte-kit/env.d.ts b/.svelte-kit/env.d.ts new file mode 100644 index 0000000..d7f6c97 --- /dev/null +++ b/.svelte-kit/env.d.ts @@ -0,0 +1 @@ +// See https://svelte.dev/docs/kit/environment-variables for more information \ No newline at end of file diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js new file mode 100644 index 0000000..ceb58ac --- /dev/null +++ b/.svelte-kit/generated/client/app.js @@ -0,0 +1,38 @@ +// in dev, this makes Vite inject its client as this module's first dependency, +// so that global constant replacements are installed before any other module +// (including user hooks) evaluates. In build it's inert. +import.meta.hot; + + + + +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1') +]; + +export const server_loads = []; + +export const dictionary = { + + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js'; + +export const get_error_template = () => import('../shared/error-template.js').then(m => m.default); \ No newline at end of file diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js new file mode 100644 index 0000000..f6bd30a --- /dev/null +++ b/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {}; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 0000000..b7f02ca --- /dev/null +++ b/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../web/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 0000000..f7e09e9 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../web/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/shared/error-template.js b/.svelte-kit/generated/shared/error-template.js new file mode 100644 index 0000000..f66e595 --- /dev/null +++ b/.svelte-kit/generated/shared/error-template.js @@ -0,0 +1 @@ +export default ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n"; \ No newline at end of file diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json new file mode 100644 index 0000000..f727b03 --- /dev/null +++ b/.svelte-kit/tsconfig.json @@ -0,0 +1,50 @@ +{ + "compilerOptions": { + "paths": { + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "env.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../test/**/*.js", + "../test/**/*.ts", + "../test/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/api/app/services/events.py b/api/app/services/events.py index f56252d..de2faac 100644 --- a/api/app/services/events.py +++ b/api/app/services/events.py @@ -12,11 +12,15 @@ import json import logging import os import shutil +import time import uuid +from collections import deque from datetime import UTC, datetime from functools import cache from typing import Any +from sqlalchemy import update + from app.config import settings from app.db import SessionLocal from app.models import Job, JobStatus @@ -24,7 +28,8 @@ from app.models import Job, JobStatus logger = logging.getLogger(__name__) PIPELINE_ENTRYPOINT = "/pipeline/main.nf" -LOG_TAIL_CHARS = 4000 +PROGRESS_LINES = 12 # rolling window of pipeline output kept on the job +PROGRESS_INTERVAL_S = 3 # between writes, unless a new process starts PUBLISH_TIMEOUT_S = 30 _watchers: set[asyncio.Task[None]] = set() @@ -129,14 +134,36 @@ async def _run_local(job_id: uuid.UUID, vcf_uri: str, assembly: str) -> str: async def _watch(job_id: uuid.UUID, proc: Any) -> None: - out, err = await proc.communicate() - if proc.returncode == 0: + """Follow the pipeline: record progress while it runs, and the reason if it dies.""" + tail: deque[str] = deque(maxlen=PROGRESS_LINES) + last_write = 0.0 + if proc.stdout is not None: # stderr is merged into stdout + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if not line: + continue + tail.append(line) + now = time.monotonic() + # A new pipeline step is worth showing at once; otherwise throttle the writes. + if line.startswith("[PROCESS") or now - last_write >= PROGRESS_INTERVAL_S: + last_write = now + await _record_progress(job_id, "\n".join(tail)) + returncode = await proc.wait() + if returncode == 0: return # the loader marks the job succeeded - tail = ((out or b"") + (err or b"")).decode(errors="replace")[-LOG_TAIL_CHARS:] async with SessionLocal() as session: job = await session.get(Job, job_id) if job is not None and job.status != JobStatus.succeeded: job.status = JobStatus.failed - job.log = f"nextflow exited with code {proc.returncode}\n{tail}" + job.log = f"nextflow exited with code {returncode}\n" + "\n".join(tail) job.finished_at = datetime.now(UTC) await session.commit() + + +async def _record_progress(job_id: uuid.UUID, text: str) -> None: + """Put the latest output on the job, leaving a job that has already finished alone.""" + async with SessionLocal() as session: + await session.execute( + update(Job).where(Job.id == job_id, Job.status == JobStatus.running).values(log=text) + ) + await session.commit() diff --git a/api/tests/test_annotate.py b/api/tests/test_annotate.py index 811edd0..0ca2ac2 100644 --- a/api/tests/test_annotate.py +++ b/api/tests/test_annotate.py @@ -33,14 +33,29 @@ async def test_local_without_nextflow_fails_fast_with_instructions( assert job["finished_at"] is not None -class FakeProcess: - def __init__(self, returncode: int, stderr: bytes) -> None: - self.returncode = returncode - self._stderr = stderr - self.pid = 4242 +class FakeStdout: + """Stands in for asyncio's StreamReader: async-iterates the process output.""" - async def communicate(self) -> tuple[bytes, bytes]: - return b"", self._stderr + def __init__(self, lines: list[bytes]) -> None: + self._lines = lines + + def __aiter__(self) -> "FakeStdout": + return self + + async def __anext__(self) -> bytes: + if not self._lines: + raise StopAsyncIteration + return self._lines.pop(0) + + +class FakeProcess: + def __init__(self, returncode: int, output: bytes = b"", lines: list[bytes] | None = None) -> None: + self.returncode = returncode + self.pid = 4242 + self.stdout = FakeStdout(lines if lines is not None else ([output] if output else [])) + + async def wait(self) -> int: + return self.returncode @pytest.mark.usefixtures("db") @@ -149,3 +164,49 @@ async def test_the_pipeline_gets_its_own_database_url( await client.post(f"/api/samples/{sample_id}/annotate") await events.drain() assert launched["env"]["DATABASE_URL"] == "postgresql+asyncpg://u:p@host.docker.internal:5432/db" + + +@pytest.mark.usefixtures("db") +async def test_progress_is_recorded_while_the_pipeline_runs( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The UI needs something better than "running" for two minutes.""" + monkeypatch.setattr(settings, "pubsub_topic", None) + monkeypatch.setattr(settings, "cloudrun_job", None) + monkeypatch.setattr(events.shutil, "which", lambda _: "/usr/bin/nextflow") + + async def fake_exec(*cmd: str, **kw: Any) -> FakeProcess: + return FakeProcess(0, lines=[ + b"[PIPELINE] main.nf | profile=docker\n", + b"[PROCESS f2/5c13bd] NORMALISE (tiny)\n", + b"[PROCESS cc/d62082] VEP (tiny)\n", + ]) + + monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec) + sample_id = await new_sample(client) + + r = await client.post(f"/api/samples/{sample_id}/annotate") + await events.drain() + + job = (await client.get(f"/api/jobs/{r.json()['id']}")).json() + assert "VEP (tiny)" in job["log"] + assert job["status"] == "running" # the loader is what marks it succeeded + + +@pytest.mark.usefixtures("db") +async def test_progress_never_overwrites_a_finished_job(client: AsyncClient) -> None: + from app.db import SessionLocal + from app.models import Job, JobStatus, Sample + + async with SessionLocal() as s: + sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38") + job = Job(sample=sample, status=JobStatus.succeeded) + s.add_all([sample, job]) + await s.commit() + job_id = job.id + + await events._record_progress(job_id, "[PROCESS 1/a] VEP (late)") + + job_json = (await client.get(f"/api/jobs/{job_id}")).json() + assert job_json["status"] == "succeeded" + assert job_json["log"] is None diff --git a/web/src/app.css b/web/src/app.css index d0b82e5..070f438 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -32,4 +32,11 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned .status { font-weight: 600; } .status.failed { color: #9a1b1b; } .status.running, .status.queued { color: var(--amber); } -@media (prefers-reduced-motion: no-preference) { button { transition: background 120ms; } } +.progress { display: flex; align-items: center; gap: 0.6rem; color: var(--ink-soft); margin: 0.25rem 0 1rem; } +.progress .hgvs { font-size: 0.85rem; } +.spinner { width: 0.9rem; height: 0.9rem; flex: none; border: 2px solid var(--line); border-top-color: var(--plum); border-radius: 50%; } +@media (prefers-reduced-motion: no-preference) { + button { transition: background 120ms; } + .spinner { animation: spin 0.9s linear infinite; } +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/web/src/lib/progress.test.ts b/web/src/lib/progress.test.ts new file mode 100644 index 0000000..97ae8e8 --- /dev/null +++ b/web/src/lib/progress.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { formatElapsed, latestStep } from './progress'; + +describe('formatElapsed', () => { + it('shows seconds under a minute', () => expect(formatElapsed(9_000)).toBe('9s')); + it('pads the seconds past a minute', () => expect(formatElapsed(64_000)).toBe('1m 04s')); + it('drops to minutes past an hour', () => expect(formatElapsed(3_725_000)).toBe('1h 02m')); + it('never goes negative when the clocks disagree', () => expect(formatElapsed(-5_000)).toBe('0s')); +}); + +describe('latestStep', () => { + it('names the process Nextflow is on', () => { + const log = [ + '[PIPELINE] main.nf | profile=docker', + '[WORKDIR] /x/work', + '[PROCESS f2/5c13bd] NORMALISE (tiny)', + '[PROCESS cc/d62082] VEP (tiny)' + ].join('\n'); + expect(latestStep(log)).toBe('VEP (tiny)'); + }); + + it('falls back to the last meaningful line', () => { + expect(latestStep('pulling ensemblorg/ensembl-vep\n\n')).toBe('pulling ensemblorg/ensembl-vep'); + }); + + it('has nothing to say before the first line arrives', () => { + expect(latestStep(null)).toBeNull(); + expect(latestStep(' \n\n')).toBeNull(); + }); + + it('truncates a runaway line', () => { + expect(latestStep('x'.repeat(300))!.length).toBeLessThanOrEqual(120); + }); +}); diff --git a/web/src/lib/progress.ts b/web/src/lib/progress.ts new file mode 100644 index 0000000..659790b --- /dev/null +++ b/web/src/lib/progress.ts @@ -0,0 +1,24 @@ +const MAX_STEP_CHARS = 120; + +/** "9s", "1m 04s", "1h 02m". How long it has been going, not a made-up percentage. */ +export function formatElapsed(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}m`; + if (minutes) return `${minutes}m ${String(seconds).padStart(2, '0')}s`; + return `${seconds}s`; +} + +/** The pipeline step Nextflow last started, else the last line it printed. */ +export function latestStep(log: string | null): string | null { + const lines = (log ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + const started = [...lines].reverse().find((line) => line.startsWith('[PROCESS')); + const step = started ? started.replace(/^\[PROCESS [^\]]*\]\s*/, '') : lines[lines.length - 1]; + return step.slice(0, MAX_STEP_CHARS); +} diff --git a/web/src/routes/samples/[id]/+page.svelte b/web/src/routes/samples/[id]/+page.svelte index 7402699..c3e3b9c 100644 --- a/web/src/routes/samples/[id]/+page.svelte +++ b/web/src/routes/samples/[id]/+page.svelte @@ -2,6 +2,7 @@ import { onDestroy, onMount } from 'svelte'; import { api, type Job, type VariantPage } from '$lib/api'; import { poll } from '$lib/poll'; + import { formatElapsed, latestStep } from '$lib/progress'; const POLL_MS = 3000; const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed'; @@ -15,6 +16,18 @@ let busy = $state(false); let error = $state(null); let stopPolling: (() => void) | null = null; + let now = $state(Date.now()); + + const running = $derived(!!job && !finished(job)); + const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0); + const step = $derived(latestStep(job?.log ?? null)); + + // Tick the elapsed time while a run is in flight; polling refreshes the step itself. + $effect(() => { + if (!running) return; + const tick = setInterval(() => (now = Date.now()), 1000); + return () => clearInterval(tick); + }); onMount(async () => { try { @@ -79,6 +92,12 @@ {#if job}

Job {job.id.slice(0, 8)}: {job.status}

+ {#if running} +

+ + Annotating for {formatElapsed(elapsedMs)}{#if step} · {step}{/if} +

+ {/if} {#if job.status === 'failed' && job.log}
{job.log}
{/if}