Skip to content

Snakemake-based execution layer + content-addressed manifests + Dask substrate#82

Merged
EiffL merged 19 commits into
mainfrom
snakemake-redesign
Apr 30, 2026
Merged

Snakemake-based execution layer + content-addressed manifests + Dask substrate#82
EiffL merged 19 commits into
mainfrom
snakemake-redesign

Conversation

@EiffL
Copy link
Copy Markdown
Member

@EiffL EiffL commented Apr 26, 2026

Status: Draft. Branch supersedes the Dagster + Postgres engine with a Snakemake-based one whose entire contribution is a content-addressed manifest layer plus a vendored Dask executor plugin. See redesign.md for the full spec and design_review.md for the requirements it answers.

TL;DR

Snakemake owns DAG resolution, parallelism, retries, and dry-run. Dask owns task dispatch, per-task resource matching, and bin-packing into worker capacity. We own one Python function (write_manifest), a Snakefile generator, the container build/hash/wrap module, an in-process cluster_for_run() bootstrap, and a vendored Snakemake executor plugin that hands rules to Dask. Net diff vs. main: −9.7k / +4.4k LOC while delivering more of design_review.md than the previous engine did.

Design principles guiding this iteration

1. Snakemake does what Snakemake does

DAG construction, topological execution, parallelism, retries, dry-run, profiles, locking — all delegated to upstream. We do not write code for any of it.

2. We own only the integrity layer

The single property design_review.md calls non-negotiable — agents cannot fake outputs — becomes a consequence of how the system is built, not a policy enforced by a process boundary:

  • Every rule declares its .lightcone-manifest.json as an output. Snakemake re-runs any rule whose manifest is missing.
  • The manifest is content-addressed: data_version = sha256(output_dir), code_version = sha256(recipe ‖ container_image ‖ decisions), input_versions chain to upstream manifests.
  • lc verify recomputes hashes and validates the chain — no orchestrator dependency.
  • lc status walks manifests offline. Snakemake's .snakemake/metadata/ is treated as a cache and never read.

3. User-facing surface does not change

lc run, lc status, lc verify keep their semantics. The Snakefile is implementation detail — generated on every lc run from astra.yaml, never edited by hand.

4. Clean-slate replacement, no dual-engine

No backwards-compatibility flag, no migration command, no shim. The Dagster / Postgres / runner / slurm-info / targets modules and their tests are deleted, not deprecated.

5. Containers are ours, end-to-end

We build images deterministically from Containerfiles with content-addressed tags (lc-<name>-<sha256>) and pre-wrap each recipe with the explicit runtime call at Snakefile-generation time. Snakemake's container: directive is intentionally unused, so the manifest's container_image field is the strong evidence of what actually ran.

6. One execution substrate everywhere — Dask

A user inside an salloc/sbatch who runs lc run gets routed through Dask automatically; same plugin and bootstrap path covers the workstation case and a pre-existing scheduler. lightcone.engine.dask_cluster.cluster_for_run returns a scheduler address for the run's lifetime:

Trigger Behavior
DASK_SCHEDULER_ADDRESS set Connect to the existing scheduler. We don't own its lifecycle.
SLURM_JOB_ID set Start an in-process LocalCluster(n_workers=0) bound to the driver's hostname; srun --ntasks=\$NNODES --ntasks-per-node=1 dask worker \$ADDR launches one persistent worker per node, each advertising the node's full cpus/memory/gpus as Dask resources.
Neither LocalCluster() sized to the local machine.

The scheduler is always in-process — its lifetime equals the run's lifetime. No service to manage, no orphaned schedulers if the driver crashes, no lc cluster lifecycle commands.

Snakemake dispatches each rule via our own executor plugin at src/snakemake_executor_plugin_dask/: client.submit(_run_shell, cmd, resources={cpus, memory, gpus}, pure=False). Per-rule cpus_per_task / mem_mb / gpus_per_task translate 1:1 to per-task Dask resources, and the scheduler bin-packs tasks into workers up to each worker's advertised budget.

