Compare commits

...
Author SHA1 Message Date
Emil 8c5350abdf Filter release artifacts to binaries in the download step
coordinator / test (push) Canceled after 0s
python / test (push) Canceled after 0s
release / binaries (amd64, darwin) (push) Canceled after 0s
release / binaries (amd64, linux) (push) Canceled after 0s
release / binaries (amd64, windows) (push) Canceled after 0s
release / binaries (arm64, darwin) (push) Canceled after 0s
release / binaries (arm64, linux) (push) Canceled after 0s
release / binaries (arm64, windows) (push) Canceled after 0s
release / image (push) Canceled after 0s
users / test (push) Canceled after 0s
release / release (push) Canceled after 0s
2026-08-02 18:48:57 +03:00
Emil 9fe81bc531 Use golang-migrate-compatible watermark tracking in migrations 2026-08-02 18:37:15 +03:00
Emil b281d5811a Fix migration path in the coordinator CI workflow 2026-08-02 18:32:20 +03:00
Emil 771952e22e Fix golangci-lint findings across the coordinator and agent 2026-08-02 18:23:43 +03:00
Emil 565d4466e4 Run gofmt on the setup wizard and migration test 2026-08-02 18:16:44 +03:00
Emil 59e7fb0155 Add the coordinator setup wizard for self-provisioned databases 2026-08-02 18:14:36 +03:00
Emil 320f52615e Embed schema migrations and self-provision the schema on startup 2026-08-02 18:04:00 +03:00
Emil f2977a990e Add versioned release builds for coordinator and worker 2026-08-02 17:54:06 +03:00
Emil 5665e7df98 Document workload-declared UI and the upload contract 2026-08-02 17:47:52 +03:00
Emil 361eb2e344 Advertise all catalog workloads from the worker by default 2026-08-02 17:47:52 +03:00
Emil 749396da05 Drive coordinator upload and reduction from the workload catalog 2026-08-02 17:47:47 +03:00
Emil 700a96a259 Load every enabled built-in workload in the worker by default 2026-08-02 17:47:47 +03:00
Emil 5d738e0a14 Add SDK-declared workload UI elements and reduction metadata 2026-08-02 17:47:43 +03:00
Emil a18b8b8ae4 Build the coordinator server as a static binary 2026-08-02 16:56:06 +03:00
Emil 706bc85e17 Replace Python worker daemon with the Go worker agent 2026-08-02 16:53:04 +03:00
Emil 9a8221163a Remove dead code and wire the two-worker smoke script 2026-08-02 16:36:05 +03:00
Emil 644c287002 Add Go worker agent prototype 2026-08-02 16:27:58 +03:00
Emil f20cc7fe00 Serve documentation from the operator UI 2026-08-02 15:50:07 +03:00
Emil 284aef5d6f Set up MkDocs Material documentation site 2026-08-02 01:22:45 +03:00
Emil f059ac626c Add workload library page to the operator UI 2026-08-02 01:18:03 +03:00
Emil 5c5a2af0a1 Add molwt-filter workload with default scaffold hooks 2026-08-02 01:09:13 +03:00
Emil bc76f386e5 Add MapReduceWorkload scaffold and generic workload execution 2026-08-02 01:02:24 +03:00
Emil 19fbb8e926 Replace legacy distributed protocol with SDK-built workloads 2026-08-01 23:57:41 +03:00
Emil 96169086f0 Add descriptor-batch SDK reference workload 2026-08-01 23:27:53 +03:00
Emil c43af32495 Add workload SDK foundation 2026-08-01 23:22:20 +03:00
Emil 11e9333033 Define generalized workload SDK contract 2026-08-01 16:30:25 +03:00
Emil 0a759a3f01 Update workload SDK roadmap 2026-08-01 16:17:19 +03:00
Emil b9a975b0ea Merge self-service worker enrollment 2026-07-27 22:28:04 +03:00
Emil dc75411907 Record user service integration 2026-07-27 22:23:34 +03:00
Emil 6cdc115d60 Merge user service and coordinator integration 2026-07-27 22:23:21 +03:00
Emil fa76133efc Secure user worker operations
coordinator / test (push) Canceled after 0s
users / test (push) Canceled after 0s
2026-07-27 22:23:08 +03:00
Emil 7d8998408c Merge branch 'main' into feat/users 2026-07-27 22:19:43 +03:00
Emil 87a483c2fb Plan user service integration 2026-07-27 01:39:26 +03:00
Emil f5ead0a450 Document team and scaling roadmap 2026-07-26 20:40:25 +03:00
194 changed files with 27169 additions and 3368 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
- name: apply migrations
run: migrate -path migrations -database "$TEST_DATABASE_URL" up
run: migrate -path internal/storage/postgres/migrations -database "$TEST_DATABASE_URL" up
- name: integration tests
run: go test -tags=integration ./internal/storage/postgres/ -v
+107
View File
@@ -0,0 +1,107 @@
name: release
# Builds static coordinator and worker-agent binaries for every major
# platform on a v* tag push, attaches them (plus SHA-256 checksums) to the
# GitHub Release, and pushes the coordinator image to GHCR.
#
# git tag v1.0.0 && git push origin v1.0.0
on:
push:
tags: ["v*"]
permissions:
contents: write
packages: write
jobs:
binaries:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin, windows]
arch: [amd64, arm64]
defaults:
run:
working-directory: coordinator
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: coordinator/go.mod
cache-dependency-path: coordinator/go.sum
- name: vet
run: go vet ./...
- name: build coordinator and worker-agent
env:
VERSION: ${{ github.ref_name }}
run: |
mkdir -p dist
for cmd in coordinator worker-agent; do
CGO_ENABLED=0 GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} \
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION#v}" \
-o "dist/${cmd}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }}" \
"./cmd/${cmd}"
done
- uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.os }}-${{ matrix.arch }}
path: coordinator/dist/*
if-no-files-found: error
release:
needs: binaries
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
# Only the binary artifacts: the image job also uploads a buildkit
# cache artifact (*.dockerbuild) that download-artifact cannot fetch.
pattern: binaries-*
merge-multiple: true
- name: checksums
working-directory: artifacts
run: sha256sum * > SHA256SUMS.txt
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}/coordinator
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: coordinator
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
+2
View File
@@ -16,3 +16,5 @@ test_structures/
worker-data*/
scimesh-worker-data/
coordinator/.demo/
site/
coordinator/bin/
+21
View File
@@ -0,0 +1,21 @@
COMPLETED
# Session Goal
давай теперь почистим проект от линего кода
## Plan
1. Аудит: ruff/pyflakes — неиспользуемые импорты по scimesh/ и tests/; grep — неиспользуемые функции/модули (после рефакторингов могли остаться мёртвые экспорты, например в descriptors/search/graph core и sdk/_validation).
2. Удаление мёртвого кода: неиспользуемые импорты, функции, дубли (например write_descriptor_shards/concatenate_descriptor_shards, если вытеснены дефолтами batch), устаревшие файлы-обёртки.
3. Проверка, что ничего публичного/API не сломано: pyright 0 ошибок, pytest зелёный, go test/vet, mkdocs build.
4. Финал: полный прогон, COMPLETED.
## Progress
Эта сессия (доп. задача): Go-агент доведён до паритета, Python-демон удалён.
- [x] Go-агент: token provider (static + worker-key exchange + 401 refresh на API/download/upload), CLEANUP_AFTER_SECONDS (очистка attempt-директорий), тесты auth (exchange/cache/reject/select/401-retry).
- [x] Python: удалены daemon.py, cli.py, config.py, coordinator.py, artifacts.py, auth.py, transport.py; `scimesh/worker/` = только task.py + runners.py (SDK-мост, allowlist из env) + models.py (ClaimedTask/RunResult); консольный скрипт scimesh-worker убран из pyproject.
- [x] Тесты: удалены test_worker_daemon.py, test_worker_auth.py; 208 pytest зелёные.
- [x] Демо/смок переведены на Go-агент (demo-ui.sh: build_agent + env; two-worker-smoke.sh: AGENT_BIN + TASK_RUNNER_JSON). `make smoke-two-worker` PASS: 4/4 шардов, worker-a=2, worker-b=2.
- [x] Документация: README, AGENTS.md, STATUS, handoff, mkdocs worker-integration/cli.
- [x] Верификация: ruff clean; 208 pytest; pyright 0 (scimesh+tests); go test 11 пакетов + vet; mkdocs 0 warnings.
+4 -2
View File
@@ -5,8 +5,10 @@
SciMesh is a Python package for molecular-similarity workloads. Source lives in
`scimesh/`: `chemistry/` reads data and makes fingerprints, `workloads/`
contains commands, and `core/` provides the workload protocol and registry.
The worker daemon in `scimesh/worker/` is a coordinator client, not a database
client. Tests are in `tests/`; specifications in `docs/`; roadmap: `PLAN.md`.
The Go worker agent (`coordinator/internal/agent/`) is a coordinator client,
not a database client; the Python side of a claimed task lives in
`scimesh/worker/` (the per-task SDK execution entry). Tests are in `tests/`;
specifications in `docs/`; roadmap: `PLAN.md`.
For distributed work, read `.agents/`, `docs/api-contract.md`,
and `STATUS.md`. Use one CTX task per pull request; local workloads are the
+25 -3
View File
@@ -1,19 +1,32 @@
.DEFAULT_GOAL := help
.PHONY: help demo-ui demo-down demo-logs
.PHONY: help agent coordinator demo-ui demo-down demo-logs smoke-two-worker docs docs-serve
help:
@printf '%s\n' \
'SciMesh developer commands:' \
' make demo-ui Start the local UI pipeline demo with 2 workers.' \
' make demo-ui WORKERS=3 Start the demo with 3 local workers.' \
' make agent Build the Go worker agent (coordinator/bin/worker-agent).' \
' make coordinator Build the coordinator server as a static binary' \
' (coordinator/bin/coordinator).' \
' make demo-ui Start the local UI pipeline demo with 2 Go worker agents.' \
' make demo-ui WORKERS=3 Start the demo with 3 workers.' \
' make demo-logs Follow coordinator logs for the demo.' \
' make demo-down Stop demo containers and workers.' \
' make smoke-two-worker E2E: two Go agents process 4 shards and the' \
' result must match the local CLI reference.' \
' make docs Build the MkDocs site into site/.' \
' make docs-serve Serve the MkDocs site at http://localhost:8000.' \
'' \
'After make demo-ui: open http://localhost:18080/ui (operator / demo-ui-secret).'
# Convenient entry points from the repository root. Extra settings are passed
# through, for example: make demo-ui WORKERS=3
agent:
$(MAKE) -C coordinator agent
coordinator:
$(MAKE) -C coordinator coordinator
demo-ui:
$(MAKE) -C coordinator demo-ui
@@ -22,3 +35,12 @@ demo-down:
demo-logs:
$(MAKE) -C coordinator demo-logs
smoke-two-worker:
./scripts/two-worker-smoke.sh
docs:
.venv/bin/mkdocs build
docs-serve:
.venv/bin/mkdocs serve
+231 -16
View File
@@ -5,11 +5,13 @@
> platform. It is intentionally detailed enough to split into independent task
> briefs for developers or coding agents.
>
> **Planning baseline.** This branch starts from `Workers`: the Python package
> has local `similarity-search` and `similarity-graph` workloads plus a Worker
> Daemon client. The coordinator and PostgreSQL implementation do not yet
> exist. The Worker contract and the Go/PostgreSQL design briefs in `docs/` are
> part of this plan.
> **Current planning baseline (2026-08-01).** The Go/PostgreSQL coordinator,
> Python Worker Agent, versioned distributed-workload protocol, artifact-backed
> task lifecycle, reducer orchestration, operator UI, User Service, and
> distributed `similarity-search` are implemented on `main`. The evidence-based
> completion tracker is [`STATUS.md`](STATUS.md); this document defines the
> remaining direction and dependencies. Earlier descriptions of a missing
> coordinator are historical context, not current work.
---
@@ -51,7 +53,7 @@ Coordinator reducer -> final artifact -> download/status API
### 2.1 In scope
- Go 1.22+ coordinator service with PostgreSQL 15+;
- Go 1.25+ coordinator service with PostgreSQL 15+;
- Python Worker Daemon running existing SciMesh workloads locally;
- durable job, task, worker, and artifact metadata;
- local coordinator-managed artifact storage for the first deployment;
@@ -66,7 +68,8 @@ Coordinator reducer -> final artifact -> download/status API
- cloud object storage, Kubernetes, autoscaling, and multi-region operation;
- arbitrary shell commands sent by coordinator to workers;
- user accounts, multi-tenancy, billing, or sophisticated authorization;
- billing and sophisticated multi-tenant administration beyond the implemented
User Service and owner scoping;
- GPU scheduling and multiprocessing inside a worker;
- Docker as a required runtime dependency;
- video/CV processing implementation;
@@ -490,14 +493,84 @@ brute-force graph for both `greater` and `less` threshold directions.
### 7.3 Future workload policy
A new workload is accepted only when it supplies:
A workload is more than a runner. It must define validation, planner and
reducer behavior, worker allowlist/capabilities, input and output artifacts,
UI/API parameters, reproducible execution environment, result verification,
golden cross-worker fixtures, and applicable resource limits. The future public
contract is described in [`docs/scimesh-sdk-roadmap.md`](docs/scimesh-sdk-roadmap.md).
Its normative future interfaces and execution semantics are defined in the
design-draft [`docs/scimesh-sdk-contract.md`](docs/scimesh-sdk-contract.md).
- an input/parameter validator;
- an explicit sharding strategy;
- bounded-memory task execution;
- deterministic reduction semantics;
- fixture-based local and distributed correctness tests;
- a `describe()` payload for UI/API discovery.
Every workload declaration must classify its task decomposition and input/output
artifact shapes, determinism, reduction semantics, verifier mode, supported
trust modes, CPU/memory/accelerator needs, and maximum output growth. The
initial profiles are:
| Profile | Current acceptance policy |
| --- | --- |
| Byte-exact deterministic | Supported for untrusted quorum when whole artifacts have identical SHA-256. |
| Canonical-exact deterministic | Deferred until the parser, schema, ordering, encoding, and serializer are versioned. |
| Numeric deterministic with tolerance | Deferred until structured numeric comparison exists. |
| Stochastic/search-based | Requires domain-specific evidence, repeated runs, or trusted execution. |
| Trusted-only or domain-verified | May be planned only with an explicit trust policy and verifier. |
The current untrusted quorum records one vote per owner and accepts a task only
when distinct owners upload artifacts with the same complete-file SHA-256. It
therefore supports only the byte-exact profile (or a workload that first makes
its output byte-identical through a specified canonicalization step). Reducers
must fail safely rather than silently merge inconsistent partial results.
Before a workload is admitted to untrusted execution it needs a reproducibility
gate: pinned environment/container digest and dependency versions; fixed locale,
timezone, UTF-8/newline/CSV settings; explicit invalid-row and algorithm
options; canonical representation and ordering; deterministic filenames/archive
metadata; golden fixtures from two independently provisioned workers; local vs
distributed parity; and retry/out-of-order completion tests. A loose dependency
constraint is insufficient for byte-exact quorum.
Near-term critical path:
```text
distributed similarity-graph
-> reliability, security, and cross-language CI
-> stable first release
-> SDK foundation and descriptor-batch
-> additional deterministic workloads
```
Initial deterministic-workload backlog: `descriptor-batch` (the first SDK
reference workload), molecule standardization, SMARTS screening, fingerprint
export, fixed-template SMIRKS enumeration with strict caps, and reaction
validation/descriptors. `similarity-graph` remains ahead of this backlog.
Bounded combinatorial libraries and seeded conformers need specialized controls.
ML, retrosynthesis, docking, QM, molecular dynamics, and GPU workloads are
deferred until verifier/trust and reproducibility requirements are met.
### 7.4 Future verification, concurrency, and accelerators
Verification is a future versioned workload capability, not permanent
whole-file-SHA logic. Planned modes are `ExactArtifactVerifier`,
`CanonicalRecordVerifier`, `NumericToleranceVerifier`, `DomainSpecificVerifier`,
and `TrustedWorkerPolicy`. Exact SHA-256 remains the first and safest mode;
canonical and numeric modes must compare bounded structured data and publish
sanitized evidence and failure reasons.
Worker concurrency remains **1** until implemented and tested. Its target model
is one physical machine running one Worker Agent with `N` execution slots and
one isolated subprocess per active Task, rather than one registered worker per
CPU core. `max_concurrency` must be separate from `cpu_count`; each task keeps
its own heartbeat, attempt directory, lease lifecycle, resource request, and
graceful-drain behavior. CPU-bound scientific code should use processes and
avoid nested oversubscription.
Accelerator support is also deferred. The coordinator matches generic resource
requirements; the Worker Agent discovers and isolates devices (including
`CUDA_VISIBLE_DEVICES`) and owns process/accounting lifecycle; the Python
workload owns batching, memory strategy, deterministic output, and scientific
validation; reducers/verifiers define CPU/GPU-independent semantics. CUDA and
scientific kernels do not belong in the Go coordinator. GPU work follows stable
CPU slices, generic resource requirements, pinned worker images, and tested
CPU/GPU or domain-valid equivalence.
---
@@ -820,6 +893,138 @@ CTX-09 enables final result downloads.
- failure/retry scenarios have automated coverage;
- README contains architecture diagram, security caveat, and troubleshooting.
### CTX-13 — In-worker CPU parallelism
**Goal:** Allow a worker to use a bounded, configured number of CPU threads or
processes while preserving the existing one-task-per-lease coordinator model.
**Depends on:** CTX-12.
**Acceptance criteria:**
- worker concurrency is an explicit configuration value with a safe default of
one;
- a task's internal parallel execution has bounded memory and does not build a
dense N×N similarity matrix;
- CPU-parallel `similarity-search` and `similarity-graph` outputs match the
single-threaded local reference byte-for-byte where ordering is observable;
- result ordering is deterministic across worker counts and block sizes;
- cancellation, lease loss, and worker failure stop child work safely and do
not report a successful result;
- benchmarks and tests cover one-worker and multi-worker configurations.
### CTX-14 — GPU-accelerated workload execution
**Goal:** Add an optional GPU execution backend for supported molecular
workloads, while retaining the validated CPU implementation as the reference
and fallback.
**Depends on:** CTX-13.
**Acceptance criteria:**
- GPU capability and backend version are advertised explicitly by a worker;
- the coordinator schedules GPU work only to compatible workers and CPU-only
workers continue to claim CPU tasks;
- unsupported hardware, unavailable drivers, and GPU execution errors produce
sanitized failures or a documented CPU fallback;
- GPU results match the CPU reference within a documented, tested numerical
tolerance and preserve deterministic output ordering;
- GPU memory use is bounded and no dense N×N similarity matrix is created;
- CPU-only CI verifies backend selection and contract behavior, with GPU
integration tests documented for compatible runners.
### CTX-15 — User Service and access control
**Goal:** Introduce a dedicated User Service for user identity and access
control, without coupling workers to user credentials or moving scientific
workload logic into the service.
**Depends on:** CTX-12.
**Acceptance criteria:**
- the service has a versioned, documented API in
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and
owns user identity data;
- credentials and authentication tokens are stored and handled securely; they
are never logged or exposed to workers;
- authenticated identity is propagated to coordinator requests through an
explicit, validated boundary;
- authorization restricts access to jobs and artifacts to the intended user or
project;
- unauthenticated, expired-token, and cross-user access attempts have
automated failure tests;
- the existing single-operator demo remains usable through a documented local
development configuration.
### CTX-16 — Workload SDK foundation
**Goal:** Provide a strict Python authoring SDK for installed, allowlisted
scientific workloads while retaining the CTX-07 distributed protocol as a
compatible wire profile.
**Depends on:** CTX-07 and CTX-08. Coordinator-backed generalized scheduling
also depends on CTX-10 through CTX-14, but the Python contract and local
conformance runtime can land independently and must fail closed for unavailable
features.
**Acceptance criteria:**
- public manifest, workflow, task, artifact, resource, execution, provenance,
and verifier value objects are immutable, typed, JSON-safe, versioned, and
strict about unknown fields;
- installed workload discovery requires an administrator allowlist plus exact
workload version and package digest; job parameters cannot select code;
- compatibility negotiation covers SDK/protocol/profile/feature/environment
versions and occurs before planner invocation;
- plans/tasks pin package and manifest digests plus selected trust mode, and
quorum candidates carry coordinator-owned candidate/owner and scientific
binding identities;
- `core-batch-v1` has a trusted local conformance executor with atomic resource
reservation, sealed-output/provenance validation, declared verifier
invocation, and golden scientific parity;
- exact, canonical-record, and structured numeric-tolerance verifier
primitives return bounded sanitized decisions;
- the existing distributed `similarity-search` is available through an adapter
without changing its wire schema, worker alias boundary, or scientific
result, and parity is tested;
- advanced dynamic, stream, accelerator, gang, and side-effect profiles are
rejected unless an enforcing runtime advertises their required features;
- an author guide documents package entry points, security boundaries,
conformance tests, and current coordinator/Worker limitations.
---
### CTX-17 — Self-provisioning coordinator and setup wizard
**Goal:** A downloaded coordinator binary should bring up a working platform
with as little manual configuration as possible: it provisions its own
schema, and a `setup` command walks the operator through the remaining
environment (database creation, secrets, admin account).
**Depends on:** the Go coordinator and the release build pipeline. Step 1
(embedded migrations, `AUTO_MIGRATE`) and step 2 (the `coordinator setup`
wizard: database reachability and creation, schema migration, `.env` with a
generated `JWT_SECRET`, readiness summary) are implemented; step 3 — a fully
embedded userservice — is the remaining work.
**Acceptance criteria:**
- the binary embeds the migrations and applies pending ones on startup by
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
idempotent and safe under concurrent starts;
- `coordinator setup` (interactive, then non-interactive with `--yes`) checks
database reachability, offers to create the role/database when credentials
allow it, applies migrations, writes a `.env` with a generated `JWT_SECRET`
and storage path, and prints exact next steps;
- `coordinator --version` and the setup output agree on the release build;
- the wizard explains what it cannot do itself: running PostgreSQL and the
userservice, with concrete commands (docker compose, systemd) to finish;
- the Docker image keeps working without the separate migrate step, and the
release workflow publishes the binaries that support `setup`;
- setup fails closed on non-interactive input and never logs secrets.
---
## 10. Suggested assignment bundles
@@ -835,6 +1040,7 @@ parallel unless one engineer owns integration.
| Distributed computation | CTX-07, CTX-08, CTX-10 | Scientific Python engineer |
| Product surface | CTX-09, CTX-11 | Full-stack/backend engineer |
| Quality gate | CTX-12 | DevOps/QA engineer |
| Workload SDK | CTX-16 | Scientific Python/platform engineer |
Suggested order for a small team:
@@ -948,15 +1154,24 @@ Before merging a task, reviewer checks:
Do not start these before CTX-12 is accepted.
- Replace local artifact storage with S3/MinIO behind an `ArtifactStore` API.
- Add worker labels/capacity-aware scheduling and concurrency > 1.
- Add worker labels and capacity-aware scheduling.
- Implement CTX-13 for bounded in-worker CPU parallelism.
- Implement CTX-14 for optional GPU-accelerated workload execution.
- Implement CTX-15 for the User Service and authenticated user/project access.
- Add cancellation propagation to workers.
- Add image outputs and final PDF reporting to job artifacts.
- Add CV/video workloads using the same planner/runner/reducer contract.
- Add observability export (Prometheus/OpenTelemetry).
- Add per-user/project authorization and signed artifact URLs.
- Add signed artifact URLs.
- Add shard caching and content-addressed input deduplication.
- Add job priority and fair scheduling.
- Add a CLI for submitting and monitoring remote jobs.
- Implement CTX-17 step 3: a fully embedded userservice
(`coordinator userservice` subcommand) so one binary can serve the whole
platform without containers.
- Replace PostgreSQL with an embedded SQLite backend for fully self-contained
single-binary deployments (large storage-layer change; postgres row locks,
transactions, and integration tests must be re-derived).
---
+48 -5
View File
@@ -3,7 +3,8 @@
SciMesh is a scientific-workload framework for molecular datasets. Its public CLI
runs exact similarity search and sparse similarity-graph construction locally in
one Python process; it creates no dense similarity matrix. The Go/PostgreSQL
coordinator and Python worker can run a shard-based `similarity-search`
coordinator and Go worker agents (which execute SDK workloads in a Python
subprocess) can run a shard-based `similarity-search`
pipeline locally. After every shard succeeds, the coordinator deterministically
merges its candidates into one final global top-k CSV. See
[`STATUS.md`](STATUS.md).
@@ -56,12 +57,24 @@ python3 -m venv .venv
make demo-ui
```
The MkDocs documentation site is served inside the UI at `/ui/docs/`
(`make docs` builds it from `mkdocs/`; the demo mounts `site/`
automatically, or set `SCIMESH_DOCS_DIR` for a manual coordinator). The site
covers the complete Workload SDK: guides (`mkdocs/sdk/`), the full
auto-generated API reference for `scimesh.sdk` (`mkdocs/api/`), and the
documentation rules the site is written by (`mkdocs/approach.md`).
Open `http://localhost:18080/ui` and sign in with username `operator` and
password `demo-ui-secret`. The command starts PostgreSQL, the coordinator, and
two local reference workers. Upload a small ChEMBL TSV, then use the job page
two Go worker agents (built by `make agent`; each executes the SDK workload
in a Python subprocess). Upload a small ChEMBL TSV, then use the job page
to follow shard progress, inspect bounded **Preview CSV** results, and see a
live processing-speed chart in shards per minute. To change the worker count,
run `make demo-ui WORKERS=3`; stop everything with `make demo-down`.
live processing-speed chart in shards per minute. The **Workloads** page shows
the installed SDK workload library (descriptions, parameters, and artifact
schemas) from the embedded catalog; regenerate it with
`make workloads-export` (or `scimesh workload export`) whenever workloads
change. To change the worker count, run `make demo-ui WORKERS=3`; stop
everything with `make demo-down`.
Run `make help` to display these commands in the terminal.
@@ -133,9 +146,39 @@ pytest
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
## Workload SDK
`scimesh.sdk` is the framework only: strict and immutable workload manifests,
typed artifact ports, static map/reduce plans, resource eligibility and local
reservations, exact/canonical/numeric verifier primitives, installed-package
allowlisting, and a local conformance executor. It contains no scientific
workload code. Workloads are user scripts built on the SDK: the built-in
`similarity-search`, `similarity-graph`, and `descriptor-batch` live in
`scimesh/workloads/` (each a small package with `core.py` + `definition.py`),
composed by `scimesh/workloads/library.py` and registered through
`scimesh.workloads` entry points. The Worker Agent executes those SDK-built
workloads directly (see `scimesh/worker/runners.py`), so the same scientific
handlers run locally, in conformance, and on claimed coordinator tasks.
`scimesh workload list` and `scimesh workload run` run any SDK workload from
the command line. See the
[SDK author guide](docs/workload-sdk.md), [contract](docs/scimesh-sdk-contract.md),
and [delivery roadmap](docs/scimesh-sdk-roadmap.md).
Dynamic workflows, real Worker concurrency, coordinator-backed GPU allocation,
streaming, and gang execution remain fail-closed until their versioned runtime
features are implemented; declaring those profiles does not silently enable
them.
The included `LocalCoreBatchExecutor` is a trusted, single-threaded in-process
conformance harness. It validates scientific parity, sealed outputs, provenance,
and limits, but intentionally refuses profiles that claim network/process
isolation, secrets, accelerators, gangs, checkpoints, or retries; those require
the future enforcing Agent runtime.
## Team
- [Emil](https://github.com/emil28092005) — Project Lead
- [Kristina](https://github.com/kristtma) — Tech Lead
- [Veniamin](https://t.me/Veniamin_Kt) — Scientific Lead
- [Arkhip](https://github.com/hIpa-ussr) — Programmer
- [Makar](https://github.com/RERAN4K) — Programmer
- [Reranchik](https://github.com/RERAN4K) — Programmer
+35 -7
View File
@@ -1,7 +1,7 @@
# SciMesh Status
**Updated:** 2026-07-24
**Branch baseline:** `main` at `6e67daa` (distributed similarity-search)
**Updated:** 2026-08-01
**Branch baseline:** `main`; this revision adds the Workload SDK foundation.
## Current state
@@ -23,6 +23,14 @@ are reduced once into a checksum-protected final CSV, which is downloadable
through the coordinator. The full Go checks (including a fresh migration and
real PostgreSQL smoke test) passed on 2026-07-24.
The User Service is merged into `main`. It owns user accounts, authentication,
roles, and verified-contributor status; the coordinator scopes user jobs and
worker operations to the authenticated owner. Its documented v1 contract is in
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md).
Users can create and revoke worker keys for self-service Worker Agent
enrollment. Untrusted workers require quorum agreement from distinct owners on
the complete result-artifact SHA-256 before a task is accepted.
## Milestone tracker
| CTX | Status | Notes |
@@ -33,13 +41,20 @@ real PostgreSQL smoke test) passed on 2026-07-24.
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency. |
| CTX-04 Worker registry and HTTP API | Implemented | Registration, claim, heartbeat, result, failure, and status endpoints. |
| CTX-05 Artifact storage | Implemented | Coordinator-owned inputs/results, checksum verification, and upload flow. |
| CTX-06 Python Worker live-contract alignment | Implemented | Worker completed a real uploaded shard via HTTP on 2026-07-23. |
| CTX-06 Python Worker live-contract alignment | Superseded | The Python worker daemon was removed; the Go worker agent (`coordinator/internal/agent/` + `cmd/worker-agent`) now implements the lifecycle (register/claim/heartbeat/download/upload/submit/fail, token refresh, cleanup) and executes SDK workloads via the Python task entry `scimesh/worker/task.py`. E2E: `make smoke-two-worker` passes 4/4 shards with two agents. |
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering are in `scimesh/distributed/`. |
| CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. |
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, and bounded polling. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload already enforces the pair-coverage invariant. |
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: MkDocs documentation served at `/ui/docs/` (SCIMESH_DOCS_DIR; the demo mounts `site/` automatically), recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, bounded polling, a Workload library page rendering the embedded catalog from `scimesh workload export` (`/ui/workloads`, regenerated via `make workloads-export`). |
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, self-service enrollment, and quorum-backed untrusted workers are merged; local Go/Python and Docker/PostgreSQL checks passed. |
| MkDocs documentation site | Implemented | A standalone documentation site (`mkdocs/`, `docs_dir: mkdocs`) covering the complete Workload SDK: guides (overview, authoring workloads, CLI, worker integration), the full auto-generated API reference for all 15 `scimesh.sdk` modules (mkdocstrings), and the writing rules (`mkdocs/approach.md`). Built with `make docs`, served inside the UI at `/ui/docs/`; the project's internal `docs/` directory is not part of the site. |
| CTX-16 Workload SDK foundation | Implemented | `scimesh.sdk` provides strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, typed DAGs, compatibility negotiation, verifier primitives with owner/binding-safe quorum inputs, resource eligibility/local allocation, measured package discovery, a trusted local core-batch conformance harness, and strict package discovery. Enforcing coordinator/Worker profiles remain fail-closed. |
| SDK roadmap step 3: `descriptor-batch` | Implemented | The first SDK-built reference workload (`scimesh/workloads/descriptors/`): pinned 81-name RDKit 2D descriptor set, canonical one-row-per-input CSV, deterministic row-bounded shards, shard-index concatenation with one header, byte-identical local/distributed output, and a two-worker `untrusted_quorum` verifier test. |
| SDK-built `similarity-search` and `similarity-graph` | Implemented | Both workloads are SDK-built packages (`scimesh/workloads/search/`, `scimesh/workloads/graph/`) built on the `MapReduceWorkload` authoring scaffold (`scimesh/sdk/batch.py`); they reuse the local scientific cores and are byte-identical to the single-process references (search; graph for both threshold directions and any block size). The graph reducer enforces the CTX-10 pair-coverage invariant. `scimesh/workloads/library.py` composes the built-in registry/runtime. |
| SDK-built `molwt-filter` | Implemented | The minimal authoring example (`scimesh/workloads/molwt_filter/`): filters molecules by exact RDKit molecular weight with only one scientific hook, using the scaffold's new default sharding and concatenation hooks. Registered in the built-in library and as a `scimesh.workloads` entry point. |
| SDK authoring scaffold | Implemented | `MapReduceWorkload` (exported from `scimesh.sdk`) assembles manifest, map/reduce stages, workflow, and digest-pinned handlers from three scientific hooks (partition/compute/merge), with overridable hooks for domain validation, plan-time resolution, custom task planning, and partial-key policy. The generic `scimesh workload list|run` CLI and the worker's allowlist-driven loading (`SCIMESH_WORKLOAD_ALLOWLIST`, `SCIMESH_CAPABILITIES`) let new workloads run without touching other code. |
## Next recommended assignment
@@ -48,14 +63,27 @@ block-pair planning and reduction for `similarity-graph`.
## Known constraints
- The worker/coordinator flow currently accepts both underscore API workload
names and hyphenated CLI names while the contract is consolidated.
- The worker/coordinator flow accepts both underscore API workload names and
hyphenated names at the runner boundary; the runner normalizes them.
- The worker executes SDK-built workloads through `scimesh/worker/runners.py`
(a workload-generic v1-wire bridge over `TaskSpec`/`LocalTaskContext`);
`query_id` resolution and parameter validation live in the workload itself.
`max_rows` is a plan-time option and is rejected per task by the stage
projection.
- A real-stack worker test uses a small `query_smiles` shard. The Python
planner resolves `query_id` once and shares `query_smiles`; the upload UI
currently accepts `query_smiles` only.
- The coordinator accepts uploaded distributed jobs only for
`similarity-search` with `query_smiles`. It rejects `similarity-graph` until
CTX-10 supplies cross-shard pair planning.
- The SDK can execute `core-batch-v1` locally, but the protocol-v1 coordinator
still has flat single-input/single-result tasks and no package/resource
leases. General DAG, concurrent-Agent, GPU, stream, and gang execution needs
a versioned coordinator/Worker rollout; unsupported features fail before
planner invocation.
- The local SDK executor is intentionally trusted and in-process. It does not
enforce process/network/timeout/credential isolation and rejects declarations
that would require those guarantees.
## Update rule
+4 -1
View File
@@ -16,6 +16,9 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# Release builds inject the tag via build-arg; local builds stay "dev".
ARG VERSION=dev
# The cache mounts persist the module cache and the compiler's build cache
# *across* builds, so a rebuild after a code edit recompiles only what changed
# instead of the whole dependency tree.
@@ -25,7 +28,7 @@ COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags="-s -w" \
-trimpath -ldflags="-s -w -X main.version=${VERSION#v}" \
-o /out/coordinator ./cmd/coordinator
# --- runtime stage --------------------------------------------------------
+39 -3
View File
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke demo-ui demo-down demo-reset demo-logs
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator setup workloads-export demo-ui demo-down demo-reset demo-logs
# `check` deliberately uses its own Compose project and host ports. This keeps
# it from connecting to or replacing a developer's local PostgreSQL instance.
@@ -26,6 +26,40 @@ DEMO_WORKERS ?= 2
WORKERS ?= $(DEMO_WORKERS)
DEMO_DIR ?= .demo
# workloads.json is the UI workload catalog, generated from the Python SDK
# workload library. It is checked in so the binary embeds it; regenerate it
# whenever workloads or their manifests change (requires the Python venv).
WORKLOADS_JSON := internal/workloads/workloads.json
# Version injected into the binaries via -ldflags; falls back to "dev".
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
# The Go worker agent: a static coordinator client that executes SDK
# workloads in a Python subprocess per claimed task.
agent:
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/worker-agent ./cmd/worker-agent
@printf '%s\n' 'Built bin/worker-agent. Configure via environment:' ' COORDINATOR_URL, WORKER_AUTH_TOKEN, WORK_DIR, CPU_COUNT, MEMORY_MB,' ' POLL_INTERVAL, REQUEST_TIMEOUT, HEARTBEAT_INTERVAL, CAPABILITIES,' ' TASK_RUNNER, MAX_TASKS, EXIT_WHEN_IDLE, WORKER_NAME, WORKER_ID'
# The coordinator server as a static binary, the same way the Docker image
# builds it (CGO_ENABLED=0, trimmed). Requires PostgreSQL at runtime.
coordinator:
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/coordinator ./cmd/coordinator
@printf '%s\n' \
'Built bin/coordinator. Configure via environment:' \
' DATABASE_URL, COORDINATOR_ADDR, COORDINATOR_TOKEN, UI_AUTH_TOKEN,' \
' COORDINATOR_STORAGE_DIR, SCIMESH_DOCS_DIR, JWT_SECRET, USERSERVICE_URL' \
' (embedded schema migrations run on startup; AUTO_MIGRATE=false disables)'
# Interactive wizard: checks the database, creates it when missing (via
# POSTGRES_ADMIN_URL or --admin-db), applies the embedded schema, generates a
# JWT_SECRET, and writes a .env file. Non-interactive: SETUP_ARGS=--yes.
setup: coordinator
./bin/coordinator setup $(SETUP_ARGS)
workloads-export:
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
help:
@printf '%s\n' \
'SciMesh coordinator commands:' \
@@ -34,6 +68,8 @@ help:
' make demo-logs Follow coordinator logs for the UI demo.' \
' make demo-down Stop the demo services and workers.' \
' make demo-reset Stop the demo and wipe its data volumes.' \
' make workloads-export Regenerate the embedded UI workload catalog.' \
' make setup Interactive one-shot provisioning wizard.' \
' make test / make vet Run Go verification.' \
'' \
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
@@ -127,10 +163,10 @@ tidy:
# DATABASE_URL must be set, e.g.:
# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable'
migrate-up:
migrate -path migrations -database "$(DATABASE_URL)" up
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" up
migrate-down:
migrate -path migrations -database "$(DATABASE_URL)" down 1
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" down 1
# --- docker --------------------------------------------------------------
# `up` starts Postgres, applies migrations, then launches the coordinator.
+44 -9
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
@@ -14,9 +16,27 @@ import (
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// version is injected at build time (-ldflags "-X main.version=...") and
// reported by --version. "dev" marks a local build.
var version = "dev"
func main() {
args := os.Args[1:]
if len(args) > 0 && args[0] == "setup" {
if err := runSetup(args[1:]); err != nil {
os.Exit(1)
}
return
}
showVersion := flag.Bool("version", false, "print the build version and exit")
flag.Parse()
if *showVersion {
fmt.Println("coordinator " + version)
return
}
// All work happens in run() so its defers (pool.Close, log flush, signal
// stop) still execute: os.Exit skips deferred calls entirely.
if err := run(); err != nil {
@@ -53,6 +73,15 @@ func run() error {
}
defer pool.Close()
// A downloaded binary provisions its own schema; AUTO_MIGRATE=false keeps
// out-of-band migration workflows (the migrate CLI, CI, managed databases).
if cfg.AutoMigrate {
if err := postgres.Migrate(ctx, cfg.DatabaseURL, log); err != nil {
log.Error("apply migrations", "err", err)
return err
}
}
blobStore, err := blob.NewFSStore(cfg.StorageDir)
if err != nil {
log.Error("init blob storage", "err", err)
@@ -70,29 +99,35 @@ func run() error {
taskResultRepo = postgres.NewTaskResultRepo(pool)
)
catalog, err := workloads.Load()
if err != nil {
log.Error("load workload catalog", "err", err)
return err
}
useCases := httptransport.UseCases{
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts),
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration),
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog),
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration, catalog),
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize),
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk),
FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk),
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize, catalog),
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk, catalog),
FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk, catalog),
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk),
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
Dashboard: usecase.NewDashboard(uiReadRepo),
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
}
// Background reapers are tracked so shutdown can wait for them. Without this
// the process would exit mid-UPDATE, and the deferred pool.Close() would pull
// connections out from under them.
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk)
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk, catalog)
markOffline := usecase.NewMarkWorkersOffline(workerRepo, clk, cfg.WorkerOfflineAfter)
var wg sync.WaitGroup
@@ -121,7 +156,7 @@ func run() error {
// pool.Ping backs /health: readiness means the database answers, not just
// that the process is alive.
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL)
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
// Shutdown order matters, and defers alone cannot express it (they run
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/setup"
)
// runSetup implements `coordinator setup` with non-interactive flags and an
// interactive fallback for anything still missing.
func runSetup(args []string) error {
flags := flag.NewFlagSet("setup", flag.ContinueOnError)
flags.Usage = func() {
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator setup [options]\n")
_, _ = fmt.Fprintf(flags.Output(), "Provisions the coordinator database, schema, and local .env settings.\n\n")
flags.PrintDefaults()
}
var (
databaseURL = flags.String("db", "", "coordinator database URL (default: DATABASE_URL)")
adminURL = flags.String("admin-db", "", "maintenance URL to create a missing database (default: same host, 'postgres' db)")
envFile = flags.String("env-file", "", "settings file to write (default: .env)")
force = flags.Bool("force", false, "overwrite an existing settings file")
yes = flags.Bool("yes", false, "non-interactive: use defaults, fail on anything missing")
)
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 0 {
return fmt.Errorf("setup takes no positional arguments")
}
databaseURLValue := *databaseURL
if databaseURLValue == "" {
databaseURLValue = os.Getenv("DATABASE_URL")
}
envFileValue := *envFile
if envFileValue == "" {
envFileValue = os.Getenv("ENV_FILE")
}
adminURLValue := *adminURL
if adminURLValue == "" {
adminURLValue = os.Getenv("POSTGRES_ADMIN_URL")
}
options := setup.Options{
DatabaseURL: databaseURLValue,
AdminDatabaseURL: adminURLValue,
EnvFile: envFileValue,
Force: *force,
Yes: *yes,
ConnectTimeout: 10 * time.Second,
Out: os.Stdout,
In: os.Stdin,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
log.Info("setup started", "db", setup.SanitizeDatabaseURL(databaseURLValue), "env_file", envFileValue)
summary, err := setup.Run(ctx, options)
if err != nil {
log.Error("setup failed", "err", err)
return err
}
_, _ = fmt.Fprint(os.Stdout, summary)
log.Info("setup complete")
return nil
}
+44
View File
@@ -0,0 +1,44 @@
// Command worker-agent is the Go worker agent: a coordinator client that
// executes SDK workloads in a Python subprocess per claimed task.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
)
// version is injected at build time (-ldflags "-X main.version=...") and
// reported by --version. "dev" marks a local build.
var version = "dev"
func main() {
showVersion := flag.Bool("version", false, "print the build version and exit")
flag.Parse()
if *showVersion {
fmt.Println("worker-agent " + version)
return
}
config, err := agent.LoadConfig()
if err != nil {
slog.Error("invalid configuration", "error", err)
os.Exit(2)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
tokens := agent.NewTokenProvider(
config.WorkerKey,
config.UserserviceURL,
config.Token,
config.RequestTimeout,
)
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
runner := agent.NewTaskRunner(config.TaskRunner)
daemon := agent.NewDaemon(config, client, runner, logger)
if err := daemon.RunForever(); err != nil {
logger.Error("agent stopped", "error", err)
os.Exit(1)
}
}
+5 -17
View File
@@ -20,20 +20,8 @@ services:
retries: 10
start_period: 5s
# One-shot: applies migrations, then exits. Schema changes stay an explicit
# deployment step — the coordinator binary never migrates on startup.
migrate:
image: migrate/migrate:v4.17.1
depends_on:
postgres:
condition: service_healthy
volumes:
- ./migrations:/migrations:ro
command:
- -path=/migrations
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh}?sslmode=disable
- up
restart: on-failure
# The coordinator applies its embedded schema migrations on startup
# (AUTO_MIGRATE, on by default), so no separate migration step is needed.
coordinator:
build:
@@ -41,9 +29,6 @@ services:
depends_on:
postgres:
condition: service_healthy
# Start only once the schema exists, otherwise the first query fails.
migrate:
condition: service_completed_successfully
environment:
COORDINATOR_ADDR: ":8080"
# Host is the service name: compose resolves it on the project network.
@@ -51,6 +36,9 @@ services:
WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token}
# Empty disables /ui. Set this separately from the worker token.
UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-}
# Directory of the built MkDocs site served at /ui/docs/ (empty disables
# the docs route; the demo mounts ./site automatically).
SCIMESH_DOCS_DIR: ${SCIMESH_DOCS_DIR:-}
DB_MAX_CONNS: "10"
REQUEST_TIMEOUT: "15s"
LEASE_DURATION: "2m"
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// TokenProvider supplies the current bearer token. A static token is served
// forever; a worker key is exchanged at the userservice for short-lived JWTs
// and refreshed before they expire (mirroring the former Python worker).
type TokenProvider interface {
Token() (string, error)
Refresh() error
}
// StaticToken serves a fixed token forever; empty means no Authorization.
type StaticToken struct{ token string }
func (s *StaticToken) Token() (string, error) { return s.token, nil }
func (s *StaticToken) Refresh() error { return nil }
// WorkerKeyToken exchanges a long-lived worker key for short-lived JWTs.
type WorkerKeyToken struct {
userserviceURL string
workerKey string
timeout time.Duration
leeway float64
mu sync.Mutex
token string
refreshAt time.Time
}
func NewWorkerKeyToken(userserviceURL, workerKey string, timeout time.Duration) *WorkerKeyToken {
return &WorkerKeyToken{
userserviceURL: strings.TrimRight(userserviceURL, "/"),
workerKey: workerKey,
timeout: timeout,
leeway: 0.2,
}
}
// Token returns the current token, exchanging first when missing or stale.
func (p *WorkerKeyToken) Token() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token == "" || time.Now().After(p.refreshAt) {
if err := p.exchangeLocked(); err != nil {
return "", err
}
}
return p.token, nil
}
// Refresh forces an immediate exchange.
func (p *WorkerKeyToken) Refresh() error {
p.mu.Lock()
defer p.mu.Unlock()
return p.exchangeLocked()
}
func (p *WorkerKeyToken) exchangeLocked() error {
payload, err := json.Marshal(map[string]string{"key": p.workerKey})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, p.userserviceURL+"/worker-tokens/exchange", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: p.timeout}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("worker key exchange request failed")
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("worker key exchange rejected with status %d", response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return fmt.Errorf("worker key exchange request failed")
}
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("worker key exchange response is invalid")
}
token, _ := data["token"].(string)
if token == "" {
return fmt.Errorf("worker key exchange response is missing a token")
}
var ttl time.Duration
switch value := data["expires_in"].(type) {
case float64:
ttl = time.Duration(value * float64(time.Second))
case int:
ttl = time.Duration(value) * time.Second
}
p.token = token
p.refreshAt = time.Time{}
if ttl > 0 {
p.refreshAt = time.Now().Add(time.Duration(float64(ttl) * (1.0 - p.leeway)))
}
return nil
}
// NewTokenProvider picks the strategy: a worker key (with userservice) wins
// over a static bearer token.
func NewTokenProvider(workerKey, userserviceURL, bearerToken string, timeout time.Duration) TokenProvider {
if workerKey != "" && userserviceURL != "" {
return NewWorkerKeyToken(userserviceURL, workerKey, timeout)
}
return &StaticToken{token: bearerToken}
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestWorkerKeyTokenExchangesAndCaches(t *testing.T) {
var exchanges atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/worker-tokens/exchange" {
http.NotFound(w, r)
return
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload["key"] != "scimesh_wk_live_x" {
http.Error(w, "bad key", http.StatusUnauthorized)
return
}
exchanges.Add(1)
writeJSON(w, http.StatusOK, map[string]any{
"token": "jwt-1",
"expires_in": 100,
})
}))
defer server.Close()
provider := NewWorkerKeyToken(server.URL, "scimesh_wk_live_x", 5*time.Second)
token, err := provider.Token()
if err != nil || token != "jwt-1" {
t.Fatalf("token = %q, err = %v", token, err)
}
// The second call within the TTL reuses the cache.
again, err := provider.Token()
if err != nil || again != "jwt-1" {
t.Fatalf("cached token = %q, err = %v", again, err)
}
if exchanges.Load() != 1 {
t.Errorf("exchanges = %d, want 1", exchanges.Load())
}
// An explicit refresh re-exchanges.
if err := provider.Refresh(); err != nil {
t.Fatalf("Refresh: %v", err)
}
if exchanges.Load() != 2 {
t.Errorf("exchanges after refresh = %d, want 2", exchanges.Load())
}
}
func TestWorkerKeyTokenRejectsBadKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
provider := NewWorkerKeyToken(server.URL, "bad", 5*time.Second)
if _, err := provider.Token(); err == nil {
t.Error("expected exchange failure for a rejected key")
}
}
func TestNewTokenProviderSelectsStrategy(t *testing.T) {
if _, ok := NewTokenProvider("", "", "static", time.Second).(*StaticToken); !ok {
t.Error("expected a static token provider")
}
if _, ok := NewTokenProvider("key", "http://users", "", time.Second).(*WorkerKeyToken); !ok {
t.Error("expected a worker-key provider")
}
}
func TestClientRefreshesTokenOnceOn401(t *testing.T) {
var attempts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
if attempts.Load() == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]any{})
}))
defer server.Close()
provider := &StaticToken{token: "t"}
client := NewClient(server.URL, provider, 5*time.Second)
status, _, err := client.requestJSON(http.MethodGet, "/ok", map[string]any{})
if err != nil {
t.Fatalf("request: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d", status)
}
if attempts.Load() != 2 {
t.Errorf("attempts = %d, want 2 (401 then retry)", attempts.Load())
}
}
+374
View File
@@ -0,0 +1,374 @@
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// CoordinatorError is a non-retriable coordinator response.
type CoordinatorError struct{ msg string }
func (e *CoordinatorError) Error() string { return e.msg }
// TransientError is a timeout, connection error, or 5xx response.
type TransientError struct{ msg string }
func (e *TransientError) Error() string { return e.msg }
// ConflictError means the worker no longer owns the task lease.
type ConflictError struct{ msg string }
func (e *ConflictError) Error() string { return e.msg }
// Client speaks the v1 worker contract over HTTP with a token provider.
//
// API calls never follow redirects (a redirect is a contract violation); the
// artifact download follows redirects but strips the Authorization header on
// cross-origin hops, matching the Python worker's SameOriginAuthRedirectHandler.
// A 401 response refreshes the token exactly once and retries, so a lapsed JWT
// does not fail an in-flight task.
type Client struct {
baseURL string
tokens TokenProvider
timeout time.Duration
apiClient *http.Client
dlClient *http.Client
}
func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
tokens: tokens,
timeout: timeout,
apiClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
dlClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
// Go strips Authorization on cross-host redirects by default;
// strip it explicitly on any origin change to be safe.
if len(via) > 0 && origin(req.URL) != origin(via[0].URL) {
req.Header.Del("Authorization")
}
return nil
},
},
}
}
func origin(u *url.URL) string {
return u.Scheme + "://" + u.Host
}
func (c *Client) authHeaders() (map[string]string, error) {
token, err := c.tokens.Token()
if err != nil {
return nil, &CoordinatorError{msg: "token refresh failed: " + err.Error()}
}
if token == "" {
return map[string]string{}, nil
}
return map[string]string{"Authorization": "Bearer " + token}, nil
}
func (c *Client) refreshAndRetry() bool {
return c.tokens.Refresh() == nil
}
// Register advertises the worker and returns its identity and heartbeat policy.
func (c *Client) Register(name string, capabilities []string, cpuCount int, memoryMB int) (*RegisteredWorker, error) {
payload := map[string]any{
"name": name,
"capabilities": capabilities,
"cpu_count": cpuCount,
}
if memoryMB > 0 {
payload["memory_mb"] = memoryMB
}
status, body, err := c.requestJSON("POST", "/workers/register", payload)
if err != nil {
return nil, err
}
if status != http.StatusCreated {
return nil, &CoordinatorError{msg: fmt.Sprintf("worker registration rejected with status %d", status)}
}
return ParseRegistered(body)
}
// Claim leases one compatible task, or returns nil when the queue is empty.
func (c *Client) Claim(workerID string, capabilities []string) (*Task, error) {
status, body, err := c.requestJSON("POST", "/tasks/claim", map[string]any{
"worker_id": workerID,
"capabilities": capabilities,
"max_concurrency": 1,
})
if err != nil {
return nil, err
}
if status == http.StatusNoContent {
return nil, nil
}
if status != http.StatusOK {
return nil, &CoordinatorError{msg: fmt.Sprintf("unexpected claim status %d", status)}
}
return ParseTask(body)
}
// Heartbeat renews the lease and returns the new deadline.
func (c *Client) Heartbeat(task *Task, workerID string) (time.Time, error) {
status, body, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/heartbeat", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
})
if err != nil {
return time.Time{}, err
}
if status != http.StatusOK {
if status == http.StatusConflict {
return time.Time{}, &ConflictError{msg: "heartbeat rejected because the task lease was lost"}
}
return time.Time{}, &CoordinatorError{msg: fmt.Sprintf("heartbeat rejected with status %d", status)}
}
raw, ok := body["lease_expires_at"].(string)
if !ok {
return time.Time{}, &CoordinatorError{msg: "heartbeat response is missing lease_expires_at"}
}
lease, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, &CoordinatorError{msg: "heartbeat returned an invalid lease_expires_at"}
}
task.LeaseExpiresAt = lease
task.leaseExpiresRaw = raw
return lease, nil
}
// Submit completes a task with the uploaded coordinator-owned artifact.
func (c *Client) Submit(task *Task, workerID string, uploaded *Uploaded, metrics map[string]any) error {
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/result", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
"result": map[string]any{"artifact_id": uploaded.ArtifactID},
"metrics": metrics,
})
if err != nil {
return err
}
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
if status == http.StatusConflict {
return &ConflictError{msg: "result rejected because the task lease was lost"}
}
return &CoordinatorError{msg: fmt.Sprintf("result rejected with status %d", status)}
}
return nil
}
// Fail reports a sanitized failure.
func (c *Client) Fail(task *Task, workerID string, code, message string, retryable bool) error {
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/failure", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
"error_code": code,
"error_message": message,
"retryable": retryable,
})
if err != nil {
return err
}
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
if status == http.StatusConflict {
return &ConflictError{msg: "failure rejected because the task lease was lost"}
}
return &CoordinatorError{msg: fmt.Sprintf("failure report rejected with status %d", status)}
}
return nil
}
// Download streams the task input to destination and returns its SHA-256.
func (c *Client) Download(uri, destination string) (string, error) {
resolved, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid input URI: %w", err)
}
if !resolved.IsAbs() {
base, parseErr := url.Parse(c.baseURL)
if parseErr != nil {
return "", fmt.Errorf("invalid coordinator URL")
}
resolved = base.ResolveReference(resolved)
}
if resolved.Scheme != "http" && resolved.Scheme != "https" {
return "", fmt.Errorf("input URI must be an HTTP(S) URL")
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, resolved.String(), nil)
if err != nil {
return "", err
}
headers, err := c.authHeaders()
if err != nil {
return "", err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.dlClient.Do(request)
if err != nil {
return "", &TransientError{msg: "input download failed"}
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.Download(uri, destination)
}
if response.StatusCode != http.StatusOK {
return "", &CoordinatorError{msg: fmt.Sprintf("input download rejected with status %d", response.StatusCode)}
}
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return "", err
}
// #nosec G304 -- destination is the worker's own attempt directory file.
target, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
digest := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(target, digest), response.Body)
closeErr := target.Close()
if copyErr != nil {
_ = os.Remove(destination)
return "", &TransientError{msg: "input download interrupted"}
}
if closeErr != nil {
return "", closeErr
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
// Upload streams a partial artifact and verifies the returned metadata.
func (c *Client) Upload(task *Task, workerID string, path, contentType string) (*Uploaded, error) {
// #nosec G304 -- the upload path is this worker's own artifact file.
file, err := os.Open(path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
digest := sha256.New()
if _, err := io.Copy(digest, file); err != nil {
_ = file.Close()
return nil, err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
_ = file.Close()
return nil, err
}
localSHA := hex.EncodeToString(digest.Sum(nil))
uploadURL := c.baseURL + "/tasks/" + url.PathEscape(task.TaskID) + "/artifacts/" + url.PathEscape(filepath.Base(path))
request, err := http.NewRequestWithContext(context.Background(), http.MethodPut, uploadURL, file)
if err != nil {
_ = file.Close()
return nil, err
}
request.ContentLength = info.Size()
request.Header.Set("Content-Type", contentType)
request.Header.Set("X-Worker-ID", workerID)
request.Header.Set("X-Task-Attempt", strconv.Itoa(task.Attempt))
headers, err := c.authHeaders()
if err != nil {
_ = file.Close()
return nil, err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.apiClient.Do(request)
_ = file.Close()
if err != nil {
return nil, &TransientError{msg: "artifact upload failed"}
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, &TransientError{msg: "artifact upload interrupted"}
}
if response.StatusCode == http.StatusConflict {
return nil, &ConflictError{msg: "artifact upload rejected because the task lease was lost"}
}
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.Upload(task, workerID, path, contentType)
}
if response.StatusCode != http.StatusOK {
return nil, &CoordinatorError{msg: fmt.Sprintf("artifact upload rejected with status %d", response.StatusCode)}
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
}
uploaded, err := ParseUploaded(payload)
if err != nil {
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
}
if uploaded.SHA256 != localSHA || uploaded.SizeBytes != info.Size() {
return nil, &CoordinatorError{msg: "artifact upload metadata does not match local artifact"}
}
return uploaded, nil
}
func (c *Client) requestJSON(method, path string, payload any) (int, map[string]any, error) {
body, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
request, err := http.NewRequestWithContext(context.Background(), method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
request.Header.Set("Content-Type", "application/json")
headers, err := c.authHeaders()
if err != nil {
return 0, nil, err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.apiClient.Do(request)
if err != nil {
return 0, nil, &TransientError{msg: "coordinator request failed"}
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, nil, &TransientError{msg: "coordinator request interrupted"}
}
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.requestJSON(method, path, payload)
}
if response.StatusCode >= 500 {
return response.StatusCode, nil, &TransientError{msg: fmt.Sprintf("coordinator returned %d", response.StatusCode)}
}
var decoded map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &decoded); err != nil {
return response.StatusCode, nil, &CoordinatorError{msg: "coordinator returned invalid JSON"}
}
}
return response.StatusCode, decoded, nil
}
+225
View File
@@ -0,0 +1,225 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func newTestClient(t *testing.T, server *httptest.Server) *Client {
t.Helper()
return NewClient(server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
}
func TestClientRegisterClaimHeartbeat(t *testing.T) {
var registered, claimed, heartbeated bool
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
registered = true
writeJSON(w, http.StatusCreated, map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15,
})
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
claimed = true
writeJSON(w, http.StatusOK, validTaskPayload())
case r.Method == http.MethodPost && r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
heartbeated = true
writeJSON(w, http.StatusOK, map[string]any{
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
registeredWorker, err := client.Register("test-worker", []string{"similarity-search"}, 2, 1024)
if err != nil {
t.Fatalf("Register: %v", err)
}
if registeredWorker.WorkerID != "22222222-2222-4222-8222-222222222222" {
t.Errorf("worker id = %q", registeredWorker.WorkerID)
}
task, err := client.Claim("22222222-2222-4222-8222-222222222222", []string{"similarity-search"})
if err != nil {
t.Fatalf("Claim: %v", err)
}
if task == nil || task.Workload != "similarity-search" {
t.Fatalf("claim = %+v", task)
}
renewed, err := client.Heartbeat(task, "22222222-2222-4222-8222-222222222222")
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if renewed.Before(time.Now()) {
t.Error("renewed lease is in the past")
}
if !registered || !claimed || !heartbeated {
t.Error("some endpoints were not hit")
}
}
func TestClientClaimEmptyAndConflict(t *testing.T) {
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/tasks/claim":
w.WriteHeader(http.StatusNoContent)
case "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
w.WriteHeader(http.StatusConflict)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
task, err := client.Claim("worker", []string{"similarity-search"})
if err != nil {
t.Fatalf("Claim: %v", err)
}
if task != nil {
t.Error("expected no task for 204")
}
claimed, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatalf("ParseTask: %v", err)
}
if _, err := client.Heartbeat(claimed, "worker"); err == nil {
t.Error("expected conflict error")
} else if !errors.As(err, &conflictError) {
t.Errorf("error type = %T", err)
}
}
func TestClientUploadSubmitFail(t *testing.T) {
var uploadedPath string
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/tasks/11111111-1111-4111-8111-111111111111/artifacts/"):
if r.Header.Get("X-Worker-ID") != "worker" || r.Header.Get("X-Task-Attempt") != "1" {
t.Errorf("missing identity headers: %+v", r.Header)
}
uploadedPath = r.URL.Path
writeJSON(w, http.StatusOK, map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": server.URL + "/artifacts/333/download",
"sha256": sha256Of(t, "partial body"),
"size_bytes": int64(len("partial body")),
})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
writeJSON(w, http.StatusAccepted, map[string]any{})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
writeJSON(w, http.StatusAccepted, map[string]any{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
task, _ := ParseTask(validTaskPayload())
dir := t.TempDir()
partial := filepath.Join(dir, "result.csv")
if err := os.WriteFile(partial, []byte("partial body"), 0o644); err != nil {
t.Fatal(err)
}
uploaded, err := client.Upload(task, "worker", partial, "text/csv")
if err != nil {
t.Fatalf("Upload: %v", err)
}
if uploaded.SizeBytes != int64(len("partial body")) {
t.Errorf("size = %d", uploaded.SizeBytes)
}
if !strings.Contains(uploadedPath, "result.csv") {
t.Errorf("upload path = %q", uploadedPath)
}
if err := client.Submit(task, "worker", uploaded, map[string]any{"rows": 1}); err != nil {
t.Fatalf("Submit: %v", err)
}
if err := client.Fail(task, "worker", "ValueError", "bad input", false); err != nil {
t.Fatalf("Fail: %v", err)
}
}
func TestClientDownloadVerifiesChecksumAndStripsAuthOnRedirect(t *testing.T) {
var redirectedAuth string
bucket := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte("input bytes"))
}))
defer bucket.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/input" {
http.Redirect(w, r, bucket.URL+"/presigned", http.StatusFound)
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := newTestClient(t, server)
destination := filepath.Join(t.TempDir(), "input")
digest, err := client.Download(server.URL+"/tasks/11111111-1111-4111-8111-111111111111/input", destination)
if err != nil {
t.Fatalf("Download: %v", err)
}
if digest != sha256Of(t, "input bytes") {
t.Errorf("digest = %q", digest)
}
if redirectedAuth != "" {
t.Error("Authorization must be stripped on the redirected download")
}
}
func TestSanitizeErrorMessageRedactsPaths(t *testing.T) {
message := SanitizeErrorMessage(
"failed at /home/alice/work/attempts/1/input and /private/secret.txt",
"/home/alice/work",
)
for _, forbidden := range []string{"/home/alice", "/private/secret.txt"} {
if strings.Contains(message, forbidden) {
t.Errorf("message leaks %q: %q", forbidden, message)
}
}
if !strings.Contains(message, "<worker-dir>") {
t.Errorf("work dir not redacted: %q", message)
}
long := SanitizeErrorMessage(strings.Repeat("x", 500), "/tmp")
if len(long) != 300 {
t.Errorf("truncated length = %d", len(long))
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func sha256Of(t *testing.T, value string) string {
t.Helper()
digest := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", digest)
}
+177
View File
@@ -0,0 +1,177 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// Config is read only from the environment, mirroring the former Python
// worker's configuration surface.
type Config struct {
CoordinatorURL string
Token string
WorkerKey string
UserserviceURL string
WorkerName string
WorkerID string // set after registration; overridable for tests
WorkDir string
CPUCount int
MemoryMB int // 0 = not advertised
PollInterval time.Duration
RequestTimeout time.Duration
Heartbeat time.Duration
CleanupAfter time.Duration // 0 = keep attempt directories
Capabilities []string
TaskRunner []string // command + args; defaults to python -m scimesh.worker.task
MaxTasks int // 0 = unlimited
ExitWhenIdle bool
}
func envList(name string) ([]string, error) {
raw := os.Getenv(name)
if raw == "" {
return nil, nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, fmt.Errorf("%s must be a JSON array", name)
}
for _, item := range items {
if strings.TrimSpace(item) == "" {
return nil, fmt.Errorf("%s must not contain empty entries", name)
}
}
return items, nil
}
// LoadConfig validates the environment and fails fast on invalid values.
func LoadConfig() (*Config, error) {
url := os.Getenv("COORDINATOR_URL")
if url == "" {
return nil, fmt.Errorf("COORDINATOR_URL is required")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("COORDINATOR_URL must be an absolute HTTP(S) URL")
}
workDir := os.Getenv("WORK_DIR")
if workDir == "" {
workDir = "./scimesh-agent-data"
}
cpu := 1
if raw := os.Getenv("CPU_COUNT"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("CPU_COUNT must be a positive integer")
}
cpu = parsed
}
memoryMB := 0
if raw := os.Getenv("MEMORY_MB"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("MEMORY_MB must be a positive integer")
}
memoryMB = parsed
}
poll, err := durationEnv("POLL_INTERVAL", 2*time.Second)
if err != nil {
return nil, err
}
timeout, err := durationEnv("REQUEST_TIMEOUT", 30*time.Second)
if err != nil {
return nil, err
}
heartbeat, err := durationEnv("HEARTBEAT_INTERVAL", 15*time.Second)
if err != nil {
return nil, err
}
cleanup, err := durationEnv("CLEANUP_AFTER_SECONDS", 0)
if err != nil {
return nil, err
}
capabilities, err := envList("CAPABILITIES")
if err != nil {
return nil, err
}
if len(capabilities) == 0 {
capabilities = defaultCapabilities()
}
runner, err := envList("TASK_RUNNER")
if err != nil {
return nil, err
}
if len(runner) == 0 {
runner = []string{"python", "-m", "scimesh.worker.task"}
}
maxTasks := 0
if raw := os.Getenv("MAX_TASKS"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("MAX_TASKS must be a positive integer")
}
maxTasks = parsed
}
name := os.Getenv("WORKER_NAME")
if name == "" {
host, _ := os.Hostname()
name = host
}
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
return nil, fmt.Errorf("WORK_DIR must be an absolute path")
}
return &Config{
CoordinatorURL: strings.TrimRight(url, "/"),
Token: os.Getenv("WORKER_AUTH_TOKEN"),
WorkerKey: os.Getenv("WORKER_KEY"),
UserserviceURL: strings.TrimRight(os.Getenv("USERSERVICE_URL"), "/"),
WorkerName: name,
WorkerID: os.Getenv("WORKER_ID"),
WorkDir: absWorkDir,
CPUCount: cpu,
MemoryMB: memoryMB,
PollInterval: poll,
RequestTimeout: timeout,
Heartbeat: heartbeat,
CleanupAfter: cleanup,
Capabilities: capabilities,
TaskRunner: runner,
MaxTasks: maxTasks,
ExitWhenIdle: os.Getenv("EXIT_WHEN_IDLE") == "1",
}, nil
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
raw := os.Getenv(name)
if raw == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(raw)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("%s must be a non-negative duration", name)
}
return parsed, nil
}
// defaultCapabilities derives the worker's advertised capabilities from the
// embedded workload catalog, so an agent is workload-agnostic out of the box:
// it claims whatever enabled workloads the coordinator library declares.
// Explicit CAPABILITIES still overrides this for operators who want a subset.
func defaultCapabilities() []string {
catalog, err := workloads.Load()
if err != nil {
return []string{"similarity-search"}
}
names := make([]string, 0, len(catalog.Enabled()))
for _, workload := range catalog.Enabled() {
names = append(names, workload.Name)
}
return names
}
+336
View File
@@ -0,0 +1,336 @@
package agent
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Outcome reports whether a claim was made and whether it completed.
type Outcome struct {
Claimed bool
Completed bool
}
// Daemon is the agent state machine: register, claim, execute via the Python
// task runner, upload, and submit — mirroring the Python worker's lifecycle.
// conflictError is the errors.As target for lease conflicts.
var conflictError *ConflictError
type Daemon struct {
config *Config
client *Client
runner *TaskRunner
log *slog.Logger
workerID string
registered bool
completed int
mu sync.Mutex
}
func NewDaemon(config *Config, client *Client, runner *TaskRunner, log *slog.Logger) *Daemon {
return &Daemon{config: config, client: client, runner: runner, log: log}
}
// RunForever loops until interrupted, idle-exit, or max tasks.
func (d *Daemon) RunForever() error {
failures := 0
for {
if !d.registered {
if err := d.register(); err != nil {
return err
}
}
d.cleanupExpiredDirectories()
outcome, err := d.runOnce()
if err != nil {
failures++
d.log.Warn("agent cycle failed", "error", err)
backoff := d.config.PollInterval
for i := 0; i < failures && i < 6; i++ {
backoff *= 2
}
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
time.Sleep(backoff)
continue
}
failures = 0
if outcome.Claimed && outcome.Completed {
d.completed++
if d.config.MaxTasks > 0 && d.completed >= d.config.MaxTasks {
d.log.Info("max tasks reached")
return nil
}
}
if !outcome.Claimed && d.config.ExitWhenIdle {
d.log.Info("queue empty, exiting")
return nil
}
if outcome.Claimed && d.config.ExitWhenIdle {
d.log.Info("one claim processed, exiting")
return nil
}
if !outcome.Claimed {
time.Sleep(d.config.PollInterval)
}
}
}
func (d *Daemon) register() error {
registered, err := d.client.Register(
d.config.WorkerName,
d.config.Capabilities,
d.config.CPUCount,
d.config.MemoryMB,
)
if err != nil {
return err
}
d.mu.Lock()
if d.config.WorkerID != "" {
d.workerID = d.config.WorkerID
} else {
d.workerID = registered.WorkerID
}
d.registered = true
d.mu.Unlock()
d.log.Info("registered", "worker_id", d.workerID)
return nil
}
func (d *Daemon) workerIDOrEmpty() string {
d.mu.Lock()
defer d.mu.Unlock()
return d.workerID
}
// cleanupExpiredDirectories removes task attempt directories older than the
// configured retention, mirroring the former Python worker's cleanup.
func (d *Daemon) cleanupExpiredDirectories() {
if d.config.CleanupAfter <= 0 {
return
}
cutoff := time.Now().Add(-d.config.CleanupAfter)
tasks, err := os.ReadDir(d.config.WorkDir)
if err != nil {
return
}
for _, taskEntry := range tasks {
if !taskEntry.IsDir() {
continue
}
taskDir := filepath.Join(d.config.WorkDir, taskEntry.Name())
attempts, err := os.ReadDir(taskDir)
if err != nil {
continue
}
for _, attemptEntry := range attempts {
if !attemptEntry.IsDir() {
continue
}
info, err := attemptEntry.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(filepath.Join(taskDir, attemptEntry.Name()))
}
}
if entries, err := os.ReadDir(taskDir); err == nil && len(entries) == 0 {
_ = os.Remove(taskDir)
}
}
}
func (d *Daemon) runOnce() (Outcome, error) {
workerID := d.workerIDOrEmpty()
if workerID == "" {
return Outcome{}, fmt.Errorf("agent is not registered")
}
task, err := d.client.Claim(workerID, d.config.Capabilities)
if err != nil {
return Outcome{}, err
}
if task == nil {
return Outcome{Claimed: false}, nil
}
started := time.Now()
taskDir := filepath.Join(d.config.WorkDir, task.TaskID, fmt.Sprint(task.Attempt))
if err := os.MkdirAll(taskDir, 0o750); err != nil {
return Outcome{Claimed: true}, err
}
heartbeat := newLeaseHeartbeat(task, workerID, d.client, d.config.Heartbeat)
completed := false
err = heartbeat.Start()
if err != nil {
if errors.As(err, &conflictError) {
d.log.Warn("lease lost", "task_id", task.TaskID)
return Outcome{Claimed: true}, nil
}
return Outcome{Claimed: true}, err
}
defer heartbeat.Stop()
// Attempt directory cleanup is deliberately minimal in the prototype:
// attempt directories are retained under the work directory.
failure := d.executeTask(task, workerID, taskDir, started, heartbeat)
if failure != nil {
if errors.As(failure, &conflictError) {
d.log.Warn("lease lost", "task_id", task.TaskID)
return Outcome{Claimed: true}, nil
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return Outcome{Claimed: true}, nil
}
d.reportFailure(task, workerID, failure)
return Outcome{Claimed: true}, nil
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return Outcome{Claimed: true}, nil
}
completed = true
d.log.Info("task completed", "task_id", task.TaskID, "elapsed_seconds", time.Since(started).Seconds())
return Outcome{Claimed: true, Completed: completed}, nil
}
// executeTask returns nil on success or a classified failure.
func (d *Daemon) executeTask(task *Task, workerID, taskDir string, started time.Time, heartbeat *leaseHeartbeat) error {
inputPath := filepath.Join(taskDir, "input")
// Downloads use the coordinator-provided URI verbatim; a relative path is
// resolved against the coordinator by the client.
actualSHA, err := d.client.Download(task.Input.URI, inputPath)
if err != nil {
return err
}
if !strings.EqualFold(actualSHA, task.Input.SHA256) {
return &CoordinatorError{msg: "input checksum mismatch"}
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
manifestPath := filepath.Join(taskDir, "manifest.json")
manifest, err := d.runner.Run(task, taskDir, manifestPath, nil)
if err != nil {
return err
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
uploaded, err := d.client.Upload(task, workerID, manifest.ArtifactPath, manifest.ContentType)
if err != nil {
return err
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
metrics := map[string]any{"elapsed_seconds": roundSeconds(time.Since(started).Seconds())}
for name, value := range manifest.Metrics {
metrics[name] = value
}
return d.client.Submit(task, workerID, uploaded, metrics)
}
func (d *Daemon) reportFailure(task *Task, workerID string, failure error) {
var code string
var coordinatorErr *CoordinatorError
if errors.As(failure, &coordinatorErr) {
code = "ValueError"
} else {
code = "TaskRunnerFailed"
}
retryable := IsRetryableError(failure)
message := SanitizeErrorMessage(failure.Error(), d.config.WorkDir)
d.log.Warn("task failed", "task_id", task.TaskID, "error_code", code, "retryable", retryable)
if err := d.client.Fail(task, workerID, code, message, retryable); err != nil {
if errors.As(err, &conflictError) {
d.log.Warn("lease lost while reporting failure", "task_id", task.TaskID)
return
}
d.log.Warn("failure report rejected", "task_id", task.TaskID, "error", err)
}
}
// leaseHeartbeat renews the lease from the returned deadline at less than
// half of the remaining TTL, mirroring the Python worker.
type leaseHeartbeat struct {
task *Task
workerID string
client *Client
interval time.Duration
stop chan struct{}
once sync.Once
mu sync.Mutex
lease time.Time
failed error
}
func newLeaseHeartbeat(task *Task, workerID string, client *Client, interval time.Duration) *leaseHeartbeat {
return &leaseHeartbeat{
task: task,
workerID: workerID,
client: client,
interval: interval,
stop: make(chan struct{}),
lease: task.LeaseExpiresAt,
}
}
func (h *leaseHeartbeat) Start() error {
if _, err := h.client.Heartbeat(h.task, h.workerID); err != nil {
return err
}
go h.loop()
return nil
}
func (h *leaseHeartbeat) Stop() {
h.once.Do(func() { close(h.stop) })
}
func (h *leaseHeartbeat) RaiseIfFailed() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.failed
}
func (h *leaseHeartbeat) loop() {
for {
delay := h.nextDelay()
select {
case <-h.stop:
return
case <-time.After(delay):
}
renewed, err := h.client.Heartbeat(h.task, h.workerID)
h.mu.Lock()
if err != nil {
h.failed = err
h.mu.Unlock()
return
}
h.lease = renewed
h.mu.Unlock()
}
}
func (h *leaseHeartbeat) nextDelay() time.Duration {
h.mu.Lock()
defer h.mu.Unlock()
remaining := time.Until(h.lease)
if remaining <= 0 {
return 0
}
half := remaining / 2
if h.interval < half {
return h.interval
}
return half
}
func roundSeconds(seconds float64) float64 {
return float64(int64(seconds*1000)) / 1000
}
+296
View File
@@ -0,0 +1,296 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sha256HexOf(value string) string {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum)
}
// fakeRunnerScript writes a result manifest for --output and exits with the
// given code.
func fakeRunnerScript(t *testing.T, dir string, exitCode int) string {
t.Helper()
script := filepath.Join(dir, "fake-runner.sh")
content := `#!/bin/sh
out=""
task_dir=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output) out="$2"; shift 2;;
--task-dir) task_dir="$2"; shift 2;;
*) shift;;
esac
done
printf 'id,score\n' > "$task_dir/result.csv"
printf '{"artifact_path":"%s/result.csv","content_type":"text/csv","metrics":{"rows":1}}' "$task_dir" > "$out"
exit ` + fmt.Sprint(exitCode) + "\n"
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
}
// fakeCoordinator implements the v1 contract over HTTP and records calls.
type fakeCoordinator struct {
server *httptest.Server
task map[string]any
submits []map[string]any
failures []map[string]any
heartbeats int
uploadSHA string
uploadSize int64
inputBytes []byte
conflict bool // 409 on heartbeat/upload/result
}
func newFakeCoordinator(t *testing.T, task map[string]any) *fakeCoordinator {
t.Helper()
fake := &fakeCoordinator{task: task, inputBytes: []byte("input fixture")}
fake.uploadSHA = sha256HexOf(string(fake.inputBytes))
fake.uploadSize = int64(len(fake.inputBytes))
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
writeJSON(w, http.StatusCreated, map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15.0,
})
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
if fake.task == nil {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, fake.task)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/input"):
_, _ = w.Write(fake.inputBytes)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/heartbeat"):
fake.heartbeats++
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
})
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/artifacts/"):
raw, _ := io.ReadAll(r.Body)
fake.uploadSize = int64(len(raw))
fake.uploadSHA = fmt.Sprintf("%x", sha256.Sum256(raw))
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": server.URL + "/artifacts/333/download",
"sha256": fake.uploadSHA,
"size_bytes": fake.uploadSize,
})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
fake.submits = append(fake.submits, payload)
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
fake.failures = append(fake.failures, payload)
w.WriteHeader(http.StatusAccepted)
default:
http.NotFound(w, r)
}
}))
fake.server = server
return fake
}
func (f *fakeCoordinator) close() { f.server.Close() }
func testDaemon(t *testing.T, fake *fakeCoordinator, script string) *Daemon {
t.Helper()
config := &Config{
CoordinatorURL: fake.server.URL,
WorkerName: "test-worker",
WorkerID: "22222222-2222-4222-8222-222222222222",
WorkDir: t.TempDir(),
CPUCount: 1,
PollInterval: time.Millisecond,
RequestTimeout: 5 * time.Second,
Heartbeat: 15 * time.Second,
Capabilities: []string{"similarity-search"},
TaskRunner: []string{script},
}
client := NewClient(fake.server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
runner := NewTaskRunner(config.TaskRunner)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
daemon := NewDaemon(config, client, runner, logger)
if err := daemon.register(); err != nil {
t.Fatalf("register: %v", err)
}
return daemon
}
func validClaimedTaskPayload() map[string]any {
return map[string]any{
"task_id": "11111111-1111-4111-8111-111111111111",
"attempt": 1.0,
"lease_expires_at": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
"workload": "similarity-search",
"input": map[string]any{
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
"sha256": sha256HexOf("input fixture"),
},
"parameters": map[string]any{"query_smiles": "CCO"},
}
}
func TestDaemonCompletesAClaimedTask(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if !outcome.Claimed || !outcome.Completed {
t.Fatalf("outcome = %+v", outcome)
}
if len(fake.submits) != 1 {
t.Fatalf("submits = %d", len(fake.submits))
}
result := fake.submits[0]["result"].(map[string]any)
if result["artifact_id"] != "33333333-3333-4333-8333-333333333333" {
t.Errorf("result artifact = %v", result)
}
metrics := fake.submits[0]["metrics"].(map[string]any)
if metrics["rows"] != float64(1) {
t.Errorf("metrics = %v", metrics)
}
if _, ok := metrics["elapsed_seconds"].(float64); !ok {
t.Errorf("missing elapsed_seconds: %v", metrics)
}
if fake.heartbeats < 1 {
t.Error("expected at least one heartbeat")
}
if len(fake.failures) != 0 {
t.Errorf("unexpected failures: %v", fake.failures)
}
}
func TestDaemonReportsChecksumMismatchAsPermanentFailure(t *testing.T) {
payload := validClaimedTaskPayload()
payload["input"].(map[string]any)["sha256"] = strings.Repeat("b", 64)
fake := newFakeCoordinator(t, payload)
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete on checksum mismatch")
}
if len(fake.failures) != 1 {
t.Fatalf("failures = %d", len(fake.failures))
}
failure := fake.failures[0]
if failure["error_code"] != "ValueError" || failure["retryable"] != false {
t.Errorf("failure = %v", failure)
}
if !strings.Contains(failure["error_message"].(string), "checksum") {
t.Errorf("message = %v", failure["error_message"])
}
if len(fake.submits) != 0 {
t.Error("no submission expected")
}
}
func TestDaemonReportsPermanentRunnerFailure(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), ExitPermanent))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete")
}
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != false {
t.Fatalf("failures = %v", fake.failures)
}
}
func TestDaemonReportsRetryableRunnerFailure(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 1))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete")
}
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != true {
t.Fatalf("failures = %v", fake.failures)
}
}
func TestDaemonLeaseConflictStopsWithoutFailureReport(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
fake.conflict = true
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if !outcome.Claimed {
t.Fatal("task was claimed")
}
if len(fake.failures) != 0 {
t.Errorf("no failure report expected after lease loss: %v", fake.failures)
}
if len(fake.submits) != 0 {
t.Errorf("no submission expected after lease loss: %v", fake.submits)
}
}
func TestDaemonIdleClaimIsNotCompleted(t *testing.T) {
fake := newFakeCoordinator(t, nil)
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Claimed || outcome.Completed {
t.Fatalf("outcome = %+v", outcome)
}
}
+205
View File
@@ -0,0 +1,205 @@
// Package agent implements a Go worker agent: a coordinator client and
// task-lifecycle supervisor that executes SDK workloads in a Python
// subprocess. It mirrors the Python worker's v1 wire contract exactly; the
// Python worker remains the reference implementation.
package agent
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
var (
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
workloadPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$`)
)
// RegisteredWorker is the coordinator's answer to /workers/register.
type RegisteredWorker struct {
WorkerID string
HeartbeatIntervalSeconds float64
}
// Input is the claimed task's input artifact.
type Input struct {
URI string
SHA256 string
}
// Task is one claimed, leased task.
type Task struct {
TaskID string
Attempt int
LeaseExpiresAt time.Time
Workload string
Input Input
Parameters map[string]any
leaseExpiresRaw string
}
// Uploaded is the coordinator-owned metadata returned after artifact upload.
type Uploaded struct {
ArtifactID string
URI string
SHA256 string
SizeBytes int64
}
func requireString(value any, field string) (string, error) {
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return "", fmt.Errorf("%s must be a non-empty string", field)
}
return text, nil
}
func safeCoordinatorURI(value any, field string) (string, error) {
uri, err := requireString(value, field)
if err != nil {
return "", err
}
if strings.HasPrefix(uri, "/") {
// A network-path reference (//host/path) or dot segments would
// resolve to another origin; reject both.
if strings.HasPrefix(uri, "//") {
return "", fmt.Errorf("%s must be a safe coordinator path", field)
}
for _, segment := range strings.Split(uri, "/") {
if segment == ".." {
return "", fmt.Errorf("%s must be a safe coordinator path", field)
}
}
return uri, nil
}
parsed, err := url.Parse(uri)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", fmt.Errorf("%s must be an absolute HTTP(S) URL or coordinator path", field)
}
return uri, nil
}
func sha256Hex(value any, field string) (string, error) {
digest, err := requireString(value, field)
if err != nil {
return "", err
}
digest = strings.ToLower(digest)
if !sha256Pattern.MatchString(digest) {
return "", fmt.Errorf("%s must be a SHA-256 hex digest", field)
}
return digest, nil
}
func uuid(value any, field string) (string, error) {
text, err := requireString(value, field)
if err != nil {
return "", err
}
if !uuidPattern.MatchString(text) {
return "", fmt.Errorf("%s must be a UUID", field)
}
return strings.ToLower(text), nil
}
// ParseTask validates a claimed-task response with the same strictness as the
// Python worker's ClaimedTask.from_json.
func ParseTask(payload map[string]any) (*Task, error) {
rawInput, ok := payload["input"].(map[string]any)
if !ok {
return nil, fmt.Errorf("input must be an object")
}
rawAttempt, ok := payload["attempt"].(float64)
if !ok || rawAttempt < 1 || rawAttempt != float64(int(rawAttempt)) {
return nil, fmt.Errorf("attempt must be a positive integer")
}
taskID, err := uuid(payload["task_id"], "task_id")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
rawLease, err := requireString(payload["lease_expires_at"], "lease_expires_at")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
lease, err := time.Parse(time.RFC3339, rawLease)
if err != nil || lease.Location() == nil {
return nil, fmt.Errorf("lease_expires_at must include a timezone")
}
workload, err := requireString(payload["workload"], "workload")
if err != nil || !workloadPattern.MatchString(workload) {
return nil, fmt.Errorf("workload must be a canonical name")
}
uri, err := safeCoordinatorURI(rawInput["uri"], "input.uri")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
digest, err := sha256Hex(rawInput["sha256"], "input.sha256")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
parameters, ok := payload["parameters"].(map[string]any)
if !ok {
parameters = map[string]any{}
}
return &Task{
TaskID: taskID,
Attempt: int(rawAttempt),
LeaseExpiresAt: lease,
leaseExpiresRaw: rawLease,
Workload: workload,
Input: Input{URI: uri, SHA256: digest},
Parameters: parameters,
}, nil
}
// LeaseExpiresRaw returns the original lease timestamp string for
// round-tripping in heartbeat deadlines.
func (t *Task) LeaseExpiresRaw() string { return t.leaseExpiresRaw }
// ParseRegistered validates a registration response.
func ParseRegistered(payload map[string]any) (*RegisteredWorker, error) {
workerID, err := uuid(payload["worker_id"], "worker_id")
if err != nil {
return nil, fmt.Errorf("invalid worker registration response: %w", err)
}
interval, ok := payload["heartbeat_interval_seconds"].(float64)
if !ok || interval <= 0 {
return nil, fmt.Errorf("heartbeat_interval_seconds must be positive")
}
return &RegisteredWorker{WorkerID: workerID, HeartbeatIntervalSeconds: interval}, nil
}
// ParseUploaded validates an artifact upload response.
func ParseUploaded(payload map[string]any) (*Uploaded, error) {
artifactID, err := uuid(payload["artifact_id"], "artifact_id")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
uri, err := safeCoordinatorURI(payload["uri"], "uri")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
digest, err := sha256Hex(payload["sha256"], "sha256")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
rawSize, ok := payload["size_bytes"].(float64)
if !ok || rawSize < 0 || rawSize != float64(int64(rawSize)) {
return nil, fmt.Errorf("artifact size_bytes must be a non-negative integer")
}
return &Uploaded{ArtifactID: artifactID, URI: uri, SHA256: digest, SizeBytes: int64(rawSize)}, nil
}
// TaskRunnerManifest is what the Python task entry writes on success.
type TaskRunnerManifest struct {
ArtifactPath string `json:"artifact_path"`
ContentType string `json:"content_type"`
Metrics map[string]any `json:"metrics"`
}
// Encode serializes a claim payload for /tasks/claim.
func Encode(v any) ([]byte, error) { return json.Marshal(v) }
+111
View File
@@ -0,0 +1,111 @@
package agent
import (
"testing"
"time"
)
func validTaskPayload() map[string]any {
return map[string]any{
"task_id": "11111111-1111-4111-8111-111111111111",
"attempt": 1.0,
"lease_expires_at": "2026-08-02T00:00:00Z",
"workload": "similarity-search",
"input": map[string]any{
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
"parameters": map[string]any{"query_smiles": "CCO"},
}
}
func TestParseTaskAcceptsValidPayload(t *testing.T) {
task, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatalf("ParseTask: %v", err)
}
if task.TaskID != "11111111-1111-4111-8111-111111111111" {
t.Errorf("task id = %q", task.TaskID)
}
if task.Attempt != 1 || task.Workload != "similarity-search" {
t.Errorf("attempt/workload = %d/%q", task.Attempt, task.Workload)
}
if task.Input.SHA256 != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Errorf("sha256 = %q", task.Input.SHA256)
}
if task.LeaseExpiresAt.IsZero() {
t.Error("lease must parse")
}
}
func TestParseTaskRejectsInvalidPayloads(t *testing.T) {
tests := []struct {
name string
mutate func(map[string]any)
}{
{"non-uuid task id", func(p map[string]any) { p["task_id"] = "../outside" }},
{"zero attempt", func(p map[string]any) { p["attempt"] = 0 }},
{"naive lease", func(p map[string]any) { p["lease_expires_at"] = "2026-08-02T00:00:00" }},
{"network-path uri", func(p map[string]any) {
p["input"].(map[string]any)["uri"] = "//outside.example/input"
}},
{"dot-segment uri", func(p map[string]any) {
p["input"].(map[string]any)["uri"] = "/tasks/../outside/input"
}},
{"short sha256", func(p map[string]any) {
p["input"].(map[string]any)["sha256"] = "abc"
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload := validTaskPayload()
test.mutate(payload)
if _, err := ParseTask(payload); err == nil {
t.Error("expected ParseTask to reject the payload")
}
})
}
}
func TestParseRegisteredAndUploaded(t *testing.T) {
registered, err := ParseRegistered(map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15.0,
})
if err != nil {
t.Fatalf("ParseRegistered: %v", err)
}
if registered.HeartbeatIntervalSeconds != 15 {
t.Errorf("interval = %v", registered.HeartbeatIntervalSeconds)
}
uploaded, err := ParseUploaded(map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": "https://coordinator.example/artifacts/333/download",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"size_bytes": 12.0,
})
if err != nil {
t.Fatalf("ParseUploaded: %v", err)
}
if uploaded.SizeBytes != 12 {
t.Errorf("size = %d", uploaded.SizeBytes)
}
if _, err := ParseUploaded(map[string]any{"artifact_id": "missing"}); err == nil {
t.Error("expected invalid upload metadata to fail")
}
}
func TestLeaseHeartbeatDelayIsBelowHalfTTL(t *testing.T) {
task, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatal(err)
}
task.LeaseExpiresAt = time.Now().Add(60 * time.Second)
heartbeat := newLeaseHeartbeat(task, "worker", nil, 15*time.Second)
delay := heartbeat.nextDelay()
if delay > 30*time.Second || delay <= 0 {
t.Errorf("delay = %v, want < 30s", delay)
}
}
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
)
var (
// Go's regexp (RE2) has no lookbehind, so these patterns conservatively
// anchor on the characters that typically precede a local path:
// whitespace, quotes, parens, brackets, or the start of the message.
windowsPathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\s'"\],)]+`)
posixPathPattern = regexp.MustCompile(`(^|[\s'"(\[=])/(?:[^\s'"\],)]+)`)
)
// SanitizeErrorMessage keeps coordinator-visible failures useful without
// exposing local paths. It mirrors the Python worker's sanitizer: local work
// directories and absolute paths are redacted, and the message is truncated
// to 300 characters.
func SanitizeErrorMessage(message string, workDir string) string {
message = strings.ReplaceAll(message, workDir, "<worker-dir>")
message = windowsPathPattern.ReplaceAllString(message, "<path>")
message = posixPathPattern.ReplaceAllString(message, "${1}<path>")
if len(message) > 300 {
message = message[:300]
}
return message
}
// IsRetryableError classifies failures for the coordinator. Invalid scientific
// input and missing local tools are permanent; everything else (transient
// transport errors, unexpected runner failures) may be retried.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
var coordinatorErr *CoordinatorError
var pathErr *os.PathError
if errors.As(err, &coordinatorErr) || errors.As(err, &pathErr) {
return false
}
return true
}
// TaskRunnerExit classifies subprocess exits.
const (
ExitPermanent = 3 // runner classified the failure as invalid input
)
func runnerExitError(exit int, stderr string) error {
if exit == ExitPermanent {
return &CoordinatorError{msg: stderr}
}
return fmt.Errorf("task runner failed with exit code %d: %s", exit, stderr)
}
+86
View File
@@ -0,0 +1,86 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
)
// TaskRunner spawns the Python task entry and returns the sealed partial
// artifact manifest it produced.
type TaskRunner struct {
command []string
}
func NewTaskRunner(command []string) *TaskRunner {
return &TaskRunner{command: command}
}
// Run executes one task: the task payload is written as JSON into the attempt
// directory, the Python entry computes and seals the partial, and the written
// manifest is parsed back. stderr is captured for failure reporting.
func (r *TaskRunner) Run(task *Task, taskDir string, manifestPath string, extraEnv []string) (*TaskRunnerManifest, error) {
if err := os.MkdirAll(taskDir, 0o750); err != nil {
return nil, err
}
payload := map[string]any{
"task_id": task.TaskID,
"attempt": task.Attempt,
"lease_expires_at": task.leaseExpiresRaw,
"workload": task.Workload,
"input": map[string]any{
"uri": task.Input.URI,
"sha256": task.Input.SHA256,
},
"parameters": task.Parameters,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, err
}
taskJSONPath := filepath.Join(taskDir, "task.json")
if err := os.WriteFile(taskJSONPath, payloadBytes, 0o600); err != nil {
return nil, err
}
args := append([]string{}, r.command[1:]...)
args = append(args,
"--task-json", taskJSONPath,
"--task-dir", taskDir,
"--output", manifestPath,
)
// #nosec G204 -- the command comes from the operator-configured TASK_RUNNER.
command := exec.CommandContext(context.Background(), r.command[0], args...)
command.Dir = taskDir
command.Env = append(os.Environ(), extraEnv...)
var stderr bytes.Buffer
command.Stderr = &stderr
if err := command.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil, runnerExitError(exitErr.ExitCode(), stderr.String())
}
return nil, fmt.Errorf("task runner could not be started: %w", err)
}
// #nosec G304 -- the manifest path is inside the worker's own task directory.
raw, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("task runner produced no result manifest")
}
var manifest TaskRunnerManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("task runner produced an invalid result manifest")
}
if manifest.ArtifactPath == "" || manifest.ContentType == "" {
return nil, fmt.Errorf("task runner produced an incomplete result manifest")
}
info, err := os.Stat(manifest.ArtifactPath)
if err != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("task runner produced no artifact file")
}
return &manifest, nil
}
+16
View File
@@ -51,6 +51,9 @@ type Config struct {
LogFile string
// Directory where artifact bytes are stored.
StorageDir string
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
// disables the docs route; the UI shows a hint page instead.
DocsDir string
// Upper bound on an uploaded dataset or artifact body, in bytes.
MaxUploadBytes int64
@@ -75,6 +78,10 @@ type Config struct {
ReaperInterval time.Duration
// A worker silent for longer than this is marked offline by the reaper.
WorkerOfflineAfter time.Duration
// Whether the binary applies its embedded schema migrations on startup.
// On by default so a downloaded binary provisions its own database; set
// AUTO_MIGRATE=false when an operator manages migrations out of band.
AutoMigrate bool
}
// Load reads the environment and fails fast on anything required-but-missing
@@ -108,6 +115,7 @@ func LoadConfig() (Config, error) {
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
MaxUploadBytes: 1 << 30, // 1 GiB
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
@@ -169,6 +177,14 @@ func LoadConfig() (Config, error) {
if cfg.DefaultMaxAttempts < 1 {
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
}
cfg.AutoMigrate = true
if raw := os.Getenv("AUTO_MIGRATE"); raw != "" {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false")
}
cfg.AutoMigrate = parsed
}
return cfg, nil
}
+57
View File
@@ -0,0 +1,57 @@
// Package reducer contains deterministic, coordinator-side result reductions.
package reducer
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
)
// ReduceOrderedConcat concatenates worker partial tables in shard order into a
// single table with one header. Every partial must carry the same header as the
// first partial and rows of the same width; anything else fails the job closed.
func ReduceOrderedConcat(partials []io.Reader) ([]byte, error) {
var out bytes.Buffer
writer := csv.NewWriter(&out)
var firstHeader []string
for _, partial := range partials {
reader := csv.NewReader(partial)
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("read partial header: %w", err)
}
if len(header) == 0 {
return nil, fmt.Errorf("partial result has an empty header")
}
if firstHeader == nil {
firstHeader = header
if err := writer.Write(header); err != nil {
return nil, err
}
} else if !equalStrings(header, firstHeader) {
return nil, fmt.Errorf("partial result has an inconsistent header")
}
for {
row, err := reader.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read partial row: %w", err)
}
if len(row) != len(header) {
return nil, fmt.Errorf("partial result has a row with an inconsistent width")
}
if err := writer.Write(row); err != nil {
return nil, err
}
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
@@ -0,0 +1,64 @@
package reducer
import (
"io"
"strings"
"testing"
)
func TestReduceOrderedConcatJoinsPartialsInOrderWithOneHeader(t *testing.T) {
first := strings.NewReader("chembl_id,canonical_smiles\nA,CC\nB,CCC\n")
second := strings.NewReader("chembl_id,canonical_smiles\nC,CCCC\n")
output, err := ReduceOrderedConcat([]io.Reader{first, second})
if err != nil {
t.Fatal(err)
}
want := "chembl_id,canonical_smiles\nA,CC\nB,CCC\nC,CCCC\n"
if string(output) != want {
t.Fatalf("output = %q, want %q", output, want)
}
}
func TestReduceOrderedConcatIsDeterministicAcrossInputOrder(t *testing.T) {
left := strings.NewReader("id,rows\nA,1\nB,2\n")
right := strings.NewReader("id,rows\nC,3\n")
first, err := ReduceOrderedConcat([]io.Reader{left, right})
if err != nil {
t.Fatal(err)
}
left, right = strings.NewReader("id,rows\nA,1\nB,2\n"), strings.NewReader("id,rows\nC,3\n")
second, err := ReduceOrderedConcat([]io.Reader{left, right})
if err != nil {
t.Fatal(err)
}
if string(first) != string(second) {
t.Fatalf("concat is not deterministic: %q != %q", first, second)
}
}
func TestReduceOrderedConcatRejectsInconsistentHeaders(t *testing.T) {
first := strings.NewReader("a,b\n1,2\n")
second := strings.NewReader("a,c\n1,2\n")
if _, err := ReduceOrderedConcat([]io.Reader{first, second}); err == nil {
t.Fatal("inconsistent headers must fail")
}
}
func TestReduceOrderedConcatRejectsRaggedRows(t *testing.T) {
partial := strings.NewReader("a,b\n1,2,3\n")
if _, err := ReduceOrderedConcat([]io.Reader{partial}); err == nil {
t.Fatal("ragged rows must fail")
}
}
func TestReduceOrderedConcatEmptyPartials(t *testing.T) {
output, err := ReduceOrderedConcat(nil)
if err != nil {
t.Fatal(err)
}
if len(output) != 0 {
t.Fatalf("empty input must produce empty output, got %q", output)
}
}
+239
View File
@@ -0,0 +1,239 @@
// Package setup implements the `coordinator setup` wizard: database reachability
// and creation, embedded schema migration, secret generation, and .env writing.
// The wizard never logs or echoes secrets.
package setup
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
)
// Options configures one wizard run.
type Options struct {
// DatabaseURL is the target coordinator database (pgx/libpq URL).
DatabaseURL string
// AdminDatabaseURL, when set, is used to create a missing target database.
// Defaults to the target URL with the database name replaced by "postgres".
AdminDatabaseURL string
// EnvFile is where the generated settings are written (default ".env").
EnvFile string
// Force overwrites an existing EnvFile.
Force bool
// Yes disables interactive prompts; missing values fail instead.
Yes bool
// ConnectTimeout bounds the reachability check.
ConnectTimeout time.Duration
// Out receives progress and summary output; In feeds interactive answers.
Out io.Writer
In io.Reader
}
// Run executes the wizard and returns a summary of what was done.
func Run(ctx context.Context, options Options) (string, error) {
if options.DatabaseURL == "" {
return "", fmt.Errorf("DATABASE_URL is required (or pass --db)")
}
if options.EnvFile == "" {
options.EnvFile = ".env"
}
if options.ConnectTimeout <= 0 {
options.ConnectTimeout = 5 * time.Second
}
if options.Out == nil {
options.Out = os.Stdout
}
report := func(format string, args ...any) {
_, _ = fmt.Fprintf(options.Out, format+"\n", args...)
}
report("SciMesh coordinator setup")
report("")
// 1. Reachability, with optional database creation.
target, err := pgx.ParseConfig(options.DatabaseURL)
if err != nil {
return "", fmt.Errorf("DATABASE_URL is not a valid postgres URL: %w", err)
}
if err := probeDatabase(ctx, target, options.ConnectTimeout); err != nil {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "3D000" {
return "", fmt.Errorf("cannot reach the coordinator database: %w", err)
}
report("database %q does not exist yet", target.Database)
admin, err := resolveAdminConfig(options, target)
if err != nil {
return "", err
}
if err := createDatabase(ctx, admin, target.Database, options.ConnectTimeout); err != nil {
return "", fmt.Errorf("cannot create database %q: %w", target.Database, err)
}
report("created database %q", target.Database)
}
report("database %q is reachable", target.Database)
// 2. Apply the embedded schema migrations (idempotent).
if err := postgres.Migrate(ctx, options.DatabaseURL, nil); err != nil {
return "", fmt.Errorf("apply schema migrations: %w", err)
}
report("schema migrations applied")
// 3. JWT secret: reuse the environment value when strong, else generate.
secret := os.Getenv("JWT_SECRET")
if secret != "" && len(secret) < 32 {
return "", fmt.Errorf("JWT_SECRET must be at least 32 bytes")
}
if secret == "" {
generated, err := generateSecret()
if err != nil {
return "", fmt.Errorf("generate JWT_SECRET: %w", err)
}
secret = generated
report("generated a fresh JWT_SECRET")
}
// 4. Write the .env file.
storageDir := os.Getenv("COORDINATOR_STORAGE_DIR")
if storageDir == "" {
storageDir = "./data"
}
if err := writeEnvFile(options, secret, storageDir); err != nil {
return "", err
}
// 5. Summary.
var summary strings.Builder
fmt.Fprintf(&summary, "Setup complete.\n\n")
fmt.Fprintf(&summary, "Ready:\n")
fmt.Fprintf(&summary, " - database %s is reachable and migrated\n", target.Database)
fmt.Fprintf(&summary, " - settings written to %s (chmod 0600)\n", options.EnvFile)
fmt.Fprintf(&summary, "\nStart the coordinator:\n")
fmt.Fprintf(&summary, " ENV_FILE=%s ./coordinator\n", options.EnvFile)
fmt.Fprintf(&summary, "\nOptional — userservice for UI logins (must share JWT_SECRET):\n")
fmt.Fprintf(&summary, " cd users && JWT_SECRET=%q docker compose up -d\n", secret)
fmt.Fprintf(&summary, " then set USERSERVICE_URL=http://localhost:8081 and BOOTSTRAP_ADMIN_EMAIL/PASSWORD\n")
fmt.Fprintf(&summary, "\nThe wizard cannot run PostgreSQL or the userservice for you; the\n")
fmt.Fprintf(&summary, "commands above are the supported way to start them.\n")
return summary.String(), nil
}
// probeDatabase verifies the target database accepts connections.
func probeDatabase(ctx context.Context, config *pgx.ConnConfig, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, config)
if err != nil {
return err
}
return conn.Close(ctx)
}
// resolveAdminConfig picks the maintenance connection used to create
// databases. pgx's ConnConfig.ConnString() caches the original URL, so the
// config itself (not a re-rendered string) is what the caller connects with.
func resolveAdminConfig(options Options, target *pgx.ConnConfig) (*pgx.ConnConfig, error) {
if options.AdminDatabaseURL != "" {
config, err := pgx.ParseConfig(options.AdminDatabaseURL)
if err != nil {
return nil, fmt.Errorf("--admin-db is not a valid postgres URL: %w", err)
}
return config, nil
}
admin := *target
admin.Database = "postgres"
return &admin, nil
}
// createDatabase creates the named database through the maintenance connection.
func createDatabase(ctx context.Context, admin *pgx.ConnConfig, name string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, admin)
if err != nil {
return err
}
defer func() { _ = conn.Close(ctx) }()
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
if _, err := conn.Exec(ctx, "CREATE DATABASE "+quoted); err != nil {
return err
}
return nil
}
// generateSecret returns 32 random bytes as lowercase hex.
func generateSecret() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
// writeEnvFile writes the settings, refusing to clobber without --force.
func writeEnvFile(options Options, secret, storageDir string) error {
path := filepath.Clean(options.EnvFile)
if _, err := os.Stat(path); err == nil && !options.Force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
content := strings.Join([]string{
"DATABASE_URL=" + options.DatabaseURL,
"JWT_SECRET=" + secret,
"COORDINATOR_STORAGE_DIR=" + storageDir,
"", // trailing newline
}, "\n")
// #nosec G703 -- the env file path is operator-supplied (--env-file / ENV_FILE).
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
return nil
}
// SanitizeDatabaseURL hides the password for logging.
func SanitizeDatabaseURL(raw string) string {
at := strings.LastIndex(raw, "@")
if at < 0 {
return raw
}
start := 0
if strings.HasPrefix(raw, "postgres://") || strings.HasPrefix(raw, "postgresql://") {
start = len("postgres://")
}
colon := strings.Index(raw[start:at], ":")
if colon < 0 {
return raw
}
colon += start
return raw[:colon] + ":***@" + raw[at+1:]
}
// prompt asks a question and returns the trimmed answer ("" on EOF).
func prompt(options Options, question, fallback string) string {
_, _ = fmt.Fprintf(options.Out, "%s [%s]: ", question, fallback)
reader := bufio.NewReader(options.In)
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fallback
}
answer := strings.TrimSpace(line)
if answer == "" {
return fallback
}
return answer
}
@@ -0,0 +1,91 @@
//go:build integration
package setup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
func TestRunProvisionsDatabaseSchemaAndEnvFile(t *testing.T) {
ctx := context.Background()
base := os.Getenv("TEST_DATABASE_URL")
if base == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
// The wizard must create a *missing* database through the admin URL.
slash := strings.LastIndex(base, "/")
target := base[:slash+1] + "scimesh_setup_test"
admin := base[:slash+1] + "postgres"
cleanup := func() {
conn, err := pgx.Connect(ctx, admin)
if err != nil {
return
}
defer func() { _ = conn.Close(ctx) }()
_, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS "scimesh_setup_test"`)
}
cleanup()
t.Cleanup(cleanup)
envPath := filepath.Join(t.TempDir(), ".env")
var output strings.Builder
options := Options{
DatabaseURL: target,
AdminDatabaseURL: admin,
EnvFile: envPath,
Force: true,
Yes: true,
ConnectTimeout: 10 * time.Second,
Out: &output,
In: strings.NewReader(""),
}
summary, err := Run(ctx, options)
if err != nil {
t.Fatalf("setup run: %v", err)
}
for _, expected := range []string{"created database", "schema migrations applied"} {
if !strings.Contains(output.String(), expected) {
t.Errorf("progress output is missing %q:\n%s", expected, output.String())
}
}
for _, expected := range []string{"Setup complete", "Start the coordinator"} {
if !strings.Contains(summary, expected) {
t.Errorf("summary is missing %q:\n%s", expected, summary)
}
}
// The database now exists, is migrated, and the env file is written.
conn, err := pgx.Connect(ctx, target)
if err != nil {
t.Fatalf("connect to provisioned database: %v", err)
}
defer func() { _ = conn.Close(ctx) }()
var watermark int64
if err := conn.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if watermark < 13 {
t.Errorf("schema watermark = %d, want >= 13", watermark)
}
envContent, err := os.ReadFile(envPath)
if err != nil {
t.Fatal(err)
}
env := string(envContent)
if !strings.Contains(env, "DATABASE_URL="+target) || !strings.Contains(env, "JWT_SECRET=") {
t.Errorf("env file is incomplete:\n%s", env)
}
// A second run is idempotent: no create-database error, same outcome.
if _, err := Run(ctx, options); err != nil {
t.Fatalf("second setup run: %v", err)
}
}
+96
View File
@@ -0,0 +1,96 @@
package setup
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateSecretIsRandomAndStrong(t *testing.T) {
first, err := generateSecret()
if err != nil {
t.Fatal(err)
}
second, err := generateSecret()
if err != nil {
t.Fatal(err)
}
if len(first) != 64 || len(second) != 64 {
t.Fatalf("secrets must be 32 random bytes as hex, got %d and %d", len(first), len(second))
}
if first == second {
t.Fatal("two generated secrets must differ")
}
}
func TestWriteEnvFileContentsAndPermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := writeEnvFile(Options{
EnvFile: path,
DatabaseURL: "postgres://scimesh@localhost/scimesh",
}, "s3cr3t", "./data"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "DATABASE_URL=postgres://scimesh@localhost/scimesh\nJWT_SECRET=s3cr3t\nCOORDINATOR_STORAGE_DIR=./data\n"
if got := string(content); got != want {
t.Fatalf("env file = %q, want %q", got, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("env file mode = %o, want 0600", info.Mode().Perm())
}
}
func TestWriteEnvFileRefusesWithoutForce(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "a", "./data"); err != nil {
t.Fatal(err)
}
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err == nil {
t.Fatal("second write without --force must fail")
}
if err := writeEnvFile(Options{EnvFile: path, Force: true, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err != nil {
t.Fatalf("write with --force: %v", err)
}
}
func TestPromptReadsAnswer(t *testing.T) {
var out bytes.Buffer
answer := prompt(Options{Out: &out, In: strings.NewReader("postgres://custom\n")}, "Database URL", "default")
if answer != "postgres://custom" {
t.Fatalf("answer = %q, want the typed value", answer)
}
if !strings.Contains(out.String(), "Database URL [default]:") {
t.Fatalf("prompt output = %q", out.String())
}
fallback := prompt(Options{Out: &out, In: strings.NewReader("\n")}, "Question", "fb")
if fallback != "fb" {
t.Fatalf("empty answer must fall back, got %q", fallback)
}
}
func TestSanitizeDatabaseURL(t *testing.T) {
cases := map[string]string{
"postgres://scimesh:hunter2@localhost:5432/scimesh?sslmode=disable": "postgres://scimesh:***@localhost:5432/scimesh?sslmode=disable",
"postgresql://scimesh@localhost/scimesh": "postgresql://scimesh@localhost/scimesh",
"not-a-url": "not-a-url",
}
for raw, want := range cases {
got := SanitizeDatabaseURL(raw)
if got != want {
t.Errorf("sanitize(%q) = %q, want %q", raw, got, want)
}
if strings.Contains(got, "hunter2") {
t.Errorf("sanitize(%q) leaked the password", raw)
}
}
}
@@ -24,6 +24,7 @@ import (
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
func testPool(t *testing.T) *pgxpool.Pool {
@@ -408,7 +409,7 @@ func TestCompleteTaskReplayIsIdempotent(t *testing.T) {
tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool)
clk := fixedClock{now: time.Now().UTC()}
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2)
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2, integrationCatalog())
claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{
Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute),
@@ -657,3 +658,46 @@ func TestExpireLeasesRequeuesElapsedTasks(t *testing.T) {
t.Errorf("pending = %d, want 1 — a dead worker must not strand its task", counts[domain.TaskPending])
}
}
func TestMigrateProvisionsAndIsIdempotent(t *testing.T) {
ctx := context.Background()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
if err := Migrate(ctx, url, nil); err != nil {
t.Fatalf("first migrate: %v", err)
}
if err := Migrate(ctx, url, nil); err != nil {
t.Fatalf("second migrate (idempotent): %v", err)
}
pool := testPool(t)
migrations, err := listMigrations()
if err != nil {
t.Fatal(err)
}
var watermark int64
if err := pool.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if watermark != int64(len(migrations)) {
t.Errorf("schema watermark = %d, want %d", watermark, len(migrations))
}
var hasJobs bool
if err := pool.QueryRow(ctx,
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'jobs')",
).Scan(&hasJobs); err != nil {
t.Fatal(err)
}
if !hasJobs {
t.Error("jobs table was not created by the embedded migrations")
}
}
func integrationCatalog() *workloads.Catalog {
catalog, err := workloads.Load()
if err != nil {
panic(err)
}
return catalog
}
@@ -0,0 +1,144 @@
package postgres
import (
"context"
"embed"
"fmt"
"log/slog"
"regexp"
"sort"
"strconv"
"github.com/jackc/pgx/v5"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.(up|down)\.sql$`)
// migration is one parsed embedded migration file.
type migration struct {
version int
name string
sql string
}
// listMigrations parses and orders the embedded .up.sql files by version.
func listMigrations() ([]migration, error) {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("read embedded migrations: %w", err)
}
up := map[int]migration{}
for _, entry := range entries {
match := migrationNamePattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
if match[2] != "up" {
continue
}
version, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
}
if _, duplicate := up[version]; duplicate {
return nil, fmt.Errorf("migration version %d is duplicated", version)
}
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return nil, fmt.Errorf("read migration %q: %w", entry.Name(), err)
}
up[version] = migration{version: version, name: entry.Name(), sql: string(body)}
}
if len(up) == 0 {
return nil, fmt.Errorf("no .up.sql migrations are embedded")
}
versions := make([]int, 0, len(up))
for version := range up {
versions = append(versions, version)
}
sort.Ints(versions)
migrations := make([]migration, 0, len(versions))
for _, version := range versions {
migrations = append(migrations, up[version])
}
for index, item := range migrations {
if item.version != index+1 {
return nil, fmt.Errorf("embedded migrations are not contiguous: version %d at position %d", item.version, index+1)
}
}
return migrations, nil
}
// Migrate applies every embedded migration above the recorded schema version,
// so the binary provisions its own schema. It is idempotent and interoperates
// with the golang-migrate CLI: both tools use the same schema_migrations
// watermark table (single row: version + dirty flag), and a PostgreSQL
// advisory lock serializes concurrent migrators. Each migration file runs as
// its own transaction (the files carry explicit BEGIN/COMMIT, matching the
// golang-migrate format the CLI and CI still use).
func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
migrations, err := listMigrations()
if err != nil {
return err
}
connConfig, err := pgx.ParseConfig(databaseURL)
if err != nil {
return fmt.Errorf("parse database url: %w", err)
}
// Migration files contain multiple statements (BEGIN...COMMIT), which the
// extended query protocol rejects; run them with the simple protocol.
connConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
conn, err := pgx.ConnectConfig(ctx, connConfig)
if err != nil {
return fmt.Errorf("connect for migration: %w", err)
}
defer func() { _ = conn.Close(ctx) }()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(82473911)"); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() { _, _ = conn.Exec(ctx, "SELECT pg_advisory_unlock(82473911)") }()
if _, err := conn.Exec(ctx,
"CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)",
); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
var applied int64
if err := conn.QueryRow(ctx,
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
).Scan(&applied); err != nil {
return fmt.Errorf("read applied schema version: %w", err)
}
for _, item := range migrations {
if int64(item.version) <= applied {
continue
}
if log != nil {
log.Info("applying migration", "version", item.version, "file", item.name)
}
if _, err := conn.Exec(ctx, item.sql); err != nil {
return fmt.Errorf("apply migration %s: %w", item.name, err)
}
// Advance the watermark to the single-row golang-migrate layout.
tag, err := conn.Exec(ctx,
"UPDATE schema_migrations SET version = $1, dirty = false", item.version)
if err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
if tag.RowsAffected() == 0 {
if _, err := conn.Exec(ctx,
"INSERT INTO schema_migrations (version, dirty) VALUES ($1, false)",
item.version,
); err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
}
}
return nil
}
@@ -0,0 +1,60 @@
package postgres
import (
"strings"
"testing"
)
func TestListMigrationsParsesAndOrdersEmbeddedFiles(t *testing.T) {
migrations, err := listMigrations()
if err != nil {
t.Fatalf("list migrations: %v", err)
}
if len(migrations) == 0 {
t.Fatal("no embedded migrations")
}
for index, item := range migrations {
if item.version != index+1 {
t.Errorf("migration %d has version %d, want contiguous ordering", index, item.version)
}
if item.name != expectedMigrationName(item.version) {
t.Errorf("migration %d file is %q, want %q", item.version, item.name, expectedMigrationName(item.version))
}
if strings.TrimSpace(item.sql) == "" {
t.Errorf("migration %d is empty", item.version)
}
}
}
func expectedMigrationName(version int) string {
switch version {
case 1:
return "0001_init.up.sql"
case 2:
return "0002_workers.up.sql"
case 3:
return "0003_artifacts.up.sql"
case 4:
return "0004_result_artifact.up.sql"
case 5:
return "0005_uploaded_input.up.sql"
case 6:
return "0006_task_running_enum.up.sql"
case 7:
return "0007_task_running_lease.up.sql"
case 8:
return "0008_artifact_attempt.up.sql"
case 9:
return "0009_unique_partial_result_attempt.up.sql"
case 10:
return "0010_job_reduction.up.sql"
case 11:
return "0011_job_owner.up.sql"
case 12:
return "0012_worker_trust.up.sql"
case 13:
return "0013_task_results.up.sql"
default:
return ""
}
}
@@ -56,6 +56,8 @@ type Server struct {
publicUserserviceURL string
// httpClient makes the login/register calls to the userservice.
httpClient *http.Client
// docsDir serves the built MkDocs site at /ui/docs/. Empty disables it.
docsDir string
// metrics holds the Prometheus registry and HTTP instrumentation.
metrics *metrics.Metrics
// ready probes downstream dependencies (the database) for /health. Kept as
@@ -78,6 +80,10 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
if len(publicURLs) > 1 {
publicUserserviceURL = strings.TrimRight(publicURLs[1], "/")
}
docsDir := ""
if len(publicURLs) > 2 {
docsDir = publicURLs[2]
}
return &Server{
uc: uc,
log: log,
@@ -88,6 +94,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
userserviceURL: strings.TrimRight(userserviceURL, "/"),
publicCoordinatorURL: publicCoordinatorURL,
publicUserserviceURL: publicUserserviceURL,
docsDir: docsDir,
httpClient: &http.Client{Timeout: 10 * time.Second},
metrics: m,
ready: ready,
@@ -135,6 +142,9 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
}{
{"GET /ui", s.handleUIHome},
{"GET /ui/jobs/new", s.handleUINewJob},
{"GET /ui/workloads", s.handleUIWorkloads},
{"GET /ui/docs", s.handleUIDocsIndex},
{"GET /ui/docs/{path...}", s.handleUIDocs},
{"GET /ui/jobs/{job_id}", s.handleUIJob},
{"GET /ui/api/overview", s.handleUIOverviewJSON},
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
@@ -47,19 +47,19 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
uc := coordhttp.UseCases{
RegisterWorker: usecase.NewRegisterWorker(work, clk),
CreateJob: usecase.NewCreateJob(jobs, tasks, tx, clk),
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3),
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease),
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog()),
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease, testCatalog()),
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2),
ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk),
FailTask: usecase.NewFailTask(tasks, jobs, tx, clk),
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2, testCatalog()),
ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk, testCatalog()),
FailTask: usecase.NewFailTask(tasks, jobs, work, tx, clk, testCatalog()),
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact),
CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(tasks, work, arts, blobs, tx, clk),
DownloadArtifact: downloadArtifact,
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts), testCatalog()),
PreviewArtifact: usecase.NewPreviewArtifact(memstore.NewUIReadRepo(jobs, tasks, work, arts), blobs),
}
worker, err := uc.RegisterWorker.Execute(context.Background(), usecase.RegisterWorkerInput{
@@ -33,7 +33,7 @@
</ol>
<h2 style="margin-top:24px">Will my results count?</h2>
<p>Your worker is <strong>untrusted</strong> by default: its results are cross-checked and accepted once a second independent worker computes the same answer (quorum), or once an admin marks your account <strong>verified</strong> — then your workers are trusted and results count immediately.</p>
<p><span class="cap">similarity-search</span> is the only workload a volunteer worker runs today.</p>
<p><span class="cap">similarity-search</span> and other SDK workloads from the library run on volunteer workers.</p>
</aside>
</div>
</main>
@@ -13,7 +13,7 @@
<main class="page">
<header class="top">
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new"> New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workloads" style="background:#23344d;color:#dce8ff;box-shadow:none">Workloads</a>{{end}}{{if .Session}}<a class="button" href="/ui/docs/" style="background:#23344d;color:#dce8ff;box-shadow:none">Docs</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new"> New computation</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
</header>
<section class="summary" aria-label="Pipeline summary">
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
@@ -22,7 +22,7 @@
<div class="metric"><span>Finished runs</span><b id="finished-jobs">{{.FinishedJobs}}</b><small>in the latest 20</small></div>
</section>
<section><div class="section-head"><h2>Recent computations</h2><p id="job-count">{{len .Jobs}} shown · newest first</p></div><div id="jobs" class="jobs">{{range .Jobs}}<a class="job" href="/ui/jobs/{{.ID}}"><div><div class="job-name">{{workloadLabel .Workload}}</div><div class="job-id">{{.ID}}</div></div><div><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><div class="job-id">{{statusHint .Status}}</div></div><div><div class="numbers"><b>{{.Completed}}</b> / {{.Total}} shards complete{{if gt .Failed 0}} · <span class="offline">{{.Failed}} failed</span>{{end}}</div><div class="bar"><span style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div></div><span class="arrow" aria-hidden="true"></span></a>{{else}}<div class="empty"><strong>No computations yet.</strong><br>Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.</div>{{end}}</div></section>
<section><div class="section-head"><h2>Recent computations</h2><p id="job-count">{{len .Jobs}} shown · newest first</p></div><div id="jobs" class="jobs">{{range .Jobs}}<a class="job" href="/ui/jobs/{{.ID}}"><div><div class="job-name">{{workloadLabel .Workload}}</div><div class="job-id">{{.ID}}</div></div><div><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><div class="job-id">{{statusHint .Status}}</div></div><div><div class="numbers"><b>{{.Completed}}</b> / {{.Total}} shards complete{{if gt .Failed 0}} · <span class="offline">{{.Failed}} failed</span>{{end}}</div><div class="bar"><span style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div></div><span class="arrow" aria-hidden="true"></span></a>{{else}}<div class="empty"><strong>No computations yet.</strong><br>Start a computation, then keep one or more workers running to watch this dashboard come alive.</div>{{end}}</div></section>
{{if and .Session (ne .Session.Role "admin")}}<section><div class="section-head"><h2>My machines</h2><p>Workers you registered. <a href="/ui/workers/new" style="color:#79aaff">Add your machine →</a></p></div><div id="my-workers" class="workers">{{range .MyWorkers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No machine of yours is connected.</strong><br><a href="/ui/workers/new" style="color:#79aaff">Turn this computer into a worker →</a></div>{{end}}</div></section>{{end}}
<section><div class="section-head"><h2>Worker fleet</h2><p>Workers register themselves; this page never controls their processes.</p></div><div id="workers" class="workers">{{range .Workers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No worker is registered.</strong><br>Start <code>scimesh-worker</code> in another terminal, then return here.</div>{{end}}</div></section>
</main>
@@ -30,7 +30,7 @@
const statusInfo={pending:['Waiting for a worker','waiting'],leased:['Assigned to a worker','active'],running:['Running','active'],reducing:['Merging results','active'],completed:['Completed','success'],failed:['Needs attention','danger'],cancelled:['Stopped','waiting']};
const pct=j=>j.total?Math.min(100,Math.floor((j.completed+j.failed+j.cancelled)*100/j.total)):0;
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
const renderJobs=jobs=>{const box=document.querySelector('#jobs');box.replaceChildren();if(!jobs.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No computations yet.'),document.createElement('br'),document.createTextNode('Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.'));box.append(empty);return}for(const job of jobs){const info=statusInfo[job.status]||[job.status,'waiting'],link=node('a',undefined,'job');link.href='/ui/jobs/'+encodeURIComponent(job.id);const intro=node('div');intro.append(node('div',job.workload==='similarity-search'?'Molecule similarity search':job.workload,'job-name'),node('div',job.id,'job-id'));const state=node('div');state.append(node('span',info[0],'badge badge-'+info[1]),node('div',job.status==='reducing'?'Every shard is complete; coordinator is ranking the global top-k.':'Live coordinator state','job-id'));const progress=node('div'),numbers=node('div',undefined,'numbers');numbers.append(node('b',String(job.completed)),document.createTextNode(' / '+job.total+' shards complete'));if(job.failed){numbers.append(document.createTextNode(' · '),node('span',job.failed+' failed','offline'))}const bar=node('div',undefined,'bar'),fill=node('span');fill.style.width=pct(job)+'%';bar.append(fill);progress.append(numbers,bar);link.append(intro,state,progress,node('span','→','arrow'));box.append(link)}};
const renderJobs=jobs=>{const box=document.querySelector('#jobs');box.replaceChildren();if(!jobs.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No computations yet.'),document.createElement('br'),document.createTextNode('Start a computation, then keep one or more workers running to watch this dashboard come alive.'));box.append(empty);return}for(const job of jobs){const info=statusInfo[job.status]||[job.status,'waiting'],link=node('a',undefined,'job');link.href='/ui/jobs/'+encodeURIComponent(job.id);const intro=node('div');intro.append(node('div',job.workload==='similarity-search'?'Molecule similarity search':job.workload,'job-name'),node('div',job.id,'job-id'));const state=node('div');state.append(node('span',info[0],'badge badge-'+info[1]),node('div',job.status==='reducing'?'Every shard is complete; coordinator is ranking the global top-k.':'Live coordinator state','job-id'));const progress=node('div'),numbers=node('div',undefined,'numbers');numbers.append(node('b',String(job.completed)),document.createTextNode(' / '+job.total+' shards complete'));if(job.failed){numbers.append(document.createTextNode(' · '),node('span',job.failed+' failed','offline'))}const bar=node('div',undefined,'bar'),fill=node('span');fill.style.width=pct(job)+'%';bar.append(fill);progress.append(numbers,bar);link.append(intro,state,progress,node('span','→','arrow'));box.append(link)}};
const workerCard=worker=>{const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));return card};
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
const renderMyWorkers=workers=>{const box=document.querySelector('#my-workers');if(!box)return;box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty'),link=node('a','Turn this computer into a worker →');link.href='/ui/workers/new';link.style.color='#79aaff';empty.append(node('strong','No machine of yours is connected.'),document.createElement('br'),link);box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
@@ -0,0 +1,24 @@
{{define "docs-unavailable.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Documentation · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:760px;margin:auto;padding:80px 22px}a{color:#94bdff}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3rem);letter-spacing:-.055em}.card{margin-top:26px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card p{color:#b9c9e2}code{background:#0a1626;border:1px solid #2b4a6b;border-radius:6px;padding:2px 7px;color:#b5d3f5;font-size:.88em}.back{display:inline-block;margin-top:22px;text-decoration:none}
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">MkDocs site</p>
<h1>Documentation is not available</h1>
<div class="card">
<p>The documentation site has not been built or the coordinator has not been pointed at it. From the repository root, run:</p>
<p><code>make docs</code> &nbsp;then restart the coordinator with <code>SCIMESH_DOCS_DIR</code> set to the generated <code>site/</code> directory (the <code>make demo-ui</code> demo does this automatically).</p>
<a class="back" href="/ui">← Back to the control room</a>
</div>
</main>
</body>
</html>
{{end}}
@@ -20,7 +20,7 @@
<section><div class="section-head"><h2>Pipeline stages</h2><p>Each stage reflects coordinator state, not a simulated progress bar.</p></div><div id="stages" class="pipeline"><article class="stage stage-done"><span class="index">1</span><b>TSV accepted</b><p>The coordinator stored the source and created shard tasks.</p></article><article class="stage {{if or (eq .Status "running") (eq .Status "leased") (eq .Status "reducing") (eq .Status "completed")}}stage-active{{else}}stage-waiting{{end}}"><span class="index">2</span><b>Shards execute</b><p id="stage-shards">{{.Completed}} of {{.Total}} candidate partitions are complete.</p></article><article class="stage {{if or (eq .Status "running") (eq .Status "leased")}}stage-active{{else if or (eq .Status "reducing") (eq .Status "completed")}}stage-done{{else}}stage-waiting{{end}}"><span class="index">3</span><b>Workers return CSVs</b><p id="stage-workers">Workers upload a checked partial result for every completed shard.</p></article><article class="stage {{if eq .Status "reducing"}}stage-active{{else if eq .Status "completed"}}stage-done{{else}}stage-waiting{{end}}"><span class="index">4</span><b>Global reduction</b><p id="stage-reducer">The coordinator waits until all shards are complete.</p></article><article class="stage {{if .FinalResultAvailable}}stage-done{{else}}stage-waiting{{end}}"><span class="index">5</span><b>Final CSV</b><p id="stage-final">Available only after deterministic reduction succeeds.</p></article></div></section>
<section class="two-col"><div><div class="section-head"><h2>Run configuration</h2><p>Allowlisted scientific parameters.</p></div><article class="run-note"><h3>What is being computed?</h3><div id="parameters" class="parameter-list">{{range .Parameters}}<div class="parameter"><span>{{.Label}}</span><code>{{.Value}}</code></div>{{else}}<p>No displayable parameters were supplied.</p>{{end}}</div></article></div><div><div class="section-head"><h2>Result status</h2><p>Safe operator guidance.</p></div><article id="result-card" class="run-note {{if .FinalResultAvailable}}result{{else if eq .Status "failed"}}alert{{end}}">{{if .FinalResultAvailable}}<h3>Final result ready</h3><p>The coordinator merged shard candidates with exact scores and stored the global top-k CSV.</p>{{range .Artifacts}}{{if and (eq .Kind "final_result") .Downloadable}}<a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}/preview">Preview CSV</a><a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Download final CSV</a>{{end}}{{end}}{{else if eq .Status "reducing"}}<h3>Merging completed shards</h3><p>The final candidate heap is being ranked now. This page will update when the CSV is stored.</p>{{else if eq .Status "failed"}}<h3>Run needs attention</h3><p>{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}One or more shards could not produce a final result. Review the task table below.{{end}}</p>{{else}}<h3>Waiting for the final result</h3><p>Partial CSVs are diagnostics. They become one global result only after every shard completes and reduction succeeds.</p>{{end}}</article></div></section>
<section class="two-col"><div><div class="section-head"><h2>Run configuration</h2><p>Allowlisted scientific parameters.</p></div><article class="run-note"><h3>What is being computed?</h3><div id="parameters" class="parameter-list">{{range .Parameters}}<div class="parameter"><span>{{.Label}}</span><code>{{.Value}}</code></div>{{else}}<p>No displayable parameters were supplied.</p>{{end}}</div></article></div><div><div class="section-head"><h2>Result status</h2><p>Safe operator guidance.</p></div><article id="result-card" class="run-note {{if .FinalResultAvailable}}result{{else if eq .Status "failed"}}alert{{end}}">{{if .FinalResultAvailable}}<h3>Final result ready</h3><p>The coordinator reduced every completed shard into one checksum-protected result file.</p>{{range .Artifacts}}{{if and (eq .Kind "final_result") .Downloadable}}<a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}/preview">Preview CSV</a><a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Download final CSV</a>{{end}}{{end}}{{else if eq .Status "reducing"}}<h3>Merging completed shards</h3><p>Every shard is complete; the coordinator is reducing the partial results now. This page will update when the result file is stored.</p>{{else if eq .Status "failed"}}<h3>Run needs attention</h3><p>{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}One or more shards could not produce a final result. Review the task table below.{{end}}</p>{{else}}<h3>Waiting for the final result</h3><p>Partial CSVs are diagnostics. They become one final result only after every shard completes and reduction succeeds.</p>{{end}}</article></div></section>
<section><div class="section-head"><h2>Shard activity</h2><p id="task-caption">Every task is one input partition. The table refreshes while work is in progress.</p></div><div class="table-wrap"><table><thead><tr><th>Shard</th><th>State</th><th>Attempt</th><th>Worker / lease</th><th>Outcome</th></tr></thead><tbody id="tasks">{{range .Tasks}}<tr><td>#{{.ChunkIndex}}</td><td><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span></td><td>{{.Attempt}} / {{.MaxAttempts}}</td><td>{{if .LeaseOwner}}<strong>{{.LeaseOwner}}</strong>{{if .LeaseExpiresAt}}<br><small class="muted">lease until {{time .LeaseExpiresAt}}</small>{{end}}{{else}}<span class="muted"></span>{{end}}</td><td class="error">{{if .ErrorCode}}<strong>{{taskErrorLabel .ErrorCode}}</strong><br><small>{{taskErrorHint .ErrorCode}}</small>{{else if eq .Status "completed"}}<span class="muted">Partial CSV uploaded</span>{{else}}<span class="muted"></span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">No shard tasks are present yet.</td></tr>{{end}}</tbody></table></div></section>
@@ -31,9 +31,9 @@
const id={{printf "%q" .ID}},statusInfo={pending:['Waiting for a worker','waiting','A compatible worker has not claimed a shard yet.'],leased:['Assigned to a worker','active','A worker has a shard lease and should begin shortly.'],running:['Running','active','Workers are calculating fingerprints and returning shard-level candidates.'],reducing:['Merging results','active','All shards are complete. The coordinator is ranking the global top-k.'],completed:['Completed','success','The final result is stored and ready to download.'],failed:['Needs attention','danger','The job cannot produce a final result. Review the safe diagnosis below.'],cancelled:['Stopped','waiting','The operator stopped unfinished shards.']},terminal=new Set(['completed','failed','cancelled']);
const text=(tag,value,cls)=>{const n=document.createElement(tag);if(value!==undefined)n.textContent=value;if(cls)n.className=cls;return n},pct=j=>j.total?Math.min(100,Math.floor((j.completed+j.failed+j.cancelled)*100/j.total)):0,fmtTime=value=>value?new Date(value).toLocaleString():'—',fmtBytes=n=>n<1024?n+' B':n<1024*1024?(n/1024).toFixed(1)+' KiB':(n/(1024*1024)).toFixed(1)+' MiB',taskError={CalledProcessError:['Local calculation failed','The local SciMesh command stopped before it could upload a result. Check the worker terminal for the original error.'],ValueError:['Task input could not be processed','The shard or its parameters did not meet the worker validation rules.'],CoordinatorTransientError:['Coordinator connection was interrupted','The worker will retry when the coordinator is available again.'],CoordinatorConflictError:['Worker lease was no longer valid','Another worker or a lease timeout changed this shard before completion.'],FileNotFoundError:['Local task file is missing','Restart the worker with an absolute --work-dir.']};
const stage=(index,title,description,state)=>{const card=text('article',undefined,'stage stage-'+state);card.append(text('span',String(index),'index'),text('b',title),text('p',description));return card};
const renderStages=job=>{const holder=document.querySelector('#stages'),allDone=job.completed===job.total&&job.total>0,reducerFailed=job.status==='failed'&&job.error_code==='reducer_failed',shardFailed=job.status==='failed'&&!reducerFailed;holder.replaceChildren(stage(1,'TSV accepted','The coordinator stored the source and created shard tasks.','done'),stage(2,'Shards execute',job.completed+' of '+job.total+' candidate partitions are complete.',shardFailed?'failed':allDone?'done':(job.running||job.leased)?'active':'waiting'),stage(3,'Workers return CSVs',allDone?'Every completed shard has a coordinator-owned partial CSV.':(job.running||job.leased)?'Workers are actively claiming and processing partitions.':'Waiting for a worker to claim a shard.',shardFailed?'failed':allDone?'done':(job.running||job.leased)?'active':'waiting'),stage(4,'Global reduction',reducerFailed?'The coordinator could not safely reduce partial results.':job.status==='reducing'?'The coordinator is merging exact candidate scores.':job.status==='completed'?'The global top-k has been merged deterministically.':'Reduction begins only after every shard completes.',reducerFailed?'failed':job.status==='reducing'?'active':job.status==='completed'?'done':'waiting'),stage(5,'Final CSV',job.final_result_available?'The checksum-protected global result is ready.':'Available only after deterministic reduction succeeds.',job.final_result_available?'done':reducerFailed?'failed':'waiting'))};
const renderStages=job=>{const holder=document.querySelector('#stages'),allDone=job.completed===job.total&&job.total>0,reducerFailed=job.status==='failed'&&job.error_code==='reducer_failed',shardFailed=job.status==='failed'&&!reducerFailed;holder.replaceChildren(stage(1,'TSV accepted','The coordinator stored the source and created shard tasks.','done'),stage(2,'Shards execute',job.completed+' of '+job.total+' candidate partitions are complete.',shardFailed?'failed':allDone?'done':(job.running||job.leased)?'active':'waiting'),stage(3,'Workers return CSVs',allDone?'Every completed shard has a coordinator-owned partial CSV.':(job.running||job.leased)?'Workers are actively claiming and processing partitions.':'Waiting for a worker to claim a shard.',shardFailed?'failed':allDone?'done':(job.running||job.leased)?'active':'waiting'),stage(4,'Global reduction',reducerFailed?'The coordinator could not safely reduce partial results.':job.status==='reducing'?'The coordinator is merging exact candidate scores.':job.status==='completed'?'The partial results have been reduced deterministically.':'Reduction begins only after every shard completes.',reducerFailed?'failed':job.status==='reducing'?'active':job.status==='completed'?'done':'waiting'),stage(5,'Final CSV',job.final_result_available?'The checksum-protected global result is ready.':'Available only after deterministic reduction succeeds.',job.final_result_available?'done':reducerFailed?'failed':'waiting'))};
const renderParameters=parameters=>{const holder=document.querySelector('#parameters');holder.replaceChildren();if(!parameters.length){holder.append(text('p','No displayable parameters were supplied.'));return}for(const parameter of parameters){const row=text('div',undefined,'parameter');row.append(text('span',parameter.label),text('code',parameter.value));holder.append(row)}};
const renderResult=job=>{const card=document.querySelector('#result-card');card.className='run-note';card.replaceChildren();if(job.final_result_available){card.classList.add('result');card.append(text('h3','Final result ready'),text('p','The coordinator merged shard candidates with exact scores and stored the global top-k CSV.'));const final=(job.artifacts||[]).find(a=>a.kind==='final_result'&&a.downloadable);if(final){const preview=text('a','Preview CSV','download');preview.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(final.id)+'/preview';const download=text('a','Download final CSV','download');download.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(final.id);card.append(preview,download)}}else if(job.status==='reducing'){card.append(text('h3','Merging completed shards'),text('p','The final candidate heap is being ranked now. This page will update when the CSV is stored.'))}else if(job.status==='failed'){card.classList.add('alert');card.append(text('h3','Run needs attention'),text('p',job.error_message||'One or more shards could not produce a final result. Review the task table below.'))}else{card.append(text('h3','Waiting for the final result'),text('p','Partial CSVs are diagnostics. They become one global result only after every shard completes and reduction succeeds.'))}};
const renderResult=job=>{const card=document.querySelector('#result-card');card.className='run-note';card.replaceChildren();if(job.final_result_available){card.classList.add('result');card.append(text('h3','Final result ready'),text('p','The coordinator reduced every completed shard into one checksum-protected result file.'));const final=(job.artifacts||[]).find(a=>a.kind==='final_result'&&a.downloadable);if(final){const preview=text('a','Preview CSV','download');preview.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(final.id)+'/preview';const download=text('a','Download final CSV','download');download.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(final.id);card.append(preview,download)}}else if(job.status==='reducing'){card.append(text('h3','Merging completed shards'),text('p','Every shard is complete; the coordinator is reducing the partial results now. This page will update when the result file is stored.'))}else if(job.status==='failed'){card.classList.add('alert');card.append(text('h3','Run needs attention'),text('p',job.error_message||'One or more shards could not produce a final result. Review the task table below.'))}else{card.append(text('h3','Waiting for the final result'),text('p','Partial CSVs are diagnostics. They become one final result only after every shard completes and reduction succeeds.'))}};
const renderTasks=tasks=>{const holder=document.querySelector('#tasks');holder.replaceChildren();if(!tasks.length){const row=document.createElement('tr'),cell=text('td','No shard tasks are present yet.','empty');cell.colSpan=5;row.append(cell);holder.append(row);return}for(const task of tasks){const row=document.createElement('tr'),info=statusInfo[task.status]||[task.status,'waiting',''];row.append(text('td','#'+task.chunk_index));const state=text('td'),badge=text('span',info[0],'badge badge-'+info[1]);state.append(badge);row.append(state,text('td',task.attempt+' / '+task.max_attempts));const worker=text('td');if(task.lease_owner){worker.append(text('strong',task.lease_owner));if(task.lease_expires_at){worker.append(document.createElement('br'),text('small','lease until '+fmtTime(task.lease_expires_at),'muted'))}}else worker.append(text('span','—','muted'));row.append(worker);const outcome=text('td',undefined,'error');if(task.error_code){const explanation=taskError[task.error_code]||['Task needs attention','Check the worker terminal for the original error.'];outcome.append(text('strong',explanation[0]),document.createElement('br'),text('small',explanation[1]))}else if(task.status==='completed')outcome.append(text('span','Partial CSV uploaded','muted'));else outcome.append(text('span','—','muted'));row.append(outcome);holder.append(row)}};
const renderArtifacts=job=>{const holder=document.querySelector('#artifacts');holder.replaceChildren();const artifacts=job.artifacts||[];if(!artifacts.length){holder.append(text('div','Artifacts appear here as the coordinator stores input, shards, partial results, and the final CSV.','empty'));return}for(const artifact of artifacts){const card=text('article',undefined,'artifact'+(artifact.kind==='final_result'?' artifact-final':''));card.append(text('div',artifact.diagnostic?'Partial result · diagnostic':artifact.kind,'artifact-type'),text('strong',artifact.filename),text('span',fmtBytes(artifact.size_bytes),'muted'),text('code','SHA-256 '+artifact.sha256));if(artifact.downloadable){const preview=text('a','Preview CSV');preview.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(artifact.id)+'/preview';const download=text('a',artifact.kind==='final_result'?'Download final CSV':'Download CSV');download.href='/ui/jobs/'+encodeURIComponent(job.id)+'/artifacts/'+encodeURIComponent(artifact.id);card.append(preview,download)}holder.append(card)}};
const speedHistory=[{at:Date.now(),completed:Number({{.Completed}})}],speedWindow=15,speedLimit=90,svgNS='http://www.w3.org/2000/svg',speedSVG=(tag,attrs)=>{const node=document.createElementNS(svgNS,tag);for(const [key,value] of Object.entries(attrs))node.setAttribute(key,String(value));return node},rateLabel=rate=>rate.toFixed(1)+' shards/min';
@@ -4,20 +4,32 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New similarity search · SciMesh</title>
<title>New computation · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:980px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.055em}.lead{max-width:720px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.layout{display:grid;grid-template-columns:1.45fr .8fr;gap:15px;margin-top:28px}.card,.aside,.notice{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021}.card{padding:22px}.aside,.notice{padding:18px}.aside h2,.notice h2{margin:0;color:#f1f6ff;font-size:1rem}.aside p,.notice p{color:#9fb3cf}.aside ol{margin:13px 0 0;padding-left:20px;color:#aebfda}.aside li{margin:10px 0}label{display:block;margin:18px 0 5px;color:#eaf2ff;font-weight:750}input,select{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus,select:focus{outline:2px solid #5d97f5;outline-offset:1px}input[type=file]{padding:8px}.hint{margin:5px 0;color:#92a9c6;font-size:.87rem}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}.run-preview{margin-top:18px;border:1px solid #2c8c70;border-radius:11px;padding:13px;background:#0c2b2a;color:#a8f1d0}.run-preview strong{color:#e6fff4}.button{display:inline-flex;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.button:disabled{opacity:.6;cursor:wait}.working{margin:14px 0 0;color:#9fc5ff}.error{margin:12px 0 0;color:#ffacba}.hidden{display:none}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 6px;color:#b5d3f5;font:.76rem ui-monospace,SFMono-Regular,monospace}@media(max-width:720px){.layout,.split{grid-template-columns:1fr}.page{padding:22px 14px}}
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:980px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.055em}.lead{max-width:720px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.layout{display:grid;grid-template-columns:1.45fr .8fr;gap:15px;margin-top:28px}.card,.aside,.notice{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021}.card{padding:22px}.aside,.notice{padding:18px}.aside h2,.notice h2{margin:0;color:#f1f6ff;font-size:1rem}.aside p,.notice p{color:#9fb3cf}.aside ol{margin:13px 0 0;padding-left:20px;color:#aebfda}.aside li{margin:10px 0}label{display:block;margin:18px 0 5px;color:#eaf2ff;font-weight:750}input,select{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus,select:focus{outline:2px solid #5d97f5;outline-offset:1px}input[type=file]{padding:8px}.checkbox-row{display:flex;align-items:flex-start;gap:10px;margin-top:16px}.checkbox-row input[type=checkbox]{width:18px;height:18px;margin-top:4px;accent-color:#67e3b8}.checkbox-row label{margin:0}.checkbox-row .hint{margin:0}.hint{margin:5px 0;color:#92a9c6;font-size:.87rem}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}.run-preview{margin-top:18px;border:1px solid #2c8c70;border-radius:11px;padding:13px;background:#0c2b2a;color:#a8f1d0}.run-preview strong{color:#e6fff4}.button{display:inline-flex;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.button:disabled{opacity:.6;cursor:wait}.working{margin:14px 0 0;color:#9fc5ff}.error{margin:12px 0 0;color:#ffacba}.hidden{display:none}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 6px;color:#bcd2f0;font-size:.75rem;font-weight:700}.req{color:#ffb4c0}.workload-meta{margin:6px 0 0;color:#8fa7c8;font-size:.9rem}
</style>
</head>
<body>
<main class="page">
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">New computation</p><h1>Similarity search, end to end</h1><p class="lead">Upload a ChEMBL-style TSV. Workers calculate shard candidates; after every shard succeeds, SciMesh merges the exact global top-k into one final CSV.</p>
<div class="layout"><form id="run" class="card" novalidate><label for="file">ChEMBL TSV</label><input id="file" type="file" name="file" required accept=".tsv,.txt,text/tab-separated-values"><p class="hint">Required columns: <code>chembl_id</code> and <code>canonical_smiles</code>.</p><label for="query-smiles">Target molecule (SMILES)</label><input id="query-smiles" name="query_smiles" required maxlength="200" value="CCO" autocomplete="off"><p class="hint">Use a valid SMILES. The coordinator shares this exact query with every shard.</p><div class="split"><div><label for="top-k">Global top-k</label><input id="top-k" name="top_k" type="number" min="1" max="100000" value="20" required><p class="hint">How many final molecules to retain.</p></div><div><label for="chunk-rows">Rows per shard</label><input id="chunk-rows" name="chunk_rows" type="number" min="1" max="100000" value="1000" required><p class="hint">Smaller shards make more visible tasks.</p></div></div><div class="split"><div><label for="threshold">Similarity threshold <small>(optional)</small></label><input id="threshold" name="threshold" type="number" min="0" max="1" step="0.01" placeholder="For example: 0.70"><p class="hint">Leave blank to rank every valid candidate.</p></div><div><label for="direction">Keep molecules</label><select id="direction" name="threshold_direction"><option value="greater">more similar (≥ threshold)</option><option value="less">less similar (≤ threshold)</option></select><p class="hint">“Less” helps explore dissimilar molecules.</p></div></div><label for="max-rows">Maximum dataset rows <small>(optional quick run)</small></label><input id="max-rows" name="max_rows" type="number" min="1" max="10000000" placeholder="For example: 500"><p class="hint">Only the first N data rows become shards; the original upload remains stored by the coordinator.</p><div id="preview" class="run-preview"><strong>Ready to plan a run.</strong><br>Select a TSV to see the file that will be sent to the coordinator.</div><button class="button" id="submit" type="submit">Create pipeline run →</button><p id="working" class="working hidden" aria-live="polite">Uploading TSV and creating coordinator-owned shards…</p><p id="error" class="error" role="alert"></p></form><aside class="aside"><h2>What you will observe</h2><ol><li><strong>Input accepted</strong><br>TSV is validated and split into durable shard artifacts.</li><li><strong>Workers claim tasks</strong><br>Each worker downloads one shard, fingerprints it, and uploads a partial CSV.</li><li><strong>Global reduction</strong><br>The coordinator compares exact scores from all partial results.</li><li><strong>Final download</strong><br>The result page exposes a checksum-protected global CSV.</li></ol><h2 style="margin-top:26px">Before you submit</h2><p>Keep at least one worker running in another terminal. The browser cannot start or control worker processes.</p><p><span class="cap">similarity-search</span> is currently the only distributed workload available here.</p></aside></div>
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">New computation</p><h1>Any workload, end to end</h1><p class="lead">Choose a workload from the installed library. Upload its dataset; workers compute shards; the coordinator reduces the partials into one final artifact.</p>
<div class="layout"><form id="run" class="card" novalidate><label for="workload">Workload</label><select id="workload" name="workload"></select><p id="workload-meta" class="workload-meta hidden"></p><div id="params"></div><div class="split"><div><label for="chunk-rows">Rows per shard</label><input id="chunk-rows" name="chunk_rows" type="number" min="1" max="100000" value="1000" required><p class="hint">Smaller shards make more visible tasks.</p></div><div><label for="max-rows">Maximum dataset rows <small>(optional)</small></label><input id="max-rows" name="max_rows" type="number" min="1" max="10000000" placeholder="For example: 500"><p class="hint">Only the first N data rows become shards; the upload stays stored.</p></div></div><label for="file">Dataset file</label><input id="file" type="file" name="file" required accept=".tsv,.txt,.csv,text/tab-separated-values,text/csv"><p class="hint">A delimited table with a header row. The workload defines the required columns.</p><div id="preview" class="run-preview"><strong>Ready to plan a run.</strong><br>Select a dataset to see the file that will be sent to the coordinator.</div><button class="button" id="submit" type="submit">Create pipeline run →</button><p id="working" class="working hidden" aria-live="polite">Uploading dataset and creating coordinator-owned shards…</p><p id="error" class="error" role="alert"></p></form><aside class="aside"><h2>What you will observe</h2><ol><li><strong>Input accepted</strong><br>Dataset is validated and split into durable shard artifacts.</li><li><strong>Workers claim tasks</strong><br>Each worker downloads one shard, computes, and uploads a partial result.</li><li><strong>Global reduction</strong><br>The coordinator reduces all partials into one final artifact.</li><li><strong>Final download</strong><br>The result page exposes a checksum-protected result file.</li></ol><h2 style="margin-top:26px">Before you submit</h2><p>Keep at least one worker running in another terminal. The browser cannot start or control worker processes.</p><p>The form controls come from the workload's own declarations in the SDK library.</p></aside></div>
</main>
<script>
const form=document.querySelector('#run'),button=document.querySelector('#submit'),working=document.querySelector('#working'),error=document.querySelector('#error'),preview=document.querySelector('#preview'),fileInput=document.querySelector('#file');
const bytes=size=>size<1024?size+' B':size<1024*1024?(size/1024).toFixed(1)+' KiB':(size/(1024*1024)).toFixed(1)+' MiB';fileInput.addEventListener('change',()=>{const file=fileInput.files&&fileInput.files[0];preview.replaceChildren();if(!file){preview.append(document.createTextNode('Select a TSV to see the file that will be sent to the coordinator.'));return}const strong=document.createElement('strong');strong.textContent='Source ready: '+file.name;preview.append(strong,document.createElement('br'),document.createTextNode(bytes(file.size)+' · the coordinator will validate its header before creating tasks.'))});
form.addEventListener('submit',async event=>{event.preventDefault();error.textContent='';const fields=new FormData(form),file=fields.get('file'),query=String(fields.get('query_smiles')||'').trim(),topK=Number(fields.get('top_k')),chunkRows=Number(fields.get('chunk_rows')),threshold=String(fields.get('threshold')||'').trim(),maxRows=String(fields.get('max_rows')||'').trim();if(!(file instanceof File)||file.size===0){error.textContent='Choose a non-empty TSV file.';return}if(!query||query.length>200||!Number.isInteger(topK)||topK<1||!Number.isInteger(chunkRows)||chunkRows<1){error.textContent='Enter a target SMILES, a positive global top-k, and a positive rows-per-shard value.';return}if(threshold&&(Number.isNaN(Number(threshold))||Number(threshold)<0||Number(threshold)>1)){error.textContent='Similarity threshold must be between 0 and 1.';return}const parameters={query_smiles:query,top_k:topK,threshold_direction:fields.get('threshold_direction'),progress_every:0};if(threshold)parameters.threshold=Number(threshold);const upload=new FormData();upload.append('workload','similarity-search');upload.append('parameters',JSON.stringify(parameters));upload.append('chunk_rows',String(chunkRows));if(maxRows)upload.append('max_rows',maxRows);upload.append('file',file,file.name);button.disabled=true;working.classList.remove('hidden');try{const response=await fetch('/ui/api/jobs/upload',{method:'POST',body:upload}),data=await response.json();if(!response.ok)throw Error(data.error||'Unable to create the job.');location.href='/ui/jobs/'+encodeURIComponent(data.job_id)}catch(err){error.textContent=err.message==='invalid input'?'The coordinator could not accept this run. Check the TSV columns and form values.':err.message;button.disabled=false;working.classList.add('hidden')}});
const DATA={{.Payload}};
const form=document.querySelector('#run'),button=document.querySelector('#submit'),working=document.querySelector('#working'),error=document.querySelector('#error'),preview=document.querySelector('#preview'),fileInput=document.querySelector('#file'),workloadSelect=document.querySelector('#workload'),workloadMeta=document.querySelector('#workload-meta'),paramsBox=document.querySelector('#params');
const bytes=size=>size<1024?size+' B':size<1024*1024?(size/1024).toFixed(1)+' KiB':(size/(1024*1024)).toFixed(1)+' MiB';
const uploadable=DATA.workloads.filter(w=>w.upload_ready);
for(const w of uploadable){const option=document.createElement('option');option.value=w.name;option.textContent=w.name;workloadSelect.append(option)}
const schemaOf=w=>{const properties=w.schema.properties||{};return name=>properties[name]||{}};
const inputType=prop=>prop.type==='number'||prop.type==='integer'?'number':'text';
const makeField=(w,element)=>{const prop=schemaOf(w)(element.field),box=document.createElement('div');const label=document.createElement('label');label.textContent=element.label||element.field;if(element.required||(w.one_of||[]).some(group=>group.includes(element.field))){const star=document.createElement('span');star.className='req';star.textContent=' *';label.append(star)}if(element.widget==='checkbox'){const row=document.createElement('div');row.className='checkbox-row';const input=document.createElement('input');input.type='checkbox';input.id='param-'+element.field;input.checked=element.default===true||(element.default==null&&w.defaults[element.field]===true);input.name=element.field;row.append(input,label);if(element.help){const hint=document.createElement('p');hint.className='hint';hint.textContent=element.help;row.append(hint)}box.append(row);return box}let input;if(element.widget==='select'){input=document.createElement('select');input.name=element.field;input.id='param-'+element.field;for(const option of element.options){const node=document.createElement('option');node.value=option;node.textContent=option;input.append(node)}const defaultValue=element.default!=null?element.default:w.defaults[element.field];if(defaultValue!=null)input.value=String(defaultValue)}else if(element.widget==='textarea'){input=document.createElement('textarea');input.name=element.field;input.id='param-'+element.field;input.rows=2;if(element.default!=null)input.value=String(element.default)}else{input=document.createElement('input');input.type=inputType(prop);input.name=element.field;input.id='param-'+element.field;input.autocomplete='off';if(prop.minLength!=null)input.maxLength=prop.maxLength;if(prop.type==='number'||prop.type==='integer'){if(prop.minimum!=null)input.min=prop.minimum;if(prop.maximum!=null)input.max=prop.maximum;input.step=prop.type==='integer'?1:'any'}if(element.placeholder)input.placeholder=element.placeholder;const defaultValue=element.default!=null?element.default:w.defaults[element.field];if(defaultValue!=null)input.value=String(defaultValue)}label.htmlFor=input.id;box.append(label,input);if(element.help){const hint=document.createElement('p');hint.className='hint';hint.textContent=element.help;box.append(hint)}return box};
const fieldsFor=w=>{const elements=[...(w.ui||[])];const declared=new Set(elements.map(e=>e.field));const properties=w.schema.properties||{};const fallback=Object.entries(properties).filter(([name])=>!declared.has(name)&&name!=='max_rows').map(([name,prop])=>({field:name,widget:prop.enum?'select':prop.type==='boolean'?'checkbox':prop.type==='string'?'text':(prop.type==='number'||prop.type==='integer')?'number':'text',label:name,help:prop.description||'',options:prop.enum||[],default:prop.default!=null?prop.default:null,placeholder:'',order:100,required:(w.required||[]).includes(name)}));return [...elements,...fallback].sort((a,b)=>a.order-b.order)};
const oneOfHint=w=>{const group=(w.one_of||[]).find(g=>g.length>1);if(!group)return null;return 'Exactly one required: '+group.map(f=>{const element=(w.ui||[]).find(e=>e.field===f);return element&&element.label?element.label:f}).join(' or ')+'.'};
const render=()=>{paramsBox.replaceChildren();const w=DATA.workloads.find(x=>x.name===workloadSelect.value);if(!w)return;workloadMeta.classList.remove('hidden');workloadMeta.textContent=w.description;const hint=oneOfHint(w);if(hint){const p=document.createElement('p');p.className='hint';p.style.marginTop='18px';p.textContent=hint;paramsBox.append(p)}for(const element of fieldsFor(w)){paramsBox.append(makeField(w,element))}};
workloadSelect.addEventListener('change',render);render();
fileInput.addEventListener('change',()=>{const file=fileInput.files&&fileInput.files[0];preview.replaceChildren();if(!file){preview.append(document.createTextNode('Select a dataset to see the file that will be sent to the coordinator.'));return}const strong=document.createElement('strong');strong.textContent='Source ready: '+file.name;preview.append(strong,document.createElement('br'),document.createTextNode(bytes(file.size)+' · the coordinator will validate the dataset before creating tasks.'))});
const values=()=>{const w=DATA.workloads.find(x=>x.name===workloadSelect.value),props=w.schema.properties||{},out={};for(const element of fieldsFor(w)){const prop=props[element.field]||{},node=document.querySelector('#param-'+CSS.escape(element.field));if(!node)continue;if(element.widget==='checkbox'){out[element.field]=node.checked;continue}const raw=String(node.value||'').trim();if(element.widget==='select'){out[element.field]=raw;continue}if(!raw){if(element.required)throw new Error((element.label||element.field)+' is required.');continue}if(prop.type==='integer'){if(!/^-?\d+$/.test(raw))throw new Error((element.label||element.field)+' must be an integer.');out[element.field]=parseInt(raw,10)}else if(prop.type==='number'){const number=Number(raw);if(Number.isNaN(number))throw new Error((element.label||element.field)+' must be a number.');out[element.field]=number}else{out[element.field]=raw}}for(const group of w.one_of||[]){if(group.length<2)continue;const filled=group.filter(field=>out[field]!==undefined&&out[field]!==null&&out[field]!==false&&String(out[field]).trim()!=='');if(filled.length!==1)throw new Error('Exactly one of '+group.join(', ')+' must be provided.')}return out};
form.addEventListener('submit',async event=>{event.preventDefault();error.textContent='';const w=DATA.workloads.find(x=>x.name===workloadSelect.value);if(!w){error.textContent='Choose a workload.';return}const file=fileInput.files&&fileInput.files[0];const chunkRows=Number(document.querySelector('#chunk-rows').value),maxRows=String(document.querySelector('#max-rows').value||'').trim();if(!(file instanceof File)||file.size===0){error.textContent='Choose a non-empty dataset file.';return}if(!Number.isInteger(chunkRows)||chunkRows<1){error.textContent='Rows per shard must be a positive integer.';return}if(maxRows&&(!Number.isInteger(Number(maxRows))||Number(maxRows)<1)){error.textContent='Maximum dataset rows must be a positive integer.';return}let parameters;try{parameters=values()}catch(err){error.textContent=err.message;return}const upload=new FormData();upload.append('workload',w.name);upload.append('parameters',JSON.stringify(parameters));upload.append('chunk_rows',String(chunkRows));if(maxRows)upload.append('max_rows',maxRows);upload.append('file',file,file.name);button.disabled=true;working.classList.remove('hidden');try{const response=await fetch('/ui/api/jobs/upload',{method:'POST',body:upload}),data=await response.json();if(!response.ok)throw Error(data.error||'Unable to create the job.');location.href='/ui/jobs/'+encodeURIComponent(data.job_id)}catch(err){error.textContent=err.message==='invalid input'?'The coordinator could not accept this run. Check the dataset and form values.':err.message;button.disabled=false;working.classList.add('hidden')}});
</script>
</body>
</html>
@@ -0,0 +1,55 @@
{{define "workloads.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workload library · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% -8%,#183f77 0,transparent 32rem),radial-gradient(circle at 95% 5%,#123e39 0,transparent 29rem),#08111f}.page{max-width:1240px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.055em}.lead{max-width:760px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.library{display:grid;gap:14px;margin-top:30px}.workload{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:20px 22px}.workload-head{display:flex;align-items:baseline;justify-content:space-between;gap:14px;flex-wrap:wrap}.workload-head h2{margin:0;color:#f2f7ff;font-size:1.22rem;letter-spacing:-.02em}.version{margin:0;color:#7d93b2;font:0.82rem ui-monospace,SFMono-Regular,monospace}.description{margin:8px 0 0;color:#b9c9e2;max-width:860px}.cap{display:inline-block;margin:12px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 7px;color:#b5d3f5;font:.76rem ui-monospace,SFMono-Regular,monospace}.badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 10px;font-size:.78rem;font-weight:800}.badge-success{background:#123f34;color:#76efb5}.badge-waiting{background:#23344d;color:#b9cce9}.meta{display:flex;gap:9px;flex-wrap:wrap;margin-top:14px}.meta span{border:1px solid #2b4a6b;border-radius:7px;padding:3px 8px;color:#a9c3e2;font-size:.8rem}.meta b{color:#dbe9fb;font-weight:750}.schema-grid{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin-top:16px}.schema{border:1px solid #233e5c;border-radius:11px;background:#091627;padding:13px}.schema h3{margin:0 0 8px;color:#cfe1f7;font-size:.86rem;letter-spacing:.04em;text-transform:uppercase}.schema pre{margin:0;overflow:auto;max-height:300px;color:#9fc1e8;font:.76rem ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-word}.params{padding:13px}.params h3{margin:0 0 8px;color:#cfe1f7;font-size:.86rem;letter-spacing:.04em;text-transform:uppercase}.param{margin:0;padding:6px 0;border-bottom:1px dashed #223a56;color:#b9c9e2;font-size:.9rem}.param:last-child{border-bottom:0}.param b{color:#e8f2ff}.param small{display:block;margin-top:2px;color:#7f96b5}.empty{padding:30px;border:1px dashed #35516f;border-radius:14px;color:#9ab0cb;text-align:center}@media(max-width:820px){.schema-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">Installed packages</p>
<h1>Workload library</h1>
<p class="lead">Every SDK-built workload installed in this SciMesh deployment, as declared by <code>scimesh workload export</code>. The coordinator stores this catalog as presentation metadata only; it never executes workload code.</p>
<div class="library">
{{range .Workloads}}
<article class="workload">
<div class="workload-head">
<h2>{{.Name}}</h2>
<p class="version">{{.Version}}</p>
{{if .Enabled}}<span class="badge badge-success">enabled</span>{{else}}<span class="badge badge-waiting">disabled</span>{{end}}
</div>
<p class="description">{{.Description}}</p>
<div>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</div>
<div class="meta">
<span>determinism <b>{{.Determinism}}</b></span>
<span>verifier <b>{{.Verifier}}</b></span>
<span>trust <b>{{range $i, $mode := .TrustModes}}{{if $i}}, {{end}}{{$mode}}{{end}}</b></span>
</div>
<div class="schema-grid">
<div class="schema params">
<h3>Parameters</h3>
<pre>{{.Parameters}}</pre>
</div>
<div>
{{range .Inputs}}
<div class="schema"><h3>Input · {{.Name}}</h3><pre>{{.Schema}}</pre></div>
{{end}}
{{range .Outputs}}
<div class="schema"><h3>Output · {{.Name}}</h3><pre>{{.Schema}}</pre></div>
{{end}}
</div>
</div>
</article>
{{else}}
<div class="empty"><strong>No workloads are installed.</strong><br>Install an SDK workload package and run <code>scimesh workload export</code> to republish this catalog.</div>
{{end}}
</div>
<p class="lead" style="margin-top:26px"><a class="back" href="/ui">← Back to the control room</a></p>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,15 @@
package http_test
import (
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// testCatalog loads the embedded workload catalog for http tests, the same
// catalog the server binary loads at startup.
func testCatalog() *workloads.Catalog {
catalog, err := workloads.Load()
if err != nil {
panic(err)
}
return catalog
}
+145 -1
View File
@@ -2,17 +2,20 @@ package http
import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"mime"
"net/http"
"sort"
"strconv"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
//go:embed templates/*.html
@@ -165,6 +168,10 @@ func uiWorkloadLabel(workload string) string {
return "Molecule similarity search"
case "similarity-graph", "similarity_graph":
return "Molecular similarity graph"
case "molwt-filter", "molwt_filter":
return "Molecular weight filter"
case "descriptor-batch", "descriptor_batch":
return "Descriptor batch"
default:
return workload
}
@@ -236,7 +243,144 @@ func (s *Server) handleUIOverviewJSON(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUINewJob(w http.ResponseWriter, r *http.Request) {
s.renderUI(w, "new-job.html", nil)
catalog, err := workloads.Load()
if err != nil {
s.log.Error("load workload catalog", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderUI(w, "new-job.html", uiNewJobView{Payload: newJobPayload(catalog)})
}
// uiNewJobWorkloadJSON is the per-workload data handed to the page script. It
// is presentation metadata from the embedded catalog; the coordinator re-
// validates everything server-side on upload.
type uiNewJobWorkloadJSON struct {
Name string `json:"name"`
Description string `json:"description"`
Schema map[string]any `json:"schema"`
UI []uiNewJobElementJSON `json:"ui"`
Required []string `json:"required"`
OneOf [][]string `json:"one_of"`
UploadReady bool `json:"upload_ready"`
Reduction string `json:"reduction"`
Defaults map[string]any `json:"defaults"`
InputMedia map[string]string `json:"input_media"`
}
type uiNewJobElementJSON struct {
Field string `json:"field"`
Widget string `json:"widget"`
Label string `json:"label"`
Help string `json:"help"`
Placeholder string `json:"placeholder"`
Options []string `json:"options"`
Default any `json:"default"`
Order int `json:"order"`
Required bool `json:"required"`
}
type uiNewJobView struct {
Payload template.JS
}
func newJobPayload(catalog *workloads.Catalog) template.JS {
payload := struct {
Workloads []uiNewJobWorkloadJSON `json:"workloads"`
}{Workloads: make([]uiNewJobWorkloadJSON, 0, len(catalog.Enabled()))}
for _, workload := range catalog.Enabled() {
required := map[string]bool{}
if entries, ok := workload.Parameters["required"].([]any); ok {
for _, entry := range entries {
if name, ok := entry.(string); ok {
required[name] = true
}
}
}
elementViews := make([]uiNewJobElementJSON, 0, len(workload.UIElements))
for _, element := range workload.UIElements {
elementViews = append(elementViews, uiNewJobElementJSON{
Field: element.Field,
Widget: element.Widget,
Label: element.Label,
Help: element.Help,
Placeholder: element.Placeholder,
Options: element.Options,
Default: element.Default,
Order: element.Order,
Required: required[element.Field],
})
}
payload.Workloads = append(payload.Workloads, uiNewJobWorkloadJSON{
Name: workload.Name,
Description: workload.Description,
Schema: workload.Parameters,
UI: elementViews,
Required: sortedKeys(required),
OneOf: oneOfGroups(workload.Parameters),
UploadReady: workload.UploadReady,
Reduction: workload.Reduction,
Defaults: catalog.ParameterDefaults(workload.Name),
InputMedia: inputMediaViews(catalog, workload.Name),
})
}
encoded, err := json.Marshal(payload)
if err != nil {
return template.JS("null")
}
// #nosec G203 -- the payload is marshaled JSON from the embedded workload catalog, injected as script data.
return template.JS(encoded)
}
func sortedKeys(values map[string]bool) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func oneOfGroups(schema map[string]any) [][]string {
rawOneOf, ok := schema["oneOf"].([]any)
if !ok {
return nil
}
// The clean shape is "exactly one of these single fields" (e.g. the search
// query id vs SMILES choice): every branch requires exactly one distinct
// field. Anything more complex is left to server-side validation.
var fields []string
seen := map[string]bool{}
for _, rawOption := range rawOneOf {
option, ok := rawOption.(map[string]any)
if !ok {
return nil
}
required, _ := option["required"].([]any)
if len(required) != 1 {
return nil
}
field, ok := required[0].(string)
if !ok || seen[field] {
return nil
}
seen[field] = true
fields = append(fields, field)
}
if len(fields) != len(rawOneOf) {
return nil
}
return [][]string{fields}
}
func inputMediaViews(catalog *workloads.Catalog, name string) map[string]string {
views := map[string]string{}
for _, port := range catalog.InputPortNames(name) {
if mediaType := catalog.InputMediaType(name, port); mediaType != "" {
views[port] = mediaType
}
}
return views
}
func (s *Server) uiJobID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
@@ -0,0 +1,53 @@
package http
import (
"net/http"
"os"
"path/filepath"
"strings"
)
// handleUIDocsIndex redirects /ui/docs to the trailing-slash form so the
// wildcard route below can resolve index.html.
func (s *Server) handleUIDocsIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/docs/", http.StatusPermanentRedirect)
}
// handleUIDocs serves the built MkDocs site (site/) as static files. The
// configured docs directory is an operator-supplied path, never derived from
// a request; path traversal is rejected by joining against the cleaned root
// and checking the result stays inside it.
func (s *Server) handleUIDocs(w http.ResponseWriter, r *http.Request) {
if s.docsDir == "" {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
root, err := filepath.Abs(s.docsDir)
if err != nil {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
clean := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/ui/docs/"))
target, err := filepath.Abs(filepath.Join(root, clean))
if err != nil {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
http.NotFound(w, r)
return
}
info, err := os.Stat(target)
if err != nil || info.IsDir() {
if err == nil && info.IsDir() {
target = filepath.Join(target, "index.html")
info, err = os.Stat(target)
}
if err != nil || info.IsDir() {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
}
http.ServeFile(w, r, target)
}
@@ -0,0 +1,90 @@
package http
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func docsTestServer(t *testing.T, docsDir string) *Server {
t.Helper()
return &Server{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
docsDir: docsDir,
}
}
func TestUIDocsServesIndexAndNestedFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<h1>Home</h1>"), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(root, "api")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "page.html"), []byte("<h1>API page</h1>"), 0o644); err != nil {
t.Fatal(err)
}
server := docsTestServer(t, root)
index := httptest.NewRecorder()
server.handleUIDocs(index, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs/", nil))
if index.Code != http.StatusOK || !strings.Contains(index.Body.String(), "<h1>Home</h1>") {
t.Fatalf("index = %d %q", index.Code, index.Body.String())
}
page := httptest.NewRecorder()
server.handleUIDocs(page, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs/api/page.html", nil))
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "<h1>API page</h1>") {
t.Fatalf("nested page = %d %q", page.Code, page.Body.String())
}
}
func TestUIDocsRejectsPathTraversal(t *testing.T) {
root := t.TempDir()
secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
server := docsTestServer(t, root)
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs/../secret.txt", nil)
request.URL.Path = "/ui/docs/../secret.txt"
recorder := httptest.NewRecorder()
server.handleUIDocs(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("traversal status = %d, want 404", recorder.Code)
}
}
func TestUIDocsShowsBuildHintWhenDisabledOrMissing(t *testing.T) {
disabled := docsTestServer(t, "")
recorder := httptest.NewRecorder()
disabled.handleUIDocs(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs/", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
t.Fatalf("disabled docs = %d %q", recorder.Code, recorder.Body.String())
}
missing := docsTestServer(t, filepath.Join(t.TempDir(), "does-not-exist"))
recorder = httptest.NewRecorder()
missing.handleUIDocs(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs/", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
t.Fatalf("missing docs = %d %q", recorder.Code, recorder.Body.String())
}
}
func TestUIDocsIndexRedirectsToTrailingSlash(t *testing.T) {
server := docsTestServer(t, t.TempDir())
recorder := httptest.NewRecorder()
server.handleUIDocsIndex(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ui/docs", nil))
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "/ui/docs/" {
t.Fatalf("redirect = %d %q", recorder.Code, recorder.Header().Get("Location"))
}
}
@@ -0,0 +1,44 @@
package http
import (
"strings"
"testing"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
func TestNewJobPageCarriesWorkloadCatalogPayload(t *testing.T) {
catalog, err := workloads.Load()
if err != nil {
t.Fatalf("load workload catalog: %v", err)
}
view := uiNewJobView{Payload: newJobPayload(catalog)}
var builder strings.Builder
if err := uiTemplates.ExecuteTemplate(&builder, "new-job.html", view); err != nil {
t.Fatalf("render new-job page: %v", err)
}
page := builder.String()
for _, expected := range []string{"const DATA=", "similarity-search", "molwt-filter", "descriptor-batch", "one_of", "query_id", "min_molwt", "skip_invalid", "upload_ready"} {
if !strings.Contains(page, expected) {
t.Errorf("new-job page is missing %q", expected)
}
}
}
func TestNewJobPayloadDeclaresUploadReadiness(t *testing.T) {
catalog, err := workloads.Load()
if err != nil {
t.Fatalf("load workload catalog: %v", err)
}
payload := newJobPayload(catalog)
text := string(payload)
if !strings.Contains(text, `"upload_ready":false`) {
t.Errorf("catalog payload must mark similarity-graph as not upload-ready")
}
if !strings.Contains(text, `"reduction":"top-k"`) {
t.Errorf("catalog payload is missing the top-k reduction for search")
}
if !strings.Contains(text, `"reduction":"ordered-concat"`) {
t.Errorf("catalog payload is missing the ordered-concat reduction for row workloads")
}
}
@@ -0,0 +1,106 @@
package http
import (
"encoding/json"
"net/http"
"sort"
"sync"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// uiPortView is one sorted input/output port of a workload, rendered as
// pretty JSON on the library page.
type uiPortView struct {
Name string
Schema string
}
// uiWorkloadView is the library page's view of one catalog workload.
type uiWorkloadView struct {
Name string
Version string
Description string
Capabilities []string
TrustModes []string
Determinism string
Verifier string
Enabled bool
Reduction string
Parameters string
Inputs []uiPortView
Outputs []uiPortView
}
type uiWorkloadsView struct {
Workloads []uiWorkloadView
}
var (
workloadLibraryOnce sync.Once
workloadLibraryView uiWorkloadsView
workloadLibraryErr error
)
func loadWorkloadLibrary() (uiWorkloadsView, error) {
workloadLibraryOnce.Do(func() {
catalog, err := workloads.Load()
if err != nil {
workloadLibraryErr = err
return
}
view := uiWorkloadsView{Workloads: make([]uiWorkloadView, 0, len(catalog.Enabled()))}
for _, item := range catalog.Enabled() {
view.Workloads = append(view.Workloads, uiWorkloadView{
Name: item.Name,
Version: item.Version,
Description: item.Description,
Capabilities: item.Capabilities,
TrustModes: item.TrustModes,
Determinism: item.Determinism,
Verifier: item.Verifier,
Enabled: item.Enabled,
Reduction: item.Reduction,
Parameters: prettyJSON(item.Parameters),
Inputs: portViews(item.Inputs),
Outputs: portViews(item.Outputs),
})
}
workloadLibraryView = view
})
return workloadLibraryView, workloadLibraryErr
}
func prettyJSON(value any) string {
if value == nil {
return "{}"
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return "{}"
}
return string(encoded)
}
func portViews(ports map[string]any) []uiPortView {
names := make([]string, 0, len(ports))
for name := range ports {
names = append(names, name)
}
sort.Strings(names)
views := make([]uiPortView, 0, len(names))
for _, name := range names {
views = append(views, uiPortView{Name: name, Schema: prettyJSON(ports[name])})
}
return views
}
func (s *Server) handleUIWorkloads(w http.ResponseWriter, r *http.Request) {
view, err := loadWorkloadLibrary()
if err != nil {
s.log.Error("load workload library", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderUI(w, "workloads.html", view)
}
@@ -0,0 +1,51 @@
package http
import (
"strings"
"testing"
)
func TestWorkloadLibraryLoadsAndListsEveryWorkload(t *testing.T) {
view, err := loadWorkloadLibrary()
if err != nil {
t.Fatalf("load workload library: %v", err)
}
names := make(map[string]bool)
for _, workload := range view.Workloads {
if workload.Name == "" || workload.Version == "" {
t.Errorf("workload with empty name or version: %+v", workload)
}
if workload.Description == "" {
t.Errorf("workload %s has no description", workload.Name)
}
if workload.Parameters == "" {
t.Errorf("workload %s has no parameter schema", workload.Name)
}
if len(workload.Inputs) == 0 || len(workload.Outputs) == 0 {
t.Errorf("workload %s has no input or output ports", workload.Name)
}
names[workload.Name] = true
}
for _, expected := range []string{"similarity-search", "similarity-graph", "descriptor-batch", "molwt-filter"} {
if !names[expected] {
t.Errorf("workload library is missing %s", expected)
}
}
}
func TestWorkloadLibraryPageRendersWorkloads(t *testing.T) {
view, err := loadWorkloadLibrary()
if err != nil {
t.Fatalf("load workload library: %v", err)
}
var builder strings.Builder
if err := uiTemplates.ExecuteTemplate(&builder, "workloads.html", view); err != nil {
t.Fatalf("render workloads page: %v", err)
}
page := builder.String()
for _, expected := range []string{"Workload library", "descriptor-batch", "molwt-filter", "byte_exact", "exact-artifact@1"} {
if !strings.Contains(page, expected) {
t.Errorf("workloads page is missing %q", expected)
}
}
}
+6 -2
View File
@@ -12,18 +12,22 @@ import (
// UploadArtifact stores a worker's partial-result bytes and records the metadata.
type UploadArtifact struct {
tasks TaskRepository
workers WorkerRepository
artifacts ArtifactRepository
blobs BlobStore
tx TxManager
clk Clock
}
func NewUploadArtifact(tasks TaskRepository, artifacts ArtifactRepository,
func NewUploadArtifact(tasks TaskRepository, workers WorkerRepository, artifacts ArtifactRepository,
blobs BlobStore, tx TxManager, clk Clock) *UploadArtifact {
return &UploadArtifact{tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
return &UploadArtifact{tasks: tasks, workers: workers, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
}
func (uc *UploadArtifact) Execute(ctx context.Context, in UploadArtifactInput) (*domain.Artifact, error) {
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
return nil, err
}
task, err := uc.tasks.Get(ctx, in.TaskID)
if err != nil {
return nil, err
+9 -6
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// Job operations: the submitter-facing lifecycle of a whole submission.
@@ -227,7 +228,7 @@ func progressFrom(job domain.Job, counts map[domain.TaskStatus]int) domain.JobPr
// Shared by CompleteTask and FailTask so both close a job by the same rule —
// the rule itself lives in domain.JobProgress.DeriveStatus.
func syncJobStatus(ctx context.Context, jobs JobRepository, tasks TaskRepository,
jobID uuid.UUID, now time.Time) error {
catalog *workloads.Catalog, jobID uuid.UUID, now time.Time) error {
counts, err := tasks.CountByStatus(ctx, jobID)
if err != nil {
@@ -239,9 +240,11 @@ func syncJobStatus(ctx context.Context, jobs JobRepository, tasks TaskRepository
}
status := progressFrom(*job, counts).DeriveStatus()
// All worker shards being complete means scientific reduction is ready, not
// that the job's final artifact already exists. CTX-09 owns the transition
// from reducing to completed after it persists that artifact.
if status == domain.JobCompleted && job.Workload == "similarity-search" {
// that the job's final artifact already exists. Known catalog workloads
// transition to reducing so ReduceJob can produce the final artifact; the
// reducer then completes the job with the result. Unknown (URI-based) jobs
// complete without a coordinator-owned final artifact.
if status == domain.JobCompleted && catalog != nil && catalog.Reduction(job.Workload) != "" {
status = domain.JobReducing
}
@@ -253,14 +256,14 @@ func syncJobStatus(ctx context.Context, jobs JobRepository, tasks TaskRepository
}
func syncExpiredJobStatuses(ctx context.Context, jobs JobRepository, tasks TaskRepository,
jobIDs []uuid.UUID, now time.Time) error {
catalog *workloads.Catalog, jobIDs []uuid.UUID, now time.Time) error {
seen := make(map[uuid.UUID]struct{}, len(jobIDs))
for _, jobID := range jobIDs {
if _, duplicate := seen[jobID]; duplicate {
continue
}
seen[jobID] = struct{}{}
if err := syncJobStatus(ctx, jobs, tasks, jobID, now); err != nil {
if err := syncJobStatus(ctx, jobs, tasks, catalog, jobID, now); err != nil {
return err
}
}
+22 -5
View File
@@ -9,6 +9,7 @@ import (
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/reducer"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// ReduceJob turns completed coordinator-owned partial artifacts into one final
@@ -20,11 +21,12 @@ type ReduceJob struct {
blobs BlobStore
tx TxManager
clock Clock
catalog *workloads.Catalog
}
func NewReduceJob(jobs JobRepository, tasks TaskRepository, artifacts ArtifactRepository,
blobs BlobStore, tx TxManager, clock Clock) *ReduceJob {
return &ReduceJob{jobs: jobs, tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clock: clock}
blobs BlobStore, tx TxManager, clock Clock, catalog *workloads.Catalog) *ReduceJob {
return &ReduceJob{jobs: jobs, tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clock: clock, catalog: catalog}
}
// Execute is idempotent for jobs that are not currently reducing. The worker
@@ -42,7 +44,11 @@ func (uc *ReduceJob) Execute(ctx context.Context, jobID uuid.UUID) error {
if job.Status != domain.JobReducing {
return nil
}
if job.Workload != "similarity-search" {
if uc.catalog == nil {
return uc.fail(ctx, jobID)
}
reduction := uc.catalog.Reduction(job.Workload)
if reduction == "" {
return uc.fail(ctx, jobID)
}
@@ -73,13 +79,13 @@ func (uc *ReduceJob) Execute(ctx context.Context, jobID uuid.UUID) error {
readers = append(readers, body)
closers = append(closers, body)
}
output, reduceErr := reducer.ReduceSimilaritySearch(readers, job.Parameters)
output, reduceErr := reducePartials(reduction, readers, job.Parameters)
closeAll(closers)
if reduceErr != nil {
return uc.fail(ctx, jobID)
}
final, err := domain.NewArtifact(jobID, nil, domain.ArtifactFinalResult, "similarity-search.csv", "text/csv", uc.clock.Now())
final, err := domain.NewArtifact(jobID, nil, domain.ArtifactFinalResult, job.Workload+".csv", "text/csv", uc.clock.Now())
if err != nil {
return uc.fail(ctx, jobID)
}
@@ -100,6 +106,17 @@ func (uc *ReduceJob) Execute(ctx context.Context, jobID uuid.UUID) error {
return nil
}
func reducePartials(reduction string, readers []io.Reader, parameters map[string]any) ([]byte, error) {
switch reduction {
case "top-k":
return reducer.ReduceSimilaritySearch(readers, parameters)
case "ordered-concat":
return reducer.ReduceOrderedConcat(readers)
default:
return nil, domain.ErrInvalidInput
}
}
func (uc *ReduceJob) fail(ctx context.Context, jobID uuid.UUID) error {
// The public state carries a stable sanitized failure, never parser/storage
// internals that may include local paths or implementation details.
+38 -27
View File
@@ -7,8 +7,8 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// Task operations: the worker-facing lifecycle of a single chunk.
@@ -28,10 +28,11 @@ type ClaimTask struct {
tx TxManager
clock Clock
leaseDuration time.Duration
catalog *workloads.Catalog
}
func NewClaimTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock, leaseDuration time.Duration) *ClaimTask {
return &ClaimTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock, leaseDuration: leaseDuration}
func NewClaimTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock, leaseDuration time.Duration, catalog *workloads.Catalog) *ClaimTask {
return &ClaimTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock, leaseDuration: leaseDuration, catalog: catalog}
}
// Execute reclaims elapsed leases first, then hands out one task.
@@ -59,11 +60,8 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
// tier would be read off a caller-supplied worker_id, letting anyone who
// knows a trusted worker's id claim as it. A shared-token caller (no
// requester) is a lab operator and may act as any worker.
if r, ok := authctx.From(ctx); ok {
if worker.OwnerID == nil || *worker.OwnerID != r.UserID {
// Don't disclose that another user's worker exists.
return nil, domain.ErrWorkerNotFound
}
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
return nil, err
}
// An untrusted volunteer may claim, but never a chunk its owner has
// already voted on — so quorum needs genuinely independent computations.
@@ -81,7 +79,7 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
if err != nil {
return err
}
if err := syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, affectedJobs, now); err != nil {
if err := syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, uc.catalog, affectedJobs, now); err != nil {
return err
}
@@ -126,6 +124,9 @@ func NewRenewLease(tasks TaskRepository, workers WorkerRepository, tx TxManager,
// locked: two concurrent heartbeats must not interleave into a lost update.
// Whether the caller may renew at all is decided by the entity, not here.
func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.ClaimedTask, error) {
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
return nil, err
}
var claimed domain.ClaimedTask
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
@@ -160,6 +161,7 @@ func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.
type CompleteTask struct {
tasks TaskRepository
jobs JobRepository
catalog *workloads.Catalog
artifacts ArtifactRepository
workers WorkerRepository
results TaskResultRepository
@@ -171,12 +173,12 @@ type CompleteTask struct {
}
func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts ArtifactRepository,
workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int) *CompleteTask {
workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int, catalog *workloads.Catalog) *CompleteTask {
if quorum < 1 {
quorum = 2
}
return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, workers: workers,
results: results, tx: tx, clock: clock, quorum: quorum}
results: results, tx: tx, clock: clock, quorum: quorum, catalog: catalog}
}
// Execute applies the result and, when that was the job's last outstanding
@@ -186,6 +188,9 @@ func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts Artifac
// Lease ownership, staleness, and idempotent replays are all decided by
// Task.CompleteWith; this use case only orchestrates.
func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*domain.Task, error) {
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
return nil, err
}
var out *domain.Task
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
@@ -227,7 +232,7 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom
if err := uc.tasks.Update(ctx, task); err != nil {
return err
}
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
return syncJobStatus(ctx, uc.jobs, uc.tasks, uc.catalog, task.JobID, now)
})
if err != nil {
return nil, err
@@ -266,7 +271,7 @@ func (uc *CompleteTask) recordVote(ctx context.Context, task *domain.Task, in Co
if err := uc.tasks.Update(ctx, task); err != nil {
return err
}
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
return syncJobStatus(ctx, uc.jobs, uc.tasks, uc.catalog, task.JobID, now)
}
// workerTrust reports whether the worker's results are accepted directly, and
@@ -317,19 +322,24 @@ func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UU
// --- FailTask ------------------------------------------------------------
type FailTask struct {
tasks TaskRepository
jobs JobRepository
tx TxManager
clock Clock
tasks TaskRepository
jobs JobRepository
workers WorkerRepository
tx TxManager
clock Clock
catalog *workloads.Catalog
}
func NewFailTask(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *FailTask {
return &FailTask{tasks: tasks, jobs: jobs, tx: tx, clock: clock}
func NewFailTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock, catalog *workloads.Catalog) *FailTask {
return &FailTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock, catalog: catalog}
}
// Execute delegates the requeue-or-terminate decision to Task.Fail, then keeps
// the parent job's status consistent in the same transaction.
func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task, error) {
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
return nil, err
}
var out *domain.Task
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
@@ -345,7 +355,7 @@ func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task
return err
}
out = task
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
return syncJobStatus(ctx, uc.jobs, uc.tasks, uc.catalog, task.JobID, now)
})
if err != nil {
return nil, err
@@ -356,14 +366,15 @@ func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task
// --- ExpireLeases --------------------------------------------------------
type ExpireLeases struct {
tasks TaskRepository
jobs JobRepository
tx TxManager
clock Clock
tasks TaskRepository
jobs JobRepository
tx TxManager
clock Clock
catalog *workloads.Catalog
}
func NewExpireLeases(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *ExpireLeases {
return &ExpireLeases{tasks: tasks, jobs: jobs, tx: tx, clock: clock}
func NewExpireLeases(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock, catalog *workloads.Catalog) *ExpireLeases {
return &ExpireLeases{tasks: tasks, jobs: jobs, tx: tx, clock: clock, catalog: catalog}
}
// Execute reclaims elapsed tasks and persists the state of every affected job.
@@ -380,7 +391,7 @@ func (uc *ExpireLeases) Execute(ctx context.Context) (int64, error) {
if err != nil {
return err
}
return syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, affected, now)
return syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, uc.catalog, affected, now)
})
return int64(len(affected)), err
}
@@ -0,0 +1,62 @@
package usecase_test
import (
"testing"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// testCatalog loads the embedded workload catalog for usecase tests. The
// catalog is checked in and generated from the SDK library, so tests exercise
// the real validation contract.
func testCatalog() *workloads.Catalog {
catalog, err := workloads.Load()
if err != nil {
panic(err)
}
return catalog
}
func TestEmbeddedCatalogLoadsAndValidatesSearchParameters(t *testing.T) {
catalog := testCatalog()
if err := catalog.ValidateParameters("similarity-search", map[string]any{
"query_smiles": "CCO", "top_k": 10, "threshold_direction": "greater",
}); err != nil {
t.Fatalf("valid search parameters rejected: %v", err)
}
if err := catalog.ValidateParameters("molwt-filter", map[string]any{
"min_molwt": 100, "max_molwt": 600, "skip_invalid": true,
}); err != nil {
t.Fatalf("valid molwt parameters rejected: %v", err)
}
if err := catalog.ValidateParameters("nope", map[string]any{}); err == nil {
t.Error("unknown workload accepted")
}
if err := catalog.ValidateParameters("similarity-graph", map[string]any{"threshold": 0.7}); err != nil {
t.Errorf("graph parameters rejected: %v", err)
}
if !catalog.UploadReady("molwt-filter") {
t.Error("molwt-filter must be upload-ready")
}
if catalog.UploadReady("similarity-graph") {
t.Error("similarity-graph must not be upload-ready")
}
if got := catalog.Reduction("similarity-search"); got != "top-k" {
t.Errorf("search reduction = %q, want top-k", got)
}
if got := catalog.Reduction("descriptor-batch"); got != "ordered-concat" {
t.Errorf("descriptor reduction = %q, want ordered-concat", got)
}
for name, parameters := range map[string]map[string]any{
"both query fields": {"query_id": "CHEMBL1", "query_smiles": "CCO"},
"undeclared parameter": {"query_smiles": "CCO", "bogus": 1},
"bad top_k": {"query_smiles": "CCO", "top_k": -1},
"bad enum": {"query_smiles": "CCO", "threshold_direction": "sideways"},
"missing query": {},
"non-integer top_k": {"query_smiles": "CCO", "top_k": 1.5},
} {
if err := catalog.ValidateParameters("similarity-search", parameters); err == nil {
t.Errorf("%s accepted", name)
}
}
}
+61 -17
View File
@@ -3,12 +3,15 @@ package usecase
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// UIReadRepository is a read-only projection source for the local operator UI.
@@ -125,9 +128,14 @@ type JobDetailView struct {
Session *SessionView `json:"-"`
}
type Dashboard struct{ read UIReadRepository }
type Dashboard struct {
read UIReadRepository
catalog *workloads.Catalog
}
func NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
func NewDashboard(read UIReadRepository, catalog *workloads.Catalog) *Dashboard {
return &Dashboard{read: read, catalog: catalog}
}
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
jobs, err := d.read.ListJobs(ctx, uiOwnerFilter(ctx), limit)
@@ -219,7 +227,7 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
JobCard: jobCard(*job, tasks),
Tasks: make([]TaskCard, 0, len(tasks)),
Artifacts: make([]ArtifactCard, 0, len(artifacts)),
Parameters: uiParameters(job.Parameters),
Parameters: uiParameters(job.Parameters, d.catalog, job.Workload),
Session: sessionViewFrom(ctx),
}
for _, task := range tasks {
@@ -304,31 +312,67 @@ func jobCard(job domain.Job, tasks []domain.Task) JobCard {
return c
}
func uiParameters(parameters map[string]any) []ParameterCard {
keys := []struct {
key string
label string
}{
{"query_smiles", "Target SMILES"},
{"query_id", "Target ChEMBL ID"},
{"top_k", "Global top-k"},
{"threshold", "Similarity threshold"},
{"threshold_direction", "Threshold direction"},
func uiParameters(parameters map[string]any, catalog *workloads.Catalog, workload string) []ParameterCard {
labels := map[string]string{
"query_smiles": "Target SMILES",
"query_id": "Target ChEMBL ID",
"top_k": "Global top-k",
"threshold": "Similarity threshold",
"threshold_direction": "Threshold direction",
"min_molwt": "Minimum molecular weight",
"max_molwt": "Maximum molecular weight",
"skip_invalid": "Skip invalid molecules",
"block_size": "Block size",
}
keys := make([]string, 0, len(parameters))
declared := declaredParameterNames(catalog, workload)
for key := range parameters {
if declared != nil && !declared[key] {
// Only schema-declared scientific parameters may reach the browser;
// anything else could carry internal coordinator state.
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]ParameterCard, 0, len(keys))
for _, entry := range keys {
value, ok := parameters[entry.key]
for _, key := range keys {
value, ok := parameters[key]
if !ok {
continue
}
formatted, ok := formatUIParameter(value)
if ok {
out = append(out, ParameterCard{Label: entry.label, Value: formatted})
if !ok {
continue
}
label := labels[key]
if label == "" {
label = strings.ReplaceAll(key, "_", " ")
}
out = append(out, ParameterCard{Label: label, Value: formatted})
}
return out
}
func declaredParameterNames(catalog *workloads.Catalog, workload string) map[string]bool {
if catalog == nil || workload == "" {
return nil
}
item := catalog.ByName(workload)
if item == nil {
return nil
}
properties, ok := item.Parameters["properties"].(map[string]any)
if !ok {
return nil
}
declared := make(map[string]bool, len(properties))
for name := range properties {
declared[name] = true
}
return declared
}
func formatUIParameter(value any) (string, bool) {
switch typed := value.(type) {
case string:
@@ -1,14 +1,22 @@
package usecase
import "testing"
import (
"testing"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
func TestUIParametersAreAllowlisted(t *testing.T) {
catalog, err := workloads.Load()
if err != nil {
t.Fatal(err)
}
parameters := uiParameters(map[string]any{
"query_smiles": "CCO",
"top_k": float64(20),
"internal_storage_key": "must-not-reach-browser",
"nested": map[string]any{"secret": "no"},
})
}, catalog, "similarity-search")
if len(parameters) != 2 {
t.Fatalf("parameters = %#v, want only two allowlisted values", parameters)
}
@@ -17,3 +25,28 @@ func TestUIParametersAreAllowlisted(t *testing.T) {
t.Fatalf("parameters = %#v", parameters)
}
}
func TestUIParametersRenderEverySchemaDeclaredField(t *testing.T) {
catalog, err := workloads.Load()
if err != nil {
t.Fatal(err)
}
parameters := uiParameters(map[string]any{
"min_molwt": 100,
"max_molwt": 600,
"skip_invalid": true,
"secret_key": "no",
}, catalog, "molwt-filter")
if len(parameters) != 3 {
t.Fatalf("parameters = %#v, want three declared values", parameters)
}
labels := map[string]bool{}
for _, card := range parameters {
labels[card.Label] = true
}
for _, expected := range []string{"Minimum molecular weight", "Maximum molecular weight", "Skip invalid molecules"} {
if !labels[expected] {
t.Errorf("missing parameter card %q in %#v", expected, parameters)
}
}
}
@@ -19,7 +19,7 @@ func newDashboard() (*usecase.Dashboard, *memstore.JobRepo) {
tasks := memstore.NewTaskRepo()
workers := memstore.NewWorkerRepo()
artifacts := memstore.NewArtifactRepo()
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), jobs
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts), testCatalog()), jobs
}
func ownedJob(t *testing.T, jobs *memstore.JobRepo, owner uuid.UUID) uuid.UUID {
@@ -17,7 +17,7 @@ func newDashboardWithWorkers() (*usecase.Dashboard, *memstore.WorkerRepo) {
tasks := memstore.NewTaskRepo()
workers := memstore.NewWorkerRepo()
artifacts := memstore.NewArtifactRepo()
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), workers
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts), testCatalog()), workers
}
func seedWorker(t *testing.T, workers *memstore.WorkerRepo, owner *uuid.UUID, name string) {
+28 -62
View File
@@ -4,12 +4,12 @@ import (
"context"
"fmt"
"io"
"math"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/chunk"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// SubmitDataset accepts an uploaded dataset, splits it into shard artifacts, and
@@ -23,15 +23,16 @@ type SubmitDataset struct {
tx TxManager
clk Clock
maxAttempts int
catalog *workloads.Catalog
}
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int) *SubmitDataset {
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts}
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog) *SubmitDataset {
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog}
}
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
if err := validateUploadedWorkload(in.Workload, in.Parameters); err != nil {
if err := validateUploadedWorkload(uc.catalog, in.Workload, in.Parameters); err != nil {
return SubmitDatasetResult{}, err
}
if uc.maxAttempts < 1 {
@@ -86,7 +87,8 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
putKeys = append(putKeys, art.StorageKey)
art.SetContent(ssum, ssize)
task, err := domain.NewShardTask(job.ID, index, in.Workload, art.ID, ssum, in.Parameters, uc.maxAttempts, now)
task, err := domain.NewShardTask(job.ID, index, in.Workload, art.ID, ssum,
taskParameterSubset(in.Parameters), uc.maxAttempts, now)
if err != nil {
return err
}
@@ -128,74 +130,38 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
}, nil
}
// validateUploadedWorkload is deliberately narrow until CTX-07/08/10 adds a
// typed distributed-workload registry. In particular, running similarity-graph
// independently per TSV shard is scientifically wrong: cross-shard pairs would
// be absent from the apparent graph.
func validateUploadedWorkload(workload string, parameters map[string]any) error {
if workload != "similarity-search" {
// validateUploadedWorkload checks the submitted workload against the embedded
// catalog: it must be an enabled, upload-ready workload whose parameters
// satisfy the declared JSON schema. Workloads that need planner-produced
// inputs (such as the graph block-pair shards) declare upload_ready=false and
// cannot be driven from a single uploaded dataset.
func validateUploadedWorkload(catalog *workloads.Catalog, workload string, parameters map[string]any) error {
if catalog == nil {
return domain.ErrInvalidInput
}
allowed := map[string]struct{}{
"query_smiles": {}, "top_k": {}, "threshold": {},
"threshold_direction": {}, "progress_every": {},
}
for key := range parameters {
if _, ok := allowed[key]; !ok {
return domain.ErrInvalidInput
}
}
query, ok := parameters["query_smiles"].(string)
if !ok || query == "" || len(query) > 200 {
if err := catalog.ValidateParameters(workload, parameters); err != nil {
return domain.ErrInvalidInput
}
if value, ok := parameters["top_k"]; ok && !isPositiveJSONInteger(value) {
return domain.ErrInvalidInput
}
if value, ok := parameters["progress_every"]; ok && !isNonNegativeJSONInteger(value) {
return domain.ErrInvalidInput
}
if value, ok := parameters["threshold"]; ok && !isUnitIntervalNumber(value) {
return domain.ErrInvalidInput
}
if value, ok := parameters["threshold_direction"]; ok && value != "greater" && value != "less" {
if !catalog.UploadReady(workload) {
return domain.ErrInvalidInput
}
return nil
}
func isPositiveJSONInteger(value any) bool { return isJSONInteger(value, false) }
func isNonNegativeJSONInteger(value any) bool { return isJSONInteger(value, true) }
func isJSONInteger(value any, allowZero bool) bool {
var n int64
switch v := value.(type) {
case int:
n = int64(v)
case int64:
n = v
case float64:
if math.Trunc(v) != v || v > math.MaxInt64 || v < math.MinInt64 {
return false
// taskParameterSubset drops coordinator-level keys from the parameters that
// are handed to workers. max_rows is a plan-time bound applied by the chunker
// here; a worker would reject it as outside its stage projection.
func taskParameterSubset(parameters map[string]any) map[string]any {
if _, present := parameters["max_rows"]; !present {
return parameters
}
subset := make(map[string]any, len(parameters)-1)
for key, value := range parameters {
if key != "max_rows" {
subset[key] = value
}
n = int64(v)
default:
return false
}
return n >= 0 && (allowZero || n > 0)
}
func isUnitIntervalNumber(value any) bool {
switch v := value.(type) {
case float64:
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
case int:
return v >= 0 && v <= 1
case int64:
return v >= 0 && v <= 1
default:
return false
}
return subset
}
// GetTaskInput resolves a task's input shard and opens it for streaming. The
+87 -9
View File
@@ -73,20 +73,20 @@ func newHarness() *harness {
}
tx := memstore.Tx{}
h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk)
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3)
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease)
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog())
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease, testCatalog())
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2)
h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk)
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2, testCatalog())
h.fail = usecase.NewFailTask(h.tasks, h.jobs, h.work, tx, h.clk, testCatalog())
h.status = usecase.NewGetJobStatus(h.jobs, h.tasks)
h.results = usecase.NewListResults(h.tasks)
h.register = usecase.NewRegisterWorker(h.work, h.clk)
h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.arts, h.blobs, tx, h.clk)
h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.work, h.arts, h.blobs, tx, h.clk)
h.downloadArt = usecase.NewDownloadArtifact(h.arts, h.blobs)
h.getInput = usecase.NewGetTaskInput(h.tasks, h.arts, h.blobs)
h.expire = usecase.NewExpireLeases(h.tasks, h.jobs, tx, h.clk)
h.expire = usecase.NewExpireLeases(h.tasks, h.jobs, tx, h.clk, testCatalog())
h.cancel = usecase.NewCancelJob(h.jobs, h.tasks, tx, h.clk)
h.reduce = usecase.NewReduceJob(h.jobs, h.tasks, h.arts, h.blobs, tx, h.clk)
h.reduce = usecase.NewReduceJob(h.jobs, h.tasks, h.arts, h.blobs, tx, h.clk, testCatalog())
h.jobResult = usecase.NewGetJobResult(h.jobs, h.downloadArt)
return h
}
@@ -133,6 +133,44 @@ func TestSimilaritySearchReductionCreatesFinalArtifact(t *testing.T) {
}
}
func TestMolwtFilterReductionConcatenatesPartialsInOrder(t *testing.T) {
h := newHarness()
jobID := h.seedJob(t, "molwt-filter", 2)
if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil {
t.Fatal(err)
}
partials := []string{
"chembl_id,canonical_smiles\nA,CC\n",
"chembl_id,canonical_smiles\nB,CCCC\n",
}
for _, partial := range partials {
taskID, attempt := h.leaseOne(t, "w1", "molwt-filter")
art, err := h.uploadArt.Execute(ctx, usecase.UploadArtifactInput{TaskID: taskID, WorkerID: "w1", Attempt: attempt, Filename: "partial.csv", ContentType: "text/csv", Body: strings.NewReader(partial)})
if err != nil {
t.Fatal(err)
}
if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: "w1", Attempt: attempt, ResultArtifactID: art.ID}); err != nil {
t.Fatal(err)
}
}
if err := h.reduce.Execute(ctx, jobID); err != nil {
t.Fatal(err)
}
progress, err := h.status.Execute(ctx, jobID)
if err != nil || progress.Job.Status != domain.JobCompleted {
t.Fatalf("status=%s err=%v", progress.Job.Status, err)
}
art, body, err := h.jobResult.Execute(ctx, jobID)
if err != nil {
t.Fatal(err)
}
defer body.Close()
bytes, _ := io.ReadAll(body)
if art.Kind != domain.ArtifactFinalResult || string(bytes) != "chembl_id,canonical_smiles\nA,CC\nB,CCCC\n" {
t.Fatalf("unexpected final %q", bytes)
}
}
func TestSimilaritySearchReductionFailureIsSanitized(t *testing.T) {
h := newHarness()
jobID := h.seedJob(t, "similarity-search", 1)
@@ -318,6 +356,36 @@ func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) {
}
}
func TestJWTCallerCannotMutateAnotherUsersWorkerLease(t *testing.T) {
h := newHarness()
h.seedJob(t, "w", 1)
victimOwner := uuid.New()
victim, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
Name: "victim", Capabilities: []string{"w"}, OwnerID: &victimOwner, TrustLevel: domain.WorkerTrusted,
})
if err != nil {
t.Fatal(err)
}
claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
if err != nil || claimed == nil {
t.Fatalf("claim = (%v, %v)", claimed, err)
}
attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
if _, err := h.renew.Execute(attacker, usecase.RenewLeaseInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt}); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("foreign heartbeat err = %v, want ErrWorkerNotFound", err)
}
if _, err := h.fail.Execute(attacker, usecase.FailTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ErrorCode: "x"}); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("foreign failure err = %v, want ErrWorkerNotFound", err)
}
if _, err := h.uploadArt.Execute(attacker, usecase.UploadArtifactInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, Filename: "x.csv", ContentType: "text/csv", Body: strings.NewReader("x")}); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("foreign upload err = %v, want ErrWorkerNotFound", err)
}
if _, err := h.complete.Execute(attacker, usecase.CompleteTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ResultArtifactID: uuid.New()}); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("foreign result err = %v, want ErrWorkerNotFound", err)
}
}
func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) {
h := newHarness()
h.seedJob(t, "w", 1)
@@ -551,7 +619,7 @@ func TestUploadRejectsLeaseThatExpiresDuringStreaming(t *testing.T) {
h.seedJob(t, "w", 1)
taskID, attempt := h.leaseOne(t, "w1", "w")
h.uploadArt = usecase.NewUploadArtifact(
h.tasks, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
h.tasks, h.work, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
)
_, err := h.uploadArt.Execute(ctx, usecase.UploadArtifactInput{
@@ -787,8 +855,18 @@ func TestSubmitDatasetRejectsUnsupportedDistributedWorkloads(t *testing.T) {
Filename: "chembl.tsv", ContentType: "text/tab-separated-values",
Body: strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"),
})
if err != nil {
t.Errorf("query_id submission err = %v, want nil", err)
}
_, err = h.submit.Execute(ctx, usecase.SubmitDatasetInput{
Workload: "similarity-search", Parameters: map[string]any{
"query_id": "CHEMBL1", "query_smiles": "CCO",
}, RowsPerShard: 2,
Filename: "chembl.tsv", ContentType: "text/tab-separated-values",
Body: strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"),
})
if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("query_id submission err = %v, want ErrInvalidInput", err)
t.Errorf("both query fields submission err = %v, want ErrInvalidInput", err)
}
}
@@ -0,0 +1,33 @@
package usecase
import (
"context"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// authorizeWorkerOwner binds a JWT-authenticated requester to a worker. The
// shared coordinator token intentionally has no requester and retains its
// existing operator privileges.
func authorizeWorkerOwner(ctx context.Context, workers WorkerRepository, workerID string) error {
requester, ok := authctx.From(ctx)
if !ok {
return nil
}
id, err := uuid.Parse(workerID)
if err != nil {
return domain.ErrWorkerNotFound
}
worker, err := workers.Get(ctx, id)
if err != nil {
return err
}
if worker.OwnerID == nil || *worker.OwnerID != requester.UserID {
// Mask ownership and existence from another user.
return domain.ErrWorkerNotFound
}
return nil
}
+273
View File
@@ -0,0 +1,273 @@
// Package workloads provides the coordinator-side view of the SDK workload
// library. The catalog is generated by `scimesh workload export` and embedded
// into the binary; it is presentation and orchestration metadata, never
// executable code. Every field that drives coordinator behaviour (reduction
// mode, parameter schema, required input columns) is validated at load time
// so a bad export fails fast instead of misbehaving at runtime.
package workloads
import (
"embed"
"encoding/json"
"fmt"
"sort"
)
//go:embed workloads.json
var catalogFile embed.FS
// Catalog is the parsed and validated workload library.
type Catalog struct {
workloads []*Workload
}
// Workload is one entry of the embedded catalog.
type Workload struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Capabilities []string `json:"capabilities"`
TrustModes []string `json:"trust_modes"`
Determinism string `json:"determinism"`
Verifier string `json:"verifier"`
Enabled bool `json:"enabled"`
Reduction string `json:"reduction"`
UploadReady bool `json:"upload_ready"`
Parameters map[string]any `json:"parameters_schema"`
UIElements []UIElement `json:"ui_elements"`
Inputs map[string]any `json:"inputs"`
Outputs map[string]any `json:"outputs"`
requiredColumns map[string]bool // derived from input validator configuration
inputMediaTypes map[string]string // port name -> media type
parameterDefaults map[string]any // derived from the schema
}
// UIElement is one workload-declared form control for the "new job" page.
type UIElement struct {
Field string `json:"field"`
Widget string `json:"widget"`
Label string `json:"label"`
Help string `json:"help"`
Placeholder string `json:"placeholder"`
Options []string `json:"options"`
Default any `json:"default"`
Order int `json:"order"`
Group string `json:"group"`
}
type libraryFile struct {
SchemaVersion int `json:"schema_version"`
GeneratedBy string `json:"generated_by"`
Workloads []*Workload `json:"workloads"`
}
// Load reads and validates the embedded catalog.
func Load() (*Catalog, error) {
raw, err := catalogFile.ReadFile("workloads.json")
if err != nil {
return nil, fmt.Errorf("read embedded workload catalog: %w", err)
}
return Parse(raw)
}
// Parse validates and builds a Catalog from catalog JSON bytes.
func Parse(raw []byte) (*Catalog, error) {
var file libraryFile
if err := json.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("parse workload catalog: %w", err)
}
if file.SchemaVersion != 2 {
return nil, fmt.Errorf("workload catalog schema_version must be 2, got %d", file.SchemaVersion)
}
if len(file.Workloads) == 0 {
return nil, fmt.Errorf("workload catalog contains no workloads")
}
catalog := &Catalog{}
names := make(map[string]bool, len(file.Workloads))
for _, workload := range file.Workloads {
if err := validateWorkload(workload); err != nil {
return nil, err
}
if names[workload.Name] {
return nil, fmt.Errorf("workload catalog lists %q more than once", workload.Name)
}
names[workload.Name] = true
catalog.workloads = append(catalog.workloads, workload)
}
sort.Slice(catalog.workloads, func(i, j int) bool {
return catalog.workloads[i].Name < catalog.workloads[j].Name
})
return catalog, nil
}
func validateWorkload(workload *Workload) error {
if workload.Name == "" || workload.Version == "" {
return fmt.Errorf("workload catalog entry must have a name and version")
}
switch workload.Reduction {
case "top-k", "ordered-concat":
default:
return fmt.Errorf("workload %q declares unknown reduction %q", workload.Name, workload.Reduction)
}
if err := validateSchema(workload.Name, workload.Parameters); err != nil {
return err
}
workload.parameterDefaults = schemaDefaults(workload.Parameters)
workload.requiredColumns = map[string]bool{}
for portName, port := range workload.Inputs {
config, ok := port.(map[string]any)
if !ok {
continue
}
validator, _ := config["validator_configuration"].(map[string]any)
columns, _ := validator["required_columns"].([]any)
for _, column := range columns {
text, ok := column.(string)
if ok {
workload.requiredColumns[text] = true
}
}
if mediaType, ok := config["media_type"].(string); ok {
if workload.inputMediaTypes == nil {
workload.inputMediaTypes = map[string]string{}
}
workload.inputMediaTypes[portName] = mediaType
}
}
for _, element := range workload.UIElements {
if err := validateUIElement(workload, element); err != nil {
return err
}
}
return nil
}
func validateUIElement(workload *Workload, element UIElement) error {
switch element.Widget {
case "text", "textarea", "number", "select", "checkbox":
default:
return fmt.Errorf("workload %q ui element %q has unknown widget %q", workload.Name, element.Field, element.Widget)
}
if element.Widget == "select" && len(element.Options) == 0 {
return fmt.Errorf("workload %q ui element %q is a select without options", workload.Name, element.Field)
}
properties, ok := workload.Parameters["properties"].(map[string]any)
if !ok {
return nil
}
property, declared := properties[element.Field].(map[string]any)
if !declared {
return fmt.Errorf("workload %q ui element %q does not name a declared parameter", workload.Name, element.Field)
}
if schemaType(property) == "boolean" && element.Widget != "checkbox" {
return fmt.Errorf("workload %q ui element %q must use the checkbox widget for a boolean parameter", workload.Name, element.Field)
}
return nil
}
// Enabled returns the enabled workloads, sorted by name.
func (c *Catalog) Enabled() []*Workload {
result := make([]*Workload, 0, len(c.workloads))
for _, workload := range c.workloads {
if workload.Enabled {
result = append(result, workload)
}
}
return result
}
// ByName returns the workload with the given name, or nil.
func (c *Catalog) ByName(name string) *Workload {
for _, workload := range c.workloads {
if workload.Name == name {
return workload
}
}
return nil
}
// ValidateParameters checks job parameters against the workload schema. It
// enforces the strict subset of JSON Schema used by the SDK manifests: object
// shape, types, required, enum, numeric bounds, string lengths, and oneOf.
func (c *Catalog) ValidateParameters(name string, parameters map[string]any) error {
workload := c.ByName(name)
if workload == nil {
return fmt.Errorf("unknown workload %q", name)
}
if !workload.Enabled {
return fmt.Errorf("workload %q is not enabled", name)
}
return validateParameters(name, workload.Parameters, parameters)
}
// UploadReady reports whether the workload can be driven from a single
// uploaded dataset file. Workloads that need planner-produced inputs (such as
// the graph block-pair shards) declare upload_ready=false.
func (c *Catalog) UploadReady(name string) bool {
workload := c.ByName(name)
if workload == nil {
return false
}
return workload.UploadReady
}
// RequiredColumns reports every column the workload's input port requires.
func (c *Catalog) RequiredColumns(name string) map[string]bool {
workload := c.ByName(name)
if workload == nil {
return nil
}
return workload.requiredColumns
}
// InputMediaType returns the declared media type of the named input port.
func (c *Catalog) InputMediaType(name, port string) string {
workload := c.ByName(name)
if workload == nil {
return ""
}
return workload.inputMediaTypes[port]
}
// InputPortNames returns the sorted input port names of the workload.
func (c *Catalog) InputPortNames(name string) []string {
workload := c.ByName(name)
if workload == nil {
return nil
}
names := make([]string, 0, len(workload.Inputs))
for port := range workload.Inputs {
names = append(names, port)
}
sort.Strings(names)
return names
}
// Reduction returns the reduction mode for the workload, or "" if unknown.
func (c *Catalog) Reduction(name string) string {
workload := c.ByName(name)
if workload == nil {
return ""
}
return workload.Reduction
}
// ParameterDefaults returns the schema-declared defaults for the workload.
func (c *Catalog) ParameterDefaults(name string) map[string]any {
workload := c.ByName(name)
if workload == nil {
return nil
}
return workload.parameterDefaults
}
// ParameterSchema returns the schema property for a workload parameter, and
// whether the parameter exists.
func (w *Workload) PropertySchema(field string) (map[string]any, bool) {
properties, ok := w.Parameters["properties"].(map[string]any)
if !ok {
return nil, false
}
property, ok := properties[field].(map[string]any)
return property, ok
}
+367
View File
@@ -0,0 +1,367 @@
package workloads
import (
"fmt"
"math"
"sort"
)
// validateSchema checks the strict JSON Schema subset used by SDK manifests.
// It mirrors what the SDK registry enforces on the Python side: an object
// schema with additionalProperties=false, typed properties, and the keyword
// subset the coordinator understands (type, enum, required, minimum/maximum,
// minLength/maxLength, oneOf, not).
func validateSchema(workloadName string, schema map[string]any) error {
if schema == nil {
return fmt.Errorf("workload %q has no parameter schema", workloadName)
}
if err := validateSchemaNode(workloadName+".parameters_schema", schema); err != nil {
return err
}
if schemaType(schema) != "object" {
return fmt.Errorf("workload %q parameter schema must be an object schema", workloadName)
}
if additional, ok := schema["additionalProperties"].(bool); !ok || additional {
return fmt.Errorf("workload %q parameter schema must set additionalProperties=false", workloadName)
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
return fmt.Errorf("workload %q parameter schema must declare properties", workloadName)
}
for name, property := range properties {
child, ok := property.(map[string]any)
if !ok {
return fmt.Errorf("workload %q parameter %q must be a schema object", workloadName, name)
}
if err := validateSchemaNode(name, child); err != nil {
return err
}
}
return nil
}
func validateSchemaNode(field string, node map[string]any) error {
for keyword := range node {
switch keyword {
case "type", "enum", "required", "minimum", "maximum", "exclusiveMinimum",
"exclusiveMaximum", "minLength", "maxLength", "properties",
"additionalProperties", "oneOf", "not", "default", "description",
"items", "minItems", "maxItems":
default:
return fmt.Errorf("%s uses unsupported JSON Schema keyword %q", field, keyword)
}
}
if rawType, ok := node["type"]; ok {
schemaType, ok := rawType.(string)
if !ok {
return fmt.Errorf("%s type must be a string", field)
}
switch schemaType {
case "string", "number", "integer", "boolean", "object", "array":
default:
return fmt.Errorf("%s has unknown type %q", field, schemaType)
}
}
for _, keyword := range []string{"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"} {
if value, ok := node[keyword]; ok {
if number, ok := value.(float64); !ok || math.IsNaN(number) || math.IsInf(number, 0) {
return fmt.Errorf("%s %s must be a finite number", field, keyword)
}
}
}
for _, keyword := range []string{"minLength", "maxLength", "minItems", "maxItems"} {
if value, ok := node[keyword]; ok {
if number, ok := value.(float64); !ok || number < 0 || number != math.Trunc(number) {
return fmt.Errorf("%s %s must be a non-negative integer", field, keyword)
}
}
}
if required, ok := node["required"]; ok {
entries, ok := required.([]any)
if !ok {
return fmt.Errorf("%s required must be an array of strings", field)
}
for _, entry := range entries {
if _, ok := entry.(string); !ok {
return fmt.Errorf("%s required must be an array of strings", field)
}
}
}
if enums, ok := node["enum"]; ok {
entries, ok := enums.([]any)
if !ok || len(entries) == 0 {
return fmt.Errorf("%s enum must be a non-empty array", field)
}
}
if oneOf, ok := node["oneOf"]; ok {
entries, ok := oneOf.([]any)
if !ok || len(entries) == 0 {
return fmt.Errorf("%s oneOf must be a non-empty array", field)
}
for index, entry := range entries {
child, ok := entry.(map[string]any)
if !ok {
return fmt.Errorf("%s oneOf[%d] must be a schema object", field, index)
}
if err := validateSchemaNode(fmt.Sprintf("%s.oneOf[%d]", field, index), child); err != nil {
return err
}
}
}
if child, ok := node["not"]; ok {
not, ok := child.(map[string]any)
if !ok {
return fmt.Errorf("%s not must be a schema object", field)
}
if err := validateSchemaNode(field+".not", not); err != nil {
return err
}
}
if items, ok := node["items"]; ok {
child, ok := items.(map[string]any)
if !ok {
return fmt.Errorf("%s items must be a schema object", field)
}
if err := validateSchemaNode(field+".items", child); err != nil {
return err
}
}
if properties, ok := node["properties"]; ok {
entries, ok := properties.(map[string]any)
if !ok {
return fmt.Errorf("%s properties must be an object", field)
}
for name, property := range entries {
child, ok := property.(map[string]any)
if !ok {
return fmt.Errorf("%s property %q must be a schema object", field, name)
}
if err := validateSchemaNode(field+"."+name, child); err != nil {
return err
}
}
}
return nil
}
func schemaType(node map[string]any) string {
rawType, _ := node["type"].(string)
return rawType
}
// validateParameters checks values against the strict schema subset.
func validateParameters(workloadName string, schema map[string]any, parameters map[string]any) error {
properties, _ := schema["properties"].(map[string]any)
for name := range parameters {
if _, declared := properties[name]; !declared {
return fmt.Errorf("workload %q does not accept parameter %q", workloadName, name)
}
}
required, _ := schema["required"].([]any)
for _, name := range required {
field, _ := name.(string)
if _, present := parameters[field]; !present {
return fmt.Errorf("workload %q requires parameter %q", workloadName, field)
}
}
for name, value := range parameters {
property, declared := properties[name].(map[string]any)
if !declared {
continue
}
if err := validateProperty(workloadName+"."+name, property, value); err != nil {
return err
}
}
if oneOf, ok := schema["oneOf"].([]any); ok && len(oneOf) > 0 {
if err := validateOneOf(workloadName, oneOf, parameters); err != nil {
return err
}
}
return nil
}
func validateProperty(field string, property map[string]any, value any) error {
if enums, ok := property["enum"].([]any); ok {
for _, candidate := range enums {
if valuesEqual(candidate, value) {
return nil
}
}
return fmt.Errorf("%s must be one of the declared enum values", field)
}
switch schemaType(property) {
case "string":
text, ok := value.(string)
if !ok {
return fmt.Errorf("%s must be a string", field)
}
if minimum, ok := lengthBound(property["minLength"]); ok && len([]rune(text)) < minimum {
return fmt.Errorf("%s is shorter than the minimum length", field)
}
if maximum, ok := lengthBound(property["maxLength"]); ok && len([]rune(text)) > maximum {
return fmt.Errorf("%s exceeds the maximum length", field)
}
case "number", "integer":
number, ok := asFloat(value)
if !ok {
return fmt.Errorf("%s must be a number", field)
}
if schemaType(property) == "integer" && number != math.Trunc(number) {
return fmt.Errorf("%s must be an integer", field)
}
if minimum, ok := numberBound(property["minimum"]); ok && number < minimum {
return fmt.Errorf("%s is below the minimum", field)
}
if maximum, ok := numberBound(property["maximum"]); ok && number > maximum {
return fmt.Errorf("%s exceeds the maximum", field)
}
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("%s must be a boolean", field)
}
case "object":
child, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("%s must be an object", field)
}
properties, _ := property["properties"].(map[string]any)
for name := range child {
if _, declared := properties[name]; !declared {
return fmt.Errorf("%s has undeclared field %q", field, name)
}
}
case "array":
items, ok := value.([]any)
if !ok {
return fmt.Errorf("%s must be an array", field)
}
if itemSchema, ok := property["items"].(map[string]any); ok {
for index, item := range items {
if err := validateProperty(fmt.Sprintf("%s[%d]", field, index), itemSchema, item); err != nil {
return err
}
}
}
case "":
// No type keyword: enum-only properties are handled above.
return fmt.Errorf("%s has no JSON Schema type", field)
}
return nil
}
func validateOneOf(workloadName string, oneOf []any, parameters map[string]any) error {
satisfied := 0
for _, candidate := range oneOf {
option, ok := candidate.(map[string]any)
if !ok {
continue
}
if optionSatisfied(option, parameters) {
satisfied++
}
}
if satisfied != 1 {
return fmt.Errorf("workload %q requires exactly one of the declared parameter alternatives", workloadName)
}
return nil
}
func optionSatisfied(option map[string]any, parameters map[string]any) bool {
if required, ok := option["required"].([]any); ok {
for _, name := range required {
field, _ := name.(string)
if _, present := parameters[field]; !present {
return false
}
}
}
if not, ok := option["not"].(map[string]any); ok {
if required, ok := not["required"].([]any); ok {
for _, name := range required {
field, _ := name.(string)
if _, present := parameters[field]; present {
return false
}
}
}
}
return true
}
func lengthBound(value any) (int, bool) {
number, ok := value.(float64)
if !ok || number != math.Trunc(number) {
return 0, false
}
return int(number), true
}
func numberBound(value any) (float64, bool) {
number, ok := asFloat(value)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) {
return 0, false
}
return number, true
}
func asFloat(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
}
return 0, false
}
func valuesEqual(left, right any) bool {
switch l := left.(type) {
case float64:
r, ok := right.(float64)
return ok && l == r
case string:
r, ok := right.(string)
return ok && l == r
case bool:
r, ok := right.(bool)
return ok && l == r
case nil:
return right == nil
}
return false
}
// schemaDefaults collects the declared default for each property. The UI uses
// these to pre-fill controls that have no workload-declared UI default.
func schemaDefaults(schema map[string]any) map[string]any {
properties, _ := schema["properties"].(map[string]any)
defaults := map[string]any{}
for name, property := range properties {
child, ok := property.(map[string]any)
if !ok {
continue
}
if value, present := child["default"]; present {
defaults[name] = value
}
}
return defaults
}
// SortedFields returns the sorted declared parameter names.
func SortedFields(schema map[string]any) []string {
properties, _ := schema["properties"].(map[string]any)
names := make([]string, 0, len(properties))
for name := range properties {
names = append(names, name)
}
sort.Strings(names)
return names
}
@@ -0,0 +1,602 @@
{
"generated_by": "scimesh workload export",
"schema_version": 2,
"workloads": [
{
"capabilities": [
"descriptor-batch"
],
"description": "Compute a pinned set of RDKit 2D descriptors, one canonical CSV row per input molecule, in deterministic input order.",
"determinism": "byte_exact",
"enabled": true,
"inputs": {
"input": {
"allow_nested_collections": false,
"canonicalizer": "scimesh-tsv-v1",
"encoding": "utf-8",
"max_bytes": 10737418240,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/tab-separated-values",
"privacy_class": "project",
"ref": "molecule-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"required_columns": [
"canonical_smiles",
"chembl_id"
]
}
}
},
"name": "descriptor-batch",
"outputs": {
"result": {
"allow_nested_collections": false,
"canonicalizer": "descriptor-table-v1",
"encoding": "utf-8",
"max_bytes": 107374182400,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/csv",
"privacy_class": "project",
"ref": "descriptor-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"columns": [
"chembl_id",
"canonical_smiles",
"ExactMolWt",
"MolWt",
"HeavyAtomMolWt",
"HeavyAtomCount",
"NumHDonors",
"NumHAcceptors",
"NumRotatableBonds",
"NumHeteroatoms",
"NumRadicalElectrons",
"NumValenceElectrons",
"FractionCSP3",
"RingCount",
"NumAromaticRings",
"NumSaturatedRings",
"NumAliphaticRings",
"NumAromaticHeterocycles",
"NumSaturatedHeterocycles",
"NumAliphaticHeterocycles",
"NumAromaticCarbocycles",
"NumSaturatedCarbocycles",
"NumAliphaticCarbocycles",
"TPSA",
"LabuteASA",
"MolLogP",
"MolMR",
"BalabanJ",
"BertzCT",
"HallKierAlpha",
"Kappa1",
"Kappa2",
"Kappa3",
"Chi0",
"Chi1",
"Chi0n",
"Chi1n",
"Chi2n",
"Chi3n",
"Chi4n",
"Chi0v",
"Chi1v",
"Chi2v",
"Chi3v",
"Chi4v",
"PEOE_VSA1",
"PEOE_VSA2",
"PEOE_VSA3",
"PEOE_VSA4",
"PEOE_VSA5",
"PEOE_VSA6",
"PEOE_VSA7",
"PEOE_VSA8",
"PEOE_VSA9",
"PEOE_VSA10",
"PEOE_VSA11",
"PEOE_VSA12",
"PEOE_VSA13",
"PEOE_VSA14",
"SMR_VSA1",
"SMR_VSA2",
"SMR_VSA3",
"SMR_VSA4",
"SMR_VSA5",
"SMR_VSA6",
"SMR_VSA7",
"SMR_VSA8",
"SMR_VSA9",
"SMR_VSA10",
"SlogP_VSA1",
"SlogP_VSA2",
"SlogP_VSA3",
"SlogP_VSA4",
"SlogP_VSA5",
"SlogP_VSA6",
"SlogP_VSA7",
"SlogP_VSA8",
"SlogP_VSA9",
"SlogP_VSA10",
"SlogP_VSA11",
"SlogP_VSA12",
"NHOHCount",
"NOCount"
]
}
}
},
"parameters_schema": {
"additionalProperties": false,
"properties": {
"skip_invalid": {
"default": true,
"description": "Skip rows with invalid SMILES instead of failing",
"type": "boolean"
}
},
"type": "object"
},
"reduction": "ordered-concat",
"trust_modes": [
"trusted",
"untrusted_quorum"
],
"ui_elements": [
{
"default": true,
"field": "skip_invalid",
"group": "",
"help": "Skip rows with invalid SMILES instead of failing the shard.",
"label": "Skip invalid molecules",
"options": [],
"order": 1,
"placeholder": "",
"widget": "checkbox"
}
],
"upload_ready": true,
"verifier": "exact-artifact@1",
"version": "1.0.0"
},
{
"capabilities": [
"molwt-filter"
],
"description": "Filter molecules by exact RDKit molecular weight, one canonical CSV row per kept input molecule, in deterministic input order.",
"determinism": "byte_exact",
"enabled": true,
"inputs": {
"input": {
"allow_nested_collections": false,
"canonicalizer": "scimesh-tsv-v1",
"encoding": "utf-8",
"max_bytes": 10737418240,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/tab-separated-values",
"privacy_class": "project",
"ref": "molecule-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"required_columns": [
"canonical_smiles",
"chembl_id"
]
}
}
},
"name": "molwt-filter",
"outputs": {
"result": {
"allow_nested_collections": false,
"canonicalizer": "molwt-filtered-table-v1",
"encoding": "utf-8",
"max_bytes": 107374182400,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/csv",
"privacy_class": "project",
"ref": "molwt-filtered-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"columns": [
"chembl_id",
"canonical_smiles",
"molwt"
]
}
}
},
"parameters_schema": {
"additionalProperties": false,
"properties": {
"max_molwt": {
"description": "Keep molecules with MolWt <= this value",
"minimum": 0,
"type": "number"
},
"min_molwt": {
"description": "Keep molecules with MolWt >= this value",
"minimum": 0,
"type": "number"
},
"skip_invalid": {
"default": true,
"description": "Skip rows with invalid SMILES instead of failing",
"type": "boolean"
}
},
"type": "object"
},
"reduction": "ordered-concat",
"trust_modes": [
"trusted",
"untrusted_quorum"
],
"ui_elements": [
{
"default": null,
"field": "min_molwt",
"group": "",
"help": "Keep molecules with MolWt at least this value. Optional.",
"label": "Minimum molecular weight",
"options": [],
"order": 1,
"placeholder": "e.g. 100",
"widget": "number"
},
{
"default": null,
"field": "max_molwt",
"group": "",
"help": "Keep molecules with MolWt at most this value. Optional.",
"label": "Maximum molecular weight",
"options": [],
"order": 2,
"placeholder": "e.g. 600",
"widget": "number"
},
{
"default": true,
"field": "skip_invalid",
"group": "",
"help": "Skip rows with invalid SMILES instead of failing the shard.",
"label": "Skip invalid molecules",
"options": [],
"order": 3,
"placeholder": "",
"widget": "checkbox"
}
],
"upload_ready": true,
"verifier": "exact-artifact@1",
"version": "1.0.0"
},
{
"capabilities": [
"similarity-graph"
],
"description": "Exact sparse Tanimoto similarity graph over deterministic block pairs with a duplicate-safe, coverage-checked merge.",
"determinism": "byte_exact",
"enabled": true,
"inputs": {
"input": {
"allow_nested_collections": false,
"canonicalizer": "scimesh-tsv-v1",
"encoding": "utf-8",
"max_bytes": 10737418240,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/tab-separated-values",
"privacy_class": "project",
"ref": "molecule-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"required_columns": [
"canonical_smiles",
"chembl_id"
]
}
}
},
"name": "similarity-graph",
"outputs": {
"result": {
"allow_nested_collections": false,
"canonicalizer": "similarity-edge-table-v1",
"encoding": "utf-8",
"max_bytes": 107374182400,
"max_dimensions": [],
"max_records": 1000000000,
"media_type": "text/csv",
"privacy_class": "project",
"ref": "similarity-edge-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"columns": [
"source_id",
"target_id",
"similarity"
]
}
}
},
"parameters_schema": {
"additionalProperties": false,
"properties": {
"block_size": {
"minimum": 1,
"type": "integer"
},
"max_rows": {
"minimum": 1,
"type": "integer"
},
"threshold": {
"maximum": 1,
"minimum": 0,
"type": "number"
},
"threshold_direction": {
"enum": [
"greater",
"less"
]
}
},
"required": [
"threshold"
],
"type": "object"
},
"reduction": "ordered-concat",
"trust_modes": [
"trusted",
"untrusted_quorum"
],
"ui_elements": [
{
"default": null,
"field": "threshold",
"group": "",
"help": "Minimum (greater) or maximum (less) edge similarity. Required.",
"label": "Similarity threshold",
"options": [],
"order": 1,
"placeholder": "",
"widget": "number"
},
{
"default": "greater",
"field": "threshold_direction",
"group": "",
"help": "Whether to keep edges above (greater) or below (less) the threshold.",
"label": "Direction",
"options": [
"greater",
"less"
],
"order": 2,
"placeholder": "",
"widget": "select"
},
{
"default": 100,
"field": "block_size",
"group": "",
"help": "Deterministic block size for pair sharding.",
"label": "Block size",
"options": [],
"order": 3,
"placeholder": "",
"widget": "number"
}
],
"upload_ready": false,
"verifier": "exact-artifact@1",
"version": "1.0.0"
},
{
"capabilities": [
"similarity-search"
],
"description": "Exact top-k Tanimoto molecular similarity search over deterministic TSV shards with a bounded merge.",
"determinism": "byte_exact",
"enabled": true,
"inputs": {
"input": {
"allow_nested_collections": false,
"canonicalizer": "scimesh-tsv-v1",
"encoding": "utf-8",
"max_bytes": 10737418240,
"max_dimensions": [],
"max_records": 100000000,
"media_type": "text/tab-separated-values",
"privacy_class": "project",
"ref": "molecule-table@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"required_columns": [
"canonical_smiles",
"chembl_id"
]
}
}
},
"name": "similarity-search",
"outputs": {
"result": {
"allow_nested_collections": false,
"canonicalizer": "scimesh-search-result-v1",
"encoding": "utf-8",
"max_bytes": 1073741824,
"max_dimensions": [],
"max_records": 100000,
"media_type": "text/csv",
"privacy_class": "project",
"ref": "similarity-search-result@1",
"retention_class": "durable",
"streaming": false,
"validator": "delimited-table@1",
"validator_configuration": {
"columns": [
"rank",
"chembl_id",
"canonical_smiles",
"similarity"
]
}
}
},
"parameters_schema": {
"additionalProperties": false,
"oneOf": [
{
"not": {
"required": [
"query_smiles"
]
},
"required": [
"query_id"
]
},
{
"not": {
"required": [
"query_id"
]
},
"required": [
"query_smiles"
]
}
],
"properties": {
"max_rows": {
"minimum": 1,
"type": "integer"
},
"progress_every": {
"minimum": 0,
"type": "integer"
},
"query_id": {
"maxLength": 200,
"minLength": 1,
"type": "string"
},
"query_smiles": {
"maxLength": 200,
"minLength": 1,
"type": "string"
},
"threshold": {
"maximum": 1,
"minimum": 0,
"type": "number"
},
"threshold_direction": {
"enum": [
"greater",
"less"
]
},
"top_k": {
"minimum": 1,
"type": "integer"
}
},
"type": "object"
},
"reduction": "top-k",
"trust_modes": [
"trusted",
"untrusted_quorum"
],
"ui_elements": [
{
"default": null,
"field": "query_id",
"group": "",
"help": "ChEMBL id of the query molecule. Provide exactly one of id or SMILES.",
"label": "Query molecule id",
"options": [],
"order": 1,
"placeholder": "",
"widget": "text"
},
{
"default": null,
"field": "query_smiles",
"group": "",
"help": "SMILES of the query molecule. Provide exactly one of id or SMILES.",
"label": "Query molecule SMILES",
"options": [],
"order": 2,
"placeholder": "",
"widget": "text"
},
{
"default": 20,
"field": "top_k",
"group": "",
"help": "Number of most similar molecules to keep per shard (global merge keeps the best of these).",
"label": "Top k",
"options": [],
"order": 3,
"placeholder": "",
"widget": "number"
},
{
"default": "greater",
"field": "threshold_direction",
"group": "",
"help": "Keep molecules with similarity greater or less than the threshold.",
"label": "Direction",
"options": [
"greater",
"less"
],
"order": 4,
"placeholder": "",
"widget": "select"
},
{
"default": null,
"field": "threshold",
"group": "",
"help": "Optional similarity bound: results are filtered to this direction.",
"label": "Similarity threshold",
"options": [],
"order": 5,
"placeholder": "e.g. 0.8",
"widget": "number"
}
],
"upload_ready": true,
"verifier": "exact-artifact@1",
"version": "1.0.0"
}
]
}
+53 -15
View File
@@ -28,10 +28,33 @@ case "$demo_dir" in
/*) ;;
*) demo_dir="$coordinator_dir/$demo_dir" ;;
esac
worker_bin=${SCIMESH_WORKER_BIN:-"$repo_dir/.venv/bin/scimesh-worker"}
agent_bin=${SCIMESH_AGENT_BIN:-"$coordinator_dir/bin/worker-agent"}
pid_file="$demo_dir/workers.pids"
logs_dir="$demo_dir/logs"
# The built MkDocs site is mounted into the demo coordinator so the UI can
# serve it at /ui/docs/. When site/ is missing (make docs), the docs route
# shows a build hint instead.
docs_compose_file="$demo_dir/docker-compose.docs.yml"
docs_compose_files=""
prepare_docs_override() {
mkdir -p "$demo_dir"
if [[ -d "$repo_dir/site" ]]; then
cat > "$docs_compose_file" <<DOCS_OVERRIDE_EOF
services:
coordinator:
volumes:
- $repo_dir/site:/site:ro
environment:
SCIMESH_DOCS_DIR: /site
DOCS_OVERRIDE_EOF
docs_compose_files="-f $docs_compose_file"
else
docs_compose_files=""
fi
}
compose() {
POSTGRES_PORT="$postgres_port" \
COORDINATOR_PORT="$coordinator_port" \
@@ -46,7 +69,8 @@ compose() {
docker compose -p "$project" \
-f "$coordinator_dir/docker-compose.yml" \
-f "$coordinator_dir/docker-compose.users.yml" \
-f "$coordinator_dir/docker-compose.monitoring.yml" "$@"
-f "$coordinator_dir/docker-compose.monitoring.yml" \
$docs_compose_files "$@"
}
stop_workers() {
@@ -55,13 +79,23 @@ stop_workers() {
[[ "$pid" =~ ^[0-9]+$ ]] || continue
command_line=$(ps -p "$pid" -o args= 2>/dev/null || true)
# Never kill a recycled PID or a worker launched outside this demo.
if [[ "$command_line" == *"$demo_dir/worker-"* ]]; then
if [[ "$command_line" == *"worker-agent"* ]]; then
kill "$pid" 2>/dev/null || true
fi
done < "$pid_file"
rm -f "$pid_file"
}
build_agent() {
if [[ ! -x "$agent_bin" ]]; then
echo "Building the Go worker agent..." >&2
make -C "$coordinator_dir" agent >&2 || {
echo "Failed to build the Go worker agent." >&2
exit 2
}
fi
}
wait_for_coordinator() {
local attempt=0
until curl --fail --silent --show-error "http://localhost:$coordinator_port/health" >/dev/null; do
@@ -116,15 +150,12 @@ wait_for_workers() {
}
start() {
prepare_docs_override
if ! [[ "$workers" =~ ^[1-9][0-9]*$ ]]; then
echo "DEMO_WORKERS must be a positive integer (got $workers)." >&2
exit 2
fi
if [[ ! -x "$worker_bin" ]]; then
echo "Reference worker not found: $worker_bin" >&2
echo "Create it first from the repository root: python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'" >&2
exit 2
fi
build_agent
command -v docker >/dev/null || { echo "Docker is required." >&2; exit 2; }
command -v curl >/dev/null || { echo "curl is required." >&2; exit 2; }
@@ -137,14 +168,21 @@ start() {
wait_for_userservice
: > "$pid_file"
task_runner="[\"$repo_dir/.venv/bin/python\",\"-m\",\"scimesh.worker.task\"]"
for index in $(seq 1 "$workers"); do
work_dir="$demo_dir/worker-$index"
mkdir -p "$work_dir"
SCIMESH_COORDINATOR_URL="http://localhost:$coordinator_port" \
SCIMESH_BEARER_TOKEN="$worker_token" \
"$worker_bin" \
--worker-name "demo-worker-$index" \
--work-dir "$work_dir" \
COORDINATOR_URL="http://localhost:$coordinator_port" \
WORKER_AUTH_TOKEN="$worker_token" \
WORKER_NAME="demo-worker-$index" \
WORK_DIR="$work_dir" \
CPU_COUNT=1 \
MEMORY_MB=1024 \
POLL_INTERVAL=0.5s \
REQUEST_TIMEOUT=15s \
HEARTBEAT_INTERVAL=15s \
TASK_RUNNER="$task_runner" \
"$agent_bin" \
>"$logs_dir/worker-$index.log" 2>&1 &
echo "$!" >> "$pid_file"
done
@@ -159,11 +197,11 @@ SciMesh manual demo is ready.
Userservice: http://localhost:$userservice_port
Grafana: http://localhost:$grafana_port (anonymous view; admin/${GRAFANA_PASSWORD:-admin} to edit)
Prometheus: http://localhost:$prometheus_port
Workers: $workers local reference workers
Workers: $workers Go worker agents (Python task execution)
Sign in with the admin above, or register a new account from the login page.
The admin sees every job; a plain user sees only their own. Upload a small
ChEMBL TSV through “New similarity search”, then watch the job page update.
ChEMBL TSV through the "New computation" form, then watch the job page update.
Worker logs are in $logs_dir. Stop everything with:
make demo-down
+7 -4
View File
@@ -96,10 +96,13 @@ paths:
description: >
multipart/form-data. The text fields (`workload`, `parameters`,
`chunk_rows`, `max_rows`) MUST precede the `file` part: the file is streamed, not
buffered, so the fields have to be parsed before it arrives. Currently
only `similarity-search` with `parameters.query_smiles` is accepted.
When every shard succeeds, the coordinator merges their candidates into
one final CSV; distributed graph planning is not implemented.
buffered, so the fields have to be parsed before it arrives. The
workload must be an enabled, upload-ready entry of the embedded SDK
workload catalog, and `parameters` must satisfy its declared JSON
schema. When every shard succeeds, the coordinator reduces the partial
results (`top-k` workloads merge exactly; `ordered-concat` workloads
concatenate in shard order) into one final CSV; distributed graph
planning is not implemented.
requestBody:
required: true
content:
+556
View File
@@ -0,0 +1,556 @@
# SciMesh Workload SDK contract
**Status:** contract `0.1`. The Python `core-batch-v1` foundation is implemented
in `scimesh.sdk`; dynamic, streaming, accelerator, gang, side-effect, and
coordinator protocol-v2 behavior remains a normative target. Normative words
**MUST**, **MUST NOT**, **SHOULD**, and **MAY** apply to an implementation only
when it advertises the affected profile or feature.
This document defines the compatibility boundary for approved SciMesh workload
packages. The sequencing and unresolved product decisions remain in
[`scimesh-sdk-roadmap.md`](scimesh-sdk-roadmap.md). The production coordinator
wire compatibility profile remains
[`ctx-07-distributed-workload-protocol.md`](ctx-07-distributed-workload-protocol.md).
## 1. Scope and invariants
The SDK must express current molecular workloads and future batch, iterative,
streaming, optimization, simulation, ML, image, engineering, and accelerator
workloads through installed, allowlisted packages. "Universal" means that a
workload can declare its dataflow, resources, execution semantics, and result
validation; it does not mean users may submit arbitrary executable code.
Every implementation MUST preserve these invariants:
1. The coordinator owns Jobs, workflow state, Tasks, Attempts, Leases, resource
assignments, and durable Artifact metadata.
2. A Worker Agent executes only an installed package and entry point whose
digest is enabled by an administrator.
3. Scientific code never receives PostgreSQL credentials, worker identity
credentials, or unrestricted coordinator credentials.
4. Every input and output crosses a typed artifact port. Worker-local paths are
attempt-scoped implementation details and never become durable references.
5. A Task reports success only after all declared outputs are durable and its
verifier policy has produced an admissible decision.
6. Resource allocation is explicit and lease-fenced. A worker MUST NOT execute
a Task without reserving its complete declared resource set.
7. Unknown fields, unsupported versions, undeclared outputs, non-finite numeric
values, and limit violations fail closed.
8. No verifier, reducer, or trust policy may silently downgrade to a weaker
mode.
## 2. Version and identity model
The following versions are independent and MUST be recorded in each Job and
output provenance manifest:
| Identifier | Meaning |
| --- | --- |
| `sdk_api_version` | Python authoring API compatibility. |
| `protocol_version` | Coordinator/Worker wire contract. |
| `workload.name` + `workload.version` | Scientific behavior and planner contract. |
| `manifest_schema_version` | Shape of the workload manifest. |
| `workflow_schema_version` | Shape of workflow/stage/task plans. |
| `artifact_schema.name` + `version` | Logical data format. |
| `verifier.name` + `version` | Result-acceptance semantics. |
| `environment.digest` | Exact executable environment or image. |
Versions use explicit compatibility ranges. The coordinator enables a workload
only when there is a non-empty intersection among coordinator protocol, Worker
runtime, SDK API, workload package, and verifier versions. A missing or unknown
version is never interpreted as "latest". Jobs remain pinned to the resolved
versions even after an administrator upgrades the installed package.
### 2.1 Feature negotiation and conformance profiles
Universality does not require every deployment to enable every execution mode.
The protocol negotiates explicit feature IDs; a workload declares required and
optional features, and coordinator, Worker, and verifier runtimes advertise
supported versions. Planning fails before Job creation when a required feature
is absent.
| Profile | Required feature set |
| --- | --- |
| `core-batch-v1` | Typed parameters/artifacts, static map/reduce DAG, subprocess runner, exact verifier, CPU/memory/scratch reservations. |
| `dynamic-v1` | Transactional expansion manifests and bounded loop controllers. |
| `stream-v1` | Partition offsets, windows, checkpoints, backpressure, and declared delivery guarantees. |
| `accelerator-v1` | Generic device inventory, fenced allocation, visibility/isolation, and device failure codes. |
| `gang-v1` | Atomic multi-Agent reservation, rendezvous, group lease, and fail-all semantics. |
| `side-effect-v1` | Idempotency/audit/compensation and scoped external credentials. |
Example feature IDs include `artifact-collections@1`, `dynamic-expansion@1`,
`bounded-loops@1`, `stream-checkpoints@1`, `gpu-exclusive@1`, `gpu-mig@1`,
`gang-leases@1`, and `numeric-verifier@1`. Optional features may select a
manifest-declared fallback such as CPU execution; they MUST NOT change output
schema or verification semantics unless the fallback is a separately versioned
workflow variant.
## 3. Workload package and manifest
An SDK workload is an installed Python distribution containing:
- one or more versioned workload manifests;
- explicit Python entry points for planner, runners, reducers, and verifiers;
- artifact and parameter schemas;
- pinned environment/image metadata;
- golden fixtures and conformance tests;
- provenance and license metadata.
Discovery MUST use configured package entry points and an administrator
allowlist. It MUST NOT import modules from job parameters, uploaded archives, or
user-provided filesystem paths.
Illustrative manifest shape:
```yaml
manifest_schema_version: 1
sdk_api: ">=1.0,<2.0"
protocol: ">=2,<3"
workload:
name: descriptor-batch
version: 1.0.0
description: Pinned RDKit 2D descriptors
package:
distribution: scimesh-descriptors
digest: sha256:...
signature: cosign-or-project-signature-reference
environment:
kind: oci
digest: sha256:...
parameters_schema: schemas/parameters-v1.json
workflow: workflows/default-v1.yaml
inputs:
molecules: {schema: molecule-table@1, cardinality: one}
outputs:
descriptors: {schema: descriptor-table@1, cardinality: one}
determinism: byte_exact
trust_modes: [trusted, verified, untrusted_quorum]
verifier: {name: exact-artifact, version: 1}
limits:
max_input_bytes: 10737418240
max_tasks: 10000
max_output_bytes: 10737418240
capabilities: [descriptor-batch]
```
The manifest MUST declare canonical hyphenated workload names, parameter
schema, external ports, workflow, determinism, supported trust modes, verifier,
resource bounds, output-growth bounds, environment, and capabilities.
## 4. Workflow model
### 4.1 Workflow graph
`WorkflowSpec` defines versioned stages and artifact edges. Its persisted form
is a DAG. Iteration is represented by a bounded controller that materializes a
new DAG segment for each iteration; persisted task dependencies never contain a
cycle.
```yaml
workflow_schema_version: 1
id: default
inputs: [molecules]
stages:
- id: partition
kind: plan
runner: descriptor.partition:v1
- id: calculate
kind: map
needs: [partition]
runner: descriptor.calculate:v1
- id: combine
kind: reduce
needs: [calculate]
reducer: descriptor.combine:v1
- id: verify
kind: verify
needs: [combine]
outputs: [descriptors]
failure_policy: fail_fast
```
Stage IDs are stable within a workflow version. Valid stage kinds are initially
`plan`, `map`, `reduce`, `verify`, `loop-controller`, `stream`, `service`, and
`side-effect`. Runtimes MAY add kinds only through a new workflow schema
version.
### 4.2 Stage contract
A `StageSpec` MUST declare:
- stable ID, kind, entry-point identity, and dependencies;
- named input/output ports and their artifact schemas/cardinality;
- parameter projection from immutable Job parameters;
- resource and execution profiles;
- retry, timeout, checkpoint, cancellation, and failure policies;
- fan-out/fan-in bounds and ordering semantics;
- verifier and trust requirements where stage outputs affect acceptance;
- cacheability, side effects, and network/secrets policy.
Dynamic fan-out requires an accepted `ExpansionManifest` containing stable child
keys, bounded child count, TaskSpecs, artifact bindings, and a digest. The
coordinator validates and persists the entire expansion transactionally. A
retry producing a different expansion digest is a conflict, not a replacement.
### 4.3 Bounded iteration
`LoopSpec` supports training epochs, optimization, adaptive sampling, MD
segments, and convergence algorithms:
```yaml
loop:
state_schema: optimizer-state@1
max_iterations: 100
max_wall_seconds: 86400
body_workflow: optimize-step@2
continue_when: verifier-entry-point-reference
checkpoint_every: 5
on_limit: fail # fail | accept-best | return-inconclusive
```
The loop controller is trusted orchestration code from the workload package.
It consumes immutable prior-state and evaluation artifacts and emits an
immutable next-iteration expansion. It MUST NOT mutate completed Tasks or reuse
an Attempt directory. Termination is bounded by iterations, wall time, cost,
and output growth. The condition and best-result selection are versioned and
auditable.
### 4.4 Streaming profile
Streaming is an explicit profile rather than an indefinitely running batch
Task. A `StreamSpec` declares source identity, partitioning, offset/checkpoint
schema, event-time or processing-time windows, watermark behavior, backpressure
limit, delivery guarantee, idle/terminal condition, and output compaction.
Supported guarantees are `at_least_once` initially and, only where the source
and sink support transactional offsets, `exactly_once`. A checkpoint commits
source offsets only after corresponding output artifacts are durable. Stream
processors MUST be restartable from a sealed checkpoint artifact. An unbounded
stream produces versioned window/result artifacts and does not hold one Task
lease forever.
### 4.5 Side effects and human interaction
Stages controlling instruments or writing external systems are trusted-only.
They require an idempotency key, declared target, credential scope, audit event,
timeout, and compensation/manual-recovery policy. Retries are disabled unless
the stage proves idempotency. Human approval is modeled as a coordinator state
transition with an authenticated decision record, never as a worker waiting
indefinitely while holding resources.
## 5. Task and artifact contracts
`TaskSpec` is the concrete unit leased to a Worker Agent:
```yaml
schema_version: 1
workload: descriptor-batch@1.2.0
package_digest: sha256:...
manifest_digest: ...
trust_mode: trusted
task_key: calculate/shard-000042
stage_id: calculate
parameters: {...validated JSON...}
inputs:
molecules:
collection: ordered
artifacts: [{artifact_id: uuid, sha256: ..., schema: molecule-table@1}]
expected_outputs:
descriptors: {schema: descriptor-table@1, cardinality: one}
resources: {profile: cpu-medium@1}
execution: {profile: python-process@1}
```
`task_key` is deterministic within a workflow expansion. The coordinator adds
its durable Task ID, Attempt number, Lease, and generated download/upload URLs.
Plans contain artifact IDs and checksums, never external credentials or local
paths.
### 5.1 Artifact schemas and collections
Each port references an `ArtifactSchema` defining logical type, schema version,
media type, encoding, cardinality, maximum bytes/records/dimensions, streaming
support, canonicalizer, validation entry point, and privacy/retention class.
Collections are `single`, `ordered`, `keyed`, or `set`:
- `ordered` preserves declared order and is included in the collection digest;
- `keyed` requires unique canonical string keys;
- `set` canonicalizes by artifact identity and forbids duplicates;
- nested collections require explicit schema permission and depth limits.
Protocol v1 compatibility uses one immutable composite-manifest artifact to
represent a collection. A later protocol may persist collection edges directly.
### 5.2 Output and provenance manifest
Before completion, a runner uploads an `OutputManifest` listing every declared
output artifact, checksum, schema, size, record/dimension summary, metrics, and
provenance. Provenance includes resolved versions, package/environment and
manifest digests, Worker runtime, allocated resource IDs, parameters digest,
input collection digest, timestamps, random seed where applicable, and
checkpoint lineage.
Unexpected ports, missing required outputs, extra artifacts, schema failures,
or limit violations reject the Attempt. Logs and checkpoints are separate
artifact kinds and never satisfy scientific output ports.
### 5.3 Locality and cache
Artifacts remain coordinator-owned even when cached. Worker Agents MAY maintain
a content-addressed read cache verified by checksum. Cache entries carry size,
last-use, schema, environment sensitivity, and retention class; eviction never
deletes the durable coordinator copy.
Task matching MAY score data locality after eligibility and fairness checks.
It MUST NOT weaken trust, resource, lease, or ownership constraints. Large input
staging occurs before execution timeout starts, with a bounded staging lease.
Cache hits are verified before use; private artifacts are isolated by tenant or
encrypted policy.
## 6. Resource and execution contracts
### 6.1 Resource inventory and requests
A Worker Agent advertises versioned, periodically refreshed inventory:
```yaml
agent:
cpu:
logical_cores: 32
allocatable_cores: 28
architecture: x86_64
memory_mb: 131072
scratch_mb: 1000000
accelerators:
- kind: gpu
vendor: nvidia
device_uuid: GPU-...
model: A100
memory_mb: 81920
compute_capability: "8.0"
partitioning: [exclusive, mig]
topology_group: nvlink-0
runtime:
os: linux
container_runtime: ...
driver_versions: {...}
environment_digests: [sha256:...]
```
A `ResourceRequirements` request distinguishes minimums, preferred values, and
hard constraints. Core fields are CPU cores, memory, scratch, accelerator count
and kind, device memory, architecture/capability, exclusivity, topology,
network/interconnect class, environment digest, estimated input/output bytes,
and maximum duration.
Allocation is atomic and lease-fenced. A Task cannot start until the Agent has
confirmed the reservation token. Resources are released only after the process
group exits and attempt cleanup completes. Device IDs and secrets are not part
of scientific parameters.
### 6.2 CPU concurrency
One machine runs one Worker Agent with `max_concurrency` execution slots. Each
Task separately requests `cpu_cores`; the sum of reservations cannot exceed
allocatable capacity. `ExecutionProfile` declares:
- `process_model`: `single`, `process_pool`, `thread_pool`, or `external_runtime`;
- maximum worker processes and threads per process;
- OpenMP/BLAS/native-library thread limits;
- affinity/NUMA preference when required;
- whether nested parallelism is prohibited (default) or explicitly bounded.
CPU-bound Python SHOULD use isolated processes. Threads remain valid for I/O or
native extensions that release the GIL. Independent task heartbeat supervisors
remain outside scientific subprocesses. Draining stops claims first, maintains
active leases, then checkpoints/cancels at the declared deadline.
### 6.3 GPU and accelerator allocation
GPU requests can specify `exclusive_device`, `fractional`, or `partition`
(including MIG-like partitions); runtimes MUST advertise which modes they can
enforce. Requests may require multiple devices in one topology group. The
coordinator performs generic eligibility and gang selection; the Agent owns
device isolation and sets backend-specific visibility variables.
The Agent MUST fence allocation by device UUID/partition ID, validate driver,
runtime and environment compatibility, prevent incompatible sharing, monitor
device health and memory, and terminate the whole process group on lease loss.
OOM, device reset, ECC failure, and unavailable-device errors have distinct
sanitized codes and workload-declared retry policies. Preemptible GPU Tasks need
an explicit compatible checkpoint contract.
The workload owns CPU/GPU implementation, batching, mixed precision, seeds,
algorithm determinism, memory strategy, and scientific parity tests. The
coordinator contains no CUDA calls or domain formulas. A GPU result follows the
same output schema and verifier semantics as any CPU result.
### 6.4 Multi-node and gang execution
`GangSpec` expresses MPI, multi-node training, and tightly coupled simulations:
```yaml
gang:
replicas: 4
per_replica_resources: {cpu_cores: 8, gpu_count: 1, memory_mb: 32768}
topology: {same_fabric: true, min_bandwidth_class: infiniband}
rendezvous: worker-agent-managed
failure_mode: fail_all
```
The coordinator atomically reserves all replicas or none, then issues one gang
lease and per-replica fenced assignments. Worker Agents establish a scoped
rendezvous channel without exposing general coordinator credentials. A failed,
expired, or cancelled replica invalidates the gang according to `failure_mode`;
partial success cannot complete the stage. Gang retries use a new Attempt and
new rendezvous credentials.
## 7. Verification and trust
Verifier decisions are `accepted`, `rejected`, or `inconclusive`. Only
`accepted` can satisfy a stage. Evidence is a bounded, sanitized artifact tied
to input/output and verifier digests.
| Verifier | Intended semantics |
| --- | --- |
| `ExactArtifactVerifier` | Whole-file or declared collection digest equality. |
| `CanonicalRecordVerifier` | Parse, validate, normalize, order, and serialize through a versioned canonicalizer. |
| `NumericToleranceVerifier` | Structured element/aggregate comparison with declared absolute, relative, ULP, NaN, and shape policies. |
| `StatisticalVerifier` | Repeated seeded evidence and versioned statistical acceptance criteria. |
| `DomainSpecificVerifier` | Workload-owned invariants, constraints, objective bounds, or reference checks. |
| `TrustedWorkerPolicy` | Accept only from allowed trust/environment attestations; still validate schema and bounds. |
The current whole-artifact SHA quorum maps only to `ExactArtifactVerifier`.
Canonical, numeric, stochastic, optimization, and side-effecting workloads MUST
declare an appropriate verifier/trust combination. Reducers consume only
accepted partial outputs and MUST detect missing, duplicate, conflicting, or
inconclusive inputs.
Quorum candidates MUST be coordinator-authenticated envelopes with unique
Attempt/candidate identity and an owner identity. A verifier counts at most one
vote per owner. It also receives a coordinator-owned binding for workload,
task, package/manifest/environment, parameters, and input-collection digests;
outputs from another job or code pin are invalid even when their result bytes
match.
## 8. Failure, retry, cancellation, and checkpoint semantics
Every failure has a stable sanitized code, category (`input`, `scientific`,
`resource`, `infrastructure`, `lease`, `verification`, or `policy`), retryability,
and optional bounded evidence reference. Raw tracebacks and private paths remain
local.
- Retries create a new Attempt directory and resource lease; they never mutate
prior artifacts.
- Idempotent completion accepts the identical output manifest for the same
Attempt; conflicting manifests are rejected.
- Speculative execution, when enabled, creates multiple Attempts but commits at
most one accepted result and cancels the rest.
- Lease loss immediately fences upload/completion and terminates execution.
- Cancellation propagates to process groups/gangs and disposes uncommitted
staging artifacts.
- Retry budgets may be per Task, Stage, Loop, and Job; the strictest exhausted
budget wins.
- Checkpoint resume requires matching workload, schema, environment, and
checkpoint compatibility versions. Otherwise execution restarts cleanly.
- Side-effecting retries require an idempotency record or explicit operator
recovery.
Workflow failure policies are `fail_fast`, `continue_independent`,
`allow_partial` (only with an output schema/verifier that defines partial
results), and `compensate`. A failed reducer/verifier never leaves a Job marked
completed.
## 9. Package security and permissions
Installation and job submission are separate authorities. Only administrators
or managed policy may install, sign, approve, enable, upgrade, or revoke a
workload package. A job references an enabled immutable package digest.
Package policy MUST cover signature trust roots, dependency/image scanning,
license/provenance records, supported platforms, vulnerability/revocation
status, and reproducible build evidence. Upgrade does not rewrite running or
historical Jobs.
Each stage declares least-privilege permissions:
- network: none, coordinator-artifacts-only, allowlisted egress, or trusted;
- filesystem: read-only inputs, attempt scratch, declared outputs;
- secrets: named scoped handles, never raw values in Task parameters;
- subprocess: denied by default except the installed runner/runtime contract;
- devices and host interfaces: only allocated resources.
Worker Agents enforce permissions through the strongest available OS/container
sandbox and report the enforcement profile. A workload requiring unavailable
isolation is ineligible rather than silently unsandboxed.
## 10. SDK interfaces and conformance
The Python API exposes protocols equivalent to:
```python
class Planner(Protocol):
def validate(self, request: JobRequest) -> ValidatedJob: ...
def plan(self, job: ValidatedJob, context: PlanningContext) -> WorkflowPlan: ...
class Runner(Protocol):
def run(self, context: TaskContext) -> OutputManifest: ...
class Reducer(Protocol):
def reduce(self, context: ReduceContext) -> OutputManifest: ...
class Verifier(Protocol):
def verify(self, context: VerifyContext, candidates: CandidateOutputs) -> VerificationDecision: ...
```
Concrete public value objects are immutable, typed, JSON-safe, strict about
unknown fields, and canonically serialized inside versioned wire contracts.
Scientific cores SHOULD remain callable without a coordinator so the same
implementation powers local and distributed adapters.
The shipped `LocalCoreBatchExecutor` is a trusted in-process conformance
harness, not the `core-batch-v1` production isolation boundary. It rejects
restricted-network, parallel-process/thread, accelerator, secret, checkpoint,
retry, gang, and advanced-stage declarations. Subprocess isolation, hard
timeouts, leases, and credential enforcement remain requirements for an Agent
runtime that advertises those guarantees.
An SDK conformance suite MUST test manifest/schema validation, deterministic
planning, no local-path/URI leakage, output bounds, local/distributed parity,
retry and completion-order invariance, lease-loss cleanup, verifier behavior,
resource eligibility, and package permission declarations. Profile-specific
suites add two-worker byte equality, numeric tolerance, stochastic evidence,
stream recovery, loop limits, gang failure, or GPU parity as applicable.
## 11. Existing workload compatibility
- Local and distributed `similarity-search` map to a bounded shard-map and
top-k reducer workflow without duplicating the scientific algorithm.
- Local `similarity-graph` and future CTX-10 distribution map to triangular
block-pair expansion plus duplicate-safe edge reduction and pair-coverage
verification.
- Existing `DistributedPlan`, single input artifact, single partial result, and
`chunk_index` become the SDK compatibility profile `map-reduce-v1`.
- `descriptor-batch` is the first new reference implementation for the full
manifest, exact verifier, golden fixtures, and local/distributed parity.
No current workload is removed or renamed by adopting the SDK. Migration is an
adapter and manifest exercise first; protocol/database generalization occurs in
versioned later phases.
## 12. Deferred decisions
The roadmap must resolve these before implementation reaches the affected
phase:
1. SDK package ownership and independent release cadence.
2. Go-to-Python planner/reducer/verifier bridge and isolation boundary.
3. Verifier execution placement and environment attestation.
4. Streaming source/sink integrations and exactly-once scope.
5. Multi-node rendezvous, network identity, and gang scheduling persistence.
6. Accelerator sharing/MIG portability and accounting.
7. Workload signing technology, trust roots, and revocation distribution.
8. Tenant quotas, costs, priorities, fairness, and data-retention policy.
9. First-class artifact collections versus composite manifests.
10. Compatibility negotiation and deprecation support windows.
+292
View File
@@ -0,0 +1,292 @@
# SciMesh Workload SDK roadmap
**Status:** active delivery roadmap. The Python `scimesh.sdk` package now
implements the `core-batch-v1` foundation, verifier primitives, resource
eligibility/local allocation, installed-package registry, local conformance
runtime, and legacy similarity-search adapter. Coordinator-backed generalized
DAG execution, Worker concurrency, accelerators, streaming, gang execution,
and authoring CLI commands remain future phases.
The normative future API, workflow, execution, resource, security, and failure
semantics are specified in the design-draft
[`scimesh-sdk-contract.md`](scimesh-sdk-contract.md). This roadmap controls
delivery order and does not override that contract.
## Purpose and boundaries
The SDK should let a scientific developer add an allowlisted workload without
learning coordinator internals or writing SQL, while keeping one scientific
implementation usable locally and in distributed execution:
```text
scientific implementation -> local adapter -> planner/tasks -> reducer -> verifier
```
It must not execute arbitrary code or shell commands supplied by a coordinator.
SDK v1 is not a public marketplace, generic container/job runner, cross-language
SDK, automatic correctness-proof system, or immediate route to stochastic ML or
GPU workloads.
The current foundation is the Python `DistributedWorkload` protocol and registry
under `scimesh/distributed/`, the Worker Agent under `scimesh/worker/`, and the
coordinator API contract. See [CTX-07](ctx-07-distributed-workload-protocol.md),
[worker-building guide](building-workers.md), and [API contract](api-contract.md).
## Proposed public concepts
The detailed schemas and invariants are defined in the SDK contract; this table
is the roadmap-level responsibility map.
| Concept | Responsibility |
| --- | --- |
| `WorkloadDefinition` / `WorkloadManifest` | Name, versions, schemas, execution and verification metadata. |
| `ParameterSchema`, `InputSpec`, `OutputSpec`, `ArtifactRef` | Typed public inputs and durable artifact shapes. |
| `TaskPlan`, `Planner`, `Runner`, `Reducer` | Validate, split, execute, and deterministically combine work. |
| `Verifier` | Accept or reject result evidence; never silently downgrade checks. |
| `ResourceRequirements`, `ExecutionProfile`, `ReproducibilityProfile` | Bounded resource needs and pinned execution assumptions. |
A manifest should include workload/version and SDK compatibility versions,
description, parameter/input/output schemas, planner/runner/reducer/verifier
types, determinism and trust profiles, resource/output limits, worker
capabilities, and environment or image digest. Compatibility must be explicit
among SDK, coordinator protocol, worker runtime, workload, output schema, and
verifier versions.
## Contracts
**Planner:** validates before durable Job/Task creation; produces versioned,
JSON-serializable plans that refer only to durable artifacts; gives stable task
order and expected resources/outputs; fails transactionally without a partial
task graph.
**Runner:** receives typed parameters and owned artifact references; runs only
allowlisted SDK code; writes to its attempt directory; produces output manifest,
metrics, and sanitized failures; respects cancellation/lease loss when platform
support exists; never uses `shell=True` or unnecessarily exposes credentials.
**Reducer:** consumes only accepted partial artifacts in stable order; is
idempotent or coordinator-state protected; creates a versioned final manifest;
defines missing, duplicate, and malformed-shard failures.
**Verifier:** is versioned with the workload and states one of byte-exact,
canonical, numeric, domain-specific, or trust-policy comparison. It processes
structured manifests and bounded streams where practical, records sanitized
evidence, and rejects inconsistent results.
Current untrusted quorum is only `ExactArtifactVerifier`: coordinator-created
candidate envelopes from distinct owners must share the exact workload, task,
package/manifest/environment, parameters, and input binding and produce whole
files with identical SHA-256. Future modes are
`CanonicalRecordVerifier`, `NumericToleranceVerifier`,
`DomainSpecificVerifier`, and `TrustedWorkerPolicy`. Canonical mode requires a
specified parser/schema/order/encoding/serialization; numeric mode compares
structured values, not CSV text.
## Resources and reproducibility
The extensible requirement model is `cpu_cores`, `memory_mb`, `scratch_mb`,
`gpu_count`, `gpu_memory_mb`, `accelerator_kind`, `exclusive_device`, and
`estimated_output_bytes`. It must not imply one Task equals one CPU core.
Untrusted byte-exact workloads require a pinned image/environment digest,
runtime and dependency versions, fixed locale/timezone/UTF-8/newlines/dialect,
explicit invalid-row policy and algorithm options, canonical ordering, stable
archive metadata, golden fixtures, two independently provisioned workers, and
local/distributed plus retry/completion-order parity tests.
## Compatibility evolution
The stable release retains the current one-input/one-result task contract.
First, a composite manifest artifact may reference multiple logical inputs;
later, ordered input and output artifact collections can become first-class.
The transition must be versioned and retain old workload compatibility.
Discovery should use an installed Python package, manifest, pinned environment
metadata, explicit entry points, and golden fixtures. It must be allowlisted;
never scan or execute user-provided module paths.
## General workload model: a versioned artifact workflow
Map/reduce is the first execution shape, not the limit of the SDK. The target
abstraction is an acyclic **workflow graph**: typed artifact ports connect
versioned stages, and a stage may fan out, fan in, or run once per job. This
allows the same SDK to express scientific ETL, simulations, parameter sweeps,
multi-step pipelines, model inference, image/video analysis, and the current
molecular workloads without placing scientific logic in the coordinator.
```text
Job inputs -> validate -> plan -> [map/partition stages] -> [join/reduce stages]
| |
accepted artifacts -----------+-> verify -> final manifest
```
The coordinator persists the graph, task attempts, leases, and artifact
ownership. The SDK declares stage behavior; it does not receive database access
or arbitrary commands. The initial `DistributedWorkload` protocol maps to a
single input, many map tasks, one reducer, and one final artifact. It remains a
supported compatibility profile rather than being replaced abruptly.
### Workflow and stage contracts
| Concept | Target responsibility |
| --- | --- |
| `WorkflowSpec` | Versioned DAG, external input ports, terminal outputs, global limits, and failure policy. |
| `StageSpec` | Stable stage ID, kind (`map`, `reduce`, `service`, `verify`), input/output port schemas, retry and resource policy. |
| `TaskSpec` | One concrete deterministic unit: stage ID, ordered artifact bindings, parameters, execution profile, and expected output manifest. |
| `ArtifactSchema` | Logical media type, schema version, cardinality, size bound, canonicalization rules, and privacy/retention class. |
| `ArtifactCollection` | Ordered, named, or keyed artifact set; used for shards, paired inputs, model bundles, and multiple outputs. |
| `OutputManifest` | Every output's artifact reference, schema/version/digest, metrics, provenance, and verifier evidence. |
| `FailurePolicy` | Retryable versus terminal errors, timeout, cancellation, partial-output disposal, and compensating cleanup rules. |
A stage is a pure artifact transformation wherever possible. Interactive,
long-running, or external-side-effect stages must declare that fact explicitly
and are initially trusted-only. A workflow cannot form cycles, read a
worker-local path from another stage, mutate a sealed input artifact, or produce
undeclared output ports. Dynamic fan-out is permitted only through a bounded,
versioned manifest emitted by an accepted planning stage; the coordinator must
enforce declared task, artifact, scratch, and output limits.
### Artifact and data-shape generality
The SDK must support more than CSV while retaining streamability and audit
trails. An `ArtifactSchema` can describe tabular records, scientific arrays,
images, meshes, molecular structures, model weights, archives, JSON manifests,
binary checkpoints, or opaque domain formats. It always declares how a consumer
validates structure and bounds bytes/records/dimensions before loading it.
Collections solve multi-input/multi-output work without an immediate database
rewrite. A task can initially receive one composite manifest artifact whose
entries name ordered or keyed logical inputs; it can return a composite output
manifest. Later protocol versions may persist first-class collection edges. The
collection manifest itself is immutable, coordinator-owned, schema-versioned,
and hash-addressed, so the old one-input/one-result API remains compatible.
## Execution model: Worker Agent, slots, and isolation
The Worker Agent is a resource manager, not a scientific runtime. One physical
machine registers one Agent. The Agent advertises a finite inventory and creates
isolated **execution slots**; each leased Task owns exactly one slot until it
finishes, loses its lease, or is cancelled.
```text
machine -> Worker Agent -> CPU / GPU / memory / scratch slot -> task subprocess -> attempt directory
```
`ExecutionProfile` declares whether a task uses a single process, a bounded
process pool, a distributed runtime, or an accelerator backend. It also carries
environment image/digest, entry-point identity, timeout, network policy,
scratch/output bounds, checkpoint policy, and determinism declaration. The
Agent—not a workload—sets environment variables, process groups, filesystem
roots, credentials, resource limits, and lifecycle signals.
### CPU parallelism
`cpu_cores` is a reservation, while `max_concurrency` is the number of slots;
neither is inferred from the other. CPU-bound Python work normally uses a
process pool constrained to the task's allocated cores. A workload must declare
its own internal parallelism and thread-library limits (for example OpenMP,
BLAS, Torch, or RDKit-related native code) so nested pools cannot oversubscribe
the host. The Agent starts independent heartbeat supervision per task and never
claims a task if it cannot reserve all declared resources.
Graceful draining means: stop new claims, continue heartbeat for active
attempts, request checkpoint/cancellation at deadline, then clean only that
attempt directory. Checkpoints are immutable artifacts and may be resumed only
when the workload's manifest explicitly supports checkpoint compatibility; they
are never treated as a completed result.
### Accelerator support
GPU/accelerator capability is generic inventory, not a coordinator-specific
CUDA feature. A future Agent reports device kind/vendor, UUID, compute
capability, memory, driver/runtime/image digest, supported backends, and
allocatable slot count. The coordinator only matches `ResourceRequirements` to
this inventory. The Agent assigns exclusive or shareable devices, sets device
visibility (for example `CUDA_VISIBLE_DEVICES`), reserves memory where the
platform supports it, starts the subprocess, measures usage, and releases the
slot.
The workload implementation chooses CUDA, ROCm, Metal, TPU, FPGA, SIMD, or a
CPU fallback; batches work and manages model/device memory; and declares the
scientific equivalence policy. Reducers and verifiers compare domain outputs,
not device-specific logs or floating-point text. GPU work cannot be enabled for
untrusted quorum merely because it runs: it additionally needs pinned images,
appropriate verifier/trust mode, and CPU/GPU or domain-valid parity evidence.
## Determinism, verification, and scientific validity
`DeterminismProfile` separates reproducibility from correctness:
| Profile | Examples | Minimum acceptance route |
| --- | --- | --- |
| `byte_exact` | canonical descriptors, fingerprints, sorted ETL | Exact artifact SHA-256 from independent owners. |
| `canonical_exact` | format-normalized records, deterministic structures | Versioned parser/canonicalizer then exact records. |
| `numeric_tolerance` | numerical solvers, GPU linear algebra | Structured comparison with absolute/relative/ULP tolerances and invariants. |
| `seeded_stochastic` | conformers, randomized search | Recorded seed, repeated-run policy, statistical/domain verifier. |
| `search_or_optimization` | routing, docking, retrosynthesis | Objective/constraint/domain evidence; often trusted execution. |
| `side_effecting` | instrument control, external database writes | Trusted-only, idempotency key, audit/compensation policy. |
The verifier consumes `OutputManifest` values, declared schemas, and bounded
streams; it returns accept/reject/inconclusive plus evidence. `inconclusive`
must never become success by a reducer default. Verification may be run by a
coordinator adapter, a pinned Python verifier subprocess, or a separate trusted
service—selection remains an open architectural decision. The manifest versions
the verifier configuration, tolerance values, canonicalizer, reference data,
and environment assumptions so historical results remain interpretable.
## Existing-workload migration matrix
| Existing capability | SDK workflow profile | Future adapter path |
| --- | --- | --- |
| Local `similarity-search` | single-process map + bounded top-k reduce | Keep local algorithm; expose a manifest and use current distributed planner/reducer. |
| Distributed `similarity-search` | deterministic shard map -> ordered reduce | Compatibility workload v1; later attach exact verifier and provenance manifest. |
| Local `similarity-graph` | triangular pair-partition map -> edge-set reduce | Preserve pair-coverage invariant as stage verifier. |
| Distributed `similarity-graph` | planned block-pair DAG -> duplicate-safe reduce | First major non-linear partition reference; implement before SDK generalization. |
| `descriptor-batch` | row-partition map -> ordered concatenation | First SDK reference workload and byte-exact quorum candidate. |
| Future ML/docking/QM/MD | parameter sweep, ensemble, or iterative workflow | Use numeric/domain/trusted verifier profile and explicit resource/environment contracts. |
## Authoring and operational lifecycle
An installed workload package should contain a signed or administrator-approved
manifest, Python entry points, schema migrations where required, pinned
environment metadata, golden fixtures, test vectors, and documentation. An
administrator controls enablement; users choose only among enabled manifests and
validated parameter ranges. Workload installation is separate from job
submission, preventing a user from sending code through the normal API.
The eventual author workflow remains deliberate: initialize a template, define
schemas and bounds, implement local scientific core, add planner/runner/reducer/
verifier adapters, generate fixtures, test local parity, test two-worker and
retry behavior, package, review, and enable. Any future CLI names are examples,
not implemented commands.
## Delivery sequence
1. Finish distributed `similarity-graph` and reliability/cross-language CI.
2. Stabilize manifest, schema, planner/runner/reducer interfaces, exact verifier,
compatibility metadata, and an author guide.
3. Deliver `descriptor-batch` as the reference workload: pinned RDKit 2D
descriptors; canonical one-row-per-input CSV; shard-index concatenation with
one header; byte-identical local/distributed output and two-worker quorum.
4. Add standardization, SMARTS screening, fingerprint export, fixed-template
reaction enumeration, then reaction validation/descriptors.
5. Generalize composite artifacts, process slots, resource requests, and richer
verifier policies.
6. Only then consider pinned, trusted/domain-verified numeric, ML, docking, QM,
MD, and GPU workloads.
Future developer tooling may include `scimesh workload init`, `validate`,
`test-local`, `test-distributed`, `golden`, and `package`; these commands do not
exist today. A template should generate a manifest, schemas, planner, runner,
reducer, verifier, unit tests, golden fixture, two-worker integration test, and
documentation.
## Open decisions
- Is the SDK part of `scimesh` or a separately versioned Python distribution?
- What stable bridge connects Go orchestration to Python planners/reducers?
- Where do future verifiers execute, and how are environments attested?
- Which trust modes may run each verifier profile?
- Who may install/enable workloads in multi-user deployments?
- How are composite I/O, version negotiation, output-growth limits, and
numeric-tolerance access governed without breaking the existing API?
+172
View File
@@ -0,0 +1,172 @@
# Workload SDK handoff
**Audience:** the engineer/AI continuing SciMesh Workload SDK implementation.
**Date:** 2026-08-01. **Baseline:** uncommitted working tree on `main` (`11e9333`
plus the CTX-16 SDK changes); `python -m pytest -q` reports **225 passed**.
Read first, in this order: `AGENTS.md` (binding repo rules),
`docs/scimesh-sdk-roadmap.md` (delivery order — it governs, this file does not),
`docs/scimesh-sdk-contract.md` (normative target semantics),
`docs/workload-sdk.md` (author guide for what exists), and the CTX-16 entry in
`PLAN.md`.
## What is already done (do not redo)
**Legacy removal (2026-08-01):** the CTX-07 `DistributedWorkload` protocol
package (`scimesh/distributed/`), the SDK compatibility adapter
(`scimesh/sdk/compat/`), and `library.similarity_search_sdk_adapter` were
removed. The worker now executes the SDK-built workloads directly:
`scimesh/worker/runners.py` builds a `TaskSpec` with the workload's pins,
negotiates, reserves resources, runs the workload's own Runner through
`LocalTaskContext` (store-backed catalog/sink), and uploads the sealed
partial over the unchanged v1 wire. `run_search_shard` + the full-precision
partial writer moved to `scimesh/workloads/search/core.py`; the partial
format is unchanged, so the Go reducer and UI keep working. The runner
resolves `query_id` per task and rejects plan-time `max_rows`.
**Default hooks + molwt-filter (2026-08-01):** `MapReduceWorkload` now provides default `partition_input` (row-bounded, header-preserving sharding for delimited inputs, `shard_rows` class attr) and default `reduce_partials` (`concatenate_partial_tables`, one header, byte-identical). A new built-in `molwt-filter@1.0.0` (`scimesh/workloads/molwt_filter/`) demonstrates the minimal authoring surface: only `compute_shard` is workload code. descriptor-batch dropped its now-redundant partition/reduce overrides.
**MkDocs site (2026-08-02):** the standalone documentation site lives in `mkdocs/` (`docs_dir: mkdocs`) and does not use the project's `docs/` directory. It contains guides (`mkdocs/sdk/`: overview, authoring-workloads, cli, worker-integration), the full auto-generated API reference for all `scimesh.sdk` modules (`mkdocs/api/`, mkdocstrings `::: scimesh.sdk.<module>` — set `show_if_no_docstring: true`), and the writing rules (`mkdocs/approach.md`). `make docs` builds it; the UI serves it at `/ui/docs/`. All public SDK members now carry Google-style docstrings.
**Go worker agent (2026-08-02):** the Python worker daemon was removed. `coordinator/internal/agent/` + `cmd/worker-agent` (build: `make agent`) now owns the full lifecycle: register/claim/heartbeat/download(checksum)/spawn-task/upload/submit/fail, static bearer or worker-key JWT auth with 401 refresh, attempt-dir cleanup (`CLEANUP_AFTER_SECONDS`), backoff, idle/max-tasks exit. `scimesh/worker/` keeps only the per-task Python execution: `task.py` (exit 0/3/1), `runners.py` (SDK bridge, `SCIMESH_WORKLOAD_ALLOWLIST` discovery), `models.py` (claim payload). The `scimesh-worker` console script and the daemon/auth/transport modules are gone. Demo (`make demo-ui`) and smoke (`make smoke-two-worker`) run the Go agent; smoke passes 4/4 with two agents. `coordinator/internal/agent/` (config, models, client, sanitize, taskrunner, daemon) + `coordinator/cmd/worker-agent`, built with `make agent`. It mirrors the Python worker's v1 lifecycle; per-task SDK execution happens in a Python subprocess (`scimesh/worker/task.py`: exits 0 on success, 3 permanent, 1 retryable). Default `TASK_RUNNER` is `python -m scimesh.worker.task` — set it to the venv python in source checkouts. Verified E2E against the demo coordinator. Open items: JWT refresh, resource slots/limits, attempt-dir cleanup, protocol-v2 features.
**Authoring scaffold (2026-08-01):** `scimesh/sdk/batch.py` adds
`MapReduceWorkload` — the primary authoring surface for `core-batch-v1`. A
subclass declares identity/parameters/ports and three scientific hooks
(`partition_input`, `compute_shard`, `reduce_partials`); the SDK assembles the
manifest, map/reduce stages, workflow, digest-pinned handlers, and the
exact-artifact verifier. Overridable hooks: `domain_validate`,
`resolved_parameters`, `resolved_parameters_for_plan`, `plan_tasks`,
`parse_partial_key`/`validate_partial_keys`, `map_stage_inputs` (multi-input
map stages share the external input schema). All three built-in workloads are
refactored onto it. Generic `scimesh workload list|run` CLI added (no
workload-specific logic). The worker loads workloads generically:
`SCIMESH_WORKLOAD_ALLOWLIST` (JSON `{distribution, name, version, digest}`,
discovery via entry points) or built-in fallback; `SCIMESH_CAPABILITIES`
overrides advertised capabilities; workloads with multi-input map stages are
rejected by the v1 bridge. `query_id` resolution moved into the search
workload's `run_search_shard`; the worker passes task parameters through and
the workload validates them.
CTX-16 "Workload SDK foundation" is complete and tested. `scimesh/sdk/`
implements the `core-batch-v1` profile:
- Immutable, JSON-strict value objects: `identity.py`, `artifacts.py`,
`workflow.py`, `manifest.py`, `plans.py`, `execution.py`, `resources.py`.
- Fail-closed compatibility negotiation: `runtime.py` (`negotiate_manifest`)
plus request-level checks in `registry.py`.
- Installed-package registry with administrator allowlist, exact version +
`sha256:` digest pinning, entry-point discovery with digest measured before
and after import: `registry.py`, `integrity.py`.
- Verifier primitives `ExactArtifactVerifier`, `CanonicalRecordVerifier`,
`NumericToleranceVerifier` with bounded sanitized evidence: `verification.py`.
- Local conformance harness: `LocalArtifactStore`, `LocalCoreBatchExecutor`,
`ResourcePool` (atomic all-or-nothing reservation): `conformance.py`.
- SDK-built workloads living outside the SDK: `scimesh/workloads/search/`,
`scimesh/workloads/graph/`, `scimesh/workloads/descriptors/` (each `core.py`
+ `definition.py`), composed by `scimesh/workloads/library.py`
(`default_sdk_registry`, `default_sdk_runtime`); entry points for all three
declared in `pyproject.toml`.
- Tests: `tests/test_sdk_{models,resources,verification,compatibility,registry}.py`
including fail-closed rejection coverage for every advanced profile
declaration (gang, GPU modes, pools, checkpoints, retries, secrets, streams,
loops, side effects), plus `tests/test_sdk_{search,graph,descriptors}.py`
and the worker bridge tests in `tests/test_worker_daemon.py`.
`tests/test_distributed*.py` were removed with the protocol.
## What remains, in delivery order
1. ~~**`descriptor-batch` reference workload**~~**done** (2026-08-01):
`scimesh/workloads/descriptors/` (`core.py` + `definition.py`) is the first
SDK-built workload. Pinned 81-name RDKit 2D descriptor set (validated at
definition build time), canonical one-row-per-input CSV with `%.6f` floats,
deterministic row-bounded shards, shard-index concatenation with one header,
byte-identical local/distributed output, `skip_invalid` explicit policy, and
`untrusted_quorum` + exact-artifact@1 declared in the manifest. Tests:
`tests/test_sdk_descriptors.py`.
2. ~~**SDK-built `similarity-search` and `similarity-graph`**~~ — **done**
(2026-08-01). Both local workloads are SDK-built packages outside the SDK:
`scimesh/workloads/search/` and `scimesh/workloads/graph/` (each `core.py` +
`definition.py`, manifest + planner/runner/reducer, byte_exact +
exact-artifact@1, trusted + untrusted_quorum). Search resolves the query at
plan time and merges partials with the reference heap (byte-identical to the
CLI). Graph plans one task per block pair `(i,j)` with `i <= j`, reducer
enforces pair-coverage and duplicate-pair rejection, output byte-identical
to the local brute-force reference for both directions and any block size.
Tests: `tests/test_sdk_search.py`, `tests/test_sdk_graph.py`.
**Architecture note:** `scimesh.sdk/` is the framework ONLY (no workload
code); workloads are user scripts/packages under `scimesh/workloads/` that
import the SDK. Keep new workloads out of the SDK package.
2. **Distributed `similarity-graph`** (CTX-10, roadmap step 1). The coordinator
currently rejects `similarity-graph` uploads; it needs cross-shard block-pair
planning and duplicate-safe reduction. STATUS.md names this the next
recommended assignment overall.
3. **Coordinator/Worker protocol v2** (needs CTX-10, then CTX-13 in-worker CPU
parallelism and CTX-14 GPU execution; Go + Python). The protocol-v1
coordinator persists only flat one-input/one-result tasks: no resource
requirements, stage edges, package versions, device allocations, or gang
leases. Until a versioned rollout lands, SDK declarations for those features
must stay fail-closed — do not silently "enable" them.
4. **More chemistry workloads** (roadmap step 4): standardization, SMARTS
screening, fingerprint export, fixed-template reaction enumeration, then
reaction validation/descriptors.
5. **Composite artifacts and richer verifier policies** (roadmap step 5):
first-class ordered/keyed `ArtifactCollection` edges instead of composite
manifest artifacts; decide where verifiers execute (open decision in the
roadmap).
6. **Authoring CLI** (future tooling, does not exist today): `scimesh workload
init`, `validate`, `test-local`, `test-distributed`, `golden`, `package`.
Per AGENTS.md, keep CLI parsing in workload modules and register through
`scimesh/core/registry.py`; no workload-specific logic in the main CLI.
7. **Open decisions** (listed at the end of the roadmap): SDK distribution
split, Go↔Python planner bridge, verifier execution/attestation, trust-mode
governance, multi-user enablement. Do not pick one unilaterally — surface it.
## Known traps (cost the previous session real time)
- **Architecture boundary:** `scimesh.sdk/` is the framework only and must
never import `scimesh.workloads` (SDK depends on nothing workload-specific).
Workload packages live under `scimesh/workloads/` (each `core.py` +
`definition.py`), and built-in wiring lives in `scimesh/workloads/library.py`.
The digest helpers are in `scimesh/workloads/environment.py`; the SDK keeps
only the generic `installed_distribution_digest` in `scimesh/sdk/integrity.py`.
- The legacy adapter pins its own manifest (`adapter.manifest`). If a test
changes limits/workflow on the manifest, the adapter's copy must be replaced
too, or `registry.plan` fails with "planner plan does not carry the selected
immutable workload pin".
- `WorkloadDefinition` validation: a PLAN stage's `entry_point` must equal
`planner.entry_point`; every non-REDUCE stage's `entry_point` must be a key
in `runners` (REDUCE → `reducers`); verifier handlers are keyed by
`ComponentRef.canonical` and must expose a matching `.identity`.
- Negotiation requires each triggering property's feature to be declared
separately: e.g. `PROCESS_POOL` needs `process-pools` **and** `multi-process`
for `max_processes > 1`. Runtime must also advertise every declared required
feature, or negotiation fails with `feature-unavailable`.
- `feature-fallback-disallowed` in `scimesh/sdk/registry.py` is currently
unreachable via `registry.plan` (the `feature-unavailable` check fires first
for any runtime that produced a fallback). Behavior is still fail-closed;
decide whether to reorder or delete the branch.
- The local executor is deliberately trusted/in-process: it rejects anything
but `TrustMode.TRUSTED`, `NetworkPolicy.TRUSTED`, single-threaded CPU
map/reduce without retries/gangs/accelerators/secrets/checkpoints. That is a
contract, not a bug — test rejections, don't "fix" them.
- `JobRequest` parameters and failure/evidence payloads reject local paths and
URIs by design; keep new payloads location-free.
- There is a stray nested clone `SciMesh/` in the repo root (same repo at an
older commit). Ignore it and never `git add` it; consider deleting it.
- The full ChEMBL extract `chembl_37_chemreps.txt` (~2.9M rows) makes the
single-threaded local executor run for many minutes; tests must use small
TSV fixtures (see `_write_tiny_dataset`).
## Working agreement
- Verify with `source .venv/bin/activate && python -m pytest -q`; the baseline
is 225 passing tests and it must stay green. Add a regression test for every
behavioral change; similarity code needs a brute-force/sorted reference and
determinism across block sizes.
- Legacy `similarity-search` wire schema, worker alias boundary, and scientific
output must not change. Worker code never talks to PostgreSQL directly;
results go through the coordinator; failures go to `/failure`, never as
`file://`/`worker://` result URIs.
- Do not commit datasets, generated CSV/PNG, tokens, or local worker artifacts.
- One CTX task per pull request; link the CTX item from `PLAN.md`.
+47
View File
@@ -0,0 +1,47 @@
# SciMesh User Service API contract (v1)
**Status:** `v1`. The User Service owns user accounts and issues access tokens.
The coordinator never receives user passwords and never accesses the User
Service database.
## Authentication boundary
- User Service signs access tokens; coordinator verifies them before accepting
user-scoped requests.
- Tokens contain a UUID `sub`, `role` (`user` or `admin`), `verified`, `iat`,
and `exp` claims.
- A user-authenticated caller may operate only workers whose `owner_id` equals
`sub`. This applies to claim, heartbeat, result, failure, and artifact upload.
- Worker traffic authenticated with the coordinator's shared worker token has
no user identity and remains an operator-only compatibility path.
- Role or verification changes take effect when the access token is renewed.
Deployments needing immediate revocation must use a short token lifetime or a
revocation mechanism before enabling volunteer-worker trust.
## Endpoints
All JSON request bodies reject unknown fields and are size-limited. Error
responses are JSON with a stable `error` value and request ID.
| Method | Path | Auth | Success |
| --- | --- | --- | --- |
| `GET` | `/health` | none | `200 {"status":"ok"}` |
| `POST` | `/register` | none | `201` user object |
| `POST` | `/login` | none | `200` user object and access token |
| `GET` | `/me` | Bearer access token | `200` current user |
| `POST` | `/users/{id}/verify` | Bearer admin token | `204` |
| `POST` | `/users/{id}/unverify` | Bearer admin token | `204` |
| `POST` | `/users/{id}/promote` | Bearer admin token | `204` |
| `POST` | `/users/{id}/demote` | Bearer admin token | `204` |
`POST /register` accepts `{ "email": string, "password": string }` and
always creates role `user` with `verified: false`. `POST /login` accepts the
same shape and returns `{ "token": string, "user": User }`. Password hashes,
JWT signing material, and raw tokens must never be logged.
## Coordinator integration tests
The coordinator must test that a JWT user cannot claim or mutate another
user's worker lease, including heartbeat, failure, result, and artifact upload.
Job and artifact access is restricted to the job owner unless the caller has
the admin role.
+468
View File
@@ -0,0 +1,468 @@
# SciMesh Workload SDK v1
SciMesh now ships a public Python SDK under `scimesh.sdk`. The implemented
authoring profile is **`core-batch-v1`**: installed and digest-pinned workload
definitions, strict JSON manifests, typed artifact ports and collections, a
static map/reduce workflow, CPU/memory/scratch eligibility, atomic local
resource reservation, exact/canonical/numeric verifier primitives, and a
compatibility adapter for the existing `DistributedWorkload` protocol. Its
local executor is deliberately a trusted, in-process conformance harness; the
production subprocess/lease sandbox remains a coordinator/Worker milestone.
The full target contract remains in
[`scimesh-sdk-contract.md`](scimesh-sdk-contract.md). Dynamic expansion,
streaming, accelerators, gang execution, and side effects have typed bounded
declarations, but the current coordinator/Worker runtime does not advertise
their features. Compatibility negotiation therefore rejects those workflows
before planner code runs.
## SDK versus workloads
`scimesh.sdk` is the framework only: strict manifests, plans, artifacts,
registry, verifiers, and the local conformance executor. It contains no
scientific workload code. Workloads are user Python scripts and packages that
import the SDK and live outside it. The built-in SciMesh workloads are under
`scimesh/workloads/`:
- `scimesh/workloads/search/` — SDK-built `similarity-search@1.0.0`;
- `scimesh/workloads/graph/` — SDK-built `similarity-graph@1.0.0`;
- `scimesh/workloads/descriptors/` — SDK-built `descriptor-batch@1.0.0`;
- `scimesh/workloads/molwt_filter/` — SDK-built `molwt-filter@1.0.0`, the
minimal authoring example: it only declares identity, parameters, ports,
and the `compute_shard` hook, using the scaffold's default sharding and
concatenation;
- `scimesh/workloads/library.py` — the built-in library wiring: a default
registry containing all three definitions and a runtime advertising their
capabilities;
- the plain `scimesh/workloads/*.py` modules remain the local CLI scientific
cores and their `Workload` registry.
Each SDK-built workload is a small package with `core.py` (scientific code)
and `definition.py` (manifest plus planner/runner/reducer handlers). A future
external workload library can follow the same shape: its own distribution, one
`scimesh.workloads` entry point per workload version, and an administrator
allowlist.
## What authors import
The stable authoring surface is exported from `scimesh.sdk`:
- `WorkloadManifest`, `WorkloadId`, `VersionRange`, `PackageSpec`, and
`EnvironmentSpec` pin identity and compatibility;
- `ArtifactSchema`, `PortSpec`, `ArtifactRef`, and `ArtifactCollection` define
immutable data boundaries without transport URLs or local paths;
- `WorkflowSpec`, `StageSpec`, `ArtifactEdge`, `TaskSpec`, and `WorkflowPlan`
define a typed acyclic plan and pin package/manifest digests plus trust mode;
- `ResourceRequirements` and `ExecutionProfile` separate per-task resources
from Agent `max_concurrency`;
- `Planner`, `Runner`, `Reducer`, and `Verifier` are the package handler
protocols;
- `OutputManifest` and `Provenance` describe sealed durable results;
- `WorkloadRegistry` resolves an exact name, version, package digest, runtime,
environment, and feature set. It never selects an implicit latest version;
- `MapReduceWorkload` is the primary authoring scaffold for `core-batch-v1`:
a subclass declares its identity, parameter schema, artifact ports, and
three scientific hooks (partition, compute, merge), and the SDK assembles
the manifest, map/reduce stages, workflow, digest-pinned handlers, and the
exact-artifact verifier. See "Authoring a workload" below.
Persisted manifests, requests, plans, tasks, expansions, outputs, candidates,
decisions, and failures are frozen, recursively immutable, JSON-safe,
canonically serialized, and strict about unknown fields; their enclosing wire
contracts carry schema versions.
Artifact identities contain a coordinator-owned UUID, schema, checksum, media
type, and bounds; a scientific handler never persists a filesystem path.
## Try the built-in SDK workloads
This example runs the SDK-built `similarity-search` without starting
PostgreSQL or the coordinator:
```python
from pathlib import Path
from scimesh.sdk import (
ArtifactCollection,
JobRequest,
LocalArtifactStore,
LocalCoreBatchExecutor,
)
from scimesh.workloads.library import (
default_sdk_registry,
default_sdk_runtime,
similarity_search_sdk_definition,
)
root = Path("sdk-run")
store = LocalArtifactStore(root / "artifacts")
workload = similarity_search_sdk_definition(shard_rows=1_000)
dataset = store.import_file(
Path("chembl_37_chemreps.txt"),
declaration=workload.manifest.inputs["input"].schema,
)
request = JobRequest(
workload=workload.manifest.workload,
parameters={"query_smiles": "CCO", "top_k": 20},
inputs={"input": ArtifactCollection.single(dataset)},
)
result = LocalCoreBatchExecutor(
default_sdk_registry(shard_rows=1_000),
default_sdk_runtime(),
store,
root / "attempts",
).execute(request, workload.manifest.package.digest)
result_ref = result.outputs["result"].items[0].artifact
print(store.materialize(result_ref))
```
`LocalCoreBatchExecutor` is a correctness/conformance runtime, not a substitute
for coordinator leases or multi-machine scheduling. It accepts only
`TrustMode.TRUSTED`, `NetworkPolicy.TRUSTED`, single-process/single-threaded CPU
map/reduce stages without secrets, checkpoints, retries, gangs, or
accelerators. It does not claim network, timeout, process, or credential
isolation. Unsupported declarations are rejected before a handler runs. The
harness runs the SDK-built workload handlers themselves, and their parity
against the single-process references is covered by automated tests.
## The descriptor-batch reference workload
`descriptor-batch@1.0.0` is the first SDK-native reference workload: it is
built directly on the manifest/planner/runner/reducer contracts, and it is
the intended first `untrusted_quorum` candidate
(`byte_exact` plus `exact-artifact@1`). Its scientific contract is pinned:
- one output CSV row per valid input molecule, in input order, with RDKit
canonical SMILES recomputed by RDKit;
- an explicit 81-name pinned RDKit 2D descriptor set (see
`scimesh/workloads/descriptors/core.py`), validated against the installed RDKit at
definition build time;
- `%.6f` float formatting, `utf-8` CSV with one header, and row-bounded
deterministic shards;
- `skip_invalid` is the only parameter (default `true`): invalid SMILES rows
are counted and skipped, or fail the run when `false`;
- the reducer concatenates shard partials by shard index with exactly one
header, so the distributed output is byte-identical to the single-process
reference for the same input rows.
```python
from pathlib import Path
from scimesh.sdk import (
ArtifactCollection,
JobRequest,
LocalArtifactStore,
LocalCoreBatchExecutor,
WorkloadRegistry,
)
from scimesh.workloads.descriptors import descriptor_batch_sdk_definition
from scimesh.workloads.library import default_sdk_runtime
root = Path("descriptor-run")
store = LocalArtifactStore(root / "artifacts")
workload = descriptor_batch_sdk_definition(shard_rows=1_000)
dataset = store.import_file(
Path("chembl_37_chemreps.txt"),
declaration=workload.manifest.inputs["input"].schema,
)
request = JobRequest(
workload=workload.manifest.workload,
parameters={"skip_invalid": True},
inputs={"input": ArtifactCollection.single(dataset)},
)
registry = WorkloadRegistry()
registry.register(workload.definition(), enabled=True)
result = LocalCoreBatchExecutor(
registry,
default_sdk_runtime(),
store,
root / "attempts",
).execute(request, workload.manifest.package.digest)
result_ref = result.outputs["result"].items[0].artifact
print(store.materialize(result_ref))
```
The descriptor-batch entry point `descriptor-batch@1.0.0` is declared in
`pyproject.toml`; discovery loads it only when an administrator supplies a
matching `AllowedPackage` allowlist entry. Its manifest declares both
`trusted` and `untrusted_quorum` trust modes and the exact-artifact verifier,
so the same definition can later run under coordinator quorum once protocol-v2
leases exist.
## The SDK-built similarity workloads
`similarity-search@1.0.0` and `similarity-graph@1.0.0` are SDK-built workloads
under `scimesh/workloads/search/` and `scimesh/workloads/graph/`; both reuse
the local scientific cores from `scimesh/workloads/similarity_search.py` and
`similarity_graph.py` and declare `byte_exact` + `exact-artifact@1`:
- the search workload resolves `query_id` exactly once at plan time, shards
the input deterministically, computes a local top-k per shard with the
reference heap, and merges the sorted partials with the same tie-breakers,
so the final CSV is byte-identical to the single-process CLI output;
- the graph workload parses molecules once into deterministic row-ordered
blocks, plans one map task per block pair `(i, j)` with `i <= j`, and its
reducer enforces the pair-coverage invariant (every unordered molecule pair
compared exactly once, no duplicates) before emitting the same
deterministically sorted edge list as the local brute-force reference, for
either threshold direction and any block size;
- the v1 worker executes SDK-built workloads directly:
`scimesh/worker/runners.py` is a workload-generic wire bridge that builds a
`TaskSpec` with the workload's own pins, negotiates against a runtime
derived from the loaded definitions, reserves resources, seals the partial
through a content-addressed store, and uploads the resulting CSV over the
unchanged coordinator contract. The worker loads workloads from
`SCIMESH_WORKLOAD_ALLOWLIST` (a JSON array of
`{distribution, name, version, digest}` entries matched against installed
`scimesh.workloads` entry points) or falls back to the built-in
`similarity-search`; advertised capabilities come from
`SCIMESH_CAPABILITIES`. Workloads whose map stage needs more than one input
port are rejected with a clear message until the coordinator contract
supports them.
## Authoring a workload
A workload is a user script that imports the SDK. For the standard
`core-batch-v1` shape (one input dataset, shards, partials, one merged result)
subclass `MapReduceWorkload` and implement the three scientific hooks; the
framework provides everything else:
```python
from pathlib import Path
from typing import Any, Mapping, Sequence
from scimesh.sdk import (
ArtifactSchema,
ComponentRef,
MapReduceWorkload,
PortSpec,
SchemaRef,
WorkloadId,
)
class CountRowsWorkload(MapReduceWorkload):
workload_id = WorkloadId("count-rows", "1.0.0")
description = "Count TSV data rows per shard and concatenate the counts."
parameters_schema = {
"type": "object",
"additionalProperties": False,
"properties": {"prefix": {"type": "string", "minLength": 1, "maxLength": 50}},
}
input_port = PortSpec(ArtifactSchema(
SchemaRef("molecule-table", 1), "text/tab-separated-values", "utf-8",
max_bytes=10**9, validator=ComponentRef("delimited-table", 1),
validator_configuration={"required_columns": ["canonical_smiles", "chembl_id"]},
))
partial_port = output_port = PortSpec(ArtifactSchema(
SchemaRef("count-table", 1), "text/csv", "utf-8",
max_bytes=10**9, validator=ComponentRef("delimited-table", 1),
validator_configuration={"columns": ["id", "rows"]},
))
map_parameter_names = ("prefix",)
def partition_input(self, input_path, parameters, workspace): # -> list[Path]
... # deterministic shard files, one per map task
def compute_shard(self, inputs, parameters, output_path): # -> Mapping[str, int|float]
... # one map task; inputs maps each map port to a materialized file
def reduce_partials(self, partial_paths, parameters, output_path): # -> Mapping[str, int|float]
... # deterministic merge of the accepted partials
```
The base class then provides `validate`, `plan`, `run`, `reduce`, and
`definition()`. For workloads whose map output is a simple filtered or
transformed table, the scaffold's defaults already cover partitioning
(row-bounded shards that keep the header) and reduction (concatenation with
one header), so only `compute_shard` has to be written — that is exactly what
the built-in `molwt-filter` workload does. The registry, negotiation, resource
reservation, verification, and the local conformance executor treat the
result like any other workload:
```python
from scimesh.sdk import (
ArtifactCollection,
JobRequest,
LocalArtifactStore,
LocalCoreBatchExecutor,
WorkloadRegistry,
)
from scimesh.workloads.library import default_sdk_runtime
workload = CountRowsWorkload(package_digest=..., environment_digest=...)
registry = WorkloadRegistry()
registry.register(workload.definition(), enabled=True)
store = LocalArtifactStore(Path("artifacts"))
artifact = store.import_file(Path("tiny.tsv"), declaration=workload.manifest.inputs["input"].schema)
request = JobRequest(workload=workload.manifest.workload, parameters={"prefix": "x"},
inputs={"input": ArtifactCollection.single(artifact)})
result = LocalCoreBatchExecutor(registry, default_sdk_runtime(), store, Path("work")) \
.execute(request, workload.manifest.package.digest)
```
Hooks you can override beyond the three scientific ones:
- `domain_validate(parameters)` — extra job-parameter validation (the JSON
schema already ran);
- `resolved_parameters(request)` / `resolved_parameters_for_plan(job, input_path, resolved)`
— values persisted into the plan (for example one-time query resolution);
- `plan_tasks(...)` — custom task construction (the graph workload uses this
to plan one task per block pair with two block inputs);
- `parse_partial_key(key)` / `validate_partial_keys(parsed)` — partial-key
policy (default: `map.<eight-digit-index>`, contiguous; the graph workload
parses `map.<i>x<j>` and enforces the pair-coverage invariant);
- `map_stage_inputs` — a map stage with more than one input port (each extra
port must share the external input schema).
Anything outside this model uses the lower-level SDK value objects directly.
Authoring rules: keep the scientific core callable without a coordinator,
inline a strict JSON parameter schema, declare artifact schemas with bounds,
return only sink-sealed artifacts, and select a verifier compatible with
determinism and trust.
To run a workload from the command line without writing any program code:
```bash
scimesh workload list
scimesh workload run count-rows --input tiny.tsv --params '{"prefix": "x"}' -o result.csv
```
`scimesh workload` is a generic SDK tool; it contains no workload-specific
logic, so new workloads do not require changes to the CLI or any other part of
the program.
## Package shape and registration
A workload distribution provides one explicit entry point per workload
version. The built-in workloads are part of the `scimesh` distribution:
```toml
[project.entry-points."scimesh.workloads"]
"similarity-search@1.0.0" = "scimesh.workloads.search:workload_definition"
"similarity-graph@1.0.0" = "scimesh.workloads.graph:workload_definition"
"descriptor-batch@1.0.0" = "scimesh.workloads.descriptors:workload_definition"
```
The factory returns a `WorkloadDefinition` containing its manifest and handler
objects. An administrator supplies an `AllowedPackage` with the same
distribution, exact `WorkloadId`, and `sha256:` package digest. Discovery
filters installed metadata before importing an entry point and fails
transactionally if an allowlisted definition is missing or mismatched. Job
parameters cannot name a module, entry point, package path, or executable.
The measured digest covers package payload files and installed entry-point
declarations and is checked before and after loading. It is a content pin, not
a signature or image attestation; production discovery should run in a fresh
trusted control-plane process so a pre-populated Python module cache is not an
integrity boundary.
Direct registration is useful for tests and embedded deployments:
```python
registry = WorkloadRegistry()
registry.register(definition, enabled=False)
registry.enable(
definition.manifest.workload.name,
definition.manifest.workload.version,
definition.manifest.package.digest,
)
```
Both version and digest are required when resolving or planning. Upgrading an
installed definition does not change the identity of an existing Job.
## Authoring rules
1. Keep the scientific core callable without a coordinator.
2. Inline a strict JSON parameter schema with `type: object` and
`additionalProperties: false`; the planner still performs domain validation.
3. Give every external and stage port an `ArtifactSchema` with a media type,
schema version, and byte/record/dimension bounds.
4. Connect stage ports with `ArtifactEdge` values. `WorkflowSpec` checks source
and target schemas, complete input bindings, declared dependencies, and
acyclicity.
5. Declare one `ResourceRequirements` and `ExecutionProfile` per stage. A task
cannot run until its entire request is eligible and atomically reserved.
6. Return only sink-sealed artifacts in `OutputManifest`; the local harness
binds task key/provenance itself and rejects fabricated references,
unexpected/missing ports, wrong schema/media type, and cumulative output or
artifact-limit violations.
7. Select a verifier compatible with determinism and trust. SDK v1 permits
`untrusted_quorum` only for `byte_exact` plus `exact-artifact@1`.
8. Add golden fixtures, local/distributed parity, retry/completion-order, and
verifier failure tests before enabling a package.
`ArtifactSink` and `ArtifactCatalog` are bridge-owned protocols. They let
scientific handlers materialize verified inputs and seal outputs without bearer
tokens, database credentials, upload URLs, or durable local paths.
## Verification
The SDK includes:
- `ExactArtifactVerifier`: compares logical port/collection/schema/content
digests while ignoring coordinator UUIDs, timestamps, metrics, and worker
identity. Quorum inputs use coordinator-created `CandidateOutput` envelopes,
count at most one vote per owner, and require a `VerificationBinding` for the
exact task, inputs, parameters, package, manifest, and environment;
- `CanonicalRecordVerifier`: applies a package-owned bounded canonicalizer and
compares length-framed canonical records;
- `NumericToleranceVerifier`: recursively checks structure plus explicit
absolute, relative, ULP, and NaN policy, returning bounded sanitized evidence.
Canonical and numeric objects expose direct bounded comparison methods. To use
them as manifest `Verifier` handlers, the package supplies an artifact-to-record
or artifact-to-structured-value loader; without one, verification returns
`inconclusive` rather than accepting bytes it did not parse.
A decision is `accepted`, `rejected`, or `inconclusive`; only `accepted`
satisfies a stage. Evidence is limited to 16 KiB and cannot contain local paths
or transport URLs.
## Resources and current runtime boundary
`ResourcePool` provides a lock-protected all-or-nothing local reservation for
CPU cores, memory, scratch, and accelerator device/partition IDs, including
whole-device versus partition conflict fencing. It enforces aggregate capacity
and execution-slot count. `ExecutionProfile` produces only
allocation-derived OpenMP/BLAS and device-visibility values; credentials never
belong to scientific parameters.
The current protocol-v1 coordinator stores one input/result per flat task and
does not persist resource requirements, device allocations, stage edges, or
package versions. The production Worker also remains serial. Consequently:
- SDK `core-batch-v1` can be authored, validated, tested, discovered, and run
through the trusted local conformance harness now;
- existing production `similarity-search` remains on its compatible v1 wire
path and is not renamed;
- real concurrent claims, GPU scheduling, multi-output DAG execution, dynamic
loops, streaming, and gang leases require the versioned coordinator/Worker
changes listed in [`scimesh-sdk-roadmap.md`](scimesh-sdk-roadmap.md);
- merely declaring a GPU or gang request never enables it. Missing runtime
features or inventory fail before the planner executes.
## Conformance commands
Install development tools and run the SDK suite:
```bash
pip install -e '.[dev]'
pytest tests/test_sdk_models.py \
tests/test_sdk_resources.py \
tests/test_sdk_verification.py \
tests/test_sdk_compatibility.py \
tests/test_sdk_registry.py \
tests/test_sdk_descriptors.py \
tests/test_sdk_search.py \
tests/test_sdk_graph.py
```
Run `pytest` for the full Worker, local-science, and SDK regression suite. Package authors can reuse `LocalArtifactStore`,
`LocalCoreBatchExecutor`, and `assert_manifest_round_trip` in their own golden
tests.
+75
View File
@@ -0,0 +1,75 @@
site_name: SciMesh
site_description: Local-first distributed scientific computation for molecular workloads
site_url: https://github.com/emil28092005/SciMesh
repo_url: https://github.com/emil28092005/SciMesh
edit_uri: blob/main/mkdocs/
docs_dir: mkdocs
theme:
name: material
palette:
scheme: slate
primary: indigo
accent: cyan
features:
- navigation.instant
- navigation.tracking
- navigation.top
- navigation.expand
- search.suggest
- content.code.copy
- content.code.annotate
icon:
repo: fontawesome/brands/github
plugins:
- search
- mkdocstrings:
handlers:
python:
options:
show_root_heading: true
show_symbol_type_heading: true
show_source: false
members_order: source
show_if_no_docstring: true
markdown_extensions:
- admonition
- toc:
permalink: true
- pymdownx.superfences
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.details
- pymdownx.tabbed:
alternate_style: true
nav:
- Home: index.md
- SDK:
- Overview: sdk/overview.md
- Authoring workloads: sdk/authoring-workloads.md
- Workload CLI: sdk/cli.md
- Worker integration: sdk/worker-integration.md
- API reference:
- Read me: api/index.md
- Artifacts and ports: api/sdk-artifacts.md
- Batch scaffold (MapReduceWorkload): api/sdk-batch.md
- Conformance runtime: api/sdk-conformance.md
- Execution profiles: api/sdk-execution.md
- Identities and versions: api/sdk-identity.md
- Package integrity: api/sdk-integrity.md
- Manifests: api/sdk-manifest.md
- Plans and tasks: api/sdk-plans.md
- Handler protocols: api/sdk-protocols.md
- Registry and discovery: api/sdk-registry.md
- Resources: api/sdk-resources.md
- Runtime negotiation: api/sdk-runtime.md
- Parameter schemas: api/sdk-schema.md
- Verification: api/sdk-verification.md
- Workflow DAGs: api/sdk-workflow.md
- Documentation approach: approach.md
extra:
generator: false
+45
View File
@@ -0,0 +1,45 @@
# API reference
This section is **generated from docstrings** by
[`mkdocstrings`](https://mkdocstrings.github.io) — it is the complete public
API surface of `scimesh.sdk`. Markdown pages in `api/` are thin wrappers
(`::: scimesh.sdk.<module>`) and must not be hand-edited; change the code and
rebuild with `make docs`.
All value objects are frozen, recursively immutable, JSON-safe, canonically
serialized, and strict about unknown fields. Constructing them performs
full validation; invalid input raises `ValueError`.
## Module map
| Page | Module | Contents |
| --- | --- | --- |
| [Artifacts and ports](sdk-artifacts.md) | `scimesh.sdk.artifacts` | `ArtifactSchema`, `PortSpec`, `ArtifactRef`, `ArtifactCollection`, `OutputManifest`, `Provenance` |
| [Batch scaffold](sdk-batch.md) | `scimesh.sdk.batch` | `MapReduceWorkload`, `concatenate_partial_tables` |
| [Conformance runtime](sdk-conformance.md) | `scimesh.sdk.conformance` | `LocalArtifactStore`, `LocalCoreBatchExecutor`, scoped contexts, round-trip helper |
| [Execution profiles](sdk-execution.md) | `scimesh.sdk.execution` | `ExecutionProfile`, `RetryPolicy`, `CheckpointPolicy`, `FailureReport` |
| [Identities](sdk-identity.md) | `scimesh.sdk.identity` | `WorkloadId`, `VersionRange`, `SchemaRef`, `ComponentRef`, `FeatureRequirement` |
| [Package integrity](sdk-integrity.md) | `scimesh.sdk.integrity` | `installed_distribution_digest` |
| [Manifests](sdk-manifest.md) | `scimesh.sdk.manifest` | `WorkloadManifest`, `PackageSpec`, `EnvironmentSpec`, `VerifierSpec`, `WorkloadLimits`, trust/determinism enums |
| [Plans and tasks](sdk-plans.md) | `scimesh.sdk.plans` | `JobRequest`, `ValidatedJob`, `TaskSpec`, `WorkflowPlan`, `ExpansionManifest` |
| [Handler protocols](sdk-protocols.md) | `scimesh.sdk.protocols` | `Planner`, `Runner`, `Reducer`, `Verifier`, contexts, catalog/sink |
| [Registry](sdk-registry.md) | `scimesh.sdk.registry` | `WorkloadRegistry`, `WorkloadDefinition`, `AllowedPackage`, discovery |
| [Resources](sdk-resources.md) | `scimesh.sdk.resources` | `ResourceRequirements`, `ResourceInventory`, `ResourcePool`, accelerators |
| [Runtime negotiation](sdk-runtime.md) | `scimesh.sdk.runtime` | `RuntimeCapabilities`, `negotiate_manifest`, `CompatibilityError` |
| [Parameter schemas](sdk-schema.md) | `scimesh.sdk.schema` | Bounded JSON Schema subset |
| [Verification](sdk-verification.md) | `scimesh.sdk.verification` | Verifiers, decisions, bindings, candidate envelopes |
| [Workflow DAGs](sdk-workflow.md) | `scimesh.sdk.workflow` | `WorkflowSpec`, `StageSpec`, `ArtifactEdge`, advanced declarations |
## Reading the generated pages
- **Classes** show their full signature, validation rules, and public
methods; properties are listed with their type.
- **Module-level functions** (for example `negotiate_manifest`) document
their exact contract and failure modes.
- Cross-references to other SDK symbols link automatically.
To keep the reference correct:
- write docstrings in **Google style** (`Args:` / `Returns:` / `Raises:`);
- document validation failures and fail-closed behavior;
- rebuild with `make docs` after any docstring change.

Some files were not shown because too many files have changed in this diff Show More