omicslab 2.5.1__tar.gz → 2.6.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 (160) hide show
  1. {omicslab-2.5.1 → omicslab-2.6.0}/PKG-INFO +55 -8
  2. {omicslab-2.5.1 → omicslab-2.6.0}/README.developer.md +11 -7
  3. {omicslab-2.5.1 → omicslab-2.6.0}/README.md +54 -7
  4. {omicslab-2.5.1 → omicslab-2.6.0}/scripts/bootstrap-env.sh +32 -4
  5. omicslab-2.6.0/src/omicslab/_version.py +1 -0
  6. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/main.py +3 -3
  7. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/runner_commands.py +17 -17
  8. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/utils.py +29 -22
  9. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/ws_commands.py +548 -39
  10. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/studio.py +5 -18
  11. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/jobs.py +234 -14
  12. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/orgs.py +22 -0
  13. {omicslab-2.5.1 → omicslab-2.6.0}/tests/e2e/conftest.py +6 -7
  14. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_cli.py +37 -2
  15. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_cli_utils.py +42 -0
  16. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_resources.py +232 -0
  17. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_ws_commands.py +314 -1
  18. omicslab-2.5.1/src/omicslab/_version.py +0 -1
  19. {omicslab-2.5.1 → omicslab-2.6.0}/.gitignore +0 -0
  20. {omicslab-2.5.1 → omicslab-2.6.0}/Makefile +0 -0
  21. {omicslab-2.5.1 → omicslab-2.6.0}/envs/.gitignore +0 -0
  22. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/hcm.pixi.lock +0 -0
  23. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/hcm.pixi.toml +0 -0
  24. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/pixi.lock +0 -0
  25. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/pixi.toml +0 -0
  26. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.lock +0 -0
  27. {omicslab-2.5.1 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.toml +0 -0
  28. {omicslab-2.5.1 → omicslab-2.6.0}/envs/pixi.toml +0 -0
  29. {omicslab-2.5.1 → omicslab-2.6.0}/envs/prod/hcm.pixi.lock +0 -0
  30. {omicslab-2.5.1 → omicslab-2.6.0}/envs/prod/hcm.pixi.toml +0 -0
  31. {omicslab-2.5.1 → omicslab-2.6.0}/envs/prod/pixi.lock +0 -0
  32. {omicslab-2.5.1 → omicslab-2.6.0}/envs/prod/pixi.toml +0 -0
  33. {omicslab-2.5.1 → omicslab-2.6.0}/envs/regions.json +0 -0
  34. {omicslab-2.5.1 → omicslab-2.6.0}/pixi.config.toml +0 -0
  35. {omicslab-2.5.1 → omicslab-2.6.0}/pyproject.toml +0 -0
  36. {omicslab-2.5.1 → omicslab-2.6.0}/scripts/check_publish.py +0 -0
  37. {omicslab-2.5.1 → omicslab-2.6.0}/scripts/write_version_manifest.py +0 -0
  38. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/__init__.py +0 -0
  39. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/__init__.py +0 -0
  40. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/analysis_commands.py +0 -0
  41. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/auth_commands.py +0 -0
  42. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/context_commands.py +0 -0
  43. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/credential_commands.py +0 -0
  44. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/fuse_commands.py +0 -0
  45. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/job_commands.py +0 -0
  46. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/org_commands.py +0 -0
  47. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/setup_commands.py +0 -0
  48. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/studio_commands.py +0 -0
  49. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/token_commands.py +0 -0
  50. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/cli/workspace_commands.py +0 -0
  51. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/client.py +0 -0
  52. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/config.py +0 -0
  53. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/exceptions.py +0 -0
  54. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/__init__.py +0 -0
  55. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/__init__.py +0 -0
  56. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_auth.py +0 -0
  57. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_broker.py +0 -0
  58. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_config_download.py +0 -0
  59. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_consts.py +0 -0
  60. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_data_prep.py +0 -0
  61. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_env.py +0 -0
  62. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_exec.py +0 -0
  63. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_git.py +0 -0
  64. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_git_proxy.py +0 -0
  65. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_images.py +0 -0
  66. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_log.py +0 -0
  67. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_monitor.py +0 -0
  68. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_nextflow.py +0 -0
  69. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_params.py +0 -0
  70. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_proc.py +0 -0
  71. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_proxy.py +0 -0
  72. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_s3_proxy.py +0 -0
  73. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_spec.py +0 -0
  74. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_storage.py +0 -0
  75. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/_studio_prep.py +0 -0
  76. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/_run/analysis.py +0 -0
  77. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/client.py +0 -0
  78. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/console.py +0 -0
  79. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/executor.py +0 -0
  80. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/paths.py +0 -0
  81. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/job/system.py +0 -0
  82. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/__init__.py +0 -0
  83. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/analysis.py +0 -0
  84. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/base.py +0 -0
  85. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/credentials.py +0 -0
  86. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/runners.py +0 -0
  87. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/storage.py +0 -0
  88. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/studio.py +0 -0
  89. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/tokens.py +0 -0
  90. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/resources/workspaces.py +0 -0
  91. {omicslab-2.5.1 → omicslab-2.6.0}/src/omicslab/selfupdate.py +0 -0
  92. {omicslab-2.5.1 → omicslab-2.6.0}/tests/__init__.py +0 -0
  93. {omicslab-2.5.1 → omicslab-2.6.0}/tests/conftest.py +0 -0
  94. {omicslab-2.5.1 → omicslab-2.6.0}/tests/e2e/__init__.py +0 -0
  95. {omicslab-2.5.1 → omicslab-2.6.0}/tests/e2e/test_cli_e2e.py +0 -0
  96. {omicslab-2.5.1 → omicslab-2.6.0}/tests/e2e/test_tokens.py +0 -0
  97. {omicslab-2.5.1 → omicslab-2.6.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
  98. {omicslab-2.5.1 → omicslab-2.6.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
  99. {omicslab-2.5.1 → omicslab-2.6.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
  100. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__auth.py +0 -0
  101. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__broker.py +0 -0
  102. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__data_prep.py +0 -0
  103. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__engine_helpers.py +0 -0
  104. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__env.py +0 -0
  105. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__get_images_cli.py +0 -0
  106. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__git.py +0 -0
  107. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__git_proxy.py +0 -0
  108. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__images.py +0 -0
  109. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__log.py +0 -0
  110. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__monitor.py +0 -0
  111. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__nextflow.py +0 -0
  112. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__params.py +0 -0
  113. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__proxy.py +0 -0
  114. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__proxy_extra.py +0 -0
  115. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__s3_proxy.py +0 -0
  116. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__spec.py +0 -0
  117. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__storage.py +0 -0
  118. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test__studio_docker.py +0 -0
  119. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/_run/test_studio_proxy_start.py +0 -0
  120. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_cli.py +0 -0
  121. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_client.py +0 -0
  122. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_client_extra.py +0 -0
  123. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_executor.py +0 -0
  124. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_executor_extra.py +0 -0
  125. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_fuse.py +0 -0
  126. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_resource.py +0 -0
  127. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_job_system.py +0 -0
  128. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_snapshot_request.py +0 -0
  129. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_studio_image_download.py +0 -0
  130. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_studio_validate_cli.py +0 -0
  131. {omicslab-2.5.1 → omicslab-2.6.0}/tests/job/test_ws_helpers.py +0 -0
  132. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_cli_catalog.py +0 -0
  133. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_cli_context.py +0 -0
  134. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_cli_storage.py +0 -0
  135. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_client.py +0 -0
  136. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_client_broker.py +0 -0
  137. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_config.py +0 -0
  138. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_fuse.py +0 -0
  139. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_job_exec.py +0 -0
  140. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_job_heartbeat.py +0 -0
  141. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_misc.py +0 -0
  142. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_runner_helpers.py +0 -0
  143. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_runner_self_update.py +0 -0
  144. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_sandbox_report.py +0 -0
  145. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_selfupdate.py +0 -0
  146. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_studio_snapshot.py +0 -0
  147. {omicslab-2.5.1 → omicslab-2.6.0}/tests/test_system_monitor_interval.py +0 -0
  148. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-nextflow/input.txt +0 -0
  149. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-nextflow/main.nf +0 -0
  150. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
  151. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
  152. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-nextflow/schema.json +0 -0
  153. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
  154. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-simple/pixi.toml +0 -0
  155. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-simple/schema.json +0 -0
  156. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-snakemake/Snakefile +0 -0
  157. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-snakemake/input.txt +0 -0
  158. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
  159. {omicslab-2.5.1 → omicslab-2.6.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
  160. {omicslab-2.5.1 → omicslab-2.6.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.5.1
3
+ Version: 2.6.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
@@ -52,7 +52,7 @@ omicslab use
52
52
  omicslab ls && omicslab upload ./data.csv
53
53
 
54
54
  # 4. Launch a job
55
- omicslab jobs launch --workspace <ws_id> --analysis <id> --compute <id>
55
+ omicslab ws analysis launch --workspace <ws_id> --analysis <id> --compute <id>
56
56
  ```
57
57
 
58
58
  ## Storage Commands
@@ -148,12 +148,13 @@ Analysis and studio jobs share one backend namespace:
148
148
  `/workspaces/{workspace_id}/jobs/...` — `job_type` ("analysis" | "studio")
149
149
  is part of the launch payload / list filter, not a separate URL space.
150
150
  `client.jobs` exposes generic methods (`launch`, `list_jobs`, `get_job`,
151
- `get_job_manifest`, `terminate_job`, `delete_job`, `connect_job`,
152
- `disconnect_job`, `check_job_ready`, ...) plus the domain aliases
153
- (`launch_analysis`, `launch_studio`, `list_analysis`, `list_studio`, ...)
154
- for convenience. Job resources (`time`/`cpu`/`memory`/`disk`) and the
155
- launch config live in the per-job manifest on S3, retrievable via
156
- `get_job_manifest` / `get_job_audit_params` / `get_job_audit_config`.
151
+ `get_job_manifest`, `rerun_job`, `terminate_job`, `delete_job`,
152
+ `connect_job`, `disconnect_job`, `check_job_ready`, ...) plus the domain
153
+ aliases (`launch_analysis`, `launch_studio`, `rerun_analysis`,
154
+ `rerun_studio`, `list_analysis`, `list_studio`, ...) for convenience. Job
155
+ resources (`time`/`cpu`/`memory`/`disk`) and the launch config live in the
156
+ per-job manifest on S3, retrievable via `get_job_manifest` /
157
+ `get_job_audit_params` / `get_job_audit_config`.
157
158
 
158
159
  ```python
159
160
  from omicslab import Client
@@ -180,6 +181,52 @@ studio = client.jobs.launch_studio(
180
181
  compute_id="cloud-comp-...",
181
182
  )
182
183
  access = client.jobs.connect_job("ws-...", studio["id"]) # {"access_url": ...}
184
+
185
+ # Rerun a job from its resolved launch manifest. Only the overrides below
186
+ # change; tool/tag/pinned commit/versioning/config stay fixed to the source.
187
+ rerun = client.jobs.rerun_analysis(
188
+ "ws-...",
189
+ job_id,
190
+ cpu=8,
191
+ time=7200,
192
+ params={"max_cpus": 8}, # merged per top-level key
193
+ )
194
+ # Masked secret params must be supplied again, otherwise the API returns 400.
195
+ ```
196
+
197
+ The same rerun is available for studio sessions via
198
+ `client.jobs.rerun_studio(...)`; the generic `client.jobs.rerun_job(...)`
199
+ works for both. The response carries `old_job_id` and
200
+ `changed_parameters` so callers can show what changed.
201
+
202
+ ```python
203
+ # Estimate the max cost before launching
204
+ estimate = client.jobs.estimate_job(
205
+ "ws-...",
206
+ compute_type="cloud",
207
+ compute_id="cloud-comp-...",
208
+ time=7200,
209
+ cpu=8,
210
+ memory=32,
211
+ disk=100,
212
+ )
213
+
214
+ # Status counts + per-period launch trend (workspace or organization scope)
215
+ counts = client.jobs.get_job_counts("ws-...", job_type="analysis", granularity="daily")
216
+ org_counts = client.orgs.get_job_counts("org-...", job_type="analysis")
217
+
218
+ # Snapshot a RUNNING studio session into workspace storage
219
+ client.jobs.snapshot_studio("ws-...", studio["id"], outdir="snapshots")
220
+
221
+ # Server-side search/filter/sort (exact statuses need filter_column="status")
222
+ client.jobs.list_analysis(
223
+ "ws-...",
224
+ search="bacass",
225
+ filter_column="status",
226
+ filter_value="running,failed",
227
+ sort_column="created_at",
228
+ sort_desc=True,
229
+ )
183
230
  ```
184
231
 
185
232
  ## Testing
@@ -92,11 +92,11 @@ and then hand the raw token to `Client`.
92
92
 
93
93
  | Property | Class | Purpose |
94
94
  |------------|--------------------------|---------|
95
- | `orgs` | `OrgResource` | Org CRUD + members + roles |
95
+ | `orgs` | `OrgResource` | Org CRUD + members + roles + job counts |
96
96
  | `workspaces` | `WorkspaceResource` | Workspace CRUD, members, pin, linked resources, budget/storage-quota (read-only) |
97
97
  | `analysis` | `AnalysisResource` | Tool catalog (batch tools) CRUD/tags/profiles/schema |
98
98
  | `studio` | `StudioResource` | Tool catalog (interactive tools) CRUD + tags/profiles/schema |
99
- | `jobs` | `JobResource` | Launch/list/get/terminate analysis + studio jobs |
99
+ | `jobs` | `JobResource` | Launch/list/get/rerun/estimate/counts/snapshot/terminate analysis + studio jobs |
100
100
  | `storage` | `StorageResource` | Upload/download list/delete/rename vs workspace S3 |
101
101
  | `credentials` | `CredentialResource` | S3/GitHub/Cloud credentials (user + org scopes) |
102
102
  | `tokens` | `TokenResource` | Create/list/revoke API tokens |
@@ -248,15 +248,16 @@ bootstraps one session-scoped user via raw `httpx`:
248
248
  ```
249
249
  POST /api/auth/register/ (unique email)
250
250
  POST /api/auth/standard/ → access_token cookie
251
- GET /api/organizations/ → take first (auto-provisioned Sponsor org)
251
+ POST /api/organizations/ → create the test org
252
252
  POST /api/credentials/organization/{org}/storage/base/ + link s3 (storage cred)
253
253
  POST /api/organizations/{org}/workspaces/ (workspace)
254
254
  POST /api/tokens/ → omx_ token scoped to org (admin)
255
255
  Client(token=..., base_url=...)
256
256
  ```
257
257
 
258
- Registration auto-provisions a **Sponsor org + Free subscription + workspace**
259
- (`service_auth/utils/provisioning.py`), so no payment step is needed.
258
+ Registration does not create any organization; the conftest creates the org
259
+ explicitly. A subscription is still activated for it through the payment flow
260
+ (see `test_subscription`).
260
261
 
261
262
  ### 7.2 Fixtures
262
263
 
@@ -475,8 +476,11 @@ Studio session mounts (no token exposure, snapshot-safe):
475
476
  `history`; the copy lives in the job dir and is never snapshotted.
476
477
  - Users can create their own project envs in the writable workspace
477
478
  (`pixi init` + `pixi add`); those envs are in the writable layer and **are**
478
- included in snapshots. The pixi package cache goes to a RAM-backed tmpfs
479
- (`PIXI_CACHE_DIR=/run/omicslab-cache`) so downloads never bloat snapshots.
479
+ included in snapshots. Pixi keeps its default package cache under the
480
+ session user's home, so that cache is part of the writable layer too and is
481
+ included in snapshots. No cache directory is mounted: sessions need no extra
482
+ mount, the cache never runs out of a fixed size cap, and pixi works for any
483
+ image user without permission fixes.
480
484
  - The staged CLI config is mounted from the host into
481
485
  `OMICSLAB_CONFIG_DIR=/run/omicslab` before the container starts. The token
482
486
  is replaced with a placeholder first; the SDK talks to a host-side
@@ -24,7 +24,7 @@ omicslab use
24
24
  omicslab ls && omicslab upload ./data.csv
25
25
 
26
26
  # 4. Launch a job
27
- omicslab jobs launch --workspace <ws_id> --analysis <id> --compute <id>
27
+ omicslab ws analysis launch --workspace <ws_id> --analysis <id> --compute <id>
28
28
  ```
29
29
 
30
30
  ## Storage Commands
@@ -120,12 +120,13 @@ Analysis and studio jobs share one backend namespace:
120
120
  `/workspaces/{workspace_id}/jobs/...` — `job_type` ("analysis" | "studio")
121
121
  is part of the launch payload / list filter, not a separate URL space.
122
122
  `client.jobs` exposes generic methods (`launch`, `list_jobs`, `get_job`,
123
- `get_job_manifest`, `terminate_job`, `delete_job`, `connect_job`,
124
- `disconnect_job`, `check_job_ready`, ...) plus the domain aliases
125
- (`launch_analysis`, `launch_studio`, `list_analysis`, `list_studio`, ...)
126
- for convenience. Job resources (`time`/`cpu`/`memory`/`disk`) and the
127
- launch config live in the per-job manifest on S3, retrievable via
128
- `get_job_manifest` / `get_job_audit_params` / `get_job_audit_config`.
123
+ `get_job_manifest`, `rerun_job`, `terminate_job`, `delete_job`,
124
+ `connect_job`, `disconnect_job`, `check_job_ready`, ...) plus the domain
125
+ aliases (`launch_analysis`, `launch_studio`, `rerun_analysis`,
126
+ `rerun_studio`, `list_analysis`, `list_studio`, ...) for convenience. Job
127
+ resources (`time`/`cpu`/`memory`/`disk`) and the launch config live in the
128
+ per-job manifest on S3, retrievable via `get_job_manifest` /
129
+ `get_job_audit_params` / `get_job_audit_config`.
129
130
 
130
131
  ```python
131
132
  from omicslab import Client
@@ -152,6 +153,52 @@ studio = client.jobs.launch_studio(
152
153
  compute_id="cloud-comp-...",
153
154
  )
154
155
  access = client.jobs.connect_job("ws-...", studio["id"]) # {"access_url": ...}
156
+
157
+ # Rerun a job from its resolved launch manifest. Only the overrides below
158
+ # change; tool/tag/pinned commit/versioning/config stay fixed to the source.
159
+ rerun = client.jobs.rerun_analysis(
160
+ "ws-...",
161
+ job_id,
162
+ cpu=8,
163
+ time=7200,
164
+ params={"max_cpus": 8}, # merged per top-level key
165
+ )
166
+ # Masked secret params must be supplied again, otherwise the API returns 400.
167
+ ```
168
+
169
+ The same rerun is available for studio sessions via
170
+ `client.jobs.rerun_studio(...)`; the generic `client.jobs.rerun_job(...)`
171
+ works for both. The response carries `old_job_id` and
172
+ `changed_parameters` so callers can show what changed.
173
+
174
+ ```python
175
+ # Estimate the max cost before launching
176
+ estimate = client.jobs.estimate_job(
177
+ "ws-...",
178
+ compute_type="cloud",
179
+ compute_id="cloud-comp-...",
180
+ time=7200,
181
+ cpu=8,
182
+ memory=32,
183
+ disk=100,
184
+ )
185
+
186
+ # Status counts + per-period launch trend (workspace or organization scope)
187
+ counts = client.jobs.get_job_counts("ws-...", job_type="analysis", granularity="daily")
188
+ org_counts = client.orgs.get_job_counts("org-...", job_type="analysis")
189
+
190
+ # Snapshot a RUNNING studio session into workspace storage
191
+ client.jobs.snapshot_studio("ws-...", studio["id"], outdir="snapshots")
192
+
193
+ # Server-side search/filter/sort (exact statuses need filter_column="status")
194
+ client.jobs.list_analysis(
195
+ "ws-...",
196
+ search="bacass",
197
+ filter_column="status",
198
+ filter_value="running,failed",
199
+ sort_column="created_at",
200
+ sort_desc=True,
201
+ )
155
202
  ```
156
203
 
157
204
  ## Testing
@@ -37,13 +37,32 @@ PIXI_LOCK="${PIXI_LOCK:-pixi.lock}"
37
37
  PIXI_VERSION="${PIXI_VERSION:-v0.76.1}"
38
38
  RETRIES=3
39
39
 
40
+ # Download helper. Every fetch is bounded and retried so a stalled connection
41
+ # fails in minutes with a clear message instead of hanging until the platform's
42
+ # SSH bootstrap deadline (900s) kills the job with no diagnostics. The bucket
43
+ # sits behind a single gateway that also serves the conda/pypi payload, so
44
+ # transient 5xx/timeouts must be retried rather than abort the whole bootstrap.
45
+ FETCH_CONNECT_TIMEOUT="${FETCH_CONNECT_TIMEOUT:-10}"
46
+ FETCH_MAX_TIME="${FETCH_MAX_TIME:-300}"
47
+ FETCH_RETRIES="${FETCH_RETRIES:-5}"
48
+ fetch() {
49
+ local url="$1" out="$2"
50
+ curl -fsSL \
51
+ --connect-timeout "${FETCH_CONNECT_TIMEOUT}" \
52
+ --max-time "${FETCH_MAX_TIME}" \
53
+ --retry "${FETCH_RETRIES}" \
54
+ --retry-delay 5 \
55
+ --retry-connrefused \
56
+ "$url" -o "$out"
57
+ }
58
+
40
59
  # ── 1. pixi (from the tools bucket, absolute path under $HOME) ────────────
41
60
  PIXI_BIN="${HOME}/.pixi/bin/pixi"
42
61
  if [ ! -x "${PIXI_BIN}" ]; then
43
62
  PIXI_URL="$TOOLS_BASE/pixi/bin/pixi-${PIXI_VERSION}-linux-amd64.tar.gz"
44
63
  echo "Installing pixi ${PIXI_VERSION} from: $PIXI_URL"
45
64
  mkdir -p "$(dirname "${PIXI_BIN}")"
46
- curl -fsSL "$PIXI_URL" -o /tmp/pixi.tar.gz
65
+ fetch "$PIXI_URL" /tmp/pixi.tar.gz
47
66
  tar -xzf /tmp/pixi.tar.gz -C "$(dirname "${PIXI_BIN}")"
48
67
  rm -f /tmp/pixi.tar.gz
49
68
  else
@@ -136,23 +155,32 @@ echo "Fetching manifest:"
136
155
  echo " TOML: $TOML_URL"
137
156
  echo " LOCK: $LOCK_URL"
138
157
  mkdir -p "$OMICSLAB_HOME"
139
- curl -fsSL "$TOML_URL" -o "$OMICSLAB_HOME/pixi.toml"
140
- curl -fsSL "$LOCK_URL" -o "$OMICSLAB_HOME/pixi.lock"
158
+ fetch "$TOML_URL" "$OMICSLAB_HOME/pixi.toml"
159
+ fetch "$LOCK_URL" "$OMICSLAB_HOME/pixi.lock"
141
160
 
142
161
  # ── 3. pixi install + SDK wheel (with retries) ────────────────────────────
143
162
  cd "$OMICSLAB_HOME"
163
+ env_ready=0
144
164
  for i in $(seq 1 $RETRIES); do
145
165
  if "${PIXI_BIN}" install --frozen; then
166
+ env_ready=1
146
167
  break
147
168
  fi
148
169
  echo "bootstrap-retry-$i"
149
170
  sleep 5
150
171
  done
172
+ # `set -e` does not fire for a command used as an `if` condition, so without an
173
+ # explicit check the script would carry on with a half-installed environment
174
+ # and fail later at the wheel step with a misleading message.
175
+ if [ "$env_ready" -ne 1 ]; then
176
+ echo "ERROR: pixi install --frozen failed after ${RETRIES} attempts" >&2
177
+ exit 1
178
+ fi
151
179
 
152
180
  # Download the stable alias and resolve the real version from wheel
153
181
  # metadata so pip gets a valid PEP 427 filename.
154
182
  echo "Installing omicslab SDK wheel..."
155
- curl -fsSL "$TOOLS_BASE/omicslab-latest.whl" -o /tmp/omicslab.whl
183
+ fetch "$TOOLS_BASE/omicslab-latest.whl" /tmp/omicslab.whl
156
184
  VERSION=$("${PIXI_BIN}" run python3 -c "
157
185
  import zipfile, os
158
186
  z = zipfile.ZipFile('/tmp/omicslab.whl')
@@ -0,0 +1 @@
1
+ __version__ = "2.6.0"
@@ -265,13 +265,13 @@ def pwd():
265
265
  parts = []
266
266
  if org:
267
267
  org_name = config.get_org_name() or org[:8]
268
- parts.append(f"[cyan]{org_name}[/cyan]")
268
+ parts.append(f"[bold]{org_name}[/bold]")
269
269
  if ws:
270
270
  ws_name = config.get_workspace_name() or ws[:8]
271
- parts.append(f"[green]{ws_name}[/green]")
271
+ parts.append(f"[bold]{ws_name}[/bold]")
272
272
  if stored_prefix is not None:
273
273
  p = "/" if not stored_prefix else stored_prefix.rstrip("/")
274
- parts.append(f"[yellow]{p}[/yellow]")
274
+ parts.append(f"[bold]{p}[/bold]")
275
275
 
276
276
  if parts:
277
277
  console.print(" ".join(parts))
@@ -121,18 +121,18 @@ def generate_token(
121
121
  output(result, format="json")
122
122
  else:
123
123
  success("Registration token created")
124
- console.print(f"[cyan]Token:[/cyan] [bold]{result['token']}[/bold]")
125
- console.print(f"[cyan]Expires:[/cyan] {result['expires_at']}")
126
- console.print(f"[cyan]Scope:[/cyan] {scope}")
124
+ console.print(f"[bold]Token:[/bold] [bold]{result['token']}[/bold]")
125
+ console.print(f"[bold]Expires:[/bold] {result['expires_at']}")
126
+ console.print(f"[bold]Scope:[/bold] {scope}")
127
127
  console.print("")
128
128
  console.print("Register a runner with:")
129
129
  console.print(
130
- f" [green]omicslab runner register --token '{result['token']}' --server {config.get_base_url()}[/green]"
130
+ f" [bold]omicslab runner register --token '{result['token']}' --server {config.get_base_url()}[/bold]"
131
131
  )
132
132
  console.print("")
133
133
  console.print("Then start the daemon:")
134
- console.print(" [green]omicslab runner start --daemon[/green]")
135
- console.print(" [green]omicslab runner status[/green]")
134
+ console.print(" [bold]omicslab runner start --daemon[/bold]")
135
+ console.print(" [bold]omicslab runner status[/bold]")
136
136
  except Exception as e:
137
137
  error(f"Failed to create registration token: {e}")
138
138
  raise typer.Exit(1)
@@ -199,11 +199,11 @@ def register(
199
199
  output(result, format="json")
200
200
  else:
201
201
  success(f"Registered as runner: {runner_name}")
202
- console.print(f"[cyan]Runner ID:[/cyan] {runner_id}")
203
- console.print(f"[cyan]Server:[/cyan] {server}")
202
+ console.print(f"[bold]Runner ID:[/bold] {runner_id}")
203
+ console.print(f"[bold]Server:[/bold] {server}")
204
204
  console.print("")
205
205
  console.print("Next, start the runner daemon:")
206
- console.print(" [green]omicslab runner start[/green]")
206
+ console.print(" [bold]omicslab runner start[/bold]")
207
207
 
208
208
  except typer.Exit:
209
209
  raise
@@ -249,17 +249,17 @@ def start(
249
249
  if not tool_info.get(t, {}).get("available")
250
250
  ]
251
251
  if missing_critical and has_slurm:
252
- console.print("[yellow]═══ Pre-flight Tool Check ═══[/yellow]")
252
+ console.print("[bold]═══ Pre-flight Tool Check ═══[/bold]")
253
253
  for t in missing_critical:
254
254
  console.print(f" [red]✗ {t}[/red] — not found in PATH")
255
- console.print(" [yellow]Install on HPC:[/yellow]")
255
+ console.print(" [bold]Install on HPC:[/bold]")
256
256
  console.print(
257
- " [green]pixi global install nextflow[/green]"
257
+ " [bold]pixi global install nextflow[/bold]"
258
258
  if "nextflow" in missing_critical
259
259
  else ""
260
260
  )
261
261
  console.print(
262
- " [green]pixi global install apptainer[/green]"
262
+ " [bold]pixi global install apptainer[/bold]"
263
263
  if "apptainer" in missing_critical
264
264
  else ""
265
265
  )
@@ -377,14 +377,14 @@ def status_command():
377
377
  server_url = config.get_base_url()
378
378
 
379
379
  if not runner_id:
380
- console.print("[yellow]Runner is not registered.[/yellow]")
380
+ console.print("[bold]Runner is not registered.[/bold]")
381
381
  console.print(" Run `omicslab runner register` first.")
382
382
  return
383
383
 
384
384
  console.print("[bold]OmicsLab Runner Status[/bold]")
385
- console.print(f" Runner ID: [cyan]{runner_id}[/cyan]")
386
- console.print(f" Name: [cyan]{runner_name}[/cyan]")
387
- console.print(f" Server URL: [cyan]{server_url}[/cyan]")
385
+ console.print(f" Runner ID: [bold]{runner_id}[/bold]")
386
+ console.print(f" Name: [bold]{runner_name}[/bold]")
387
+ console.print(f" Server URL: [bold]{server_url}[/bold]")
388
388
 
389
389
  running, pid = _runner_is_running()
390
390
  if running:
@@ -134,7 +134,7 @@ _COST_KEYS = frozenset(
134
134
  def _format_value(key: str, val):
135
135
  """Convert a raw value to a human-readable string."""
136
136
  if val is None:
137
- return "[dim]—[/dim]"
137
+ return "—"
138
138
  if isinstance(val, (dict, list)):
139
139
  return json.dumps(val)[:60]
140
140
  if isinstance(val, str) and key in _DATETIME_KEYS:
@@ -158,8 +158,12 @@ def get_client() -> Client:
158
158
  raise typer.Exit(1)
159
159
 
160
160
 
161
- def output(data, format: str = "table"):
162
- """Output data in the specified format."""
161
+ def output(data, format: str = "table", columns: list[str] | None = None):
162
+ """Output data in the specified format.
163
+
164
+ ``columns`` selects and orders table columns; when omitted the generic
165
+ display-key heuristic is used.
166
+ """
163
167
  if format == "json":
164
168
  console.print(json.dumps(data, indent=2, default=str))
165
169
  return
@@ -171,14 +175,14 @@ def output(data, format: str = "table"):
171
175
  if not data:
172
176
  console.print("[dim]No items found.[/dim]")
173
177
  return
174
- _print_table(data)
178
+ _print_table(data, columns=columns)
175
179
  elif isinstance(data, dict):
176
180
  if "items" in data and isinstance(data["items"], list):
177
181
  items = data["items"]
178
182
  if not items:
179
183
  console.print("[dim]No items found.[/dim]")
180
184
  return
181
- _print_table(items)
185
+ _print_table(items, columns=columns)
182
186
  if "total" in data:
183
187
  console.print(
184
188
  f"[dim]Page {data.get('page', '?')} of {data.get('total_pages', '?')} — {data['total']} total items[/dim]"
@@ -187,18 +191,21 @@ def output(data, format: str = "table"):
187
191
  _print_dict(data)
188
192
 
189
193
 
190
- def _print_table(items: list[dict]):
191
- """Print a list of dicts as a rich table."""
194
+ def _print_table(items: list[dict], columns: list[str] | None = None):
195
+ """Print a list of dicts as a rich table.
196
+
197
+ Data cells deliberately keep the terminal's default foreground so the
198
+ table stays readable on both light and dark backgrounds. Only the header
199
+ is emphasised (bold).
200
+ """
192
201
  if not items:
193
202
  return
194
203
 
195
- keys = _get_display_keys(items)
196
- table = Table(box=box.SIMPLE_HEAVY)
204
+ keys = columns or _get_display_keys(items)
205
+ table = Table(box=box.SIMPLE_HEAVY, header_style="bold")
197
206
 
198
207
  for key in keys:
199
- table.add_column(
200
- key, style="cyan", no_wrap=key in ("id", "role", "state", "type")
201
- )
208
+ table.add_column(key, no_wrap=key in ("id", "role", "state", "type"))
202
209
 
203
210
  for item in items:
204
211
  row = []
@@ -215,12 +222,12 @@ def _print_dict(data: dict, indent: int = 0):
215
222
  prefix = " " * indent
216
223
  for key, val in data.items():
217
224
  if isinstance(val, dict):
218
- console.print(f"{prefix}[cyan]{key}:[/cyan]")
225
+ console.print(f"{prefix}[bold]{key}:[/bold]")
219
226
  _print_dict(val, indent + 1)
220
227
  elif isinstance(val, list):
221
- console.print(f"{prefix}[cyan]{key}:[/cyan] {json.dumps(val)[:120]}")
228
+ console.print(f"{prefix}[bold]{key}:[/bold] {json.dumps(val)[:120]}")
222
229
  else:
223
- console.print(f"{prefix}[cyan]{key}:[/cyan] {_format_value(key, val)}")
230
+ console.print(f"{prefix}[bold]{key}:[/bold] {_format_value(key, val)}")
224
231
 
225
232
 
226
233
  def _get_display_keys(items: list[dict]) -> list[str]:
@@ -293,8 +300,8 @@ def info(msg: str):
293
300
 
294
301
 
295
302
  def print_panel(title: str, data: dict):
296
- table = Table(box=box.SIMPLE)
297
- table.add_column("Key", style="cyan")
303
+ table = Table(box=box.SIMPLE, header_style="bold")
304
+ table.add_column("Key")
298
305
  table.add_column("Value")
299
306
  for k, v in data.items():
300
307
  if isinstance(v, (dict, list)):
@@ -454,11 +461,11 @@ def print_storage_listing(objects: list[dict]):
454
461
  sorted_prefixes = sorted(prefixes)
455
462
  sorted_files = sorted(files, key=lambda f: f.get("name", ""))
456
463
 
457
- table = Table(box=box.SIMPLE)
458
- table.add_column("Type", style="cyan", width=4)
459
- table.add_column("Name", style="white")
460
- table.add_column("Size", style="dim", justify="right")
461
- table.add_column("Modified", style="dim")
464
+ table = Table(box=box.SIMPLE, header_style="bold")
465
+ table.add_column("Type", width=4)
466
+ table.add_column("Name")
467
+ table.add_column("Size", justify="right")
468
+ table.add_column("Modified")
462
469
 
463
470
  for p in sorted_prefixes:
464
471
  table.add_row("DIR", p.rstrip("/"), "", "")