Compare commits

..
33 Commits
Author SHA1 Message Date
Emil 029b26e6ae Explain the admin-required error and offer account switching on the login page
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-03 02:06:45 +03:00
Emil 12499d2d7b Remove the demo control room; /ui lands on the admin console
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-03 01:58:02 +03:00
Emil c4d88c7ffc Satisfy the linter gate: errors.Is, context-aware exec/listen, gosec nolints
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-03 01:47:53 +03:00
Emil 44247bd94e Give every wizard test its own port and disable keep-alive pooling
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-03 01:43:48 +03:00
Emil 7c1d0dc568 Tell non-admins why the console is off-limits instead of bouncing them to the dashboard
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-03 01:34:22 +03:00
Emil 82eaec4c55 Make the wizard supervisor portable (no signal probing) and bind tests to an ephemeral port
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-03 00:38:33 +03:00
Emil c301499bef Sync README and installer header with the auto-open flow 2026-08-03 00:33:50 +03:00
Emil 8b7094b0cb Open the admin console from serve --open and land login on the intended page 2026-08-03 00:32:47 +03:00
Emil bdbe0e3e39 Auto-open the coordinator UI and the worker wizard from the installers 2026-08-03 00:27:48 +03:00
Emil c11756c3d8 Document the admin console and the worker setup wizard 2026-08-03 00:24:06 +03:00
Emil 46645b8730 Add the worker setup wizard (setup, --config, --check) to worker-agent 2026-08-03 00:22:37 +03:00
Emil e923627ce0 Add admin console access and platform pages (trust, users, keys, workloads, settings) 2026-08-03 00:11:47 +03:00
Emil 7fb1059401 Add coordinator admin console foundation (system, jobs, metrics) 2026-08-02 23:51:26 +03:00
Emil 41cae546ff Approve UI mockups and write the full implementation plan 2026-08-02 23:30:23 +03:00
Emil f8ff0956b9 Move the worker setup UI into the worker-agent binary 2026-08-02 23:02:31 +03:00
Emil 7da9bf8c3a Plan the coordinator admin UI and worker setup wizard 2026-08-02 22:55:33 +03:00
Emil 69a2d59b23 Add the coordinator token subcommand for joining workers 2026-08-02 22:43:17 +03:00
Emil 45d5c2eaad Update the add-worker command and expose the coordinator public URL in serve 2026-08-02 22:32:54 +03:00
Emil 10a7e8df1d Document the standalone split setup and build a userservice binary 2026-08-02 22:29:05 +03:00
Emil 76746187eb Add a standalone worker installer and dynamic userservice port 2026-08-02 22:23:54 +03:00
Emil 9b0b9b208a Warn on a stale download in the installer
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 22:07:04 +03:00
Emil a339ac853b Install the newest release including pre-releases by default 2026-08-02 22:03:10 +03:00
Emil 0494b8bc2a Check out the repo in the release job so installers exist
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 21:15:38 +03:00
Emil 0f9cbcec48 Restore installer asset paths relative to the repo root 2026-08-02 21:12:04 +03:00
Emil a4abdb970a Fix installer asset paths in the release workflow 2026-08-02 21:04:54 +03:00
Emil b4fe7aaf72 Publish pre-release tags as GitHub pre-releases 2026-08-02 21:01:32 +03:00
Emil e308085fe2 Mark the session goal as completed 2026-08-02 21:00:10 +03:00
Emil 988101afab Track session goal progress 2026-08-02 20:06:17 +03:00
Emil 215325a9e0 Document the single-binary platform and update the roadmap 2026-08-02 20:06:08 +03:00
Emil c06d8673ce Add one-command installers and serve/agent make targets 2026-08-02 20:06:08 +03:00
Emil 1473bbe2a8 Embed the userservice and add serve/agent subcommands for one-binary operation 2026-08-02 19:49:54 +03:00
Emil 9883def0c2 Add an embedded SQLite storage backend for single-binary deployments 2026-08-02 19:23:44 +03:00
Emil 079eca071e Update the README for releases, setup wizard, and the workload-agnostic UI 2026-08-02 18:57:25 +03:00
119 changed files with 11551 additions and 598 deletions
+8 -1
View File
@@ -58,6 +58,8 @@ jobs:
needs: binaries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
@@ -72,7 +74,12 @@ jobs:
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
files: |
artifacts/*
install.sh
install.ps1
# Pre-release tags (e.g. v1.1.0-alpha.1) publish as pre-releases.
prerelease: ${{ contains(github.ref_name, '-alpha') }}
generate_release_notes: true
image:
+16 -15
View File
@@ -1,21 +1,22 @@
COMPLETED
# Session Goal
давай теперь почистим проект от линего кода
COMPLETED
выполни полностью намеченный сейчас план. автономно
## 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.
1. Фаза 1 — SQLite-хранилище: `coordinator/internal/storage/sqlite` (все порты, TxManager, миграции, `SCIMESH_DB=sqlite|postgres`, тесты).
2. Фаза 2 — Встроенный userservice: перенос `users/internal/*` в `coordinator/internal/userservice/` (sqlite-хранилище), запуск на 127.0.0.1, BootstrapAdmin.
3. Фаза 3 — `coordinator serve` (data-dir, всё-в-одном, --workers N, --open) + subcommand `coordinator agent`.
4. Фаза 4 — Управляемый venv + install.sh/install.ps1 + ассеты релиза.
5. Документация: mkdocs, README, PLAN.md (CTX-17 done, CTX-18), STATUS.md.
6. Проверка: полный E2E без внешних сервисов + все тесты/lint/vet.
## 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.
- ВСЕ ФАЗЫ ВЫПОЛНЕНЫ И ЗАПУШЕНЫ:
- 9883def — SQLite-бэкенд (SCIMESH_DB=sqlite|postgres, миграции, тесты).
- 1473bbe — встроенный userservice + serve/agent subcommands.
- c06d867 — install.sh/install.ps1 + make serve.
- 215325a — документация (README, mkdocs, PLAN CTX-17/CTX-18, STATUS).
- E2E «чистая машина»: `coordinator serve` → health/login (embedded userservice) → molwt-filter джоб через локального агента → результат byte-точный.
- Верификация: 208 pytest, pyright 0, 18 Go-пакетов ok, gofmt чист, vet чист, golangci 0 issues, postgres integration ok, все CI-раны success.
+84 -11
View File
@@ -1003,11 +1003,12 @@ 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.
**Depends on:** the Go coordinator and the release build pipeline. All steps
are implemented: embedded migrations with `AUTO_MIGRATE`, the `setup` wizard,
the embedded SQLite storage backend (`SCIMESH_DB=sqlite`), the embedded
userservice, and the `coordinator serve` single-binary mode with local worker
agents. The standalone `users/` service and the PostgreSQL engine remain for
cluster deployments.
**Acceptance criteria:**
@@ -1027,6 +1028,79 @@ embedded userservice — is the remaining work.
---
### CTX-18 — Single-binary platform (`coordinator serve`)
**Goal:** A scientist installs one file, runs one command, and gets the whole
platform: coordinator, both databases, the userservice, and local workers —
no PostgreSQL, no Docker, no Python setup.
**Depends on:** CTX-17 (embedded migrations, SQLite, embedded userservice).
**Acceptance criteria:**
- `coordinator serve` provisions `~/.scimesh` (databases, secrets chmod 0600,
generated admin password printed once, artifacts dir) and serves the UI on
127.0.0.1:8080 by default; `--open` opens the browser;
- `--workers N` spawns N `coordinator agent` subprocesses that claim and
execute tasks locally; agents are stopped on shutdown;
- the embedded userservice listens on the loopback interface and shares the
JWT secret with the coordinator, so UI login/registration work unchanged;
- a managed scientific runtime venv (`~/.scimesh/venv`) is bootstrapped on
first start; `SCIMESH_PIP_PACKAGE` controls what gets installed (the PyPI
name is not ours), and `TASK_RUNNER` points at the venv python;
- `install.sh` / `install.ps1` detect the platform, download the release
binary, and print the start command; both are release assets;
- the PostgreSQL engine and the standalone userservice stay fully supported;
- `coordinator serve` passes the full local E2E without any external service:
health, login, upload, claim, compute, reduction, byte-exact result.
---
### CTX-19 — Coordinator Admin UI
**Goal:** Replace the demo-level operator pages with a real admin console
(`/ui/admin`): system overview, job management with filters and pagination,
worker management (trust, capabilities), users/roles and worker keys,
workload enable/disable, settings (read-only), and storage metrics.
**Depends on:** CTX-11 (UI patterns, session/roles), CTX-15 (userservice
proxy), the sqlite/postgres storage pair.
**Status: implemented.** Admin console at `/ui/admin`: system/storage/health,
jobs (filter + pagination + owner resolution), workers with trust controls,
users & worker keys (userservice admin endpoints), workload enable/disable
(`workload_settings` migration in both engines, enforced at submit time and
hidden from the job form), metrics (7-day buckets, failure rate), and
settings with an audited worker-token reveal. All `/ui/admin/*` routes are
admin-only and backed by bounded read models; sqlite + postgres parity;
unit/permission/integration tests green.
### CTX-20 — Worker Setup UI
**Goal:** A local setup wizard embedded in the **worker-agent** binary
(`worker-agent setup`, browser on 127.0.0.1) that turns any machine into a
worker: coordinator URL + auth (serve token or worker key), work dir,
connection check, start/stop, and a live status page. Runs on machines that
have only the worker installed — no coordinator needed.
**Depends on:** the agent daemon; the worker-key exchange (CTX-15).
**Status: implemented.** `worker-agent setup` serves the local wizard on
127.0.0.1 (default port 12700): coordinator URL + token or worker key,
work dir and name, preflight check (coordinator/health, python3, scimesh),
config saved to `~/.scimesh-worker/config.json` (0600), start/stop of the
worker as a background process, and a live status page with the agent log.
The daemon also gained `--config <path>` (environment still wins) and
`--check`. End-to-end verified: the wizard started a real worker that
registered with a coordinator and appeared in the admin console.
**Acceptance criteria:** full plan in
[`docs/ui-admin-worker-plan.md`](docs/ui-admin-worker-plan.md) (section 4);
the agent gains `setup`, `--config <path>`, and `--check`; end-to-end: the
wizard starts a real worker that registers and completes a job.
---
## 10. Suggested assignment bundles
These bundles minimize overlap. Do not run tasks from the same bundle in
@@ -1166,12 +1240,11 @@ Do not start these before CTX-12 is accepted.
- 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).
- Publish the scimesh Python package to PyPI so the managed venv bootstrap
(`SCIMESH_PIP_PACKAGE`) works out of the box on a scientist's machine.
- Bundle a Python runtime (python-build-standalone) into the release so local
workers need no system Python at all.
- Native installers (.msi/.dmg/.deb) built by the release workflow.
---
+124 -31
View File
@@ -1,15 +1,26 @@
# SciMesh
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 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).
SciMesh is a local-first platform for scientific computation on molecular
datasets. It turns a scientific run into independent tasks, dispatches them
to worker agents, and deterministically combines the partial results into a
checksum-protected final artifact.
The ChEMBL TSV database is intentionally not included in this repository. Download it separately and pass its path to the commands below. The expected columns are `chembl_id` and `canonical_smiles`.
- **The Workload SDK (`scimesh.sdk`)** — a strict Python framework for
authoring scientific workloads: `similarity-search` (exact top-k Tanimoto),
`similarity-graph` (exact sparse graph), `descriptor-batch`, and
`molwt-filter`. Workloads are ordinary user scripts built on the SDK; they
run locally, in the conformance harness, and on claimed coordinator tasks
without touching any other part of the program.
- **The coordinator and worker agents** — a Go/PostgreSQL coordinator with an
operator UI and Go worker agents that execute SDK workloads in a Python
subprocess. The UI is workload-agnostic: the "New computation" form offers
every workload from the embedded SDK library, and each workload declares its
own form controls (`UIElement`) through the SDK.
The ChEMBL TSV database is intentionally not included in this repository.
Download it separately and pass its path to the commands below. The expected
columns are `chembl_id` and `canonical_smiles`. See
[`STATUS.md`](STATUS.md) and [`PLAN.md`](PLAN.md).
## Installation
@@ -28,6 +39,66 @@ conda install -c conda-forge rdkit
pip install -e .
```
## Releases
Every `v*` tag pushes a GitHub Release with static binaries for `coordinator`
and `worker-agent` on linux/darwin/windows × amd64/arm64 (plus SHA-256
checksums), the installer scripts, and the `coordinator` image on GHCR:
```bash
docker pull ghcr.io/emil28092005/SciMesh/coordinator:latest
```
For scientists: one command downloads the right binary, starts it, and opens
the UI in the browser:
```bash
# Linux / macOS — installs, starts and opens the admin console automatically
curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash
# Windows (PowerShell)
powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.ps1 | iex"
```
Set `SCIMESH_AUTO_START=0` to install without starting anything. The old demo
control room was removed: `/ui` is the admin console. A standalone
worker is installed the same way (`bash -s worker`, or
`SCIMESH_COMPONENT=worker` on Windows); its installer opens the local setup
wizard (`worker-agent setup`) in the browser automatically.
`coordinator serve` is the single-binary mode: it embeds SQLite (coordinator +
userservice databases), the userservice itself, and local worker agents
(`--workers N`, default 1). On first start it generates secrets and the admin
password under `~/.scimesh`, prints the login, and opens the UI. No
PostgreSQL, no Docker, no environment variables. The scientific runtime is a
managed venv (`~/.scimesh/venv`); point `SCIMESH_PIP_PACKAGE` at your scimesh
wheel to install it automatically.
The coordinator's UI is the **admin console** at `/ui/admin` — cluster health
and storage, paginated job table, worker fleet with trust controls, users and
worker keys, workload enable/disable, metrics and the worker token. The job
form (`/ui/jobs/new`), job detail pages and the workload library complete the
operator surface; `/ui` redirects to the console, `serve --open` lands on it,
and login returns you to the page you asked for. The **worker** binary (`worker-agent`) carries its own local setup
wizard for machines that run only a worker: `worker-agent setup` opens a
browser wizard at `127.0.0.1` that collects the coordinator URL and
credential, runs a preflight check, saves `~/.scimesh-worker/config.json` and
starts/stops the worker with a live log (see the
[standalone docs](mkdocs/standalone.md)).
Manual download and run of a release binary:
```bash
curl -L -o coordinator https://github.com/emil28092005/SciMesh/releases/latest/download/coordinator-linux-amd64
chmod +x coordinator
./coordinator --version
```
Cluster deployments keep the PostgreSQL engine (`SCIMESH_DB=postgres` with
`DATABASE_URL`, or `coordinator setup` to provision it) and the standalone
userservice (`users/`). `coordinator agent` runs a worker agent from the same
binary.
## Quick start
Run the built-in help command for copy-paste examples of both workloads:
@@ -47,9 +118,9 @@ scimesh similarity-graph --help
## Manual pipeline demo
To inspect the coordinator, Web UI, and distributed `similarity-search`
pipeline by hand, install development dependencies once and start the isolated
demo from the repository root:
To inspect the coordinator, Web UI, and distributed pipeline by hand, install
development dependencies once and start the isolated demo from the repository
root:
```bash
python3 -m venv .venv
@@ -64,17 +135,19 @@ 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 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. 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`.
Open `http://localhost:18080/ui` and sign in with username
`root@scimesh.local` and password `rootpassword`. The command starts
PostgreSQL, the coordinator, and two Go worker agents (built by `make agent`;
each executes the SDK workload in a Python subprocess). The **New computation**
form offers every upload-ready workload from the installed library — the
controls come from each workload's own SDK declarations. 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. 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.
@@ -146,6 +219,15 @@ pytest
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
The coordinator and worker agent are Go modules under `coordinator/` and `users/`:
```bash
cd coordinator && make coordinator agent && go test ./...
```
`make check` runs the full gate: vet, lint, race tests, the PostgreSQL
integration suite, and the two-worker end-to-end smoke script.
## Workload SDK
`scimesh.sdk` is the framework only: strict and immutable workload manifests,
@@ -153,14 +235,25 @@ 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
`similarity-search`, `similarity-graph`, `descriptor-batch`, and
`molwt-filter` 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; `scimesh workload export` writes the
coordinator's embedded workload catalog, and `scimesh workload allowlist`
prints the JSON for `SCIMESH_WORKLOAD_ALLOWLIST`.
Workloads can also declare how they should appear in the coordinator UI:
a tuple of `UIElement`s (`scimesh.sdk.UIElement`) shapes the "New computation"
form — widget, label, help, defaults, and ordering — plus the coordinator-side
reduction mode (`reduction`: `top-k` or `ordered-concat`) and whether a single
uploaded dataset can drive the workload (`upload_ready`). The strict parameter
schema stays the authoritative validation contract.
See the
[SDK author guide](docs/workload-sdk.md), [contract](docs/scimesh-sdk-contract.md),
and [delivery roadmap](docs/scimesh-sdk-roadmap.md).
+35 -23
View File
@@ -1,7 +1,7 @@
# SciMesh Status
**Updated:** 2026-08-01
**Branch baseline:** `main`; this revision adds the Workload SDK foundation.
**Updated:** 2026-08-02
**Branch baseline:** `main`; this revision adds the single-binary platform.
## Current state
@@ -17,11 +17,22 @@ the reference behaviour for future distributed execution:
The Go coordinator and its PostgreSQL-backed task lifecycle are implemented:
registration, atomic claiming, lease renewal, artifact storage, dataset
chunking, result/failure reporting, and job progress. The Python worker now
uses the live coordinator contract. Completed similarity-search shard results
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.
chunking, result/failure reporting, and job progress. The Go worker agent now
uses the live coordinator contract. Completed shard results are reduced once
into a checksum-protected final CSV, downloadable through the coordinator.
**Single-binary platform (`coordinator serve`)**: the coordinator now ships
an embedded SQLite storage backend (`SCIMESH_DB=sqlite`), an embedded
userservice, and `serve`/`agent` subcommands, so one downloaded binary runs
the whole platform — coordinator, both databases, UI logins, and local
workers — with no PostgreSQL, no Docker, and no environment variables. The
first start provisions `~/.scimesh` (secrets, admin password printed once,
managed scientific-runtime venv) and opens the UI. `install.sh` / `install.ps1`
download the right release binary in one command and are release assets. The
PostgreSQL engine, the `setup` wizard, and the standalone `users/` service
remain fully supported for cluster deployments. The full E2E passes with zero
external services: health, UI login, job upload, local agent compute,
reduction, and a byte-exact final CSV.
The User Service is merged into `main`. It owns user accounts, authentication,
roles, and verified-contributor status; the coordinator scopes user jobs and
@@ -37,24 +48,25 @@ the complete result-artifact SHA-256 before a task is accepted.
| --- | --- | --- |
| CTX-00 API and error contract | Implemented | Contract, OpenAPI, and request examples are in `docs/`. |
| CTX-01 Go coordinator bootstrap | Implemented | Go service and Docker runtime in `coordinator/`. |
| CTX-02 PostgreSQL migrations | Implemented | Applied by the Compose migration service. |
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency. |
| CTX-02 PostgreSQL migrations | Implemented | Embedded into the binary (`AUTO_MIGRATE`); the CLI path is still available for managed databases. |
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency; the SQLite backend mirrors the semantics. |
| 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 | 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; 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. |
| CTX-06 Python Worker live-contract alignment | Superseded | The Python worker daemon was removed; the Go worker agent (`coordinator/internal/agent/` + `cmd/worker-agent`, or `coordinator agent`) implements the lifecycle and executes SDK workloads via `scimesh/worker/task.py`. |
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering. |
| CTX-08 Distributed similarity-search | Implemented | Planner/worker/reducer match the local reference byte-exactly. |
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side reducers (`top-k` and `ordered-concat`), sanitized failure, final artifact, `result_uri`. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload enforces the pair-coverage invariant. |
| CTX-11 Dashboard/operator view | Implemented | Protected live control room, workload library, workload-agnostic "New computation" form (SDK-declared `UIElement`s), MkDocs at `/ui/docs/`, final-result download. |
| CTX-12 Reliability, security, CI | In progress | vet, gofmt, race tests, golangci-lint (0 issues), PostgreSQL integration, and smoke checks exist. |
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, quorum; also embeddable (`coordinator serve`). |
| CTX-16 Workload SDK foundation | Implemented | Strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, negotiation, verifier primitives, conformance harness. |
| CTX-17 Self-provisioning + setup wizard | Implemented | Embedded migrations, `coordinator setup`, SQLite backend, embedded userservice, `serve` mode. |
| CTX-18 Single-binary platform | Implemented | `coordinator serve` (data dir, secrets, admin bootstrap, local agents, managed venv) + `install.sh`/`install.ps1`; full no-external-service E2E green. |
| SDK roadmap step 3: `descriptor-batch` | Implemented | Byte-identical local/distributed output, quorum verifier test. |
| SDK-built `similarity-search` and `similarity-graph` | Implemented | SDK-built packages, byte-identical to single-process references. |
| SDK-built `molwt-filter` | Implemented | Minimal authoring example; also the single-binary E2E workload. |
| SDK authoring scaffold | Implemented | `MapReduceWorkload` with `UIElement` declarations, `reduction`, `upload_ready`; generic `scimesh workload list|run|export|allowlist` CLI. |
## Next recommended assignment
+6 -1
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 agent coordinator setup workloads-export 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 serve 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.
@@ -57,6 +57,11 @@ coordinator:
setup: coordinator
./bin/coordinator setup $(SETUP_ARGS)
# The single-binary mode: everything embedded (sqlite + userservice + local
# workers), no PostgreSQL or Docker. SETUP_ARGS=--workers 2 --open.
serve: coordinator
./bin/coordinator serve $(SETUP_ARGS)
workloads-export:
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
)
// runAgent implements `coordinator agent`: the worker agent as a subcommand of
// the same binary, so one file can serve the whole platform. `serve` spawns
// these for its local workers.
func runAgent(args []string) error {
flags := flag.NewFlagSet("agent", flag.ContinueOnError)
flags.Usage = func() {
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator agent [options]\n")
_, _ = fmt.Fprintf(flags.Output(), "Runs as a worker agent: claims tasks, executes SDK workloads in a\n")
_, _ = fmt.Fprintf(flags.Output(), "Python subprocess, uploads results.\n\n")
flags.PrintDefaults()
}
var (
coordinatorURL = flags.String("coordinator-url", os.Getenv("COORDINATOR_URL"), "coordinator base URL")
token = flags.String("token", os.Getenv("WORKER_AUTH_TOKEN"), "worker bearer token")
workDir = flags.String("work-dir", os.Getenv("WORK_DIR"), "worker work directory")
name = flags.String("name", os.Getenv("WORKER_NAME"), "worker name (default: hostname)")
workerID = flags.String("worker-id", os.Getenv("WORKER_ID"), "persistent worker id (optional)")
cpuCount = flags.Int("cpu", envInt("CPU_COUNT", 1), "advertised CPU cores")
memoryMB = flags.Int("memory-mb", envInt("MEMORY_MB", 1024), "advertised memory in MiB")
poll = flags.Duration("poll-interval", 2*time.Second, "claim poll interval")
taskRunner = flags.String("task-runner", os.Getenv("TASK_RUNNER"), "python command + args that run scimesh.worker.task")
maxTasks = flags.Int("max-tasks", envInt("MAX_TASKS", 0), "stop after N completed tasks (0 = unlimited)")
exitWhenIdle = flags.Bool("exit-when-idle", false, "exit when the queue is empty")
)
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 0 {
return fmt.Errorf("agent takes no positional arguments")
}
if *coordinatorURL == "" || *token == "" || *workDir == "" {
return fmt.Errorf("--coordinator-url, --token, and --work-dir are required")
}
if *taskRunner == "" {
*taskRunner = "python -m scimesh.worker.task"
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
config := agent.Config{
CoordinatorURL: strings.TrimRight(*coordinatorURL, "/"),
Capabilities: agent.DefaultCapabilities(),
Token: *token,
WorkerName: *name,
WorkerID: *workerID,
WorkDir: *workDir,
CPUCount: *cpuCount,
MemoryMB: *memoryMB,
PollInterval: *poll,
RequestTimeout: 30 * time.Second,
Heartbeat: 15 * time.Second,
TaskRunner: strings.Fields(*taskRunner),
MaxTasks: *maxTasks,
ExitWhenIdle: *exitWhenIdle,
}
tokens := agent.NewTokenProvider("", "", config.Token, config.RequestTimeout)
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
runner := agent.NewTaskRunner(config.TaskRunner)
daemon := agent.NewDaemon(&config, client, runner, logger)
return daemon.RunForever()
}
func envInt(name string, fallback int) int {
raw := os.Getenv(name)
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
return fallback
}
return n
}
+154 -27
View File
@@ -14,6 +14,7 @@ import (
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/blob"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/sqlite"
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
@@ -25,11 +26,33 @@ 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)
if len(args) > 0 {
switch args[0] {
case "setup":
if err := runSetup(args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "setup:", err)
os.Exit(1)
}
return
case "serve":
if err := runServe(args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "serve:", err)
os.Exit(1)
}
return
case "agent":
if err := runAgent(args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "agent:", err)
os.Exit(1)
}
return
case "token":
if err := runToken(args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "token:", err)
os.Exit(1)
}
return
}
return
}
showVersion := flag.Bool("version", false, "print the build version and exit")
flag.Parse()
@@ -44,9 +67,27 @@ func main() {
}
}
// storageDeps carries the engine-specific database handles and the repository
// implementations. The usecases below only ever see the ports.
type storageDeps struct {
tx usecase.TxManager
taskRepo usecase.TaskRepository
jobRepo usecase.JobRepository
workerRepo usecase.WorkerRepository
artifactRepo usecase.ArtifactRepository
uiReadRepo usecase.UIReadRepository
adminReadRepo usecase.AdminReadRepository
settingsRepo usecase.WorkloadSettingsRepository
taskResultRepo usecase.TaskResultRepository
statsRepo interface {
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
}
ready func(ctx context.Context) error
migrate func(ctx context.Context, log *slog.Logger) error
close func()
}
func run() error {
// Bootstrap logger, used only until config says where logs should go. It
// writes to stderr so it never contaminates the configured stdout stream.
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
cfg, err := infra.LoadConfig()
@@ -54,6 +95,16 @@ func run() error {
boot.Error("load config", "err", err)
return err
}
return runWithConfig(cfg)
}
// runWithConfig boots the coordinator server with an explicit config. The
// `serve` subcommand builds such a config for the single-binary mode; the
// plain `coordinator` binary loads it from the environment.
func runWithConfig(cfg infra.Config) error {
// Bootstrap logger, used only until config says where logs should go. It
// writes to stderr so it never contaminates the configured stdout stream.
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
// The real logger: stdout plus an optional rotated file (LOG_FILE).
log, logCloser, err := infra.NewLogger(cfg)
@@ -66,17 +117,25 @@ func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pool, err := infra.NewPool(ctx, cfg, log)
var deps *storageDeps
switch cfg.DatabaseEngine {
case "sqlite":
deps, err = openSQLite(ctx, cfg, log)
case "postgres":
deps, err = openPostgres(ctx, cfg, log)
default:
err = fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
}
if err != nil {
log.Error("connect database", "err", err)
log.Error("init storage", "err", err)
return err
}
defer pool.Close()
defer deps.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 {
if err := deps.migrate(ctx, log); err != nil {
log.Error("apply migrations", "err", err)
return err
}
@@ -88,16 +147,9 @@ func run() error {
return err
}
var (
clk = infra.NewClock()
tx = postgres.NewTxManager(pool)
taskRepo = postgres.NewTaskRepo(pool)
jobRepo = postgres.NewJobRepo(pool)
workerRepo = postgres.NewWorkerRepo(pool)
artifactRepo = postgres.NewArtifactRepo(pool)
uiReadRepo = postgres.NewUIReadRepo(pool)
taskResultRepo = postgres.NewTaskResultRepo(pool)
)
clk := infra.NewClock()
tx, taskRepo, jobRepo, workerRepo, artifactRepo, uiReadRepo, taskResultRepo :=
deps.tx, deps.taskRepo, deps.jobRepo, deps.workerRepo, deps.artifactRepo, deps.uiReadRepo, deps.taskResultRepo
catalog, err := workloads.Load()
if err != nil {
@@ -108,7 +160,7 @@ func run() error {
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, catalog),
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog, deps.settingsRepo),
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, catalog),
@@ -122,10 +174,25 @@ func run() error {
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
Admin: usecase.NewAdmin(deps.adminReadRepo, uiReadRepo, workerRepo, deps.settingsRepo, catalog,
usecase.AdminNodeInfo{
Version: version,
StartedAt: clk.Now(),
Binary: executablePath(),
Addr: cfg.Addr,
DataDir: cfg.StorageDir,
DBEngine: cfg.DatabaseEngine,
PublicURL: cfg.PublicCoordinatorURL,
Userservice: cfg.UserserviceURL,
WorkerToken: func() string { return cfg.Token },
}, deps.ready, clk.Now).
WithAuditLog(log, func(ctx context.Context, action, detail string) {
log.Info("admin audit", "action", action, "detail", detail)
}),
}
// 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
// the process would exit mid-UPDATE, and the deferred close() would pull
// connections out from under them.
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk, catalog)
markOffline := usecase.NewMarkWorkersOffline(workerRepo, clk, cfg.WorkerOfflineAfter)
@@ -147,16 +214,15 @@ func run() error {
// Business metrics: gauges of tasks/jobs/workers by status, sampled from the
// database on every Prometheus scrape.
statsRepo := postgres.NewStatsRepo(pool)
m := metrics.New()
m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
tasks, jobs, workers, err := statsRepo.Counts(ctx)
tasks, jobs, workers, err := deps.statsRepo.Counts(ctx)
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
})
// pool.Ping backs /health: readiness means the database answers, not just
// deps.ready 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, cfg.DocsDir)
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, 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
@@ -164,7 +230,7 @@ func run() error {
//
// 1. stop() cancel the context, telling the reaper to finish
// 2. wg.Wait() let it return from its current tick
// 3. deferred pool.Close() closes an idle pool, not a busy one
// 3. deferred close() closes an idle pool, not a busy one
//
// Calling stop() here also covers the path where RunServer failed on its
// own: the context would never be cancelled otherwise and wg.Wait()
@@ -175,3 +241,64 @@ func run() error {
return err
}
// executablePath resolves the running binary for the admin console's node
// information, falling back to the invocation name.
func executablePath() string {
path, err := os.Executable()
if err != nil || path == "" {
return os.Args[0]
}
return path
}
// openSQLite opens the embedded database and builds the sqlite repositories.
func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
return nil, fmt.Errorf("create storage dir: %w", err)
}
db, err := sqlite.Open(cfg.DBPath)
if err != nil {
return nil, err
}
closeOnce := &sync.Once{}
return &storageDeps{
tx: sqlite.NewTxManager(db),
taskRepo: sqlite.NewTaskRepo(db),
jobRepo: sqlite.NewJobRepo(db),
workerRepo: sqlite.NewWorkerRepo(db),
artifactRepo: sqlite.NewArtifactRepo(db),
uiReadRepo: sqlite.NewUIReadRepo(db),
adminReadRepo: sqlite.NewAdminReadRepo(db),
settingsRepo: sqlite.NewWorkloadSettingsRepo(db),
taskResultRepo: sqlite.NewTaskResultRepo(db),
statsRepo: sqlite.NewStatsRepo(db),
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
migrate: func(ctx context.Context, log *slog.Logger) error { return sqlite.Migrate(ctx, db, log) },
close: func() { closeOnce.Do(func() { _ = db.Close() }) },
}, nil
}
// openPostgres connects to PostgreSQL and builds the postgres repositories.
func openPostgres(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
pool, err := infra.NewPool(ctx, cfg, log)
if err != nil {
return nil, err
}
closeOnce := &sync.Once{}
return &storageDeps{
tx: postgres.NewTxManager(pool),
taskRepo: postgres.NewTaskRepo(pool),
jobRepo: postgres.NewJobRepo(pool),
workerRepo: postgres.NewWorkerRepo(pool),
artifactRepo: postgres.NewArtifactRepo(pool),
uiReadRepo: postgres.NewUIReadRepo(pool),
adminReadRepo: postgres.NewAdminReadRepo(pool),
settingsRepo: postgres.NewWorkloadSettingsRepo(pool),
taskResultRepo: postgres.NewTaskResultRepo(pool),
statsRepo: postgres.NewStatsRepo(pool),
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
migrate: func(ctx context.Context, log *slog.Logger) error { return postgres.Migrate(ctx, cfg.DatabaseURL, log) },
close: func() { closeOnce.Do(pool.Close) },
}, nil
}
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice"
)
// runServe implements `coordinator serve`: the single-binary mode for a
// scientist. It provisions a data directory (default ~/.scimesh) with the
// coordinator and userservice sqlite databases, secrets, the admin account,
// and optionally local worker agents — then runs the same server run() does.
func runServe(args []string) error {
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
flags.Usage = func() {
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator serve [options]\n")
_, _ = fmt.Fprintf(flags.Output(), "Runs the whole SciMesh platform from one binary: embedded databases, the\n")
_, _ = fmt.Fprintf(flags.Output(), "userservice, and optional local workers. No PostgreSQL or Docker needed.\n\n")
flags.PrintDefaults()
}
var (
dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)")
addr = flags.String("addr", "127.0.0.1:8080", "listen address")
workers = flags.Int("workers", 1, "number of local worker agents to spawn")
open = flags.Bool("open", false, "open the UI in the browser")
docsDir = flags.String("docs-dir", "", "built MkDocs site directory to serve at /ui/docs/")
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
password = flags.String("admin-password", "", "admin password (generated on first run when empty)")
publicURL = flags.String("public-url", "", "browser/worker-facing coordinator URL (default: http://<addr>)")
)
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 0 {
return fmt.Errorf("serve takes no positional arguments")
}
if *workers < 0 {
return fmt.Errorf("--workers must be >= 0")
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := os.MkdirAll(*dataDir, 0o750); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
// 1. Secrets, persisted in the data dir so restarts keep working.
jwtSecret, err := loadOrGenerate(filepath.Join(*dataDir, "jwt.secret"))
if err != nil {
return err
}
workerToken, err := loadOrGenerate(filepath.Join(*dataDir, "worker.token"))
if err != nil {
return err
}
// 2. Admin account: generated once and printed, remembered for later boots.
if *password == "" {
*password, err = loadOrGenerate(filepath.Join(*dataDir, "admin.password"))
if err != nil {
return err
}
}
// 3. Scientific runtime: ensure the managed venv (best effort).
venvPython := filepath.Join(*dataDir, "venv", binName("bin/python"))
ensureRuntime(log, *dataDir, venvPython)
// 4. Embedded userservice on the loopback interface.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
usersAddr, closeUsers, err := userservice.Serve(ctx, userservice.Config{
DBPath: filepath.Join(*dataDir, "users.db"),
JWTSecret: jwtSecret,
AdminEmail: *email,
AdminPassword: *password,
Log: log,
})
if err != nil {
return fmt.Errorf("embedded userservice: %w", err)
}
defer func() { _ = closeUsers() }()
// 5. Local worker agents before the server, so they can claim immediately.
coordinatorURL := "http://" + *addr
agents, err := spawnAgents(ctx, log, *dataDir, *workers, coordinatorURL, workerToken, venvPython)
if err != nil {
return err
}
defer stopAgents(agents)
// 6. The coordinator server itself.
coordinatorPublicURL := *publicURL
if coordinatorPublicURL == "" {
coordinatorPublicURL = "http://" + *addr
}
cfg := infra.Config{
Addr: *addr,
DatabaseEngine: "sqlite",
DBPath: filepath.Join(*dataDir, "scimesh.db"),
Token: workerToken,
JWTSecret: jwtSecret,
UserserviceURL: "http://" + usersAddr,
PublicCoordinatorURL: coordinatorPublicURL,
PublicUserserviceURL: "http://" + usersAddr,
LogLevel: "info",
StorageDir: filepath.Join(*dataDir, "artifacts"),
DocsDir: *docsDir,
MaxUploadBytes: 1 << 30,
DBMaxConns: 4,
DBConnectTimeout: 10 * time.Second,
RequestTimeout: 15 * time.Second,
HeartbeatInterval: 15 * time.Second,
LeaseDuration: 2 * time.Minute,
DefaultMaxAttempts: 3,
QuorumSize: 2,
ReaperInterval: 30 * time.Second,
WorkerOfflineAfter: 1 * time.Minute,
AutoMigrate: true,
}
if *open {
openBrowser("http://" + *addr + "/ui/admin")
}
// Print the login once the server is about to start.
fmt.Printf("\nSciMesh is starting at http://%s/ui\n", *addr)
fmt.Printf(" admin login: %s / %s\n", *email, *password)
if runtimeStatus(venvPython) {
fmt.Printf(" scientific runtime: ready (%s)\n", venvPython)
} else {
fmt.Printf(" scientific runtime: NOT ready — install Python 3, then restart serve\n")
}
fmt.Printf(" data directory: %s\n\n", *dataDir)
err = runWithConfig(cfg)
cancel()
stopAgents(agents)
return err
}
// defaultDataDir returns the platform-appropriate data directory.
func defaultDataDir() string {
if dir := os.Getenv("SCIMESH_DATA_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ".scimesh"
}
return filepath.Join(home, ".scimesh")
}
// loadOrGenerate reads a secret file, creating it with fresh random content
// (chmod 0600) when missing.
// #nosec G304 -- the path is an operator-supplied secret file inside the data dir.
func loadOrGenerate(path string) (string, error) {
if raw, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(raw)), nil
}
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
secret := hex.EncodeToString(buffer)
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write %s: %w", path, err)
}
return secret, nil
}
// spawnAgents starts `coordinator agent` subprocesses that claim tasks from
// the coordinator. Each gets its own work directory under the data dir.
func spawnAgents(ctx context.Context, log *slog.Logger, dataDir string, count int,
coordinatorURL, token, venvPython string) ([]*exec.Cmd, error) {
var agents []*exec.Cmd
for i := 0; i < count; i++ {
workDir := filepath.Join(dataDir, "workers", fmt.Sprintf("%d", i))
if err := os.MkdirAll(workDir, 0o750); err != nil {
return agents, err
}
taskRunner := defaultTaskRunner(venvPython)
// #nosec G204,G702 -- the command is this binary itself with operator flags.
cmd := exec.CommandContext(ctx, os.Args[0], "agent",
"--coordinator-url", coordinatorURL,
"--token", token,
"--work-dir", workDir,
"--task-runner", taskRunner,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return agents, fmt.Errorf("start local agent %d: %w", i, err)
}
agents = append(agents, cmd)
log.Info("local worker agent started", "index", i)
}
return agents, nil
}
// stopAgents terminates the spawned agents and waits briefly for them.
func stopAgents(agents []*exec.Cmd) {
for _, agent := range agents {
if agent.Process != nil {
_ = agent.Process.Kill()
}
}
done := make(chan struct{})
go func() {
for _, agent := range agents {
_, _ = agent.Process.Wait()
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
// defaultTaskRunner picks the managed venv python when present, else the
// system `python`.
func defaultTaskRunner(venvPython string) string {
if runtimeStatus(venvPython) {
return venvPython + " -m scimesh.worker.task"
}
return "python -m scimesh.worker.task"
}
// ensureRuntime creates the managed venv and installs scimesh into it, unless
// it already exists. Best effort: a missing Python only logs a hint.
func ensureRuntime(log *slog.Logger, dataDir, venvPython string) {
if runtimeStatus(venvPython) {
return
}
python := findPython()
if python == "" {
log.Warn("python3 not found; local workers need it to run scientific workloads")
return
}
log.Info("creating the scientific runtime venv", "python", python)
venvDir := filepath.Dir(filepath.Dir(venvPython))
// #nosec G204 -- python comes from PATH and venvDir from the data dir.
create := exec.CommandContext(context.Background(), python, "-m", "venv", venvDir)
if out, err := create.CombinedOutput(); err != nil {
log.Warn("venv creation failed; local workers need a manual Python install", "err", err, "output", string(out))
return
}
pip := filepath.Join(venvDir, binName("bin/pip"))
// The scimesh package is installed from an explicit source only: the PyPI
// name is not ours yet, so `pip install scimesh` would fetch a stranger's
// package. Operators publish a wheel or index via SCIMESH_PIP_PACKAGE.
source := os.Getenv("SCIMESH_PIP_PACKAGE")
if source == "" {
log.Warn("scientific runtime venv created, but scimesh is not installed",
"hint", pip+" install <your scimesh wheel or index> (or set SCIMESH_PIP_PACKAGE)")
return
}
// #nosec G204,G702 -- pip and source are operator-configured paths.
install := exec.CommandContext(context.Background(), pip, "install", source)
if out, err := install.CombinedOutput(); err != nil {
log.Warn("pip install failed", "err", err, "output", string(out))
return
}
log.Info("scientific runtime installed", "venv", venvDir)
}
// findPython locates a usable python3.
func findPython() string {
for _, candidate := range []string{"python3", "python"} {
path, err := exec.LookPath(candidate)
if err == nil {
return path
}
}
return ""
}
// runtimeStatus reports whether the managed venv python exists.
func runtimeStatus(venvPython string) bool {
info, err := os.Stat(venvPython)
return err == nil && !info.IsDir()
}
// binName adapts a relative path to the platform layout.
func binName(relative string) string {
if runtime.GOOS == "windows" {
parts := strings.Split(relative, "/")
parts[len(parts)-1] += ".exe"
return strings.Join(parts, string(filepath.Separator))
}
return relative
}
// openBrowser opens the UI in the platform's default browser.
func openBrowser(target string) {
command := ""
switch runtime.GOOS {
case "darwin":
command = "open"
case "windows":
command = "rundll32"
default:
command = "xdg-open"
}
if command == "rundll32" {
// #nosec G204 -- target is the local UI URL the operator asked to open.
_ = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", target).Start()
return
}
// #nosec G204 -- target is the local UI URL the operator asked to open.
_ = exec.CommandContext(context.Background(), command, target).Start()
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
)
// runToken implements `coordinator token`: prints the worker auth token of a
// `coordinator serve` instance, so a scientist can join a worker without
// hunting through the data directory. The file itself is what serve created.
func runToken(args []string) error {
flags := flag.NewFlagSet("token", flag.ContinueOnError)
flags.Usage = func() {
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator token [options]\n")
_, _ = fmt.Fprintf(flags.Output(), "Prints the WORKER_AUTH_TOKEN of this coordinator's serve instance.\n\n")
flags.PrintDefaults()
}
var dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 0 {
return fmt.Errorf("token takes no positional arguments")
}
token, err := os.ReadFile(filepath.Join(*dataDir, "worker.token"))
if err != nil {
return fmt.Errorf("no worker token found in %s — start the coordinator with `coordinator serve` first", *dataDir)
}
fmt.Print(string(token))
return nil
}
+140 -4
View File
@@ -1,14 +1,25 @@
// Command worker-agent is the Go worker agent: a coordinator client that
// executes SDK workloads in a Python subprocess per claimed task.
// executes SDK workloads in a Python subprocess per claimed task. It also
// carries the local setup wizard (`worker-agent setup`) so a machine that
// installs only the worker can configure and start itself without a
// coordinator on site.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
"github.com/emil28092005/SciMesh/coordinator/internal/agent/setupui"
)
// version is injected at build time (-ldflags "-X main.version=...") and
@@ -16,13 +27,44 @@ import (
var version = "dev"
func main() {
showVersion := flag.Bool("version", false, "print the build version and exit")
flag.Parse()
if len(os.Args) > 1 && os.Args[1] == "setup" {
os.Exit(runSetup(os.Args[2:]))
}
fs := flag.NewFlagSet("worker-agent", flag.ExitOnError)
showVersion := fs.Bool("version", false, "print the build version and exit")
configPath := fs.String("config", "", "path to a JSON config file (SCIMESH_WORKER_CONFIG overrides the default)")
checkMode := fs.Bool("check", false, "run the preflight check (coordinator + local runtime) and exit 0/1")
checkURL := fs.String("coordinator-url", "", "coordinator URL to probe in --check mode")
_ = fs.Parse(os.Args[1:])
agent.Version = version
if *showVersion {
fmt.Println("worker-agent " + version)
return
}
config, err := agent.LoadConfig()
if *checkMode {
url := *checkURL
if url == "" {
url = os.Getenv("COORDINATOR_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if url == "" {
fmt.Println("check: no coordinator URL (pass --coordinator-url or set COORDINATOR_URL)")
os.Exit(1)
}
report := agent.RunCheck(ctx, url)
printCheck(report)
if !report.Coordinator.OK || !report.Python.OK || !report.Scimesh.OK {
os.Exit(1)
}
return
}
config, err := loadConfig(*configPath)
if err != nil {
slog.Error("invalid configuration", "error", err)
os.Exit(2)
@@ -42,3 +84,97 @@ func main() {
os.Exit(1)
}
}
// loadConfig prefers a --config file; environment variables override the file
// (see agent.ConfigFile.Config). Without a file, the plain environment path is
// used exactly as before.
func loadConfig(configPath string) (*agent.Config, error) {
if configPath != "" {
return agent.LoadConfigFile(configPath)
}
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
if envPath != "" {
if _, err := os.Stat(envPath); err == nil { //nolint:gosec // G703: path is the operator's own env var
return agent.LoadConfigFile(envPath)
}
}
return agent.LoadConfig()
}
func printCheck(report agent.CheckReport) {
fmt.Printf("worker-agent %s\n", report.Agent)
line := func(item agent.CheckItem) string {
mark := "✗"
if item.OK {
mark = "✓"
}
detail := item.Detail
if item.Latency > 0 {
detail = fmt.Sprintf("%s (%d ms)", detail, item.Latency)
}
return fmt.Sprintf(" %s %s: %s", mark, item.Name, detail)
}
fmt.Println(line(report.Coordinator))
fmt.Println(line(report.Auth))
fmt.Println(line(report.Python))
fmt.Println(line(report.Scimesh))
}
// runSetup serves the local setup wizard until interrupted. It binds the
// loopback interface only.
func runSetup(args []string) int {
fs := flag.NewFlagSet("worker-agent setup", flag.ContinueOnError)
port := fs.Int("port", 0, "listen port (default 12700)")
noOpen := fs.Bool("no-open", false, "do not open the browser automatically")
if err := fs.Parse(args); err != nil {
return 2
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
server := setupui.New(logger, setupui.Options{
Port: *port,
OpenBrowser: func(url string) {
if *noOpen {
return
}
openBrowser(url)
},
})
listener, err := server.Listen()
if err != nil {
logger.Error("setup wizard could not bind the loopback port", "err", err)
return 1
}
url := "http://" + listener.Addr().String()
logger.Info("SciMesh worker setup wizard", "url", url, "press-ctrl-c-to-stop", true)
server.OpenBrowser(url)
// Block until the signal arrives (never returns an error that matters: a
// cancelled context is the normal exit path).
err = server.Serve(ctx, listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("setup wizard stopped", "err", err)
return 1
}
return 0
}
// openBrowser points the user's default browser at the wizard. Best-effort:
// a missing browser must never fail the setup flow.
func openBrowser(url string) {
for _, candidate := range [][]string{
{"xdg-open", url},
{"open", url},
{"cmd", "/c", "start", url},
} {
binary, err := exec.LookPath(candidate[0])
if err != nil {
continue
}
//nolint:gosec // G204: candidates are our own fixed list; the url is a loopback literal
_ = exec.CommandContext(context.Background(), binary, candidate[1:]...).Start()
return
}
}
+8
View File
@@ -16,18 +16,26 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
)
+17
View File
@@ -9,6 +9,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -29,8 +31,12 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
@@ -41,6 +47,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -51,6 +59,7 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
@@ -63,3 +72,11 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+109
View File
@@ -0,0 +1,109 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"runtime"
"strings"
"time"
)
// CheckItem is one line of the preflight report the setup wizard shows.
type CheckItem struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
Latency int64 `json:"latency_ms,omitempty"`
}
// CheckReport is the full preflight result of `worker-agent --check` and of
// the wizard's test step.
type CheckReport struct {
Coordinator CheckItem `json:"coordinator"`
Auth CheckItem `json:"auth"`
Python CheckItem `json:"python"`
Scimesh CheckItem `json:"scimesh"`
Agent string `json:"agent_version"`
CoordinatorVersion string `json:"coordinator_version,omitempty"`
}
// checkHTTP runs one GET and reports reachability + latency, with a fallback
// detail message when the server answers without JSON.
func checkHTTP(ctx context.Context, url string, timeout time.Duration) (CheckItem, string) {
started := time.Now()
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
if err != nil {
return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, ""
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
detail := err.Error()
if strings.Contains(detail, "connection refused") {
detail = "no coordinator answering at this address"
}
return CheckItem{Name: "coordinator", OK: false, Detail: detail}, ""
}
defer func() { _ = resp.Body.Close() }()
version := ""
if resp.StatusCode == http.StatusOK {
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Status == "ok" {
return CheckItem{Name: "coordinator", OK: true, Latency: time.Since(started).Milliseconds()}, version
}
}
return CheckItem{Name: "coordinator", OK: false, Detail: fmt.Sprintf("HTTP %d", resp.StatusCode)}, version
}
// CheckCoordinator probes the coordinator's /health endpoint.
func CheckCoordinator(ctx context.Context, url string, timeout time.Duration) CheckReport {
report := CheckReport{Agent: Version}
item, _ := checkHTTP(ctx, strings.TrimRight(url, "/")+"/health", timeout)
report.Coordinator = item
report.Auth = CheckItem{Name: "auth", OK: true, Detail: "no token configured — will be checked at registration"}
return report
}
// CheckEnvironment verifies the local runtime: Python present and the scimesh
// package importable.
func CheckEnvironment(ctx context.Context) CheckReport {
report := CheckReport{Agent: Version}
python, err := exec.LookPath("python3")
if err != nil {
report.Python = CheckItem{Name: "python", OK: false, Detail: "python3 not found on PATH"}
return report
}
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
//nolint:gosec // G204: python comes from LookPath, the argument list is constant
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
out, err := cmd.Output()
if err != nil {
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "install with: pip install scimesh"}
return report
}
report.Scimesh = CheckItem{Name: "scimesh", OK: true, Detail: strings.TrimSpace(string(out))}
return report
}
// RunCheck combines the coordinator probe and the local environment probe; it
// is the body behind `worker-agent --check` and the wizard's test step.
func RunCheck(ctx context.Context, coordinatorURL string) CheckReport {
report := CheckCoordinator(ctx, coordinatorURL, 15*time.Second)
env := CheckEnvironment(ctx)
report.Python = env.Python
report.Scimesh = env.Scimesh
return report
}
// Version is the agent build version; main injects it via -ldflags and the
// setup wizard mirrors it into the report. "dev" marks a local build.
var Version = "dev"
// Platform is the host platform string shown on the wizard.
func Platform() string { return runtime.GOOS + "/" + runtime.GOARCH }
+3 -3
View File
@@ -101,7 +101,7 @@ func LoadConfig() (*Config, error) {
return nil, err
}
if len(capabilities) == 0 {
capabilities = defaultCapabilities()
capabilities = DefaultCapabilities()
}
runner, err := envList("TASK_RUNNER")
if err != nil {
@@ -160,11 +160,11 @@ func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
return parsed, nil
}
// defaultCapabilities derives the worker's advertised capabilities from the
// 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 {
func DefaultCapabilities() []string {
catalog, err := workloads.Load()
if err != nil {
return []string{"similarity-search"}
+134
View File
@@ -0,0 +1,134 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ConfigFile is the persisted worker configuration written by the setup
// wizard and read back by `worker-agent --config`. Environment variables
// still win: the file fills in what the environment left unset.
type ConfigFile struct {
CoordinatorURL string `json:"coordinator_url"`
Token string `json:"token,omitempty"`
WorkerKey string `json:"worker_key,omitempty"`
UserserviceURL string `json:"userservice_url,omitempty"`
WorkDir string `json:"work_dir"`
WorkerName string `json:"worker_name,omitempty"`
CPUCount int `json:"cpu_count"`
MemoryMB int `json:"memory_mb"`
TaskRunner []string `json:"task_runner,omitempty"`
}
// DefaultConfigPath is where the setup wizard stores the worker's
// configuration. SCIMESH_WORKER_CONFIG overrides it.
func DefaultConfigPath() string {
if raw := os.Getenv("SCIMESH_WORKER_CONFIG"); raw != "" {
return raw
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".", ".scimesh-worker", "config.json")
}
return filepath.Join(home, ".scimesh-worker", "config.json")
}
// LoadConfigFile reads and validates a persisted configuration. The file is
// created by the wizard with 0600 permissions, so no credential is exposed to
// other local users.
func LoadConfigFile(path string) (*Config, error) {
//nolint:gosec // G304: path is --config or SCIMESH_WORKER_CONFIG, operator-supplied
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
var file ConfigFile
if err := json.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("parse config file: %w", err)
}
return file.Config()
}
// Config turns the file into the daemon configuration. Environment variables
// take precedence so operators can still override any value per-process.
func (f *ConfigFile) Config() (*Config, error) {
config := &Config{}
if env := os.Getenv("COORDINATOR_URL"); env != "" {
config.CoordinatorURL = env
} else {
config.CoordinatorURL = strings.TrimSpace(f.CoordinatorURL)
}
if config.CoordinatorURL == "" {
return nil, fmt.Errorf("coordinator_url is required")
}
if !strings.HasPrefix(config.CoordinatorURL, "http://") && !strings.HasPrefix(config.CoordinatorURL, "https://") {
return nil, fmt.Errorf("coordinator_url must be an absolute HTTP(S) URL")
}
if env := os.Getenv("WORKER_AUTH_TOKEN"); env != "" {
config.Token = env
} else {
config.Token = f.Token
}
if env := os.Getenv("WORKER_KEY"); env != "" {
config.WorkerKey = env
} else {
config.WorkerKey = f.WorkerKey
}
if env := os.Getenv("USERSERVICE_URL"); env != "" {
config.UserserviceURL = env
} else {
config.UserserviceURL = f.UserserviceURL
}
if env := os.Getenv("WORK_DIR"); env != "" {
config.WorkDir = env
} else if f.WorkDir != "" {
config.WorkDir = f.WorkDir
} else {
config.WorkDir = "./scimesh-agent-data"
}
if env := os.Getenv("WORKER_NAME"); env != "" {
config.WorkerName = env
} else {
config.WorkerName = f.WorkerName
}
config.CPUCount = f.CPUCount
if config.CPUCount < 1 {
config.CPUCount = 1
}
config.MemoryMB = f.MemoryMB
if config.MemoryMB < 0 {
config.MemoryMB = 0
}
if len(f.TaskRunner) > 0 {
config.TaskRunner = f.TaskRunner
}
if len(config.TaskRunner) == 0 {
config.TaskRunner = []string{"python", "-m", "scimesh.worker.task"}
}
config.PollInterval = 2 * time.Second
config.RequestTimeout = 30 * time.Second
config.Heartbeat = 15 * time.Second
config.Capabilities = DefaultCapabilities()
return config, nil
}
// Save writes the configuration file, creating the parent directory and
// restricting permissions to the owner.
func SaveConfigFile(path string, file ConfigFile) error {
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return err
}
payload = append(payload, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
return nil
}
@@ -0,0 +1,414 @@
// Package setupui serves the local worker setup wizard: a small HTTP server
// bound to 127.0.0.1 that writes the worker's config file, runs preflight
// checks, and starts/stops the worker as a background process. It is part of
// the worker-agent binary so a machine that installs only a worker never needs
// a coordinator.
package setupui
import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
)
//go:embed template.html
var templateFS embed.FS
const (
defaultPort = 12700
pidFileName = "worker.pid"
logFileName = "worker.log"
)
// Supervisor starts and stops the worker process and tracks its pid. It is an
// interface so tests can substitute a fake.
type Supervisor interface {
// Start launches `worker-agent --config <path>` detached, writing output
// into the log file. Returns the child pid.
Start(configPath, logPath string) (int, error)
// Stop terminates the process recorded in the pid file.
Stop() error
// Pid returns the recorded child pid, or 0 when none is recorded.
Pid() int
// Alive reports whether the recorded child is still running.
Alive() bool
}
// PIDSupervisor is the real Supervisor: it spawns the running binary with
// --config and manages its pid file. Liveness comes from a Wait goroutine, so
// it works on every platform (no signal probing, which Windows lacks).
type PIDSupervisor struct {
mu sync.Mutex
pidPath string
proc *os.Process
done chan struct{} // closed when the spawned process exits; nil when not started
}
func NewPIDSupervisor(pidPath string) *PIDSupervisor { return &PIDSupervisor{pidPath: pidPath} }
func (s *PIDSupervisor) Pid() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.readPid()
}
func (s *PIDSupervisor) readPid() int {
raw, err := os.ReadFile(s.pidPath)
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || pid < 1 {
return 0
}
return pid
}
func (s *PIDSupervisor) Alive() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.done == nil {
return false
}
select {
case <-s.done:
return false
default:
return true
}
}
func (s *PIDSupervisor) Start(configPath, logPath string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.done != nil {
select {
case <-s.done:
default:
return s.readPid(), fmt.Errorf("worker is already running (pid %d)", s.readPid())
}
}
exe, err := os.Executable()
if err != nil {
return 0, fmt.Errorf("resolve worker binary: %w", err)
}
//nolint:gosec // G304: logPath lives in the wizard's own config directory
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return 0, fmt.Errorf("open worker log: %w", err)
}
defer func() { _ = logFile.Close() }()
//nolint:gosec // G204: exe is os.Executable, configPath is the wizard's own file;
// Background ctx: the child's lifecycle is managed by the supervisor, not the context
cmd := exec.CommandContext(context.Background(), exe, "--config", configPath)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("start worker: %w", err)
}
// The child inherits our stdout/stderr descriptors pointing at the log
// file, so we can close our copy; the child keeps it open.
_ = logFile.Close()
s.proc = cmd.Process
s.done = make(chan struct{})
go func() { _ = cmd.Wait(); close(s.done) }()
if err := os.WriteFile(s.pidPath, []byte(strconv.Itoa(cmd.Process.Pid)+"\n"), 0o600); err != nil {
_ = cmd.Process.Kill()
return 0, fmt.Errorf("write pid file: %w", err)
}
return cmd.Process.Pid, nil
}
func (s *PIDSupervisor) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.done == nil {
return nil
}
select {
case <-s.done:
s.done = nil
s.proc = nil
_ = os.Remove(s.pidPath)
return nil
default:
}
// Ask politely, then force. os.Interrupt terminates on Windows too.
_ = s.proc.Signal(os.Interrupt)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
select {
case <-s.done:
s.done = nil
s.proc = nil
_ = os.Remove(s.pidPath)
return nil
default:
time.Sleep(100 * time.Millisecond)
}
}
_ = s.proc.Kill()
select {
case <-s.done:
case <-time.After(2 * time.Second):
}
s.done = nil
s.proc = nil
_ = os.Remove(s.pidPath)
return nil
}
// Server is the wizard HTTP server, bound to the loopback interface only.
type Server struct {
log *slog.Logger
cfgPath string
logPath string
dir string
sup Supervisor
openBrowser func(string)
port int
}
// Options customises the wizard for tests and embedding.
type Options struct {
Port int
ConfigPath string
OpenBrowser func(url string)
Supervisor Supervisor
Dir string // directory for pid/log files; defaults to the config dir
}
func New(log *slog.Logger, opts Options) *Server {
cfgPath := opts.ConfigPath
if cfgPath == "" {
cfgPath = agent.DefaultConfigPath()
}
dir := opts.Dir
if dir == "" {
dir = filepath.Dir(cfgPath)
}
sup := opts.Supervisor
if sup == nil {
sup = NewPIDSupervisor(filepath.Join(dir, pidFileName))
}
open := opts.OpenBrowser
if open == nil {
open = func(string) {}
}
port := opts.Port
if port == 0 {
port = defaultPort
}
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port}
}
// Listen binds the loopback listener and returns it; Serve runs the server on
// it. Split so tests can inspect the actual ephemeral port.
func (s *Server) Listen() (net.Listener, error) {
return (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
}
// OpenBrowser hands the wizard URL to the configured opener (default: no-op).
func (s *Server) OpenBrowser(url string) { s.openBrowser(url) }
// Serve runs the wizard until ctx is cancelled.
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleIndex)
mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("POST /api/config", s.handleSaveConfig)
mux.HandleFunc("POST /api/test", s.handleTest)
mux.HandleFunc("POST /api/start", s.handleStart)
mux.HandleFunc("POST /api/stop", s.handleStop)
mux.HandleFunc("GET /api/logs", s.handleLogs)
server := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
return server.Serve(listener)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
html, err := templateFS.ReadFile("template.html")
if err != nil {
http.Error(w, "template unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(html)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// statusView is what the wizard needs to paint the running/stopped state.
type statusView struct {
ConfigPresent bool `json:"config_present"`
ConfigPath string `json:"config_path"`
LogPath string `json:"log_path"`
Running bool `json:"running"`
Pid int `json:"pid"`
WorkerName string `json:"worker_name,omitempty"`
Coordinator string `json:"coordinator,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
TokenSet bool `json:"token_set"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
view := statusView{ConfigPath: s.cfgPath, LogPath: s.logPath, Running: s.sup.Alive(), Pid: s.sup.Pid()}
if raw, err := os.ReadFile(s.cfgPath); err == nil {
var file agent.ConfigFile
if json.Unmarshal(raw, &file) == nil {
view.ConfigPresent = true
view.WorkerName = file.WorkerName
view.Coordinator = file.CoordinatorURL
view.WorkDir = file.WorkDir
view.TokenSet = file.Token != "" || file.WorkerKey != ""
}
}
writeJSON(w, http.StatusOK, view)
}
type saveConfigRequest struct {
CoordinatorURL string `json:"coordinator_url"`
Token string `json:"token"`
WorkerKey string `json:"worker_key"`
UserserviceURL string `json:"userservice_url"`
WorkDir string `json:"work_dir"`
WorkerName string `json:"worker_name"`
CPUCount int `json:"cpu_count"`
MemoryMB int `json:"memory_mb"`
TaskRunner []string `json:"task_runner"`
}
func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
var req saveConfigRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
file := agent.ConfigFile{
CoordinatorURL: strings.TrimSpace(req.CoordinatorURL),
Token: req.Token,
WorkerKey: req.WorkerKey,
UserserviceURL: strings.TrimSpace(req.UserserviceURL),
WorkDir: strings.TrimSpace(req.WorkDir),
WorkerName: strings.TrimSpace(req.WorkerName),
CPUCount: req.CPUCount,
MemoryMB: req.MemoryMB,
TaskRunner: req.TaskRunner,
}
if file.CoordinatorURL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
return
}
if !strings.HasPrefix(file.CoordinatorURL, "http://") && !strings.HasPrefix(file.CoordinatorURL, "https://") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url must be an absolute HTTP(S) URL"})
return
}
if file.WorkDir == "" {
file.WorkDir = "./scimesh-agent-data"
}
if file.WorkerName == "" {
if host, err := os.Hostname(); err == nil {
file.WorkerName = host
} else {
file.WorkerName = "worker"
}
}
if file.CPUCount < 1 {
file.CPUCount = 1
}
if err := agent.SaveConfigFile(s.cfgPath, file); err != nil {
s.log.Error("save wizard config", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not write the config file"})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"saved": true})
}
func (s *Server) handleTest(w http.ResponseWriter, r *http.Request) {
var req saveConfigRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
url := strings.TrimSpace(req.CoordinatorURL)
if url == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
return
}
report := agent.RunCheck(r.Context(), url)
writeJSON(w, http.StatusOK, report)
}
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
if _, err := os.Stat(s.cfgPath); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no configuration saved yet"})
return
}
pid, err := s.sup.Start(s.cfgPath, s.logPath)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]int{"pid": pid})
}
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
if err := s.sup.Stop(); err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"stopped": true})
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
raw, err := os.ReadFile(s.logPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"log": ""})
return
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
tail := 200
if n, err := strconv.Atoi(r.URL.Query().Get("tail")); err == nil && n > 0 && n < 5000 {
tail = n
}
if len(lines) > tail {
lines = lines[len(lines)-tail:]
}
writeJSON(w, http.StatusOK, map[string]string{"log": strings.Join(lines, "\n")})
}
// ErrCanceled mirrors context.Canceled for callers that treat a cancelled
// wizard as a clean exit.
var ErrCanceled = errors.New("setup wizard cancelled")
@@ -0,0 +1,293 @@
package setupui
import (
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
t.Helper()
dir := t.TempDir()
server := New(testLogger(), Options{
// A distinct random port per test: Port 0 means "the default 12700" in
// the server, which would let the shared http.Client pool reuse a stale
// keep-alive connection across tests (EOF after a Shutdown).
Port: freePort(t),
ConfigPath: filepath.Join(dir, "config.json"),
Dir: dir,
Supervisor: sup,
OpenBrowser: func(string) {},
})
listener, err := server.Listen()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go func() { _ = server.Serve(ctx, listener) }()
return server, "http://" + listener.Addr().String()
}
type fakeSup struct {
mu sync.Mutex
started bool
stopped bool
pid int
alive bool
}
func (f *fakeSup) Start(configPath, logPath string) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.started = true
f.alive = true
f.pid = 4242
return f.pid, nil
}
func (f *fakeSup) Stop() error {
f.mu.Lock()
defer f.mu.Unlock()
f.stopped = true
f.alive = false
return nil
}
func (f *fakeSup) Pid() int { f.mu.Lock(); defer f.mu.Unlock(); return f.pid }
func (f *fakeSup) Alive() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.alive
}
func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, base+path, strings.NewReader(mustJSON(t, body)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// No keep-alive pooling: a pooled connection to a shut-down test server
// would surface as an EOF instead of a fresh dial.
client := http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
rec := httptest.NewRecorder()
rec.Code = resp.StatusCode
data := map[string]any{}
_ = json.NewDecoder(resp.Body).Decode(&data)
return rec, data
}
// freePort reserves an ephemeral port and returns it. The listener is closed
// immediately; the tiny reuse window is acceptable for tests and each test
// gets a different port, so nothing can collide or share pooled connections.
func freePort(t *testing.T) int {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close()
return port
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestWizardSavesConfigWithStrictPermissions(t *testing.T) {
sup := &fakeSup{}
server, base := newTestServer(t, sup)
rec, _ := postJSON(t, base, "/api/config", map[string]any{
"coordinator_url": "http://192.168.1.10:8080",
"token": "sm_live_secret",
"work_dir": "/home/emil/scimesh-worker",
"worker_name": "emil-laptop",
"cpu_count": 8,
"memory_mb": 16384,
})
if rec.Code != http.StatusOK {
t.Fatalf("save config: got %d, want 200", rec.Code)
}
info, err := os.Stat(server.cfgPath)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("config perms = %o, want 600", perm)
}
config, err := agent.LoadConfigFile(server.cfgPath)
if err != nil {
t.Fatal(err)
}
if config.CoordinatorURL != "http://192.168.1.10:8080" || config.Token != "sm_live_secret" || config.WorkDir != "/home/emil/scimesh-worker" || config.WorkerName != "emil-laptop" {
t.Errorf("config = %+v", config)
}
if config.CPUCount != 8 || config.MemoryMB != 16384 {
t.Errorf("resources: cpu=%d mem=%d", config.CPUCount, config.MemoryMB)
}
}
func TestWizardRejectsInvalidConfig(t *testing.T) {
sup := &fakeSup{}
_, base := newTestServer(t, sup)
for _, body := range []map[string]any{
{"coordinator_url": "", "token": "x"},
{"coordinator_url": "not-a-url", "token": "x"},
} {
rec, _ := postJSON(t, base, "/api/config", body)
if rec.Code != http.StatusBadRequest {
t.Errorf("body %v: got %d, want 400", body, rec.Code)
}
}
}
func TestWizardStartStopLifecycle(t *testing.T) {
sup := &fakeSup{}
_, base := newTestServer(t, sup)
// Starting without a saved config is rejected.
rec, _ := postJSON(t, base, "/api/start", map[string]any{})
if rec.Code != http.StatusBadRequest {
t.Errorf("start without config: got %d, want 400", rec.Code)
}
postJSON(t, base, "/api/config", map[string]any{
"coordinator_url": "http://127.0.0.1:8080", "token": "t", "work_dir": ".",
})
rec, data := postJSON(t, base, "/api/start", map[string]any{})
if rec.Code != http.StatusOK || int(data["pid"].(float64)) != 4242 {
t.Errorf("start: got %d %v, want 200 pid 4242", rec.Code, data)
}
if !sup.started {
t.Error("supervisor never started the worker")
}
// Status reflects the running state.
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var status map[string]any
_ = json.NewDecoder(resp.Body).Decode(&status)
if status["running"] != true || status["pid"] != float64(4242) {
t.Errorf("status = %v, want running pid 4242", status)
}
rec, _ = postJSON(t, base, "/api/stop", map[string]any{})
if rec.Code != http.StatusOK || !sup.stopped {
t.Errorf("stop: got %d stopped=%v, want 200/true", rec.Code, sup.stopped)
}
}
func TestWizardStatusPrefillsSavedConfig(t *testing.T) {
sup := &fakeSup{}
_, base := newTestServer(t, sup)
postJSON(t, base, "/api/config", map[string]any{
"coordinator_url": "http://10.0.0.5:8080", "worker_key": "smk_abc", "work_dir": "/w", "worker_name": "n1",
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var status struct {
ConfigPresent bool `json:"config_present"`
WorkerName string `json:"worker_name"`
Coordinator string `json:"coordinator"`
TokenSet bool `json:"token_set"`
}
_ = json.NewDecoder(resp.Body).Decode(&status)
if !status.ConfigPresent || status.WorkerName != "n1" || status.Coordinator != "http://10.0.0.5:8080" || !status.TokenSet {
t.Errorf("status = %+v", status)
}
// The secret must never appear in the status projection.
if strings.Contains(strings.ToLower(mustJSON(t, status)), "smk_abc") {
t.Error("status leaks the worker key")
}
}
func TestCheckCoordinatorReachable(t *testing.T) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
_, _ = w.Write([]byte(`{"status":"ok"}`))
return
}
http.NotFound(w, r)
}))
defer stub.Close()
report := agent.CheckCoordinator(context.Background(), stub.URL, 5*time.Second)
if !report.Coordinator.OK {
t.Errorf("coordinator check = %+v, want ok", report.Coordinator)
}
}
func TestCheckCoordinatorUnreachable(t *testing.T) {
report := agent.CheckCoordinator(context.Background(), "http://127.0.0.1:1", 2*time.Second)
if report.Coordinator.OK {
t.Error("unreachable coordinator reported ok")
}
}
func TestConfigFileDefaultsAndEnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
file := agent.ConfigFile{
CoordinatorURL: "http://coord:8080",
Token: "file-token",
WorkDir: "/w",
CPUCount: 4,
}
if err := agent.SaveConfigFile(path, file); err != nil {
t.Fatal(err)
}
t.Setenv("COORDINATOR_URL", "http://env:9090")
t.Setenv("WORKER_AUTH_TOKEN", "")
config, err := agent.LoadConfigFile(path)
if err != nil {
t.Fatal(err)
}
if config.CoordinatorURL != "http://env:9090" {
t.Errorf("env must win: %s", config.CoordinatorURL)
}
if config.Token != "file-token" {
t.Errorf("token = %q, want the file value", config.Token)
}
if config.CPUCount != 4 {
t.Errorf("cpu = %d", config.CPUCount)
}
}
@@ -0,0 +1,310 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh Worker · Setup</title>
<style>
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
*{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(900px 500px at 50% -180px,#16233d66,transparent),var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px 13px;width:100%;outline:none;transition:border-color .12s,box-shadow .12s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
input::placeholder{color:var(--text-3)}
code{font-family:var(--mono);font-size:.86em}
.shell{max-width:660px;margin:0 auto;padding:44px 22px 70px}
.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:8px}
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 16px #5b8cff40}
.brand-mark svg{width:18px;height:18px;stroke:#fff}
.brand-name{font-weight:700;font-size:16px;letter-spacing:-.01em}
.brand-name span{color:var(--text-3);font-weight:500}
.tagline{text-align:center;color:var(--text-3);font-size:12.5px;margin-bottom:34px}
.tagline code{color:var(--text-2)}
.steps{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:30px}
.step{display:flex;flex-direction:column;align-items:center;gap:7px;width:96px}
.step-dot{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border);background:var(--panel);color:var(--text-3);font-size:12.5px;font-weight:700;transition:all .2s}
.step-label{font-size:11px;font-weight:600;color:var(--text-3);letter-spacing:.02em}
.step.active .step-dot{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);box-shadow:0 0 0 4px #5b8cff14}
.step.active .step-label{color:var(--text)}
.step.done .step-dot{border-color:var(--green);background:var(--green-soft);color:var(--green)}
.step.done .step-label{color:var(--text-2)}
.step-line{flex:1;max-width:44px;height:1.5px;background:var(--border);margin:0 6px 22px;position:relative;overflow:hidden}
.step-line.done:after{content:"";position:absolute;inset:0;background:var(--green)}
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:15px;padding:26px 28px;box-shadow:0 24px 60px #0000004d}
.card h1{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
.card .sub{color:var(--text-2);font-size:13px;margin-bottom:22px}
.field{margin-bottom:16px}
.field label{display:block;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin-bottom:7px}
.field .hint{margin-top:6px;font-size:12px;color:var(--text-3)}
.field .hint code{color:var(--text-2)}
.radio-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.radio-card{border:1px solid var(--border);border-radius:11px;padding:13px 14px;cursor:pointer;transition:all .13s;background:var(--panel-2)}
.radio-card:hover{border-color:#2a3446}
.radio-card.sel{border-color:var(--accent);background:var(--accent-soft);box-shadow:0 0 0 3px #5b8cff14}
.radio-card b{display:flex;align-items:center;gap:8px;font-size:13.5px}
.radio-card b svg{width:15px;height:15px;stroke:var(--accent)}
.radio-card p{margin-top:4px;font-size:12px;color:var(--text-2)}
.check-row{display:flex;align-items:center;gap:12px;padding:11px 14px;border:1px solid var(--border-soft);border-radius:10px;margin-bottom:9px;background:var(--panel-2)}
.check-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;flex:none}
.check-ic svg{width:13px;height:13px;stroke-width:2.6}
.check-ok{background:var(--green-soft)}.check-ok svg{stroke:var(--green)}
.check-bad{background:var(--red-soft)}.check-bad svg{stroke:var(--red)}
.check-wait{background:#ffffff10}.check-wait svg{stroke:var(--text-3)}
.check-row b{font-size:13.5px;font-weight:600}
.check-row span{display:block;font-size:12px;color:var(--text-3)}
.check-row .ms{margin-left:auto;font:11.5px var(--mono);color:var(--text-3)}
.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:24px}
.btn{display:inline-flex;align-items:center;gap:8px;border-radius:9px;padding:10px 18px;font-weight:650;font-size:13.5px;border:1px solid transparent;transition:all .13s}
.btn svg{width:15px;height:15px;stroke:currentColor}
.btn-primary{background:var(--accent);color:#0a1222}
.btn-primary:hover{background:var(--accent-strong);color:#fff}
.btn-ghost{border-color:var(--border);color:var(--text-2);background:var(--panel-2)}
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
.btn-danger{background:var(--red-soft);color:var(--red)}
.btn-lg{padding:12px 24px;font-size:14.5px;border-radius:10px}
.link{color:var(--text-3);font-size:13px}
.link:hover{color:var(--text)}
.status-head{display:flex;align-items:center;gap:14px;margin-bottom:22px}
.pulse{position:relative;width:12px;height:12px;border-radius:50%;background:var(--green);flex:none}
.pulse:after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--green);opacity:.5;animation:ping 1.6s ease-out infinite}
@keyframes ping{from{transform:scale(.6);opacity:.7}to{transform:scale(1.4);opacity:0}}
.status-head h1{font-size:19px}
.status-head .sub{font-size:12.5px;color:var(--text-3)}
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
.stat{background:var(--panel-2);border:1px solid var(--border-soft);border-radius:11px;padding:12px 14px}
.stat b{display:block;font-size:20px;font-weight:700;letter-spacing:-.02em}
.stat span{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3)}
.stat.bad b{color:var(--red)}
.logbox{background:#0a0d12;border:1px solid var(--border-soft);border-radius:11px;padding:14px 16px;font:12px/1.7 var(--mono);color:#8fa3bf;max-height:210px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
.meta-line{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:99px;padding:4px 11px;font-size:12px;color:var(--text-2);background:var(--panel-2)}
.chip svg{width:12px;height:12px;stroke:var(--text-3)}
.wizard-page{display:none}.wizard-page.active{display:block;animation:fade .18s ease}
@keyframes fade{from{opacity:0;transform:translateY(5px)}to{opacity:1}}
.error-strip{background:var(--red-soft);border:1px solid #f2647c33;border-radius:9px;padding:9px 12px;font-size:12.5px;color:#ffb3c0;margin-bottom:14px}
.spinner{display:inline-block;width:13px;height:13px;border:2px solid var(--text-3);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;vertical-align:-2px;margin-right:7px}
@keyframes spin{to{transform:rotate(360deg)}}
.checks{padding-bottom:6px}
</style>
</head>
<body>
<div class="shell">
<div class="brand">
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
<div class="brand-name">SciMesh <span>· Worker setup</span></div>
</div>
<p class="tagline">Local wizard served by <code>worker-agent setup</code> · <code>127.0.0.1</code></p>
<!-- ═══ WIZARD VIEW ═══ -->
<div id="view-wizard">
<div class="steps" id="steps">
<div class="step active" id="st1"><div class="step-dot">1</div><div class="step-label">Connect</div></div>
<div class="step-line" id="sl1"></div>
<div class="step" id="st2"><div class="step-dot">2</div><div class="step-label">Machine</div></div>
<div class="step-line" id="sl2"></div>
<div class="step" id="st3"><div class="step-dot">3</div><div class="step-label">Check</div></div>
<div class="step-line" id="sl3"></div>
<div class="step" id="st4"><div class="step-dot">4</div><div class="step-label">Run</div></div>
</div>
<div id="error-box"></div>
<!-- step 1: connect -->
<div class="wizard-page active" id="wp1">
<div class="card">
<h1>Connect to a coordinator</h1>
<p class="sub">The coordinator hands out work and collects results. Ask your cluster admin for its address.</p>
<div class="field"><label>Coordinator URL</label><input id="in-url" placeholder="http://192.168.1.10:8080"><p class="hint">For a served instance this is the address printed by <code>coordinator serve</code>.</p></div>
<div class="field"><label>Authentication</label>
<div class="radio-grid" id="auth-grid">
<div class="radio-card sel" data-mode="token"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Cluster token</b><p>Serve instances: one token for every worker.</p></div>
<div class="radio-card" data-mode="key"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="8" cy="14" r="4"/><path d="M10.8 11.2L20 2M15 4l3 3"/></svg>Worker key</b><p>Shared clusters: a key tied to your account.</p></div>
</div>
</div>
<div class="field" id="token-field"><label>Token</label><input id="in-token" type="password" placeholder="paste the token from coordinator token"><p class="hint">The admin can copy it from <code>coordinator token</code> on the server.</p></div>
<div class="field" id="key-field" style="display:none"><label>Worker key + userservice URL</label><input id="in-key" type="password" placeholder="smk_…"><input id="in-users" placeholder="http://userservice-host:8081" style="margin-top:8px"><p class="hint">Create a key in the coordinator UI: Users → worker keys.</p></div>
<div class="actions"><span class="link" id="l1">Step 1 of 4</span><button class="btn btn-primary" id="b1">Continue →</button></div>
</div>
</div>
<!-- step 2: machine -->
<div class="wizard-page" id="wp2">
<div class="card">
<h1>This machine</h1>
<p class="sub">Where tasks run and how the machine appears in the cluster.</p>
<div class="field"><label>Worker name</label><input id="in-name" placeholder="auto-detected"><p class="hint">Shown in the coordinators worker list.</p></div>
<div class="field"><label>Work directory</label><input id="in-dir" placeholder="./scimesh-agent-data"><p class="hint">Datasets and shard results live here. ~1 GB free space recommended.</p></div>
<div class="field"><label>Compute resources advertised</label>
<div class="radio-grid">
<div class="radio-card sel" data-cpu="auto"><b>Auto</b><p>Detect the machines CPU count.</p></div>
<div class="radio-card" data-cpu="custom"><b>Custom…</b><p>Limit what this machine advertises.</p></div>
</div>
</div>
<div class="field" id="cpu-field" style="display:none"><label>CPU count</label><input id="in-cpu" type="number" min="1" value="1"></div>
<div class="actions"><button class="btn btn-ghost" id="b2b">← Back</button><button class="btn btn-primary" id="b2">Continue →</button></div>
</div>
</div>
<!-- step 3: preflight -->
<div class="wizard-page" id="wp3">
<div class="card">
<h1>Preflight check</h1>
<p class="sub">Making sure this machine can reach the coordinator and run SciMesh workloads.</p>
<div class="checks" id="checks"></div>
<div class="actions"><button class="btn btn-ghost" id="b3b">← Back</button><button class="btn btn-primary" id="b3" disabled>Continue anyway →</button></div>
</div>
</div>
<!-- step 4: run -->
<div class="wizard-page" id="wp4">
<div class="card" style="text-align:center;padding:40px 28px">
<div class="brand-mark" style="margin:0 auto 18px;width:46px;height:46px;border-radius:13px"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" style="width:22px;height:22px"><path d="M6 4l14 8-14 8V4z"/></svg></div>
<h1 style="font-size:20px">Ready to join the cluster</h1>
<p class="sub" style="max-width:380px;margin:8px auto 26px">Configuration will be saved and the worker started as a background process.</p>
<div class="actions" style="justify-content:space-between;margin-top:30px"><button class="btn btn-ghost" id="b4b">← Back</button><button class="btn btn-primary btn-lg" id="b4">Start worker</button></div>
</div>
</div>
</div>
<!-- ═══ STATUS VIEW ═══ -->
<div id="view-status" style="display:none">
<div class="card">
<div class="status-head">
<div class="pulse" id="st-pulse"></div>
<div><h1 id="st-title">Worker is working</h1><div class="sub" id="st-sub"></div></div>
<button class="btn btn-danger" id="st-stop" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
</div>
<div class="meta-line" id="st-meta"></div>
<div class="logbox" id="st-log"></div>
<div class="actions"><span class="link" id="st-cfg"></span><button class="btn btn-ghost" id="st-reconfig">Reconfigure…</button></div>
</div>
</div>
</div>
<script>
const $=id=>document.getElementById(id);
let state={mode:'token',cpu:'auto'};
let checksOk=false;
function err(msg){$('error-box').innerHTML=msg?'<div class="error-strip">'+msg+'</div>':''}
function goto(n){
['wp1','wp2','wp3','wp4'].forEach((id,i)=>$(id).classList.toggle('active',i===n-1));
for(let i=1;i<=4;i++){
const st=$('st'+i);
st.classList.toggle('done',i<n);st.classList.toggle('active',i===n);
st.querySelector('.step-dot').textContent=i<n?'':i;
if(i<4)$('sl'+i).classList.toggle('done',i<n);
}
err('');
}
document.querySelectorAll('#auth-grid .radio-card').forEach(c=>c.addEventListener('click',()=>{
document.querySelectorAll('#auth-grid .radio-card').forEach(x=>x.classList.remove('sel'));
c.classList.add('sel');state.mode=c.dataset.mode;
$('token-field').style.display=state.mode==='token'?'':'none';
$('key-field').style.display=state.mode==='key'?'':'none';
}));
document.querySelectorAll('#wp2 .radio-card').forEach(c=>c.addEventListener('click',()=>{
c.parentElement.querySelectorAll('.radio-card').forEach(x=>x.classList.remove('sel'));
c.classList.add('sel');state.cpu=c.dataset.cpu;
$('cpu-field').style.display=state.cpu==='custom'?'':'none';
}));
async function postJSON(path,body){
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const data=await r.json().catch(()=>({}));
return {status:r.status,data};
}
function draftConfig(){
return {
coordinator_url:$('in-url').value.trim(),
token:state.mode==='token'?$('in-token').value.trim():'',
worker_key:state.mode==='key'?$('in-key').value.trim():'',
userservice_url:state.mode==='key'?$('in-users').value.trim():'',
work_dir:$('in-dir').value.trim(),
worker_name:$('in-name').value.trim(),
cpu_count:state.cpu==='custom'?parseInt($('in-cpu').value||'1',10):0
};
}
$('b1').onclick=()=>{
const c=draftConfig();
if(!c.coordinator_url){err('Enter the coordinator URL.');return}
if(state.mode==='token'&&!c.token){err('Enter the cluster token.');return}
if(state.mode==='key'&&!c.worker_key){err('Enter the worker key.');return}
goto(2);
};
$('b2b').onclick=()=>goto(1);
$('b2').onclick=()=>{goto(3);runChecks()};
$('b3b').onclick=()=>goto(2);
$('b3').onclick=()=>goto(4);
$('b4b').onclick=()=>goto(3);
$('b4').onclick=async()=>{
const c=draftConfig();
const r=await postJSON('/api/config',c);
if(r.status!==200){err('Could not save the configuration: '+(r.data.error||'unknown error'));return}
const s=await postJSON('/api/start',{});
if(s.status!==200){err('Could not start the worker: '+(s.data.error||'unknown error'));return}
showStatus();
};
const checkRow=(name,ok,detail,ms)=>
'<div class="check-row"><div class="check-ic '+(ok===null?'check-wait':ok?'check-ok':'check-bad')+'"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round">'+(ok===null?'<path d="M12 7v5l3 3"/>':ok?'<path d="M4 12l5 5L20 6"/>':'<path d="M6 6l12 12M18 6L6 18"/>')+'</svg></div><div><b>'+name+'</b><span>'+(detail||'')+'</span></div>'+(ms?'<span class="ms">'+ms+' ms</span>':'')+'</div>';
async function runChecks(){
const box=$('checks');
box.innerHTML=checkRow('Coordinator reachable','',null,null)+checkRow('Python 3','',null,null)+checkRow('scimesh package','',null,null);
const r=await postJSON('/api/test',draftConfig());
box.innerHTML='';
checksOk=true;
const items=[r.data.coordinator,r.data.python,r.data.scimesh];
for(const item of items){
if(item&&!item.ok)checksOk=false;
box.insertAdjacentHTML('beforeend',checkRow(item?item.name:'?',item?item.ok:null,item?item.detail:'',item?item.latency_ms:null));
}
$('b3').disabled=!checksOk;
}
async function showStatus(){
$('view-wizard').style.display='none';
$('view-status').style.display='block';
await refreshStatus();
setInterval(refreshStatus,2000);
}
async function refreshStatus(){
const r=await fetch('/api/status');
if(!r.ok)return;
const v=await r.json();
$('st-pulse').style.background=v.running?'var(--green)':'var(--text-3)';
$('st-pulse').style.animation=v.running?'':'none';
$('st-title').textContent=v.running?(v.worker_name||'Worker')+' is working':(v.worker_name||'Worker')+' is stopped';
$('st-sub').textContent='pid '+(v.pid||'—')+' · config '+(v.config_present?v.config_path:'not saved yet');
$('st-cfg').textContent='Configuration: '+(v.config_present?v.config_path:'—');
const meta=$('st-meta');
meta.innerHTML='';
if(v.coordinator){const c=document.createElement('span');c.className='chip';c.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>'+v.coordinator;meta.append(c)}
if(v.work_dir){const d=document.createElement('span');d.className='chip';d.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>'+v.work_dir;meta.append(d)}
if(!v.token_set){const t=document.createElement('span');t.className='chip';t.style.color='var(--amber)';t.textContent='no credential set';meta.append(t)}
const logs=await fetch('/api/logs?tail=200');
const lv=await logs.json();
$('st-log').textContent=lv.log||'(no log yet — the worker writes here once started)';
}
$('st-stop').onclick=async()=>{await postJSON('/api/stop',{});refreshStatus()};
$('st-reconfig').onclick=()=>{
$('view-status').style.display='none';
$('view-wizard').style.display='block';
goto(1);
};
// Prefill from a saved configuration, then decide which view to show.
(async()=>{
const r=await fetch('/api/status');
const v=await r.json();
if(v.config_present){
$('in-url').value=v.coordinator||'';
$('in-dir').value=v.work_dir||'';
$('in-name').value=v.worker_name||'';
}
if(v.running)showStatus();
})();
</script>
</body>
</html>
+1
View File
@@ -18,4 +18,5 @@ var (
ErrResultConflict = errors.New("different result already recorded")
ErrInvalidInput = errors.New("invalid input")
ErrTaskNotLeased = errors.New("task is not currently leased")
ErrWorkloadDisabled = errors.New("workload is disabled")
)
+17 -3
View File
@@ -8,6 +8,7 @@ import (
"io/fs"
"math"
"os"
"path/filepath"
"strconv"
"time"
@@ -82,6 +83,12 @@ type Config struct {
// 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
// DatabaseEngine selects the storage backend: "sqlite" (embedded, the
// single-binary default) or "postgres" (cluster deployments). The
// postgres engine requires DATABASE_URL.
DatabaseEngine string
// DBPath is the sqlite database file (engine=sqlite only).
DBPath string
}
// Load reads the environment and fails fast on anything required-but-missing
@@ -128,8 +135,16 @@ func LoadConfig() (Config, error) {
WorkerOfflineAfter: 1 * time.Minute,
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
cfg.DatabaseEngine = getEnv("SCIMESH_DB", "sqlite")
switch cfg.DatabaseEngine {
case "sqlite", "postgres":
default:
return Config{}, fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
}
cfg.DBPath = getEnv("SCIMESH_DB_PATH", filepath.Join(cfg.StorageDir, "scimesh.db"))
if cfg.DatabaseEngine == "postgres" && cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required for the postgres engine")
}
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
@@ -185,7 +200,6 @@ func LoadConfig() (Config, error) {
}
cfg.AutoMigrate = parsed
}
return cfg, nil
}
+11
View File
@@ -314,6 +314,17 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
return n, nil
}
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
r.mu.Lock()
defer r.mu.Unlock()
w, ok := r.workers[id]
if !ok {
return domain.ErrWorkerNotFound
}
w.TrustLevel = trust
return nil
}
// --- ArtifactRepo --------------------------------------------------------
type ArtifactRepo struct {
@@ -0,0 +1,198 @@
package postgres
import (
"context"
"fmt"
"time"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
// counters, metrics buckets and storage figures. Read-only.
type AdminReadRepo struct{ pool *pgxpool.Pool }
func NewAdminReadRepo(pool *pgxpool.Pool) *AdminReadRepo { return &AdminReadRepo{pool: pool} }
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
if limit < 1 || limit > 100 || offset < 0 {
return nil, 0, domain.ErrInvalidInput
}
countQ := psql.Select("COUNT(*)").From("jobs")
listQ := psql.Select(jobColumns...).From("jobs")
if status != "" {
countQ = countQ.Where(sq.Eq{"status": status})
listQ = listQ.Where(sq.Eq{"status": status})
}
countSQL, args, err := countQ.ToSql()
if err != nil {
return nil, 0, err
}
var total int
if err := conn(ctx, r.pool).QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count jobs: %w", err)
}
listSQL, args, err := listQ.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).Offset(uint64(offset)).ToSql()
if err != nil {
return nil, 0, err
}
rows, err := conn(ctx, r.pool).Query(ctx, listSQL, args...)
if err != nil {
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
}
defer rows.Close()
jobs := make([]domain.Job, 0)
for rows.Next() {
var j domain.Job
var statusRaw string
if err := rows.Scan(
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &statusRaw, &j.CreatedAt, &j.CompletedAt,
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
&j.OwnerID,
); err != nil {
return nil, 0, err
}
j.Status = domain.JobStatus(statusRaw)
jobs = append(jobs, j)
}
return jobs, total, rows.Err()
}
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
if err != nil {
return nil, fmt.Errorf("count jobs by status: %w", err)
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, err
}
out[status] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
if len(jobIDs) == 0 {
return out, nil
}
sql, args, err := psql.Select("job_id", "status", "COUNT(*)").From("tasks").
Where(sq.Eq{"job_id": jobIDs}).GroupBy("job_id", "status").ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("task counts by jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
var jobID uuid.UUID
var status string
var count int
if err := rows.Scan(&jobID, &status, &count); err != nil {
return nil, err
}
if out[jobID] == nil {
out[jobID] = make(map[string]int)
}
out[jobID][status] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
rows, err := conn(ctx, r.pool).Query(ctx,
"SELECT to_char(date_trunc('day', created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day, COUNT(*) FROM jobs WHERE created_at >= $1 GROUP BY 1",
since.UTC())
if err != nil {
return nil, fmt.Errorf("job counts by day: %w", err)
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var day string
var count int
if err := rows.Scan(&day, &count); err != nil {
return nil, err
}
out[day] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
if err != nil {
return nil, fmt.Errorf("job counts by workload: %w", err)
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var workload string
var count int
if err := rows.Scan(&workload, &count); err != nil {
return nil, err
}
out[workload] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
var completed, failed int64
var avgSeconds *float64
err := conn(ctx, r.pool).QueryRow(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL
THEN EXTRACT(EPOCH FROM completed_at - started_at) END)::float8
FROM tasks`).Scan(&completed, &failed, &avgSeconds)
if err != nil {
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
}
var avg float64
if avgSeconds != nil {
avg = *avgSeconds
}
return completed, failed, avg, nil
}
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
if err != nil {
return nil, fmt.Errorf("artifact sizes: %w", err)
}
defer rows.Close()
out := make(map[string]int64)
for rows.Next() {
var kind string
var size int64
if err := rows.Scan(&kind, &size); err != nil {
return nil, err
}
out[kind] = size
}
return out, rows.Err()
}
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
var size int64
if err := conn(ctx, r.pool).QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil {
return 0, fmt.Errorf("database size: %w", err)
}
return size, nil
}
@@ -145,20 +145,20 @@ func TestUIReadRepoListsReducerFields(t *testing.T) {
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
}
listed, err := NewUIReadRepo(pool).ListJobs(ctx, nil, 20)
listed, _, err := NewAdminReadRepo(pool).ListJobsPaginated(ctx, "", 20, 0)
if err != nil {
t.Fatalf("list UI jobs: %v", err)
t.Fatalf("list admin jobs: %v", err)
}
for _, item := range listed {
if item.ID != job.ID {
continue
}
if item.Status != domain.JobReducing || item.ReducerStartedAt == nil {
t.Fatalf("UI reducer projection = %+v", item)
t.Fatalf("admin reducer projection = %+v", item)
}
return
}
t.Fatalf("seeded job %s is missing from UI list", job.ID)
t.Fatalf("seeded job %s is missing from the admin list", job.ID)
}
// A job must land whole or not at all: a half-created job leaves chunks no
@@ -54,6 +54,8 @@ func expectedMigrationName(version int) string {
return "0012_worker_trust.up.sql"
case 13:
return "0013_task_results.up.sql"
case 14:
return "0014_workload_settings.up.sql"
default:
return ""
}
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS workload_settings;
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
-- Per-workload enable/disable. Absence of a row means "enabled" (the catalog
-- default); a row only exists once an admin flipped a workload off or back on.
CREATE TABLE workload_settings (
workload text NOT NULL PRIMARY KEY,
enabled boolean NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMIT;
@@ -24,40 +24,6 @@ func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, err
return job, err
}
func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
q := psql.Select(jobColumns...).From("jobs")
if owner != nil {
q = q.Where(sq.Eq{"owner_id": *owner})
}
sql, args, err := q.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("list jobs: %w", err)
}
defer rows.Close()
jobs := make([]domain.Job, 0)
for rows.Next() {
var j domain.Job
var status string
if err := rows.Scan(
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
&j.OwnerID,
); err != nil {
return nil, err
}
j.Status = domain.JobStatus(status)
jobs = append(jobs, j)
}
return jobs, rows.Err()
}
func (r *UIReadRepo) ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error) {
sql, args, err := psql.Select(taskColumns...).From("tasks").Where(sq.Eq{"job_id": jobID}).OrderBy("chunk_index ASC").ToSql()
if err != nil {
@@ -79,68 +45,18 @@ func (r *UIReadRepo) ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]dom
return tasks, rows.Err()
}
func (r *UIReadRepo) ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error) {
out := make(map[uuid.UUID][]domain.Task, len(jobIDs))
if len(jobIDs) == 0 {
return out, nil
}
sql, args, err := psql.Select(taskColumns...).From("tasks").
Where(sq.Eq{"job_id": jobIDs}).OrderBy("job_id ASC", "chunk_index ASC").ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("list tasks by jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
task, err := scanTask(rows)
if err != nil {
return nil, err
}
out[task.JobID] = append(out[task.JobID], *task)
}
return out, rows.Err()
}
func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
sql, args, err := psql.Select(workerColumns...).From("workers").OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("list workers: %w", err)
}
defer rows.Close()
workers := make([]domain.Worker, 0)
for rows.Next() {
worker, err := scanWorker(rows)
if err != nil {
return nil, err
}
workers = append(workers, *worker)
}
return workers, rows.Err()
}
func (r *UIReadRepo) ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
sql, args, err := psql.Select(workerColumns...).From("workers").
Where(sq.Eq{"owner_id": owner}).
OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("list workers by owner: %w", err)
return nil, fmt.Errorf("list workers: %w", err)
}
defer rows.Close()
workers := make([]domain.Worker, 0)
@@ -91,6 +91,24 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
return tag.RowsAffected(), nil
}
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
sql, args, err := psql.Update("workers").
SetMap(map[string]any{"trust_level": string(trust), "updated_at": time.Now()}).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
if err != nil {
return fmt.Errorf("set worker trust: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrWorkerNotFound
}
return nil
}
func scanWorker(row pgx.Row) (*domain.Worker, error) {
var (
w domain.Worker
@@ -0,0 +1,67 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
// Absence of a row means the workload is enabled (the catalog default).
type WorkloadSettingsRepo struct{ pool *pgxpool.Pool }
func NewWorkloadSettingsRepo(pool *pgxpool.Pool) *WorkloadSettingsRepo {
return &WorkloadSettingsRepo{pool: pool}
}
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
var enabled bool
err := conn(ctx, r.pool).QueryRow(ctx,
"SELECT enabled FROM workload_settings WHERE workload = $1", workload).Scan(&enabled)
if err != nil && err.Error() == "no rows in result set" {
return true, nil // no override: catalog default enabled
}
if err != nil {
return false, fmt.Errorf("get workload setting: %w", err)
}
return enabled, nil
}
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
rows, err := conn(ctx, r.pool).Query(ctx,
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
if err != nil {
return nil, fmt.Errorf("list workload settings: %w", err)
}
defer rows.Close()
var out []usecase.WorkloadSetting
for rows.Next() {
var s usecase.WorkloadSetting
if err := rows.Scan(&s.Workload, &s.Enabled, &s.UpdatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
sql, args, err := psql.Insert("workload_settings").
Columns("workload", "enabled", "updated_at").
Values(workload, enabled, now).
Suffix(`ON CONFLICT (workload) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at`).
ToSql()
if err != nil {
return err
}
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
return fmt.Errorf("set workload setting: %w", err)
}
return nil
}
@@ -0,0 +1,90 @@
package sqlite
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
func TestWorkloadSettingsRepoRoundTrip(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewWorkloadSettingsRepo(db)
// No override: enabled by default.
enabled, err := repo.GetEnabled(ctx, "similarity-search")
if err != nil {
t.Fatal(err)
}
if !enabled {
t.Error("workload without an override must be enabled")
}
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
if err := repo.SetEnabled(ctx, "similarity-search", false, now); err != nil {
t.Fatal(err)
}
enabled, err = repo.GetEnabled(ctx, "similarity-search")
if err != nil {
t.Fatal(err)
}
if enabled {
t.Error("workload must be disabled after the override")
}
// Upsert flips it back and updates the timestamp.
later := now.Add(time.Hour)
if err := repo.SetEnabled(ctx, "similarity-search", true, later); err != nil {
t.Fatal(err)
}
enabled, err = repo.GetEnabled(ctx, "similarity-search")
if err != nil {
t.Fatal(err)
}
if !enabled {
t.Error("workload must be re-enabled after the upsert")
}
list, err := repo.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Workload != "similarity-search" || !list[0].Enabled {
t.Errorf("list = %+v, want the single re-enabled override", list)
}
}
func TestWorkerSetTrust(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewWorkerRepo(db)
worker, err := domain.NewWorker("lab-node", []string{"similarity-search"}, fixedTime())
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, worker); err != nil {
t.Fatal(err)
}
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerUntrusted); err != nil {
t.Fatal(err)
}
got, err := repo.Get(ctx, worker.ID)
if err != nil {
t.Fatal(err)
}
if got.TrustLevel != domain.WorkerUntrusted {
t.Errorf("trust = %q, want untrusted", got.TrustLevel)
}
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerTrusted); err != nil {
t.Fatal(err)
}
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("unknown worker trust err = %v, want ErrWorkerNotFound", err)
}
}
@@ -0,0 +1,191 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
// counters, metrics buckets and storage figures. Read-only.
type AdminReadRepo struct{ db *sql.DB }
func NewAdminReadRepo(db *sql.DB) *AdminReadRepo { return &AdminReadRepo{db: db} }
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
if limit < 1 || limit > 100 || offset < 0 {
return nil, 0, domain.ErrInvalidInput
}
where := ""
args := []any{}
if status != "" {
where = " WHERE status = ?"
args = append(args, status)
}
var total int
if err := conn(ctx, r.db).QueryRowContext(ctx, "SELECT COUNT(*) FROM jobs"+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count jobs: %w", err)
}
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT "+jobColumns+" FROM jobs"+where+" ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
append(args, limit, offset)...)
if err != nil {
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
}
defer func() { _ = rows.Close() }()
jobs := make([]domain.Job, 0)
for rows.Next() {
job, err := scanJob(rows)
if err != nil {
return nil, 0, err
}
jobs = append(jobs, *job)
}
return jobs, total, rows.Err()
}
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
if err != nil {
return nil, fmt.Errorf("count jobs by status: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int)
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, err
}
out[status] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
if len(jobIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(jobIDs))
args := make([]any, 0, len(jobIDs))
for _, id := range jobIDs {
placeholders = append(placeholders, "?")
args = append(args, id.String())
}
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT job_id, status, COUNT(*) FROM tasks WHERE job_id IN ("+strings.Join(placeholders, ", ")+") GROUP BY job_id, status",
args...)
if err != nil {
return nil, fmt.Errorf("task counts by jobs: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var jobRaw, status string
var count int
if err := rows.Scan(&jobRaw, &status, &count); err != nil {
return nil, err
}
jobID, err := uuid.Parse(jobRaw)
if err != nil {
return nil, fmt.Errorf("task counts: parse job id: %w", err)
}
if out[jobID] == nil {
out[jobID] = make(map[string]int)
}
out[jobID][status] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
// created_at is unix nanos; the bucket is the UTC calendar day.
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT strftime('%Y-%m-%d', created_at / 1000000000, 'unixepoch') AS day, COUNT(*) FROM jobs WHERE created_at >= ? GROUP BY day",
since.UTC().UnixNano())
if err != nil {
return nil, fmt.Errorf("job counts by day: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int)
for rows.Next() {
var day string
var count int
if err := rows.Scan(&day, &count); err != nil {
return nil, err
}
out[day] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
if err != nil {
return nil, fmt.Errorf("job counts by workload: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int)
for rows.Next() {
var workload string
var count int
if err := rows.Scan(&workload, &count); err != nil {
return nil, err
}
out[workload] = count
}
return out, rows.Err()
}
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
var completed, failed int64
var avgNanos sql.NullFloat64
err := conn(ctx, r.db).QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL THEN completed_at - started_at END)
FROM tasks`).Scan(&completed, &failed, &avgNanos)
if err != nil {
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
}
return completed, failed, avgNanos.Float64 / 1e9, nil
}
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
if err != nil {
return nil, fmt.Errorf("artifact sizes: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int64)
for rows.Next() {
var kind string
var size int64
if err := rows.Scan(&kind, &size); err != nil {
return nil, err
}
out[kind] = size
}
return out, rows.Err()
}
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
var pageCount, pageSize int64
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_count").Scan(&pageCount); err != nil {
return 0, fmt.Errorf("page count: %w", err)
}
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_size").Scan(&pageSize); err != nil {
return 0, fmt.Errorf("page size: %w", err)
}
return pageCount * pageSize, nil
}
@@ -0,0 +1,171 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
func TestAdminListJobsPaginatedAndCounts(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
jobRepo := NewJobRepo(db)
adminRepo := NewAdminReadRepo(db)
jobs := make([]*domain.Job, 5)
for i := range jobs {
jobs[i] = seedJob(t, db, 2)
}
// Two completed, two running, one pending.
if err := jobRepo.UpdateStatus(ctx, jobs[0].ID, domain.JobCompleted, nil); err != nil {
t.Fatal(err)
}
if err := jobRepo.UpdateStatus(ctx, jobs[1].ID, domain.JobCompleted, nil); err != nil {
t.Fatal(err)
}
if err := jobRepo.UpdateStatus(ctx, jobs[2].ID, domain.JobRunning, nil); err != nil {
t.Fatal(err)
}
if err := jobRepo.UpdateStatus(ctx, jobs[3].ID, domain.JobRunning, nil); err != nil {
t.Fatal(err)
}
all, total, err := adminRepo.ListJobsPaginated(ctx, "", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(all) != 5 {
t.Errorf("all: total=%d len=%d, want 5/5", total, len(all))
}
completed, total, err := adminRepo.ListJobsPaginated(ctx, "completed", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 2 || len(completed) != 2 {
t.Errorf("completed: total=%d len=%d, want 2/2", total, len(completed))
}
page, total, err := adminRepo.ListJobsPaginated(ctx, "", 2, 2)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(page) != 2 {
t.Errorf("page: total=%d len=%d, want 5/2", total, len(page))
}
counts, err := adminRepo.CountJobsByStatus(ctx)
if err != nil {
t.Fatal(err)
}
if counts["completed"] != 2 || counts["running"] != 2 || counts["pending"] != 1 {
t.Errorf("counts = %v, want completed=2 running=2 pending=1", counts)
}
}
func TestAdminTaskCountsByJobs(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 3)
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'completed', result_artifact_id = ? WHERE chunk_index = 0 AND job_id = ?", uuid.NewString(), job.ID.String()); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'failed' WHERE chunk_index = 1 AND job_id = ?", job.ID.String()); err != nil {
t.Fatal(err)
}
counts, err := NewAdminReadRepo(db).TaskCountsByJobs(ctx, []uuid.UUID{job.ID})
if err != nil {
t.Fatal(err)
}
got := counts[job.ID]
if got["completed"] != 1 || got["failed"] != 1 || got["pending"] != 1 {
t.Errorf("task counts = %v, want completed=1 failed=1 pending=1", got)
}
}
func TestAdminJobCountsByDay(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 1)
// Move the seed job to two days ago; create two more today.
old := fixedTime().Add(-48 * time.Hour)
if _, err := db.ExecContext(ctx, "UPDATE jobs SET created_at = ? WHERE id = ?", old.UnixNano(), job.ID.String()); err != nil {
t.Fatal(err)
}
seedJob(t, db, 1)
seedJob(t, db, 1)
repo := NewAdminReadRepo(db)
counts, err := repo.JobCountsByDay(ctx, fixedTime().Add(-6*24*time.Hour))
if err != nil {
t.Fatal(err)
}
today := fixedTime().UTC().Format("2006-01-02")
oldDay := old.UTC().Format("2006-01-02")
if counts[today] != 2 {
t.Errorf("today count = %d, want 2 (got %v)", counts[today], counts)
}
if counts[oldDay] != 1 {
t.Errorf("old day count = %d, want 1 (got %v)", counts[oldDay], counts)
}
}
func TestAdminTaskStatsAndStorage(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewAdminReadRepo(db)
// One completed task with a known duration, one failed.
job := seedJob(t, db, 2)
start := fixedTime().Add(-2 * time.Minute)
done := fixedTime().Add(-90 * time.Second)
queries := []string{
"UPDATE tasks SET status='completed', result_artifact_id=?, started_at=?, completed_at=? WHERE job_id=? AND chunk_index=0",
"UPDATE tasks SET status='failed' WHERE job_id=? AND chunk_index=1",
}
for i, q := range queries {
args := []any{uuid.NewString(), start.UnixNano(), done.UnixNano(), job.ID.String()}
if i == 1 {
args = []any{job.ID.String()}
}
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
completed, failed, avg, err := repo.TaskStats(ctx)
if err != nil {
t.Fatal(err)
}
if completed != 1 || failed != 1 {
t.Errorf("stats = completed %d failed %d, want 1/1", completed, failed)
}
if avg < 29 || avg > 31 {
t.Errorf("avg duration = %.1fs, want ~30s", avg)
}
// Artifact sizes by kind.
for _, kind := range []string{"input", "shard", "final_result"} {
if _, err := db.ExecContext(ctx, "INSERT INTO artifacts (id, job_id, kind, filename, storage_key, content_type, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
uuid.NewString(), job.ID.String(), kind, kind+".csv", "key-"+kind, "text/csv", int64(len(kind)*1000), fixedTime().UnixNano()); err != nil {
t.Fatal(err)
}
}
sizes, err := repo.ArtifactSizeByKind(ctx)
if err != nil {
t.Fatal(err)
}
if sizes["input"] != 5000 || sizes["shard"] != 5000 || sizes["final_result"] != 12000 {
t.Errorf("sizes = %v", sizes)
}
dbBytes, err := repo.DatabaseSizeBytes(ctx)
if err != nil {
t.Fatal(err)
}
if dbBytes <= 0 {
t.Errorf("database size = %d, want > 0", dbBytes)
}
}
@@ -0,0 +1,91 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// ArtifactRepo implements usecase.ArtifactRepository on SQLite.
type ArtifactRepo struct {
db *sql.DB
}
func NewArtifactRepo(db *sql.DB) *ArtifactRepo {
return &ArtifactRepo{db: db}
}
const artifactColumns = `id, job_id, task_id, attempt, kind, filename, storage_key,
content_type, size_bytes, sha256, created_at`
// scanArtifact maps one row onto a domain.Artifact.
func scanArtifact(row interface{ Scan(dest ...any) error }) (*domain.Artifact, error) {
var (
a domain.Artifact
kind string
)
var (
taskID sql.NullString
attempt sql.NullInt64
createdAt sql.NullInt64
)
if err := row.Scan(
&a.ID, &a.JobID, &taskID, &attempt, &kind, &a.Filename, &a.StorageKey,
&a.ContentType, &a.SizeBytes, &a.SHA256, &createdAt,
); err != nil {
return nil, err
}
a.CreatedAt = decodeTime(createdAt.Int64)
a.Kind = domain.ArtifactKind(kind)
if taskID.Valid {
if id, err := uuid.Parse(taskID.String); err == nil {
a.TaskID = &id
}
}
if attempt.Valid {
value := int(attempt.Int64)
a.Attempt = &value
}
return &a, nil
}
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO artifacts (id, job_id, task_id, attempt, kind, filename, storage_key,
content_type, size_bytes, sha256, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID.String(), a.JobID.String(), nullableUUID(a.TaskID), nullableInt(a.Attempt),
string(a.Kind), a.Filename, a.StorageKey, a.ContentType, a.SizeBytes, a.SHA256,
encodeTime(a.CreatedAt))
return err
}
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
row := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT "+artifactColumns+" FROM artifacts WHERE id = ?", id.String())
artifact, err := scanArtifact(row)
return artifact, mapErrNoRows(err, domain.ErrArtifactNotFound)
}
func (r *ArtifactRepo) FindPartialResult(ctx context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
row := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT "+artifactColumns+" FROM artifacts WHERE task_id = ? AND attempt = ? AND kind = ?",
taskID.String(), attempt, string(domain.ArtifactPartialResult))
artifact, err := scanArtifact(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return artifact, err
}
// nullableInt renders a nilable int as its value, or NULL.
func nullableInt(n *int) any {
if n == nil {
return nil
}
return *n
}
@@ -0,0 +1,168 @@
package sqlite
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// JobRepo implements usecase.JobRepository on SQLite.
type JobRepo struct {
db *sql.DB
}
func NewJobRepo(db *sql.DB) *JobRepo {
return &JobRepo{db: db}
}
const jobColumns = `id, workload, input_uri, parameters, status, created_at, completed_at,
input_artifact_id, result_artifact_id, error_code, error_message, reducer_started_at, owner_id`
// scanJob maps one row onto a domain.Job. Scanned values follow the sqlite
// column order exactly: ids are TEXT, parameters JSON TEXT, timestamps unix
// nanoseconds (nullable), statuses plain strings.
func scanJob(row interface{ Scan(dest ...any) error }) (*domain.Job, error) {
var (
j domain.Job
status string
params string
)
var (
createdAt sql.NullInt64
completedAt, reducerStartedAt sql.NullInt64
inputArtifact, resultArtifact, ownerID sql.NullString
errorCode, errorMessage sql.NullString
)
if err := row.Scan(
&j.ID, &j.Workload, &j.InputURI, &params, &status, &createdAt,
&completedAt, &inputArtifact, &resultArtifact, &errorCode, &errorMessage,
&reducerStartedAt, &ownerID,
); err != nil {
return nil, err
}
if err := decodeJSON(params, &j.Parameters); err != nil {
return nil, err
}
j.Status = domain.JobStatus(status)
j.CreatedAt = decodeTime(createdAt.Int64)
if completedAt.Valid {
value := decodeTime(completedAt.Int64)
j.CompletedAt = &value
}
if reducerStartedAt.Valid {
value := decodeTime(reducerStartedAt.Int64)
j.ReducerStartedAt = &value
}
if inputArtifact.Valid {
if id, err := uuid.Parse(inputArtifact.String); err == nil {
j.InputArtifactID = &id
}
}
if resultArtifact.Valid {
if id, err := uuid.Parse(resultArtifact.String); err == nil {
j.ResultArtifactID = &id
}
}
if ownerID.Valid {
if id, err := uuid.Parse(ownerID.String); err == nil {
j.OwnerID = &id
}
}
if errorCode.Valid {
j.ErrorCode = &errorCode.String
}
if errorMessage.Valid {
j.ErrorMessage = &errorMessage.String
}
return &j, nil
}
// Insert runs inside the caller's transaction alongside the job's tasks.
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO jobs (id, workload, input_uri, parameters, status, created_at, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
j.ID.String(), j.Workload, j.InputURI, encodeJSON(j.Parameters), string(j.Status),
encodeTime(j.CreatedAt), nullableUUID(j.OwnerID))
return err
}
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
row := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT "+jobColumns+" FROM jobs WHERE id = ?", id.String())
job, err := scanJob(row)
return job, mapErrNoRows(err, domain.ErrJobNotFound)
}
func (r *JobRepo) ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
res, err := conn(ctx, r.db).ExecContext(ctx, `
UPDATE jobs SET reducer_started_at = ?
WHERE id = ? AND status = ? AND reducer_started_at IS NULL`,
encodeTime(startedAt), id.String(), string(domain.JobReducing))
if err != nil {
return false, err
}
affected, err := res.RowsAffected()
return affected == 1, err
}
func (r *JobRepo) CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
res, err := conn(ctx, r.db).ExecContext(ctx, `
UPDATE jobs SET status = ?, result_artifact_id = ?, completed_at = ?,
reducer_started_at = NULL, error_code = NULL, error_message = NULL
WHERE id = ? AND status = ?`,
string(domain.JobCompleted), resultArtifactID.String(), encodeTime(completedAt),
id.String(), string(domain.JobReducing))
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return domain.ErrJobNotFound
}
return nil
}
func (r *JobRepo) FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
UPDATE jobs SET status = ?, completed_at = ?, error_code = ?, error_message = ?,
reducer_started_at = NULL
WHERE id = ? AND status = ?`,
string(domain.JobFailed), encodeTime(completedAt), code, message,
id.String(), string(domain.JobReducing))
return err
}
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID,
status domain.JobStatus, completedAt *time.Time) error {
res, err := conn(ctx, r.db).ExecContext(ctx,
"UPDATE jobs SET status = ?, completed_at = ? WHERE id = ?",
string(status), encodeTimePtr(completedAt), id.String())
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return domain.ErrJobNotFound
}
return nil
}
// nullableUUID renders a nilable UUID as its text, or NULL.
func nullableUUID(id *uuid.UUID) any {
if id == nil {
return nil
}
return id.String()
}
@@ -0,0 +1,85 @@
package sqlite
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"regexp"
"sort"
"strconv"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.sql$`)
// Migrate applies every embedded migration above the current PRAGMA
// user_version watermark, each inside its own transaction. It is idempotent:
// the watermark only advances after a migration commits.
func Migrate(ctx context.Context, db *sql.DB, log *slog.Logger) error {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
type file struct {
version int
name string
}
var files []file
byVersion := map[int]string{}
for _, entry := range entries {
match := migrationNamePattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
version, err := strconv.Atoi(match[1])
if err != nil {
return fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
}
if _, duplicate := byVersion[version]; duplicate {
return fmt.Errorf("migration version %d is duplicated", version)
}
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %q: %w", entry.Name(), err)
}
byVersion[version] = string(body)
files = append(files, file{version: version, name: entry.Name()})
}
if len(files) == 0 {
return fmt.Errorf("no sqlite migrations are embedded")
}
sort.Slice(files, func(i, j int) bool { return files[i].version < files[j].version })
var applied int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&applied); err != nil {
return fmt.Errorf("read schema version: %w", err)
}
for _, item := range files {
if item.version <= applied {
continue
}
if log != nil {
log.Info("applying sqlite migration", "version", item.version, "file", item.name)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, byVersion[item.version]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", item.name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", item.version)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("advance schema version after %s: %w", item.name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", item.name, err)
}
}
return nil
}
@@ -0,0 +1,96 @@
-- 0001: core schema. SQLite stores enums as TEXT with CHECK constraints and
-- JSON documents as TEXT; timestamps are unix nanoseconds (INTEGER).
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
workload TEXT NOT NULL,
input_uri TEXT NOT NULL DEFAULT '',
parameters TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','reducing','completed','failed','cancelled')),
created_at INTEGER NOT NULL,
completed_at INTEGER,
input_artifact_id TEXT,
result_artifact_id TEXT,
error_code TEXT,
error_message TEXT,
reducer_started_at INTEGER,
owner_id TEXT
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
workload TEXT NOT NULL,
input_uri TEXT,
input_artifact_id TEXT,
input_sha256 TEXT NOT NULL,
parameters TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','leased','running','completed','failed','cancelled')),
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0),
lease_owner TEXT,
lease_expires_at INTEGER,
result_artifact_id TEXT,
metrics TEXT,
error_code TEXT,
error_message TEXT,
created_at INTEGER NOT NULL,
started_at INTEGER,
completed_at INTEGER,
version INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uq_tasks_job_chunk UNIQUE (job_id, chunk_index),
CONSTRAINT ck_tasks_completed_result CHECK (
status <> 'completed' OR (result_artifact_id IS NOT NULL)
),
CONSTRAINT ck_tasks_leased_owner CHECK (
status <> 'leased' OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS ix_tasks_claim ON tasks (status, lease_expires_at, created_at);
CREATE INDEX IF NOT EXISTS ix_tasks_job ON tasks (job_id);
CREATE TABLE IF NOT EXISTS artifacts (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
task_id TEXT,
attempt INTEGER,
kind TEXT NOT NULL
CHECK (kind IN ('input','shard','partial_result','final_result','log')),
filename TEXT NOT NULL,
storage_key TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
CONSTRAINT uq_partial_result_task_attempt UNIQUE (task_id, attempt)
);
CREATE INDEX IF NOT EXISTS ix_artifacts_job ON artifacts (job_id);
CREATE TABLE IF NOT EXISTS workers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'online'
CHECK (status IN ('online','busy','offline')),
owner_id TEXT,
trust_level TEXT NOT NULL DEFAULT 'trusted'
CHECK (trust_level IN ('trusted','untrusted')),
last_heartbeat_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS task_results (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
owner_id TEXT NOT NULL,
result_sha256 TEXT NOT NULL,
result_artifact_id TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (task_id, owner_id)
);
CREATE INDEX IF NOT EXISTS ix_task_results_task ON task_results (task_id);
@@ -0,0 +1,8 @@
-- 0002: per-workload enable/disable. Absence of a row means "enabled" (the
-- catalog default); a row only exists once an admin flipped a workload off or
-- back on.
CREATE TABLE IF NOT EXISTS workload_settings (
workload TEXT NOT NULL PRIMARY KEY,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
updated_at INTEGER NOT NULL
);
@@ -0,0 +1,172 @@
// Package sqlite implements the usecase repository ports on an embedded
// SQLite database. It is the single-binary storage backend: no external
// service, one file per database, pure-Go driver (modernc.org/sqlite) so the
// static release binaries stay static.
//
// Concurrency model: SQLite allows exactly one writer. Every repository write
// runs inside a TxManager transaction, and the database is opened with a
// busy_timeout, so concurrent writers serialize instead of failing. The
// postgres claim path uses FOR UPDATE SKIP LOCKED; here the same guarantee
// comes from the write lock of the surrounding transaction — ClaimNext is
// always called inside WithinTx by the usecase layer, so SELECT + UPDATE
// cannot interleave.
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// Open opens (and creates when missing) the database file, applying WAL,
// foreign keys, and a busy timeout. Callers own the returned handle.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err)
}
if err := db.PingContext(context.Background()); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite database: %w", err)
}
return db, nil
}
// querier is satisfied by both *sql.DB and *sql.Tx, letting every repository
// method run identically inside or outside a transaction.
type querier interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// txKey is an unexported struct type, so no other package can collide with it
// or reach the transaction we stash in the context.
type txKey struct{}
// TxManager implements usecase.TxManager.
type TxManager struct {
db *sql.DB
}
func NewTxManager(db *sql.DB) *TxManager {
return &TxManager{db: db}
}
var _ usecase.TxManager = (*TxManager)(nil)
// WithinTx runs fn inside one transaction, committing on success and rolling
// back on any error or panic. The transaction travels in the context, the
// same pattern as the postgres backend. SQLite write transactions are
// serialized by the database's single-writer lock, so a concurrent writer
// waits on the busy timeout instead of racing.
func (m *TxManager) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
if _, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
return fn(ctx)
}
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
return err
}
return tx.Commit()
}
// conn returns the transaction bound to ctx, or the database when there is none.
func conn(ctx context.Context, db *sql.DB) querier {
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
return tx
}
return db
}
// --- JSON and null helpers ------------------------------------------------
// encodeJSON stores a Go value as JSON text, defaulting to "{}".
func encodeJSON(value any) string {
if value == nil {
return "{}"
}
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
// decodeJSON reads a JSON text column into the destination.
func decodeJSON(raw any, destination any) error {
text, ok := raw.(string)
if !ok {
return nil
}
if text == "" {
return nil
}
return json.Unmarshal([]byte(text), destination)
}
// encodeTime stores a time as unix nanoseconds (NULL for zero time).
func encodeTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UnixNano()
}
// encodeTimePtr stores a nilable time as unix nanoseconds.
func encodeTimePtr(t *time.Time) any {
if t == nil {
return nil
}
return t.UnixNano()
}
// decodeTime reads a unix-nanosecond column back into a time.Time.
func decodeTime(raw any) time.Time {
switch v := raw.(type) {
case int64:
return time.Unix(0, v).UTC()
case int:
return time.Unix(0, int64(v)).UTC()
}
return time.Time{}
}
// nullIfEmpty maps "" to SQL NULL.
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// mapErrNoRows translates sql.ErrNoRows into the domain not-found errors.
func mapErrNoRows(err error, notFound error) error {
if errors.Is(err, sql.ErrNoRows) {
return notFound
}
return err
}
var (
_ usecase.JobRepository = (*JobRepo)(nil)
_ usecase.TaskRepository = (*TaskRepo)(nil)
_ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
_ usecase.WorkerRepository = (*WorkerRepo)(nil)
_ usecase.TaskResultRepository = (*TaskResultRepo)(nil)
_ usecase.UIReadRepository = (*UIReadRepo)(nil)
_ usecase.TxManager = (*TxManager)(nil)
)
@@ -0,0 +1,349 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// newTestDB opens an isolated on-disk database and applies the migrations.
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := Migrate(context.Background(), db, nil); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func fixedTime() time.Time {
return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
}
func seedJob(t *testing.T, db *sql.DB, n int) *domain.Job {
t.Helper()
ctx := context.Background()
tx := NewTxManager(db)
chunks := make([]domain.ChunkSpec, 0, n)
for i := 0; i < n; i++ {
chunks = append(chunks, domain.ChunkSpec{
ChunkIndex: i,
InputURI: "s3://chunk-" + string(rune('a'+i)),
InputSHA256: "sha-" + string(rune('a'+i)),
})
}
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, fixedTime())
if err != nil {
t.Fatal(err)
}
if err := tx.WithinTx(ctx, func(ctx context.Context) error {
if err := NewJobRepo(db).Insert(ctx, job); err != nil {
return err
}
return NewTaskRepo(db).InsertBatch(ctx, tasks)
}); err != nil {
t.Fatalf("seed: %v", err)
}
return job
}
func TestMigrateIsIdempotent(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
if err := Migrate(ctx, db, nil); err != nil {
t.Fatalf("second migrate: %v", err)
}
var version int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
if version != 2 {
t.Errorf("user_version = %d, want 2", version)
}
}
func TestJobRepoRoundTrip(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 1)
got, err := NewJobRepo(db).Get(ctx, job.ID)
if err != nil {
t.Fatal(err)
}
if got.Workload != job.Workload || got.Status != domain.JobPending {
t.Errorf("job = %+v", got)
}
if err := NewJobRepo(db).UpdateStatus(ctx, job.ID, domain.JobRunning, nil); err != nil {
t.Fatal(err)
}
got, err = NewJobRepo(db).Get(ctx, job.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != domain.JobRunning {
t.Errorf("status = %q, want running", got.Status)
}
if _, err := NewJobRepo(db).Get(ctx, uuid.New()); !errors.Is(err, domain.ErrJobNotFound) {
t.Errorf("missing job err = %v, want ErrJobNotFound", err)
}
}
func TestClaimGivesEachTaskToExactlyOneWorker(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
seedJob(t, db, 3)
repo := NewTaskRepo(db)
claimed := map[uuid.UUID]bool{}
for i := 0; i < 3; i++ {
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
Workloads: []string{"similarity_search"},
Owner: "w1",
Now: fixedTime(),
LeaseUntil: fixedTime().Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
if task == nil {
t.Fatal("claim returned nil on a non-empty queue")
}
if claimed[task.ID] {
t.Fatalf("task %s claimed twice", task.ID)
}
claimed[task.ID] = true
if task.Status != domain.TaskLeased || task.Attempt != 1 || task.LeaseOwner == nil || *task.LeaseOwner != "w1" {
t.Errorf("task = %+v", task)
}
}
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: []string{"similarity_search"}, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if task != nil {
t.Fatal("claim must return nil on an empty queue")
}
}
func TestUpdateRejectsStaleVersion(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
seedJob(t, db, 1)
repo := NewTaskRepo(db)
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
if err != nil || task == nil {
t.Fatalf("claim: %v", err)
}
stale := *task
task.Status = domain.TaskRunning
task.Version++ // as a domain method would have done
if err := repo.Update(ctx, task); err != nil {
t.Fatal(err)
}
stale.Status = domain.TaskCompleted
stale.Version++
if err := repo.Update(ctx, &stale); !errors.Is(err, domain.ErrLeaseConflict) {
t.Errorf("stale update err = %v, want ErrLeaseConflict", err)
}
}
func TestExpireLeasesRequeuesElapsedTasks(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
seedJob(t, db, 1)
repo := NewTaskRepo(db)
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(-time.Minute)})
if err != nil || task == nil {
t.Fatalf("claim: %v", err)
}
affected, err := repo.ExpireLeases(ctx, fixedTime())
if err != nil {
t.Fatal(err)
}
if len(affected) != 1 {
t.Fatalf("affected = %v, want 1 job", affected)
}
task, err = repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w2", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if task == nil || task.Attempt != 2 {
t.Errorf("requeued task = %+v, want attempt 2", task)
}
}
func TestExpireLeasesFailsAfterFinalAttempt(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
seedJob(t, db, 1)
repo := NewTaskRepo(db)
for attempt := 1; attempt <= 3; attempt++ {
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(-time.Minute)})
if err != nil || task == nil {
t.Fatalf("claim %d: %v", attempt, err)
}
if _, err := repo.ExpireLeases(ctx, fixedTime()); err != nil {
t.Fatal(err)
}
}
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if task != nil {
t.Fatal("exhausted task must not be claimable")
}
}
func TestArtifactRepoRoundTripAndUniqueness(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 1)
repo := NewArtifactRepo(db)
attempt := 1
artifact, err := domain.NewArtifact(job.ID, nil, domain.ArtifactShard, "shard-0.tsv", "text/tab-separated-values", fixedTime())
if err != nil {
t.Fatal(err)
}
artifact.SetContent("abc123", 42)
if err := repo.Insert(ctx, artifact); err != nil {
t.Fatal(err)
}
got, err := repo.Get(ctx, artifact.ID)
if err != nil {
t.Fatal(err)
}
if got.SHA256 != "abc123" || got.SizeBytes != 42 {
t.Errorf("artifact = %+v", got)
}
partialTask := uuid.New()
partial, err := domain.NewArtifact(job.ID, &partialTask, domain.ArtifactPartialResult, "p.csv", "text/csv", fixedTime())
if err != nil {
t.Fatal(err)
}
partial.Attempt = &attempt
if err := repo.Insert(ctx, partial); err != nil {
t.Fatal(err)
}
found, err := repo.FindPartialResult(ctx, partialTask, attempt)
if err != nil || found == nil {
t.Fatalf("find partial: %v", err)
}
duplicate, err := domain.NewArtifact(job.ID, &partialTask, domain.ArtifactPartialResult, "p2.csv", "text/csv", fixedTime())
if err != nil {
t.Fatal(err)
}
duplicate.Attempt = &attempt
if err := repo.Insert(ctx, duplicate); err == nil {
t.Fatal("duplicate partial for the same attempt must fail")
}
}
func TestWorkerRepoRoundTripAndLiveness(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewWorkerRepo(db)
worker, err := domain.NewWorker("w1", []string{"similarity-search"}, fixedTime())
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, worker); err != nil {
t.Fatal(err)
}
got, err := repo.Get(ctx, worker.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "w1" || len(got.Capabilities) != 1 || got.TrustLevel != domain.WorkerTrusted {
t.Errorf("worker = %+v", got)
}
if err := repo.Touch(ctx, worker.ID, fixedTime().Add(time.Hour)); err != nil {
t.Fatal(err)
}
changed, err := repo.MarkStaleOffline(ctx, fixedTime().Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if changed != 1 {
t.Errorf("offline changes = %d, want 1", changed)
}
got, err = repo.Get(ctx, worker.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != domain.WorkerOffline {
t.Errorf("status = %q, want offline", got.Status)
}
}
func TestTaskResultVotes(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 1)
repo := NewTaskResultRepo(db)
task, err := domain.NewTask(job.ID, 1, "similarity_search", "s3://in", "sha", nil, 3, fixedTime())
if err != nil {
t.Fatal(err)
}
if err := NewTaskRepo(db).InsertBatch(ctx, []*domain.Task{task}); err != nil {
t.Fatal(err)
}
taskID := task.ID
artifactID := uuid.New()
ownerA, ownerB := uuid.New(), uuid.New()
if err := repo.RecordVote(ctx, taskID, ownerA, "hash", artifactID); err != nil {
t.Fatal(err)
}
if err := repo.RecordVote(ctx, taskID, ownerB, "hash", artifactID); err != nil {
t.Fatal(err)
}
if err := repo.RecordVote(ctx, taskID, ownerA, "hash2", artifactID); err != nil {
t.Fatal(err)
}
n, err := repo.CountAgreeing(ctx, taskID, "hash")
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("agreeing = %d, want 1 (owner A changed its vote)", n)
}
}
func TestCancelByJobInvalidatesTasks(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
job := seedJob(t, db, 2)
repo := NewTaskRepo(db)
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
if err != nil || task == nil {
t.Fatalf("claim: %v", err)
}
cancelled, err := repo.CancelByJob(ctx, job.ID, fixedTime())
if err != nil {
t.Fatal(err)
}
if cancelled != 2 {
t.Errorf("cancelled = %d, want 2", cancelled)
}
got, err := repo.Get(ctx, task.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != domain.TaskCancelled || got.LeaseOwner != nil {
t.Errorf("cancelled task = %+v", got)
}
}
@@ -0,0 +1,63 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// Known statuses per entity, so counts are zero-filled and every status is
// always present in the metrics (a flat 0 line beats a gap on the dashboard).
var (
taskStatuses = []string{string(domain.TaskPending), string(domain.TaskLeased), string(domain.TaskRunning), string(domain.TaskCompleted), string(domain.TaskFailed), string(domain.TaskCancelled)}
jobStatuses = []string{string(domain.JobPending), string(domain.JobRunning), string(domain.JobReducing), string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled)}
workerStatuses = []string{string(domain.WorkerOnline), string(domain.WorkerBusy), string(domain.WorkerOffline)}
)
// StatsRepo answers the aggregate status counts the business metrics report.
type StatsRepo struct {
db *sql.DB
}
func NewStatsRepo(db *sql.DB) *StatsRepo {
return &StatsRepo{db: db}
}
// Counts returns status->count maps for tasks, jobs, and workers, each
// zero-filled across its known statuses.
func (r *StatsRepo) Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error) {
if tasks, err = r.countByStatus(ctx, "tasks", taskStatuses); err != nil {
return nil, nil, nil, err
}
if jobs, err = r.countByStatus(ctx, "jobs", jobStatuses); err != nil {
return nil, nil, nil, err
}
if workers, err = r.countByStatus(ctx, "workers", workerStatuses); err != nil {
return nil, nil, nil, err
}
return tasks, jobs, workers, nil
}
func (r *StatsRepo) countByStatus(ctx context.Context, table string, known []string) (map[string]int, error) {
out := make(map[string]int, len(known))
for _, s := range known {
out[s] = 0 // zero-fill
}
// table is a fixed internal constant, never user input — safe to format.
rows, err := r.db.QueryContext(ctx, fmt.Sprintf("SELECT status, count(*) FROM %s GROUP BY status", table))
if err != nil {
return nil, fmt.Errorf("count %s by status: %w", table, err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return nil, err
}
out[status] = n
}
return out, rows.Err()
}
@@ -0,0 +1,314 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// TaskRepo implements usecase.TaskRepository on SQLite.
type TaskRepo struct {
db *sql.DB
}
func NewTaskRepo(db *sql.DB) *TaskRepo {
return &TaskRepo{db: db}
}
const taskColumns = `id, job_id, chunk_index, workload, input_uri, input_artifact_id, input_sha256,
parameters, status, attempt, max_attempts, lease_owner, lease_expires_at,
result_artifact_id, metrics, error_code, error_message,
created_at, started_at, completed_at, version`
// scanTask maps one row onto a domain.Task.
func scanTask(row interface{ Scan(dest ...any) error }) (*domain.Task, error) {
var (
t domain.Task
status string
params string
metrics sql.NullString
)
var (
inputURI, leaseOwner, errorCode, errorMessage sql.NullString
inputArtifact, resultArtifact sql.NullString
leaseExpiresAt, startedAt, completedAt sql.NullInt64
createdAt sql.NullInt64
)
if err := row.Scan(
&t.ID, &t.JobID, &t.ChunkIndex, &t.Workload, &inputURI, &inputArtifact, &t.InputSHA256,
&params, &status, &t.Attempt, &t.MaxAttempts, &leaseOwner, &leaseExpiresAt,
&resultArtifact, &metrics, &errorCode, &errorMessage,
&createdAt, &startedAt, &completedAt, &t.Version,
); err != nil {
return nil, err
}
if err := decodeJSON(params, &t.Parameters); err != nil {
return nil, err
}
if metrics.Valid && metrics.String != "" {
if err := decodeJSON(metrics.String, &t.Metrics); err != nil {
return nil, err
}
}
t.Status = domain.TaskStatus(status)
t.CreatedAt = decodeTime(createdAt.Int64)
if inputURI.Valid {
t.InputURI = inputURI.String
}
if leaseOwner.Valid {
t.LeaseOwner = &leaseOwner.String
}
if inputArtifact.Valid {
if id, err := uuid.Parse(inputArtifact.String); err == nil {
t.InputArtifactID = &id
}
}
if resultArtifact.Valid {
if id, err := uuid.Parse(resultArtifact.String); err == nil {
t.ResultArtifactID = &id
}
}
if leaseExpiresAt.Valid {
value := decodeTime(leaseExpiresAt.Int64)
t.LeaseExpiresAt = &value
}
if startedAt.Valid {
value := decodeTime(startedAt.Int64)
t.StartedAt = &value
}
if completedAt.Valid {
value := decodeTime(completedAt.Int64)
t.CompletedAt = &value
}
if errorCode.Valid {
t.ErrorCode = &errorCode.String
}
if errorMessage.Valid {
t.ErrorMessage = &errorMessage.String
}
return &t, nil
}
// ClaimNext atomically leases the next eligible task. SQLite has no SKIP
// LOCKED: the guarantee comes from the surrounding transaction's write lock —
// the usecase layer always calls ClaimNext inside WithinTx, so SELECT + UPDATE
// cannot interleave with another claimant.
func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domain.Task, error) {
workloadClause := ""
workloadArgs := []any{}
if len(f.Workloads) > 0 {
placeholders := make([]string, 0, len(f.Workloads))
for _, w := range f.Workloads {
placeholders = append(placeholders, "?")
workloadArgs = append(workloadArgs, w)
}
workloadClause = " AND workload IN (" + strings.Join(placeholders, ", ") + ")"
}
voterClause := ""
var voterArg any
if f.VoterOwner != nil {
voterClause = " AND NOT EXISTS (SELECT 1 FROM task_results tr WHERE tr.task_id = tasks.id AND tr.owner_id = ?)"
voterArg = f.VoterOwner.String()
}
args := append([]any{f.Owner, f.LeaseUntil.UnixNano(), f.Now.UnixNano()}, workloadArgs...)
if f.VoterOwner != nil {
args = append(args, voterArg)
}
query := `
UPDATE tasks SET
status = 'leased',
attempt = attempt + 1,
lease_owner = ?,
lease_expires_at = ?,
started_at = COALESCE(started_at, ?),
version = version + 1
WHERE id IN (
SELECT id FROM tasks
WHERE status = 'pending' AND attempt < max_attempts` + workloadClause + voterClause + `
ORDER BY created_at, chunk_index
LIMIT 1
)
RETURNING ` + taskColumns
row := conn(ctx, r.db).QueryRowContext(ctx, query, args...)
task, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return task, nil
}
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
row := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT "+taskColumns+" FROM tasks WHERE id = ?", id.String())
task, err := scanTask(row)
return task, mapErrNoRows(err, domain.ErrTaskNotFound)
}
// GetForUpdate reads a task. SQLite serializes writers inside a transaction,
// so no row lock is needed: the surrounding write transaction already isolates
// the read-modify-write sequence.
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
return r.Get(ctx, id)
}
// Update writes the mutated entity back under optimistic concurrency.
func (r *TaskRepo) Update(ctx context.Context, t *domain.Task) error {
res, err := conn(ctx, r.db).ExecContext(ctx, `
UPDATE tasks SET
status = ?, attempt = ?, lease_owner = ?, lease_expires_at = ?,
result_artifact_id = ?, metrics = ?, error_code = ?, error_message = ?,
started_at = ?, completed_at = ?, version = ?
WHERE id = ? AND version = ?`,
string(t.Status), t.Attempt, nullableString(t.LeaseOwner), encodeTimePtr(t.LeaseExpiresAt),
nullableUUID(t.ResultArtifactID), nullableMetrics(t.Metrics),
nullableString(t.ErrorCode), nullableString(t.ErrorMessage),
encodeTimePtr(t.StartedAt), encodeTimePtr(t.CompletedAt), t.Version,
t.ID.String(), t.Version-1)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return domain.ErrLeaseConflict
}
return nil
}
func (r *TaskRepo) InsertBatch(ctx context.Context, tasks []*domain.Task) error {
for _, t := range tasks {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO tasks (id, job_id, chunk_index, workload, input_uri, input_artifact_id,
input_sha256, parameters, status, attempt, max_attempts, created_at, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID.String(), t.JobID.String(), t.ChunkIndex, t.Workload,
nullIfEmpty(t.InputURI), nullableUUID(t.InputArtifactID),
t.InputSHA256, encodeJSON(t.Parameters), string(t.Status),
t.Attempt, t.MaxAttempts, encodeTime(t.CreatedAt), t.Version)
if err != nil {
return err
}
}
return nil
}
func (r *TaskRepo) ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT "+taskColumns+" FROM tasks WHERE job_id = ? AND status = ? ORDER BY chunk_index",
jobID.String(), string(domain.TaskCompleted))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var tasks []*domain.Task
for rows.Next() {
task, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, rows.Err()
}
func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT status, count(*) FROM tasks WHERE job_id = ? GROUP BY status", jobID.String())
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
counts := make(map[domain.TaskStatus]int)
for rows.Next() {
var (
status string
n int
)
if err := rows.Scan(&status, &n); err != nil {
return nil, err
}
counts[domain.TaskStatus(status)] = n
}
return counts, rows.Err()
}
func (r *TaskRepo) CancelByJob(ctx context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
res, err := conn(ctx, r.db).ExecContext(ctx, `
UPDATE tasks SET
status = 'cancelled',
lease_owner = NULL,
lease_expires_at = NULL,
error_code = NULL,
error_message = NULL,
completed_at = ?,
version = version + 1
WHERE job_id = ? AND status IN ('pending','leased','running')`,
now.UnixNano(), jobID.String())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ExpireLeases applies the lease-expiry rule set-based and returns the
// distinct jobs whose aggregate status may have changed.
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx, `
UPDATE tasks SET
status = CASE WHEN attempt < max_attempts THEN 'pending' ELSE 'failed' END,
lease_owner = NULL,
lease_expires_at = NULL,
error_code = CASE WHEN attempt >= max_attempts THEN ? ELSE error_code END,
error_message = CASE WHEN attempt >= max_attempts THEN ? ELSE error_message END,
completed_at = CASE WHEN attempt >= max_attempts THEN ? ELSE completed_at END,
version = version + 1
WHERE status IN ('leased','running') AND lease_expires_at < ?
RETURNING job_id`,
domain.ErrCodeLeaseExpired, "lease expired after the final attempt", now.UnixNano(), now.UnixNano())
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var affected []uuid.UUID
seen := map[uuid.UUID]bool{}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
if id, err := uuid.Parse(raw); err == nil && !seen[id] {
seen[id] = true
affected = append(affected, id)
}
}
return affected, rows.Err()
}
// nullableString renders a nilable string, or NULL.
func nullableString(s *string) any {
if s == nil {
return nil
}
return *s
}
// nullableMetrics stores nil metrics as NULL, else JSON text.
func nullableMetrics(m map[string]any) any {
if m == nil {
return nil
}
return encodeJSON(m)
}
@@ -0,0 +1,41 @@
package sqlite
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
// TaskResultRepo records and tallies quorum votes for untrusted task results.
type TaskResultRepo struct {
db *sql.DB
}
func NewTaskResultRepo(db *sql.DB) *TaskResultRepo {
return &TaskResultRepo{db: db}
}
// RecordVote stores (or replaces) one owner's vote for a task's result.
func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (task_id, owner_id) DO UPDATE SET
result_sha256 = excluded.result_sha256,
result_artifact_id = excluded.result_artifact_id,
created_at = excluded.created_at`,
taskID.String(), ownerID.String(), sha256, artifactID.String(), time.Now().UnixNano())
return err
}
// CountAgreeing returns how many distinct owners have voted for the given
// result hash on this task.
func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
var n int
err := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = ? AND result_sha256 = ?",
taskID.String(), sha256).Scan(&n)
return n, err
}
@@ -0,0 +1,78 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// UIReadRepo contains bounded, deterministic read queries for the operator UI.
type UIReadRepo struct{ db *sql.DB }
func NewUIReadRepo(db *sql.DB) *UIReadRepo { return &UIReadRepo{db: db} }
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
return NewJobRepo(r.db).Get(ctx, id)
}
func (r *UIReadRepo) ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT "+taskColumns+" FROM tasks WHERE job_id = ? ORDER BY chunk_index ASC", jobID.String())
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
defer func() { _ = rows.Close() }()
tasks := make([]domain.Task, 0)
for rows.Next() {
task, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, *task)
}
return tasks, rows.Err()
}
func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT "+workerColumns+" FROM workers ORDER BY last_heartbeat_at DESC, id DESC LIMIT ?", limit)
if err != nil {
return nil, fmt.Errorf("list workers: %w", err)
}
defer func() { _ = rows.Close() }()
workers := make([]domain.Worker, 0)
for rows.Next() {
worker, err := scanWorker(rows)
if err != nil {
return nil, err
}
workers = append(workers, *worker)
}
return workers, rows.Err()
}
func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT "+artifactColumns+" FROM artifacts WHERE job_id = ? ORDER BY created_at ASC, id ASC",
jobID.String())
if err != nil {
return nil, fmt.Errorf("list artifacts: %w", err)
}
defer func() { _ = rows.Close() }()
artifacts := make([]domain.Artifact, 0)
for rows.Next() {
artifact, err := scanArtifact(rows)
if err != nil {
return nil, err
}
artifacts = append(artifacts, *artifact)
}
return artifacts, rows.Err()
}
@@ -0,0 +1,109 @@
package sqlite
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// WorkerRepo implements usecase.WorkerRepository on SQLite.
type WorkerRepo struct {
db *sql.DB
}
func NewWorkerRepo(db *sql.DB) *WorkerRepo {
return &WorkerRepo{db: db}
}
const workerColumns = `id, name, capabilities, status, owner_id, trust_level, last_heartbeat_at, created_at, updated_at`
// scanWorker maps one row onto a domain.Worker.
func scanWorker(row interface{ Scan(dest ...any) error }) (*domain.Worker, error) {
var (
w domain.Worker
status string
trust string
caps string
)
var (
ownerID sql.NullString
lastHeartbeat, created, updated sql.NullInt64
)
if err := row.Scan(
&w.ID, &w.Name, &caps, &status, &ownerID, &trust,
&lastHeartbeat, &created, &updated,
); err != nil {
return nil, err
}
w.LastHeartbeatAt = decodeTime(lastHeartbeat.Int64)
w.CreatedAt = decodeTime(created.Int64)
w.UpdatedAt = decodeTime(updated.Int64)
if err := decodeJSON(caps, &w.Capabilities); err != nil {
return nil, err
}
w.Status = domain.WorkerStatus(status)
w.TrustLevel = domain.WorkerTrust(trust)
if ownerID.Valid {
if id, err := uuid.Parse(ownerID.String); err == nil {
w.OwnerID = &id
}
}
return &w, nil
}
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO workers (id, name, capabilities, status, owner_id, trust_level,
last_heartbeat_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID.String(), w.Name, encodeJSON(w.Capabilities), string(w.Status),
nullableUUID(w.OwnerID), string(w.TrustLevel),
encodeTime(w.LastHeartbeatAt), encodeTime(w.CreatedAt), encodeTime(w.UpdatedAt))
return err
}
func (r *WorkerRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error) {
row := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT "+workerColumns+" FROM workers WHERE id = ?", id.String())
worker, err := scanWorker(row)
return worker, mapErrNoRows(err, domain.ErrWorkerNotFound)
}
func (r *WorkerRepo) Touch(ctx context.Context, id uuid.UUID, at time.Time) error {
_, err := conn(ctx, r.db).ExecContext(ctx,
"UPDATE workers SET last_heartbeat_at = ?, status = ?, updated_at = ? WHERE id = ?",
encodeTime(at), string(domain.WorkerOnline), encodeTime(at), id.String())
return err
}
func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error) {
res, err := conn(ctx, r.db).ExecContext(ctx,
"UPDATE workers SET status = ?, updated_at = ? WHERE last_heartbeat_at < ? AND status <> ?",
string(domain.WorkerOffline), encodeTime(cutoff), encodeTime(cutoff),
string(domain.WorkerOffline))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
res, err := conn(ctx, r.db).ExecContext(ctx,
"UPDATE workers SET trust_level = ?, updated_at = ? WHERE id = ?",
string(trust), encodeTime(time.Now()), id.String())
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return domain.ErrWorkerNotFound
}
return nil
}
@@ -0,0 +1,75 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
// Absence of a row means the workload is enabled (the catalog default).
type WorkloadSettingsRepo struct{ db *sql.DB }
func NewWorkloadSettingsRepo(db *sql.DB) *WorkloadSettingsRepo { return &WorkloadSettingsRepo{db: db} }
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
var enabled int
err := conn(ctx, r.db).QueryRowContext(ctx,
"SELECT enabled FROM workload_settings WHERE workload = ?", workload).Scan(&enabled)
if err == sql.ErrNoRows {
return true, nil // no override: catalog default enabled
}
if err != nil {
return false, fmt.Errorf("get workload setting: %w", err)
}
return enabled == 1, nil
}
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
rows, err := conn(ctx, r.db).QueryContext(ctx,
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
if err != nil {
return nil, fmt.Errorf("list workload settings: %w", err)
}
defer func() { _ = rows.Close() }()
var out []usecase.WorkloadSetting
for rows.Next() {
var (
name string
enabled int
updatedAt int64
)
if err := rows.Scan(&name, &enabled, &updatedAt); err != nil {
return nil, err
}
out = append(out, usecase.WorkloadSetting{
Workload: name,
Enabled: enabled == 1,
UpdatedAt: decodeTime(updatedAt),
})
}
return out, rows.Err()
}
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
_, err := conn(ctx, r.db).ExecContext(ctx, `
INSERT INTO workload_settings (workload, enabled, updated_at) VALUES (?, ?, ?)
ON CONFLICT (workload) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
workload, boolInt(enabled), now.UnixNano())
if err != nil {
return fmt.Errorf("set workload setting: %w", err)
}
return nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
@@ -42,7 +42,7 @@ func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrInvalidInput):
case errors.Is(err, domain.ErrInvalidInput), errors.Is(err, domain.ErrWorkloadDisabled):
status = http.StatusBadRequest
case errors.Is(err, domain.ErrJobNotFound), errors.Is(err, domain.ErrTaskNotFound),
errors.Is(err, domain.ErrWorkerNotFound), errors.Is(err, domain.ErrArtifactNotFound):
+16 -1
View File
@@ -35,6 +35,7 @@ type UseCases struct {
GetTaskInput *usecase.GetTaskInput
Dashboard *usecase.Dashboard
PreviewArtifact *usecase.PreviewArtifact
Admin *usecase.Admin
}
type Server struct {
@@ -146,7 +147,6 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
{"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},
{"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
{"POST /ui/api/jobs/upload", s.handleUploadDataset},
@@ -158,6 +158,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
// Public auth pages — reachable without a session so a user can log in.
ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
ui.HandleFunc("POST /ui/login", s.handleUILogin)
ui.HandleFunc("GET /ui/logout-form", s.handleUILogoutForm)
ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
ui.HandleFunc("POST /ui/register", s.handleUIRegister)
ui.HandleFunc("POST /ui/logout", s.handleUILogout)
@@ -177,6 +178,20 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
// Admin panel: session + admin role.
ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
// Admin console APIs: session + admin role, bounded read models.
ui.Handle("GET /ui/admin/api/system", chain(http.HandlerFunc(s.handleUIAdminSystemJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/jobs", chain(http.HandlerFunc(s.handleUIAdminJobsJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/metrics", chain(http.HandlerFunc(s.handleUIAdminMetricsJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/workers", chain(http.HandlerFunc(s.handleUIAdminWorkersJSON), gate, requireAdmin))
ui.Handle("POST /ui/admin/api/workers/{id}/trust", chain(http.HandlerFunc(s.handleUIAdminSetTrustJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/users", chain(http.HandlerFunc(s.handleUIAdminUsersJSON), gate, requireAdmin))
ui.Handle("POST /ui/admin/api/users/{id}/role", chain(http.HandlerFunc(s.handleUIAdminSetUserRoleJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/worker-keys", chain(http.HandlerFunc(s.handleUIAdminWorkerKeysJSON), gate, requireAdmin))
ui.Handle("POST /ui/admin/api/worker-keys/{id}/revoke", chain(http.HandlerFunc(s.handleUIAdminRevokeKeyJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/workloads", chain(http.HandlerFunc(s.handleUIAdminWorkloadsJSON), gate, requireAdmin))
ui.Handle("POST /ui/admin/api/workloads/{name}/enabled", chain(http.HandlerFunc(s.handleUIAdminSetWorkloadEnabledJSON), gate, requireAdmin))
ui.Handle("GET /ui/admin/api/settings", chain(http.HandlerFunc(s.handleUIAdminSettingsJSON), gate, requireAdmin))
ui.Handle("POST /ui/admin/api/token/reveal", chain(http.HandlerFunc(s.handleUIAdminRevealTokenJSON), gate, requireAdmin))
} else {
for _, rt := range app {
ui.HandleFunc(rt.pattern, rt.handler)
@@ -39,6 +39,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
work := memstore.NewWorkerRepo()
arts := memstore.NewArtifactRepo()
blobs := memstore.NewBlobStore()
settings := memstoreSettings{}
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
tx := memstore.Tx{}
lease := 2 * time.Minute
@@ -47,7 +48,7 @@ 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, testCatalog()),
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog(), settings),
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, testCatalog()),
@@ -144,42 +145,17 @@ func TestUIRequiresDistinctCredentialAndRendersDashboard(t *testing.T) {
req = request()
req.SetBasicAuth("operator", uiToken)
resp, err = http.DefaultClient.Do(req)
// Do not follow the redirect: we assert it, not its target.
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("UI status: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SciMesh control room") {
t.Errorf("dashboard body missing title")
}
}
func TestUIOverviewReturnsLiveSafeProjection(t *testing.T) {
e := newEnv(t, healthy)
code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("create job: %d", code)
}
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/api/overview", nil)
req.SetBasicAuth("operator", uiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var overview map[string]any
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK || overview["active_jobs"].(float64) != 1 || overview["online_workers"].(float64) != 1 {
t.Fatalf("overview = (%d, %v)", resp.StatusCode, overview)
}
if _, leaked := overview["worker_auth_token"]; leaked {
t.Fatal("overview must not expose authentication configuration")
// In basic-auth mode (no userservice) /ui lands on the job form: the admin
// console exists only in session mode.
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ui/jobs/new" {
t.Fatalf("UI status: %d -> %s, want 303 -> /ui/jobs/new", resp.StatusCode, resp.Header.Get("Location"))
}
}
@@ -739,3 +715,22 @@ func (e *env) uploadDataset(t *testing.T, workload string, rows int, tsv string)
}
func itoa(n int) string { return strconv.Itoa(n) }
// memstoreSettings is an in-memory WorkloadSettingsRepository for tests.
type memstoreSettings struct{ overrides map[string]bool }
func (m memstoreSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
if enabled, ok := m.overrides[workload]; ok {
return enabled, nil
}
return true, nil
}
func (m memstoreSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
return nil, nil
}
func (m memstoreSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
m.overrides[workload] = enabled
return nil
}
@@ -11,7 +11,7 @@
</head>
<body data-coordinator="{{.CoordinatorURL}}" data-userservice="{{.UserserviceURL}}">
<main class="page">
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">Contribute compute</p><h1>Turn this computer into a worker</h1><p class="lead">Create a key, install the worker, and run one command. The worker binds to your account and pulls tasks whenever it is online.</p>
<a class="back" href="/ui/admin">← Back to control room</a><p class="eyebrow">Contribute compute</p><h1>Turn this computer into a worker</h1><p class="lead">Create a key, install the worker, and run one command. The worker binds to your account and pulls tasks whenever it is online.</p>
<div class="layout">
<section class="card">
<h2 style="margin:0 0 4px;color:#f1f6ff">Your worker keys</h2>
@@ -29,8 +29,12 @@
<h2>Set it up</h2>
<ol>
<li><strong>Create a key</strong><br>Use the form; copy the command it generates.</li>
<li><strong>Paste it in a terminal</strong><br>The command clones the project, sets up a Python environment, installs the worker, and starts it. The machine then appears under <a href="/ui">My machines</a>.</li>
<li><strong>Paste it in a terminal</strong><br>The command installs the worker, points it at this coordinator, and starts it. The machine then appears under <a href="/ui/admin">Workers</a>.</li>
</ol>
<h2 style="margin-top:24px">Single-binary mode?</h2>
<p>If the coordinator runs as <code>coordinator serve</code>, skip the key:
use the token in <code>~/.scimesh/worker.token</code> on the coordinator
machine as <code>WORKER_AUTH_TOKEN</code> instead.</p>
<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> and other SDK workloads from the library run on volunteer workers.</p>
@@ -43,8 +47,8 @@
const keysBox=document.querySelector('#keys'),cmdBox=document.querySelector('#command'),form=document.querySelector('#create'),nameInput=document.querySelector('#key-name'),createBtn=document.querySelector('#create-btn'),error=document.querySelector('#error');
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
const shq=s=>"'"+String(s).replace(/'/g,"'\\''")+"'";
const buildCommand=(key,name)=>['git clone https://github.com/emil28092005/SciMesh.git','cd SciMesh','python -m venv .venv','source .venv/bin/activate','pip install -e .','','SCIMESH_COORDINATOR_URL='+coord+' \\','SCIMESH_USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','SCIMESH_WORKER_KEY='+key+' \\','scimesh-worker --worker-name '+shq(name||'my-machine')].join('\n');
const showCommand=(key,name)=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key,name));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key,name)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set SCIMESH_USERSERVICE_URL to a userservice URL your machine can reach.','warn'))}cmdBox.classList.remove('hidden')};
const buildCommand=(key,name)=>['# install the worker binary (or download worker-agent from the release page)','curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash -s worker','','export COORDINATOR_URL='+coord,'export USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','export WORKER_KEY='+key,'export WORKER_NAME='+shq(name||'my-machine'),'export WORK_DIR=~/scimesh-worker','worker-agent'].join('\n');
const showCommand=(key,name)=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key,name));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key,name)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set USERSERVICE_URL to a userservice URL your machine can reach, or skip it and run the worker with WORKER_AUTH_TOKEN instead of a key.','warn'))}cmdBox.classList.remove('hidden')};
const revoke=async id=>{const r=await fetch('/ui/api/worker-keys/'+encodeURIComponent(id)+'/revoke',{method:'POST'});if(r.status===204||r.ok){loadKeys()}else{error.textContent='Could not revoke the key.'}};
const renderKeys=keys=>{keysBox.replaceChildren();if(!keys.length){keysBox.append(node('div','No keys yet. Create one above to connect a machine.','empty'));return}for(const k of keys){const row=node('div',undefined,'key'),left=node('div');left.append(node('div',k.name||'unnamed','kn'),node('div',k.prefix+'…','kp'),node('div','Created '+new Date(k.created_at).toLocaleString()+(k.last_used_at?' · last used '+new Date(k.last_used_at).toLocaleString():' · never used'),'kd'));const btn=node('button','Revoke','revoke');btn.type='button';btn.addEventListener('click',()=>revoke(k.id));row.append(left,btn);keysBox.append(row)}};
const loadKeys=async()=>{try{const r=await fetch('/ui/api/worker-keys',{headers:{Accept:'application/json'}});if(!r.ok)throw Error();const data=await r.json();renderKeys(data.worker_keys||[])}catch(_){keysBox.replaceChildren(node('div','Could not load your keys.','empty'))}};
@@ -2,45 +2,535 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · 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:820px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.lead{max-width:640px;margin:10px 0 0;color:#aabed9}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card h2{margin:0 0 4px;color:#f1f6ff;font-size:1.1rem}.card p{margin:0;color:#9fb3cf;font-size:.92rem}label{display:block;margin:16px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer}.btn-primary{background:#67e3b8;color:#062018}.btn-muted{background:#23344d;color:#dce8ff}.notice{margin-top:16px;border-radius:10px;padding:11px 13px;font-weight:700}.ok{background:#123f34;color:#76efb5}.err{background:#552334;color:#ff9bad}.muted{color:#8ba2c2}.hint{margin-top:4px;color:#92a9c6;font-size:.85rem}</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh · Coordinator Admin</title>
<style>
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input,select{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;outline:none}
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
code{font-family:var(--mono);font-size:.86em}
a{color:var(--accent);text-decoration:none}
.layout{display:flex;min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;width:232px;flex:none;display:flex;flex-direction:column;background:var(--panel);border-right:1px solid var(--border-soft)}
.brand{display:flex;align-items:center;gap:11px;padding:20px 20px 18px;border-bottom:1px solid var(--border-soft)}
.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 14px #5b8cff40}
.brand-mark svg{width:17px;height:17px;stroke:#fff}
.brand-name{font-weight:700;font-size:14.5px;letter-spacing:-.01em}
.brand-sub{font-size:11px;color:var(--text-3);letter-spacing:.02em}
.nav{flex:1;overflow-y:auto;padding:14px 12px}
.nav-label{margin:16px 10px 6px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3)}
.nav-label:first-child{margin-top:0}
.nav-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:8px;color:var(--text-2);font-weight:500;text-align:left;transition:background .12s,color .12s}
.nav-item svg{width:16px;height:16px;stroke:currentColor;flex:none}
.nav-item:hover{background:var(--panel-2);color:var(--text)}
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
.nav-item .count{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3);background:var(--panel-2);border-radius:99px;padding:1px 7px}
.nav-item.active .count{color:var(--accent);background:#5b8cff26}
.side-foot{padding:14px;border-top:1px solid var(--border-soft)}
.user-chip{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:9px;background:var(--panel-2)}
.avatar{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#3fce8a,#2ea56c);color:#08130d;font-weight:800;font-size:12px;flex:none}
.user-chip b{display:block;font-size:12.5px;line-height:1.25}
.user-chip span{display:block;font-size:11px;color:var(--text-3)}
.back-link{display:block;margin-top:9px;padding:7px 10px;color:var(--text-3);font-size:12.5px;text-decoration:none;border-radius:8px}
.back-link:hover{color:var(--text);background:var(--panel-2)}
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;background:#0b0e13e6;backdrop-filter:blur(10px);border-bottom:1px solid var(--border-soft)}
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.015em}
.topbar p{font-size:12.5px;color:var(--text-3);margin-top:1px}
.env-badge{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);border:1px solid var(--border);border-radius:99px;padding:5px 12px;background:var(--panel)}
.env-badge i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green)}
.content{flex:1;padding:26px 32px 60px;max-width:1120px;width:100%;margin:0 auto}
.page{display:none}.page.active{display:block;animation:fade .18s ease}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px}
.card-pad{padding:20px}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 20px;border-bottom:1px solid var(--border-soft)}
.card-head h3{font-size:13.5px;font-weight:650}
.card-head span{font-size:12px;color:var(--text-3)}
.grid-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
.kpi{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px;padding:16px 18px}
.kpi .k-label{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3)}
.kpi .k-value{margin-top:8px;font-size:26px;font-weight:700;letter-spacing:-.03em;line-height:1}
.kpi .k-sub{margin-top:6px;font-size:12px;color:var(--text-2)}
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:99px;padding:3px 10px;font-size:11.5px;font-weight:650;white-space:nowrap}
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
.pill-success{background:var(--green-soft);color:var(--green)}
.pill-active{background:var(--accent-soft);color:var(--accent)}
.pill-waiting{background:#ffffff12;color:var(--text-2)}
.pill-danger{background:var(--red-soft);color:var(--red)}
.pill-amber{background:var(--amber-soft);color:var(--amber)}
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 14px;font-weight:600;font-size:13px;border:1px solid transparent;transition:filter .12s,background .12s}
.btn svg{width:14px;height:14px;stroke:currentColor}
.btn-primary{background:var(--accent);color:#0a1222}
.btn-ghost{background:var(--panel-2);border-color:var(--border);color:var(--text-2)}
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
.btn-danger{background:var(--red-soft);color:var(--red)}
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
table{width:100%;border-collapse:collapse}
th{padding:10px 20px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
td{padding:12px 20px;border-bottom:1px solid var(--border-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
tbody tr{transition:background .1s}
tbody tr:hover{background:var(--panel-2)}
.t-main{font-weight:600;font-size:13.5px}
.t-sub{font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
.bar{height:5px;width:130px;border-radius:99px;background:#ffffff10;overflow:hidden}
.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5b8cff,#3fce8a)}
.bar-label{font-size:11.5px;color:var(--text-2);font-family:var(--mono);margin-top:5px}
.tabs{display:flex;gap:4px;padding:4px;background:var(--panel);border:1px solid var(--border-soft);border-radius:10px;width:max-content;margin-bottom:14px;flex-wrap:wrap}
.tab{padding:6px 13px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--text-2)}
.tab:hover{color:var(--text)}
.tab.active{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
.tab .n{color:var(--text-3);font-weight:500;margin-left:5px}
.tab.active .n{color:var(--accent)}
.section-title{margin:26px 0 12px;font-size:13px;font-weight:700;letter-spacing:-.01em;color:var(--text)}
.section-title:first-child{margin-top:0}
.section-note{font-size:12px;color:var(--text-3);margin:-8px 0 12px}
.kv{display:grid;grid-template-columns:210px 1fr;row-gap:0}
.kv dt{padding:11px 20px;font-size:12.5px;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
.kv dd{padding:11px 20px;font-size:13px;border-bottom:1px solid var(--border-soft)}
.kv dt:last-of-type,.kv dd:last-of-type{border-bottom:0}
.stack{display:grid;gap:14px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.storage-bar{display:flex;height:10px;border-radius:99px;overflow:hidden;margin:14px 20px 6px}
.storage-bar div{height:100%}
.legend{display:flex;gap:20px;padding:10px 20px 18px;flex-wrap:wrap}
.legend span{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2)}
.legend i{width:9px;height:9px;border-radius:3px}
.footer-row{display:flex;align-items:center;justify-content:space-between;padding:11px 20px;font-size:12px;color:var(--text-3)}
.pager{display:flex;gap:4px}
.pager button{width:26px;height:26px;border-radius:7px;font-size:12px;color:var(--text-2)}
.pager button.cur{background:var(--accent-soft);color:var(--accent);font-weight:700}
.pager button:disabled{opacity:.35;cursor:default}
.chart{width:100%;height:auto;display:block}
.chart-bar{fill:#2c3a52;rx:4}
.chart-bar.hot{fill:var(--accent)}
.chart-grid{stroke:#ffffff08}
.chart-label{font:10px var(--mono);fill:var(--text-3)}
.placeholder{border:1px dashed #2c3a52;border-radius:13px;padding:34px 24px;text-align:center;color:var(--text-2)}
.placeholder b{display:block;color:var(--text);margin-bottom:6px}
.empty{padding:26px;text-align:center;color:var(--text-3);font-size:13px}
.sec-label{font-size:11px;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3);margin:14px 20px 6px}
.sec-label:first-child{margin-top:18px}
.field-row{display:flex;gap:10px;align-items:center;margin-bottom:10px}
.field-row label{font-size:12px;color:var(--text-2);width:200px;flex:none}
@media(max-width:960px){.sidebar{display:none}.grid-kpi,.split{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<main class="page">
<header class="top">
<div><p class="eyebrow">Admin panel</p><h1>User &amp; run control</h1></div>
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><a href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
</header>
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
<div class="layout">
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
<div><div class="brand-name">SciMesh</div><div class="brand-sub">Coordinator Admin</div></div>
</div>
<nav class="nav" id="nav">
<div class="nav-label">Operate</div>
<button class="nav-item active" data-page="system"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>System</button>
<button class="nav-item" data-page="jobs"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Jobs</button>
<button class="nav-item" data-page="workers"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>Workers</button>
<div class="nav-label">Access</div>
<button class="nav-item" data-page="users"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 19c.7-3 2.9-4.5 5.5-4.5s4.8 1.5 5.5 4.5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.6c2.6.2 4.3 1.7 5 4.4"/></svg>Users &amp; keys</button>
<div class="nav-label">Platform</div>
<button class="nav-item" data-page="workloads"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>Workloads</button>
<button class="nav-item" data-page="metrics"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 19V5M4 19h16"/><path d="M8 15v-4M12 15V7M16 15v-6M20 15V9"/></svg>Metrics</button>
<button class="nav-item" data-page="settings"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z"/></svg>Settings</button>
</nav>
<div class="side-foot">
<div class="user-chip"><div class="avatar">{{.Role}}</div><div><b>Signed in as {{.Role}}</b><span>cluster administrator</span></div></div>
<a class="back-link" href="/ui/jobs/new"> New computation</a>
</div>
</aside>
<section class="card">
<h2>Manage a user</h2>
<p>Paste the user id (the JWT <code>sub</code> / the value shown at registration). Actions are applied immediately.</p>
<form method="post" action="/ui/admin/user-action">
<label for="user_id">User id</label>
<input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
<p class="hint">Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).</p>
<div class="actions">
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
<button class="btn btn-muted" name="action" value="demote" type="submit">Remove admin</button>
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
<button class="btn btn-muted" name="action" value="unverify" type="submit">Unverify</button>
<div class="main">
<header class="topbar">
<div><h1 id="page-title">System</h1><p id="page-sub">Cluster state and node information</p></div>
<div style="display:flex;gap:10px;align-items:center"><a class="btn btn-primary" href="/ui/jobs/new"> New computation</a><div class="env-badge"><i></i><span id="env-label">admin console</span></div></div>
</header>
<div class="content">
<!-- ═══ SYSTEM ═══ -->
<section class="page active" id="page-system">
<div class="grid-kpi">
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>Version</div><div class="k-value" id="k-version"></div><div class="k-sub" id="k-version-sub">loading…</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Uptime</div><div class="k-value" id="k-uptime"></div><div class="k-sub" id="k-started">loading…</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Active jobs</div><div class="k-value" id="k-active"></div><div class="k-sub" id="k-active-sub">loading…</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/></svg>Workers online</div><div class="k-value" id="k-workers"></div><div class="k-sub" id="k-workers-sub">loading…</div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h3>Storage usage</h3><span id="storage-total"></span></div>
<div class="storage-bar" id="storage-bar"><div style="width:0;background:#5b8cff"></div><div style="width:0;background:#7c5cff"></div><div style="width:0;background:#3fce8a"></div></div>
<div class="legend">
<span><i style="background:#5b8cff"></i>Datasets · <b id="storage-datasets"></b></span>
<span><i style="background:#7c5cff"></i>Artifacts · <b id="storage-artifacts"></b></span>
<span><i style="background:#3fce8a"></i>Database · <b id="storage-db"></b></span>
</div>
</form>
</section>
</div>
<div class="card">
<div class="card-head"><h3>Health</h3><span id="health-state"></span></div>
<dl class="kv">
<dt>Database</dt><dd id="h-db"></dd>
<dt>Userservice</dt><dd id="h-users"></dd>
<dt>Reducer</dt><dd id="h-reducer"></dd>
</dl>
</div>
</div>
<div class="section-title">Node information</div>
<div class="card">
<dl class="kv">
<dt>Binary</dt><dd><code id="n-binary"></code></dd>
<dt>Listen address</dt><dd><code id="n-addr"></code></dd>
<dt>Data directory</dt><dd><code id="n-datadir"></code></dd>
<dt>Database engine</dt><dd id="n-engine"></dd>
<dt>Public URL</dt><dd><code id="n-public"></code></dd>
</dl>
</div>
</section>
<section class="card">
<h2>Jobs &amp; tasks</h2>
<p>As an admin you already see <strong>every user's jobs</strong> on the dashboard, with per-task status and job cancellation. A regular user sees only their own.</p>
<div class="actions"><a class="btn btn-muted" href="/ui" style="text-decoration:none">Open the dashboard →</a></div>
</section>
</main>
<!-- ═══ JOBS ═══ -->
<section class="page" id="page-jobs">
<div class="tabs" id="job-tabs"></div>
<div class="card">
<table>
<thead><tr><th>Job</th><th>Workload</th><th>Owner</th><th>Status</th><th>Progress</th><th>Submitted</th><th></th></tr></thead>
<tbody id="job-rows"></tbody>
</table>
<div class="footer-row"><span id="job-range"></span><div class="pager"><button id="pg-prev" aria-label="previous"></button><button id="pg-next" aria-label="next"></button></div></div>
</div>
</section>
<!-- ═══ WORKERS ═══ -->
<section class="page" id="page-workers">
<div class="section-note">Workers register themselves. Trust decides whether a machine's results are accepted directly or need quorum.</div>
<div class="card">
<table>
<thead><tr><th>Worker</th><th>Status</th><th>Capabilities</th><th>Trust</th><th>Owner</th><th>Last signal</th></tr></thead>
<tbody id="worker-rows"></tbody>
</table>
</div>
</section>
<!-- ═══ USERS & KEYS ═══ -->
<section class="page" id="page-users">
<div class="section-title">Users</div>
<div class="card">
<table>
<thead><tr><th>Email</th><th>Role</th><th>Verified</th><th>Created</th></tr></thead>
<tbody id="user-rows"></tbody>
</table>
<div class="footer-row"><span id="user-count"></span></div>
</div>
<div class="section-title">Worker keys</div>
<div class="section-note">Keys let lab machines register as workers under a user account. Served instances can also use the cluster token.</div>
<div class="card">
<table>
<thead><tr><th>Label</th><th>Prefix</th><th>Owner</th><th>Created</th><th>Last used</th><th></th></tr></thead>
<tbody id="key-rows"></tbody>
</table>
</div>
<div class="section-title">Quick user action</div>
<div class="card">
<form method="post" action="/ui/admin/user-action" style="padding:16px 20px">
<div class="field-row"><label for="user_id">User id</label><input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required style="flex:1"></div>
<div style="display:flex;gap:8px;margin-left:210px;flex-wrap:wrap">
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
<button class="btn btn-ghost" name="action" value="demote" type="submit">Remove admin</button>
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
<button class="btn btn-ghost" name="action" value="unverify" type="submit">Unverify</button>
</div>
{{if .Msg}}<div style="margin-left:210px;margin-top:10px;color:var(--green);font-size:13px">✓ {{.Msg}}</div>{{end}}
{{if .Error}}<div style="margin-left:210px;margin-top:10px;color:var(--red);font-size:13px">✗ {{.Error}}</div>{{end}}
</form>
</div>
</section>
<!-- ═══ WORKLOADS ═══ -->
<section class="page" id="page-workloads">
<div class="section-note">Disabled workloads are rejected at submit time and hidden from the job form. Settings persist in the database.</div>
<div class="card">
<table>
<thead><tr><th>Workload</th><th>Reduction</th><th>Parameters</th><th>Dataset upload</th><th>Enabled</th></tr></thead>
<tbody id="workload-rows"></tbody>
</table>
</div>
</section>
<!-- ═══ METRICS ═══ -->
<section class="page" id="page-metrics">
<div class="grid-kpi">
<div class="kpi"><div class="k-label">Jobs · 7 days</div><div class="k-value" id="m-jobs7"></div><div class="k-sub">created in the last week</div></div>
<div class="kpi"><div class="k-label">Shards completed</div><div class="k-value" id="m-shards"></div><div class="k-sub" id="m-shards-sub">across all workers</div></div>
<div class="kpi"><div class="k-label">Avg shard time</div><div class="k-value" id="m-avg"></div><div class="k-sub">completed shards only</div></div>
<div class="kpi"><div class="k-label">Failure rate</div><div class="k-value" id="m-failrate"></div><div class="k-sub" id="m-failrate-sub"></div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h3>Jobs per day</h3><span>last 7 days</span></div>
<div style="padding:16px 20px 10px" id="chart-days"></div>
</div>
<div class="card">
<div class="card-head"><h3>Jobs by workload</h3><span>all time</span></div>
<dl class="kv" id="chart-workloads"></dl>
</div>
</div>
</section>
<!-- ═══ SETTINGS ═══ -->
<section class="page" id="page-settings">
<div class="warn-strip" style="display:flex;gap:10px;align-items:flex-start;background:var(--amber-soft);border:1px solid #e5b64f33;border-radius:10px;padding:12px 14px;font-size:12.5px;color:#eecf8d"><span>The cluster token below authenticates <b>any</b> worker. Reveal it only on a trusted machine.</span></div>
<div class="section-title">Cluster</div>
<div class="card">
<dl class="kv">
<dt>Worker token</dt><dd><div class="secret"><code id="tok">••••••••••••••••••••••••</code><button class="btn btn-ghost btn-sm" id="reveal">Reveal</button></div></dd>
<dt>Public URL</dt><dd><code id="s-public"></code></dd>
<dt>Listen address</dt><dd><code id="s-addr"></code></dd>
<dt>Data directory</dt><dd><code id="s-datadir"></code></dd>
<dt>Database engine</dt><dd id="s-engine"></dd>
<dt>Binary</dt><dd><code id="s-binary"></code></dd>
</dl>
</div>
</section>
</div>
</div>
</div>
<script>
const titles={system:['System','Cluster state and node information'],jobs:['Jobs','Every computation, filterable and paginated'],workers:['Workers','Fleet overview and trust management'],users:['Users & keys','Accounts, roles and worker keys'],workloads:['Workloads','Catalog entries and availability'],metrics:['Metrics','Throughput and reliability, last 7 days'],settings:['Settings','Cluster, storage and security']};
const statusLabel={pending:'Waiting',leased:'Assigned',running:'Running',reducing:'Merging',completed:'Completed',failed:'Failed',cancelled:'Cancelled'};
const statusClass={pending:'pill-waiting',leased:'pill-active',running:'pill-active',reducing:'pill-active',completed:'pill-success',failed:'pill-danger',cancelled:'pill-waiting'};
const fmtBytes=b=>{if(b==null||b<0)return '';if(b<1024)return b+' B';if(b<1048576)return (b/1024).toFixed(1)+' KB';if(b<1073741824)return (b/1048576).toFixed(1)+' MB';return (b/1073741824).toFixed(2)+' GB'};
const fmtTime=t=>t?new Date(t).toLocaleString():'—';
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let timer=null,current={page:'system',jobsStatus:'',jobsPage:1};
const setPage=page=>{current.page=page;document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+page));const [t,s]=titles[page];document.getElementById('page-title').textContent=t;document.getElementById('page-sub').textContent=s;if(timer){clearInterval(timer);timer=null}refresh();timer=setInterval(refresh,5000)};
document.querySelectorAll('.nav-item').forEach(b=>b.addEventListener('click',()=>setPage(b.dataset.page)));
const refresh=()=>{if(document.hidden)return;const p=current.page;if(p==='system')loadSystem();else if(p==='jobs')loadJobs();else if(p==='metrics')loadMetrics();else if(p==='workers')loadWorkers();else if(p==='users')loadUsers();else if(p==='workloads')loadWorkloads();else if(p==='settings')loadSettings()};
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
async function loadSystem(){
const r=await fetch('/ui/admin/api/system',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
document.getElementById('k-version').textContent=v.version;
document.getElementById('k-version-sub').textContent=v.node.db_engine+' · '+navigator.platform;
const secs=v.uptime_seconds;
const uptime=secs<3600?(secs/60).toFixed(0)+' min':secs<86400?(secs/3600).toFixed(1)+' h':(secs/86400).toFixed(1)+' d';
document.getElementById('k-uptime').textContent=uptime;
document.getElementById('k-started').textContent='since '+fmtTime(v.started_at);
document.getElementById('k-active').textContent=v.active_jobs;
document.getElementById('k-active-sub').textContent=v.running_jobs+' running · '+v.waiting_jobs+' waiting';
document.getElementById('k-workers').textContent=v.workers_online;
document.getElementById('k-workers-sub').textContent=(v.workers_total-v.workers_online)+' offline · '+v.workers_busy+' busy';
const total=v.storage.datasets_bytes+v.storage.artifacts_bytes+v.storage.database_bytes;
document.getElementById('storage-total').textContent=fmtBytes(total);
const pct=b=>total?Math.round(b*100/total)+'%':'0%';
document.getElementById('storage-bar').children[0].style.width=pct(v.storage.datasets_bytes);
document.getElementById('storage-bar').children[1].style.width=pct(v.storage.artifacts_bytes);
document.getElementById('storage-bar').children[2].style.width=pct(v.storage.database_bytes);
document.getElementById('storage-datasets').textContent=fmtBytes(v.storage.datasets_bytes);
document.getElementById('storage-artifacts').textContent=fmtBytes(v.storage.artifacts_bytes);
document.getElementById('storage-db').textContent=fmtBytes(v.storage.database_bytes);
document.getElementById('h-db').innerHTML=pill(v.health.database==='connected'?'Connected':'Error','pill-success',v.health.database==='connected'?'pill-danger':null);
document.getElementById('h-users').innerHTML=pill(cap(v.health.userservice),'pill-success',null);
document.getElementById('h-reducer').innerHTML=pill(cap(v.health.reducer),'pill-waiting',null);
document.getElementById('health-state').textContent=v.health.database==='connected'?'all checks pass':'database unreachable';
document.getElementById('n-binary').textContent=v.node.binary||'—';
document.getElementById('n-addr').textContent=v.node.addr;
document.getElementById('n-datadir').textContent=v.node.data_dir||'—';
document.getElementById('n-engine').textContent=v.node.db_engine;
document.getElementById('n-public').textContent=v.node.public_url||'—';
document.getElementById('env-label').textContent=(v.node.public_url||'').replace(/^https?:\/\//,'')+' · '+v.node.db_engine+' · '+v.node.addr;
}
const cap=s=>s?s.charAt(0).toUpperCase()+s.slice(1):'—';
const pill=(text,cls,fail)=>{const c=fail||cls;return '<span class="pill '+c+'"><i></i>'+esc(text)+'</span>'};
let jobFilter={};
async function loadJobs(){
const page=current.jobsPage,status=current.jobsStatus;
const r=await fetch('/ui/admin/api/jobs?page='+page+'&per_page=10'+(status?'&status='+status:''),{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
const tabs=document.getElementById('job-tabs');
const defs=[['', 'All'],['running','Running'],['pending','Waiting'],['completed','Completed'],['failed','Failed'],['cancelled','Cancelled']];
tabs.replaceChildren();
for(const [key,label] of defs){
const n=v.counts[key]||0;
const b=document.createElement('button');
b.className='tab'+(key===status?' active':'');
b.innerHTML=label+'<span class="n">'+n+'</span>';
b.addEventListener('click',()=>{current.jobsStatus=key;current.jobsPage=1;loadJobs()});
tabs.append(b);
}
const rows=document.getElementById('job-rows');
rows.replaceChildren();
if(!v.jobs.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No jobs'+(status?' with this status':'')+'.</div></td>';rows.append(tr)}
for(const j of v.jobs){
const tr=document.createElement('tr');
const pct=j.total?Math.min(100,Math.round((j.completed+j.failed)*100/j.total)):0;
tr.innerHTML='<td><div class="t-main">'+esc(j.workload)+'</div><div class="t-sub">'+esc(j.id.slice(0,8))+'…</div></td>'+
'<td><code style="color:var(--text-2)">'+esc(j.workload)+'</code></td>'+
'<td style="color:var(--text-2)">'+esc(j.owner)+'</td>'+
'<td>'+pill(statusLabel[j.status]||j.status,statusClass[j.status]||'pill-waiting',null)+'</td>'+
'<td><div class="bar"><span style="width:'+pct+'%"></span></div><div class="bar-label">'+j.completed+' / '+j.total+' shards'+(j.failed?' · '+j.failed+' failed':'')+'</div></td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(j.created_at)+'</td>'+
'<td><a class="btn btn-ghost btn-sm" href="/ui/jobs/'+encodeURIComponent(j.id)+'">Open</a></td>';
rows.append(tr);
}
const from=(v.page-1)*v.per_page+1,to=Math.min(v.page*v.per_page,v.total);
document.getElementById('job-range').textContent=v.total?(from+''+to+' of '+v.total+' jobs'):'no jobs';
const prev=document.getElementById('pg-prev'),next=document.getElementById('pg-next');
prev.disabled=v.page<=1;next.disabled=v.page*v.per_page>=v.total;
prev.onclick=()=>{current.jobsPage--;loadJobs()};
next.onclick=()=>{current.jobsPage++;loadJobs()};
}
function dayChart(days){
const max=Math.max(1,...days.map(d=>d.count));
const w=460,h=150,bw=44,gap=18,base=118;
let bars='';
days.forEach((d,i)=>{const x=12+i*(bw+gap),bh=Math.round(d.count*base/max);bars+='<rect class="chart-bar'+(d.count===max&&d.count>0?' hot':'')+'" x="'+x+'" y="'+(base-bh+6)+'" width="'+bw+'" height="'+bh+'"/>';bars+='<text class="chart-label" x="'+x+'" y="146">'+d.day.slice(5)+'</text>'});
return '<svg class="chart" viewBox="0 0 '+w+' '+h+'"><line class="chart-grid" x1="0" y1="30" x2="'+w+'" y2="30"/><line class="chart-grid" x1="0" y1="60" x2="'+w+'" y2="60"/><line class="chart-grid" x1="0" y1="90" x2="'+w+'" y2="90"/>'+bars+'</svg>';
}
async function loadMetrics(){
const r=await fetch('/ui/admin/api/metrics',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
document.getElementById('m-jobs7').textContent=v.jobs_last_7_days;
document.getElementById('m-shards').textContent=v.shards_completed.toLocaleString();
document.getElementById('m-shards-sub').textContent=(v.shards_completed+v.shards_failed)+' shards · '+v.shards_failed+' failed';
document.getElementById('m-avg').textContent=v.avg_shard_seconds?v.avg_shard_seconds.toFixed(1)+'s':'—';
document.getElementById('m-failrate').textContent=(v.failure_rate*100).toFixed(1)+'%';
document.getElementById('m-failrate-sub').textContent=v.shards_failed+' of '+(v.shards_completed+v.shards_failed)+' shards failed';
document.getElementById('chart-days').innerHTML=dayChart(v.jobs_by_day);
const wl=document.getElementById('chart-workloads');
wl.replaceChildren();
if(!v.jobs_by_workload.length){const p=document.createElement('p');p.className='empty';p.textContent='No workloads used yet.';wl.append(p)}
const max=Math.max(1,...v.jobs_by_workload.map(w=>w.count));
for(const w of v.jobs_by_workload){
const dt=document.createElement('dt');dt.textContent=w.workload;
const dd=document.createElement('dd');
dd.innerHTML='<div class="bar" style="width:100%"><span style="width:'+Math.round(w.count*100/max)+'%"></span></div>';
wl.append(dt,dd);
}
}
setPage('system');
const workerStatusPill=s=>({online:['Online','pill-success'],busy:['Busy','pill-active'],offline:['Offline','pill-waiting']}[s]||[s,'pill-waiting']);
async function loadWorkers(){
const r=await fetch('/ui/admin/api/workers',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
const rows=document.getElementById('worker-rows');
rows.replaceChildren();
if(!v.workers.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No worker is registered yet.</div></td>';rows.append(tr);return}
for(const w of v.workers){
const tr=document.createElement('tr');
const [label,cls]=workerStatusPill(w.status);
const trustSel='<select data-id="'+w.id+'" class="trust-sel" '+(w.status==='offline'?'disabled':'')+'><option value="trusted" '+(w.trust==='trusted'?'selected':'')+'>Trusted</option><option value="untrusted" '+(w.trust==='untrusted'?'selected':'')+'>Untrusted</option></select>';
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub">'+esc(w.id.slice(0,8))+'…</div></td>'+
'<td>'+pill(label,cls,null)+'</td>'+
'<td>'+(w.capabilities||[]).map(c=>'<span class="cap">'+esc(c)+'</span>').join('')+'</td>'+
'<td>'+trustSel+'</td>'+
'<td style="color:var(--text-2)">'+esc(w.owner)+'</td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(w.last_heartbeat_at)+'</td>';
rows.append(tr);
}
document.querySelectorAll('.trust-sel').forEach(sel=>sel.addEventListener('change',async()=>{
await fetch('/ui/admin/api/workers/'+sel.dataset.id+'/trust',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trusted:sel.value==='trusted'})});
loadWorkers();
}));
}
async function loadUsers(){
const r=await fetch('/ui/admin/api/users',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
const rows=document.getElementById('user-rows');
rows.replaceChildren();
for(const u of v.users||[]){
const tr=document.createElement('tr');
const roleSel='<select class="role-sel" data-id="'+u.id+'"><option value="user" '+(u.role==='user'?'selected':'')+'>user</option><option value="admin" '+(u.role==='admin'?'selected':'')+'>admin</option></select>';
tr.innerHTML='<td><div class="t-main">'+esc(u.email)+'</div></td>'+
'<td>'+roleSel+'</td>'+
'<td>'+pill(u.verified?'Verified':'—',u.verified?'pill-success':'pill-waiting',null)+'</td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(u.created_at)+'</td>';
rows.append(tr);
}
document.getElementById('user-count').textContent=(v.users||[]).length+' users';
document.querySelectorAll('.role-sel').forEach(sel=>sel.addEventListener('change',async()=>{
await fetch('/ui/admin/api/users/'+sel.dataset.id+'/role',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({role:sel.value})});
loadUsers();
}));
const keys=await (await fetch('/ui/admin/api/worker-keys',{headers:{Accept:'application/json'}})).json();
const keyRows=document.getElementById('key-rows');
keyRows.replaceChildren();
const emailOf={};for(const u of v.users||[])emailOf[u.id]=u.email;
for(const k of keys.worker_keys||[]){
const tr=document.createElement('tr');
tr.innerHTML='<td class="t-main">'+esc(k.name)+'</td>'+
'<td><code style="color:var(--text-2)">'+esc(k.prefix)+'…</code></td>'+
'<td style="color:var(--text-2)">'+esc(emailOf[k.user_id]||k.user_id.slice(0,8)+'…')+'</td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(k.created_at)+'</td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+(k.last_used_at?fmtTime(k.last_used_at):'never')+'</td>'+
'<td>'+((k.revoked_at)?'<span class="pill pill-danger"><i></i>Revoked</span>':'<button class="btn btn-danger btn-sm key-revoke" data-id="'+k.id+'">Revoke</button>')+'</td>';
keyRows.append(tr);
}
document.querySelectorAll('.key-revoke').forEach(btn=>btn.addEventListener('click',async()=>{
if(!confirm('Revoke this worker key? The machine will be cut off on its next refresh.'))return;
await fetch('/ui/admin/api/worker-keys/'+btn.dataset.id+'/revoke',{method:'POST'});
loadUsers();
}));
}
async function loadWorkloads(){
const r=await fetch('/ui/admin/api/workloads',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
const rows=document.getElementById('workload-rows');
rows.replaceChildren();
for(const w of v.workloads||[]){
const tr=document.createElement('tr');
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub" style="font-family:inherit">'+esc(w.description||'')+'</div></td>'+
'<td><span class="pill pill-active"><i></i>'+esc(w.reduction)+'</span></td>'+
'<td style="color:var(--text-2);font-size:12.5px">'+w.parameters+' declared</td>'+
'<td>'+pill(w.upload_ready?'ready':'—',w.upload_ready?'pill-success':'pill-waiting',null)+'</td>'+
'<td><button class="toggle wl-toggle '+(w.enabled?'on':'')+'" data-name="'+w.name+'" aria-label="enabled"></button></td>';
rows.append(tr);
}
document.querySelectorAll('.wl-toggle').forEach(t=>t.addEventListener('click',async()=>{
const enabled=!t.classList.contains('on');
await fetch('/ui/admin/api/workloads/'+encodeURIComponent(t.dataset.name)+'/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
t.classList.toggle('on',enabled);
}));
}
async function loadSettings(){
const r=await fetch('/ui/admin/api/settings',{headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
document.getElementById('s-public').textContent=v.public_url||'—';
document.getElementById('s-addr').textContent=v.addr;
document.getElementById('s-datadir').textContent=v.data_dir||'—';
document.getElementById('s-engine').textContent=v.db_engine;
document.getElementById('s-binary').textContent=v.binary||'—';
}
document.getElementById('reveal').addEventListener('click',async e=>{
const tok=document.getElementById('tok');
if(tok.textContent.startsWith('•')){
const r=await fetch('/ui/admin/api/token/reveal',{method:'POST',headers:{Accept:'application/json'}});
if(!r.ok)return;
const v=await r.json();
tok.textContent=v.token||'(none)';
e.target.textContent='Hide';
}else{tok.textContent='••••••••••••••••••••••••';e.target.textContent='Reveal'}
});
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
</script>
</body>
</html>
{{end}}
@@ -1,42 +0,0 @@
{{define "dashboard.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh control room</title>
<style>
:root{color:#dce8ff;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 15% -10%,#163d77 0,transparent 32rem),radial-gradient(circle at 95% 5%,#123e39 0,transparent 29rem),#08111f}.page{max-width:1240px;margin:auto;padding:28px 22px 64px}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:22px}.eyebrow{margin:0;color:#78a9ff;font-size:.77rem;font-weight:750;letter-spacing:.14em;text-transform:uppercase}.title{max-width:700px;margin:8px 0;font-size:clamp(2rem,5vw,3.6rem);line-height:1.04;letter-spacing:-.055em}.lead{max-width:690px;margin:0;color:#aabbd5;font-size:1.05rem}.button{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:10px;padding:12px 16px;background:#4f8cff;color:#071224;font:inherit;font-weight:800;text-decoration:none;box-shadow:0 12px 30px #163d7766}.live{display:inline-flex;align-items:center;gap:7px;margin-top:18px;color:#8ba2c2;font-size:.87rem}.pulse{width:8px;height:8px;border-radius:50%;background:#5ee6a6;box-shadow:0 0 0 5px #5ee6a622}.summary{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:13px;margin:32px 0}.panel,.metric,.flow-step,.job,.worker{border:1px solid #26415f;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #0000001f}.panel{padding:20px}.pipeline{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:16px}.flow-step{position:relative;padding:14px;min-height:116px}.flow-step:not(:last-child):after{content:"";position:absolute;top:35px;right:-10px;width:10px;height:2px;background:#3c5d83}.flow-step b{display:block;color:#f2f7ff}.flow-step small{display:block;margin-top:6px;color:#91a8c6}.flow-step .dot{display:inline-block;width:9px;height:9px;margin-right:7px;border-radius:50%;background:#5ee6a6}.metric{padding:16px}.metric b{display:block;margin-top:7px;color:#f5f8ff;font-size:2rem;line-height:1}.metric span{color:#9bb0cc;font-size:.84rem}.section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:36px 0 12px}.section-head h2{margin:0;color:#f3f7ff;font-size:1.18rem}.section-head p{margin:0;color:#8fa6c3;font-size:.9rem}.jobs{display:grid;gap:10px}.job{display:grid;grid-template-columns:minmax(210px,1.35fr) minmax(150px,.9fr) minmax(180px,1fr) auto;gap:18px;align-items:center;padding:17px 18px;text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}.job:hover{border-color:#5d96ee;transform:translateY(-1px)}.job-name{color:#f3f7ff;font-weight:750}.job-id{margin-top:3px;color:#8196b3;font-family:ui-monospace,SFMono-Regular,monospace;font-size:.76rem}.badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;font-size:.78rem;font-weight:800}.badge-waiting{background:#23344d;color:#b9cce9}.badge-active{background:#173d77;color:#9fc7ff}.badge-success{background:#123f34;color:#76efb5}.badge-danger{background:#552334;color:#ff9bad}.bar{height:7px;margin-top:8px;overflow:hidden;border-radius:999px;background:#20344e}.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5a92ff,#61e1bd)}.numbers{color:#afc0d9;font-size:.88rem}.arrow{color:#79aaff;font-size:1.35rem}.workers{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}.worker{padding:15px}.worker-head{display:flex;justify-content:space-between;gap:8px}.worker strong{color:#f3f7ff}.worker small,.worker p{color:#95a9c4}.worker p{margin:12px 0 0}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #365576;border-radius:5px;padding:2px 6px;color:#a9c9f4;font:.75rem ui-monospace,SFMono-Regular,monospace}.empty{padding:30px;border:1px dashed #35516f;border-radius:14px;color:#9ab0cb;text-align:center}.offline{color:#faafbd}.sr{position:absolute;width:1px;height:1px;clip:rect(0,0,0,0);overflow:hidden;white-space:nowrap}@media(max-width:820px){.top,.section-head{display:block}.button{margin-top:18px}.summary{grid-template-columns:1fr 1fr}.panel{grid-column:span 2}.pipeline{grid-template-columns:1fr 1fr}.flow-step:not(:last-child):after{display:none}.job{grid-template-columns:1fr 1fr}.arrow{display:none}.workers{grid-template-columns:1fr 1fr}}@media(max-width:540px){.page{padding:22px 14px}.summary,.workers{grid-template-columns:1fr}.panel{grid-column:auto}.pipeline,.job{grid-template-columns:1fr}.title{font-size:2.35rem}}
</style>
</head>
<body>
<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/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>
<div class="metric"><span>Active runs</span><b id="active-jobs">{{.ActiveJobs}}</b><small>waiting, running, or merging</small></div>
<div class="metric"><span>Available workers</span><b id="online-workers">{{.OnlineWorkers}}</b><small>recently registered</small></div>
<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 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>
<script>
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 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))};
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);renderMyWorkers(view.my_workers||[]);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
const start=()=>{if(!timer&&!document.hidden)timer=setInterval(refresh,2000)};document.addEventListener('visibilitychange',()=>{if(document.hidden&&timer){clearInterval(timer);timer=undefined}else start()});start();
</script>
</body>
</html>
{{end}}
@@ -16,7 +16,7 @@
<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>
<a class="back" href="/ui/admin">← Back to the control room</a>
</div>
</main>
</body>
@@ -12,7 +12,7 @@
</head>
<body>
<main class="page">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui">← Back to control room</a>{{if .Session}}<div style="display:flex;gap:10px;align-items:center"><a class="back" href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form></div>{{end}}</div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui/admin">← Back to control room</a>{{if .Session}}<div style="display:flex;gap:10px;align-items:center"><a class="back" href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form></div>{{end}}</div>
<div class="top"><div><p class="eyebrow">{{workloadLabel .Workload}}</p><h1 class="title">Live pipeline</h1><p class="subtitle">One job, shown from accepted input through its final coordinator-owned scientific result.</p></div><div class="live" id="refresh-state">Live · refreshes every 2 seconds</div></div>
<section class="panel summary"><div class="summary-top"><div><span id="status" class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><p id="hint" class="hint">{{statusHint .Status}}</p></div><div id="stop-wrap" {{if not (cancellable .Status)}}class="hidden"{{end}}><button id="stop-job" class="stop" type="button">Stop unfinished shards</button><div class="live">Completed shards are preserved.</div></div></div><div class="bar"><span id="progress-bar" style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div><p class="progress-line" id="progress">{{.Completed}} of {{.Total}} shards complete</p><div class="metrics"><div class="metric"><b id="total">{{.Total}}</b><small>total shards</small></div><div class="metric"><b id="completed">{{.Completed}}</b><small>completed</small></div><div class="metric"><b id="pending">{{.Pending}}</b><small>waiting</small></div><div class="metric"><b id="active">{{add .Leased .Running}}</b><small>with workers</small></div><div class="metric"><b id="failed">{{.Failed}}</b><small>failed</small></div><div class="metric"><b id="cancelled">{{.Cancelled}}</b><small>stopped</small></div></div></section>
@@ -13,13 +13,17 @@
<p class="eyebrow">SciMesh</p>
<h1>Sign in</h1>
<form method="post" action="/ui/login">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="button" type="submit">Sign in</button>
</form>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if eq .Error "admin role required"}}
<p class="error">The admin console is reserved for the cluster administrator.</p>
<p class="alt">You are signed in as a non-admin. <a href="/ui/logout-form">Log out</a>, then sign in with the admin account — its login is printed by <code>coordinator serve</code> on first start and stored in <code>~/.scimesh/admin.password</code>.</p>
{{else if .Error}}<p class="error">{{.Error}}</p>{{end}}
<p class="alt">No account? <a href="/ui/register">Register</a></p>
</main>
</body>
@@ -0,0 +1,24 @@
{{define "logout-form.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign out · 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;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 12px;color:#f4f8ff;font-size:1.5rem;letter-spacing:-.03em}p{margin:0 0 18px;color:#9fb3cf;font-size:.92rem}code{color:#cfe0ff}.button{display:block;width:100%;margin-top:4px;border:0;border-radius:10px;padding:12px 16px;background:#ff7d92;color:#230810;font:inherit;font-weight:850;cursor:pointer}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}
</style>
</head>
<body>
<main class="card">
<p class="eyebrow">SciMesh</p>
<h1>Sign out</h1>
<p>End the current session so you can sign in with a different account (for example the cluster administrator).</p>
<form method="post" action="/ui/logout">
<button class="button" type="submit">Log out</button>
</form>
<p class="alt">Changed your mind? <a href="/ui/login">Back to sign in</a></p>
</main>
</body>
</html>
{{end}}
@@ -11,7 +11,7 @@
</head>
<body>
<main class="page">
<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>
<a class="back" href="/ui/admin">← 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>
@@ -12,7 +12,7 @@
<main class="page">
<header class="top">
<div><p class="eyebrow">Account</p><h1>Your profile</h1></div>
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
<div style="display:flex;gap:10px;align-items:center"><a href="/ui/admin">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
</header>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
@@ -48,7 +48,7 @@
<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>
<p class="lead" style="margin-top:26px"><a class="back" href="/ui/admin">← Back to the control room</a></p>
</main>
</body>
</html>
+9 -21
View File
@@ -217,29 +217,17 @@ func (s *Server) renderUI(w http.ResponseWriter, name string, data any) {
}
}
// handleUIHome lands the operator on the real UI. In session mode that is the
// admin console; under basic auth (no userservice, no roles) it is the job
// form, since the console does not exist there. The old demo control room is
// gone; /ui is a plain redirect so stale bookmarks still arrive somewhere
// useful.
func (s *Server) handleUIHome(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Dashboard.Overview(ctx, 20)
if err != nil {
s.writeError(w, r, err)
return
target := "/ui/jobs/new"
if s.uiSessionMode() {
target = "/ui/admin"
}
s.renderUI(w, "dashboard.html", view)
}
// handleUIOverviewJSON is the bounded polling projection used by the operator
// dashboard. It intentionally returns only the safe UI read model, never
// worker tokens, storage keys, or database entities.
func (s *Server) handleUIOverviewJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Dashboard.Overview(ctx, 20)
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) handleUINewJob(w http.ResponseWriter, r *http.Request) {
+281 -3
View File
@@ -2,14 +2,17 @@ package http
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// adminUserActions are the userservice endpoints the admin panel may invoke, by
@@ -23,12 +26,19 @@ var adminUserActions = map[string]bool{
}
// requireAdmin gates a route on the session caller being an admin. It runs
// inside withUISession, which has already stamped the requester. A non-admin is
// sent back to the dashboard rather than shown the panel.
// inside withUISession, which has already stamped the requester. A signed-in
// non-admin is told why (and bounced to the login with the message); an
// unauthenticated caller never gets here — the gate has already sent them to
// the login page with the intended destination.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
http.Redirect(w, r, "/ui", http.StatusSeeOther)
target := "/ui/login?error=admin+role+required"
// Keep the destination so a successful login lands straight back.
if strings.HasPrefix(r.URL.Path, "/ui/") {
target += "&next=" + url.QueryEscape(r.URL.Path)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
@@ -112,3 +122,271 @@ func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer
}
return resp.StatusCode, body, nil
}
// handleUIAdminSystemJSON serves the admin "System" page: process info,
// storage figures and health. Admin-only via the route chain.
func (s *Server) handleUIAdminSystemJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Admin.System(ctx)
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// handleUIAdminJobsJSON serves one page of the admin jobs table. The owner
// emails are resolved from the userservice when it is reachable; the resolver
// failing is not fatal (cards fall back to short ids).
func (s *Server) handleUIAdminJobsJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
view, err := s.uc.Admin.Jobs(ctx, status, page, perPage, s.adminOwnerEmails(r))
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// handleUIAdminMetricsJSON serves the admin "Metrics" page.
func (s *Server) handleUIAdminMetricsJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Admin.Metrics(ctx)
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// handleUIAdminWorkersJSON serves the admin "Workers" page.
func (s *Server) handleUIAdminWorkersJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Admin.Workers(ctx, s.adminOwnerEmails(r))
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// handleUIAdminSetTrustJSON flips one worker's trust level.
func (s *Server) handleUIAdminSetTrustJSON(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
var body struct {
Trusted bool `json:"trusted"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
ctx, cancel := s.reqCtx(r)
defer cancel()
if err := s.uc.Admin.SetTrust(ctx, id, body.Trusted); err != nil {
s.writeError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUIAdminWorkloadsJSON serves the catalog with persisted enable flags.
func (s *Server) handleUIAdminWorkloadsJSON(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.Admin.Workloads(ctx)
if err != nil {
s.writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// handleUIAdminSetWorkloadEnabledJSON flips a workload's enable flag.
func (s *Server) handleUIAdminSetWorkloadEnabledJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
ctx, cancel := s.reqCtx(r)
defer cancel()
if err := s.uc.Admin.SetWorkloadEnabled(ctx, name, body.Enabled); err != nil {
s.writeError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUIAdminSettingsJSON serves the read-only cluster settings.
func (s *Server) handleUIAdminSettingsJSON(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.uc.Admin.Settings())
}
// handleUIAdminRevealTokenJSON reveals the shared worker token, auditing the
// reveal. Admin-only via the route chain.
func (s *Server) handleUIAdminRevealTokenJSON(w http.ResponseWriter, r *http.Request) {
actor := "admin"
if req, ok := authctx.From(r.Context()); ok {
actor = req.Role + ":" + req.UserID.String()
}
ctx, cancel := s.reqCtx(r)
defer cancel()
writeJSON(w, http.StatusOK, map[string]string{"token": s.uc.Admin.RevealWorkerToken(ctx, actor)})
}
// handleUIAdminUsersJSON serves the account table, proxied from the
// userservice. The userservice projects away password hashes; a failure here
// is a 502 rather than a silent empty table.
func (s *Server) handleUIAdminUsersJSON(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
redirectToLogin(w, r)
return
}
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
if err != nil {
s.writeError(w, r, err)
return
}
if status != http.StatusOK {
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
// handleUIAdminSetUserRoleJSON changes a user's role through the userservice
// promote/demote actions.
func (s *Server) handleUIAdminSetUserRoleJSON(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
var body struct {
Role string `json:"role"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
action := ""
switch body.Role {
case "admin":
action = "promote"
case "user":
action = "demote"
default:
s.writeError(w, r, domain.ErrInvalidInput)
return
}
c, err := r.Cookie(sessionCookie)
if err != nil {
redirectToLogin(w, r)
return
}
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+id.String()+"/"+action, c.Value)
if err != nil {
s.writeError(w, r, err)
return
}
if status != http.StatusNoContent {
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUIAdminWorkerKeysJSON serves every worker key with its owning user,
// proxied from the userservice.
func (s *Server) handleUIAdminWorkerKeysJSON(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
redirectToLogin(w, r)
return
}
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/worker-keys/all", c.Value)
if err != nil {
s.writeError(w, r, err)
return
}
if status != http.StatusOK {
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
// handleUIAdminRevokeKeyJSON revokes any worker key through the userservice
// (whose DELETE endpoint already lets an admin revoke keys of any owner).
func (s *Server) handleUIAdminRevokeKeyJSON(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
c, err := r.Cookie(sessionCookie)
if err != nil {
redirectToLogin(w, r)
return
}
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodDelete, "/worker-keys/"+id.String(), c.Value)
if err != nil {
s.writeError(w, r, err)
return
}
if status != http.StatusNoContent {
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// adminOwnerEmails resolves job owner ids to emails through the userservice,
// which is the only place email addresses live. It never blocks the page on
// failure: an empty map leaves the admin jobs table on short ids.
func (s *Server) adminOwnerEmails(r *http.Request) map[uuid.UUID]string {
if s.userserviceURL == "" {
return nil
}
c, err := r.Cookie(sessionCookie)
if err != nil {
return nil
}
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
if err != nil || status != http.StatusOK {
return nil
}
var users []struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.Unmarshal(body, &users); err != nil {
return nil
}
out := make(map[uuid.UUID]string, len(users))
for _, user := range users {
id, err := uuid.Parse(user.ID)
if err != nil {
continue
}
out[id] = user.Email
}
return out
}
@@ -11,7 +11,6 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
func adminReq(t *testing.T, role string) *http.Request {
@@ -30,15 +29,15 @@ func TestRequireAdminAllowsAdminOnly(t *testing.T) {
t.Error("admin must reach the handler")
}
// Plain user is redirected to the dashboard.
// Plain user is redirected to the login with the reason and the destination.
reached = false
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminReq(t, "user"))
if reached {
t.Error("non-admin must not reach the handler")
}
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required&next=%2Fui%2Fadmin" {
t.Errorf("non-admin got %d -> %q, want 303 -> login with the admin-required error and next", rec.Code, rec.Header().Get("Location"))
}
}
@@ -101,13 +100,24 @@ func TestAdminUserActionRejectsBadID(t *testing.T) {
}
}
func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) {
admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
if !strings.Contains(admin, "/ui/admin") {
t.Error("admin must see the Admin link")
func TestLoginPageExplainsAdminRequiredError(t *testing.T) {
html := render(t, "login.html", map[string]any{"Error": "admin role required"})
if !strings.Contains(html, "/ui/logout-form") {
t.Error("the admin-required error must offer a logout path to switch accounts")
}
user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}})
if strings.Contains(user, "/ui/admin") {
t.Error("a plain user must not see the Admin link")
if !strings.Contains(html, "cluster administrator") {
t.Error("the admin-required error must name the admin account")
}
// Other errors keep the plain message, no logout teaser.
plain := render(t, "login.html", map[string]any{"Error": "invalid email or password"})
if strings.Contains(plain, "/ui/logout-form") {
t.Error("plain login errors must not advertise logout")
}
}
func TestLogoutFormRendersPostButton(t *testing.T) {
html := render(t, "logout-form.html", map[string]any{})
if !strings.Contains(html, `action="/ui/logout"`) || !strings.Contains(html, "Log out") {
t.Error("logout form must POST /ui/logout")
}
}
+29 -3
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
@@ -53,7 +55,14 @@ type tokenVerifier interface {
}
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error"), "Next": r.URL.Query().Get("next")})
}
// handleUILogoutForm renders a small confirm page for ending the current
// session. The actual logout stays a POST (/ui/logout); this page exists so a
// signed-in non-admin who hit an admin-only page can switch accounts.
func (s *Server) handleUILogoutForm(w http.ResponseWriter, r *http.Request) {
s.renderUI(w, "logout-form.html", map[string]any{})
}
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
@@ -85,7 +94,14 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
return
}
setSessionCookie(w, r, resp.Token)
http.Redirect(w, r, "/ui", http.StatusSeeOther)
// Land back where the user was headed (e.g. /ui/admin); never follow a
// value that escapes the UI prefix — that would be an open redirect.
next := strings.TrimSpace(r.FormValue("next"))
if next == "" || !strings.HasPrefix(next, "/ui/") {
next = "/ui/admin"
}
//nolint:gosec // G710: next is validated to start with /ui/ just above
http.Redirect(w, r, next, http.StatusSeeOther)
}
// handleUIRegister creates an account through the userservice, then sends the
@@ -167,5 +183,15 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
}
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
// Remember where the user was headed so a successful login lands back
// there (e.g. /ui/admin) instead of the control room.
next := r.URL.Path
if !strings.HasPrefix(next, "/ui/") {
next = ""
}
target := "/ui/login"
if next != "" {
target += "?next=" + url.QueryEscape(next)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
@@ -115,8 +115,8 @@ func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
rec := httptest.NewRecorder()
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/admin" {
t.Fatalf("got %d -> %q, want 303 -> /ui/admin", rec.Code, rec.Header().Get("Location"))
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
@@ -173,3 +173,56 @@ func TestHandleUILogoutClearsCookie(t *testing.T) {
t.Error("logout must clear the session cookie")
}
}
func TestHandleUILoginRedirectsToNext(t *testing.T) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"token":"t"}`))
}))
defer stub.Close()
s := newLoginServer(stub)
// A UI-scoped next is honoured: the admin lands back on the console.
rec := httptest.NewRecorder()
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {"/ui/admin"}}))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/admin" {
t.Errorf("got %d -> %q, want 303 -> /ui/admin", rec.Code, rec.Header().Get("Location"))
}
// Anything outside the UI prefix must not become a redirect target.
for _, next := range []string{"https://evil.example", "/", "//evil.example", "/api/jobs"} {
rec = httptest.NewRecorder()
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {next}}))
if loc := rec.Header().Get("Location"); loc != "/ui/admin" {
t.Errorf("next=%q landed on %q, want /ui/admin (no open redirect)", next, loc)
}
}
}
func TestRedirectToLoginCarriesNext(t *testing.T) {
rec := httptest.NewRecorder()
req := newReq(http.MethodGet, "/ui/admin", nil)
redirectToLogin(rec, req)
if loc := rec.Header().Get("Location"); loc != "/ui/login?next=%2Fui%2Fadmin" {
t.Errorf("location = %q, want /ui/login?next=%%2Fui%%2Fadmin", loc)
}
// Paths outside the UI stay on the plain login.
rec = httptest.NewRecorder()
req = newReq(http.MethodGet, "/health", nil)
redirectToLogin(rec, req)
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
t.Errorf("location = %q, want /ui/login", loc)
}
}
func TestLoginFormRendersNext(t *testing.T) {
html := render(t, "login.html", map[string]any{"Next": "/ui/admin"})
if !strings.Contains(html, `name="next" value="/ui/admin"`) {
t.Error("login form must carry the next field")
}
html = render(t, "login.html", map[string]any{})
if strings.Contains(html, `name="next"`) {
t.Error("login form must not render next when absent")
}
}
@@ -17,15 +17,15 @@ func render(t *testing.T, name string, data any) string {
return buf.String()
}
func TestDashboardLogoutOnlyInSession(t *testing.T) {
withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
func TestJobPageLogoutOnlyInSession(t *testing.T) {
withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "admin"}})
if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") {
t.Error("dashboard must show a logout control in session mode")
t.Error("job page must show a logout control in session mode")
}
noSession := render(t, "dashboard.html", usecase.DashboardView{})
noSession := render(t, "job.html", usecase.JobDetailView{})
if strings.Contains(noSession, "/ui/logout") {
t.Error("dashboard must not show logout under basic auth (no session)")
t.Error("job page must not show logout under basic auth (no session)")
}
}
+523
View File
@@ -0,0 +1,523 @@
package usecase
import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// AdminReadRepository is the bounded read projection behind the coordinator
// admin console. Like UIReadRepository it exposes no storage paths or
// credentials; unlike it, every method is admin-scoped (no owner filter).
type AdminReadRepository interface {
// ListJobsPaginated returns one page of jobs filtered by stored status;
// an empty status returns all. total counts the filtered set (for the
// pager).
ListJobsPaginated(ctx context.Context, status string, limit, offset int) (jobs []domain.Job, total int, err error)
// CountJobsByStatus powers the status tabs: every stored status, all jobs.
CountJobsByStatus(ctx context.Context) (map[string]int, error)
// TaskCountsByJobs aggregates task statuses per job for progress bars.
TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error)
// JobCountsByDay buckets jobs created since `since` by UTC day
// ("2006-01-02").
JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error)
// JobCountsByWorkload counts all jobs per workload name.
JobCountsByWorkload(ctx context.Context) (map[string]int, error)
// TaskStats totals shard execution: completed/failed counts and the mean
// run duration of completed shards (seconds; 0 when nothing completed).
TaskStats(ctx context.Context) (completed, failed int64, avgSeconds float64, err error)
// ArtifactSizeByKind sums stored bytes per artifact kind.
ArtifactSizeByKind(ctx context.Context) (map[string]int64, error)
// DatabaseSizeBytes reports the engine's own size figure (sqlite pages,
// pg_database_size); 0 when the engine cannot say.
DatabaseSizeBytes(ctx context.Context) (int64, error)
}
// AdminNodeInfo describes the running coordinator process to the admin
// console. It is static for the process lifetime and assembled at startup.
type AdminNodeInfo struct {
Version string
StartedAt time.Time
Binary string
Addr string
DataDir string
DBEngine string
PublicURL string
Userservice string // base URL; empty when the UI runs without user auth
// WorkerToken reads the shared worker token for the Settings page. It is a
// func so serve mode can read the token file lazily after provisioning.
WorkerToken func() string
}
type AdminStorageView struct {
DatasetsBytes int64 `json:"datasets_bytes"`
ArtifactsBytes int64 `json:"artifacts_bytes"`
DatabaseBytes int64 `json:"database_bytes"`
}
type AdminHealthView struct {
Database string `json:"database"` // connected | error
Reducer string `json:"reducer"` // idle | active
Userservice string `json:"userservice"` // embedded | external | disabled
}
type AdminNodeView struct {
Binary string `json:"binary"`
Addr string `json:"addr"`
DataDir string `json:"data_dir"`
DBEngine string `json:"db_engine"`
PublicURL string `json:"public_url"`
}
type AdminSystemView struct {
Version string `json:"version"`
StartedAt time.Time `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"`
ActiveJobs int `json:"active_jobs"`
RunningJobs int `json:"running_jobs"`
WaitingJobs int `json:"waiting_jobs"`
WorkersOnline int `json:"workers_online"`
WorkersBusy int `json:"workers_busy"`
WorkersTotal int `json:"workers_total"`
Storage AdminStorageView `json:"storage"`
Health AdminHealthView `json:"health"`
Node AdminNodeView `json:"node"`
}
// AdminJobCard is one row of the admin jobs table. Owner is a display string
// resolved by the caller (email when the userservice is reachable, a short id
// or "cluster token" otherwise).
type AdminJobCard struct {
ID string `json:"id"`
Workload string `json:"workload"`
Status string `json:"status"`
OwnerID string `json:"owner_id,omitempty"`
Owner string `json:"owner"`
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type AdminJobsView struct {
Jobs []AdminJobCard `json:"jobs"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
// Counts holds every stored status for the filter tabs (all jobs, not
// just the current filter).
Counts map[string]int `json:"counts"`
}
type AdminDayCount struct {
Day string `json:"day"`
Count int `json:"count"`
}
type AdminWorkloadCount struct {
Workload string `json:"workload"`
Count int `json:"count"`
}
type AdminMetricsView struct {
JobsLast7Days int `json:"jobs_last_7_days"`
JobsByDay []AdminDayCount `json:"jobs_by_day"`
JobsByWorkload []AdminWorkloadCount `json:"jobs_by_workload"`
ShardsCompleted int64 `json:"shards_completed"`
ShardsFailed int64 `json:"shards_failed"`
AvgShardSeconds float64 `json:"avg_shard_seconds"`
FailureRate float64 `json:"failure_rate"`
}
// Admin answers the coordinator admin console from the bounded read model
// plus process info supplied at startup.
type Admin struct {
read AdminReadRepository
uiRead UIReadRepository
workers WorkerRepository
settings WorkloadSettingsRepository
catalog *workloads.Catalog
node AdminNodeInfo
ready func(context.Context) error
now func() time.Time
log *slog.Logger
audit func(ctx context.Context, action, detail string)
}
func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, workers WorkerRepository,
settings WorkloadSettingsRepository, catalog *workloads.Catalog, node AdminNodeInfo,
ready func(context.Context) error, now func() time.Time) *Admin {
if now == nil {
now = time.Now
}
return &Admin{read: read, uiRead: uiRead, workers: workers, settings: settings, catalog: catalog, node: node, ready: ready, now: now}
}
// WithAuditLog attaches an audit sink for sensitive actions (token reveal).
// Without it the admin usecase stays silent about them.
func (a *Admin) WithAuditLog(log *slog.Logger, audit func(ctx context.Context, action, detail string)) *Admin {
a.log = log
a.audit = audit
return a
}
func (a *Admin) revealToken(ctx context.Context, actor string) string {
if a.node.WorkerToken != nil {
return a.node.WorkerToken()
}
return ""
}
func (a *Admin) System(ctx context.Context) (AdminSystemView, error) {
counts, err := a.read.CountJobsByStatus(ctx)
if err != nil {
return AdminSystemView{}, err
}
workers, err := a.uiRead.ListWorkers(ctx, 100)
if err != nil {
return AdminSystemView{}, err
}
sizes, err := a.read.ArtifactSizeByKind(ctx)
if err != nil {
return AdminSystemView{}, err
}
dbSize, err := a.read.DatabaseSizeBytes(ctx)
if err != nil {
return AdminSystemView{}, err
}
out := AdminSystemView{
Version: a.node.Version,
StartedAt: a.node.StartedAt,
WaitingJobs: counts[string(domain.JobPending)],
RunningJobs: counts[string(domain.JobRunning)] + counts[string(domain.JobReducing)],
}
out.ActiveJobs = out.WaitingJobs + out.RunningJobs
out.UptimeSeconds = int64(a.now().Sub(a.node.StartedAt).Seconds())
if out.UptimeSeconds < 0 {
out.UptimeSeconds = 0
}
for _, w := range workers {
out.WorkersTotal++
switch w.Status {
case domain.WorkerOnline:
out.WorkersOnline++
case domain.WorkerBusy:
out.WorkersOnline++
out.WorkersBusy++
}
}
for kind, size := range sizes {
if kind == string(domain.ArtifactInput) {
out.Storage.DatasetsBytes += size
} else {
out.Storage.ArtifactsBytes += size
}
}
out.Storage.DatabaseBytes = dbSize
out.Health.Database = "connected"
if a.ready != nil {
if err := a.ready(ctx); err != nil {
out.Health.Database = "error"
}
}
out.Health.Reducer = "idle"
if counts[string(domain.JobReducing)] > 0 {
out.Health.Reducer = "active"
}
out.Health.Userservice = "disabled"
if a.node.Userservice != "" {
out.Health.Userservice = "external"
// The embedded userservice always binds the loopback interface.
if strings.Contains(a.node.Userservice, "127.0.0.1") || strings.Contains(a.node.Userservice, "localhost") {
out.Health.Userservice = "embedded"
}
}
out.Node = AdminNodeView{
Binary: a.node.Binary,
Addr: a.node.Addr,
DataDir: a.node.DataDir,
DBEngine: a.node.DBEngine,
PublicURL: a.node.PublicURL,
}
return out, nil
}
// Jobs returns one page of the admin jobs table. The owner emails map may be
// nil; cards then fall back to a short id or "cluster token".
func (a *Admin) Jobs(ctx context.Context, status string, page, perPage int, ownerEmails map[uuid.UUID]string) (AdminJobsView, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
jobs, total, err := a.read.ListJobsPaginated(ctx, status, perPage, (page-1)*perPage)
if err != nil {
return AdminJobsView{}, err
}
counts, err := a.read.CountJobsByStatus(ctx)
if err != nil {
return AdminJobsView{}, err
}
jobIDs := make([]uuid.UUID, 0, len(jobs))
for _, job := range jobs {
jobIDs = append(jobIDs, job.ID)
}
taskCounts, err := a.read.TaskCountsByJobs(ctx, jobIDs)
if err != nil {
return AdminJobsView{}, err
}
out := AdminJobsView{
Jobs: make([]AdminJobCard, 0, len(jobs)),
Total: total,
Page: page,
PerPage: perPage,
Counts: counts,
}
for _, job := range jobs {
tc := taskCounts[job.ID]
card := AdminJobCard{
ID: job.ID.String(),
Workload: job.Workload,
CreatedAt: job.CreatedAt,
CompletedAt: job.CompletedAt,
Owner: "cluster token",
}
var pending, leased, cancelled int
for status, n := range tc {
card.Total += n
switch domain.TaskStatus(status) {
case domain.TaskCompleted:
card.Completed = n
case domain.TaskFailed:
card.Failed = n
case domain.TaskPending:
pending = n
case domain.TaskLeased, domain.TaskRunning:
leased += n
case domain.TaskCancelled:
cancelled = n
}
}
// Derive the status exactly like the operator dashboard does, so the
// two views never disagree about the same job.
progress := domain.JobProgress{Job: job, Total: card.Total, Pending: pending, Leased: leased, Done: card.Completed, Failed: card.Failed, Cancelled: cancelled}
card.Status = string(progress.DeriveStatus())
if job.OwnerID != nil {
card.OwnerID = job.OwnerID.String()
card.Owner = "user " + shortID(job.OwnerID.String())
if email, ok := ownerEmails[*job.OwnerID]; ok && email != "" {
card.Owner = email
}
}
out.Jobs = append(out.Jobs, card)
}
return out, nil
}
func (a *Admin) Metrics(ctx context.Context) (AdminMetricsView, error) {
since := a.now().Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour)
byDay, err := a.read.JobCountsByDay(ctx, since)
if err != nil {
return AdminMetricsView{}, err
}
byWorkload, err := a.read.JobCountsByWorkload(ctx)
if err != nil {
return AdminMetricsView{}, err
}
completed, failed, avg, err := a.read.TaskStats(ctx)
if err != nil {
return AdminMetricsView{}, err
}
out := AdminMetricsView{
JobsByDay: make([]AdminDayCount, 0, 7),
JobsByWorkload: make([]AdminWorkloadCount, 0, len(byWorkload)),
ShardsCompleted: completed,
ShardsFailed: failed,
AvgShardSeconds: avg,
}
if completed+failed > 0 {
out.FailureRate = float64(failed) / float64(completed+failed)
}
for i := 0; i < 7; i++ {
day := since.Add(time.Duration(i) * 24 * time.Hour).UTC().Format("2006-01-02")
count := byDay[day]
out.JobsByDay = append(out.JobsByDay, AdminDayCount{Day: day, Count: count})
out.JobsLast7Days += count
}
for workload, count := range byWorkload {
out.JobsByWorkload = append(out.JobsByWorkload, AdminWorkloadCount{Workload: workload, Count: count})
}
sort.Slice(out.JobsByWorkload, func(i, j int) bool {
if out.JobsByWorkload[i].Count != out.JobsByWorkload[j].Count {
return out.JobsByWorkload[i].Count > out.JobsByWorkload[j].Count
}
return out.JobsByWorkload[i].Workload < out.JobsByWorkload[j].Workload
})
return out, nil
}
// AdminWorkerCard is one row of the admin workers table.
type AdminWorkerCard struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capabilities []string `json:"capabilities"`
Trust string `json:"trust"`
OwnerID string `json:"owner_id,omitempty"`
Owner string `json:"owner"`
Completed int `json:"completed"`
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
}
type AdminWorkersView struct {
Workers []AdminWorkerCard `json:"workers"`
}
// Workers lists the whole fleet for the admin console. Owner emails are
// resolved through the same map as the jobs table (userservice-backed).
func (a *Admin) Workers(ctx context.Context, ownerEmails map[uuid.UUID]string) (AdminWorkersView, error) {
workers, err := a.uiRead.ListWorkers(ctx, 100)
if err != nil {
return AdminWorkersView{}, err
}
out := AdminWorkersView{Workers: make([]AdminWorkerCard, 0, len(workers))}
for _, w := range workers {
card := AdminWorkerCard{
ID: w.ID.String(),
Name: w.Name,
Status: string(w.Status),
Capabilities: w.Capabilities,
Trust: string(w.TrustLevel),
LastHeartbeatAt: w.LastHeartbeatAt,
Owner: "cluster token",
}
if w.OwnerID != nil {
card.OwnerID = w.OwnerID.String()
card.Owner = "user " + shortID(w.OwnerID.String())
if email, ok := ownerEmails[*w.OwnerID]; ok && email != "" {
card.Owner = email
}
}
out.Workers = append(out.Workers, card)
}
return out, nil
}
// SetTrust reclassifies one worker (trusted/untrusted).
func (a *Admin) SetTrust(ctx context.Context, id uuid.UUID, trusted bool) error {
trust := domain.WorkerUntrusted
if trusted {
trust = domain.WorkerTrusted
}
return a.workers.SetTrust(ctx, id, trust)
}
// AdminWorkloadView is the catalog plus the persisted enable flag.
type AdminWorkloadView struct {
Name string `json:"name"`
Description string `json:"description"`
Reduction string `json:"reduction"`
Parameters int `json:"parameters"`
UploadReady bool `json:"upload_ready"`
Enabled bool `json:"enabled"`
DefaultOn bool `json:"default_on"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type AdminWorkloadsView struct {
Workloads []AdminWorkloadView `json:"workloads"`
}
// Workloads lists the catalog with persisted enable/disable overrides.
func (a *Admin) Workloads(ctx context.Context) (AdminWorkloadsView, error) {
if a.catalog == nil {
return AdminWorkloadsView{}, domain.ErrInvalidInput
}
items := a.catalog.Items()
overrides, err := a.settings.List(ctx)
if err != nil {
return AdminWorkloadsView{}, err
}
enabled := make(map[string]WorkloadSetting, len(overrides))
for _, s := range overrides {
enabled[s.Workload] = s
}
out := AdminWorkloadsView{Workloads: make([]AdminWorkloadView, 0, len(items))}
for _, item := range items {
params := 0
if properties, ok := item.Parameters["properties"].(map[string]any); ok {
params = len(properties)
}
view := AdminWorkloadView{
Name: item.Name,
Description: item.Description,
Reduction: item.Reduction,
Parameters: params,
UploadReady: item.UploadReady,
Enabled: true,
DefaultOn: true,
}
if s, ok := enabled[item.Name]; ok {
view.Enabled = s.Enabled
view.DefaultOn = false
view.UpdatedAt = &s.UpdatedAt
}
out.Workloads = append(out.Workloads, view)
}
return out, nil
}
// SetWorkloadEnabled flips the persisted enable flag. An unknown workload is
// rejected: the admin console must not invent catalog entries.
func (a *Admin) SetWorkloadEnabled(ctx context.Context, name string, enabled bool) error {
if a.catalog == nil || a.catalog.ByName(name) == nil {
return domain.ErrInvalidInput
}
return a.settings.SetEnabled(ctx, name, enabled, a.now())
}
// AdminSettingsView is the read-only cluster configuration the Settings page
// shows. The token is never included; it is revealed only through
// RevealWorkerToken, which audits.
type AdminSettingsView struct {
PublicURL string `json:"public_url"`
Addr string `json:"addr"`
DataDir string `json:"data_dir"`
DBEngine string `json:"db_engine"`
Binary string `json:"binary"`
}
func (a *Admin) Settings() AdminSettingsView {
return AdminSettingsView{
PublicURL: a.node.PublicURL,
Addr: a.node.Addr,
DataDir: a.node.DataDir,
DBEngine: a.node.DBEngine,
Binary: a.node.Binary,
}
}
// RevealWorkerToken returns the shared worker token for the Settings page and
// records the reveal in the audit log. It must only be called for an admin
// session.
func (a *Admin) RevealWorkerToken(ctx context.Context, actor string) string {
token := a.revealToken(ctx, actor)
if a.audit != nil {
a.audit(ctx, "worker token revealed", "by "+actor)
}
if a.log != nil {
a.log.Warn("admin console revealed the worker token", "actor", actor)
}
return token
}
+338
View File
@@ -0,0 +1,338 @@
package usecase
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
type fakeAdminRead struct {
jobs []domain.Job
taskCounts map[uuid.UUID]map[string]int
sizes map[string]int64
byDay map[string]int
byWorkload map[string]int
completed int64
failed int64
avg float64
dbSize int64
}
func (f *fakeAdminRead) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
var out []domain.Job
for _, j := range f.jobs {
if status == "" || string(j.Status) == status {
out = append(out, j)
}
}
total := len(out)
if offset >= len(out) {
return nil, total, nil
}
if offset+limit < len(out) {
out = out[offset : offset+limit]
} else {
out = out[offset:]
}
return out, total, nil
}
func (f *fakeAdminRead) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
out := map[string]int{}
for _, j := range f.jobs {
out[string(j.Status)]++
}
return out, nil
}
func (f *fakeAdminRead) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
return f.taskCounts, nil
}
func (f *fakeAdminRead) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
return f.byDay, nil
}
func (f *fakeAdminRead) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
return f.byWorkload, nil
}
func (f *fakeAdminRead) TaskStats(ctx context.Context) (int64, int64, float64, error) {
return f.completed, f.failed, f.avg, nil
}
func (f *fakeAdminRead) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
return f.sizes, nil
}
func (f *fakeAdminRead) DatabaseSizeBytes(ctx context.Context) (int64, error) { return f.dbSize, nil }
type fakeUIRead struct {
UIReadRepository // embedded: only ListWorkers is exercised
workers []domain.Worker
}
type fakeSettings struct {
WorkloadSettingsRepository // embedded: only the methods below are exercised
overrides map[string]bool
}
func (f *fakeSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
if enabled, ok := f.overrides[workload]; ok {
return enabled, nil
}
return true, nil
}
func (f *fakeSettings) List(ctx context.Context) ([]WorkloadSetting, error) {
out := make([]WorkloadSetting, 0, len(f.overrides))
for name, enabled := range f.overrides {
out = append(out, WorkloadSetting{Workload: name, Enabled: enabled, UpdatedAt: time.Now()})
}
return out, nil
}
func (f *fakeSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
if f.overrides == nil {
f.overrides = map[string]bool{}
}
f.overrides[workload] = enabled
return nil
}
func (f *fakeUIRead) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
return f.workers, nil
}
func adminFixture() *Admin {
return NewAdmin(
&fakeAdminRead{},
&fakeUIRead{},
nil, // workers repo
&fakeSettings{},
nil, // catalog
AdminNodeInfo{
Version: "1.1.0-alpha.1", StartedAt: time.Unix(1_000_000, 0).UTC(),
Binary: "/usr/local/bin/coordinator", Addr: ":8080", DataDir: "/var/lib/scimesh",
DBEngine: "sqlite", PublicURL: "http://192.168.1.10:8080", Userservice: "http://127.0.0.1:41273",
},
func(context.Context) error { return nil },
func() time.Time { return time.Unix(1_000_000+3600*3, 0).UTC() },
)
}
func TestAdminSystemAssemblesKpis(t *testing.T) {
owner := uuid.New()
a := adminFixture()
a.read = &fakeAdminRead{
jobs: []domain.Job{
{ID: uuid.New(), Status: domain.JobRunning, Workload: "similarity-search"},
{ID: uuid.New(), Status: domain.JobPending, Workload: "similarity-search", OwnerID: &owner},
{ID: uuid.New(), Status: domain.JobCompleted, Workload: "molwt-filter"},
},
sizes: map[string]int64{"input": 1 << 20, "shard": 2 << 20},
dbSize: 34 << 20,
}
a.uiRead = &fakeUIRead{workers: []domain.Worker{
{Status: domain.WorkerOnline},
{Status: domain.WorkerBusy},
{Status: domain.WorkerOffline},
}}
v, err := a.System(context.Background())
if err != nil {
t.Fatal(err)
}
if v.Version != "1.1.0-alpha.1" {
t.Errorf("version = %q", v.Version)
}
if v.ActiveJobs != 2 || v.WaitingJobs != 1 || v.RunningJobs != 1 {
t.Errorf("jobs: active=%d waiting=%d running=%d, want 2/1/1", v.ActiveJobs, v.WaitingJobs, v.RunningJobs)
}
if v.WorkersOnline != 2 || v.WorkersBusy != 1 || v.WorkersTotal != 3 {
t.Errorf("workers: online=%d busy=%d total=%d, want 2/1/3", v.WorkersOnline, v.WorkersBusy, v.WorkersTotal)
}
if v.UptimeSeconds != 10800 {
t.Errorf("uptime = %d, want 10800", v.UptimeSeconds)
}
if v.Storage.DatasetsBytes != 1<<20 || v.Storage.ArtifactsBytes != 2<<20 || v.Storage.DatabaseBytes != 34<<20 {
t.Errorf("storage = %+v", v.Storage)
}
if v.Health.Database != "connected" || v.Health.Userservice != "embedded" || v.Health.Reducer != "idle" {
t.Errorf("health = %+v", v.Health)
}
if v.Node.Binary != "/usr/local/bin/coordinator" || v.Node.DBEngine != "sqlite" {
t.Errorf("node = %+v", v.Node)
}
}
func TestAdminSystemReportsUnhealthyDatabase(t *testing.T) {
a := adminFixture()
a.ready = func(context.Context) error { return errors.New("connection refused") }
v, err := a.System(context.Background())
if err != nil {
t.Fatal(err)
}
if v.Health.Database != "error" {
t.Errorf("database health = %q, want error", v.Health.Database)
}
}
func TestAdminJobsDerivesStatusAndResolvesOwners(t *testing.T) {
jobID := uuid.New()
owner := uuid.New()
a := adminFixture()
a.read = &fakeAdminRead{
jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "similarity-graph", OwnerID: &owner, CreatedAt: time.Unix(100, 0)}},
taskCounts: map[uuid.UUID]map[string]int{
jobID: {"completed": 5, "failed": 1, "running": 2},
},
}
view, err := a.Jobs(context.Background(), "", 1, 20, map[uuid.UUID]string{owner: "alice@lab.org"})
if err != nil {
t.Fatal(err)
}
if len(view.Jobs) != 1 {
t.Fatalf("jobs = %d, want 1", len(view.Jobs))
}
card := view.Jobs[0]
if card.Owner != "alice@lab.org" || card.OwnerID != owner.String() {
t.Errorf("owner = %q (%s)", card.Owner, card.OwnerID)
}
if card.Total != 8 || card.Completed != 5 || card.Failed != 1 {
t.Errorf("progress: total=%d completed=%d failed=%d", card.Total, card.Completed, card.Failed)
}
if card.Status != "running" {
t.Errorf("derived status = %q, want running (5 completed / 8 with 1 failed)", card.Status)
}
if view.Counts["pending"] != 1 {
t.Errorf("counts = %v", view.Counts)
}
}
func TestAdminJobsFallsBackWithoutOwners(t *testing.T) {
jobID := uuid.New()
a := adminFixture()
a.read = &fakeAdminRead{jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "x", CreatedAt: time.Unix(100, 0)}}}
view, err := a.Jobs(context.Background(), "", 1, 20, nil)
if err != nil {
t.Fatal(err)
}
if view.Jobs[0].Owner != "cluster token" {
t.Errorf("owner fallback = %q, want cluster token", view.Jobs[0].Owner)
}
}
func TestAdminMetricsBuckets(t *testing.T) {
now := time.Unix(1_000_000+3600*3, 0).UTC()
since := now.Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour)
day := func(offset int) string { return since.Add(time.Duration(offset) * 24 * time.Hour).Format("2006-01-02") }
a := adminFixture()
a.read = &fakeAdminRead{
byDay: map[string]int{day(1): 1, day(6): 4},
byWorkload: map[string]int{"molwt-filter": 1, "similarity-search": 5},
completed: 100, failed: 4, avg: 2.5,
}
v, err := a.Metrics(context.Background())
if err != nil {
t.Fatal(err)
}
if len(v.JobsByDay) != 7 || v.JobsLast7Days != 5 {
t.Errorf("by day: %d entries, total %d (want 7 / 5)", len(v.JobsByDay), v.JobsLast7Days)
}
if v.JobsByDay[6].Count != 4 || v.JobsByDay[1].Count != 1 {
t.Errorf("by day = %+v", v.JobsByDay)
}
if v.JobsByWorkload[0].Workload != "similarity-search" || v.JobsByWorkload[0].Count != 5 {
t.Errorf("by workload = %+v", v.JobsByWorkload)
}
if v.FailureRate != 4.0/104.0 || v.AvgShardSeconds != 2.5 {
t.Errorf("rate=%.4f avg=%.2f", v.FailureRate, v.AvgShardSeconds)
}
}
type fakeWorkerRepo struct {
WorkerRepository // embedded: only SetTrust is exercised
}
func (f *fakeWorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
return nil
}
func TestAdminWorkersAndTrust(t *testing.T) {
owner := uuid.New()
a := adminFixture()
a.uiRead = &fakeUIRead{workers: []domain.Worker{
{ID: uuid.New(), Name: "lab-node-01", Status: domain.WorkerBusy, TrustLevel: domain.WorkerTrusted, Capabilities: []string{"similarity-search"}},
{ID: uuid.New(), Name: "emil-laptop", Status: domain.WorkerOnline, TrustLevel: domain.WorkerUntrusted, OwnerID: &owner},
}}
view, err := a.Workers(context.Background(), map[uuid.UUID]string{owner: "alice@lab.org"})
if err != nil {
t.Fatal(err)
}
if len(view.Workers) != 2 {
t.Fatalf("workers = %d, want 2", len(view.Workers))
}
if view.Workers[0].Trust != "trusted" || view.Workers[1].Trust != "untrusted" {
t.Errorf("trust flags wrong: %+v", view.Workers)
}
if view.Workers[1].Owner != "alice@lab.org" {
t.Errorf("owner = %q, want alice@lab.org", view.Workers[1].Owner)
}
}
func TestAdminSetTrust(t *testing.T) {
called := false
a := adminFixture()
a.workers = &fakeWorkerRepo{}
_ = called
if err := a.SetTrust(context.Background(), uuid.New(), false); err != nil {
t.Fatal(err)
}
}
func TestAdminWorkloadsWithOverrides(t *testing.T) {
catalog, err := workloads.Load()
if err != nil {
t.Fatal(err)
}
a := adminFixture()
a.catalog = catalog
a.settings = &fakeSettings{overrides: map[string]bool{"molwt-filter": false}}
view, err := a.Workloads(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, w := range view.Workloads {
if w.Name == "molwt-filter" {
found = true
if w.Enabled || w.DefaultOn {
t.Errorf("molwt-filter: enabled=%v default_on=%v, want disabled override", w.Enabled, w.DefaultOn)
}
}
if w.Name == "similarity-search" && !w.Enabled {
t.Error("similarity-search must stay enabled (no override)")
}
}
if !found {
t.Error("molwt-filter missing from the catalog view")
}
if err := a.SetWorkloadEnabled(context.Background(), "similarity-search", false); err != nil {
t.Fatal(err)
}
if err := a.SetWorkloadEnabled(context.Background(), "nope", false); err == nil {
t.Error("unknown workload must be rejected")
}
}
func TestAdminRevealToken(t *testing.T) {
a := adminFixture()
a.node.WorkerToken = func() string { return "sm_live_secret" }
if got := a.RevealWorkerToken(context.Background(), "admin:user"); got != "sm_live_secret" {
t.Errorf("token = %q", got)
}
}
-12
View File
@@ -20,18 +20,6 @@ func ownerFromContext(ctx context.Context) *uuid.UUID {
return nil
}
// uiOwnerFilter returns the owner a UI listing must be restricted to: nil for an
// operator/admin or an unauthenticated (basic-auth) session, which see all jobs,
// or the caller's id for a plain user, who sees only their own.
func uiOwnerFilter(ctx context.Context) *uuid.UUID {
r, ok := authctx.From(ctx)
if !ok || r.IsAdmin() {
return nil
}
id := r.UserID
return &id
}
// authorizeJobAccess enforces that a non-admin user may only act on their own
// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
// never reveals that another user's job exists.
+23
View File
@@ -94,6 +94,9 @@ type WorkerRepository interface {
// MarkStaleOffline flips every worker last seen before cutoff to offline and
// reports how many changed.
MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error)
// SetTrust reclassifies a worker's trust level (trusted/untrusted). Returns
// ErrNotFound when the id is unknown.
SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error
}
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
@@ -131,6 +134,26 @@ type Clock interface {
Now() time.Time
}
// WorkloadSetting is one persisted enable/disable override from the admin
// console. A workload with no row in the store is enabled by default.
type WorkloadSetting struct {
Workload string `json:"workload"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkloadSettingsRepository persists the admin enable/disable overrides on
// top of the embedded workload catalog.
type WorkloadSettingsRepository interface {
// GetEnabled reports whether the workload is enabled. True when the
// workload has no override row (catalog default).
GetEnabled(ctx context.Context, workload string) (bool, error)
// List returns every override row, newest update first.
List(ctx context.Context) ([]WorkloadSetting, error)
// SetEnabled upserts the override.
SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error
}
// ErrNotImplemented marks scaffold code with no body yet. Unlike the errors in
// domain, it describes the state of this codebase, not a business rule.
var ErrNotImplemented = errors.New("not implemented")
+7 -88
View File
@@ -18,15 +18,8 @@ import (
// It intentionally exposes no storage paths or credentials.
type UIReadRepository interface {
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
// ListJobs returns the most recent jobs. A non-nil owner restricts the list
// to that user's jobs; nil returns all (operator/admin view).
ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error)
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
// ListWorkersByOwner returns the most recent workers registered by one user,
// for the "my machines" section of the dashboard.
ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error)
ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error)
}
@@ -88,18 +81,13 @@ type WorkerCard struct {
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
}
type DashboardView struct {
Jobs []JobCard `json:"jobs"`
Workers []WorkerCard `json:"workers"`
// MyWorkers is the signed-in user's own registered workers. Empty for an
// admin or a basic-auth operator, who instead see the whole fleet in Workers.
MyWorkers []WorkerCard `json:"my_workers"`
ActiveJobs int `json:"active_jobs"`
FinishedJobs int `json:"finished_jobs"`
OnlineWorkers int `json:"online_workers"`
// Session is the signed-in user, when the UI runs in session mode. nil under
// basic auth. Template-only, never serialised to the polling JSON.
Session *SessionView `json:"-"`
type JobDetailView struct {
JobCard
Tasks []TaskCard `json:"tasks"`
Artifacts []ArtifactCard `json:"artifacts"`
Parameters []ParameterCard `json:"parameters"`
FinalResultAvailable bool `json:"final_result_available"`
Session *SessionView `json:"-"`
}
// SessionView is the minimal identity the UI header needs to show who is signed
@@ -119,15 +107,6 @@ func sessionViewFrom(ctx context.Context) *SessionView {
return &SessionView{Role: r.Role, Verified: r.Verified}
}
type JobDetailView struct {
JobCard
Tasks []TaskCard `json:"tasks"`
Artifacts []ArtifactCard `json:"artifacts"`
Parameters []ParameterCard `json:"parameters"`
FinalResultAvailable bool `json:"final_result_available"`
Session *SessionView `json:"-"`
}
type Dashboard struct {
read UIReadRepository
catalog *workloads.Catalog
@@ -137,66 +116,6 @@ 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)
if err != nil {
return DashboardView{}, err
}
workers, err := d.read.ListWorkers(ctx, limit)
if err != nil {
return DashboardView{}, err
}
out := DashboardView{Jobs: make([]JobCard, 0, len(jobs)), Workers: make([]WorkerCard, 0, len(workers))}
jobIDs := make([]uuid.UUID, 0, len(jobs))
for _, job := range jobs {
jobIDs = append(jobIDs, job.ID)
}
tasksByJob, err := d.read.ListTasksByJobs(ctx, jobIDs)
if err != nil {
return DashboardView{}, err
}
for _, job := range jobs {
card := jobCard(job, tasksByJob[job.ID])
out.Jobs = append(out.Jobs, card)
switch card.Status {
case string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled):
out.FinishedJobs++
default:
out.ActiveJobs++
}
}
for _, worker := range workers {
out.Workers = append(out.Workers, workerCard(worker))
if worker.Status == domain.WorkerOnline || worker.Status == domain.WorkerBusy {
out.OnlineWorkers++
}
}
// A plain user also gets a dedicated "my machines" list scoped to their own
// registrations; an admin/operator sees only the fleet above.
if owner := uiOwnerFilter(ctx); owner != nil {
mine, err := d.read.ListWorkersByOwner(ctx, *owner, limit)
if err != nil {
return DashboardView{}, err
}
out.MyWorkers = make([]WorkerCard, 0, len(mine))
for _, worker := range mine {
out.MyWorkers = append(out.MyWorkers, workerCard(worker))
}
}
out.Session = sessionViewFrom(ctx)
return out, nil
}
func workerCard(w domain.Worker) WorkerCard {
return WorkerCard{
ID: w.ID.String(),
Name: w.Name,
Status: string(w.Status),
Capabilities: w.Capabilities,
LastHeartbeatAt: w.LastHeartbeatAt,
}
}
func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailView, error) {
job, err := d.read.GetJob(ctx, jobID)
if err != nil {
@@ -36,32 +36,6 @@ func userCtx(id uuid.UUID, role string) context.Context {
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
}
func TestOverviewScopesJobsByOwner(t *testing.T) {
dash, jobs := newDashboard()
alice, bob := uuid.New(), uuid.New()
ownedJob(t, jobs, alice)
ownedJob(t, jobs, bob)
// A plain user sees only their own job.
v, err := dash.Overview(userCtx(alice, "user"), 20)
if err != nil {
t.Fatal(err)
}
if len(v.Jobs) != 1 {
t.Errorf("alice sees %d jobs, want 1", len(v.Jobs))
}
// An admin sees every job.
if v, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(v.Jobs) != 2 {
t.Errorf("admin sees %d jobs, want 2", len(v.Jobs))
}
// No requester (basic-auth operator) sees every job — unchanged behaviour.
if v, _ := dash.Overview(context.Background(), 20); len(v.Jobs) != 2 {
t.Errorf("operator sees %d jobs, want 2", len(v.Jobs))
}
}
func TestJobDetailRejectsAnotherUsersJob(t *testing.T) {
dash, jobs := newDashboard()
alice, bob := uuid.New(), uuid.New()
@@ -1,66 +0,0 @@
package usecase_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
func newDashboardWithWorkers() (*usecase.Dashboard, *memstore.WorkerRepo) {
jobs := memstore.NewJobRepo()
tasks := memstore.NewTaskRepo()
workers := memstore.NewWorkerRepo()
artifacts := memstore.NewArtifactRepo()
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts), testCatalog()), workers
}
func seedWorker(t *testing.T, workers *memstore.WorkerRepo, owner *uuid.UUID, name string) {
t.Helper()
w := &domain.Worker{
ID: uuid.New(),
Name: name,
Capabilities: []string{"similarity-search"},
Status: domain.WorkerOnline,
OwnerID: owner,
LastHeartbeatAt: time.Now().UTC(),
}
if err := workers.Insert(context.Background(), w); err != nil {
t.Fatalf("insert worker: %v", err)
}
}
func TestOverviewSplitsMyWorkers(t *testing.T) {
dash, workers := newDashboardWithWorkers()
alice, bob := uuid.New(), uuid.New()
seedWorker(t, workers, &alice, "alice-box")
seedWorker(t, workers, &bob, "bob-box")
seedWorker(t, workers, nil, "lab-shared") // owner-less shared-token worker
// A plain user sees the whole fleet, but MyWorkers holds only their own.
v, err := dash.Overview(userCtx(alice, "user"), 20)
if err != nil {
t.Fatal(err)
}
if len(v.Workers) != 3 {
t.Errorf("fleet shows %d workers, want 3", len(v.Workers))
}
if len(v.MyWorkers) != 1 || v.MyWorkers[0].Name != "alice-box" {
t.Errorf("MyWorkers = %+v, want only alice-box", v.MyWorkers)
}
// An admin is not owner-scoped: they get the fleet and no personal list.
if av, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(av.MyWorkers) != 0 || len(av.Workers) != 3 {
t.Errorf("admin MyWorkers=%d Workers=%d, want 0 and 3", len(av.MyWorkers), len(av.Workers))
}
// A basic-auth operator (no requester) also gets no personal list.
if ov, _ := dash.Overview(context.Background(), 20); len(ov.MyWorkers) != 0 {
t.Errorf("operator MyWorkers=%d, want 0", len(ov.MyWorkers))
}
}
+12 -2
View File
@@ -24,17 +24,27 @@ type SubmitDataset struct {
clk Clock
maxAttempts int
catalog *workloads.Catalog
settings WorkloadSettingsRepository
}
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
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}
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog, settings WorkloadSettingsRepository) *SubmitDataset {
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog, settings: settings}
}
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
if err := validateUploadedWorkload(uc.catalog, in.Workload, in.Parameters); err != nil {
return SubmitDatasetResult{}, err
}
if uc.settings != nil {
enabled, err := uc.settings.GetEnabled(ctx, in.Workload)
if err != nil {
return SubmitDatasetResult{}, err
}
if !enabled {
return SubmitDatasetResult{}, domain.ErrWorkloadDisabled
}
}
if uc.maxAttempts < 1 {
return SubmitDatasetResult{}, domain.ErrInvalidInput
}
+46 -1
View File
@@ -42,6 +42,7 @@ type harness struct {
blobs *memstore.BlobStore
clk *memstore.Clock
taskResults *memstore.TaskResultRepo
settings *memSettings
createJob *usecase.CreateJob
submit *usecase.SubmitDataset
@@ -70,10 +71,11 @@ func newHarness() *harness {
blobs: memstore.NewBlobStore(),
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
taskResults: memstore.NewTaskResultRepo(),
settings: newMemSettings(),
}
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, testCatalog())
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog(), h.settings)
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, testCatalog())
@@ -935,3 +937,46 @@ func TestFinalLeaseExpiryPersistsFailedJobAndCannotBeCancelled(t *testing.T) {
t.Errorf("cancel terminal lease failure = %v, want ErrJobNotCancellable", err)
}
}
// memSettings is an in-memory WorkloadSettingsRepository for tests.
type memSettings struct {
overrides map[string]bool
}
func newMemSettings() *memSettings { return &memSettings{overrides: map[string]bool{}} }
func (m *memSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
if enabled, ok := m.overrides[workload]; ok {
return enabled, nil
}
return true, nil
}
func (m *memSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) { return nil, nil }
func (m *memSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
m.overrides[workload] = enabled
return nil
}
func TestSubmitDatasetRejectsDisabledWorkload(t *testing.T) {
h := newHarness()
h.settings.SetEnabled(ctx, "molwt-filter", false, h.clk.Now())
_, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
Body: strings.NewReader("smiles\nCC\n"),
})
if !errors.Is(err, domain.ErrWorkloadDisabled) {
t.Fatalf("err = %v, want ErrWorkloadDisabled", err)
}
// Re-enabling accepts the same submit.
h.settings.SetEnabled(ctx, "molwt-filter", true, h.clk.Now())
if _, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
Body: strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"),
}); err != nil {
t.Fatalf("submit after re-enable: %v", err)
}
}
@@ -0,0 +1,72 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// Claims is the payload of a signed token. Subject (from RegisteredClaims) is
// the user id — it becomes the coordinator's jobs.owner_id; Role drives
// authorization; Verified tells the coordinator whether this user's workers are
// trusted (results accepted without quorum). Both services verify this token
// locally with the shared HS256 secret, so no runtime call back to the
// userservice is ever needed.
type Claims struct {
Role domain.Role `json:"role"`
Verified bool `json:"verified"`
jwt.RegisteredClaims
}
// Issuer signs and verifies tokens with a shared HS256 secret.
type Issuer struct {
secret []byte
ttl time.Duration
now func() time.Time
}
// NewIssuer builds an Issuer. now defaults to time.Now when nil; tests inject a
// fixed clock to make expiry deterministic.
func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer {
if now == nil {
now = time.Now
}
return Issuer{secret: []byte(secret), ttl: ttl, now: now}
}
// Issue returns a signed token for the user, valid for the configured TTL. It
// takes the whole user so every trust-bearing field (role, verified) travels in
// the token, keeping the two services from needing a runtime lookup.
func (i Issuer) Issue(u *domain.User) (string, error) {
now := i.now()
claims := Claims{
Role: u.Role,
Verified: u.Verified,
RegisteredClaims: jwt.RegisteredClaims{
Subject: u.ID.String(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
}
// Verify checks the signature and expiry and returns the claims. It pins the
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
// key — the classic algorithm-substitution attack against naive verifiers.
func (i Issuer) Verify(token string) (*Claims, error) {
var claims Claims
_, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.secret, nil
})
if err != nil {
return nil, err
}
return &claims, nil
}
@@ -0,0 +1,77 @@
package auth
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
const testSecret = "test-secret-at-least-32-bytes-long!!"
func TestIssueVerifyRoundTrip(t *testing.T) {
iss := NewIssuer(testSecret, time.Hour, nil)
id := uuid.New()
token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true})
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := iss.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Subject != id.String() {
t.Errorf("sub = %q, want %q", claims.Subject, id.String())
}
if claims.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", claims.Role)
}
if !claims.Verified {
t.Error("verified claim not carried in token")
}
}
func TestVerifyRejectsExpired(t *testing.T) {
// Negative TTL: the token is already expired when issued.
iss := NewIssuer(testSecret, -time.Minute, nil)
token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
if _, err := iss.Verify(token); err == nil {
t.Error("expired token accepted")
}
}
func TestVerifyRejectsWrongSecret(t *testing.T) {
token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil)
if _, err := other.Verify(token); err == nil {
t.Error("token verified under the wrong secret")
}
}
func TestVerifyRejectsNoneAlgorithm(t *testing.T) {
// Forge a token signed with "none" — the classic algorithm-substitution
// attack. A verifier that trusts the header's alg would accept it.
tok := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{
Role: domain.RoleAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Subject: uuid.New().String(),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("sign none: %v", err)
}
iss := NewIssuer(testSecret, time.Hour, nil)
if _, err := iss.Verify(raw); err == nil {
t.Error("none-signed token accepted")
}
}
@@ -0,0 +1,40 @@
// Package auth holds the cryptographic adapters — password hashing and JWT
// signing/verification. They implement use-case ports and keep bcrypt and the
// JWT library out of the domain and use-case layers.
package auth
import "golang.org/x/crypto/bcrypt"
// Hasher turns plaintext passwords into storable hashes and checks them back.
type Hasher struct {
cost int
}
// NewHasher builds a Hasher. A cost of 0 uses bcrypt's default work factor.
func NewHasher(cost int) Hasher {
if cost == 0 {
cost = bcrypt.DefaultCost
}
return Hasher{cost: cost}
}
// Hash returns the bcrypt hash of password. The salt and the cost are embedded
// in the returned string, so nothing else needs to be stored alongside it.
//
// bcrypt silently ignores input past 72 bytes; the use case rejects longer
// passwords before reaching here so a truncated tail never becomes a security
// surprise.
func (h Hasher) Hash(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
if err != nil {
return "", err
}
return string(b), nil
}
// Compare reports whether password matches the stored hash. It returns a
// non-nil error (bcrypt.ErrMismatchedHashAndPassword) on any mismatch, which
// the caller collapses into a generic authentication failure.
func (h Hasher) Compare(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
@@ -0,0 +1,30 @@
package auth
import "testing"
func TestHashAndCompare(t *testing.T) {
h := NewHasher(0) // default cost
hash, err := h.Hash("correct horse battery staple")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "correct horse battery staple" {
t.Fatal("hash must not equal the plaintext")
}
if err := h.Compare(hash, "correct horse battery staple"); err != nil {
t.Errorf("correct password rejected: %v", err)
}
if err := h.Compare(hash, "wrong password"); err == nil {
t.Error("wrong password accepted")
}
}
func TestHashSaltsEachTime(t *testing.T) {
h := NewHasher(0)
a, _ := h.Hash("same")
b, _ := h.Hash("same")
if a == b {
t.Error("two hashes of the same password must differ (random salt)")
}
}
@@ -0,0 +1,13 @@
package domain
import "errors"
// Domain validation errors. They describe an entity that cannot be constructed,
// independent of storage or transport, and the HTTP layer maps them to 400.
var (
ErrEmptyEmail = errors.New("email is required")
ErrInvalidEmail = errors.New("email is not a valid address")
ErrEmptyPasswordHash = errors.New("password hash is required")
ErrWorkerKeyNameTooLong = errors.New("worker key name is too long")
)
@@ -0,0 +1,84 @@
package domain
import (
"net/mail"
"strings"
"time"
"github.com/google/uuid"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
func (r Role) Valid() bool {
switch r {
case RoleAdmin, RoleUser:
return true
default:
return false
}
}
type User struct {
ID uuid.UUID
Email string
PasswordHash string
Role Role
// Verified marks a trusted contributor whose workers' results the
// coordinator accepts without quorum. Distinct from Role; granted by an
// admin, defaults to false.
Verified bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUser builds a freshly registered account. It normalises the email and
// enforces every invariant a row must satisfy, so an invalid User cannot be
// constructed. The caller supplies the already-hashed password — hashing is an
// adapter's job, not the domain's.
//
// Registration always produces a plain user; promotion to admin is a manual,
// out-of-band operation, never something a request can trigger.
func NewUser(email, passwordHash string, now time.Time) (*User, error) {
email = NormalizeEmail(email)
if err := validateEmail(email); err != nil {
return nil, err
}
if passwordHash == "" {
return nil, ErrEmptyPasswordHash
}
return &User{
ID: uuid.New(),
Email: email,
PasswordHash: passwordHash,
Role: RoleUser,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and
// "bob@x.com" resolve to the same account. Every lookup and every insert must
// pass through here, matching the ck_users_email_lower database constraint.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func validateEmail(email string) error {
if email == "" {
return ErrEmptyEmail
}
// A minimal shape check, not full RFC 5322: real deliverability is proven by
// sending mail, not by a regex. mail.ParseAddress also accepts the
// "Name <addr>" form, so we insist the parsed address equals the input.
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return ErrInvalidEmail
}
return nil
}
@@ -0,0 +1,62 @@
package domain
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
func TestNewUserNormalisesAndValidates(t *testing.T) {
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
u, err := NewUser(" Bob@Example.COM ", "hashed", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Email != "bob@example.com" {
t.Errorf("email not normalised: got %q", u.Email)
}
if u.Role != RoleUser {
t.Errorf("new user must default to RoleUser, got %q", u.Role)
}
if u.ID == uuid.Nil {
t.Error("new user must get an id")
}
if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) {
t.Error("timestamps not set from clock")
}
}
func TestNewUserRejectsBadInput(t *testing.T) {
now := time.Now()
cases := []struct {
name string
email string
hash string
wantErr error
}{
{"empty email", "", "h", ErrEmptyEmail},
{"no domain", "bob", "h", ErrInvalidEmail},
{"name form", "Bob <bob@x.com>", "h", ErrInvalidEmail},
{"empty hash", "bob@x.com", "", ErrEmptyPasswordHash},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewUser(tc.email, tc.hash, now)
if !errors.Is(err, tc.wantErr) {
t.Errorf("got %v, want %v", err, tc.wantErr)
}
})
}
}
func TestRoleValid(t *testing.T) {
if !RoleUser.Valid() || !RoleAdmin.Valid() {
t.Error("user and admin must be valid")
}
if Role("root").Valid() {
t.Error("unknown role must be invalid")
}
}
@@ -0,0 +1,84 @@
package domain
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"time"
"github.com/google/uuid"
)
const (
// workerKeyLabel makes a key self-describing when it turns up in a log or an
// env var, and lets a client sanity-check the shape before exchanging it.
workerKeyLabel = "scimesh_wk_live_"
// workerKeyRandomBytes is the entropy behind the secret. 24 bytes (192 bits)
// is far beyond guessable, which is why the stored hash needs no salt.
workerKeyRandomBytes = 24
// workerKeyPrefixChars is how much of the random tail we keep, alongside the
// label, as the non-secret identifier shown in the UI.
workerKeyPrefixChars = 8
// workerKeyNameMax caps the user-supplied label.
workerKeyNameMax = 100
// workerKeyDefaultName is used when the caller supplies no label.
workerKeyDefaultName = "my machine"
)
// WorkerKey is a long-lived, per-user credential for running a worker. The
// secret itself is never stored — only TokenHash — so the plaintext returned by
// NewWorkerKey is the one and only chance to show it to the user.
type WorkerKey struct {
ID uuid.UUID
UserID uuid.UUID
Name string
TokenHash string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
// NewWorkerKey mints a key for a user and returns both the entity (carrying only
// the hash) and the one-time plaintext to hand back to the caller. The label is
// trimmed and defaulted; an over-long one is rejected.
func NewWorkerKey(userID uuid.UUID, name string, now time.Time) (*WorkerKey, string, error) {
name = strings.TrimSpace(name)
if name == "" {
name = workerKeyDefaultName
}
if len(name) > workerKeyNameMax {
return nil, "", ErrWorkerKeyNameTooLong
}
b := make([]byte, workerKeyRandomBytes)
if _, err := rand.Read(b); err != nil {
return nil, "", err
}
// URL-safe, unpadded: the key rides in env vars and shell commands, so it
// must contain no '=', '+', or '/' that a shell might mangle.
raw := workerKeyLabel + base64.RawURLEncoding.EncodeToString(b)
key := &WorkerKey{
ID: uuid.New(),
UserID: userID,
Name: name,
TokenHash: HashWorkerKey(raw),
Prefix: raw[:len(workerKeyLabel)+workerKeyPrefixChars],
CreatedAt: now,
}
return key, raw, nil
}
// HashWorkerKey returns the hex SHA-256 of a presented key. Exchange hashes the
// incoming key the same way and looks the row up by it, so the plaintext never
// has to be compared directly.
func HashWorkerKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Revoked reports whether the key has been retired and must no longer exchange.
func (k *WorkerKey) Revoked() bool { return k.RevokedAt != nil }
@@ -0,0 +1,62 @@
package domain_test
import (
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
func TestNewWorkerKeyShape(t *testing.T) {
owner := uuid.New()
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
key, raw, err := domain.NewWorkerKey(owner, "home-desktop", now)
if err != nil {
t.Fatalf("NewWorkerKey: %v", err)
}
if !strings.HasPrefix(raw, "scimesh_wk_live_") {
t.Errorf("raw key has no recognisable label: %q", raw)
}
if key.TokenHash != domain.HashWorkerKey(raw) {
t.Error("stored hash does not match the plaintext")
}
if key.TokenHash == raw || strings.Contains(key.TokenHash, raw) {
t.Error("plaintext leaked into the stored hash")
}
if !strings.HasPrefix(raw, key.Prefix) {
t.Errorf("prefix %q is not a leading slice of the key", key.Prefix)
}
if key.UserID != owner || key.CreatedAt != now || key.Revoked() {
t.Errorf("unexpected key metadata: %+v", key)
}
}
func TestNewWorkerKeyDefaultsBlankName(t *testing.T) {
key, _, err := domain.NewWorkerKey(uuid.New(), " ", time.Now())
if err != nil {
t.Fatalf("NewWorkerKey: %v", err)
}
if key.Name == "" {
t.Error("blank name was not defaulted")
}
}
func TestNewWorkerKeyRejectsLongName(t *testing.T) {
_, _, err := domain.NewWorkerKey(uuid.New(), strings.Repeat("x", 101), time.Now())
if !errors.Is(err, domain.ErrWorkerKeyNameTooLong) {
t.Errorf("got %v, want ErrWorkerKeyNameTooLong", err)
}
}
func TestNewWorkerKeyUniquePerCall(t *testing.T) {
a, rawA, _ := domain.NewWorkerKey(uuid.New(), "a", time.Now())
b, rawB, _ := domain.NewWorkerKey(uuid.New(), "b", time.Now())
if rawA == rawB || a.TokenHash == b.TokenHash || a.ID == b.ID {
t.Error("two keys collided; generation is not random")
}
}
@@ -0,0 +1,116 @@
// Package userservice is the SciMesh authentication service, embedded into the
// coordinator binary for single-binary deployments. The packages here are the
// same code the standalone `users/` service runs, with its PostgreSQL storage
// replaced by an embedded SQLite backend. The coordinator's HTTP layer talks
// to it through the usual USERSERVICE_URL proxy, so no proxy code changes.
package userservice
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/storage/sqlite"
usershttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// Config wires the embedded userservice.
type Config struct {
// DBPath is the sqlite database file (for example <data-dir>/users.db).
DBPath string
// JWTSecret must equal the coordinator's JWT_SECRET so tokens verify.
JWTSecret string
// AdminEmail/AdminPassword bootstrap the first admin on first run.
AdminEmail string
AdminPassword string
// Log receives the service's log lines.
Log *slog.Logger
}
// Serve runs the embedded userservice until ctx is cancelled. It listens only
// on the loopback interface; the coordinator proxies to it internally.
func Serve(ctx context.Context, cfg Config) (string, func() error, error) {
db, err := sqlite.Open(cfg.DBPath)
if err != nil {
return "", nil, err
}
if err := sqlite.Migrate(ctx, db, cfg.Log); err != nil {
_ = db.Close()
return "", nil, err
}
clock := NewClock()
users := sqlite.NewUserRepo(db)
workerKeys := sqlite.NewWorkerKeyRepo(db)
hasher := auth.NewHasher(0)
issuer := auth.NewIssuer(cfg.JWTSecret, 24*time.Hour, clock.Now)
uc := usershttp.UseCases{
Register: usecase.NewRegister(users, hasher, clock),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock),
ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys),
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(workerKeys),
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(workerKeys),
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour),
ListUsers: usecase.NewListUsers(users),
Users: users,
}
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
created, err := usecase.NewBootstrapAdmin(users, hasher, clock).
Execute(ctx, cfg.AdminEmail, cfg.AdminPassword)
if err != nil {
_ = db.Close()
return "", nil, fmt.Errorf("bootstrap admin: %w", err)
}
if created {
cfg.Log.Info("embedded userservice created the admin account", "email", cfg.AdminEmail)
}
}
handler := usershttp.NewServer(cfg.Log, uc, issuer)
handler = http.TimeoutHandler(handler, 15*time.Second, `{"error":"request timeout"}`)
// Bind an ephemeral loopback port so a second serve instance can never
// collide with the first; the coordinator's proxy uses the returned
// address and needs no fixed-port assumption.
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
if err != nil {
_ = db.Close()
return "", nil, fmt.Errorf("listen for embedded userservice: %w", err)
}
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
cfg.Log.Error("embedded userservice stopped", "err", err)
}
}()
return listener.Addr().String(), func() error { return db.Close() }, nil
}
// NewClock returns the userservice's wall clock.
func NewClock() *Clock { return &Clock{} }
// Clock implements the userservice usecase clock.
type Clock struct{}
// Now returns the current UTC time.
func (c *Clock) Now() time.Time { return time.Now().UTC() }
@@ -0,0 +1,189 @@
// Package memstore provides in-memory implementations of the usecase ports for
// fast, deterministic tests that need no database.
package memstore
import (
"context"
"sort"
"sync"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// UserRepo is an in-memory usecase.UserRepository. It stores copies, so callers
// mutating a returned user cannot corrupt the store.
type UserRepo struct {
mu sync.Mutex
byID map[uuid.UUID]domain.User
byEmail map[string]uuid.UUID
}
func NewUserRepo() *UserRepo {
return &UserRepo{
byID: make(map[uuid.UUID]domain.User),
byEmail: make(map[string]uuid.UUID),
}
}
func (r *UserRepo) Insert(_ context.Context, u *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byEmail[u.Email]; ok {
return usecase.ErrEmailExists
}
r.byID[u.ID] = *u
r.byEmail[u.Email] = u.ID
return nil
}
func (r *UserRepo) GetByEmail(_ context.Context, email string) (*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
id, ok := r.byEmail[email]
if !ok {
return nil, usecase.ErrUserNotFound
}
u := r.byID[id]
return &u, nil
}
func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return nil, usecase.ErrUserNotFound
}
return &u, nil
}
func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return usecase.ErrUserNotFound
}
u.Verified = verified
r.byID[id] = u
return nil
}
func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return usecase.ErrUserNotFound
}
u.Role = role
r.byID[id] = u
return nil
}
// ListUsers returns every account, oldest first. It copies, so callers cannot
// corrupt the store through the returned slice.
func (r *UserRepo) ListUsers(_ context.Context) ([]*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
users := make([]*domain.User, 0, len(r.byID))
for _, u := range r.byID {
copy := u
users = append(users, &copy)
}
sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.Before(users[j].CreatedAt) })
return users, nil
}
// Clock is a fixed usecase.Clock for deterministic tests.
type Clock struct{ T time.Time }
func (c Clock) Now() time.Time { return c.T }
// WorkerKeyRepo is an in-memory usecase.WorkerKeyRepository.
type WorkerKeyRepo struct {
mu sync.Mutex
keys map[uuid.UUID]*domain.WorkerKey
}
func NewWorkerKeyRepo() *WorkerKeyRepo {
return &WorkerKeyRepo{keys: map[uuid.UUID]*domain.WorkerKey{}}
}
func (r *WorkerKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
r.mu.Lock()
defer r.mu.Unlock()
r.keys[k.ID] = k
return nil
}
func (r *WorkerKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*domain.WorkerKey
for _, k := range r.keys {
if k.UserID == userID && !k.Revoked() {
out = append(out, k)
}
}
return out, nil
}
func (r *WorkerKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*domain.WorkerKey, 0, len(r.keys))
for _, k := range r.keys {
out = append(out, k)
}
return out, nil
}
func (r *WorkerKeyRepo) GetActiveByHash(_ context.Context, tokenHash string) (*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, k := range r.keys {
if k.TokenHash == tokenHash && !k.Revoked() {
return k, nil
}
}
return nil, usecase.ErrWorkerKeyNotFound
}
func (r *WorkerKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
k, ok := r.keys[id]
if !ok || k.UserID != userID || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func (r *WorkerKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
k, ok := r.keys[id]
if !ok || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func (r *WorkerKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
if k, ok := r.keys[id]; ok {
now := time.Now()
k.LastUsedAt = &now
}
return nil
}
@@ -0,0 +1,81 @@
package sqlite
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"regexp"
"sort"
"strconv"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.sql$`)
// Migrate applies every embedded migration above the PRAGMA user_version
// watermark, each inside its own transaction.
func Migrate(ctx context.Context, db *sql.DB, log *slog.Logger) error {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
type file struct {
version int
name string
}
var files []file
byVersion := map[int]string{}
for _, entry := range entries {
match := migrationNamePattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
version, err := strconv.Atoi(match[1])
if err != nil {
return fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
}
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %q: %w", entry.Name(), err)
}
byVersion[version] = string(body)
files = append(files, file{version: version, name: entry.Name()})
}
if len(files) == 0 {
return fmt.Errorf("no userservice migrations are embedded")
}
sort.Slice(files, func(i, j int) bool { return files[i].version < files[j].version })
var applied int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&applied); err != nil {
return fmt.Errorf("read schema version: %w", err)
}
for _, item := range files {
if item.version <= applied {
continue
}
if log != nil {
log.Info("applying userservice migration", "version", item.version, "file", item.name)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, byVersion[item.version]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", item.name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", item.version)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("advance schema version after %s: %w", item.name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", item.name, err)
}
}
return nil
}
@@ -0,0 +1,24 @@
-- 0001: userservice schema. Users and long-lived worker keys, in the same
-- style as the coordinator's sqlite schema: TEXT ids, INTEGER timestamps.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','user')),
verified INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS worker_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER
);
CREATE INDEX IF NOT EXISTS ix_worker_keys_user ON worker_keys (user_id);
@@ -0,0 +1,304 @@
// Package sqlite implements the userservice repository ports on an embedded
// SQLite database, mirroring the coordinator's single-binary storage choice.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
const userColumns = `id, email, password_hash, role, verified, created_at, updated_at`
// scanUser maps one row onto a domain.User.
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
var (
u domain.User
role string
verified int64
)
var created, updated sql.NullInt64
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &verified, &created, &updated); err != nil {
return nil, err
}
u.Role = domain.Role(role)
u.Verified = verified != 0
u.CreatedAt = decodeTime(created.Int64)
u.UpdatedAt = decodeTime(updated.Int64)
return &u, nil
}
// UserRepo implements usecase.UserRepository on SQLite.
type UserRepo struct {
db *sql.DB
}
func NewUserRepo(db *sql.DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO users (id, email, password_hash, role, verified, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.ID.String(), u.Email, u.PasswordHash, string(u.Role), boolInt(u.Verified),
u.CreatedAt.UnixNano(), u.UpdatedAt.UnixNano())
if err != nil && isUnique(err) {
return usecase.ErrEmailExists
}
return err
}
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
return r.getBy(ctx, "email = ?", email)
}
func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
return r.getBy(ctx, "id = ?", id.String())
}
func (r *UserRepo) getBy(ctx context.Context, clause string, arg any) (*domain.User, error) {
// #nosec G202 -- clause is an internal constant, never user input.
row := r.db.QueryRowContext(ctx, "SELECT "+userColumns+" FROM users WHERE "+clause, arg)
user, err := scanUser(row)
return user, mapErrNoRows(err, usecase.ErrUserNotFound)
}
func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error {
res, err := r.db.ExecContext(ctx,
"UPDATE users SET verified = ?, updated_at = ? WHERE id = ?",
boolInt(verified), time.Now().UnixNano(), id.String())
if err != nil {
return err
}
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
}
func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error {
res, err := r.db.ExecContext(ctx,
"UPDATE users SET role = ?, updated_at = ? WHERE id = ?",
string(role), time.Now().UnixNano(), id.String())
if err != nil {
return err
}
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
}
// ListUsers returns every account, oldest first.
func (r *UserRepo) ListUsers(ctx context.Context) ([]*domain.User, error) {
rows, err := r.db.QueryContext(ctx, "SELECT "+userColumns+" FROM users ORDER BY created_at ASC, id ASC")
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var users []*domain.User
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
const workerKeyColumns = `id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at`
func scanWorkerKey(row interface{ Scan(dest ...any) error }) (*domain.WorkerKey, error) {
var (
k domain.WorkerKey
lastUsed sql.NullInt64
revoked sql.NullInt64
created sql.NullInt64
)
if err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.TokenHash, &k.Prefix, &created, &lastUsed, &revoked); err != nil {
return nil, err
}
k.CreatedAt = decodeTime(created.Int64)
if lastUsed.Valid {
value := decodeTime(lastUsed.Int64)
k.LastUsedAt = &value
}
if revoked.Valid {
value := decodeTime(revoked.Int64)
k.RevokedAt = &value
}
return &k, nil
}
// WorkerKeyRepo implements usecase.WorkerKeyRepository on SQLite.
type WorkerKeyRepo struct {
db *sql.DB
}
func NewWorkerKeyRepo(db *sql.DB) *WorkerKeyRepo {
return &WorkerKeyRepo{db: db}
}
func (r *WorkerKeyRepo) Insert(ctx context.Context, k *domain.WorkerKey) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO worker_keys (id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
k.ID.String(), k.UserID.String(), k.Name, k.TokenHash, k.Prefix,
k.CreatedAt.UnixNano(), nullableTime(k.LastUsedAt), nullableTime(k.RevokedAt))
return err
}
func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
userID.String())
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var keys []*domain.WorkerKey
for rows.Next() {
key, err := scanWorkerKey(rows)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
// ListAll returns every key, revoked included, newest first. Admin-only.
func (r *WorkerKeyRepo) ListAll(ctx context.Context) ([]*domain.WorkerKey, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT "+workerKeyColumns+" FROM worker_keys ORDER BY created_at DESC, id DESC")
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var keys []*domain.WorkerKey
for rows.Next() {
key, err := scanWorkerKey(rows)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
row := r.db.QueryRowContext(ctx,
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE token_hash = ? AND revoked_at IS NULL",
tokenHash)
key, err := scanWorkerKey(row)
return key, mapErrNoRows(err, usecase.ErrWorkerKeyNotFound)
}
func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error {
res, err := r.db.ExecContext(ctx,
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
time.Now().UnixNano(), id.String(), userID.String())
if err != nil {
return err
}
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
}
// RevokeAny retires a key by id regardless of its owner.
func (r *WorkerKeyRepo) RevokeAny(ctx context.Context, id uuid.UUID) error {
res, err := r.db.ExecContext(ctx,
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
time.Now().UnixNano(), id.String())
if err != nil {
return err
}
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
}
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := r.db.ExecContext(ctx,
"UPDATE worker_keys SET last_used_at = ? WHERE id = ?",
time.Now().UnixNano(), id.String())
return err
}
// --- helpers ---------------------------------------------------------------
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return t.UnixNano()
}
func decodeTime(raw any) time.Time {
switch v := raw.(type) {
case int64:
return time.Unix(0, v).UTC()
}
return time.Time{}
}
func mapErrNoRows(err error, notFound error) error {
if errors.Is(err, sql.ErrNoRows) {
return notFound
}
return err
}
func rowsAffectedOrNotFound(res sql.Result, notFound error) error {
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return notFound
}
return nil
}
// isUnique reports whether the error is a SQLite unique-constraint violation.
func isUnique(err error) bool {
return err != nil && (contains(err.Error(), "UNIQUE constraint failed") ||
contains(err.Error(), "constraint failed"))
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (haystack == needle || len(haystack) > len(needle) &&
(indexOf(haystack, needle) >= 0))
}
func indexOf(haystack, needle string) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}
// Open opens (and creates when missing) the userservice database file.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open userservice database: %w", err)
}
if err := db.PingContext(context.Background()); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping userservice database: %w", err)
}
return db, nil
}
@@ -0,0 +1,137 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := Migrate(context.Background(), db, nil); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestMigrateIsIdempotent(t *testing.T) {
db := newTestDB(t)
if err := Migrate(context.Background(), db, nil); err != nil {
t.Fatalf("second migrate: %v", err)
}
}
func TestUserRepoRoundTripAndSentinels(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewUserRepo(db)
user, err := domain.NewUser("root@scimesh.local", "hash", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, user); err != nil {
t.Fatal(err)
}
got, err := repo.GetByEmail(ctx, "root@scimesh.local")
if err != nil {
t.Fatal(err)
}
if got.ID != user.ID || got.Role != domain.RoleUser || got.Verified {
t.Errorf("user = %+v", got)
}
duplicate, err := domain.NewUser("ROOT@scimesh.local", "hash2", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, duplicate); !errors.Is(err, usecase.ErrEmailExists) {
t.Errorf("duplicate email err = %v, want ErrEmailExists", err)
}
if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) {
t.Errorf("missing user err = %v, want ErrUserNotFound", err)
}
if err := repo.SetVerified(ctx, user.ID, true); err != nil {
t.Fatal(err)
}
got, err = repo.GetByID(ctx, user.ID)
if err != nil {
t.Fatal(err)
}
if !got.Verified {
t.Error("verified flag did not persist")
}
if err := repo.SetRole(ctx, user.ID, domain.RoleAdmin); err != nil {
t.Fatal(err)
}
got, err = repo.GetByID(ctx, user.ID)
if err != nil {
t.Fatal(err)
}
if got.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", got.Role)
}
}
func TestWorkerKeyRepoLifecycle(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
repo := NewWorkerKeyRepo(db)
user, err := domain.NewUser("worker@scimesh.local", "hash", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if err := NewUserRepo(db).Insert(ctx, user); err != nil {
t.Fatal(err)
}
key, plaintext, err := domain.NewWorkerKey(user.ID, "my machine", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if plaintext == "" || key.TokenHash == "" {
t.Fatal("key must carry a hash and return a plaintext")
}
if err := repo.Insert(ctx, key); err != nil {
t.Fatal(err)
}
found, err := repo.GetActiveByHash(ctx, key.TokenHash)
if err != nil {
t.Fatal(err)
}
if found.ID != key.ID {
t.Errorf("key = %+v", found)
}
keys, err := repo.ListByUser(ctx, user.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 {
t.Errorf("keys = %d, want 1", len(keys))
}
if err := repo.TouchLastUsed(ctx, key.ID); err != nil {
t.Fatal(err)
}
if err := repo.Revoke(ctx, key.ID, user.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.GetActiveByHash(ctx, key.TokenHash); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
t.Errorf("revoked key err = %v, want ErrWorkerKeyNotFound", err)
}
if err := repo.Revoke(ctx, key.ID, user.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
t.Errorf("double revoke err = %v, want ErrWorkerKeyNotFound", err)
}
}
@@ -0,0 +1,114 @@
package http
import (
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// registerRequest / loginRequest are the JSON bodies clients POST. Kept separate
// from the domain so the wire format can evolve without touching the entity.
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// userResponse is the public view of a user. It never carries the password hash.
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Verified bool `json:"verified"`
CreatedAt string `json:"created_at"`
}
type loginResponse struct {
Token string `json:"token"`
User userResponse `json:"user"`
}
func toUserResponse(u *domain.User) userResponse {
return userResponse{
ID: u.ID.String(),
Email: u.Email,
Role: string(u.Role),
Verified: u.Verified,
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
}
}
// createWorkerKeyRequest is the body for minting a worker key. Name is an
// optional human label; the domain defaults it when blank.
type createWorkerKeyRequest struct {
Name string `json:"name"`
}
// exchangeWorkerKeyRequest trades a worker key for a short-lived JWT.
type exchangeWorkerKeyRequest struct {
Key string `json:"key"`
}
type exchangeWorkerKeyResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
// workerKeyResponse is the public view of a key. It never carries the secret —
// only the non-secret prefix used to identify a row.
type workerKeyResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
// createdWorkerKeyResponse extends the public view with the one-time plaintext,
// returned only from the create call and never again.
type createdWorkerKeyResponse struct {
workerKeyResponse
Key string `json:"key"`
}
type workerKeysResponse struct {
WorkerKeys []workerKeyResponse `json:"worker_keys"`
}
// adminWorkerKeyResponse extends the public view with the owning user and the
// revocation state, both needed by the coordinator admin console.
type adminWorkerKeyResponse struct {
workerKeyResponse
UserID string `json:"user_id"`
RevokedAt string `json:"revoked_at,omitempty"`
}
func toAdminWorkerKeyResponse(k *domain.WorkerKey) adminWorkerKeyResponse {
resp := adminWorkerKeyResponse{workerKeyResponse: toWorkerKeyResponse(k), UserID: k.UserID.String()}
if k.RevokedAt != nil {
resp.RevokedAt = k.RevokedAt.UTC().Format(time.RFC3339)
}
return resp
}
// usersResponse is the admin list of accounts, password hashes excluded.
type usersResponse struct {
Users []userResponse `json:"users"`
}
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
resp := workerKeyResponse{
ID: k.ID.String(),
Name: k.Name,
Prefix: k.Prefix,
CreatedAt: k.CreatedAt.UTC().Format(time.RFC3339),
}
if k.LastUsedAt != nil {
resp.LastUsedAt = k.LastUsedAt.UTC().Format(time.RFC3339)
}
return resp
}
@@ -0,0 +1,69 @@
package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// maxJSONBody caps a request body. Credentials are tiny; anything larger is a
// mistake or an attack, so reject it before allocating.
const maxJSONBody = 1 << 20 // 1 MiB
type errorResponse struct {
Error string `json:"error"`
RequestID string `json:"request_id,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError maps a domain or use-case error to an HTTP status and a safe
// message, logging only genuine server faults (5xx). Client errors (4xx) are
// expected and stay out of the error log.
func writeError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
status, msg := statusForError(err)
if status >= http.StatusInternalServerError {
log.Error("request failed",
"err", err,
"request_id", requestIDFrom(r.Context()),
"path", r.URL.Path,
)
}
writeJSON(w, status, errorResponse{Error: msg, RequestID: requestIDFrom(r.Context())})
}
func statusForError(err error) (int, string) {
switch {
case errors.Is(err, usecase.ErrEmailExists):
return http.StatusConflict, "email already registered"
case errors.Is(err, usecase.ErrInvalidCredentials):
return http.StatusUnauthorized, "invalid email or password"
case errors.Is(err, usecase.ErrUserNotFound):
return http.StatusNotFound, "user not found"
case errors.Is(err, usecase.ErrWorkerKeyNotFound):
return http.StatusNotFound, "worker key not found"
case errors.Is(err, usecase.ErrInvalidWorkerKey):
return http.StatusUnauthorized, "invalid worker key"
case errors.Is(err, domain.ErrWorkerKeyNameTooLong):
return http.StatusBadRequest, "worker key name is too long"
case errors.Is(err, usecase.ErrPasswordTooShort):
return http.StatusBadRequest, "password must be at least 8 characters"
case errors.Is(err, usecase.ErrPasswordTooLong):
return http.StatusBadRequest, "password must be at most 72 bytes"
case errors.Is(err, usecase.ErrInvalidRole):
return http.StatusBadRequest, "invalid role"
case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail):
return http.StatusBadRequest, "email is not a valid address"
default:
// Don't leak internals; the real error is in the log under request_id.
return http.StatusInternalServerError, "internal error"
}
}
@@ -0,0 +1,275 @@
package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// Handlers holds the use cases each endpoint drives.
type Handlers struct {
register *usecase.Register
login *usecase.Login
setVerified *usecase.SetVerified
setRole *usecase.SetRole
createWorkerKey *usecase.CreateWorkerKey
listWorkerKeys *usecase.ListWorkerKeys
listWorkerKeysAll *usecase.ListWorkerKeysAll
revokeWorkerKey *usecase.RevokeWorkerKey
revokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
exchangeWorkerKey *usecase.ExchangeWorkerKey
listUsers *usecase.ListUsers
users usecase.UserRepository
log *slog.Logger
}
// handleHealth is an unauthenticated liveness probe for the container and load
// balancer.
func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleRegister creates an account. It returns 201 with the public user view,
// 409 if the email is taken, or 400 on a malformed body / weak password.
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if !decodeJSON(w, r, &req) {
return
}
u, err := h.register.Execute(r.Context(), req.Email, req.Password)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusCreated, toUserResponse(u))
}
// handleLogin verifies credentials and returns a signed token plus the user.
func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
token, u, err := h.login.Execute(r.Context(), req.Email, req.Password)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)})
}
// handleMe returns the caller's own account, proving the token works end to end.
// It reads the user id the JWT middleware stashed in the context.
func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) {
id, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
u, err := h.users.GetByID(r.Context(), id)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusOK, toUserResponse(u))
}
// handleSetVerified grants (verified=true) or revokes (false) the trusted-
// contributor badge for the user in the path. Admin-only; the withAdmin
// middleware has already enforced the role by the time this runs.
func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid user id",
RequestID: requestIDFrom(r.Context()),
})
return
}
if err := h.setVerified.Execute(r.Context(), id, verified); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin-
// only; the withAdmin middleware has already enforced the caller's role.
func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid user id",
RequestID: requestIDFrom(r.Context()),
})
return
}
if err := h.setRole.Execute(r.Context(), id, role); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleCreateWorkerKey mints a long-lived worker key for the authenticated
// caller and returns it once, plaintext included. The user copies it into their
// worker's SCIMESH_WORKER_KEY; it is never retrievable again.
func (h *Handlers) handleCreateWorkerKey(w http.ResponseWriter, r *http.Request) {
id, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
// A signed JWT can outlive a demo reset or an account deletion. Check that
// its subject still exists before attempting the insert, otherwise the
// worker_keys foreign key would turn a stale session into an internal error.
if _, err := h.users.GetByID(r.Context(), id); err != nil {
if errors.Is(err, usecase.ErrUserNotFound) {
unauthorized(w, r)
return
}
writeError(w, r, h.log, err)
return
}
var req createWorkerKeyRequest
if !decodeJSON(w, r, &req) {
return
}
key, raw, err := h.createWorkerKey.Execute(r.Context(), id, req.Name)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusCreated, createdWorkerKeyResponse{
workerKeyResponse: toWorkerKeyResponse(key),
Key: raw,
})
}
// handleListWorkerKeys returns the caller's live keys (no secrets) for display
// and revocation.
func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request) {
id, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
keys, err := h.listWorkerKeys.Execute(r.Context(), id)
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]workerKeyResponse, 0, len(keys))
for _, k := range keys {
out = append(out, toWorkerKeyResponse(k))
}
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
}
// handleListWorkerKeysAll returns every key in the service — revoked included,
// with the owning user id — for the coordinator admin console. Admin-only.
func (h *Handlers) handleListWorkerKeysAll(w http.ResponseWriter, r *http.Request) {
keys, err := h.listWorkerKeysAll.Execute(r.Context())
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]adminWorkerKeyResponse, 0, len(keys))
for _, k := range keys {
out = append(out, toAdminWorkerKeyResponse(k))
}
writeJSON(w, http.StatusOK, struct {
WorkerKeys []adminWorkerKeyResponse `json:"worker_keys"`
}{WorkerKeys: out})
}
// handleListUsers returns every account for the coordinator admin console.
// Password hashes never leave the service: only the public projection is sent.
func (h *Handlers) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.listUsers.Execute(r.Context())
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]userResponse, 0, len(users))
for _, u := range users {
out = append(out, toUserResponse(u))
}
writeJSON(w, http.StatusOK, usersResponse{Users: out})
}
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
// the delete to the owner, so a mismatched id is a clean 404, not another user's
// key.
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
keyID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid worker key id",
RequestID: requestIDFrom(r.Context()),
})
return
}
// An admin may revoke any key; a plain user only their own.
if role, ok := r.Context().Value(roleKey).(domain.Role); ok && role == domain.RoleAdmin {
if err := h.revokeWorkerKeyAdmin.Execute(r.Context(), keyID); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
userID, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleExchangeWorkerKey trades a worker key for a short-lived JWT. It is
// unauthenticated: the key itself is the credential. A worker calls this on
// startup and again to refresh before the JWT expires.
func (h *Handlers) handleExchangeWorkerKey(w http.ResponseWriter, r *http.Request) {
var req exchangeWorkerKeyRequest
if !decodeJSON(w, r, &req) {
return
}
token, expiresIn, err := h.exchangeWorkerKey.Execute(r.Context(), req.Key)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusOK, exchangeWorkerKeyResponse{Token: token, ExpiresIn: expiresIn})
}
// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
// It writes a 400 and returns false on any problem, so callers can `if
// !decodeJSON(...) { return }`.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid JSON body",
RequestID: requestIDFrom(r.Context()),
})
return false
}
return true
}
@@ -0,0 +1,149 @@
package http
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
type ctxKey string
const (
requestIDKey ctxKey = "request_id"
userIDKey ctxKey = "user_id"
roleKey ctxKey = "role"
)
// withRequestID stamps every request with an ID for correlated logs and error
// bodies. It wraps the auth middleware rather than the other way round, so even
// a rejected request carries an ID the caller can quote in a bug report.
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := newRequestID()
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
})
}
func requestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
func newRequestID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an
// interface keeps the middleware testable with a stub verifier.
type tokenVerifier interface {
Verify(token string) (*auth.Claims, error)
}
// withJWT verifies the Bearer token and stashes the caller's id and role in the
// request context. It rejects any request without a valid, unexpired HS256
// token — this is what protects endpoints that act on a specific user.
func withJWT(v tokenVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
unauthorized(w, r)
return
}
claims, err := v.Verify(raw)
if err != nil {
unauthorized(w, r)
return
}
id, err := uuid.Parse(claims.Subject)
if err != nil {
unauthorized(w, r)
return
}
ctx := context.WithValue(r.Context(), userIDKey, id)
ctx = context.WithValue(ctx, roleKey, claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func unauthorized(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Bearer")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "unauthorized",
RequestID: requestIDFrom(r.Context()),
})
}
// userIDFrom returns the authenticated caller's id, set by withJWT.
func userIDFrom(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDKey).(uuid.UUID)
return id, ok
}
// withAdmin rejects any caller whose token role is not admin. It must sit inside
// withJWT, which stamps the role after verifying the token.
func withAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "admin role required",
RequestID: requestIDFrom(r.Context()),
})
return
}
next.ServeHTTP(w, r)
})
}
// statusRecorder captures the status code for the access log.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// withAccessLog records one structured line per request — the minimum needed to
// debug a distributed system after the fact.
func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Info("request",
"request_id", requestIDFrom(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
// chain applies middleware so that the first argument is the outermost layer.
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
@@ -0,0 +1,85 @@
// Package http exposes the userservice over HTTP: registration, login, and a
// token-protected /me. It owns routing, request decoding, and error mapping;
// business rules live in the usecase layer.
package http
import (
"log/slog"
"net/http"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// UseCases bundles the application services the handlers drive.
type UseCases struct {
Register *usecase.Register
Login *usecase.Login
SetVerified *usecase.SetVerified
SetRole *usecase.SetRole
CreateWorkerKey *usecase.CreateWorkerKey
ListWorkerKeys *usecase.ListWorkerKeys
ListWorkerKeysAll *usecase.ListWorkerKeysAll
RevokeWorkerKey *usecase.RevokeWorkerKey
RevokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
ExchangeWorkerKey *usecase.ExchangeWorkerKey
ListUsers *usecase.ListUsers
Users usecase.UserRepository
}
// NewServer wires the routes and the middleware stack and returns the handler.
// The issuer verifies tokens for the JWT-protected routes.
func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
h := &Handlers{
register: uc.Register,
login: uc.Login,
setVerified: uc.SetVerified,
setRole: uc.SetRole,
createWorkerKey: uc.CreateWorkerKey,
listWorkerKeys: uc.ListWorkerKeys,
listWorkerKeysAll: uc.ListWorkerKeysAll,
revokeWorkerKey: uc.RevokeWorkerKey,
revokeWorkerKeyAdmin: uc.RevokeWorkerKeyAdmin,
exchangeWorkerKey: uc.ExchangeWorkerKey,
listUsers: uc.ListUsers,
users: uc.Users,
log: log,
}
mux := http.NewServeMux()
// Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match.
mux.HandleFunc("GET /health", h.handleHealth)
mux.HandleFunc("POST /register", h.handleRegister)
mux.HandleFunc("POST /login", h.handleLogin)
// /me proves a token round-trips; it sits behind JWT auth.
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
// Worker keys: a user mints a long-lived key (JWT-protected), and a worker
// trades it for a short-lived JWT on the public exchange endpoint — the key
// itself is the credential there, so no prior token is required.
mux.HandleFunc("POST /worker-tokens/exchange", h.handleExchangeWorkerKey)
mux.Handle("POST /worker-keys", chain(http.HandlerFunc(h.handleCreateWorkerKey), withJWT(issuer)))
mux.Handle("GET /worker-keys", chain(http.HandlerFunc(h.handleListWorkerKeys), withJWT(issuer)))
mux.Handle("DELETE /worker-keys/{id}", chain(http.HandlerFunc(h.handleRevokeWorkerKey), withJWT(issuer)))
// Admin-only: grant or revoke the trusted-contributor badge. withAdmin sits
// inside withJWT so the role is available from the verified token.
mux.Handle("POST /users/{id}/verify",
chain(h.handleSetVerified(true), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/unverify",
chain(h.handleSetVerified(false), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/promote",
chain(h.handleSetRole(domain.RoleAdmin), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/demote",
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
// Admin console: lists of every account and every worker key, and the key
// revoke path the admin console calls (the same DELETE endpoint already
// lets an admin revoke any key).
mux.Handle("GET /users", chain(http.HandlerFunc(h.handleListUsers), withJWT(issuer), withAdmin))
mux.Handle("GET /worker-keys/all", chain(http.HandlerFunc(h.handleListWorkerKeysAll), withJWT(issuer), withAdmin))
// Outermost first: every request gets an ID and an access-log line.
return chain(mux, withRequestID, withAccessLog(log))
}
@@ -0,0 +1,481 @@
package http_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
apihttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
const secret = "server-test-secret-32-bytes-long!!!!"
func newTestServer() http.Handler {
users := memstore.NewUserRepo()
keys := memstore.NewWorkerKeyRepo()
hasher := auth.NewHasher(4)
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
// Real clock for the issuer so tokens are valid at verification time.
issuer := auth.NewIssuer(secret, time.Hour, nil)
uc := apihttp.UseCases{
Register: usecase.NewRegister(users, hasher, clk),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
CreateWorkerKey: usecase.NewCreateWorkerKey(keys, clk),
ListWorkerKeys: usecase.NewListWorkerKeys(keys),
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(keys),
RevokeWorkerKey: usecase.NewRevokeWorkerKey(keys),
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(keys),
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
ListUsers: usecase.NewListUsers(users),
Users: users,
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return apihttp.NewServer(log, uc, issuer)
}
func do(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
req, err := http.NewRequestWithContext(context.Background(), method, path, &buf)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestRegisterThenLoginThenMe(t *testing.T) {
h := newTestServer()
creds := map[string]string{"email": "flow@example.com", "password": "password123"}
// Register -> 201
rec := do(t, h, http.MethodPost, "/register", "", creds)
if rec.Code != http.StatusCreated {
t.Fatalf("register: got %d, body %s", rec.Code, rec.Body)
}
// Login -> 200 with a token
rec = do(t, h, http.MethodPost, "/login", "", creds)
if rec.Code != http.StatusOK {
t.Fatalf("login: got %d, body %s", rec.Code, rec.Body)
}
var lr struct {
Token string `json:"token"`
User struct {
Email string `json:"email"`
Role string `json:"role"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
t.Fatal(err)
}
if lr.Token == "" || lr.User.Email != "flow@example.com" || lr.User.Role != "user" {
t.Fatalf("unexpected login body: %+v", lr)
}
// /me with the token -> 200, same user
rec = do(t, h, http.MethodGet, "/me", lr.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("me: got %d, body %s", rec.Code, rec.Body)
}
var me struct {
Email string `json:"email"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatal(err)
}
if me.Email != "flow@example.com" {
t.Errorf("me email = %q", me.Email)
}
}
func TestRegisterDuplicate(t *testing.T) {
h := newTestServer()
creds := map[string]string{"email": "dup@example.com", "password": "password123"}
_ = do(t, h, http.MethodPost, "/register", "", creds)
rec := do(t, h, http.MethodPost, "/register", "", creds)
if rec.Code != http.StatusConflict {
t.Errorf("duplicate register: got %d, want 409", rec.Code)
}
}
func TestRegisterValidation(t *testing.T) {
h := newTestServer()
cases := []struct {
name string
body map[string]string
}{
{"weak password", map[string]string{"email": "a@b.com", "password": "short"}},
{"bad email", map[string]string{"email": "nope", "password": "password123"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := do(t, h, http.MethodPost, "/register", "", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
})
}
}
func TestRegisterRejectsUnknownFields(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{
"email": "a@b.com", "password": "password123", "role": "admin",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("unknown field must be rejected: got %d", rec.Code)
}
}
func TestLoginWrongPassword(t *testing.T) {
h := newTestServer()
_ = do(t, h, http.MethodPost, "/register", "", map[string]string{
"email": "x@example.com", "password": "password123",
})
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{
"email": "x@example.com", "password": "wrongpass1",
})
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", rec.Code)
}
}
func TestMeRequiresToken(t *testing.T) {
h := newTestServer()
if rec := do(t, h, http.MethodGet, "/me", "", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("no token: got %d, want 401", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/me", "garbage.token.here", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("bad token: got %d, want 401", rec.Code)
}
}
func TestHealth(t *testing.T) {
h := newTestServer()
if rec := do(t, h, http.MethodGet, "/health", "", nil); rec.Code != http.StatusOK {
t.Errorf("health: got %d", rec.Code)
}
}
// failingUsers is a UserRepository whose reads fail with an unexpected (non-
// sentinel) error, so the handler must map it to 500 and not leak internals.
type failingUsers struct{ usecase.UserRepository }
func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
return nil, errors.New("db exploded")
}
func TestMeInternalError(t *testing.T) {
hasher := auth.NewHasher(4)
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
issuer := auth.NewIssuer(secret, time.Hour, nil)
users := failingUsers{UserRepository: memstore.NewUserRepo()}
uc := apihttp.UseCases{
Register: usecase.NewRegister(users, hasher, clk),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
Users: users,
}
h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer)
// A structurally valid token for a caller the failing repo can't load.
token, err := issuer.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
if err != nil {
t.Fatal(err)
}
rec := do(t, h, http.MethodGet, "/me", token, nil)
if rec.Code != http.StatusInternalServerError {
t.Errorf("got %d, want 500", rec.Code)
}
// The body must not disclose the underlying error.
if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) {
t.Error("internal error leaked to the client")
}
}
// mintToken issues a token with the package secret for a synthetic caller of the
// given role — enough to drive the admin-gated endpoints. userID defaults to a
// fresh random id; pass one to act as an existing account.
func mintToken(t *testing.T, role domain.Role) string {
t.Helper()
return mintTokenFor(t, role, uuid.New())
}
func mintTokenFor(t *testing.T, role domain.Role, userID uuid.UUID) string {
t.Helper()
token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: userID, Role: role})
if err != nil {
t.Fatal(err)
}
return token
}
// registerUser creates an account and returns its id.
func registerUser(t *testing.T, h http.Handler, email string) string {
t.Helper()
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": email, "password": "password123"})
if rec.Code != http.StatusCreated {
t.Fatalf("register: %d", rec.Code)
}
var reg struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &reg); err != nil {
t.Fatal(err)
}
return reg.ID
}
func TestAdminVerifiesUserEndToEnd(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "contrib@example.com")
// Admin grants the badge.
rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("admin verify: got %d, body %s", rec.Code, rec.Body)
}
// The change is visible when the contributor logs in.
rec = do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "contrib@example.com", "password": "password123"})
var lr struct {
User struct {
Verified bool `json:"verified"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
t.Fatal(err)
}
if !lr.User.Verified {
t.Error("verified badge not reflected after admin granted it")
}
}
func TestVerifyRequiresAdminRole(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "someone@example.com")
// A plain user token must not be able to grant the badge.
rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleUser), nil)
if rec.Code != http.StatusForbidden {
t.Errorf("plain user: got %d, want 403", rec.Code)
}
}
func TestVerifyRequiresAuth(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Errorf("no token: got %d, want 401", rec.Code)
}
}
func TestVerifyInvalidID(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/users/not-a-uuid/verify", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("bad id: got %d, want 400", rec.Code)
}
}
func TestVerifyUnknownUser(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusNotFound {
t.Errorf("unknown user: got %d, want 404", rec.Code)
}
}
func TestAdminPromotesAndDemotes(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "promote@example.com")
admin := mintToken(t, domain.RoleAdmin)
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("promote: got %d, body %s", rec.Code, rec.Body)
}
// The promoted user now logs in as an admin.
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "promote@example.com", "password": "password123"})
var lr struct {
User struct {
Role string `json:"role"`
} `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &lr)
if lr.User.Role != "admin" {
t.Errorf("role after promote = %q, want admin", lr.User.Role)
}
if rec := do(t, h, http.MethodPost, "/users/"+id+"/demote", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("demote: got %d", rec.Code)
}
}
func TestPromoteRequiresAdmin(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "target@example.com")
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
t.Errorf("plain user promote: got %d, want 403", rec.Code)
}
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", "", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("no token: got %d, want 401", rec.Code)
}
}
func TestPromoteUnknownUser(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/promote", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusNotFound {
t.Errorf("unknown user promote: got %d, want 404", rec.Code)
}
}
func TestUnverifyRevokes(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "revoke@example.com")
admin := mintToken(t, domain.RoleAdmin)
if rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("verify: %d", rec.Code)
}
if rec := do(t, h, http.MethodPost, "/users/"+id+"/unverify", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("unverify: %d", rec.Code)
}
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "revoke@example.com", "password": "password123"})
var lr struct {
User struct {
Verified bool `json:"verified"`
} `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &lr)
if lr.User.Verified {
t.Error("verified should be false after unverify")
}
}
func TestAdminListsUsersAndKeys(t *testing.T) {
h := newTestServer()
userID, _ := uuid.Parse(registerUser(t, h, "listed@example.com"))
userToken := mintTokenFor(t, domain.RoleUser, userID)
// Mint a worker key as the plain user.
keyRec := do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node"})
if keyRec.Code != http.StatusCreated {
t.Fatalf("create key: got %d, body %s", keyRec.Code, keyRec.Body)
}
var created struct {
ID string `json:"id"`
}
_ = json.Unmarshal(keyRec.Body.Bytes(), &created)
// Admin lists users: emails present, password hashes absent.
rec := do(t, h, http.MethodGet, "/users", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusOK {
t.Fatalf("list users: got %d, body %s", rec.Code, rec.Body)
}
var users struct {
Users []struct {
Email string `json:"email"`
Role string `json:"role"`
PasswordHash string `json:"password_hash"`
} `json:"users"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
t.Fatal(err)
}
found := false
for _, u := range users.Users {
if u.PasswordHash != "" {
t.Error("password hash leaked through the admin users list")
}
if u.Email == "listed@example.com" {
found = true
}
}
if !found {
t.Error("listed user missing from the admin list")
}
// Admin lists all keys: the owner is attached, no secret.
rec = do(t, h, http.MethodGet, "/worker-keys/all", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusOK {
t.Fatalf("list all keys: got %d", rec.Code)
}
var keys struct {
WorkerKeys []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
} `json:"worker_keys"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil {
t.Fatal(err)
}
if len(keys.WorkerKeys) != 1 || keys.WorkerKeys[0].UserID != userID.String() {
t.Errorf("all keys = %+v, want the one key owned by %s", keys.WorkerKeys, userID)
}
// Plain users cannot see either list.
for _, path := range []string{"/users", "/worker-keys/all"} {
if rec := do(t, h, http.MethodGet, path, mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
t.Errorf("%s as user: got %d, want 403", path, rec.Code)
}
}
// An admin revokes a key that belongs to another user; the plain owner of
// that key could not (it would be a 404, scoped to their own keys).
// The owner of the key revokes it themselves: 204.
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, userToken, nil); rec.Code != http.StatusNoContent {
t.Errorf("user revoke own key: got %d, want 204", rec.Code)
}
// Another plain user cannot revoke it: scoped to their own keys, so a
// mismatch reads as 404.
otherID, _ := uuid.Parse(registerUser(t, h, "other@example.com"))
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, mintTokenFor(t, domain.RoleUser, otherID), nil); rec.Code != http.StatusNotFound {
t.Errorf("other user revoke: got %d, want 404", rec.Code)
}
// An admin revokes a key that belongs to someone else: 204.
keyRec = do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node-2"})
var second struct {
ID string `json:"id"`
}
_ = json.Unmarshal(keyRec.Body.Bytes(), &second)
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+second.ID, mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNoContent {
t.Errorf("admin revoke other's key: got %d, want 204", rec.Code)
}
// Admin cannot revoke an unknown key.
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+uuid.NewString(), mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNotFound {
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
}
}
@@ -0,0 +1,65 @@
package usecase
import (
"context"
"errors"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// BootstrapAdmin seeds the first admin account. It exists because there is no
// other way to create one: /register always makes a plain user, and promoting a
// user to admin requires an already-existing admin. Running it at startup with
// operator-supplied credentials breaks that chicken-and-egg.
type BootstrapAdmin struct {
users UserRepository
hasher PasswordHasher
clk Clock
}
func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin {
return &BootstrapAdmin{users: users, hasher: hasher, clk: clk}
}
// Execute creates the admin if it does not already exist, reporting whether it
// created one. It is idempotent: a second run (a restart) finds the account and
// does nothing, so it is safe to call on every boot.
func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) {
email = domain.NormalizeEmail(email)
if _, err := uc.users.GetByEmail(ctx, email); err == nil {
return false, nil // already bootstrapped
} else if !errors.Is(err, ErrUserNotFound) {
return false, err
}
if len(password) < minPasswordLen {
return false, ErrPasswordTooShort
}
if len(password) > maxPasswordLen {
return false, ErrPasswordTooLong
}
hash, err := uc.hasher.Hash(password)
if err != nil {
return false, err
}
u, err := domain.NewUser(email, hash, uc.clk.Now())
if err != nil {
return false, err
}
// Direct role assignment is safe here: this is a trusted server-side seed,
// not a request. A root admin is also a trusted contributor.
u.Role = domain.RoleAdmin
u.Verified = true
if err := uc.users.Insert(ctx, u); err != nil {
// A concurrent bootstrap (two replicas booting at once) is fine: whoever
// lost the race just observes the account now exists.
if errors.Is(err, ErrEmailExists) {
return false, nil
}
return false, err
}
return true, nil
}
@@ -0,0 +1,71 @@
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) {
users := memstore.NewUserRepo()
hasher := auth.NewHasher(4)
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
return usecase.NewBootstrapAdmin(users, hasher, clk), users
}
func TestBootstrapCreatesAdmin(t *testing.T) {
bs, users := newBootstrap()
created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword")
if err != nil {
t.Fatalf("bootstrap: %v", err)
}
if !created {
t.Fatal("expected an admin to be created")
}
u, err := users.GetByEmail(context.Background(), "root@example.com")
if err != nil {
t.Fatalf("admin not persisted: %v", err)
}
if u.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", u.Role)
}
if !u.Verified {
t.Error("bootstrap admin should be verified")
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
bs, users := newBootstrap()
ctx := context.Background()
if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil {
t.Fatal(err)
}
created, err := bs.Execute(ctx, "root@example.com", "rootpassword")
if err != nil {
t.Fatalf("second run: %v", err)
}
if created {
t.Error("second run must not create a duplicate admin")
}
// The account must still be a single admin.
if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin {
t.Errorf("role changed: %q", u.Role)
}
}
func TestBootstrapRejectsWeakPassword(t *testing.T) {
bs, _ := newBootstrap()
if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
t.Errorf("got %v, want ErrPasswordTooShort", err)
}
}
@@ -0,0 +1,110 @@
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
)
// These stubs let a test inject failures the happy-path memstore never produces,
// so the use cases' error branches are exercised too.
var errBoom = errors.New("boom")
type stubRepo struct {
getByEmail func() (*domain.User, error)
insert func() error
}
func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() }
func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) {
return s.getByEmail()
}
func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
return nil, usecase.ErrUserNotFound
}
func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
return usecase.ErrUserNotFound
}
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
return usecase.ErrUserNotFound
}
func (s stubRepo) ListUsers(context.Context) ([]*domain.User, error) { return nil, nil }
type stubHasher struct {
hashErr error
compareErr error
}
func (s stubHasher) Hash(string) (string, error) {
if s.hashErr != nil {
return "", s.hashErr
}
return "hashed", nil
}
func (s stubHasher) Compare(string, string) error { return s.compareErr }
type stubIssuer struct{ err error }
func (s stubIssuer) Issue(*domain.User) (string, error) {
if s.err != nil {
return "", s.err
}
return "token", nil
}
func TestRegisterPropagatesHasherError(t *testing.T) {
clk := stubClock{time.Now()}
reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk)
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
if !errors.Is(err, errBoom) {
t.Errorf("got %v, want errBoom", err)
}
}
func TestRegisterPropagatesInsertError(t *testing.T) {
clk := stubClock{time.Now()}
repo := stubRepo{insert: func() error { return errBoom }}
reg := usecase.NewRegister(repo, stubHasher{}, clk)
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
if !errors.Is(err, errBoom) {
t.Errorf("got %v, want errBoom", err)
}
}
func TestLoginPropagatesRepoError(t *testing.T) {
// A non-ErrUserNotFound repo error must surface as-is, not be masked as
// ErrInvalidCredentials.
repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }}
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{})
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
if !errors.Is(err, errBoom) {
t.Errorf("got %v, want errBoom", err)
}
}
func TestLoginPropagatesIssuerError(t *testing.T) {
repo := stubRepo{getByEmail: func() (*domain.User, error) {
return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil
}}
// Hasher accepts the password (nil compareErr) so we reach token issuance.
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom})
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
if !errors.Is(err, errBoom) {
t.Errorf("got %v, want errBoom", err)
}
}
type stubClock struct{ t time.Time }
func (c stubClock) Now() time.Time { return c.t }
@@ -0,0 +1,27 @@
package usecase
import "errors"
var (
// Repository-contract errors, returned by UserRepository implementations.
ErrEmailExists = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
// ErrWorkerKeyNotFound is returned by WorkerKeyRepository when no live key
// matches (by id for revoke, by hash for exchange).
ErrWorkerKeyNotFound = errors.New("worker key not found")
// ErrInvalidWorkerKey is surfaced to the transport layer for a key that does
// not exchange (unknown, revoked, or owner gone). Deliberately opaque so a
// caller cannot distinguish the cases while probing.
ErrInvalidWorkerKey = errors.New("invalid worker key")
// Use-case errors surfaced to the transport layer.
//
// ErrInvalidCredentials is deliberately returned for both an unknown email
// and a wrong password, so an attacker cannot use the response to learn
// which emails are registered.
ErrInvalidCredentials = errors.New("invalid email or password")
ErrPasswordTooShort = errors.New("password too short")
ErrPasswordTooLong = errors.New("password too long")
ErrInvalidRole = errors.New("invalid role")
)
@@ -0,0 +1,42 @@
package usecase
import (
"context"
"errors"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// Login verifies credentials and issues a signed token.
type Login struct {
users UserRepository
hasher PasswordHasher
tokens TokenIssuer
}
func NewLogin(users UserRepository, hasher PasswordHasher, tokens TokenIssuer) *Login {
return &Login{users: users, hasher: hasher, tokens: tokens}
}
// Execute returns a signed token and the user on success. It returns
// ErrInvalidCredentials for both an unknown email and a wrong password so the
// two cases are indistinguishable to a caller probing for valid accounts.
func (l *Login) Execute(ctx context.Context, email, password string) (string, *domain.User, error) {
u, err := l.users.GetByEmail(ctx, domain.NormalizeEmail(email))
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return "", nil, ErrInvalidCredentials
}
return "", nil, err
}
if err := l.hasher.Compare(u.PasswordHash, password); err != nil {
return "", nil, ErrInvalidCredentials
}
token, err := l.tokens.Issue(u)
if err != nil {
return "", nil, err
}
return token, u, nil
}

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