Why Dask, not slurm-jobstep or slurm:

  • --executor slurm is the wrong shape — head-node sbatch submission, with the Snakemake docs warning that running it inside an active SLURM job leads to unpredictable behavior. Our users want pilot-job semantics: one big salloc, many tasks dispatched within it.
  • --executor slurm-jobstep is the right shape but its repo description states it is "meant for internal use by snakemake-executor-plugin-slurm" — using it standalone is an off-label code path the maintainers do not commit to keeping working. It also inherits known footguns (SLURM 22.05+'s --cpus-per-task non-inheritance, the long-standing one-core dispatch issue) that would land in our user docs.
  • Dask is pip install everywhere, gives us persistent workers within a run (sub-second task dispatch), exposes a live dashboard, and the Dask scheduler's failure modes are well understood. The trade we accept is that resource matching is advisory rather than cgroup-enforced — for minutes-to-hours recipes whose mem_mb is declared in astra.yaml, this is acceptable, and SLURM's per-allocation memory cgroup is the backstop.
  • Flux was evaluated and rejected: richer scheduling, but module load flux (or building flux-core from source) on every host is install friction we don't want outside Perlmutter.

If standalone slurm-jobstep becomes a maintained, user-facing executor, this is worth revisiting. Until then, owning ~350 LOC of well-scoped Dask plumbing is the better trade.

7. No service to manage

The old lc cluster start/attach/stop lifecycle is gone with no replacement. The Dask scheduler is in-process; SLURM workers are bounded by the user's salloc. No Postgres, no scheduler daemon, no daemon-per-project.

What's in the diff

Added

  • redesign.md, design_review.md — spec + requirements
  • src/lightcone/engine/snakefile.py — generator (Jinja → .lightcone/Snakefile + sidecar JSON)
  • src/lightcone/engine/manifest.pywrite_manifest(), sha256_dir(), code_version helpers
  • src/lightcone/engine/verify.py — chain integrity check
  • src/lightcone/engine/dask_cluster.pycluster_for_run() context manager
  • src/snakemake_executor_plugin_dask/ — vendored Snakemake executor plugin
  • Manifest, Snakefile, status, verify, container, dask-plugin, dask-cluster test suites

Removed

  • engine/assets.py, engine/runner.py, engine/slurm_info.py, engine/targets.py, engine/io_manager.py
  • All Dagster, Postgres scaffolding and corresponding tests
  • The `lc cluster` command surface

Kept (re-purposed)

  • `dask` and `distributed` — no longer behind `dagster-dask`'s shim, now driven directly by our vendored Snakemake executor plugin and `cluster_for_run` bootstrap.

Test plan

  • `uv run pytest` — 206 tests passing (+ 1 opt-in `slow` marker for the real `LocalCluster` smoke)
  • `uv run ruff check src/ tests/`
  • `uv run mypy` clean on touched modules (only pre-existing `astra.helpers` stub gap remains)
  • End-to-end `lc run` on a sample project locally (`LocalCluster` path)
  • End-to-end `lc run` inside a Perlmutter `salloc` (srun-workers path)
  • `lc verify` against manifests produced by Dask-driven runs

Open questions / not in this PR

  • Heterogeneous nodes — first version assumes all nodes in an allocation share the same `cpus_per_node` / `mem_per_node`. If a site needs heterogeneous shapes we'll need to launch workers per node-type.
  • Memory enforcement is advisory — Dask's `resources={"memory": ...}` is a soft scheduling constraint, not a cgroup cap. SLURM's per-allocation memory cgroup is the backstop on HPC.
  • Worker-per-node sizing — fixed at one worker per node with `--nthreads = cpus_per_node`. Could expose tunables if a workload wants more workers per node, but starting simple.
  • External input hashing default — `(mtime, size)` for now; `--strict-inputs` for sha256 mode.
  • slurm-jobstep revisit — if the upstream plugin gets a stable, user-facing standalone-executor contract, the case for swapping out the Dask layer becomes strong.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 26, 2026

✅ Eval Results

Metric Value
Score 1.00
Build complete
Cost $1.39
Turns 61
Duration 534s
lightcone-cli 0.1.dev1+g9d7bd2242 (9d7bd224)
Results Download

Graders

✅ spec_valid (1.00)
✅ all_materialized (1.00)

Full output
80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:06 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:06 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:07 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:07 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:07 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:07 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:08 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:08 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:08 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:08 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:09 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:09 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:09 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:09 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:09 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:25:10 httpx HTTP Request: POST https://proxy.app.daytona.io/toolbox/8d5f472b-4be1-4dcc-80ab-63ac65abb96d/files/bulk-upload "HTTP/1.1 200 OK"
10:34:06 lightcone.eval.sandbox Deleted sandbox for trial build-snae-0
  snae trial 0: score=1.00 complete

lightcone-cli: 0.1.dev1+g9d7bd2242 (HEAD 9d7bd224)

  Eval Results: Scores  
┏━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Task ┃     Score     ┃
┡━━━━━━╇━━━━━━━━━━━━━━━┩
│ snae │ 1.00 +/- 0.00 │
│      │ pass@k: 100%  │
└──────┴───────────────┘

   Eval Results: Cost &   
         Duration         
┏━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃ Task ┃ Cost / Duration ┃
┡━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ snae │      $1.39      │
│      │      534s       │
└──────┴─────────────────┘

Total: 1 trials, $1.39, 534s

Results saved to: eval-results/build-9d7bd224/results.json

EiffL and others added 3 commits April 26, 2026 22:38
Replace the vendored snakemake-executor-plugin-lightconeflux + srun-flux-start
wrap with a Dask-based stack: a new vendored snakemake-executor-plugin-
lightconedask that submits each rule's shell command via client.submit, plus
lightcone.engine.dask_cluster.cluster_for_run that produces a scheduler
address for the run's lifetime.

Rationale:
- pip-installable everywhere (no `module load flux`, no PMI bootstrap
  dependency, no system-binary requirement)
- One execution code path covers laptop, workstation, and SLURM allocation:
  LocalCluster locally, srun-launched workers across nodes inside an
  allocation, or an existing scheduler if DASK_SCHEDULER_ADDRESS is set
- Resource translation is first-class: snakemake's threads/mem_mb/gpus map
  cleanly to Dask's per-task resources; the scheduler bin-packs tasks into
  per-node workers up to advertised cpu/memory/gpu budgets

Trade: Dask doesn't offer Flux's hierarchical sub-allocation scheduling,
which we weren't using anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ecutor_plugin_dask

The plugin has nothing lightcone-specific in it — it's a generic
dask.distributed executor for snakemake. Drop the project prefix so the
discovery name (`--executor dask`) is clean and the package is reusable
outside lightcone-cli if anyone wants. Confirmed no upstream
`snakemake-executor-plugin-dask` exists on PyPI or GitHub at rename time.

Class renamed: LightconeDaskExecutor → DaskExecutor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread redesign.md Outdated
@cailmdaley
Copy link
Copy Markdown
Member

Two questions on the cluster execution section, since they seem to pull in different directions:

  1. Headline vs. body. The opening philosophy and bottom line both frame this as "Snakemake + manifests, not Dagster + Dask + Postgres" — but the cluster section reintroduces Dask as the inner execution substrate via a custom snakemake-executor-plugin-dask. Worth making explicit up front that Dask stays (just in a much thinner role), since right now the doc reads as if it were deleted.

  2. Is Dask actually necessary? Snakemake's own executor abstraction seems to cover the cases:

    • local for laptop/workstation (--cores N)
    • slurm-jobstep for inside an existing allocation — the pilot-job pattern, dispatches each rule as srun --jobid=$SLURM_JOB_ID …
    • slurm for per-rule sbatch

    Profiles pick between them; user surface stays uniform.

    For the multi-node-within-one-allocation case in particular: can't Snakemake handle this in non-Slurm mode directly (e.g. local with high --cores, or slurm-jobstep) rather than going through a Dask scheduler + workers? Adding a Dask orchestration layer underneath Snakemake feels like it's in tension with the doc's own opening principle ("Snakemake does what Snakemake does, we don't reimplement"). Curious what the Dask layer is doing that Snakemake's native executors can't.

EiffL and others added 4 commits April 27, 2026 16:09
- Default --rerun-triggers now includes `params` so per-universe
  code_version drift in cfg actually triggers Snakemake reruns; embed
  code_version literally into shell_command as belt-and-braces.
- Bind the SLURM-mode Dask scheduler to SLURMD_NODENAME (or
  gethostname() fallback) instead of 127.0.0.1, so workers on remote
  nodes can connect.
- Narrow read_manifest to swallow only JSONDecodeError; propagate
  OSError so permission failures don't masquerade as missing manifests.
- Stop closing the Dask client in cancel_jobs — Snakemake calls it for
  partial cancellations and subsequent submissions need the client.

Adds five regression tests covering each fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LocalCluster now advertises memory and gpus alongside cpus, so rules
  with mem_mb / gpus_per_task are schedulable on a workstation.
- code_version hashes the resolved (content-addressed) image tag, so
  Containerfile or lockfile edits propagate to lc status. The same
  resolution is now used by snakefile.generate and status.get_output_status
  via a memoizing make_image_tag_resolver helper.
- verify resolves qualified-id inputs (e.g. sub.foo) through the tree
  via a shared find_upstream_output helper, so cross-analysis chain
  drift surfaces as broken_chain instead of being silently skipped.
- lc run --force scopes to explicit targets; --forceall is used only
  when no targets were named.
- validate_output is wired into the rule body; output_type flows
  through cfg so empty / all-NaN / wrong-extension outputs surface
  as warnings.
- Drop unused snakemake-executor-plugin-slurm dependency.
- _target_for accepts qualified analysis.output ids and errors
  clearly on ambiguity; uses MANIFEST_FILENAME and resolve_output_path.
- Tighten the recommended permission tier (rm -rf * / rm -fr *).
- Collapse _input_path_for onto find_upstream_output; factor
  _resource_dict shared by _local_cluster and _resources_arg;
  hoist the rule-body validation block to a constant.

216 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Snakemake redesign removed the target-selection layer (engine/targets.py
deleted in c2e04a6), but `default_target: local` was still being written into
new ~/.lightcone/config.yaml files by `lc setup`, by the eval sandbox bootstrap,
and by the test fixture — and the Claude guides still documented a long-gone
`lc target` command, `lc dev`, `--qos`/`--constraint`/etc. SLURM-target
overrides, and the "Choosing run options" section that depended on the removed
target system. None of it is read by current code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@EiffL
Copy link
Copy Markdown
Member Author

EiffL commented Apr 28, 2026

@cailmdailey, from Claude:

What I found

slurm-jobstep exists and does roughly what the reviewer described — but with significant qualifications:

  1. Officially marked "internal use only." The repo description says verbatim: "meant for internal use by
    snakemake-executor-plugin-slurm." The blessed deployment is: main slurm plugin runs on a head node, submits each rule via sbatch, and the inner sbatch wrapper invokes slurm-jobstep to do the actual srun -n1 --cpu-bind=q …. The maintainers do not officially endorse using it as a standalone executor for the pilot-job pattern.
  2. You can use it standalone — the code reads SLURM_JOB_ID and dispatches srun -n1 --cpu-bind=q [cmd], which is exactly the pilot-job behavior. People in the community do this. But you're off the supported path; nothing prevents the maintainers from breaking that use case.
  3. The main slurm executor explicitly warns against running inside an allocation. Snakemake docs say: "Running Snakemake within an active SLURM job can lead to unpredictable behavior." The intended pattern is head-node submission, not in-allocation dispatch.
  4. Known footgun: SLURM 22.05+ broke --cpus-per-task inheritance. srun no longer inherits cpus_per_task from the surrounding sbatch/salloc — it must be re-requested or set via SRUN_CPUS_PER_TASK. This is a real correctness issue if the executor doesn't handle it explicitly. There's an open issue (snakemake-executor-plugin-slurm#41) about this.
  5. Open issue: "Executing with --slurm always runs tasks with 1 core" (#2447). Suggests the per-rule resource translation has rough edges in production.
  6. Robustness signals — package is on bioconda, active maintenance, version in the last 12 months, no Snyk vulnerabilities. No public production-scale references I could find. Maturity is "fine for the documented use case", less reassuring for the off-label pilot-job use.

Honest read: the reviewer's principle is right (don't reimplement what Snakemake does), but the specific recommendation oversells slurm-jobstep's maturity for standalone pilot-job use. The strongest version of the critique would be: try slurm-jobstep first, keep the Dask layer as a fallback only if slurm-jobstep proves unreliable in your testing. That's a defensible middle ground — the redesign would prove out the in-tree path before committing to bespoke code.

@EiffL EiffL changed the title Snakemake-based execution layer + content-addressed manifests + Flux pilot Snakemake-based execution layer + content-addressed manifests + Dask substrate Apr 28, 2026
@EiffL EiffL requested review from aboucaud and dkn16 and removed request for aboucaud April 28, 2026 07:17
@EiffL EiffL marked this pull request as ready for review April 28, 2026 07:28
@EiffL EiffL requested review from aboucaud and removed request for dkn16 April 28, 2026 08:43
Comment thread claude/lightcone/guides/lightcone-cli-reference.md
Comment thread src/lightcone/cli/commands.py
EiffL and others added 2 commits April 28, 2026 11:49
NERSC's home and CFS filesystems are mounted via Cray DVS, which
doesn't implement llistxattr. Buildah's copier (used by podman /
podman-hpc) calls it on every COPY source and crashes with EPROTO
when the project lives on DVS. Stage the Containerfile, dependency
files, and parsed COPY/ADD sources into a tempdir before invoking
the runtime. compute_image_tag and the staging logic share one
iterator so the hashed and staged file sets cannot drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EiffL and others added 2 commits April 30, 2026 10:46
The redesign lost the Containerfile that `lc init` used to write, and
the boilerplate `astra.yaml` had drifted into a parallel spec template
maintained inside lightcone-cli.

`lc init` now calls `astra.cli.init` for the spec scaffold (astra.yaml,
universes/baseline.yaml, base .gitignore, src/) and only owns the
lightcone-specific layer on top:

- patches `container: python:3.12-slim` → `container: Containerfile`
- writes Containerfile + requirements.txt
- appends lightcone-specific `.gitignore` lines
- writes `.lightcone/lightcone.yaml`, `.claude/`, CLAUDE.md, results/

Bumps astra-tools to >=0.2.5 (the release that ships the `container:`
field in the boilerplate). Bundles assorted local edits to engine
modules, tests, and skill guides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first lc invocation now writes ~/.lightcone/config.yaml with
defaults if missing, replacing the "Run lc setup first" gate. The
setup command was non-interactive and only created the same file,
so it's removed.

Also brings README, lightcone-cli-reference, and CLAUDE.md in line
with the current Snakemake/Dask CLI surface (drops stale lc dev,
lc target, --qos/--time-limit/--strategy, and --sub-analysis docs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@EiffL EiffL merged commit 6b45e42 into main Apr 30, 2026
5 of 6 checks passed
EiffL added a commit that referenced this pull request Apr 30, 2026
## Summary
- Realign `lc-new`, `lc-migrate`, `lightcone-cli-reference.md`,
`astra-reference.md`, and the project `CLAUDE.md` template with the
current ASTRA spec, scaffold, and CLI surface
- Drop `lc-build`, `lc-verify` skills and `ui-brand.md` guide;
consolidate skill set to `lc-new`, `lc-migrate`, `lc-feedback`
- Wire the four project hooks into `.claude/settings.json` on `lc init`
(previously bundled but never invoked)
- Rewrite `check-lc-run.sh` as pure bash + jq (was importing
`lightcone.engine.status` from a Python heredoc, which silently no-op'd
in the empty venv `lc init` creates). Add `recipe_command` to
`OutputStatus` and surface it in `lc status --json` so the rewritten
hook can match agent invocations against recipe scripts via jq.
- Fix `session-start.sh` validation preview (`head -5` always landed
before the real error block), drop a dead `decision_count` line, and
remove the lc-build loop block

This PR replaces #97, which had merged #82 history conflicts. This is
rebased cleanly on `main` with the same scope.

## Test plan
- [x] `uv run pytest` — 287 passed
- [x] `uv run ruff check src/ tests/` — clean
- [ ] Manual: `lc init` in a fresh dir, confirm hooks land in
`.claude/settings.json` and trigger as expected
- [ ] Manual: edit a malformed `astra.yaml`, confirm `validate-on-save`
surfaces real errors
- [ ] Manual: run a recipe script directly, confirm `check-lc-run` warns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Francois Lanusse <EiffL@users.noreply.github.com>
@aboucaud aboucaud deleted the snakemake-redesign branch May 8, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants