omicslab 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. {omicslab-2.1.0 → omicslab-2.2.0}/PKG-INFO +1 -1
  2. omicslab-2.2.0/scripts/bootstrap-env.sh +224 -0
  3. omicslab-2.2.0/src/omicslab/_version.py +1 -0
  4. omicslab-2.2.0/src/omicslab/cli/job_commands.py +441 -0
  5. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/main.py +84 -0
  6. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/runner_commands.py +7 -1
  7. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/studio_commands.py +53 -21
  8. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/client.py +32 -0
  9. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_exec.py +16 -0
  10. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_git.py +13 -3
  11. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_monitor.py +23 -5
  12. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_nextflow.py +12 -7
  13. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_proxy.py +9 -1
  14. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_studio_prep.py +18 -8
  15. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/analysis.py +36 -7
  16. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/studio.py +116 -29
  17. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/client.py +12 -2
  18. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/storage.py +109 -2
  19. {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/conftest.py +2 -1
  20. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__engine_helpers.py +128 -1
  21. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__git.py +13 -0
  22. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__monitor.py +4 -1
  23. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__nextflow.py +8 -5
  24. omicslab-2.2.0/tests/job/_run/test__studio_docker.py +107 -0
  25. omicslab-2.2.0/tests/job/_run/test_studio_proxy_start.py +85 -0
  26. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_client_extra.py +41 -0
  27. omicslab-2.2.0/tests/job/test_snapshot_request.py +37 -0
  28. omicslab-2.2.0/tests/job/test_studio_image_download.py +66 -0
  29. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_storage.py +34 -0
  30. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_client.py +24 -0
  31. omicslab-2.2.0/tests/test_job_heartbeat.py +84 -0
  32. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_resources.py +111 -3
  33. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_runner_helpers.py +3 -0
  34. omicslab-2.2.0/tests/test_studio_snapshot.py +224 -0
  35. omicslab-2.2.0/tests/test_system_monitor_interval.py +36 -0
  36. omicslab-2.1.0/scripts/bootstrap-env.sh +0 -103
  37. omicslab-2.1.0/src/omicslab/_version.py +0 -1
  38. omicslab-2.1.0/src/omicslab/cli/job_commands.py +0 -238
  39. omicslab-2.1.0/tests/test_studio_snapshot.py +0 -106
  40. {omicslab-2.1.0 → omicslab-2.2.0}/.gitignore +0 -0
  41. {omicslab-2.1.0 → omicslab-2.2.0}/Makefile +0 -0
  42. {omicslab-2.1.0 → omicslab-2.2.0}/README.developer.md +0 -0
  43. {omicslab-2.1.0 → omicslab-2.2.0}/README.md +0 -0
  44. {omicslab-2.1.0 → omicslab-2.2.0}/envs/.gitignore +0 -0
  45. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/hcm.pixi.lock +0 -0
  46. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/hcm.pixi.toml +0 -0
  47. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/pixi.lock +0 -0
  48. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/pixi.toml +0 -0
  49. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/us-east-1.pixi.lock +0 -0
  50. {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/us-east-1.pixi.toml +0 -0
  51. {omicslab-2.1.0 → omicslab-2.2.0}/envs/pixi.toml +0 -0
  52. {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/hcm.pixi.lock +0 -0
  53. {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/hcm.pixi.toml +0 -0
  54. {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/pixi.lock +0 -0
  55. {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/pixi.toml +0 -0
  56. {omicslab-2.1.0 → omicslab-2.2.0}/envs/regions.json +0 -0
  57. {omicslab-2.1.0 → omicslab-2.2.0}/pixi.config.toml +0 -0
  58. {omicslab-2.1.0 → omicslab-2.2.0}/pyproject.toml +0 -0
  59. {omicslab-2.1.0 → omicslab-2.2.0}/scripts/check_publish.py +0 -0
  60. {omicslab-2.1.0 → omicslab-2.2.0}/scripts/write_version_manifest.py +0 -0
  61. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/__init__.py +0 -0
  62. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/__init__.py +0 -0
  63. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/analysis_commands.py +0 -0
  64. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/auth_commands.py +0 -0
  65. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/context_commands.py +0 -0
  66. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/credential_commands.py +0 -0
  67. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/fuse_commands.py +0 -0
  68. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/org_commands.py +0 -0
  69. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/setup_commands.py +0 -0
  70. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/token_commands.py +0 -0
  71. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/utils.py +0 -0
  72. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/workspace_commands.py +0 -0
  73. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/ws_commands.py +0 -0
  74. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/config.py +0 -0
  75. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/exceptions.py +0 -0
  76. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/__init__.py +0 -0
  77. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/__init__.py +0 -0
  78. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_auth.py +0 -0
  79. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_config_download.py +0 -0
  80. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_consts.py +0 -0
  81. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_data_prep.py +0 -0
  82. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_env.py +0 -0
  83. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_images.py +0 -0
  84. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_log.py +0 -0
  85. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_params.py +0 -0
  86. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_proc.py +0 -0
  87. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_spec.py +0 -0
  88. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_storage.py +0 -0
  89. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/console.py +0 -0
  90. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/executor.py +0 -0
  91. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/paths.py +0 -0
  92. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/system.py +0 -0
  93. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/__init__.py +0 -0
  94. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/analysis.py +0 -0
  95. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/base.py +0 -0
  96. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/credentials.py +0 -0
  97. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/jobs.py +0 -0
  98. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/orgs.py +0 -0
  99. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/runners.py +0 -0
  100. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/studio.py +0 -0
  101. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/tokens.py +0 -0
  102. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/workspaces.py +0 -0
  103. {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/selfupdate.py +0 -0
  104. {omicslab-2.1.0 → omicslab-2.2.0}/tests/__init__.py +0 -0
  105. {omicslab-2.1.0 → omicslab-2.2.0}/tests/conftest.py +0 -0
  106. {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/__init__.py +0 -0
  107. {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/test_cli_e2e.py +0 -0
  108. {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/test_tokens.py +0 -0
  109. {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
  110. {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
  111. {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
  112. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__auth.py +0 -0
  113. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__data_prep.py +0 -0
  114. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__env.py +0 -0
  115. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__get_images_cli.py +0 -0
  116. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__images.py +0 -0
  117. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__log.py +0 -0
  118. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__params.py +0 -0
  119. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__proxy.py +0 -0
  120. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__proxy_extra.py +0 -0
  121. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__spec.py +0 -0
  122. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__storage.py +0 -0
  123. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_cli.py +0 -0
  124. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_client.py +0 -0
  125. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_executor.py +0 -0
  126. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_executor_extra.py +0 -0
  127. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_fuse.py +0 -0
  128. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_resource.py +0 -0
  129. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_system.py +0 -0
  130. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_studio_validate_cli.py +0 -0
  131. {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_ws_helpers.py +0 -0
  132. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli.py +0 -0
  133. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_catalog.py +0 -0
  134. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_context.py +0 -0
  135. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_utils.py +0 -0
  136. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_fuse.py +0 -0
  137. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_job_exec.py +0 -0
  138. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_misc.py +0 -0
  139. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_runner_self_update.py +0 -0
  140. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_sandbox_report.py +0 -0
  141. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_selfupdate.py +0 -0
  142. {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_ws_commands.py +0 -0
  143. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/input.txt +0 -0
  144. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/main.nf +0 -0
  145. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
  146. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
  147. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/schema.json +0 -0
  148. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
  149. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/pixi.toml +0 -0
  150. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/schema.json +0 -0
  151. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/Snakefile +0 -0
  152. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/input.txt +0 -0
  153. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
  154. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
  155. {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/schema.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omicslab
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Python SDK and CLI for the OmicsLab Platform
5
5
  Project-URL: Homepage, https://platform.omicslab.io
6
6
  Project-URL: Repository, https://github.com/vieomics/omicslab-platform
@@ -0,0 +1,224 @@
1
+ #!/bin/bash
2
+ # bootstrap-env.sh — Install pixi + omicslab SDK from a public tools bucket.
3
+ #
4
+ # Environment variables:
5
+ # SDK_TOOLS_BASE — tools bucket base URL (default: R2 production). Override
6
+ # to point at any public S3-compatible provider (e.g.
7
+ # local S3: http://localhost:4566/omicslab/data/tools).
8
+ # OMICSLAB_HOME — where pixi.toml/lock live (default: $HOME)
9
+ # PIXI_TOML — manifest filename (default: pixi.toml, or {region}.pixi.toml)
10
+ # PIXI_LOCK — lock filename (default: pixi.lock, or {region}.pixi.lock)
11
+ # PIXI_VERSION — pixi release to fetch from the tools bucket
12
+ # (default: v0.76.1)
13
+ # INSTALL_DOCKER — set to 1 for studio jobs to install the docker engine
14
+ # (client + daemon) and start its daemon (default: 0)
15
+ #
16
+ # The script is idempotent: re-running it after a successful install is a
17
+ # no-op (pixi binary present, environment installed, SDK already loaded).
18
+ #
19
+ # It:
20
+ # 1. Installs pixi from the tools bucket (pixi/bin/pixi-<version>-linux-amd64.tar.gz)
21
+ # into an absolute path under $HOME — never hits pixi.sh / GitHub, so it
22
+ # works on air-gapped runners.
23
+ # 2. Downloads the wheel-free manifest from the same bucket.
24
+ # 3. Runs pixi install --frozen (all conda + pypi deps, both cached in the
25
+ # bucket channel/pypi mirrors).
26
+ # 4. Downloads omicslab-latest.whl, extracts version from wheel metadata,
27
+ # renames to a valid PEP 427 filename, pip installs.
28
+ # 5. Grants the pixi interpreter CAP_NET_BIND_SERVICE (falling back to
29
+ # lowering ip_unprivileged_port_start) so the studio TLS proxy can bind
30
+ # the privileged port 443.
31
+ set -euo pipefail
32
+
33
+ TOOLS_BASE="${SDK_TOOLS_BASE:-https://s3-hcm-r2.s3cloud.vn/omicslab/data/tools}"
34
+ OMICSLAB_HOME="${OMICSLAB_HOME:-$HOME}"
35
+ PIXI_TOML="${PIXI_TOML:-pixi.toml}"
36
+ PIXI_LOCK="${PIXI_LOCK:-pixi.lock}"
37
+ PIXI_VERSION="${PIXI_VERSION:-v0.76.1}"
38
+ RETRIES=3
39
+
40
+ # ── 1. pixi (from the tools bucket, absolute path under $HOME) ────────────
41
+ PIXI_BIN="${HOME}/.pixi/bin/pixi"
42
+ if [ ! -x "${PIXI_BIN}" ]; then
43
+ PIXI_URL="$TOOLS_BASE/pixi/bin/pixi-${PIXI_VERSION}-linux-amd64.tar.gz"
44
+ echo "Installing pixi ${PIXI_VERSION} from: $PIXI_URL"
45
+ mkdir -p "$(dirname "${PIXI_BIN}")"
46
+ curl -fsSL "$PIXI_URL" -o /tmp/pixi.tar.gz
47
+ tar -xzf /tmp/pixi.tar.gz -C "$(dirname "${PIXI_BIN}")"
48
+ rm -f /tmp/pixi.tar.gz
49
+ else
50
+ echo "pixi already installed at ${PIXI_BIN}"
51
+ fi
52
+ export PATH="$(dirname "${PIXI_BIN}"):$PATH"
53
+
54
+ # ── 1b. FUSE helper for mountpoint-s3 (non-root mounts) ────────────────────
55
+ # mount-s3 performs unprivileged FUSE mounts via `fusermount3`, which must be
56
+ # root-owned + setuid. The pixi env ships a non-setuid copy under sbin/ (also
57
+ # not on PATH), so use the distro package when it is missing. Best-effort: on
58
+ # air-gapped hosts without apt, mounts still work when run as root.
59
+ if ! command -v fusermount3 >/dev/null 2>&1; then
60
+ if command -v apt-get >/dev/null 2>&1; then
61
+ SUDO=""
62
+ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
63
+ SUDO="sudo -n"
64
+ fi
65
+ echo "Installing fuse3 (fusermount3 helper for mountpoint-s3)..."
66
+ $SUDO apt-get update -qq >/dev/null 2>&1 || true
67
+ $SUDO apt-get install -y -qq fuse3 >/dev/null 2>&1 || \
68
+ echo "WARNING: fuse3 install failed; mountpoint-s3 mounts will require root"
69
+ else
70
+ echo "WARNING: no apt-get; mountpoint-s3 non-root mounts will require root"
71
+ fi
72
+ fi
73
+
74
+ # ── 1c. docker engine (studio jobs) ────────────────────────────────────────
75
+ # Studio jobs start their session container with `docker run`. On the
76
+ # Docker-simulated VM the host daemon answers `docker info` through the
77
+ # mounted /var/run/docker.sock, so only the client is needed; a real cloud VM
78
+ # (CloudFly / GreenNode) has no host socket, so install the engine (client +
79
+ # daemon), start the daemon, and let the job user reach its socket. Gated by
80
+ # INSTALL_DOCKER so analysis jobs pay nothing for it. Best-effort: on
81
+ # air-gapped hosts without apt the install is skipped and `job run --studio`
82
+ # reports the unreachable daemon.
83
+ if [ "${INSTALL_DOCKER:-0}" = "1" ]; then
84
+ SUDO=""
85
+ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
86
+ SUDO="sudo -n"
87
+ fi
88
+ if ! command -v docker >/dev/null 2>&1; then
89
+ if command -v apt-get >/dev/null 2>&1; then
90
+ echo "Installing docker engine (client + daemon)..."
91
+ $SUDO apt-get update -qq >/dev/null 2>&1 || true
92
+ $SUDO apt-get install -y -qq docker.io >/dev/null 2>&1 || \
93
+ echo "WARNING: docker install failed; the studio session cannot start"
94
+ else
95
+ echo "WARNING: no apt-get; cannot install docker for the studio session"
96
+ fi
97
+ fi
98
+ # Start the daemon: systemd where available, else a detached dockerd.
99
+ # Check for the socket (not `docker info`) so a permission problem on the
100
+ # socket is not mistaken for a stopped daemon and does not spawn a second
101
+ # dockerd next to the systemd unit.
102
+ if command -v systemctl >/dev/null 2>&1; then
103
+ $SUDO systemctl enable --now docker >/dev/null 2>&1 || true
104
+ fi
105
+ if [ ! -S /var/run/docker.sock ] && command -v dockerd >/dev/null 2>&1; then
106
+ echo "Starting dockerd..."
107
+ $SUDO dockerd >/dev/null 2>&1 &
108
+ fi
109
+ for _ in $(seq 1 15); do
110
+ [ -S /var/run/docker.sock ] && break
111
+ sleep 1
112
+ done
113
+ # The daemon socket is root:docker mode 0660. The studio agent runs as the
114
+ # (possibly non-root) SSH user and reuses this very session, so `usermod`
115
+ # membership alone would only apply to a later login. Grant the socket to
116
+ # the job user's own group for the current session instead — least
117
+ # privilege, never world-writable. The studio container itself is started
118
+ # with --security-opt no-new-privileges and without the host socket mounted,
119
+ # so the web app inside it cannot reach this daemon.
120
+ if [ "$(id -u)" -ne 0 ]; then
121
+ $SUDO usermod -aG docker "$USER" >/dev/null 2>&1 || true
122
+ $SUDO chgrp "$(id -gn)" /var/run/docker.sock >/dev/null 2>&1 || true
123
+ $SUDO chmod 660 /var/run/docker.sock >/dev/null 2>&1 || true
124
+ fi
125
+ if docker info >/dev/null 2>&1; then
126
+ echo "docker daemon ready"
127
+ else
128
+ echo "WARNING: docker daemon not reachable after install"
129
+ fi
130
+ fi
131
+
132
+ # ── 2. manifest (from the tools bucket) ────────────────────────────────────
133
+ TOML_URL="$TOOLS_BASE/pixi/config/$PIXI_TOML"
134
+ LOCK_URL="$TOOLS_BASE/pixi/config/$PIXI_LOCK"
135
+ echo "Fetching manifest:"
136
+ echo " TOML: $TOML_URL"
137
+ echo " LOCK: $LOCK_URL"
138
+ mkdir -p "$OMICSLAB_HOME"
139
+ curl -fsSL "$TOML_URL" -o "$OMICSLAB_HOME/pixi.toml"
140
+ curl -fsSL "$LOCK_URL" -o "$OMICSLAB_HOME/pixi.lock"
141
+
142
+ # ── 3. pixi install + SDK wheel (with retries) ────────────────────────────
143
+ cd "$OMICSLAB_HOME"
144
+ for i in $(seq 1 $RETRIES); do
145
+ if "${PIXI_BIN}" install --frozen; then
146
+ break
147
+ fi
148
+ echo "bootstrap-retry-$i"
149
+ sleep 5
150
+ done
151
+
152
+ # Download the stable alias and resolve the real version from wheel
153
+ # metadata so pip gets a valid PEP 427 filename.
154
+ echo "Installing omicslab SDK wheel..."
155
+ curl -fsSL "$TOOLS_BASE/omicslab-latest.whl" -o /tmp/omicslab.whl
156
+ VERSION=$("${PIXI_BIN}" run python3 -c "
157
+ import zipfile, os
158
+ z = zipfile.ZipFile('/tmp/omicslab.whl')
159
+ meta = [n for n in z.namelist() if n.endswith('.dist-info/METADATA')][0]
160
+ ver = [l.split(': ',1)[1] for l in z.read(meta).decode().splitlines() if l.startswith('Version:')][0]
161
+ os.rename('/tmp/omicslab.whl', f'/tmp/omicslab-{ver}-py3-none-any.whl')
162
+ print(ver)
163
+ ")
164
+ WHEEL_PATH="/tmp/omicslab-${VERSION}-py3-none-any.whl"
165
+ WHEEL_SHA256=$(sha256sum "$WHEEL_PATH" | awk '{print $1}')
166
+ if "${PIXI_BIN}" run python -m pip install --no-deps "$WHEEL_PATH"; then
167
+ # Record the installed wheel so the runner auto-updater can tell whether
168
+ # the tools bucket has a newer build (by SHA-256, not just version).
169
+ RECORD_DIR="${HOME}/.omicslab"
170
+ mkdir -p "$RECORD_DIR"
171
+ cat > "$RECORD_DIR/install.json" <<JSON
172
+ {
173
+ "version": "${VERSION}",
174
+ "sha256": "${WHEEL_SHA256}",
175
+ "wheel": "omicslab-${VERSION}-py3-none-any.whl",
176
+ "tools_base": "${TOOLS_BASE}",
177
+ "installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
178
+ }
179
+ JSON
180
+ chmod 600 "$RECORD_DIR/install.json"
181
+ rm -f "$WHEEL_PATH"
182
+ echo "SDK ${VERSION} installed successfully"
183
+ else
184
+ echo "Bootstrap failed after $RETRIES attempts"
185
+ exit 1
186
+ fi
187
+
188
+ # ── 4. studio direct access: allow binding the privileged port 443 ─────────
189
+ # A studio session's in-VM TLS proxy binds 0.0.0.0:443 so
190
+ # {job_id}.<studio-domain> resolves straight to this host. An unprivileged
191
+ # user cannot bind ports below 1024 by default, so grant the pixi interpreter
192
+ # CAP_NET_BIND_SERVICE — a targeted, per-binary capability with no system-wide
193
+ # effect. Where setcap is unavailable (or the filesystem rejects capabilities)
194
+ # fall back to lowering the kernel's unprivileged port floor. This is
195
+ # provider-agnostic: it runs on every Linux host the SDK is bootstrapped on.
196
+ # Best-effort — `job run --studio` still starts, and the proxy logs an
197
+ # actionable error at startup if 443 turns out not to be bindable.
198
+ PIXI_PY="$(readlink -f "${OMICSLAB_HOME}/.pixi/envs/default/bin/python" 2>/dev/null || true)"
199
+ if [ ! -x "${PIXI_PY}" ]; then
200
+ PIXI_PY="$("${PIXI_BIN}" run python -c 'import sys; print(sys.executable)' 2>/dev/null | tail -n1)"
201
+ PIXI_PY="$(readlink -f "${PIXI_PY}" 2>/dev/null || true)"
202
+ fi
203
+ if [ -z "${PIXI_PY}" ] || [ ! -x "${PIXI_PY}" ]; then
204
+ echo "WARNING: could not locate the pixi python; studio proxy may be unable to bind 443"
205
+ else
206
+ SUDO_CAP=""
207
+ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
208
+ SUDO_CAP="sudo -n"
209
+ fi
210
+ if ! command -v setcap >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
211
+ $SUDO_CAP apt-get update -qq >/dev/null 2>&1 || true
212
+ $SUDO_CAP apt-get install -y -qq libcap2-bin >/dev/null 2>&1 || true
213
+ fi
214
+ if command -v setcap >/dev/null 2>&1 && \
215
+ $SUDO_CAP setcap cap_net_bind_service=+ep "${PIXI_PY}" 2>/dev/null; then
216
+ echo "Granted CAP_NET_BIND_SERVICE to ${PIXI_PY}"
217
+ elif $SUDO_CAP sysctl -w net.ipv4.ip_unprivileged_port_start=0 >/dev/null 2>&1; then
218
+ echo "net.ipv4.ip_unprivileged_port_start=0" | \
219
+ $SUDO_CAP tee /etc/sysctl.d/99-omicslab-studio.conf >/dev/null 2>&1 || true
220
+ echo "Lowered net.ipv4.ip_unprivileged_port_start to 0"
221
+ else
222
+ echo "WARNING: could not enable binding port 443; direct studio access may be unavailable"
223
+ fi
224
+ fi
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0"
@@ -0,0 +1,441 @@
1
+ """``omicslab job run`` — the single job-execution command.
2
+
3
+ ``job run`` IS the job. On a cloud VM it runs directly; on SLURM it is the
4
+ sbatch payload running on the compute node. It runs the native engine
5
+ (:mod:`omicslab.job._run`) synchronously, self-reports RUNNING / incremental
6
+ logs / terminal status over REST, and exits with the job's exit code. There
7
+ is no controller, no daemon and no rendered ``job.sh`` on the execution path.
8
+
9
+ Usage::
10
+
11
+ omicslab job run --analysis --id <id> --work-dir <dir>
12
+ omicslab job run --studio --id <id> --work-dir <dir>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import threading
21
+ import time
22
+
23
+ import typer
24
+
25
+ from .utils import error, info, success
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ job_app = typer.Typer(
30
+ name="job",
31
+ help="Run jobs (analysis / studio) — self-managed, synchronous",
32
+ no_args_is_help=True,
33
+ )
34
+
35
+
36
+ def _snapshot_argv(*, job_id: str, output_dir: str, request: dict) -> list[str]:
37
+ """Build the ``omicslab studio snapshot`` argv for a queued request.
38
+
39
+ The request is queued by the backend and delivered over the status poll
40
+ (the cloud agent has no WebSocket channel). The agent runs the snapshot
41
+ locally, so no SSH credential is needed.
42
+ """
43
+ argv = [
44
+ "studio",
45
+ "snapshot",
46
+ "--container",
47
+ f"omicslab-studio-{job_id}",
48
+ "--output-dir",
49
+ output_dir,
50
+ "--filename",
51
+ str(request.get("filename") or f"studio-snapshot-{job_id}.tar.zst"),
52
+ "--upload",
53
+ "--workspace-id",
54
+ str(request.get("workspace_id") or ""),
55
+ ]
56
+ if request.get("key"):
57
+ argv += ["--key", str(request["key"])]
58
+ if request.get("op_id"):
59
+ argv += ["--op-id", str(request["op_id"])]
60
+ return argv
61
+
62
+
63
+ # The backend marks a job FAILED when no status/log update arrives for 10
64
+ # minutes (service_job/tasks/sweep.py), treating it as compute-disconnected. An
65
+ # interactive studio session can sit idle for far longer than that (code-server
66
+ # emits no logs while the user is not typing), so ``job run`` must prove the VM
67
+ # is alive on a periodic cadence. RUNNING → RUNNING is a no-op transition on the
68
+ # backend, so re-reporting RUNNING is safe.
69
+ JOB_HEARTBEAT_INTERVAL_SECONDS = 120
70
+
71
+
72
+ def _job_heartbeat_loop(
73
+ job_client,
74
+ job_id: str,
75
+ stop_event,
76
+ interval: int = JOB_HEARTBEAT_INTERVAL_SECONDS,
77
+ ) -> None:
78
+ """Post RUNNING every ``interval`` seconds until ``stop_event`` is set.
79
+
80
+ Refreshes the backend's ``runner:job:last_update`` heartbeat so a quiet job
81
+ is not swept as disconnected. Failures are swallowed — a heartbeat POST must
82
+ never take the job down.
83
+ """
84
+ while not stop_event.wait(interval):
85
+ try:
86
+ job_client.post(f"/jobs/{job_id}/status/", json_data={"status": "running"})
87
+ except Exception:
88
+ logger.debug("heartbeat failed for job %s", job_id)
89
+
90
+
91
+ # Cadence of the periodic ``[system]`` log line (CPU / memory / disk). A fast
92
+ # cadence is useful locally, but on production it floods the job log/API over a
93
+ # long interactive session, so use 30s for local (http) and 5 minutes for
94
+ # production (https) — the same split as the runner client (job/client.py).
95
+ JOB_SYSTEM_MONITOR_INTERVAL_SECONDS = 30
96
+ JOB_SYSTEM_MONITOR_INTERVAL_PROD_SECONDS = 300
97
+
98
+
99
+ def _system_monitor_interval(server_url: str | None) -> int:
100
+ """Seconds between ``[system]`` lines: fast locally, 5 min in production.
101
+
102
+ ``OMICSLAB_SYSTEM_MONITOR_INTERVAL`` overrides both (used by tests/ops).
103
+ """
104
+ override = os.environ.get("OMICSLAB_SYSTEM_MONITOR_INTERVAL", "")
105
+ if override:
106
+ try:
107
+ return max(1, int(override))
108
+ except ValueError:
109
+ pass
110
+ if (server_url or "").startswith("http://"):
111
+ return JOB_SYSTEM_MONITOR_INTERVAL_SECONDS
112
+ return JOB_SYSTEM_MONITOR_INTERVAL_PROD_SECONDS
113
+
114
+
115
+ def _start_system_monitor(
116
+ job_dir: str,
117
+ stop_event,
118
+ emit,
119
+ slurm_job_id: str | None = None,
120
+ interval: int = JOB_SYSTEM_MONITOR_INTERVAL_SECONDS,
121
+ ) -> threading.Thread:
122
+ """Start a daemon thread emitting periodic ``[system]`` log lines.
123
+
124
+ Writes into the job's ``job.log`` and forwards each line to ``emit`` (the
125
+ ``job run`` log callback that POSTs it to the API) so the platform shows
126
+ live CPU / memory / disk. Uses SLURM ``sstat`` on a compute node, else
127
+ psutil. Returns the started thread.
128
+ """
129
+ from ..job._run._monitor import (
130
+ format_slurm_stats,
131
+ format_system_stats,
132
+ monitor_slurm_job,
133
+ monitor_system,
134
+ )
135
+
136
+ log_file = os.path.join(job_dir, "job.log")
137
+ if slurm_job_id:
138
+ target = monitor_slurm_job
139
+ kwargs = {
140
+ "job_id": slurm_job_id,
141
+ "log_file": log_file,
142
+ "callback": lambda stats: emit(format_slurm_stats(stats)),
143
+ }
144
+ else:
145
+ target = monitor_system
146
+ kwargs = {
147
+ "log_file": log_file,
148
+ "callback": lambda stats: emit(format_system_stats(stats)),
149
+ }
150
+
151
+ thread = threading.Thread(
152
+ target=target,
153
+ kwargs={**kwargs, "interval": interval, "stop_event": stop_event},
154
+ name="job-system-monitor",
155
+ daemon=True,
156
+ )
157
+ thread.start()
158
+ return thread
159
+
160
+
161
+ @job_app.command("run")
162
+ def run_job(
163
+ id: str | None = typer.Option(
164
+ None,
165
+ "--id",
166
+ "-j",
167
+ help="Job ID to execute",
168
+ ),
169
+ work_dir: str | None = typer.Option(
170
+ None,
171
+ "--work-dir",
172
+ "-w",
173
+ help="Job working directory (contains params.json). Executed in place.",
174
+ ),
175
+ studio: bool = typer.Option(
176
+ False,
177
+ "--studio",
178
+ help="Run a studio job (interactive container session). Default: analysis.",
179
+ ),
180
+ server_url: str | None = typer.Option(
181
+ None,
182
+ "--server-url",
183
+ "-s",
184
+ help="Server API base URL (e.g. http://omx-backend:8000/api/v1)",
185
+ ),
186
+ api_token: str | None = typer.Option(
187
+ None,
188
+ "--token",
189
+ envvar="OMICSLAB_TOKEN",
190
+ help="API token for authentication",
191
+ ),
192
+ no_report: bool = typer.Option(
193
+ False,
194
+ "--no-report",
195
+ help="Skip API reporting (writes status/log locally; for testing)",
196
+ ),
197
+ ):
198
+ """Execute a job synchronously and self-report its lifecycle.
199
+
200
+ ``job run --id <id> --work-dir <dir>`` runs a Nextflow / tool analysis
201
+ job in place; ``job run --studio --id <id> --work-dir <dir>`` runs an
202
+ interactive studio container session. Both are self-managed: they POST
203
+ RUNNING, stream incremental log lines, watch for CANCELLING and post the
204
+ terminal status over REST as they run. No job.sh, no background submit,
205
+ no monitor loop.
206
+ """
207
+ from datetime import datetime as _dt
208
+
209
+ kind = "studio" if studio else "analysis"
210
+
211
+ if not work_dir:
212
+ error("Provide --work-dir <dir> (the staged job directory with params.json).")
213
+ raise typer.Exit(1)
214
+
215
+ job_dir = os.path.abspath(work_dir)
216
+ os.makedirs(job_dir, exist_ok=True)
217
+ params_file = os.path.join(job_dir, "params.json")
218
+ if not os.path.isfile(params_file):
219
+ error(f"Params file not found: {params_file}")
220
+ raise typer.Exit(1)
221
+ try:
222
+ with open(params_file) as f:
223
+ params_dict = json.load(f)
224
+ except json.JSONDecodeError as e:
225
+ error(f"Invalid JSON in {params_file}: {e}")
226
+ raise typer.Exit(1)
227
+
228
+ # ── Resolve the job id ─────────────────────────────────────────────
229
+ # The staged work-dir is ``<jobs_dir>/<job_id>`` so its basename is the
230
+ # job id; this lets the runner dispatch ``job run --work-dir <dir>``
231
+ # without threading ``--id`` through the sbatch wrapper.
232
+ if not id:
233
+ id = os.path.basename(job_dir.rstrip("/")) or None
234
+ if not id:
235
+ error("Provide --id <job-id>.")
236
+ raise typer.Exit(1)
237
+
238
+ # ── Resolve server URL / token (staged creds win over CLI/env) ──
239
+ if not no_report:
240
+ from ..config import Config
241
+
242
+ config = Config()
243
+ if not api_token:
244
+ api_token = params_dict.get("OMICSLAB_TOKEN") or config.get_token()
245
+ if not api_token:
246
+ error(
247
+ "No API token. Provide --token, set OMICSLAB_TOKEN, or stage "
248
+ "OMICSLAB_TOKEN in params.json."
249
+ )
250
+ raise typer.Exit(1)
251
+ if not server_url:
252
+ server_url = params_dict.get("API_BASE_URL") or config.get_base_url()
253
+ if not server_url:
254
+ error(
255
+ "Server URL required. Provide --server-url, stage API_BASE_URL "
256
+ "in params.json, or set it via 'omicslab auth login'."
257
+ )
258
+ raise typer.Exit(1)
259
+
260
+ # ── API client for self-reporting ──
261
+ client = None
262
+ if not no_report:
263
+ from ..client import Client
264
+
265
+ resolved_url = server_url.rstrip("/")
266
+ if not resolved_url.endswith("/api/v1"):
267
+ resolved_url += "/api/v1"
268
+ client = Client(token=api_token, base_url=resolved_url)
269
+
270
+ if client:
271
+ try:
272
+ client.post(f"/jobs/{id}/status/", json_data={"status": "running"})
273
+ except Exception as e:
274
+ logger.warning("Failed to report running: %s", e)
275
+
276
+ start = _dt.now()
277
+
278
+ def _post_log(line: str):
279
+ if client is None:
280
+ return
281
+ try:
282
+ client.post(f"/jobs/{id}/log/", json_data={"lines": [line]})
283
+ except Exception:
284
+ pass
285
+
286
+ # ── Watch for CANCELLING / snapshot requests while the engine runs ──
287
+ cancel_flag = threading.Event()
288
+ cancelled = threading.Event()
289
+ job_client = client
290
+
291
+ def _launch_snapshot(request: dict) -> None:
292
+ """Run a backend-queued snapshot locally (the agent owns the container)."""
293
+ import shutil
294
+ import subprocess
295
+ import sys as _sys
296
+
297
+ from ..job._run._log import append as _append_log
298
+
299
+ out_dir = os.path.join(job_dir, "snapshots")
300
+ os.makedirs(out_dir, exist_ok=True)
301
+ env = dict(os.environ)
302
+ if api_token:
303
+ env["OMICSLAB_TOKEN"] = api_token
304
+ if server_url:
305
+ env["OMICSLAB_BASE_URL"] = server_url
306
+ exe = shutil.which("omicslab") or _sys.argv[0]
307
+ argv = [exe, *_snapshot_argv(job_id=id, output_dir=out_dir, request=request)]
308
+ logger.info(
309
+ "Snapshot requested for job %s; launching local studio snapshot", id
310
+ )
311
+ log_path = os.path.join(out_dir, "snapshot.log")
312
+ try:
313
+ proc = subprocess.Popen(
314
+ argv,
315
+ stdin=subprocess.DEVNULL,
316
+ stdout=subprocess.PIPE,
317
+ stderr=subprocess.STDOUT,
318
+ start_new_session=True,
319
+ close_fds=True,
320
+ env=env,
321
+ )
322
+ except Exception as exc: # noqa: BLE001 — surface it, never crash the agent
323
+ _append_log(job_dir, f"[snapshot] failed to start: {exc}", _post_log)
324
+ return
325
+
326
+ def _stream_snapshot_log(proc=proc, log_path=log_path) -> None:
327
+ # Tee the snapshot output: keep the on-VM snapshot.log and mirror it
328
+ # into the job log (and the API) so snapshot progress is visible in
329
+ # the platform, not only on the VM.
330
+ try:
331
+ with open(log_path, "a") as log:
332
+ for raw in iter(proc.stdout.readline, b""):
333
+ line = raw.decode("utf-8", "replace").rstrip("\n")
334
+ log.write(line + "\n")
335
+ log.flush()
336
+ _append_log(job_dir, f"[snapshot] {line}", _post_log)
337
+ except Exception:
338
+ logger.debug("snapshot log stream ended", exc_info=True)
339
+ finally:
340
+ try:
341
+ proc.stdout.close()
342
+ except Exception:
343
+ pass
344
+
345
+ threading.Thread(
346
+ target=_stream_snapshot_log,
347
+ name=f"snapshot-log-{id[:8]}",
348
+ daemon=True,
349
+ ).start()
350
+
351
+ def _watch_cancel():
352
+ while not cancel_flag.is_set():
353
+ if job_client is None:
354
+ return
355
+ try:
356
+ resp = job_client.get(f"/jobs/{id}/")
357
+ except Exception:
358
+ resp = None
359
+ if isinstance(resp, dict):
360
+ if kind == "studio" and resp.get("snapshot_request"):
361
+ try:
362
+ _launch_snapshot(resp["snapshot_request"])
363
+ except Exception:
364
+ logger.warning(
365
+ "Failed to launch requested snapshot for job %s", id
366
+ )
367
+ if resp.get("status") == "cancelling":
368
+ cancelled.set()
369
+ # Signal the whole process group so the child pipeline is killed.
370
+ import os as _os
371
+ import signal as _signal
372
+
373
+ try:
374
+ _os.killpg(_os.getpgid(_os.getpid()), _signal.SIGTERM)
375
+ except (ProcessLookupError, OSError):
376
+ pass
377
+ return
378
+ time.sleep(5)
379
+
380
+ if job_client is not None:
381
+ threading.Thread(target=_watch_cancel, daemon=True).start()
382
+ threading.Thread(
383
+ target=_job_heartbeat_loop,
384
+ args=(job_client, id, cancel_flag),
385
+ name=f"job-heartbeat-{id[:8]}",
386
+ daemon=True,
387
+ ).start()
388
+ _start_system_monitor(
389
+ job_dir,
390
+ cancel_flag,
391
+ _post_log,
392
+ slurm_job_id=os.environ.get("SLURM_JOB_ID")
393
+ or os.environ.get("SLURM_JOBID"),
394
+ interval=_system_monitor_interval(server_url),
395
+ )
396
+
397
+ info(f"Executing {kind} job {id}")
398
+ try:
399
+ if kind == "studio":
400
+ from ..job._run.studio import run_studio
401
+
402
+ exit_code = run_studio(id, params_dict, on_log=_post_log, job_dir=job_dir)
403
+ else:
404
+ from ..job._run.analysis import run_analysis
405
+
406
+ exit_code = run_analysis(id, params_dict, on_log=_post_log, job_dir=job_dir)
407
+ except Exception as e:
408
+ logger.exception("Job %s failed", id)
409
+ error(f"Execution failed: {e}")
410
+ exit_code = 1
411
+
412
+ cancel_flag.set()
413
+ if client is not None:
414
+ status = (
415
+ "cancelled"
416
+ if (cancelled.is_set() or exit_code == 130)
417
+ else ("completed" if exit_code == 0 else "failed")
418
+ )
419
+ payload = {"status": status}
420
+ elapsed = int((_dt.now() - start).total_seconds())
421
+ payload["running_time"] = elapsed
422
+ try:
423
+ client.post(f"/jobs/{id}/status/", json_data=payload)
424
+ except Exception as e:
425
+ logger.warning("Failed to report final status: %s", e)
426
+
427
+ # Keep the work-dir for non-https (dev/local) servers; job run owns
428
+ # cleanup in production.
429
+ if server_url and server_url.startswith("https://"):
430
+ try:
431
+ from ..job.executor import JobExecutor
432
+
433
+ executor = JobExecutor()
434
+ executor.register_job_dir(id, job_dir)
435
+ executor.cleanup(id)
436
+ except Exception as e:
437
+ logger.warning("Failed to cleanup work-dir: %s", e)
438
+
439
+ if exit_code not in (0, 130):
440
+ raise typer.Exit(exit_code)
441
+ success(f"Job {id} completed")